Initial release
No files matched your search
@@ -0,0 +1,7 @@
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-this-local-password
|
||||
# Optional: set a stable signing key for admin sessions in production.
|
||||
# SESSION_SECRET=replace-with-a-random-secret
|
||||
# Optional: enable Cloudflare usage statistics in the admin data workspace.
|
||||
# CLOUDFLARE_ACCOUNT_ID=your-account-id
|
||||
# CLOUDFLARE_API_TOKEN=token-with-account-analytics-read
|
||||
@@ -0,0 +1,142 @@
|
||||
name: Build tagged release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*.*.*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Three-part release version, for example 0.1.0
|
||||
required: true
|
||||
default: 0.1.0
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
name: Validate version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: version
|
||||
name: Validate three-part version
|
||||
shell: bash
|
||||
env:
|
||||
RAW_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }}
|
||||
run: |
|
||||
version="${RAW_VERSION#v}"
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Tag must be a three-part version such as v1.2.3 or 1.2.3" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
root_version=$(node -p "require('./package.json').version")
|
||||
frontend_version=$(node -p "require('./frontend/package.json').version")
|
||||
worker_version=$(awk -F '"' '/^version = / { print $2; exit }' Cargo.toml)
|
||||
agent_version=$(awk -F '"' '/^version = / { print $2; exit }' agent/Cargo.toml)
|
||||
for package_version in "$root_version" "$frontend_version" "$worker_version" "$agent_version"; do
|
||||
if [ "$package_version" != "$version" ]; then
|
||||
echo "Tag version $version does not match package version $package_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
agent:
|
||||
name: Agent (${{ matrix.platform }})
|
||||
needs: metadata
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
CARGO_TARGET_DIR: target/agent
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: Linux x86_64
|
||||
runner: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
asset: agent-linux-x86_64
|
||||
extension: ""
|
||||
cross: true
|
||||
- platform: Linux ARM64
|
||||
runner: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
asset: agent-linux-aarch64
|
||||
extension: ""
|
||||
cross: true
|
||||
- platform: Windows x86_64
|
||||
runner: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
asset: agent-windows-x86_64.exe
|
||||
extension: .exe
|
||||
cross: false
|
||||
- platform: macOS ARM64
|
||||
runner: macos-14
|
||||
target: aarch64-apple-darwin
|
||||
asset: agent-macos-aarch64
|
||||
extension: ""
|
||||
cross: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- name: Install Linux cross compiler
|
||||
if: matrix.cross
|
||||
run: cargo install cross --version 0.2.5 --locked
|
||||
- name: Build Rust Agent with cross
|
||||
if: matrix.cross
|
||||
run: cross build --manifest-path agent/Cargo.toml --release --locked --target ${{ matrix.target }}
|
||||
- name: Build Rust Agent with Cargo
|
||||
if: matrix.cross == false
|
||||
run: cargo build --manifest-path agent/Cargo.toml --release --locked --target ${{ matrix.target }}
|
||||
- name: Stage Agent binary
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p release
|
||||
cp "target/agent/${{ matrix.target }}/release/cf-monitor-agent${{ matrix.extension }}" "release/${{ matrix.asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: release/${{ matrix.asset }}
|
||||
if-no-files-found: error
|
||||
|
||||
frontend:
|
||||
name: Frontend
|
||||
needs: metadata
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Build public frontend
|
||||
run: bun run --cwd frontend build:public
|
||||
- name: Package frontend
|
||||
run: tar -czf "cf-monitor-frontend-${{ needs.metadata.outputs.version }}.tar.gz" -C frontend/dist .
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend
|
||||
path: cf-monitor-frontend-${{ needs.metadata.outputs.version }}.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
if: github.event_name == 'push'
|
||||
needs: [metadata, agent, frontend]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: release-assets/**
|
||||
@@ -0,0 +1,11 @@
|
||||
/build/
|
||||
/target/
|
||||
/node_modules/
|
||||
/frontend/public/agent-*
|
||||
/frontend/dist/
|
||||
/frontend/admin-dist/
|
||||
/frontend/node_modules/
|
||||
/.wrangler/
|
||||
/.dev.vars
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,874 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cf-monitor-worker"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"worker",
|
||||
"worker-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-wasm-bindgen"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.76"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7d3be8814f5ba5f074491a469eed3d73c273ffad955f25ed1635efac4b0d269"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "worker"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f8adbf6c9ae45b665dee995c5e3a342c2bd7d58a2e8ca5c75b50ce8b1b8bfd9"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"js-sys",
|
||||
"matchit",
|
||||
"pin-project",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"strum",
|
||||
"tokio",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"worker-macros",
|
||||
"worker-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "worker-macros"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d908735d273dd7f9c325a842623f4e5a745e0686187ce465b34dc162ad348df"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strum",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-macro-support",
|
||||
"worker-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "worker-sys"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33faa1a8fa6c7eec67b196e008859c44d468a5ad4f991855cdc856f119e0e98f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "cf-monitor-worker"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Rust/WASM server monitor for Cloudflare Workers"
|
||||
repository = "https://github.com/imengying/CF-Monitor"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
worker = { version = "0.8", features = ["d1"] }
|
||||
worker-macros = "0.8"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 CF Monitor contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# CF Monitor
|
||||
|
||||
基于 Rust、WebAssembly 和 Cloudflare Workers 的轻量服务器监控。单个 Worker 同时提供 API、WebSocket 实时推送和静态前端,D1 保存节点与历史指标,Durable Object 负责实时广播;Linux、Windows 和 macOS agent 同样由 Rust 独立实现。
|
||||
|
||||
## 能力
|
||||
|
||||
- CPU/多 GPU、三段负载、内存/Swap、多磁盘容量与 IO、磁盘 await/利用率、网络、连接数、进程和系统信息
|
||||
- 独立延迟任务,支持 TCP/ICMP、按节点分配、默认分配给新节点和独立测试周期
|
||||
- Komari-Glass 式资产总览、高密度节点卡、可开关的搜索/分组筛选和响应式深浅主题
|
||||
- 独立节点详情页,支持实时以及 1/4/24/168/720 小时负载和延迟图表
|
||||
- Durable Object WebSocket 实时刷新,支持总览或单节点订阅,断线时 60 秒轮询兜底
|
||||
- 节点计费/到期、币种、流量计算口径、公开备注和隐藏控制;后端每日更新汇率并在 D1 保存原子快照,混合币种资产可选 12 种展示币种
|
||||
- 用户名+密码管理登录,可选 Cloudflare Turnstile 登录验证和全站盾验证
|
||||
- 后台工作区:节点增删改、批量删除、拖拽排序和独立 Agent 密钥轮换
|
||||
- 后台安全、外观、主题、告警、数据维护工作区,支持 Telegram 通知测试、离线/到期提醒,以及按节点、指标、时间窗口和平均/持续方式配置的资源告警规则
|
||||
- 接入 CFSM Theme Store,支持 Emerald、Pulse、指定 GitHub 构建目录的签名预览、应用和恢复内置主题
|
||||
- 可配置总览项、资产折算币种、毛玻璃效果、默认主题、明暗独立背景图、历史保留和私有仪表盘
|
||||
- 节点 Agent 采样间隔、批量上报间隔、统计网卡、延迟任务和上下行流量修正可在线下发
|
||||
- Agent 默认每 5 秒采样、每 60 秒批量上报;D1 每分钟保存一个代表点,7 天后压缩为小时数据,保留周期可配置
|
||||
- 可聚合最多 16 个公开 CF Monitor 站点,远端节点、详情和 WebSocket 数据保持各自来源隔离
|
||||
- 公开前端支持简体中文和 English,可配置 favicon、API/WebSocket CORS 来源和第三方主题 CSP 来源
|
||||
- 汇率由 Worker 每日从 ER API 拉取、Frankfurter 兜底;失败时保留旧快照并按小时退避重试
|
||||
- 独立 Rust Agent,支持 Linux x86_64/ARM64、Windows x86_64 和 macOS ARM64,支持后台按节点开启自动更新
|
||||
|
||||
## 架构
|
||||
|
||||
```text
|
||||
Rust cross-platform agent -- HTTPS report --> Rust Worker (WASM) -- batch write --> D1
|
||||
|
|
||||
+-- push --> Durable Object --> WebSocket clients
|
||||
|
|
||||
+-- daily FX refresh -----------> D1 snapshot
|
||||
|
|
||||
Browser <-- static assets / REST API -+
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
需要 Bun 1.3+、Rust stable、`wasm32-unknown-unknown` target 和 Cloudflare Wrangler。
|
||||
|
||||
```bash
|
||||
rustup target add wasm32-unknown-unknown
|
||||
bun install
|
||||
cp .dev.vars.example .dev.vars
|
||||
bun run dev
|
||||
```
|
||||
|
||||
首次运行时,`bun run dev` 会编译 Agent 和两个前端入口,自动创建本地 D1 并应用尚未执行的 migration,然后启动 Worker。访问 `http://localhost:8787`。仅调试前端时运行 `bun run dev:frontend`,Vite 会把 `/api` 代理到 `8787`。使用 `http://localhost:5173/?demo=1` 可查看完整演示数据。
|
||||
|
||||
## 部署到 Cloudflare
|
||||
|
||||
1. 登录 Cloudflare:
|
||||
|
||||
```bash
|
||||
bunx wrangler login
|
||||
```
|
||||
|
||||
2. 设置初始管理员凭据。生产环境至少设置密码,用户名默认为 `admin`:
|
||||
|
||||
```bash
|
||||
bunx wrangler secret put ADMIN_USERNAME
|
||||
bunx wrangler secret put ADMIN_PASSWORD
|
||||
# 可选:固定会话签名密钥,避免密码修改后旧会话全部失效
|
||||
bunx wrangler secret put SESSION_SECRET
|
||||
# 可选:后台查询 D1/Workers 用量,Token 需要 Account Analytics Read 权限
|
||||
bunx wrangler secret put CLOUDFLARE_ACCOUNT_ID
|
||||
bunx wrangler secret put CLOUDFLARE_API_TOKEN
|
||||
```
|
||||
|
||||
3. 部署:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run deploy
|
||||
```
|
||||
|
||||
Wrangler 4.45+ 的自动资源配置会根据 `wrangler.toml` 中的 `DB` binding 创建并绑定 D1,不需要手动创建数据库或填写 `database_id`。`bun run deploy` 会先检查 binding;仅在部署环境尚未预配置 D1 时,执行一次用于触发自动配置的初始上传;随后应用所有待执行 migration,并完成最终 Worker 部署。Cloudflare Deploy Button 会预先配置资源,普通 CLI 与 Workers Builds 是否需要初始上传则由脚本自动判断。自动资源配置目前仍是 Cloudflare Beta 能力;如需使用既有 D1,再显式填入对应的 `database_id`。参见 [Wrangler 自动资源配置](https://developers.cloudflare.com/workers/wrangler/configuration/)、[Deploy Button](https://developers.cloudflare.com/workers/platform/deploy-buttons/) 和 [D1 migrations](https://developers.cloudflare.com/d1/reference/migrations/)。
|
||||
|
||||
部署后打开站点,点击右上角管理按钮添加节点。新建节点或重置密钥时会显示平台安装命令:Linux 自动安装 systemd 或 OpenRC 服务,Windows 使用 SYSTEM 启动任务,macOS ARM64 使用 LaunchDaemon;均支持开机启动、异常重启和 Agent 自动更新。TCP 延迟由 Rust 直接测试,ICMP 任务需要系统 `ping`;Linux 的 `ip`、`df` 及各平台的 `nvidia-smi` 是可选采集依赖。
|
||||
|
||||
Linux 保留 `/proc` 提供的磁盘 IO 和 TCP/UDP 连接统计。Windows 与 macOS 使用跨平台系统接口采集 CPU、内存、Swap、磁盘、网络、进程和系统信息;当前没有可靠统一来源的磁盘 IO 与连接数会上报为 `0`。自动更新优先下载 Worker 同源文件,未提供相应平台文件时回退到 `imengying/CF-Monitor` 最新正式 GitHub Release。
|
||||
|
||||
第三方主题只替换公开首页,`/admin` 始终使用内置管理界面。应用前会验证 GitHub `tree` 目录中的构建产物,主题资源设置长效浏览器缓存。第三方主题代码与站点同源运行,只应安装可信作者提供的主题。
|
||||
|
||||
定时任务每 5 分钟检查一次汇率快照,但只有成功数据超过 24 小时才访问上游。ER API 失败时自动尝试 Frankfurter;两者都失败不会清空 D1,下一次尝试至少间隔一小时。后台“监控数据库”工作区可以查看来源、日期和 12 种资产币种,也可以手动刷新。Cloudflare 用量可在同一页面填写 Account ID 和具有 Account Analytics Read 权限的 API Token;输入留空时回退到 `CLOUDFLARE_ACCOUNT_ID` 与 `CLOUDFLARE_API_TOKEN` Worker Secret。
|
||||
|
||||
后台“通知”工作区可创建最多 20 条资源规则。指标支持 CPU、内存、磁盘、上行和下行;网络阈值单位为 MiB/s。空服务器列表表示应用到全部服务器,也可逐台指定。时间窗口为 1-1440 分钟,“平均”按窗口平均值判断,“持续”要求窗口内所有采样均超过阈值。
|
||||
|
||||
后台“登录与安全”中的 CORS 列表同时保护 API 和 WebSocket Origin。配置多站点聚合时,应在每个远端站点的允许来源中加入主站 Origin。CSP 外部资源列表用于可信第三方主题所需的脚本、样式、字体和 API 域名。
|
||||
|
||||
后台“延迟”工作区管理独立任务。每个任务可选择 TCP 或 ICMP、目标、30-3600 秒周期和任意节点;“默认分配给新服务器”只影响以后添加的节点,不会覆盖当前选择。TCP 目标支持域名、IPv4 和 `host:port`,省略端口时使用 443;ICMP 目标只接受域名或 IPv4。每轮测试四次,保存成功 RTT 中位数和丢包率,配置会在 Agent 下一次上报后生效。
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
推送三段版本 Tag(`1.2.3` 或 `v1.2.3`)时,`release.yml` 会先校验 Tag 与根项目、前端、Worker 和 Agent 的包版本完全一致,再构建 Linux x86_64/ARM64、Windows x86_64、macOS ARM64 Agent 以及公开前端,将产物附加到同一个 GitHub Release。发布前应同步修改四个 `package.version`;GitHub Actions 不编译或部署 Rust/WASM Worker,Worker 仍由 Cloudflare 在代码 push 或同步时构建。
|
||||
|
||||
登录页和管理面板使用独立前端入口,并在 Rust Worker release 构建时嵌入 WASM;`/admin` 和 `/admin-assets/*` 由 Worker 直接响应,不受第三方公开主题影响。直接执行 Worker 构建前应先运行 `bun run build:frontend`,项目提供的构建脚本会自动保证该顺序。
|
||||
|
||||
## Cloudflare Git 部署
|
||||
|
||||
Worker 由 Cloudflare Workers Builds 直接从 GitHub 仓库构建。创建仓库并推送代码后,在 Cloudflare Workers 中导入 `imengying/CF-Monitor`,使用以下设置:
|
||||
|
||||
- Worker 名称:`cf-monitor`
|
||||
- 生产分支:`main`
|
||||
- 根目录:`/`
|
||||
- 构建命令:留空
|
||||
- 部署命令:`bun run deploy`
|
||||
|
||||
`bun run deploy` 会依次编译 Rust Agent、公开前端、嵌入式管理端,确保 D1 已自动配置,应用 migration,最后编译和发布 Rust/WASM Worker。以后每次 push 到 `main`,以及 Cloudflare 同步到新的提交时,都会重复同一套可幂等流程,不再需要单独的 D1 初始化步骤。
|
||||
|
||||
项目尚未发布,数据库和 Agent 协议按当前版本直接演进,不包含旧 schema 或旧单样本上报协议的兼容层。开发期间若初始 schema 发生变化,请重新创建未投入使用的 D1 数据库后执行唯一的 `0001_initial.sql`。
|
||||
|
||||
## 配置
|
||||
|
||||
`wrangler.toml` 中可调整:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | ---: | --- |
|
||||
| `SITE_NAME` | `CF Monitor` | 初始站点名,可在管理端覆盖 |
|
||||
| `OFFLINE_THRESHOLD_SECONDS` | `180` | 初始离线判定秒数 |
|
||||
| `HISTORY_RETENTION_DAYS` | `30` | 历史保留天数,范围 1-365 |
|
||||
|
||||
管理凭据使用 Worker secrets 或后台“安全”工作区保存,不要写进 `wrangler.toml`。开启 Turnstile 后,在“安全”工作区填写站点密钥和密钥;服务端会调用 Cloudflare Siteverify 校验令牌。部署前请把本地测试密钥替换为正式密钥。
|
||||
|
||||
本地调试可以使用 Cloudflare 官方 Turnstile 测试站点密钥和密钥;测试令牌固定为 `XXXX.DUMMY.TOKEN.XXXX`,只适用于开发环境。
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/config` | 公开站点配置 |
|
||||
| `GET` | `/api/themes` | 第三方主题商店 |
|
||||
| `GET` | `/api/exchange-rates` | D1 中的 CNY 基准汇率快照 |
|
||||
| `GET` | `/api/servers` | 公开节点和最新指标 |
|
||||
| `GET` | `/api/history/:id?hours=24` | 节点历史 |
|
||||
| `GET` | `/api/latency/:id?hours=24` | 节点延迟任务历史 |
|
||||
| `GET` | `/api/ws` | 实时 WebSocket |
|
||||
| `POST` | `/api/agent/report` | 探针上报,节点 Bearer token |
|
||||
| `POST` | `/api/admin/login` | 管理登录 |
|
||||
| `GET/POST` | `/api/admin/servers` | 管理节点 |
|
||||
| `GET/POST` | `/api/admin/latency-tasks` | 查询或创建延迟任务 |
|
||||
| `PATCH/DELETE` | `/api/admin/latency-tasks/:id` | 编辑或删除延迟任务 |
|
||||
| `GET/POST` | `/api/admin/alert-rules` | 查询或创建资源告警规则 |
|
||||
| `PATCH/DELETE` | `/api/admin/alert-rules/:id` | 编辑或删除资源告警规则 |
|
||||
| `PATCH/DELETE` | `/api/admin/servers/:id` | 编辑或删除节点 |
|
||||
| `PATCH` | `/api/admin/servers/order` | 更新全部节点顺序 |
|
||||
| `POST` | `/api/admin/servers/:id/token` | 轮换探针密钥 |
|
||||
| `DELETE` | `/api/admin/servers` | 批量删除节点 |
|
||||
| `GET/PATCH` | `/api/admin/settings` | 读写站点与展示设置 |
|
||||
| `GET` | `/api/admin/theme-settings` | 读取当前前端提供的主题设置描述 |
|
||||
| `POST` | `/api/admin/themes/preview` | 创建短时签名主题预览 |
|
||||
| `POST` | `/api/turnstile/verify` | 校验全站 Turnstile 令牌 |
|
||||
| `GET` | `/api/admin/database` | 查询 D1 节点、历史和数据库统计 |
|
||||
| `GET` | `/api/admin/cloudflare-usage` | 查询 UTC 今日/昨日的 D1 与 Workers 用量 |
|
||||
| `POST` | `/api/admin/exchange-rates/refresh` | 立即拉取并更新 D1 汇率快照 |
|
||||
| `DELETE` | `/api/admin/history` | 清理历史指标 |
|
||||
| `POST` | `/api/admin/notifications/test` | 发送一条通知测试消息 |
|
||||
|
||||
第三方主题可在构建目录根部提供 `theme-settings.json`。文件使用 `{ "schema": 1, "settings": [...] }`,字段支持 `text`、`textarea`、`url`、`color`、`select`、`toggle` 和 `number` 类型。后台会按描述生成控件,保存值通过 `/api/config` 的 `theme_options` 返回给主题。
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "cf-monitor-agent"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Independent Rust telemetry agent for CF Monitor"
|
||||
repository = "https://github.com/imengying/CF-Monitor"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ureq = { version = "2.12", default-features = false, features = ["json", "tls"] }
|
||||
|
||||
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
|
||||
sysinfo = { version = "0.39.6", default-features = false, features = ["disk", "network", "system"] }
|
||||
ureq = { version = "2.12", default-features = false, features = ["json", "native-tls"] }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SERVICE_NAME="cf-monitor-agent"
|
||||
INSTALL_DIR="/opt/cf-monitor"
|
||||
AGENT_FILE="$INSTALL_DIR/agent"
|
||||
CONFIG_FILE="$INSTALL_DIR/agent.env"
|
||||
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
OPENRC_FILE="/etc/init.d/$SERVICE_NAME"
|
||||
|
||||
usage() {
|
||||
echo "Usage: agent.sh install --server-id ID --token TOKEN --url WORKER_URL [--interval 60] [--collect-interval 5]"
|
||||
echo " agent.sh uninstall"
|
||||
}
|
||||
|
||||
arg() {
|
||||
key="$1"; shift
|
||||
while [ "$#" -gt 1 ]; do
|
||||
[ "$1" = "$key" ] && { printf '%s' "$2"; return 0; }
|
||||
shift
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
safe_value() {
|
||||
case "$1" in *[!A-Za-z0-9_./:@-]*|'') return 1 ;; esac
|
||||
}
|
||||
|
||||
install_agent() {
|
||||
[ "$(id -u)" -eq 0 ] || { echo "Run install as root" >&2; exit 1; }
|
||||
command -v curl >/dev/null || { echo "curl is required" >&2; exit 1; }
|
||||
server_id=$(arg --server-id "$@") || { usage; exit 1; }
|
||||
token=$(arg --token "$@") || { usage; exit 1; }
|
||||
worker_url=$(arg --url "$@") || { usage; exit 1; }
|
||||
interval=$(arg --interval "$@" || printf '60')
|
||||
collect_interval=$(arg --collect-interval "$@" || printf '5')
|
||||
worker_url=${worker_url%/}
|
||||
safe_value "$server_id" && safe_value "$token" && safe_value "$worker_url" || { echo "Invalid install argument" >&2; exit 1; }
|
||||
case "$interval" in ''|*[!0-9]*) echo "Invalid interval" >&2; exit 1 ;; esac
|
||||
[ "$interval" -ge 15 ] && [ "$interval" -le 3600 ] || { echo "Interval must be between 15 and 3600 seconds" >&2; exit 1; }
|
||||
case "$collect_interval" in ''|*[!0-9]*) echo "Invalid collect interval" >&2; exit 1 ;; esac
|
||||
[ "$collect_interval" -ge 2 ] && [ "$collect_interval" -le 60 ] && [ "$collect_interval" -le "$interval" ] || { echo "Collect interval must be between 2 and 60 seconds and no greater than report interval" >&2; exit 1; }
|
||||
case "$(uname -m)" in x86_64|amd64) arch="x86_64" ;; aarch64|arm64) arch="aarch64" ;; *) echo "Unsupported architecture" >&2; exit 1 ;; esac
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
temporary="$INSTALL_DIR/agent.download"
|
||||
if ! curl --fail --silent --show-error --max-time 120 "$worker_url/agent-linux-$arch" -o "$temporary"; then
|
||||
curl --fail --location --silent --show-error --max-time 120 \
|
||||
"https://github.com/imengying/CF-Monitor/releases/latest/download/agent-linux-$arch" \
|
||||
-o "$temporary"
|
||||
fi
|
||||
chmod 755 "$temporary"
|
||||
"$temporary" version >/dev/null
|
||||
mv "$temporary" "$AGENT_FILE"
|
||||
{
|
||||
printf 'SERVER_ID=%s\n' "$server_id"
|
||||
printf 'AGENT_TOKEN=%s\n' "$token"
|
||||
printf 'WORKER_URL=%s\n' "$worker_url"
|
||||
printf 'REPORT_INTERVAL=%s\n' "$interval"
|
||||
printf 'COLLECT_INTERVAL=%s\n' "$collect_interval"
|
||||
printf 'NETWORK_INTERFACE=\n'
|
||||
} > "$CONFIG_FILE"
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
printf '%s\n' \
|
||||
'[Unit]' \
|
||||
'Description=CF Monitor Rust Agent' \
|
||||
'After=network-online.target' \
|
||||
'Wants=network-online.target' \
|
||||
'' \
|
||||
'[Service]' \
|
||||
'Type=simple' \
|
||||
"EnvironmentFile=$CONFIG_FILE" \
|
||||
"ExecStart=$AGENT_FILE run" \
|
||||
'Restart=always' \
|
||||
'RestartSec=10' \
|
||||
'NoNewPrivileges=true' \
|
||||
'PrivateTmp=true' \
|
||||
'ProtectHome=true' \
|
||||
'ProtectSystem=strict' \
|
||||
"ReadWritePaths=$INSTALL_DIR" \
|
||||
'' \
|
||||
'[Install]' \
|
||||
'WantedBy=multi-user.target' > "$SERVICE_FILE"
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now "$SERVICE_NAME"
|
||||
elif command -v rc-service >/dev/null 2>&1; then
|
||||
printf '%s\n' \
|
||||
'#!/sbin/openrc-run' \
|
||||
"name=\"$SERVICE_NAME\"" \
|
||||
"command=\"$AGENT_FILE\"" \
|
||||
'command_args="run"' \
|
||||
"command_user=\"root\"" \
|
||||
"supervisor=\"supervise-daemon\"" \
|
||||
"respawn_delay=10" \
|
||||
"export \$(cat $CONFIG_FILE | xargs)" \
|
||||
'depend() { need net; }' > "$OPENRC_FILE"
|
||||
chmod 755 "$OPENRC_FILE"
|
||||
rc-update add "$SERVICE_NAME" default
|
||||
rc-service "$SERVICE_NAME" restart
|
||||
else
|
||||
echo "systemd or OpenRC is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "CF Monitor Rust agent installed."
|
||||
}
|
||||
|
||||
uninstall_agent() {
|
||||
[ "$(id -u)" -eq 0 ] || { echo "Run uninstall as root" >&2; exit 1; }
|
||||
systemctl disable --now "$SERVICE_NAME" 2>/dev/null || true
|
||||
rc-service "$SERVICE_NAME" stop 2>/dev/null || true
|
||||
rc-update del "$SERVICE_NAME" default 2>/dev/null || true
|
||||
rm -f "$SERVICE_FILE"
|
||||
rm -f "$OPENRC_FILE"
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
rm -rf "$INSTALL_DIR"
|
||||
echo "CF Monitor agent removed."
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
install) shift; install_agent "$@" ;;
|
||||
uninstall) uninstall_agent ;;
|
||||
*) usage; exit 1 ;;
|
||||
esac
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
LABEL="com.cfmonitor.agent"
|
||||
INSTALL_DIR="/usr/local/libexec/cf-monitor"
|
||||
AGENT_FILE="$INSTALL_DIR/agent"
|
||||
PLIST_FILE="/Library/LaunchDaemons/$LABEL.plist"
|
||||
|
||||
arg() {
|
||||
key="$1"; shift
|
||||
while [ "$#" -gt 1 ]; do
|
||||
[ "$1" = "$key" ] && { printf '%s' "$2"; return 0; }
|
||||
shift
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
safe_value() {
|
||||
case "$1" in *[!A-Za-z0-9_./:@-]*|'') return 1 ;; esac
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "uninstall" ]; then
|
||||
[ "$(id -u)" -eq 0 ] || { echo "Run as root" >&2; exit 1; }
|
||||
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
|
||||
rm -f "$PLIST_FILE" "$AGENT_FILE"
|
||||
rmdir "$INSTALL_DIR" 2>/dev/null || true
|
||||
echo "CF Monitor Agent removed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ "${1:-}" = "install" ] || { echo "Usage: install-macos.sh install --server-id ID --token TOKEN --url URL" >&2; exit 1; }
|
||||
shift
|
||||
[ "$(id -u)" -eq 0 ] || { echo "Run as root" >&2; exit 1; }
|
||||
server_id=$(arg --server-id "$@") || exit 1
|
||||
token=$(arg --token "$@") || exit 1
|
||||
worker_url=$(arg --url "$@") || exit 1
|
||||
interval=$(arg --interval "$@" || printf '60')
|
||||
collect_interval=$(arg --collect-interval "$@" || printf '5')
|
||||
worker_url=${worker_url%/}
|
||||
safe_value "$server_id" && safe_value "$token" && safe_value "$worker_url" || { echo "Invalid argument" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
curl -fL "https://github.com/imengying/CF-Monitor/releases/latest/download/agent-macos-aarch64" -o "$AGENT_FILE.download"
|
||||
chmod 755 "$AGENT_FILE.download"
|
||||
"$AGENT_FILE.download" version >/dev/null
|
||||
mv "$AGENT_FILE.download" "$AGENT_FILE"
|
||||
|
||||
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>run</string></array>
|
||||
<key>EnvironmentVariables</key><dict>
|
||||
<key>SERVER_ID</key><string>$server_id</string>
|
||||
<key>AGENT_TOKEN</key><string>$token</string>
|
||||
<key>WORKER_URL</key><string>$worker_url</string>
|
||||
<key>REPORT_INTERVAL</key><string>$interval</string>
|
||||
<key>COLLECT_INTERVAL</key><string>$collect_interval</string>
|
||||
</dict>
|
||||
<key>KeepAlive</key><true/><key>RunAtLoad</key><true/>
|
||||
<key>StandardOutPath</key><string>/var/log/cf-monitor-agent.log</string>
|
||||
<key>StandardErrorPath</key><string>/var/log/cf-monitor-agent.log</string>
|
||||
</dict></plist>
|
||||
EOF
|
||||
chmod 600 "$PLIST_FILE"
|
||||
launchctl bootout system "$PLIST_FILE" 2>/dev/null || true
|
||||
launchctl bootstrap system "$PLIST_FILE"
|
||||
echo "CF Monitor Agent installed and started."
|
||||
@@ -0,0 +1,65 @@
|
||||
param(
|
||||
[Parameter(Position = 0)][ValidateSet("install", "uninstall", "status")][string]$Action = "install",
|
||||
[string]$ServerId,
|
||||
[string]$Token,
|
||||
[string]$Url,
|
||||
[ValidateRange(15, 3600)][int]$Interval = 60,
|
||||
[ValidateRange(2, 60)][int]$CollectInterval = 5
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$TaskName = "CF Monitor Agent"
|
||||
$InstallDir = Join-Path $env:ProgramData "CFMonitor"
|
||||
$AgentFile = Join-Path $InstallDir "cf-monitor-agent.exe"
|
||||
$RunnerFile = Join-Path $InstallDir "run.ps1"
|
||||
|
||||
function Assert-Safe([string]$Name, [string]$Value) {
|
||||
if ([string]::IsNullOrWhiteSpace($Value) -or $Value -notmatch '^[A-Za-z0-9_./:@-]+$') {
|
||||
throw "Invalid $Name"
|
||||
}
|
||||
}
|
||||
|
||||
if ($Action -eq "uninstall") {
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "CF Monitor Agent removed."
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($Action -eq "status") {
|
||||
Get-ScheduledTask -TaskName $TaskName
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw "Run PowerShell as Administrator"
|
||||
}
|
||||
Assert-Safe "ServerId" $ServerId
|
||||
Assert-Safe "Token" $Token
|
||||
Assert-Safe "Url" $Url
|
||||
$Url = $Url.TrimEnd('/')
|
||||
$CollectInterval = [Math]::Min($CollectInterval, $Interval)
|
||||
|
||||
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
|
||||
$Download = "https://github.com/imengying/CF-Monitor/releases/latest/download/agent-windows-x86_64.exe"
|
||||
Invoke-WebRequest -Uri $Download -OutFile "$AgentFile.download"
|
||||
& "$AgentFile.download" version | Out-Null
|
||||
Move-Item -LiteralPath "$AgentFile.download" -Destination $AgentFile -Force
|
||||
|
||||
@"
|
||||
`$env:SERVER_ID='$ServerId'
|
||||
`$env:AGENT_TOKEN='$Token'
|
||||
`$env:WORKER_URL='$Url'
|
||||
`$env:REPORT_INTERVAL='$Interval'
|
||||
`$env:COLLECT_INTERVAL='$CollectInterval'
|
||||
& '$AgentFile' run
|
||||
"@ | Set-Content -LiteralPath $RunnerFile -Encoding UTF8
|
||||
|
||||
$TaskAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$RunnerFile`""
|
||||
$Trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$Settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650)
|
||||
$Principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $TaskAction -Trigger $Trigger -Settings $Settings -Principal $Principal -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $TaskName
|
||||
Write-Host "CF Monitor Agent installed and started."
|
||||
@@ -0,0 +1,40 @@
|
||||
use std::{env, fs, path::PathBuf};
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest directory"));
|
||||
let dist = root.join("frontend/admin-dist");
|
||||
let out = PathBuf::from(env::var("OUT_DIR").expect("build output directory"));
|
||||
let release = env::var("PROFILE").as_deref() == Ok("release");
|
||||
|
||||
println!("cargo:rerun-if-changed=frontend/admin-dist/admin.html");
|
||||
println!("cargo:rerun-if-changed=frontend/admin-dist/admin.js");
|
||||
println!("cargo:rerun-if-changed=frontend/admin-dist/admin.css");
|
||||
|
||||
let files = [
|
||||
(
|
||||
"admin.html",
|
||||
b"<!doctype html><meta charset=\"utf-8\"><title>Admin unavailable</title><p>Run the frontend admin build first.</p>".as_slice(),
|
||||
),
|
||||
(
|
||||
"admin.js",
|
||||
b"console.error('Run the frontend admin build first.');".as_slice(),
|
||||
),
|
||||
(
|
||||
"admin.css",
|
||||
b"body{font-family:system-ui,sans-serif}".as_slice(),
|
||||
),
|
||||
];
|
||||
|
||||
for (name, fallback) in files {
|
||||
let source = dist.join(name);
|
||||
let body = match fs::read(&source) {
|
||||
Ok(body) => body,
|
||||
Err(error) if release => panic!(
|
||||
"missing embedded admin asset {} ({error}); run `bun run build:frontend` before the release Worker build",
|
||||
source.display()
|
||||
),
|
||||
Err(_) => fallback.to_vec(),
|
||||
};
|
||||
fs::write(out.join(name), body).expect("write embedded admin asset");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "cf-monitor",
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.120.0",
|
||||
},
|
||||
},
|
||||
"frontend": {
|
||||
"name": "cf-monitor-ui",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"recharts": "^2.15.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.6",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||
|
||||
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
|
||||
|
||||
"@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.20260801.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-wuJWbXpKvncJi1P0GKS+iYpN5tHdb7JPJJ/+6ZQe8zzovHHVMkLJPNBsgWpqeUhpM3g9qTwEKd2rglNKejuh5A=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260801.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kwoZiTpnhNrF3+APx84Q/oAqvJ3sU9yefGagwm/ASaH/2W19x0vghkW/r4qCoHCK0WW7EPugZ+aXjgPMRtlq1Q=="],
|
||||
|
||||
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260801.1", "", { "os": "linux", "cpu": "x64" }, "sha512-r0vAxCZH+Jih9Unm1yoyiByPNWNgawcKciOHDm5Q37ZVGOkKLsT9AtLe3yLSaul76WrKqtf+JP2n0WW32VBLJg=="],
|
||||
|
||||
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260801.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-zWgpdZtSozvIgzQNmQiDSF8yEOQJUkRAWNsDXXzAAoy+fCn8YUoSibj3mpFSbZRvbUldeBEaW6SCdC2VEMkhNQ=="],
|
||||
|
||||
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260801.1", "", { "os": "win32", "cpu": "x64" }, "sha512-2oQz+Ksu4ji6e/+ZoYX+tWQEcxAii2p7l+iR8kx48W1llMalaufAsmVxTlk+3/vrM7D3/2c0iK448e0UQTcIMg=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="],
|
||||
|
||||
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="],
|
||||
|
||||
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||
|
||||
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
|
||||
|
||||
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
|
||||
|
||||
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.4", "", { "os": "android", "cpu": "arm" }, "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.4", "", { "os": "android", "cpu": "arm64" }, "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.4", "", { "os": "none", "cpu": "arm64" }, "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||
|
||||
"@speed-highlight/core": ["@speed-highlight/core@1.2.23", "", {}, "sha512-iRoq6i6JDJP6Mt2A5JaPvzw0pgYHH6k92ij+yXiTrB7T2y9N789aWE3EHWj/5ztlJBokcCBja3iYLVdu5wgnkg=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
||||
|
||||
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||
|
||||
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||
|
||||
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
||||
|
||||
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
|
||||
|
||||
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||
|
||||
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA=="],
|
||||
|
||||
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="],
|
||||
|
||||
"cf-monitor-ui": ["cf-monitor-ui@workspace:frontend"],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
|
||||
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||
|
||||
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||
|
||||
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
|
||||
|
||||
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||
|
||||
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
||||
|
||||
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
||||
|
||||
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
||||
|
||||
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
||||
|
||||
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.402", "", {}, "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA=="],
|
||||
|
||||
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
|
||||
"fast-equals": ["fast-equals@5.4.1", "", {}, "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.525.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ=="],
|
||||
|
||||
"miniflare": ["miniflare@5.20260801.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260801.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-BHPVzIDA6mbx7LefxpvkXW7DHx9FKB9GorZatbnrrFTt3CVMU8zuUpbgyCuebwDKcTTOZos43ta8GQ0eMVEpxA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
||||
|
||||
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
|
||||
|
||||
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
|
||||
|
||||
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
|
||||
|
||||
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
|
||||
|
||||
"rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.3.0", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@36.9.2", "", { "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-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
|
||||
|
||||
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.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", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"workerd": ["workerd@1.20260801.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260801.1", "@cloudflare/workerd-darwin-arm64": "1.20260801.1", "@cloudflare/workerd-linux-64": "1.20260801.1", "@cloudflare/workerd-linux-arm64": "1.20260801.1", "@cloudflare/workerd-windows-64": "1.20260801.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-/g9JGTyqnHtoIscpBHqKD8swE2V4StBs2i69PmLiOhH45OP95jCFICl4F1hKlgN57rqfni5LiCitttoX5OOkVA=="],
|
||||
|
||||
"wrangler": ["wrangler@4.120.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.20260801.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260801.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260801.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-cBmu/MeaB/fPacC0JpATs4duTOCagBxrZo+vBzuTX06tLzwSyAHE1drlHUZ8rP0VqVz1fy3ReGYTiHdKkoHltg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f4f6f8" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<link rel="icon" href="/logo.svg" type="image/svg+xml" />
|
||||
<title>管理面板</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/admin-main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#edf1f3" />
|
||||
<meta name="description" content="CF Monitor server status dashboard" />
|
||||
<link rel="icon" href="/logo.svg" type="image/svg+xml" />
|
||||
<title>CF Monitor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "cf-monitor-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"repository": "https://github.com/imengying/CF-Monitor",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"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": "^0.525.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"recharts": "^2.15.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Permissions-Policy: camera=(), microphone=(), geolocation=()
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' ws: wss:; frame-src https://challenges.cloudflare.com; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="CF Monitor">
|
||||
<rect width="48" height="48" rx="10" fill="#11191f"/>
|
||||
<path d="M9 27h7l3-10 6 20 4-13 3 6h7" fill="none" stroke="#54d6ab" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="39" cy="30" r="2" fill="#facc15"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 347 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1755186299402" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1493" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M508.928 979.968C250.368 977.92 42.496 766.976 44.032 508.416s212.992-466.432 471.04-464.384c257.024 2.048 464.896 210.944 464.896 467.968 0 258.56-209.408 467.968-467.968 467.968h-3.072z m-200.704-670.72c-57.344 2.56-101.888 50.176-100.864 107.52v187.904c-1.536 57.344 43.52 104.96 100.864 107.52h132.608l-31.744-48.64-97.28-31.744c-17.92-6.656-29.696-24.064-28.672-43.008V432.64c-0.512-18.944 11.264-36.352 29.184-43.008l97.28-32.256 30.72-48.64-132.096 0.512z m100.864 176.128v48.64h201.216v-48.64H409.088zM711.68 309.248h-133.12l32.256 48.64 97.28 31.744c17.92 6.656 29.696 24.064 29.184 43.008V588.8c0.512 18.944-11.264 36.352-29.184 43.008l-97.28 31.744-32.256 48.128h133.12c57.344-2.048 102.4-50.176 100.864-107.52V416.768c1.024-57.344-43.52-105.472-100.864-107.52z" fill="#FF8826" p-id="1494"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M62.0617 39.7C64.6593 39.5002 66.7574 41.1986 66.9572 43.7962C67.157 46.4937 65.2588 48.6917 62.6612 48.8915C60.1635 49.0913 57.9656 47.193 57.7657 44.6954C57.5659 41.9979 59.3643 39.9997 62.0617 39.7Z" fill="#86DA2F"/>
|
||||
<path d="M30.491 60.9802C30.491 58.3826 32.4892 56.3845 34.8869 56.3845C37.2847 56.3845 39.5826 58.5824 39.5826 61.0801C39.5826 63.4779 37.5844 65.5759 35.2866 65.6758C32.3893 65.6758 30.491 63.8775 30.491 60.9802Z" fill="#24C2FF"/>
|
||||
<path d="M37.5845 31.8073C37.1849 32.0071 36.9851 31.7074 36.8852 31.4077C33.1886 24.5141 34.2876 15.8221 40.7816 10.627C42.48 9.22828 45.677 8.92855 47.0757 10.3273C47.6752 10.8268 47.7751 11.4262 47.875 12.1256C48.0748 13.6242 48.3745 15.1228 49.3736 16.3217C50.4726 17.6205 51.8713 18.12 53.4698 18.0201C54.8685 18.0201 56.2672 17.8203 57.1664 19.4188C57.6659 20.318 57.4661 23.8147 56.6668 24.5141C56.2672 24.8138 55.9675 24.614 55.6677 24.5141C53.3699 23.6149 50.9721 23.6149 48.5743 24.0145C47.7751 24.1144 47.3754 23.9146 47.3754 23.0155C47.2755 21.5169 46.9758 20.1182 46.1766 18.8194C44.678 16.1219 41.8805 16.022 40.0822 18.5196C38.5836 20.5178 38.184 22.9156 37.7844 25.3133C37.3847 27.4114 37.4846 29.6093 37.5845 31.8073C37.4846 31.8073 37.5845 31.8073 37.5845 31.8073Z" fill="#FFCB12"/>
|
||||
<path d="M40.0822 33.3059C39.8824 33.0061 39.9823 32.7064 40.282 32.5066C45.9767 27.3114 54.4688 26.3124 61.0627 31.4076C62.7611 32.8063 63.8601 35.7036 62.9609 37.4021C62.5613 38.1014 62.0617 38.4011 61.4623 38.6009C60.0636 39.2004 58.7648 39.7998 57.8656 41.0986C56.9665 42.3974 56.7667 43.896 57.1663 45.4945C57.466 46.7933 57.9656 48.192 56.6668 49.3909C55.9674 50.0903 52.5706 50.6897 51.6714 50.0903C51.2718 49.7906 51.3717 49.4908 51.4716 49.0912C51.7713 46.5935 51.1719 44.2957 50.2727 42.0977C49.973 41.2984 50.0729 40.8988 50.8721 40.699C52.2708 40.2994 53.5696 39.6 54.5687 38.6009C56.7667 36.5029 56.2671 33.8054 53.3698 32.6065C51.072 31.6074 48.6742 31.8073 46.3763 32.0071C44.1784 32.107 42.0803 32.7064 40.0822 33.3059Z" fill="#86DA2F"/>
|
||||
<path d="M38.7833 35.9035C39.0831 35.6038 39.2829 35.7037 39.5826 35.9035C46.1765 39.8998 49.5733 47.7925 46.5761 55.4853C45.7768 57.4835 43.1793 59.4816 41.281 58.9821C40.4818 58.7823 40.0821 58.3826 39.6825 57.8831C38.7833 56.6842 37.7843 55.5852 36.2857 55.0857C34.6871 54.5862 33.2884 54.8859 31.8897 55.6851C30.6909 56.3845 29.492 57.2837 27.9934 56.3845C27.0942 55.885 25.5956 52.7878 25.8953 51.7888C26.0951 51.3891 26.4947 51.3891 26.8944 51.3891C29.3921 50.9895 31.3902 49.7906 33.2884 48.1921C33.8879 47.6926 34.3874 47.6926 34.887 48.3919C35.6862 49.5908 36.6853 50.5899 37.9841 51.2892C40.5817 52.7878 43.0793 51.489 43.3791 48.4918C43.6788 45.9941 42.7796 43.7962 41.9804 41.5982C41.0812 39.6001 39.9822 37.7018 38.7833 35.9035Z" fill="#24C2FF"/>
|
||||
<path d="M35.5864 36.503C35.1867 38.5011 34.2876 40.3994 33.0887 42.0978C29.492 47.4928 24.2968 49.6907 17.9028 49.0913C15.6049 48.8915 13.7067 46.9932 13.5069 45.095C13.407 44.2957 13.6068 43.6963 14.1063 43.0968C14.8057 42.1977 15.4051 41.3984 15.7048 40.2994C16.3043 38.1015 15.505 36.3032 13.9065 34.7046C11.7085 32.5067 12.0083 30.5085 14.6058 28.91C14.9056 28.7102 15.3052 28.5104 15.7048 28.3106C16.3043 28.0109 16.8038 28.0109 17.0036 28.7102C17.9028 31.0081 19.7011 32.7065 21.6993 34.1052C22.3986 34.7046 22.3986 35.1043 21.7992 35.8036C20.6003 37.1024 19.9009 38.7009 19.801 40.4993C19.6012 42.6972 20.9 44.0959 23.098 44.0959C24.4967 44.0959 25.7955 43.5964 26.9943 42.9969C30.0915 41.3984 32.4892 39.1006 34.7871 36.7028C35.0868 36.6029 35.1867 36.4031 35.5864 36.503Z" fill="#0069DA"/>
|
||||
<path d="M19.2016 15.5224C19.4014 15.5224 19.9009 15.6224 20.4004 15.7223C24.097 16.4216 26.3949 15.1228 27.5938 11.6261C28.393 9.3282 30.0914 8.62885 32.2894 9.82773C32.3893 9.82773 32.3893 9.92764 32.4892 9.92764C34.7871 11.2264 34.7871 11.4262 33.3884 13.4244C32.1895 15.0229 31.59 16.8212 31.2903 18.7195C31.0905 19.8185 30.6909 20.0183 29.6918 19.6186C28.0933 19.0192 26.3949 19.0192 24.6965 19.6186C22.7982 20.2181 21.999 21.9165 22.5984 23.8147C23.3977 26.3124 25.5956 27.4114 27.4939 28.7102C29.3921 30.009 31.59 30.7083 33.6881 31.6075C33.9878 31.7074 34.4874 31.7074 34.3874 32.2069C34.2875 32.5067 33.8879 32.5067 33.4883 32.5067C28.9925 32.7065 24.6965 32.0071 21.1997 29.0099C17.9028 26.3124 15.505 23.0155 15.9046 18.4198C16.2043 16.9211 17.3033 15.8222 19.2016 15.5224Z" fill="#FF4649"/>
|
||||
<path d="M11.0092 43.5964C8.61139 43.8961 6.21362 41.8979 6.01381 39.4003C5.81399 37.0025 7.81214 34.6047 10.11 34.4049C12.7076 34.1052 15.1054 35.9035 15.3052 38.3013C15.4051 40.5992 13.9065 43.3966 11.0092 43.5964Z" fill="#0069DA"/>
|
||||
<path d="M52.9702 8.02938C55.4679 7.82957 57.8657 9.82771 58.0655 12.3254C58.2653 14.7232 56.2671 17.021 53.8694 17.2208C51.2718 17.4207 48.9739 15.5224 48.7741 13.0247C48.5743 10.5271 50.3726 8.2292 52.9702 8.02938Z" fill="#FFCB12"/>
|
||||
<path d="M26.8944 9.92763C27.1941 12.5252 25.3957 14.7232 22.6982 15.1228C20.4004 15.4225 18.0026 13.5243 17.7029 11.3263C17.4032 8.42902 19.0017 6.33097 21.6992 6.03125C24.1969 5.73153 26.5946 7.62976 26.8944 9.92763Z" fill="#FF4649"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px" baseProfile="basic"><path fill="#1e88e5" d="M28.465,38.611c0.419-1.105,0.664-2.365,0.664-3.714c0-4.133-2.211-7.494-4.929-7.494 c-2.741,0-4.951,3.361-4.951,7.494c0,1.326,0.221,2.586,0.641,3.669c-9.041,0.951-15.407,4.731-17.993,6.432 c4.355-6.278,8.909-13.638,13.262-22.105c1.083-2.101,2.101-4.178,3.05-6.211c0.375,0.243,0.751,0.509,1.171,0.775 c1.945,1.215,3.759,1.879,5.084,2.233c-0.973-0.73-2.033-1.613-3.116-2.697c-0.817-0.817-1.547-1.637-2.167-2.433 C21.016,10.538,22.608,6.669,24,3c2.32,6.144,5.217,12.842,8.841,19.893c2.343,4.531,4.731,8.754,7.117,12.644 c-0.685-0.375-1.437-0.73-2.233-1.039c-1.371-0.53-2.652-0.862-3.759-1.06c1.503,0.751,3.25,1.747,5.084,3.073 c1.194,0.885,2.254,1.769,3.161,2.631c0.021,0.021,0.021,0.021,0.045,0.045c1.26,2.056,2.565,3.957,3.846,5.813 C43.561,43.319,37.306,39.605,28.465,38.611z"/></svg>
|
||||
|
After Width: | Height: | Size: 915 B |
|
After Width: | Height: | Size: 57 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.8179 33.5986L33.1974 35.9641L30.8179 38.3296H14.0705V46.2116L3.85712 35.9641L14.0705 25.8893V33.5986L30.8179 33.5986Z" fill="#932279"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4015 30.8181L36.036 33.1975L33.6705 30.8181V14.0706H25.7886L36.036 3.85715L46.1108 14.0706H38.4015L38.4015 30.8181Z" fill="#EFA724"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.182 38.4002L38.8026 36.0346L41.182 33.6691H57.9295V25.7872L68.1428 36.0346L57.9295 46.1094V38.4002L41.182 38.4002Z" fill="#262577"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.5972 41.1807L35.9628 38.8012L38.3283 41.1807V57.9282H46.2102L35.9628 68.1416L25.888 57.9282H33.5972L33.5972 41.1807Z" fill="#9CCD2A"/>
|
||||
<path d="M13.4708 13.4723H33.82V33.8217H13.4708V13.4723Z" fill="#9CCD2A" stroke="white"/>
|
||||
<path d="M38.1804 13.4723H58.5296V33.8216H38.1804V13.4723Z" fill="#932279" stroke="white"/>
|
||||
<path d="M38.1805 38.1846H58.5297V58.5339H38.1805V38.1846Z" fill="#EFA724" stroke="white"/>
|
||||
<path d="M13.4708 38.1846H33.82V58.5339H13.4708V38.1846Z" fill="#262577" stroke="white"/>
|
||||
<path d="M4.13776 36L18.5268 21.6109L32.9159 36L18.5268 50.3892L4.13776 36Z" stroke="white"/>
|
||||
<path d="M21.6108 18.5248L35.9999 4.13568L50.389 18.5248L35.9999 32.9139L21.6108 18.5248Z" stroke="white"/>
|
||||
<path d="M39.0823 35.9964L53.4714 21.6072L67.8605 35.9964L53.4714 50.3855L39.0823 35.9964Z" stroke="white"/>
|
||||
<path d="M21.6107 53.474L35.9998 39.0848L50.3889 53.474L35.9998 67.8631L21.6107 53.474Z" stroke="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M39.8656 36.5312C38.8004 36.546 40.0672 37.0801 41.4578 37.2941C41.8419 36.9942 42.1905 36.6907 42.5011 36.3954C41.635 36.6077 40.7536 36.6124 39.8656 36.5312Z" fill="#A80030"/>
|
||||
<path d="M45.5816 35.1074C46.2159 34.2319 46.6782 33.2734 46.8412 32.2823C46.699 32.9888 46.3155 33.5988 45.9545 34.2426C43.9633 35.4963 45.7671 33.498 45.9533 32.7387C43.8122 35.4334 45.6593 34.3546 45.5816 35.1074Z" fill="#A80030"/>
|
||||
<path d="M47.6935 29.6197C47.8222 27.7015 47.3159 28.3079 47.1458 29.04C47.3444 29.1431 47.5015 30.3921 47.6935 29.6197Z" fill="#A80030"/>
|
||||
<path d="M35.8262 3.40257C36.3947 3.50453 37.0545 3.58278 36.962 3.71852C37.5838 3.58218 37.7249 3.45651 35.8262 3.40257Z" fill="#A80030"/>
|
||||
<path d="M36.9605 3.72028L36.5586 3.80327L36.9326 3.77007L36.9605 3.72028Z" fill="#A80030"/>
|
||||
<path d="M54.6866 30.3464C54.75 32.069 54.1827 32.9048 53.6712 34.3844L52.7506 34.8444C51.9972 36.3074 52.8235 35.7733 52.2841 36.9369C51.108 37.9825 48.715 40.209 47.9491 40.4123C47.3901 40.3999 48.3279 39.7525 48.4506 39.4988C46.8762 40.5801 47.1874 41.1218 44.7796 41.7786L44.709 41.6221C38.7706 44.4159 30.5216 38.8794 30.6301 31.3251C30.5667 31.8046 30.4499 31.6849 30.3183 31.8787C30.0118 27.9919 32.1132 24.0879 35.6574 22.494C39.1239 20.7779 43.188 21.4821 45.6711 23.7963C44.3071 22.0097 41.5922 20.1158 38.3747 20.293C35.2229 20.3428 32.2744 22.3458 31.2904 24.5201C29.6757 25.5367 29.4884 28.4389 28.7848 28.97C27.8381 35.9274 30.5655 38.9333 35.179 42.4692C35.9052 42.9589 35.3835 43.0329 35.4819 43.4058C33.949 42.688 32.5453 41.6044 31.3912 40.2777C32.0035 41.174 32.6645 42.0454 33.5187 42.73C32.0735 42.2404 30.1428 39.2279 29.5791 39.1052C32.0705 43.5659 39.6871 46.9281 43.6752 45.26C41.8299 45.3282 39.4855 45.2979 37.412 44.5315C36.5412 44.0833 35.3569 43.1551 35.5685 42.9814C41.0113 45.0146 46.6338 44.5214 51.3433 40.746C52.5413 39.813 53.8502 38.2256 54.2284 38.2036C53.6587 39.0602 54.3256 38.6156 53.8881 39.372C55.082 37.4467 53.3694 38.5883 55.1223 36.0471L55.7696 36.9387C55.5289 35.3405 57.7542 33.3998 57.5283 30.8722C58.0387 30.0992 58.098 31.7039 57.5562 33.4822C58.3078 31.5094 57.7542 31.1923 57.9474 29.5646C58.1561 30.1117 58.4299 30.6932 58.5704 31.2706C58.0808 29.3642 59.0731 28.0601 59.3185 26.9522C59.0767 26.8449 58.5627 27.7951 58.4454 25.5432C58.4626 24.5651 58.7174 25.0304 58.8158 24.7898C58.6238 24.6795 58.1199 23.9297 57.8135 22.4916C58.0358 22.1537 58.4074 23.3677 58.7097 23.4175C58.5153 22.274 58.1804 21.4021 58.1668 20.5248C57.2835 18.6789 57.8544 20.7708 57.1377 19.7322C56.1976 16.7998 57.9178 19.0517 58.034 17.7192C59.459 19.7838 60.2717 22.9836 60.6445 24.309C60.36 22.6931 59.9 21.1276 59.3387 19.6131C59.7714 19.7951 58.6416 16.2882 59.9012 18.6107C58.5556 13.6599 54.1424 9.03386 50.0825 6.86313C50.5793 7.31778 51.2064 7.88862 50.9812 7.97813C48.9622 6.77599 49.3172 6.68233 49.028 6.17432C47.383 5.50508 47.2752 6.22827 46.1856 6.17551C43.0854 4.53116 42.4879 4.70603 39.6349 3.67579L39.7647 4.28219C37.7108 3.59813 37.3717 4.54183 35.1518 4.28456C35.0166 4.17905 35.8631 3.90282 36.5596 3.80145C34.5738 4.06346 34.6669 3.41022 32.7238 3.87377C33.2027 3.53767 33.7089 3.31538 34.2199 3.02966C32.6005 3.12806 30.3539 3.97217 31.0474 3.20453C28.406 4.38296 23.7148 6.03739 21.0823 8.5057L20.9993 7.95264C19.793 9.40079 15.739 12.2775 15.416 14.153L15.0935 14.2283C14.4658 15.2912 14.0597 16.4957 13.5618 17.5894C12.7408 18.9883 12.3585 18.1276 12.4752 18.3469C10.8605 21.6208 10.0585 24.3719 9.36554 26.628C9.85932 27.366 9.37739 31.0708 9.56412 34.0358C8.75321 48.6797 19.8416 62.898 31.962 66.1807C33.7386 66.8162 36.3806 66.7919 38.6278 66.8571C35.9763 66.0989 35.6337 66.4552 33.051 65.5548C31.1879 64.6775 30.7795 63.6757 29.46 62.5304L29.9822 63.4534C27.3941 62.5376 28.4771 62.32 26.3716 61.6531L26.9294 60.9246C26.0906 60.8612 24.7077 59.5109 24.3295 58.7634L23.4119 58.7995C22.3093 57.4391 21.7219 56.4587 21.7646 55.6993L21.4682 56.2275C21.1321 55.6507 17.4118 51.1255 19.3419 52.1788C18.9833 51.851 18.5067 51.6454 17.9898 50.7064L18.3828 50.2571C17.4539 49.062 16.6732 47.5303 16.7325 47.0199C17.2281 47.6892 17.5719 47.8143 17.9121 47.9287C15.5665 42.1088 15.4349 47.608 13.6584 42.0045L14.0342 41.9743C13.7461 41.5403 13.5713 41.0691 13.3395 40.6067L13.5031 38.9766C11.8143 37.024 13.0307 30.6742 13.2743 27.1917C13.4432 25.7756 14.6839 24.2681 15.6276 21.9041L15.0526 21.8052C16.1516 19.8881 21.3277 14.1062 23.7249 14.4038C24.8861 12.945 23.4943 14.3985 23.2673 14.0309C25.818 11.3913 26.62 12.1661 28.3414 11.6913C30.198 10.5893 26.748 12.121 27.6283 11.271C30.8376 10.4512 29.9028 9.40731 34.0895 8.99118C34.5311 9.24252 33.0646 9.37945 32.6965 9.70547C35.3705 8.39722 41.1583 8.6948 44.9177 10.4316C49.2799 12.4702 54.181 18.4963 54.3742 24.1662L54.5941 24.2254C54.4827 26.4792 54.9391 29.0856 54.1483 31.4798L54.6866 30.3464Z" fill="#A80030"/>
|
||||
<path d="M28.2373 37.9964L28.088 38.7432C28.788 39.694 29.3434 40.7243 30.2373 41.4676C29.5942 40.2121 29.1164 39.6935 28.2373 37.9964Z" fill="#A80030"/>
|
||||
<path d="M29.8947 37.9354C29.5242 37.5258 29.3049 37.0326 29.0594 36.5412C29.2942 37.4049 29.7749 38.147 30.2225 38.9016L29.8947 37.9354Z" fill="#A80030"/>
|
||||
<path d="M59.1789 31.5728L59.0224 31.9652C58.7355 34.0032 58.1161 36.0198 57.1664 37.8894C58.2157 35.9166 58.8944 33.7589 59.1789 31.5728Z" fill="#A80030"/>
|
||||
<path d="M36.0361 2.88974C36.7563 2.62596 37.8067 2.74511 38.5708 2.57143C37.5749 2.65501 36.5838 2.7048 35.6051 2.83106L36.0361 2.88974Z" fill="#A80030"/>
|
||||
<path d="M10.7503 16.3301C10.9163 17.8666 9.59438 18.4629 11.0431 17.4498C11.8197 15.7006 10.7396 16.9667 10.7503 16.3301Z" fill="#A80030"/>
|
||||
<path d="M9.04867 23.4467C9.3824 22.4224 9.44287 21.8071 9.57031 21.2143C8.64796 22.3933 9.14589 22.6447 9.04867 23.4467Z" fill="#A80030"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.041"
|
||||
height="502.439"
|
||||
viewBox="0 0 135.47752 132.93699"
|
||||
version="1.1">
|
||||
<g>
|
||||
<path
|
||||
fill="#51A2DA"
|
||||
d="M 67.77483,0 C 30.36665,0 0.07878,29.732002 0.0126,66.438752 H 0 v 51.430478 h 0.0126 c 0.0174,8.3247 6.90194,15.06782 15.39209,15.06782 h 52.37676 c 37.41481,-0.0238 67.69606,-29.75187 67.69606,-66.438739 5e-5,-36.70675 -30.32095,-66.438755 -67.76224,-66.438755 z" />
|
||||
<path
|
||||
fill="#FFFFFF"
|
||||
d="m 81.54629,27.337877 c -10.56799,0 -19.90324,7.801562 -21.19561,18.038694 -0.0868,0.856948 -0.11179,1.474652 -0.11679,2.25154 l 0.0124,41.854812 c 0,8.0289 -6.12782,13.493227 -13.78418,13.493227 -7.66691,0 -13.8436,-6.008637 -13.8436,-13.506147 0.12574,-8.230728 6.84267,-13.450867 14.67766,-13.678237 h 0.0419 l 7.95404,-0.07183 V 64.668931 l -7.95404,0.05891 c -14.2605,-0.12574 -25.57978,10.82588 -25.99324,24.748872 0,13.539207 11.34236,24.576797 25.11319,24.576797 12.98996,0 23.80035,-9.95243 24.9809,-22.406387 0.1702,-4.239107 0.0873,-9.833503 0.0873,-9.833503 0.0238,-2.303511 -0.0365,-1.476328 -0.0124,-6.050278 l 9.86659,-0.07183 c 7.6344,0.05295 7.54863,-11.225802 -0.0879,-11.070643 l -9.7808,0.07131 c 0,-5.677739 0.0911,-11.320375 0,-17.006713 0.007,-4.918056 4.7563,-9.264033 10.02574,-9.264033 5.27077,0 10.84585,2.580457 10.84585,9.264033 0,0.954889 -0.0195,1.510723 -0.0734,1.864484 -0.58829,3.099594 1.57066,6.061962 4.75165,6.509166 3.18098,0.442039 6.10201,-1.811142 6.41718,-4.94647 0.18261,-1.239441 0.1757,-2.332945 0.1757,-3.426148 0,-11.785592 -10.74013,-20.32899 -22.11545,-20.32899 z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="594.7" height="622.5" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Gentoo Linux logo (matte)</title>
|
||||
<defs>
|
||||
<linearGradient id="vlpa">
|
||||
<stop style="stop-color:#fff" offset="0"/>
|
||||
<stop style="stop-color:#fff" offset=".3421"/>
|
||||
<stop style="stop-color:#bebdf3" offset=".52"/>
|
||||
<stop style="stop-color:#8f82f9" offset=".7541"/>
|
||||
<stop style="stop-color:#9f99c7" offset=".8711"/>
|
||||
<stop style="stop-color:#524c76" offset="1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="vlpe" x1="464" x2="368.6" y1="697.4" y2="269.7" gradientTransform="matrix(1.237 0 0 1.237 -161.1 -377.3)" gradientUnits="userSpaceOnUse" xlink:href="#vlpa"/>
|
||||
<linearGradient id="vlpd" x1="783.9" x2="1100" y1="-313.5" y2="1341" gradientTransform="matrix(1.237 0 0 1.237 -1037 -1010)" gradientUnits="userSpaceOnUse" xlink:href="#vlpa"/>
|
||||
<radialGradient id="vlpc" cx="332.7" cy="590.6" r="218" gradientTransform="matrix(-.06604 4.19 -3.574 -.05627 2168 -1171)" gradientUnits="userSpaceOnUse" xlink:href="#vlpa"/>
|
||||
<radialGradient id="vlpb" cx="49.53" cy="320.1" r="31.95" gradientTransform="matrix(3.287 0 0 2.52 111.7 -642.7)" gradientUnits="userSpaceOnUse" xlink:href="#vlpa"/>
|
||||
</defs>
|
||||
<g transform="translate(-25.69 -25.52)">
|
||||
<path d="m25.76 521.9c0.06818-26.26 22.39-62.92 54.11-96.23 21.2-22.26 42.73-41.8 89.66-81.81-32.46-17.81-82.95-39.3-108.3-67.45-9.374-10.41-26.36-34.01-23.07-65.53 6.716-64.25 86.01-161.7 198.7-182.4 37.5-6.859 80.56-1.908 116 14.02 111.2 49.98 247.1 174.2 263.9 234.8 5.888 21.34 4.757 55.36-5.395 74.32-12.58 23.5-56.14 71.22-115.7 120.5-93.5 77.27-224.5 160.2-322.4 174.2-31.61 4.511-63.91-0.194-86.57-12.98-35.46-20.01-47.23-46.72-51.37-54.73-11-21.29-9.637-48.27-9.613-56.73zm263.1-295c1.207-14.89 59.59-0.06577 55.9 11.73-3.892 12.44-57.29 5.355-55.9-11.73z" style="fill-rule:evenodd;fill:url(#vlpd)"/>
|
||||
<path d="m201.1 319.3c-30.72 25.94-51.15 43.43-76.76 66.84-61.68 56.37-103.6 99.28-81.98 157.9 24.92 67.66 79.56 64.23 143.6 51.65 165.1-32.57 436.6-241.3 422.8-318.3-11.2-62.37-162.9-186.4-252.6-230.5-28.05-13.79-60.38-20.91-92.81-19.39-123.5 5.828-219.9 118.2-215 179.2 4.792 59.26 154.6 110.9 152.8 112.7zm-18.53-206.9c-37.15 41.34-13.4 121.3 48.21 164.9 72.1 51.01 188.8 60.24 212.1 20.91 43.34-73.37-3.98-151-74.61-185.6-69.8-34.2-162.5-26.01-185.7-0.245z" style="fill-rule:evenodd;fill:url(#vlpe)"/>
|
||||
<path d="m309.3 275.6c52.16 7.25 97.12-13.64 104.1-65.14 5.355-39.23-54.69-82.1-90.79-91.99-30.15-8.256-95.6 2.36-100 59.38-4.587 59.27 56.24 93.53 86.65 97.75zm-25.54-98.58c-34.28 53.1 49.77 69.43 62.64 59.75 55.48-41.75-47.54-83.14-62.64-59.75z" style="fill-rule:evenodd;fill:url(#vlpb)"/>
|
||||
<path d="m253.4 41.57c-60.63 6.39-72.25 14.19-127.2 55.75-18.41 13.93-52.29 47.82-60.98 88.03-3.307 15.31 0.6019 28.53 11.63 37.8 35.31 29.69 94.42 53.77 142.9 77.06 14.2 6.82 4.799 19.19-18.1 39.01-57.79 50.03-135.9 110.9-141.4 157.3-2.57 21.87 4.916 51.58 31.46 66.79 31.01 17.76 95.37 6.17 138.4-9.835 62.81-23.39 145.3-75 210.8-125.7 68.11-52.76 131.4-119.8 134.6-130.7 3.847-13.42 3.463-25.82-3.96-40.07-11.66-22.38-27.24-39.01-43.98-57.63-44.9-47.34-92.41-88.93-149.3-120.8-41.05-23.02-77.16-42.03-124.9-37zm89.07 106.1c43.81 21.18 88.79 76.81-0.3904 108.8-24.25 8.706-101.2-21.38-98.8-62.44 3.433-60.66 45.71-72.24 99.19-46.38z" style="fill-rule:evenodd;fill:url(#vlpc)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#bbb" viewBox="0 0 100 100" width="100px" height="100px"><path d="M 22.060547 19.480469 C 18.150547 19.480469 13.970625 19.650234 9.640625 19.990234 C 8.570625 20.070234 7.7607813 20.990547 7.8007812 22.060547 C 7.8407813 23.130547 8.7207813 23.980469 9.8007812 23.980469 C 23.710781 24.000469 36.469141 25.689922 44.619141 28.419922 C 42.129141 28.209922 39.610547 28.089844 37.060547 28.089844 C 18.060547 28.089844 3.8009375 33.910391 3.2109375 34.150391 C 2.2409375 34.560391 1.7503125 35.630859 2.0703125 36.630859 C 2.3503125 37.460859 3.1307031 38 3.9707031 38 C 4.1307031 38 4.2909375 37.979453 4.4609375 37.939453 C 4.6109375 37.899453 20.480938 34 36.460938 34 C 38.520938 34 40.500859 34.069453 42.380859 34.189453 C 24.690859 37.009453 11.460313 46.990469 10.820312 47.480469 C 9.9803125 48.130469 9.7908594 49.309453 10.380859 50.189453 C 10.770859 50.759453 11.399063 51.070312 12.039062 51.070312 C 12.389062 51.070312 12.740547 50.979062 13.060547 50.789062 C 13.250547 50.679063 31.949531 39.689453 50.769531 39.689453 C 51.289531 39.689453 51.800547 39.690937 52.310547 39.710938 L 52.369141 39.710938 C 49.879141 41.790937 46.609375 45.790547 46.859375 52.310547 C 47.179375 60.540547 53.159453 65.49 58.939453 66.75 C 61.319453 67.27 63.350547 67.39 65.310547 67.5 C 68.920547 67.72 72.050156 67.899375 76.910156 70.609375 C 82.340156 73.629375 86.450625 81.589141 87.390625 90.869141 C 87.490625 91.879141 88.339375 92.659922 89.359375 92.669922 L 89.380859 92.669922 C 90.390859 92.669922 91.239141 91.919922 91.369141 90.919922 C 91.429141 90.429922 92.430937 81.599687 86.960938 72.929688 C 89.010937 74.779687 90.759844 77.349141 91.339844 80.869141 C 91.499844 81.829141 92.340547 82.539063 93.310547 82.539062 L 93.339844 82.539062 C 94.329844 82.519063 95.160781 81.790547 95.300781 80.810547 C 95.330781 80.560547 96.069531 74.619219 91.019531 69.199219 C 85.779531 63.569219 76.979609 60.720703 64.849609 60.720703 C 61.169609 60.720703 58.320156 59.710938 56.410156 57.710938 C 54.310156 55.520938 53.660234 52.440703 53.740234 50.470703 C 53.870234 47.320703 56.390312 43.640625 63.070312 43.640625 C 66.720313 43.640625 73.270156 46.709141 76.410156 48.369141 C 76.610156 49.469141 77.069141 50.729141 78.119141 51.369141 C 78.399141 51.549141 78.709609 51.679844 79.099609 51.839844 C 80.239609 52.329844 82.150156 53.140156 84.410156 55.660156 C 84.800156 56.090156 85.350391 56.320313 85.900391 56.320312 C 86.280391 56.320312 86.66 56.210234 87 55.990234 L 90.009766 54.009766 C 90.689766 53.559766 91.030859 52.750938 90.880859 51.960938 C 90.720859 51.170938 90.110313 50.540859 89.320312 50.380859 C 88.280312 50.160859 86.969766 49.650234 86.259766 49.240234 C 86.229766 48.780234 86.089062 48.309375 85.789062 47.859375 C 85.649062 47.649375 85.480781 47.450703 85.300781 47.220703 C 84.910781 46.760703 84.389453 46.129922 83.939453 45.169922 C 83.779453 44.839922 83.380781 44.220078 82.550781 43.830078 C 82.780781 43.630078 82.979609 43.389609 83.099609 43.099609 C 83.409609 42.349609 83.240156 41.489922 82.660156 40.919922 C 74.000156 32.299922 66.640078 30.499687 66.330078 30.429688 C 66.180078 30.389688 66.029141 30.369141 65.869141 30.369141 C 65.049141 30.369141 64.3 30.879688 64 31.679688 C 63.65 32.619687 64.059219 33.680391 64.949219 34.150391 C 64.999219 34.170391 67.709297 35.589375 70.779297 37.609375 L 70.720703 37.609375 C 70.260703 37.639375 69.839531 37.840859 69.519531 38.130859 C 68.549531 37.750859 67.309219 37.339375 65.949219 37.109375 C 64.739219 36.899375 63.459844 36.869609 62.339844 36.849609 C 61.359844 36.829609 59.870234 36.790312 59.490234 36.570312 C 59.360234 36.460313 59.189766 36.329453 59.009766 36.189453 C 58.039766 35.469453 56.569297 34.380938 56.029297 27.710938 C 55.989297 27.170938 55.719063 26.660313 55.289062 26.320312 C 54.949062 26.040312 46.530547 19.480469 22.060547 19.480469 z M 22.060547 21.480469 C 46.110547 21.480469 54.039063 27.880859 54.039062 27.880859 C 54.719062 36.340859 56.98 37.080859 58.25 38.130859 C 59.51 39.190859 62.869375 38.610078 65.609375 39.080078 C 68.349375 39.550078 70.550781 40.820312 70.550781 40.820312 L 70.869141 39.609375 C 71.749141 41.679375 73.869141 42.390625 73.869141 42.390625 L 73.869141 40.759766 C 74.659141 42.659766 76.869141 43.970703 76.869141 43.970703 L 76.869141 42.410156 C 76.869141 42.410156 77.609844 43.539844 78.339844 44.339844 C 79.079844 45.149844 79.859375 45.599609 79.859375 45.599609 C 79.859375 45.599609 80.350859 45.480469 80.880859 45.480469 C 81.380859 45.480469 81.920859 45.589531 82.130859 46.019531 C 82.890859 47.649531 83.810859 48.500703 84.130859 48.970703 C 84.440859 49.440703 84.130859 50 84.130859 50 C 84.650859 50.98 87.390156 52.019844 88.910156 52.339844 L 85.900391 54.320312 C 82.680391 50.740313 79.939922 50.149922 79.169922 49.669922 C 78.389922 49.189922 78.269531 47.109375 78.269531 47.109375 C 78.269531 47.109375 68.530313 41.640625 63.070312 41.64Line truncated
|
||||
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg fill="#bbb" width="80px" height="80px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.71 19.5C17.88 20.74 17 21.95 15.66 21.97C14.32 22 13.89 21.18 12.37 21.18C10.84 21.18 10.37 21.95 9.09997 22C7.78997 22.05 6.79997 20.68 5.95997 19.47C4.24997 17 2.93997 12.45 4.69997 9.39C5.56997 7.87 7.12997 6.91 8.81997 6.88C10.1 6.86 11.32 7.75 12.11 7.75C12.89 7.75 14.37 6.68 15.92 6.84C16.57 6.87 18.39 7.1 19.56 8.82C19.47 8.88 17.39 10.1 17.41 12.63C17.44 15.65 20.06 16.66 20.09 16.67C20.06 16.74 19.67 18.11 18.71 19.5ZM13 3.5C13.73 2.67 14.94 2.04 15.94 2C16.07 3.17 15.6 4.35 14.9 5.19C14.21 6.04 13.07 6.7 11.95 6.61C11.8 5.46 12.36 4.26 13 3.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 725 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" stroke="#35bf5c" stroke-width="4" viewBox="0 0 14 14">
|
||||
<path d="M9 2H2V14 2M7 5v9M12 0V14"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 139 B |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 256 256" style="enable-background:new 0 0 256 256;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
.st1{fill:#69B53F;}
|
||||
.st2{fill:#F8F8F8;}
|
||||
</style>
|
||||
<g>
|
||||
<circle class="st0" cx="128" cy="128" r="128"/>
|
||||
<circle class="st1" cx="128" cy="128" r="108"/>
|
||||
<path id="_x3C_Path_x3E_" class="st2" d="M58,63v90c0.1,22.1,17.9,39.9,40,40h60c22.1-0.1,39.9-17.9,40-40v-50
|
||||
c-0.1-16.5-13.5-29.9-30-30c-7.4,0-14.5,2.8-20,7.9c-5.5-5-12.6-7.8-20-7.9c-16.5,0.1-29.9,13.5-30,30v50h20v-50
|
||||
c0-5.5,4.5-10,10-10c5.5,0,10,4.5,10,10v50h20v-50c0-5.5,4.5-10,10-10s10,4.5,10,10v50c0.1,11-8.8,19.9-19.7,20c-0.1,0-0.2,0-0.3,0
|
||||
H98c-11,0.1-19.9-8.8-20-19.7c0-0.1,0-0.2,0-0.3V63H58z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 967 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64" viewBox="0 0 60 60"><g fill-rule="evenodd"><use xlink:href="#B" fill="#5277c3"/><path d="M23.58 20.214L8.964 45.528 5.55 39.743l3.94-6.78-7.823-.02L0 30.052l1.703-2.956 11.135.035 4.002-6.9zM24.7 40.45h29.23l-3.302 5.85-7.84-.022 3.894 6.785-1.67 2.9-3.412.004-5.537-9.66-7.976-.016zm17.014-11.092L27.1 4.043l6.716-.063 3.902 6.8 3.93-6.765 3.337.002 1.7 2.953-5.598 9.626 3.974 6.916z" fill="#7ebae4"/><g fill="#5277c3"><use xlink:href="#B"/><path d="M35.28 19.486l-29.23-.002 3.303-5.848 7.84.022L13.3 6.873l1.67-2.9 3.412-.004 5.537 9.66 7.976.016zm1.14 20.294l14.616-25.313 3.413 5.785-3.94 6.78 7.823.02 1.668 2.9-1.703 2.956-11.135-.035-4.002 6.9z"/></g></g><defs ><path id="B" d="M18.305 30.642L32.92 55.956l-6.716.063-3.902-6.8-3.93 6.765-3.337-.002-1.71-2.953 5.598-9.626-3.974-6.916z"/></defs></svg>
|
||||
|
After Width: | Height: | Size: 915 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1755186680441" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2513" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M203.264 659.456l152.064-152.064 152.064 152.064a215.04 215.04 0 0 1-304.128 0z m304.128-304.128a215.04 215.04 0 0 1 304.128 0l-152.064 152.064-152.064-152.064z" fill="#060198" p-id="2514"></path><path d="M203.264 659.456a215.04 215.04 0 0 1 0-304.128L507.392 51.2a215.04 215.04 0 0 1 0 304.128l-304.128 304.128z" fill="#00C0FF" p-id="2515"></path><path d="M659.456 811.4688l-152.064 152.064a215.04 215.04 0 0 1 0-304.128l152.064-152.064 152.064-152.064a214.9376 214.9376 0 0 1 0 304.128l-152.064 152.064z" fill="#0368EC" p-id="2516"></path></svg>
|
||||
|
After Width: | Height: | Size: 880 B |
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 128 128"
|
||||
version="1.1"
|
||||
id="svg1182"
|
||||
sodipodi:docname="opensuse-original.svg"
|
||||
inkscape:version="1.1.2 (b8e25be8, 2022-02-05)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1186" />
|
||||
<sodipodi:namedview
|
||||
id="namedview1184"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
showgrid="false"
|
||||
inkscape:zoom="6.8203125"
|
||||
inkscape:cx="64"
|
||||
inkscape:cy="65.75945"
|
||||
inkscape:window-width="1296"
|
||||
inkscape:window-height="1081"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg1182" />
|
||||
<g
|
||||
fill="#73ba25"
|
||||
id="g1180"
|
||||
transform="translate(0,24.000045)">
|
||||
<path
|
||||
d="m 109.434,22.902 a 4.614,4.614 0 0 0 -3.329,1.23 4.53,4.53 0 0 0 -1.468,3.2 c -0.086,2.54 1.922,4.676 4.48,4.77 a 4.68,4.68 0 0 0 3.336,-1.239 4.532,4.532 0 0 0 1.465,-3.199 c 0.09,-2.539 -1.918,-4.672 -4.484,-4.762 z m 0.761,5.032 c -1.14,0 -2.062,-0.614 -2.062,-1.36 0,-0.758 0.922,-1.363 2.062,-1.363 1.137,0 2.063,0.605 2.063,1.363 0,0.746 -0.926,1.36 -2.063,1.36 z m 10.825,5.48 c 0.035,-0.023 0.113,-0.094 0.101,-0.156 -0.258,-1.594 -2.652,-9.317 -4.469,-11.172 -0.5,-0.508 -0.898,-1.004 -1.707,-1.477 -6.562,-3.785 -22.132,-6.07 -22.86,-6.172 0,0 -0.073,-0.015 -0.124,0.032 -0.04,0.039 -0.047,0.113 -0.047,0.113 l -0.07,3.55 c -1.59,-0.53 -13.149,-4.226 -23.98,-4.597 -9.192,-0.32 -22.571,-1.496 -40.305,9.297 l -0.524,0.324 C 18.691,28.31 12.934,34.66 9.922,42.043 c -0.945,2.32 -2.215,7.562 -0.957,12.492 0.543,2.156 1.555,4.324 2.918,6.262 3.078,4.383 8.25,7.289 13.816,7.781 7.86,0.695 13.809,-2.8 15.926,-9.344 1.453,-4.515 0,-11.14 -5.578,-14.52 -4.54,-2.75 -9.418,-2.124 -12.25,-0.273 -2.453,1.614 -3.844,4.118 -3.82,6.871 0.054,4.88 4.3,7.473 7.355,7.481 0.887,0 1.777,-0.152 2.781,-0.477 0.36,-0.109 0.696,-0.242 1.067,-0.468 l 0.113,-0.067 0.074,-0.05 -0.023,0.015 a 2.482,2.482 0 0 0 1.113,-2.05 c 0,-0.223 -0.031,-0.45 -0.098,-0.68 -0.359,-1.235 -1.593,-1.98 -2.87,-1.75 l -0.173,0.039 -0.234,0.07 -0.34,0.117 c -0.699,0.168 -1.226,0.184 -1.34,0.188 -0.359,-0.024 -2.113,-0.543 -2.113,-2.45 v -0.023 c 0,-0.703 0.281,-1.191 0.438,-1.46 0.546,-0.849 2.043,-1.68 4.066,-1.509 2.652,0.227 4.566,1.575 5.836,4.117 1.176,2.364 0.867,5.27 -0.793,7.399 -1.652,2.11 -4.59,3.008 -8.496,2.59 -3.942,-0.43 -7.274,-2.676 -9.137,-6.168 -1.828,-3.418 -1.926,-7.469 -0.258,-10.578 3.989,-7.457 11.52,-7.38 15.653,-6.672 6.113,1.05 13.066,6.633 15.535,13.078 0.398,1.027 0.601,1.844 0.777,2.59 l 0.266,1.113 6.906,3.336 c 0.148,0.074 0.2,0.098 0.258,0.055 0.074,-0.055 0.031,-0.2 0.031,-0.2 -0.043,-0.152 -0.144,-0.293 -0.3,-2.207 -0.133,-1.699 -0.4,-6.347 1.964,-8.648 0.918,-0.902 2.317,-1.707 3.422,-1.961 4.523,-1.098 9.832,-0.34 14.852,5.41 2.597,2.969 3.863,4.324 4.5,4.93 0,0 0.144,0.137 0.222,0.2 0.082,0.07 0.137,0.124 0.262,0.194 0.207,0.114 8.629,3.954 8.629,3.954 0,0 0.098,0.046 0.172,-0.043 0.074,-0.09 0.004,-0.18 0.004,-0.18 -0.055,-0.063 -5.336,-6.82 -4.399,-12.387 0.742,-4.43 4.305,-4.031 9.235,-3.48 1.609,0.187 3.441,0.394 5.336,0.437 5.296,0.035 11,-0.933 14.515,-2.457 2.274,-0.976 3.723,-1.629 4.633,-2.45 0.328,-0.265 0.496,-0.702 0.672,-1.17 l 0.125,-0.31 c 0.148,-0.378 0.363,-1.18 0.457,-1.62 0.039,-0.192 0.086,-0.383 -0.078,-0.505 -0.145,-0.113 -0.485,0.09 -0.485,0.09 -1.543,0.918 -5.402,2.66 -9.011,2.735 -4.477,0.09 -13.555,-4.489 -14.493,-4.973 -0.632,-1.484 -1.257,-2.969 -1.898,-4.445 6.492,4.234 11.879,6.574 16,6.937 4.59,0.403 8.168,-2.074 9.719,-3.105 0.203,-0.137 0.406,-0.285 0.594,-0.43 z M 102.602,27.32 a 6.556,6.556 0 0 1 2.109,-4.593 6.7,6.7 0 0 1 4.781,-1.766 c 3.668,0.129 6.551,3.195 6.426,6.824 a 6.549,6.549 0 0 1 -2.11,4.59 6.644,6.644 0 0 1 -4.785,1.773 c -3.664,-0.132 -6.543,-3.195 -6.421,-6.828 z m 0,0"
|
||||
fill-rule="evenodd"
|
||||
id="path1176" />
|
||||
<path
|
||||
d="m 92.496,57.863 c 0.805,0 1.445,0.645 1.445,1.453 0,0.82 -0.64,1.461 -1.449,1.461 a 1.45,1.45 0 0 1 -1.457,-1.46 c 0,-0.81 0.653,-1.454 1.457,-1.454 z m -0.004,0.227 c -0.648,0 -1.176,0.547 -1.176,1.226 0,0.692 0.528,1.235 1.18,1.235 0.652,0.008 1.176,-0.543 1.176,-1.227 0,-0.687 -0.524,-1.234 -1.176,-1.234 z m -0.277,2.074 h -0.262 v -1.621 c 0.14,-0.02 0.27,-0.04 0.465,-0.04 0.25,0 0.41,0.052 0.508,0.122 0.101,0.07 0.152,0.18 0.152,0.336 0,0.21 -0.144,0.344 -0.32,0.394 v 0.012 c 0.144,0.028 0.242,0.156 0.273,0.395 0.043,0.254 0.078,0.351 0.106,0.402 h -0.274 c -0.039,-0.05 -0.078,-0.2 -0.11,-0.414 -0.042,-0.207 -0.144,-0.285 -0.355,-0.285 h -0.183 z m 0,-0.898 h 0.191 c 0.215,0 0.399,-0.078 0.399,-0.278 0,-0.144 -0.106,-0.285 -0.399,-0.285 -0.086,0 -0.144,0.004 -0.191,0.012 z m 0,0"
|
||||
id="path1178" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="#bbb" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenWrt</title><path d="m12 0c-3.906 0-7.4465 1.5949-10.006 4.1543l1.6953 1.6953c2.1278-2.1278 5.069-3.4395 8.3105-3.4395 3.2416 0 6.1833 1.3122 8.3105 3.4395l1.6953-1.6953c-2.56-2.5594-6.0999-4.1543-10.006-4.1543zm0 4.3203c-2.7091 0-5.1685 1.1138-6.9473 2.8926l1.6953 1.6953c1.346-1.346 3.2079-2.1777 5.252-2.1777 2.0435 0 3.9053 0.83172 5.252 2.1777l1.6953-1.6953c-1.7782-1.7782-4.2381-2.8926-6.9473-2.8926zm-7.7285 3.7559c-1.3295 1.6792-2.1113 3.7906-2.1113 6.084 0 5.4189 4.4216 9.8398 9.8398 9.8398 5.4183 0 9.8398-4.4209 9.8398-9.8398 0-2.2934-0.79833-4.4048-2.1113-6.084l-1.7129 1.7129c0.8974 1.2298 1.4297 2.7427 1.4297 4.3711 0 4.1053-3.34 7.4473-7.4453 7.4473-4.1052 0-7.4473-3.342-7.4473-7.4473 0-1.6284 0.53174-3.1413 1.4297-4.3711l-1.7109-1.7129zm7.7285 0.56641c-1.5128 0-2.8928 0.61633-3.9062 1.6133l1.6953 1.6953c1.2219-1.2195 3.2006-1.2195 4.4219 0l1.6953-1.6953c-1.0354-1.0361-2.4416-1.6176-3.9062-1.6133zm0 3.8887c-0.8997 0-1.6289 0.72911-1.6289 1.6289 0 0.8992 0.72921 1.6289 1.6289 1.6289 0.8998 0 1.6289-0.72971 1.6289-1.6289-0.0019-0.8992-0.72971-1.6277-1.6289-1.6289z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="2500" viewBox="0 0.3 31.4 19.8" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="M9.9 20.1c-5.5 0-9.9-4.4-9.9-9.9S4.4.3 9.9.3h11.6c5.5 0 9.9 4.4 9.9 9.9s-4.4 9.9-9.9 9.9zm11.3-3.5c3.6 0 6.4-2.9 6.4-6.4 0-3.6-2.9-6.4-6.4-6.4h-11c-3.6 0-6.4 2.9-6.4 6.4s2.9 6.4 6.4 6.4z" fill="#c74634"/></svg>
|
||||
|
After Width: | Height: | Size: 312 B |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
id="b6bdd2b4-52ab-488a-9a30-1e6d1d7dd2d4"
|
||||
data-name="Layer 1"
|
||||
viewBox="0 0 192.30001 146"
|
||||
version="1.1"
|
||||
sodipodi:docname="logo.svg"
|
||||
width="192.3"
|
||||
height="146"
|
||||
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
|
||||
<metadata
|
||||
id="metadata15">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1007"
|
||||
id="namedview13"
|
||||
showgrid="false"
|
||||
fit-margin-top="1"
|
||||
fit-margin-right="1"
|
||||
fit-margin-bottom="1"
|
||||
fit-margin-left="1"
|
||||
inkscape:zoom="0.45930043"
|
||||
inkscape:cx="306.89999"
|
||||
inkscape:cy="73"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="b6bdd2b4-52ab-488a-9a30-1e6d1d7dd2d4" />
|
||||
<defs
|
||||
id="defs4">
|
||||
<style
|
||||
id="style2">.a737459c-e8c7-4afa-8008-f6cfd15ccda2{fill:#e00;}</style>
|
||||
</defs>
|
||||
<path
|
||||
class="a737459c-e8c7-4afa-8008-f6cfd15ccda2"
|
||||
d="m 128,84 c 12.5,0 30.6,-2.6 30.6,-17.5 a 19.53,19.53 0 0 0 -0.3,-3.4 L 150.9,30.7 C 149.2,23.6 147.7,20.3 135.2,14.1 125.5,9.1 104.4,1 98.1,1 92.2,1 90.5,8.5 83.6,8.5 76.9,8.5 72,2.9 65.7,2.9 c -6,0 -9.9,4.1 -12.9,12.5 0,0 -8.4,23.7 -9.5,27.2 a 6.15,6.15 0 0 0 -0.2,1.9 C 43,53.7 79.3,83.9 128,84 m 32.5,-11.4 c 1.7,8.2 1.7,9.1 1.7,10.1 0,14 -15.7,21.8 -36.4,21.8 C 79,104.5 38.1,77.1 38.1,59 a 18.35,18.35 0 0 1 1.5,-7.3 C 22.8,52.5 1,55.5 1,74.7 1,106.2 75.6,145 134.6,145 c 45.3,0 56.7,-20.5 56.7,-36.7 0,-12.7 -11,-27.1 -30.8,-35.7"
|
||||
id="path8"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ee0000" />
|
||||
<path
|
||||
d="m 160.5,72.6 c 1.7,8.2 1.7,9.1 1.7,10.1 0,14 -15.7,21.8 -36.4,21.8 C 79,104.5 38.1,77.1 38.1,59 a 18.35,18.35 0 0 1 1.5,-7.3 l 3.7,-9.1 a 6.15,6.15 0 0 0 -0.2,1.9 c 0,9.2 36.3,39.4 84.9,39.4 12.5,0 30.6,-2.6 30.6,-17.5 A 19.53,19.53 0 0 0 158.3,63 Z"
|
||||
id="path10"
|
||||
inkscape:connector-curvature="0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0.28 799.99 799.44" xmlns="http://www.w3.org/2000/svg" width="2500" height="2497"><path d="M777.73 531.82c14.42-41.27 22.26-85.63 22.26-131.82C799.99 179.24 620.9.28 399.99.28S0 179.24 0 400c0 109.23 43.85 208.23 114.91 280.38l405.74-405.47 100.17 100.11zm-73.06 127.19L520.65 475.12 232.49 763.09c50.95 23.51 107.69 36.63 167.5 36.63 122.04 0 231.31-54.61 304.68-140.71z" fill="#10b981"/></svg>
|
||||
|
After Width: | Height: | Size: 411 B |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M36 65.5714C52.3319 65.5714 65.5714 52.3318 65.5714 36C65.5714 19.6681 52.3319 6.42857 36 6.42857C19.6682 6.42857 6.42859 19.6681 6.42859 36C6.42859 52.3318 19.6682 65.5714 36 65.5714Z" fill="#DD4814"/>
|
||||
<path d="M15.8907 39.9501C18.0709 39.9501 19.8383 38.1827 19.8383 36.0025C19.8383 33.8223 18.0709 32.0549 15.8907 32.0549C13.7105 32.0549 11.9431 33.8223 11.9431 36.0025C11.9431 38.1827 13.7105 39.9501 15.8907 39.9501Z" fill="white"/>
|
||||
<path d="M26.4839 50.2677C22.9757 47.9281 20.4507 44.3798 19.39 40.2986C20.0289 39.7788 20.5439 39.1231 20.8976 38.3792C21.2514 37.6354 21.4349 36.8221 21.4349 35.9984C21.4349 35.1747 21.2514 34.3614 20.8976 33.6176C20.5439 32.8737 20.0289 32.218 19.39 31.6982C20.4507 27.617 22.9757 24.0687 26.4839 21.7291L29.3716 26.5634C27.8563 27.6266 26.6194 29.039 25.7654 30.6813C24.9115 32.3236 24.4657 34.1474 24.4657 35.9984C24.4657 37.8494 24.9115 39.6732 25.7654 41.3155C26.6194 42.9578 27.8563 44.3702 29.3716 45.4334L26.4839 50.2677Z" fill="white"/>
|
||||
<path d="M46.0538 22.5352C48.2339 22.5352 50.0013 20.7678 50.0013 18.5877C50.0013 16.4075 48.2339 14.6401 46.0538 14.6401C43.8736 14.6401 42.1062 16.4075 42.1062 18.5877C42.1062 20.7678 43.8736 22.5352 46.0538 22.5352Z" fill="white"/>
|
||||
<path d="M28.399 20.6259C32.1796 18.7583 36.515 18.3462 40.5798 19.4679C40.7109 20.281 41.0214 21.0548 41.4888 21.7329C41.9563 22.411 42.5689 22.9765 43.2821 23.3882C43.9954 23.8 44.7915 24.0478 45.6124 24.1135C46.4334 24.1792 47.2587 24.0612 48.0284 23.7681C51.032 26.7273 52.8425 30.6876 53.1152 34.8952L47.4818 34.9786C47.3182 33.1349 46.7131 31.3576 45.7176 29.797C44.7221 28.2365 43.3656 26.9386 41.7626 26.013C40.1596 25.0874 38.3574 24.5613 36.5082 24.4793C34.659 24.3972 32.8172 24.7616 31.1386 25.5416L28.399 20.6259Z" fill="white"/>
|
||||
<path d="M46.0538 57.365C48.2339 57.365 50.0013 55.5977 50.0013 53.4175C50.0013 51.2373 48.2339 49.4699 46.0538 49.4699C43.8736 49.4699 42.1062 51.2373 42.1062 53.4175C42.1062 55.5977 43.8736 57.365 46.0538 57.365Z" fill="white"/>
|
||||
<path d="M53.1149 37.108C52.8422 41.3156 51.0317 45.2759 48.0281 48.2351C47.2584 47.942 46.4331 47.824 45.6121 47.8897C44.7912 47.9554 43.9951 48.2032 43.2818 48.6149C42.5686 49.0267 41.956 49.5922 41.4885 50.2703C41.0211 50.9484 40.7105 51.7221 40.5795 52.5352C36.5147 53.657 32.1793 53.2448 28.3987 51.3773L31.1424 46.4616C32.8211 47.2416 34.6628 47.606 36.512 47.5239C38.3612 47.4419 40.1635 46.9158 41.7665 45.9902C43.3694 45.0646 44.726 43.7667 45.7215 42.2061C46.7169 40.6456 47.322 38.8683 47.4856 37.0245L53.1149 37.108Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="#bbb" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0m9 5v.01"/><path d="M12 13.5a1.5 1.5 0 0 1 1-1.5a2.6 2.6 0 1 0-3-4"/></g></svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4875 4875"><path fill="#0078d4" d="M0 0h2311v2310H0zm2564 0h2311v2310H2564zM0 2564h2311v2311H0zm2564 0h2311v2311H2564"/></svg>
|
||||
|
After Width: | Height: | Size: 179 B |
@@ -0,0 +1,260 @@
|
||||
import { Megaphone, Moon, Search, Sun, UserCircle } from "lucide-react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api, ApiError, getToken, getTurnstileProof, setTurnstileProof } from "./api";
|
||||
import { NodeCard } from "./components/NodeCard";
|
||||
import { StatsBar } from "./components/StatsBar";
|
||||
import { TurnstileWidget } from "./components/TurnstileWidget";
|
||||
import { demoConfig, demoExchangeRates, demoServers } from "./demo";
|
||||
import type { Config, ExchangeRates, Server } from "./types";
|
||||
import { resolveBackground, themeToggle } from "./theme";
|
||||
import { ui } from "./locale";
|
||||
|
||||
const defaultConfig: Config = { ...demoConfig, site_description: "", site_name: "CF Monitor" };
|
||||
const demoMode = import.meta.env.DEV && new URLSearchParams(window.location.search).has("demo");
|
||||
const NodeDetails = lazy(() => import("./components/NodeDetails").then((module) => ({ default: module.NodeDetails })));
|
||||
|
||||
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 [servers, setServers] = useState<Server[]>([]);
|
||||
const [exchangeRates, setExchangeRates] = useState<ExchangeRates | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
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 [appearance, setAppearance] = useState<"light" | "dark" | null>(() => {
|
||||
const stored = localStorage.getItem("cf-monitor-theme");
|
||||
return stored === "light" || stored === "dark" ? stored : null;
|
||||
});
|
||||
const [systemDark, setSystemDark] = useState(() => matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const wsRef = useRef<WebSocket[]>([]);
|
||||
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 load = useCallback(async (quiet = false) => {
|
||||
if (!quiet) setLoading(true);
|
||||
if (demoMode) {
|
||||
setConfig(demoConfig);
|
||||
setServers(demoServers);
|
||||
setExchangeRates(demoExchangeRates);
|
||||
setError("");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const nextConfig = await api.config();
|
||||
setConfig(nextConfig);
|
||||
if (nextConfig.turnstile_enabled && !getTurnstileProof() && !getToken()) {
|
||||
setNeedsVerification(true);
|
||||
setServers([]);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
const [serverResult, ratesResult] = await Promise.all([api.servers(), api.exchangeRates()]);
|
||||
const localServers = serverResult.servers.map((server) => ({ ...server, source_id: server.id, source_url: "", source_name: nextConfig.site_name }));
|
||||
const remoteResults = await Promise.allSettled((nextConfig.federation_sites || []).map(async (site, index) => {
|
||||
const result = await api.servers(false, site.url);
|
||||
return result.servers.map((server) => ({ ...server, id: `remote-${index}-${server.id}`, source_id: server.id, source_url: site.url.replace(/\/$/, ""), source_name: site.name }));
|
||||
}));
|
||||
const remoteServers = remoteResults.flatMap((result) => result.status === "fulfilled" ? result.value : []);
|
||||
setServers([...localServers, ...remoteServers]);
|
||||
setExchangeRates(ratesResult);
|
||||
setNeedsVerification(false);
|
||||
const failedSites = remoteResults.filter((result) => result.status === "rejected").length;
|
||||
setError(failedSites ? ui(nextConfig.locale, `${failedSites} 个聚合站点暂时不可用`, `${failedSites} federated sites are unavailable`) : "");
|
||||
} catch (reason) {
|
||||
if (reason instanceof ApiError && reason.status === 403) {
|
||||
setTurnstileProof("");
|
||||
setNeedsVerification(true);
|
||||
}
|
||||
setError(reason instanceof Error ? reason.message : ui(config.locale, "无法加载节点状态", "Unable to load server status"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const verifyDashboard = useCallback(async (token: string) => {
|
||||
if (!token || verificationBusy) return;
|
||||
setVerificationBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.verifyTurnstile(token);
|
||||
setTurnstileProof(result.verification);
|
||||
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();
|
||||
const timer = window.setInterval(() => void load(true), 60_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
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 || "zh-CN";
|
||||
}, [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 || "/logo.svg";
|
||||
}, [config.favicon_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(() => {
|
||||
const selected = servers.find((server) => server.id === selectedId);
|
||||
document.title = selected ? `${selected.name} · ${config.site_name}` : config.site_name;
|
||||
}, [config.site_name, selectedId, servers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !config.websocket || !config.public_dashboard || demoMode) return;
|
||||
if (config.turnstile_enabled && !getTurnstileProof()) return;
|
||||
const reconnects: number[] = [];
|
||||
let cancelled = false;
|
||||
const sources = [{ url: "", name: config.site_name }, ...(config.federation_sites || []).map((site) => ({ ...site, url: site.url.replace(/\/$/, "") }))];
|
||||
const connect = (source: typeof sources[number]) => {
|
||||
if (cancelled) return;
|
||||
const endpoint = source.url ? new URL(source.url) : new URL(location.origin);
|
||||
endpoint.protocol = endpoint.protocol === "https:" ? "wss:" : "ws:";
|
||||
endpoint.pathname = "/api/ws";
|
||||
endpoint.search = "";
|
||||
const selectedServer = servers.find((server) => server.id === selectedId && (server.source_url || "") === source.url);
|
||||
if (selectedServer?.source_id) endpoint.searchParams.set("server_id", selectedServer.source_id);
|
||||
if (!source.url) {
|
||||
const proof = getTurnstileProof();
|
||||
if (proof) endpoint.searchParams.set("turnstile_verified", proof);
|
||||
}
|
||||
const socket = new WebSocket(endpoint);
|
||||
wsRef.current.push(socket);
|
||||
socket.onclose = () => {
|
||||
if (!cancelled) reconnects.push(window.setTimeout(() => connect(source), 3000));
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
if (event.data === "pong") return;
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type !== "metrics") return;
|
||||
setServers((current) => current.map((server) => (server.source_url || "") === source.url && (server.source_id || server.id) === message.server_id
|
||||
? { ...server, ...message.metrics, timestamp: message.timestamp }
|
||||
: server));
|
||||
} catch { /* Ignore non-protocol messages. */ }
|
||||
};
|
||||
};
|
||||
sources.forEach(connect);
|
||||
const heartbeat = window.setInterval(() => wsRef.current.forEach((socket) => socket.readyState === WebSocket.OPEN && socket.send("ping")), 30_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(heartbeat);
|
||||
reconnects.forEach(clearTimeout);
|
||||
wsRef.current.forEach((socket) => socket.close());
|
||||
wsRef.current = [];
|
||||
};
|
||||
}, [config.websocket, config.public_dashboard, config.turnstile_enabled, config.site_name, config.federation_sites, loading, needsVerification, selectedId]);
|
||||
|
||||
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;
|
||||
}), [servers, query, group, config.show_groups, config.show_search]);
|
||||
const selected = servers.find((server) => server.id === selectedId) ?? null;
|
||||
|
||||
function goHome() {
|
||||
window.history.pushState({}, "", demoMode ? "/?demo=1" : "/");
|
||||
setSelectedId(null);
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
}
|
||||
|
||||
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 openAdmin() {
|
||||
window.location.assign("/admin");
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const next = dark ? "light" : "dark";
|
||||
localStorage.setItem("cf-monitor-theme", next);
|
||||
setAppearance(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="theme-background" style={background ? { backgroundImage: `url(${JSON.stringify(background)})`, opacity: 1 } : undefined} />
|
||||
<header className="site-header">
|
||||
<div className="container header-inner">
|
||||
<div className="brand"><img src="/logo.svg" 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>
|
||||
</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}
|
||||
{needsVerification ? (
|
||||
<section className="verification-gate">
|
||||
<img src="/logo.svg" alt="" width="52" height="52" />
|
||||
<div><h1>{ui(config.locale, "访问验证", "Access verification")}</h1><p>{config.site_name}</p></div>
|
||||
{config.turnstile_site_key ? <TurnstileWidget siteKey={config.turnstile_site_key} 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} threshold={config.offline_threshold_seconds} retentionDays={config.history_retention_days} locale={config.locale} demo={demoMode} onClose={goHome} /></Suspense>
|
||||
) : loading ? <div className="chart-loading">{ui(config.locale, "正在加载节点", "Loading server")}</div> : <div className="empty-state"><strong>{ui(config.locale, "节点不存在或已隐藏", "Server not found or hidden")}</strong><button className="primary-btn" onClick={goHome}>{ui(config.locale, "返回首页", "Back")}</button></div> : <div className="home-content">
|
||||
<StatsBar servers={servers} config={config} exchangeRates={exchangeRates} />
|
||||
{config.show_search || config.show_groups ? <div className="toolbar">
|
||||
{config.show_search ? <div className="search-box"><Search size={16} /><input aria-label={ui(config.locale, "搜索节点", "Search servers")} placeholder={ui(config.locale, "搜索节点", "Search servers")} value={query} onChange={(event) => setQuery(event.target.value)} /></div> : null}
|
||||
{config.show_groups ? <div className="group-tabs" aria-label={ui(config.locale, "节点分组", "Server groups")}>{groups.map((item) => <button className={group === item ? "active" : ""} key={item} onClick={() => setGroup(item)}>{item === "__all__" ? ui(config.locale, "全部", "All") : item}</button>)}</div> : null}
|
||||
<span className="result-count">{ui(config.locale, `${visible.length} 个节点`, `${visible.length} servers`)}</span>
|
||||
</div> : null}
|
||||
{error ? <div className="error-band"><span>{error}</span><button onClick={() => void load()}>{ui(config.locale, "重试", "Retry")}</button></div> : null}
|
||||
{loading && !servers.length ? <div className="loading-grid">{Array.from({ length: 8 }).map((_, index) => <span key={index} />)}</div> : visible.length ? (
|
||||
<section className="node-grid">{visible.map((server) => <NodeCard key={server.id} server={server} config={config} onOpen={() => openServer(server)} />)}</section>
|
||||
) : !error ? <div className="empty-state"><strong>{servers.length ? ui(config.locale, "没有匹配的节点", "No matching servers") : ui(config.locale, "尚未添加节点", "No servers added")}</strong></div> : null}
|
||||
</div>}
|
||||
</main>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { StrictMode, useEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { api } from "./api";
|
||||
import { AdminPanel } from "./components/AdminPanel";
|
||||
import type { Config } from "./types";
|
||||
import "./styles.css";
|
||||
|
||||
const THEME_KEY = "cf-monitor-admin-theme";
|
||||
|
||||
function initialAppearance(): "light" | "dark" | null {
|
||||
const stored = localStorage.getItem(THEME_KEY);
|
||||
return stored === "light" || stored === "dark" ? stored : null;
|
||||
}
|
||||
|
||||
function AdminApp() {
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [appearance, setAppearance] = useState<"light" | "dark" | null>(initialAppearance);
|
||||
const [systemDark, setSystemDark] = useState(() => matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const dark = appearance ? appearance === "dark" : config?.default_theme === "system" || !config ? systemDark : config.default_theme === "dark";
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const next = await api.config();
|
||||
setConfig(next);
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "无法加载管理面板");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void loadConfig(); }, []);
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
document.documentElement.style.colorScheme = dark ? "dark" : "light";
|
||||
}, [dark]);
|
||||
useEffect(() => {
|
||||
const media = matchMedia("(prefers-color-scheme: dark)");
|
||||
const update = () => setSystemDark(media.matches);
|
||||
media.addEventListener("change", update);
|
||||
return () => media.removeEventListener("change", update);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
document.title = config ? `管理面板 · ${config.site_name}` : "管理面板";
|
||||
}, [config]);
|
||||
|
||||
if (error) return <div className={`admin-loading ${dark ? "admin-dark" : ""}`}><span>{error}</span><button className="secondary-btn" onClick={() => void loadConfig()}>重试</button></div>;
|
||||
if (!config) return <div className={`admin-loading ${dark ? "admin-dark" : ""}`}>正在加载管理面板</div>;
|
||||
return <AdminPanel config={config} dark={dark} onToggleTheme={() => {
|
||||
const next = dark ? "light" : "dark";
|
||||
localStorage.setItem(THEME_KEY, next);
|
||||
setAppearance(next);
|
||||
}} onClose={() => window.location.assign("/")} onChanged={() => void loadConfig()} />;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><AdminApp /></StrictMode>);
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { AlertRule, AlertRuleInput, CloudflareUsage, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, Server, ServerInput, Settings, ThemeSettingsSchema, ThemeStore } from "./types";
|
||||
|
||||
const TOKEN_KEY = "cf-monitor-admin-token";
|
||||
const TURNSTILE_KEY = "cf-monitor-turnstile-verified";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message: string, public status: number) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return sessionStorage.getItem(TOKEN_KEY) ?? "";
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
if (token) sessionStorage.setItem(TOKEN_KEY, token);
|
||||
else sessionStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function getTurnstileProof() {
|
||||
return localStorage.getItem(TURNSTILE_KEY) ?? "";
|
||||
}
|
||||
|
||||
export function setTurnstileProof(value: string) {
|
||||
if (value) localStorage.setItem(TURNSTILE_KEY, value);
|
||||
else localStorage.removeItem(TURNSTILE_KEY);
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}, admin = false, baseUrl = ""): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (init.body) headers.set("Content-Type", "application/json");
|
||||
if (!baseUrl && getToken()) headers.set("Authorization", `Bearer ${getToken()}`);
|
||||
if (!baseUrl && getTurnstileProof()) headers.set("X-Turnstile-Verified", getTurnstileProof());
|
||||
const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, { ...init, headers });
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({ error: response.statusText }));
|
||||
if (response.status === 401 && admin) setToken("");
|
||||
throw new ApiError(payload.error ?? "请求失败", response.status);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
config: (baseUrl = "") => request<Config>("/api/config", {}, false, baseUrl),
|
||||
exchangeRates: (baseUrl = "") => request<ExchangeRates>("/api/exchange-rates", {}, false, baseUrl),
|
||||
refreshExchangeRates: () => request<ExchangeRates>("/api/admin/exchange-rates/refresh", { method: "POST" }, true),
|
||||
servers: (admin = false, baseUrl = "") =>
|
||||
request<{ servers: Server[]; server_time?: number }>(admin ? "/api/admin/servers" : "/api/servers", {}, admin, baseUrl),
|
||||
server: (id: string, baseUrl = "") => request<Server>(`/api/servers/${encodeURIComponent(id)}`, {}, false, baseUrl),
|
||||
history: (id: string, hours: number, baseUrl = "") =>
|
||||
request<{ points: HistoryPoint[] }>(`/api/history/${encodeURIComponent(id)}?hours=${hours}`, {}, false, baseUrl),
|
||||
latencyHistory: (id: string, hours: number, baseUrl = "") =>
|
||||
request<{ tasks: LatencyTestPoint[]; points: LatencySample[] }>(`/api/latency/${encodeURIComponent(id)}?hours=${hours}`, {}, false, baseUrl),
|
||||
verifyTurnstile: (token: string) =>
|
||||
request<{ verification: string }>("/api/turnstile/verify", { method: "POST", body: JSON.stringify({ token }) }),
|
||||
login: (username: string, password: string, turnstileToken: string) =>
|
||||
request<{ token: string }>("/api/admin/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password, turnstile_token: turnstileToken }),
|
||||
}),
|
||||
settings: () => request<Settings>("/api/admin/settings", {}, true),
|
||||
latencyTasks: () => request<{ tasks: LatencyTask[] }>("/api/admin/latency-tasks", {}, true),
|
||||
createLatencyTask: (input: LatencyTaskInput) =>
|
||||
request<{ id: string }>("/api/admin/latency-tasks", { method: "POST", body: JSON.stringify(input) }, true),
|
||||
updateLatencyTask: (id: string, input: LatencyTaskInput) =>
|
||||
request<{ success: boolean }>(`/api/admin/latency-tasks/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(input) }, true),
|
||||
deleteLatencyTask: (id: string) =>
|
||||
request<{ success: boolean }>(`/api/admin/latency-tasks/${encodeURIComponent(id)}`, { method: "DELETE" }, true),
|
||||
alertRules: () => request<{ rules: AlertRule[] }>("/api/admin/alert-rules", {}, true),
|
||||
createAlertRule: (input: AlertRuleInput) =>
|
||||
request<{ id: string }>("/api/admin/alert-rules", { method: "POST", body: JSON.stringify(input) }, true),
|
||||
updateAlertRule: (id: string, input: AlertRuleInput) =>
|
||||
request<{ success: boolean }>(`/api/admin/alert-rules/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(input) }, true),
|
||||
deleteAlertRule: (id: string) =>
|
||||
request<{ success: boolean }>(`/api/admin/alert-rules/${encodeURIComponent(id)}`, { method: "DELETE" }, true),
|
||||
themes: () => request<ThemeStore>("/api/themes"),
|
||||
themeSettings: () => request<ThemeSettingsSchema>("/api/admin/theme-settings", {}, true),
|
||||
previewTheme: (themeUrl: string) =>
|
||||
request<{ theme_url: string; proof: string }>("/api/admin/themes/preview", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ theme_url: themeUrl }),
|
||||
}, true),
|
||||
saveSettings: (input: Partial<Settings>) =>
|
||||
request<{ settings: Settings; token: string | null }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(input) }, true),
|
||||
createServer: (input: ServerInput) =>
|
||||
request<{ id: string; agent_token: string }>(
|
||||
"/api/admin/servers",
|
||||
{ method: "POST", body: JSON.stringify(input) },
|
||||
true,
|
||||
),
|
||||
updateServer: (id: string, input: ServerInput) =>
|
||||
request<{ success: boolean }>(
|
||||
`/api/admin/servers/${encodeURIComponent(id)}`,
|
||||
{ method: "PATCH", body: JSON.stringify(input) },
|
||||
true,
|
||||
),
|
||||
deleteServer: (id: string) =>
|
||||
request<{ success: boolean }>(
|
||||
`/api/admin/servers/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
true,
|
||||
),
|
||||
deleteServers: (ids: string[]) =>
|
||||
request<{ success: boolean }>("/api/admin/servers", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ ids }),
|
||||
}, true),
|
||||
rotateToken: (id: string) =>
|
||||
request<{ agent_token: string }>(
|
||||
`/api/admin/servers/${encodeURIComponent(id)}/token`,
|
||||
{ method: "POST" },
|
||||
true,
|
||||
),
|
||||
reorderServers: (ids: string[]) =>
|
||||
request<{ success: boolean }>(
|
||||
"/api/admin/servers/order",
|
||||
{ method: "PATCH", body: JSON.stringify({ ids }) },
|
||||
true,
|
||||
),
|
||||
databaseStats: () => request<DatabaseStats>("/api/admin/database", {}, true),
|
||||
cloudflareUsage: () => request<CloudflareUsage>("/api/admin/cloudflare-usage", {}, true),
|
||||
clearHistory: () => request<{ success: boolean }>("/api/admin/history", { method: "DELETE" }, true),
|
||||
testNotification: () => request<{ success: boolean }>("/api/admin/notifications/test", { method: "POST" }, true),
|
||||
};
|
||||
@@ -0,0 +1,662 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Cloud,
|
||||
Coins,
|
||||
Copy,
|
||||
Database,
|
||||
Eye,
|
||||
GripVertical,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
Moon,
|
||||
Palette,
|
||||
Pencil,
|
||||
Plus,
|
||||
RadioTower,
|
||||
RotateCw,
|
||||
Save,
|
||||
ServerCog,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Sun,
|
||||
Trash2,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import { DragEvent, FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { api, ApiError, getToken, setToken } from "../api";
|
||||
import { formatBytes, isOnline } from "../format";
|
||||
import { ASSET_CURRENCIES, type AgentInstallTarget, type CloudflareUsage, type Config, type DatabaseStats, type ExchangeRates, type FederationSite, type Server, type ServerInput, type Settings, type ThirdPartyTheme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue, type ThemeStore } from "../types";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { TurnstileWidget } from "./TurnstileWidget";
|
||||
import { LatencyManager } from "./LatencyManager";
|
||||
import { AlertRuleManager } from "./AlertRuleManager";
|
||||
|
||||
type AdminTab = "servers" | "latency" | "appearance" | "themes" | "themeSettings" | "alerts" | "security" | "data";
|
||||
type AgentPlatform = "linux" | "windows" | "macos";
|
||||
|
||||
const adminPages: Record<AdminTab, { title: string; description: string }> = {
|
||||
servers: { title: "服务器", description: "管理监控节点、Agent 密钥和运行参数" },
|
||||
latency: { title: "延迟监测", description: "配置分配给各服务器的 TCP 与 ICMP 延迟任务" },
|
||||
appearance: { title: "站点设置", description: "调整站点信息、公开内容和前台显示项目" },
|
||||
themes: { title: "主题管理", description: "选择内置或第三方前端主题" },
|
||||
themeSettings: { title: "主题设置", description: "调整当前前端主题提供的显示选项" },
|
||||
alerts: { title: "通知", description: "配置 Telegram 通知和资源告警阈值" },
|
||||
security: { title: "登录与安全", description: "管理管理员账号和 Cloudflare Turnstile 防护" },
|
||||
data: { title: "监控数据库", description: "查看 D1 用量、汇率状态并维护历史数据" },
|
||||
};
|
||||
|
||||
|
||||
const emptyServer: ServerInput = {
|
||||
name: "",
|
||||
region: "",
|
||||
group_name: "默认",
|
||||
tags: "",
|
||||
note: "",
|
||||
hidden: false,
|
||||
expires_at: null,
|
||||
traffic_limit: 0,
|
||||
traffic_limit_type: "sum",
|
||||
price: -2,
|
||||
billing_cycle: 30,
|
||||
currency: "CNY",
|
||||
auto_renewal: false,
|
||||
public_remark: "",
|
||||
network_interface: "",
|
||||
reset_day: 1,
|
||||
report_interval: 60,
|
||||
collect_interval: 5,
|
||||
rx_correction: 0,
|
||||
tx_correction: 0,
|
||||
offline_notify_disabled: false,
|
||||
auto_update: false,
|
||||
};
|
||||
|
||||
function toInput(server: Server): ServerInput {
|
||||
return {
|
||||
...emptyServer,
|
||||
name: server.name,
|
||||
region: server.region,
|
||||
group_name: server.group_name,
|
||||
tags: server.tags,
|
||||
note: server.note,
|
||||
hidden: !!server.hidden,
|
||||
expires_at: server.expires_at,
|
||||
traffic_limit: server.traffic_limit,
|
||||
traffic_limit_type: server.traffic_limit_type || "sum",
|
||||
price: server.price,
|
||||
billing_cycle: server.billing_cycle,
|
||||
currency: server.currency,
|
||||
auto_renewal: !!server.auto_renewal,
|
||||
public_remark: server.public_remark,
|
||||
network_interface: server.network_interface || "",
|
||||
reset_day: server.reset_day || 1,
|
||||
report_interval: server.report_interval || 60,
|
||||
collect_interval: server.collect_interval || 5,
|
||||
rx_correction: server.rx_correction || 0,
|
||||
tx_correction: server.tx_correction || 0,
|
||||
offline_notify_disabled: !!server.offline_notify_disabled,
|
||||
auto_update: !!server.auto_update,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(value: number | null) {
|
||||
return value ? new Date(value * 1000).toISOString().slice(0, 10) : "";
|
||||
}
|
||||
|
||||
function settingPatch(settings: Settings, key: keyof Settings, value: unknown) {
|
||||
return { ...settings, [key]: value } as Settings;
|
||||
}
|
||||
|
||||
function shellLiteral(value: string) {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function powershellLiteral(value: string) {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
function federationText(sites: FederationSite[]) {
|
||||
return sites.map((site) => `${site.name}|${site.url}`).join("\n");
|
||||
}
|
||||
|
||||
function parseFederationSites(value: string): FederationSite[] {
|
||||
return value.split("\n").map((line) => line.trim()).filter(Boolean).flatMap((line) => {
|
||||
const separator = line.indexOf("|");
|
||||
if (separator < 1) return [];
|
||||
return [{ name: line.slice(0, separator).trim(), url: line.slice(separator + 1).trim() }];
|
||||
});
|
||||
}
|
||||
|
||||
export function AdminPanel({
|
||||
config,
|
||||
dark,
|
||||
onToggleTheme,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
config: Config;
|
||||
dark: boolean;
|
||||
onToggleTheme: () => void;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [authenticated, setAuthenticated] = useState(!!getToken());
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [password, setPassword] = useState("");
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
const [turnstileReset, setTurnstileReset] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [tab, setTab] = useState<AdminTab>("servers");
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [draggingId, setDraggingId] = useState("");
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [database, setDatabase] = useState<DatabaseStats | null>(null);
|
||||
const [cloudflareUsage, setCloudflareUsage] = useState<CloudflareUsage | null>(null);
|
||||
const [exchangeRates, setExchangeRates] = useState<ExchangeRates | null>(null);
|
||||
const [themeStore, setThemeStore] = useState<ThemeStore | null>(null);
|
||||
const [themeSettingsSchema, setThemeSettingsSchema] = useState<ThemeSettingsSchema | null>(null);
|
||||
const [themeVersions, setThemeVersions] = useState<Record<string, string>>({});
|
||||
const [customThemeUrl, setCustomThemeUrl] = useState("");
|
||||
const [editing, setEditing] = useState<Server | "new" | null>(null);
|
||||
const [form, setForm] = useState<ServerInput>(emptyServer);
|
||||
const [install, setInstall] = useState<AgentInstallTarget[]>([]);
|
||||
const [installPlatform, setInstallPlatform] = useState<AgentPlatform>("linux");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!getToken()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const [serverResult, settingsResult] = await Promise.all([api.servers(true), api.settings()]);
|
||||
setServers(serverResult.servers);
|
||||
setSettings({
|
||||
...settingsResult,
|
||||
site_announcement: settingsResult.site_announcement || "",
|
||||
theme_options: settingsResult.theme_options || {},
|
||||
cloudflare_account_id: settingsResult.cloudflare_account_id || "",
|
||||
cloudflare_api_token: settingsResult.cloudflare_api_token || "",
|
||||
favicon_url: settingsResult.favicon_url || "",
|
||||
locale: settingsResult.locale || "zh-CN",
|
||||
federation_sites: settingsResult.federation_sites || [],
|
||||
cors_allowed_origins: settingsResult.cors_allowed_origins || "",
|
||||
csp_asset_origins: settingsResult.csp_asset_origins || "",
|
||||
});
|
||||
setAuthenticated(true);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "加载失败");
|
||||
if (reason instanceof ApiError && reason.status === 401) {
|
||||
setToken("");
|
||||
setAuthenticated(false);
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const timer = window.setTimeout(() => setNotice(""), 3200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [notice]);
|
||||
|
||||
async function login(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.login(username.trim(), password, turnstileToken);
|
||||
setToken(result.token);
|
||||
setPassword("");
|
||||
setTurnstileToken("");
|
||||
setAuthenticated(true);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "登录失败");
|
||||
setTurnstileToken("");
|
||||
setTurnstileReset((value) => value + 1);
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function openEditor(server?: Server) {
|
||||
setEditing(server ?? "new");
|
||||
setForm(server ? toInput(server) : { ...emptyServer });
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function saveServer(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
const result = await api.createServer(form);
|
||||
setInstall([{ id: result.id, name: form.name, agent_token: result.agent_token }]);
|
||||
} else if (editing) {
|
||||
await api.updateServer(editing.id, form);
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
onChanged();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "保存节点失败");
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function removeSelected() {
|
||||
if (!selectedIds.length || !window.confirm(`确认删除选中的 ${selectedIds.length} 个节点及其全部历史数据?`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.deleteServers(selectedIds);
|
||||
setSelectedIds([]);
|
||||
await load();
|
||||
onChanged();
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "批量删除失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function remove(server: Server) {
|
||||
if (!window.confirm(`确认删除“${server.name}”及其全部历史数据?`)) return;
|
||||
try { await api.deleteServer(server.id); await load(); onChanged(); }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : "删除失败"); }
|
||||
}
|
||||
|
||||
async function rotate(server: Server) {
|
||||
if (!window.confirm(`重置“${server.name}”的 Agent 密钥?旧 Agent 将立即失效。`)) return;
|
||||
try {
|
||||
const result = await api.rotateToken(server.id);
|
||||
setInstall([{ id: server.id, name: server.name, agent_token: result.agent_token }]);
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "重置失败"); }
|
||||
}
|
||||
|
||||
async function move(index: number, offset: number) {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= servers.length) return;
|
||||
const next = [...servers];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
setServers(next);
|
||||
try { await api.reorderServers(next.map((server) => server.id)); onChanged(); }
|
||||
catch (reason) { setServers(servers); setError(reason instanceof Error ? reason.message : "排序失败"); }
|
||||
}
|
||||
|
||||
function startDrag(event: DragEvent<HTMLButtonElement>, id: string) {
|
||||
setDraggingId(id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", id);
|
||||
}
|
||||
|
||||
async function dropServer(event: DragEvent<HTMLDivElement>, targetId: string) {
|
||||
event.preventDefault();
|
||||
const sourceId = draggingId || event.dataTransfer.getData("text/plain");
|
||||
setDraggingId("");
|
||||
if (!sourceId || sourceId === targetId) return;
|
||||
const sourceIndex = servers.findIndex((server) => server.id === sourceId);
|
||||
const targetIndex = servers.findIndex((server) => server.id === targetId);
|
||||
if (sourceIndex < 0 || targetIndex < 0) return;
|
||||
const previous = [...servers];
|
||||
const next = [...servers];
|
||||
const [moved] = next.splice(sourceIndex, 1);
|
||||
next.splice(targetIndex, 0, moved);
|
||||
setServers(next);
|
||||
try { await api.reorderServers(next.map((server) => server.id)); onChanged(); }
|
||||
catch (reason) { setServers(previous); setError(reason instanceof Error ? reason.message : "排序失败"); }
|
||||
}
|
||||
|
||||
async function saveSite(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!settings) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
const payload = { ...settings };
|
||||
if (!payload.new_password) delete payload.new_password;
|
||||
try {
|
||||
const result = await api.saveSettings(payload);
|
||||
if (result.token) setToken(result.token);
|
||||
setSettings(result.settings);
|
||||
setNotice("设置已保存");
|
||||
onChanged();
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "保存设置失败"); }
|
||||
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 {
|
||||
const [stats, rates] = await Promise.all([api.databaseStats(), api.exchangeRates()]);
|
||||
setDatabase(stats);
|
||||
setExchangeRates(rates);
|
||||
}
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : "读取数据库统计失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function refreshExchangeRates() {
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
const rates = await api.refreshExchangeRates();
|
||||
setExchangeRates(rates);
|
||||
setNotice(`汇率已更新 · ${rates.source} · ${rates.date}`);
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "汇率更新失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function loadCloudflareUsage() {
|
||||
setBusy(true); setError("");
|
||||
try { setCloudflareUsage(await api.cloudflareUsage()); }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : "读取 Cloudflare 用量失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function loadThemes() {
|
||||
if (themeStore) return;
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
const result = await api.themes();
|
||||
const themes = Array.isArray(result.themes) ? result.themes : [];
|
||||
setThemeStore({ ...result, themes });
|
||||
setThemeVersions(Object.fromEntries(themes.flatMap((theme) => {
|
||||
const version = theme.versions?.[0];
|
||||
const reference = theme.branch || version?.commitid || version?.commitId || version?.theme_url || "";
|
||||
return reference ? [[theme.id, reference]] : [];
|
||||
})));
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "读取主题商店失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function loadThemeSettings() {
|
||||
setBusy(true); setError("");
|
||||
try { setThemeSettingsSchema(await api.themeSettings()); }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : "读取主题设置失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function themeUrl(theme: ThirdPartyTheme) {
|
||||
const selected = themeVersions[theme.id];
|
||||
if (selected?.startsWith("https://github.com/")) return selected;
|
||||
const first = theme.versions?.[0];
|
||||
const reference = selected || first?.commitid || first?.commitId || theme.branch;
|
||||
return reference ? `${theme.url.replace(/\/$/, "")}/tree/${reference}` : first?.theme_url || "";
|
||||
}
|
||||
|
||||
async function previewTheme(themeUrl: string) {
|
||||
if (!themeUrl.trim()) return;
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
const result = await api.previewTheme(themeUrl.trim());
|
||||
const query = new URLSearchParams({ theme_url: result.theme_url, theme_proof: result.proof });
|
||||
window.open(`${window.location.origin}/?${query}`, "_blank", "noopener,noreferrer");
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "主题预览失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function applyTheme(themeUrl: string) {
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
const result = await api.saveSettings({ theme_url: themeUrl.trim(), theme_options: {} });
|
||||
if (result.token) setToken(result.token);
|
||||
setSettings(result.settings);
|
||||
setThemeSettingsSchema(null);
|
||||
setNotice(themeUrl ? "第三方主题已应用" : "已恢复内置主题");
|
||||
onChanged();
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "应用主题失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function clearHistory() {
|
||||
if (!window.confirm("确认清空全部历史指标?节点配置和最新状态不会删除。")) return;
|
||||
setBusy(true); setError("");
|
||||
try { await api.clearHistory(); setNotice("历史指标已清空"); await loadDatabase(); }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : "清理历史失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function logout() {
|
||||
setToken(""); setAuthenticated(false); setServers([]); setSettings(null); setSelectedIds([]);
|
||||
}
|
||||
|
||||
const commands = useMemo(() => install.map((server) => {
|
||||
const origin = window.location.origin;
|
||||
if (installPlatform === "windows") {
|
||||
return {
|
||||
...server,
|
||||
command: `Invoke-WebRequest -Uri "${origin}/agent.ps1" -OutFile "$env:TEMP\\cf-monitor-install.ps1"\n& "$env:TEMP\\cf-monitor-install.ps1" install -ServerId ${powershellLiteral(server.id)} -Token ${powershellLiteral(server.agent_token)} -Url ${powershellLiteral(origin)}`,
|
||||
};
|
||||
}
|
||||
if (installPlatform === "macos") {
|
||||
return {
|
||||
...server,
|
||||
command: `curl -fsSL ${origin}/agent-macos.sh | sudo sh -s -- install --server-id ${shellLiteral(server.id)} --token ${shellLiteral(server.agent_token)} --url ${shellLiteral(origin)}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...server,
|
||||
command: `curl -fsSL ${origin}/agent.sh | sudo bash -s -- install --server-id ${server.id} --token ${server.agent_token} --url ${origin}`,
|
||||
};
|
||||
}), [install, installPlatform]);
|
||||
|
||||
const toggleSelected = (id: string) => setSelectedIds((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]);
|
||||
const allSelected = servers.length > 0 && selectedIds.length === servers.length;
|
||||
const updateForm = <K extends keyof ServerInput>(key: K, value: ServerInput[K]) => setForm((current) => ({ ...current, [key]: value }));
|
||||
const updateSettings = <K extends keyof Settings>(key: K, value: Settings[K]) => setSettings((current) => current ? settingPatch(current, key, value) : current);
|
||||
const updateThemeOption = (key: string, value: ThemeSettingValue) => setSettings((current) => current ? {
|
||||
...current,
|
||||
theme_options: { ...current.theme_options, [key]: value },
|
||||
} : current);
|
||||
const selectTab = (next: AdminTab) => {
|
||||
setTab(next);
|
||||
setNotice("");
|
||||
setError("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`admin-page ${dark ? "admin-dark" : ""}`}>
|
||||
{!authenticated ? <div className="admin-login-stage">
|
||||
<button className="admin-back" type="button" onClick={onClose}><ArrowLeft size={14} />返回仪表盘</button>
|
||||
<button className="admin-login-theme" type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={16} /> : <Moon size={16} />}</button>
|
||||
<form className="login-form glass-panel" onSubmit={login}>
|
||||
<img src="/logo.svg" alt="" width="48" height="48" />
|
||||
<div className="login-copy"><h1>管理员登录</h1><p>{config.site_name} 控制台</p></div>
|
||||
<label><span>用户名</span><input autoFocus type="text" autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required /></label>
|
||||
<label><span>密码</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
|
||||
{config.turnstile_login_enabled || config.turnstile_enabled ? <div className="login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
|
||||
{error ? <p className="form-error login-error">{error}</p> : null}
|
||||
<button className="primary-btn login-submit" disabled={busy || ((config.turnstile_login_enabled || config.turnstile_enabled) && !turnstileToken)} type="submit"><KeyRound size={16} />{busy ? "验证中" : "登录"}</button>
|
||||
</form>
|
||||
</div> : <section className="admin-shell" aria-label="管理面板">
|
||||
<header className="admin-topbar">
|
||||
<div className="admin-brand">
|
||||
<img src="/logo.svg" alt="" width="36" height="36" />
|
||||
<strong>{config.site_name}</strong>
|
||||
</div>
|
||||
<div className="admin-topbar-actions">
|
||||
<button type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button>
|
||||
<button type="button" onClick={onClose}><ArrowLeft size={17} />返回前台</button>
|
||||
<button type="button" onClick={logout}><LogOut size={17} />退出登录</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="admin-body">
|
||||
<aside className="admin-sidebar">
|
||||
<span className="admin-nav-label">管理</span>
|
||||
<nav className="admin-tabs">
|
||||
<button className={tab === "servers" ? "active" : ""} onClick={() => selectTab("servers")}><ServerCog size={19} />服务器</button>
|
||||
<button className={tab === "latency" ? "active" : ""} onClick={() => selectTab("latency")}><RadioTower size={19} />延迟监测</button>
|
||||
<button className={tab === "appearance" ? "active" : ""} onClick={() => selectTab("appearance")}><Eye size={19} />站点设置</button>
|
||||
<button className={tab === "themes" ? "active" : ""} onClick={() => { selectTab("themes"); void loadThemes(); }}><Palette size={19} />主题管理</button>
|
||||
<button className={tab === "themeSettings" ? "active" : ""} onClick={() => { selectTab("themeSettings"); void loadThemeSettings(); }}><SlidersHorizontal size={19} />主题设置</button>
|
||||
<button className={tab === "alerts" ? "active" : ""} onClick={() => selectTab("alerts")}><AlertTriangle size={19} />通知</button>
|
||||
<button className={tab === "security" ? "active" : ""} onClick={() => selectTab("security")}><ShieldCheck size={19} />登录与安全</button>
|
||||
<button className={tab === "data" ? "active" : ""} onClick={() => { selectTab("data"); if (!database) void loadDatabase(); }}><Database size={19} />监控数据库</button>
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="admin-content">
|
||||
<header className="admin-content-header"><h1>{adminPages[tab].title}</h1><p>{adminPages[tab].description}</p></header>
|
||||
{error ? <p className="form-error admin-error">{error}</p> : null}
|
||||
{notice ? <p className="form-notice">{notice}</p> : null}
|
||||
{tab === "servers" ? (
|
||||
<div className="admin-section">
|
||||
<div className="section-head"><div><h3>监控节点</h3><span>{servers.length} 个节点 · 可拖动上下排序</span></div><div className="section-actions"><button className="primary-btn compact" onClick={() => openEditor()}><Plus size={16} />添加</button></div></div>
|
||||
<div className="batch-toolbar"><label className="select-all"><Checkbox checked={allSelected} onChange={() => setSelectedIds(allSelected ? [] : servers.map((server) => server.id))} />全选</label>{selectedIds.length ? <button className="danger-btn compact" onClick={() => void removeSelected()}><Trash2 size={14} />删除选中 ({selectedIds.length})</button> : <span>批量操作</span>}</div>
|
||||
<div className="server-list">
|
||||
{servers.map((server, index) => (
|
||||
<div className={`server-row ${draggingId === server.id ? "dragging" : ""}`} key={server.id} onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={(event) => void dropServer(event, server.id)}>
|
||||
<button type="button" className="drag-handle" draggable onDragStart={(event) => startDrag(event, server.id)} onDragEnd={() => setDraggingId("")} title={`拖动排序:${server.name}`}><GripVertical size={15} /></button>
|
||||
<Checkbox checked={selectedIds.includes(server.id)} onChange={() => toggleSelected(server.id)} ariaLabel={`选择 ${server.name}`} />
|
||||
<span className={`status-dot ${settings && isOnline(server, settings.offline_threshold_seconds) ? "online" : ""}`} />
|
||||
<div className="server-name"><strong>{server.name}</strong><small>{server.group_name} · {server.region || "未设置地区"} · {server.report_interval}s</small></div>
|
||||
<span className="server-usage">{server.traffic_limit > 0 ? formatBytes(server.traffic_limit) : "不限流量"}</span>
|
||||
<div className="row-actions"><button className="icon-btn" disabled={index === 0} onClick={() => void move(index, -1)} title="上移"><ChevronUp size={15} /></button><button className="icon-btn" disabled={index === servers.length - 1} onClick={() => void move(index, 1)} title="下移"><ChevronDown size={15} /></button><button className="icon-btn" onClick={() => openEditor(server)} title="编辑节点"><Pencil size={15} /></button><button className="icon-btn" onClick={() => void rotate(server)} title="重置 Agent 密钥"><RotateCw size={15} /></button><button className="icon-btn danger" onClick={() => void remove(server)} title="删除节点"><Trash2 size={15} /></button></div>
|
||||
</div>
|
||||
))}
|
||||
{!servers.length && !busy ? <div className="list-empty">暂无节点</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : tab === "latency" ? (
|
||||
<LatencyManager servers={servers} onError={setError} onNotice={setNotice} />
|
||||
) : tab === "themes" && settings ? (
|
||||
<div className="admin-section theme-section">
|
||||
<div className="section-head"><div><h3>第三方主题</h3><span>{settings.theme_url ? "已启用" : "当前使用内置主题"}</span></div>{settings.theme_url ? <button className="secondary-btn compact" disabled={busy} onClick={() => void applyTheme("")}><Undo2 size={14} />恢复内置</button> : null}</div>
|
||||
<div className="theme-grid">
|
||||
{themeStore?.themes.map((theme) => {
|
||||
const url = themeUrl(theme);
|
||||
const description = typeof theme.description === "string" ? theme.description : theme.description?.["zh-CN"] || theme.description?.en || "";
|
||||
return <article className={`theme-card ${settings.theme_url === url ? "active" : ""}`} key={theme.id}>
|
||||
<img src={theme.cover} alt={`${theme.title} 预览`} loading="lazy" />
|
||||
<div className="theme-card-body"><div className="theme-card-title"><div><strong>{theme.title}</strong><span>{theme.author || "第三方作者"}</span></div>{settings.theme_url === url ? <b>使用中</b> : null}</div><p>{description}</p>
|
||||
{theme.versions?.length ? <label><span>版本</span><select value={themeVersions[theme.id] || theme.branch || theme.versions[0].commitid || theme.versions[0].commitId || theme.versions[0].theme_url || ""} onChange={(event) => setThemeVersions((current) => ({ ...current, [theme.id]: event.target.value }))}>{theme.branch ? <option value={theme.branch}>最新 · {theme.branch}</option> : null}{theme.versions.map((version) => { const value = version.commitid || version.commitId || version.theme_url || ""; return <option value={value} key={value}>{version.version || version.short_version || value.slice(0, 7)}{version.releaseDate ? ` · ${version.releaseDate}` : ""}</option>; })}</select></label> : <span className="theme-branch">{theme.branch || "main"}</span>}
|
||||
<div className="theme-actions"><button className="secondary-btn compact" disabled={busy || !url} onClick={() => void previewTheme(url)}><Eye size={14} />预览</button><button className="primary-btn compact" disabled={busy || !url || settings.theme_url === url} onClick={() => void applyTheme(url)}><Palette size={14} />应用</button></div>
|
||||
</div>
|
||||
</article>;
|
||||
})}
|
||||
{themeStore && !themeStore.themes.length ? <div className="theme-empty">主题商店暂时不可用</div> : null}
|
||||
{!themeStore ? <div className="theme-empty">正在读取主题商店</div> : null}
|
||||
</div>
|
||||
<div className="custom-theme"><div className="section-title"><Settings2 size={15} />自定义主题</div><div className="custom-theme-controls"><input type="url" value={customThemeUrl} onChange={(event) => setCustomThemeUrl(event.target.value)} placeholder="https://github.com/owner/repo/tree/build" /><button className="secondary-btn" disabled={busy || !customThemeUrl.trim()} onClick={() => void previewTheme(customThemeUrl)}><Eye size={15} />预览</button><button className="primary-btn" disabled={busy || !customThemeUrl.trim()} onClick={() => void applyTheme(customThemeUrl)}><Palette size={15} />应用</button></div></div>
|
||||
</div>
|
||||
) : settings ? (
|
||||
<form className="settings-form" onSubmit={saveSite}>
|
||||
{tab === "appearance" ? <>
|
||||
<div className="section-title"><Eye size={15} />外观与展示</div>
|
||||
<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"><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>站点图标地址</span><input type="url" 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>
|
||||
<label><span>聚合站点</span><textarea rows={4} value={federationText(settings.federation_sites)} onChange={(event) => updateSettings("federation_sites", parseFederationSites(event.target.value))} placeholder={'香港站|https://hk.example.com\n欧洲站|https://eu.example.com'} /></label>
|
||||
<p className="settings-hint">每行“名称|HTTPS 地址”。远端站点需在 CORS 允许来源中加入本站地址。</p>
|
||||
<div className="settings-toggles"><Toggle label="公开仪表盘" checked={settings.public_dashboard} onChange={(value) => updateSettings("public_dashboard", value)} /></div>
|
||||
</> : null}
|
||||
|
||||
{tab === "themeSettings" ? <>
|
||||
<div className="section-title"><SlidersHorizontal size={15} />通用主题设置</div>
|
||||
<div className="form-grid"><label><span>默认主题</span><select value={settings.default_theme} onChange={(event) => updateSettings("default_theme", event.target.value as Settings["default_theme"])}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></label><label><span>背景图地址</span><input type="text" value={settings.background_url} onChange={(event) => updateSettings("background_url", event.target.value)} /></label></div>
|
||||
<p className="settings-hint">背景图可用 | 分隔浅色和深色地址,例如 light.jpg|dark.jpg。</p>
|
||||
{themeSettingsSchema?.settings.length ? <><div className="section-subtitle">当前主题选项</div><div className="theme-option-grid">{themeSettingsSchema.settings.map((field) => <ThemeOption key={field.key} field={field} value={settings.theme_options[field.key] ?? field.default} onChange={(value) => updateThemeOption(field.key, value)} />)}</div></> : <div className="theme-option-empty">当前主题没有提供额外设置</div>}
|
||||
<div className="section-subtitle">公开界面元素</div>
|
||||
<div className="settings-toggles"><Toggle label="显示搜索" checked={settings.show_search} onChange={(value) => updateSettings("show_search", value)} /><Toggle label="显示分组" checked={settings.show_groups} onChange={(value) => updateSettings("show_groups", value)} /><Toggle label="总览统计" checked={settings.show_stats} onChange={(value) => updateSettings("show_stats", value)} /><Toggle label="在线资产" checked={settings.show_assets} onChange={(value) => updateSettings("show_assets", value)} /><Toggle label="累计流量" checked={settings.show_traffic} onChange={(value) => updateSettings("show_traffic", value)} /><Toggle label="实时网速" checked={settings.show_speed} onChange={(value) => updateSettings("show_speed", value)} /><Toggle label="价格信息" checked={settings.show_price} onChange={(value) => updateSettings("show_price", value)} /><Toggle label="到期信息" checked={settings.show_expiry} onChange={(value) => updateSettings("show_expiry", value)} /><Toggle label="延迟与丢包" checked={settings.show_latency} onChange={(value) => updateSettings("show_latency", value)} /><Toggle label="在线时长" checked={settings.show_uptime} onChange={(value) => updateSettings("show_uptime", value)} /></div>
|
||||
</> : null}
|
||||
|
||||
{tab === "alerts" ? <>
|
||||
<div className="section-title"><AlertTriangle size={15} />通知与告警</div>
|
||||
<div className="form-grid"><label><span>Telegram Bot Token</span><input autoComplete="off" 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>
|
||||
<AlertRuleManager servers={servers} onError={setError} onNotice={setNotice} />
|
||||
</> : null}
|
||||
|
||||
{tab === "security" ? <>
|
||||
<div className="section-title"><ShieldCheck size={15} />账号与 Cloudflare 防护</div>
|
||||
<div className="form-grid"><label><span>管理员用户名</span><input autoComplete="username" value={settings.admin_username} onChange={(event) => updateSettings("admin_username", event.target.value)} /></label><label><span>新密码(留空不修改)</span><input autoComplete="new-password" type="password" value={settings.new_password || ""} onChange={(event) => updateSettings("new_password", event.target.value)} placeholder="至少 8 个字符" /></label></div>
|
||||
<div className="security-note">当前密码状态:{settings.admin_password_configured ? "已使用 D1 加盐哈希" : "使用 ADMIN_PASSWORD 初始化密钥"}</div>
|
||||
<div className="form-grid"><Toggle label="保护公开仪表盘" checked={settings.turnstile_enabled} onChange={(value) => updateSettings("turnstile_enabled", value)} /><Toggle label="保护管理员登录" checked={settings.turnstile_login_enabled} onChange={(value) => updateSettings("turnstile_login_enabled", value)} /></div>
|
||||
<div className="form-grid"><label><span>Turnstile Site Key</span><input value={settings.turnstile_site_key} onChange={(event) => updateSettings("turnstile_site_key", event.target.value)} /></label><label><span>Turnstile Secret Key</span><input type="password" value={settings.turnstile_secret_key} onChange={(event) => updateSettings("turnstile_secret_key", event.target.value)} /></label></div>
|
||||
<div className="section-subtitle">跨域与第三方主题</div>
|
||||
<label><span>CORS 允许来源</span><textarea rows={3} value={settings.cors_allowed_origins} onChange={(event) => updateSettings("cors_allowed_origins", event.target.value)} placeholder="https://dashboard.example.com" /></label>
|
||||
<label><span>CSP 外部资源来源</span><textarea rows={3} value={settings.csp_asset_origins} onChange={(event) => updateSettings("csp_asset_origins", event.target.value)} placeholder="https://cdn.example.com" /></label>
|
||||
<p className="settings-hint">每行一个完整 Origin,不要填写路径。WebSocket 会使用相同的 CORS 来源列表校验。</p>
|
||||
</> : null}
|
||||
|
||||
{tab === "data" ? <>
|
||||
<div className="section-title"><Database size={15} />D1 数据维护</div>
|
||||
<div className="data-stat-grid">{database ? <><DataStat label="节点" value={database.server_count} /><DataStat label="在线" value={database.online_count} /><DataStat label="历史行数" value={database.history_rows.toLocaleString()} /></> : <p className="settings-hint">正在读取数据库统计...</p>}</div>
|
||||
<div className="usage-section">
|
||||
<div className="usage-head"><div><div className="section-title"><Coins size={15} />每日汇率</div><p className="settings-hint">{exchangeRates ? `${exchangeRates.source} · ${exchangeRates.date || "等待首次更新"}${exchangeRates.stale ? " · 数据待更新" : ""}` : "正在读取 D1 汇率快照"}</p></div><button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void refreshExchangeRates()}><RotateCw size={14} />立即更新</button></div>
|
||||
{exchangeRates ? <div className="usage-table-wrap"><table className="usage-table"><thead><tr><th>币种</th><th>1 CNY 可兑换</th></tr></thead><tbody>{ASSET_CURRENCIES.map((currency) => <tr key={currency}><th scope="row">{currency}</th><td>{exchangeRates.rates[currency]?.toLocaleString(undefined, { maximumFractionDigits: 6 }) ?? "--"}</td></tr>)}</tbody></table></div> : <div className="usage-empty">尚未读取</div>}
|
||||
</div>
|
||||
<div className="usage-section">
|
||||
<div className="usage-head"><div><div className="section-title"><Cloud size={15} />Cloudflare 用量</div><p className="settings-hint">统计周期使用 UTC</p></div><button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void loadCloudflareUsage()}><RotateCw size={14} />{cloudflareUsage ? "刷新用量" : "查询用量"}</button></div>
|
||||
<div className="form-grid"><label><span>Cloudflare Account ID</span><input autoComplete="off" maxLength={32} value={settings.cloudflare_account_id} onChange={(event) => updateSettings("cloudflare_account_id", event.target.value)} placeholder="32 位账户 ID" /></label><label><span>Cloudflare API Token</span><input autoComplete="off" type="password" value={settings.cloudflare_api_token} onChange={(event) => updateSettings("cloudflare_api_token", event.target.value)} placeholder="需要 Account Analytics Read 权限" /></label></div>
|
||||
<div className="usage-config-actions"><p className="settings-hint">留空时读取 Worker Secret 中的同名变量。</p><button type="submit" className="primary-btn compact" disabled={busy}><Save size={14} />保存用量配置</button></div>
|
||||
{cloudflareUsage ? <div className="usage-table-wrap"><table className="usage-table"><thead><tr><th>周期</th><th>D1 读取</th><th>D1 写入</th><th>Workers 请求</th></tr></thead><tbody><UsageRow label="今日" usage={cloudflareUsage.today} /><UsageRow label="昨日" usage={cloudflareUsage.yesterday} /></tbody></table></div> : <div className="usage-empty">尚未读取</div>}
|
||||
</div>
|
||||
<div className="data-actions"><button type="button" className="secondary-btn" onClick={() => void loadDatabase()}>刷新统计</button><button type="button" className="danger-btn" onClick={() => void clearHistory()}><Trash2 size={15} />清空历史指标</button></div>
|
||||
<p className="settings-hint">清空历史不会删除节点、密钥或最新状态。</p>
|
||||
</> : null}
|
||||
|
||||
{tab !== "data" ? <div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={16} />保存{tab === "security" ? "账号与安全设置" : "设置"}</button></div> : null}
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setEditing(null)}><form className="editor-modal glass-panel" onSubmit={saveServer} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<header><div><span className="eyebrow">节点配置</span><h3>{editing === "new" ? "添加节点" : `编辑 · ${editing.name}`}</h3></div></header>
|
||||
<div className="form-grid"><label><span>名称</span><input autoFocus required value={form.name} onChange={(event) => updateForm("name", event.target.value)} /></label><label><span>地区代码</span><input maxLength={16} placeholder="CN / JP / DE" value={form.region} onChange={(event) => updateForm("region", event.target.value.toUpperCase())} /></label><label><span>分组</span><input value={form.group_name} onChange={(event) => updateForm("group_name", event.target.value)} /></label><label><span>标签</span><input placeholder="主力, 线路:BGP" value={form.tags} onChange={(event) => updateForm("tags", event.target.value)} /></label></div>
|
||||
<div className="form-grid three"><label><span>流量限额(GB)</span><input min="0" type="number" value={Math.round(form.traffic_limit / 1024 ** 3)} onChange={(event) => updateForm("traffic_limit", Number(event.target.value) * 1024 ** 3)} /></label><label><span>流量口径</span><select value={form.traffic_limit_type} onChange={(event) => updateForm("traffic_limit_type", event.target.value as ServerInput["traffic_limit_type"])}><option value="sum">上下行合计</option><option value="max">取较大值</option><option value="min">取较小值</option><option value="up">仅上行</option><option value="down">仅下行</option></select></label><label><span>流量重置日</span><input min="1" max="31" type="number" value={form.reset_day} onChange={(event) => updateForm("reset_day", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid three"><label><span>价格</span><input min="-2" step="0.01" type="number" value={form.price} onChange={(event) => updateForm("price", Number(event.target.value))} /></label><label><span>币种</span><select value={form.currency} onChange={(event) => updateForm("currency", event.target.value)}>{ASSET_CURRENCIES.map((code) => <option key={code}>{code}</option>)}</select></label><label><span>计费周期(天)</span><input min="1" max="3650" type="number" value={form.billing_cycle} onChange={(event) => updateForm("billing_cycle", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid three"><label><span>到期日期</span><input type="date" value={formatDate(form.expires_at)} onChange={(event) => updateForm("expires_at", event.target.value ? Math.floor(new Date(`${event.target.value}T00:00:00Z`).getTime() / 1000) : null)} /></label><label><span>Agent 上报间隔(秒)</span><input min="15" max="3600" type="number" value={form.report_interval} onChange={(event) => updateForm("report_interval", Number(event.target.value))} /></label><label><span>指标采样间隔(秒)</span><input min="2" max="60" type="number" value={form.collect_interval} onChange={(event) => updateForm("collect_interval", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid"><label><span>统计网卡(逗号分隔,留空自动)</span><input value={form.network_interface} onChange={(event) => updateForm("network_interface", event.target.value)} placeholder="eth0,ens3" /></label><label><span>下行流量修正(GB)</span><input min="0" step="0.1" type="number" value={form.rx_correction / 1024 ** 3} onChange={(event) => updateForm("rx_correction", Number(event.target.value) * 1024 ** 3)} /></label><label><span>上行流量修正(GB)</span><input min="0" step="0.1" type="number" value={form.tx_correction / 1024 ** 3} onChange={(event) => updateForm("tx_correction", Number(event.target.value) * 1024 ** 3)} /></label></div>
|
||||
<label><span>公开备注</span><textarea rows={2} value={form.public_remark} onChange={(event) => updateForm("public_remark", event.target.value)} /></label><label><span>管理备注</span><textarea rows={2} value={form.note} onChange={(event) => updateForm("note", event.target.value)} /></label>
|
||||
<div className="settings-toggles editor-toggles"><Toggle label="自动续费" checked={form.auto_renewal} onChange={(value) => updateForm("auto_renewal", value)} /><Toggle label="Agent 自动更新" checked={form.auto_update} onChange={(value) => updateForm("auto_update", value)} /><Toggle label="隐藏节点" checked={form.hidden} onChange={(value) => updateForm("hidden", value)} /><Toggle label="关闭离线告警" checked={form.offline_notify_disabled} onChange={(value) => updateForm("offline_notify_disabled", value)} /></div>
|
||||
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={16} />保存节点</button></div>
|
||||
</form></div> : null}
|
||||
|
||||
{commands.length ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setInstall([])}><section className="install-modal glass-panel" onMouseDown={(event) => event.stopPropagation()}><header><div><span className="eyebrow">Agent 部署</span><h3>安装命令</h3></div><div className="segmented install-platform" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} onClick={() => setInstallPlatform("macos")}>macOS ARM</button></div></header><div className="install-list">{commands.map((item) => <div key={item.id}><strong>{item.name}</strong><pre>{item.command}</pre></div>)}</div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall([])}>完成</button><button className="primary-btn" type="button" onClick={() => navigator.clipboard.writeText(commands.map((item) => `# ${item.name}\n${item.command}`).join("\n\n"))}><Copy size={16} />复制全部</button></div></section></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) {
|
||||
return <label className="toggle-row"><span><b>{label}</b></span><Checkbox checked={checked} onChange={onChange} /></label>;
|
||||
}
|
||||
|
||||
function ThemeOption({ field, value, onChange }: { field: ThemeSettingField; value: ThemeSettingValue | undefined; onChange: (value: ThemeSettingValue) => void }) {
|
||||
if (field.type === "toggle") {
|
||||
return <Toggle label={field.label} checked={typeof value === "boolean" ? value : false} onChange={onChange} />;
|
||||
}
|
||||
if (field.type === "textarea") {
|
||||
return <label className="theme-option"><span>{field.label}</span><textarea rows={3} maxLength={500} placeholder={field.placeholder} value={typeof value === "string" ? value : ""} onChange={(event) => onChange(event.target.value)} /></label>;
|
||||
}
|
||||
if (field.type === "select") {
|
||||
return <label className="theme-option"><span>{field.label}</span><select value={typeof value === "string" ? value : ""} onChange={(event) => onChange(event.target.value)}>{field.options?.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>;
|
||||
}
|
||||
if (field.type === "number") {
|
||||
return <label className="theme-option"><span>{field.label}</span><input type="number" min={field.min} max={field.max} step={field.step} value={typeof value === "number" ? value : ""} onChange={(event) => onChange(Number(event.target.value))} /></label>;
|
||||
}
|
||||
return <label className={`theme-option ${field.type === "color" ? "theme-color-option" : ""}`}><span>{field.label}</span><input type={field.type} maxLength={field.type === "color" ? undefined : 500} placeholder={field.placeholder} value={typeof value === "string" ? value : field.type === "color" ? "#0f766e" : ""} onChange={(event) => onChange(event.target.value)} /></label>;
|
||||
}
|
||||
|
||||
function DataStat({ label, value }: { label: string; value: number | string }) {
|
||||
return <div className="data-stat"><span>{label}</span><strong>{value}</strong></div>;
|
||||
}
|
||||
|
||||
function UsageRow({ label, usage }: { label: string; usage: CloudflareUsage["today"] }) {
|
||||
return <tr><th scope="row">{label}</th><td>{usage.rows_read.toLocaleString()}</td><td>{usage.rows_written.toLocaleString()}</td><td>{usage.workers_requests.toLocaleString()}</td></tr>;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Pencil, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import type { AlertRule, AlertRuleInput, Server } from "../types";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
|
||||
const emptyRule: AlertRuleInput = {
|
||||
name: "",
|
||||
metric: "cpu",
|
||||
threshold: 90,
|
||||
duration_minutes: 5,
|
||||
aggregation: "average",
|
||||
enabled: true,
|
||||
server_ids: [],
|
||||
};
|
||||
|
||||
const metricLabels = {
|
||||
cpu: "CPU 使用率",
|
||||
memory: "内存使用率",
|
||||
disk: "磁盘使用率",
|
||||
net_in: "下行速度",
|
||||
net_out: "上行速度",
|
||||
};
|
||||
|
||||
export function AlertRuleManager({ servers, onError, onNotice }: {
|
||||
servers: Server[];
|
||||
onError: (message: string) => void;
|
||||
onNotice: (message: string) => void;
|
||||
}) {
|
||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||
const [editing, setEditing] = useState<AlertRule | "new" | null>(null);
|
||||
const [form, setForm] = useState<AlertRuleInput>(emptyRule);
|
||||
const [allServers, setAllServers] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setRules((await api.alertRules()).rules); }
|
||||
catch (reason) { onError(reason instanceof Error ? reason.message : "读取告警规则失败"); }
|
||||
}, [onError]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
function open(rule?: AlertRule) {
|
||||
setEditing(rule ?? "new");
|
||||
setForm(rule ? {
|
||||
name: rule.name,
|
||||
metric: rule.metric,
|
||||
threshold: rule.threshold,
|
||||
duration_minutes: rule.duration_minutes,
|
||||
aggregation: rule.aggregation,
|
||||
enabled: !!rule.enabled,
|
||||
server_ids: [...rule.server_ids],
|
||||
} : { ...emptyRule, server_ids: [] });
|
||||
setAllServers(!rule?.server_ids.length);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
onError("");
|
||||
const input = { ...form, server_ids: allServers ? [] : form.server_ids };
|
||||
try {
|
||||
if (editing === "new") await api.createAlertRule(input);
|
||||
else if (editing) await api.updateAlertRule(editing.id, input);
|
||||
setEditing(null);
|
||||
await load();
|
||||
onNotice("告警规则已保存");
|
||||
} catch (reason) { onError(reason instanceof Error ? reason.message : "保存告警规则失败"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function remove(rule: AlertRule) {
|
||||
if (!window.confirm(`确认删除告警规则“${rule.name}”?`)) return;
|
||||
try { await api.deleteAlertRule(rule.id); await load(); onNotice("告警规则已删除"); }
|
||||
catch (reason) { onError(reason instanceof Error ? reason.message : "删除告警规则失败"); }
|
||||
}
|
||||
|
||||
async function toggle(rule: AlertRule) {
|
||||
try {
|
||||
await api.updateAlertRule(rule.id, {
|
||||
name: rule.name,
|
||||
metric: rule.metric,
|
||||
threshold: rule.threshold,
|
||||
duration_minutes: rule.duration_minutes,
|
||||
aggregation: rule.aggregation,
|
||||
enabled: !rule.enabled,
|
||||
server_ids: rule.server_ids,
|
||||
});
|
||||
await load();
|
||||
} catch (reason) { onError(reason instanceof Error ? reason.message : "更新告警规则失败"); }
|
||||
}
|
||||
|
||||
const unit = form.metric === "net_in" || form.metric === "net_out" ? "MiB/s" : "%";
|
||||
|
||||
return <div className="alert-rule-manager">
|
||||
<div className="section-head"><div><h3>资源告警规则</h3><span>最多 20 条,可指定服务器和统计窗口</span></div><button type="button" className="primary-btn compact" onClick={() => open()}><Plus size={14} />新建规则</button></div>
|
||||
<div className="alert-rule-list">
|
||||
{rules.map((rule) => <div className="alert-rule-row" key={rule.id}>
|
||||
<Checkbox checked={!!rule.enabled} onChange={() => void toggle(rule)} ariaLabel={`${rule.name}启用状态`} />
|
||||
<div><strong>{rule.name}</strong><small>{metricLabels[rule.metric]} ≥ {rule.threshold} {rule.metric.startsWith("net_") ? "MiB/s" : "%"} · {rule.duration_minutes} 分钟{rule.aggregation === "continuous" ? "持续" : "平均"} · {rule.server_ids.length ? `${rule.server_ids.length} 台服务器` : "全部服务器"}</small></div>
|
||||
<button type="button" className="icon-btn" title="编辑规则" onClick={() => open(rule)}><Pencil size={14} /></button>
|
||||
<button type="button" className="icon-btn danger" title="删除规则" onClick={() => void remove(rule)}><Trash2 size={14} /></button>
|
||||
</div>)}
|
||||
{!rules.length ? <div className="list-empty">尚未配置资源告警规则</div> : null}
|
||||
</div>
|
||||
|
||||
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setEditing(null)}><section className="alert-rule-editor glass-panel" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<header><div><span className="eyebrow">通知规则</span><h3>{editing === "new" ? "新建资源告警" : "编辑资源告警"}</h3></div></header>
|
||||
<label><span>规则名称</span><input required maxLength={80} value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} /></label>
|
||||
<div className="form-grid"><label><span>监控指标</span><select value={form.metric} onChange={(event) => setForm((current) => ({ ...current, metric: event.target.value as AlertRuleInput["metric"] }))}>{Object.entries(metricLabels).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label><label><span>阈值({unit})</span><input required type="number" min="0.01" max={unit === "%" ? 100 : 1000000} step="0.01" value={form.threshold} onChange={(event) => setForm((current) => ({ ...current, threshold: Number(event.target.value) }))} /></label></div>
|
||||
<div className="form-grid"><label><span>时间窗口(分钟)</span><input required type="number" min="1" max="1440" value={form.duration_minutes} onChange={(event) => setForm((current) => ({ ...current, duration_minutes: Number(event.target.value) }))} /></label><label><span>判断方式</span><select value={form.aggregation} onChange={(event) => setForm((current) => ({ ...current, aggregation: event.target.value as AlertRuleInput["aggregation"] }))}><option value="average">窗口平均值</option><option value="continuous">窗口内持续超限</option></select></label></div>
|
||||
<div className="settings-toggles"><label className="toggle-row"><b>启用规则</b><Checkbox checked={form.enabled} onChange={(value) => setForm((current) => ({ ...current, enabled: value }))} /></label><label className="toggle-row"><b>全部服务器</b><Checkbox checked={allServers} onChange={setAllServers} /></label></div>
|
||||
{!allServers ? <div className="alert-server-picker">{servers.map((server) => <label key={server.id}><Checkbox checked={form.server_ids.includes(server.id)} onChange={(checked) => setForm((current) => ({ ...current, server_ids: checked ? [...current.server_ids, server.id] : current.server_ids.filter((id) => id !== server.id) }))} /><span>{server.name}</span></label>)}</div> : null}
|
||||
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button type="button" className="primary-btn" onClick={() => void save()} disabled={busy || !form.name.trim() || (!allServers && !form.server_ids.length)}><Save size={15} />保存规则</button></div>
|
||||
</section></div> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
return <span className="checkbox-control"><input aria-label={ariaLabel} type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} /><Check aria-hidden="true" /></span>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
import { countryFlag } from "../format";
|
||||
|
||||
export function regionDisplayName(region: string | null | undefined, locale = "zh-CN") {
|
||||
const code = region?.trim().slice(0, 2).toUpperCase() ?? "";
|
||||
if (!/^[A-Z]{2}$/.test(code)) return region?.trim() || (locale === "en" ? "Unknown region" : "未知地区");
|
||||
try { return typeof Intl.DisplayNames === "function" ? new Intl.DisplayNames([locale], { type: "region" }).of(code) || code : code; } catch { return code; }
|
||||
}
|
||||
|
||||
export function Flag({ region, size = 20, className = "", locale = "zh-CN" }: { region: string | null | undefined; size?: number; className?: string; locale?: string }) {
|
||||
const emoji = countryFlag(region || "");
|
||||
const label = useMemo(() => regionDisplayName(region, locale), [locale, region]);
|
||||
if (!emoji) return null;
|
||||
return <span className={`region-flag ${className}`} style={{ fontSize: size * 0.9 }} title={label} aria-label={label}>{emoji}</span>;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Pencil, Plus, RadioTower, Save, Search, Trash2 } from "lucide-react";
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import type { LatencyTask, LatencyTaskInput, Server } from "../types";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
|
||||
const emptyTask: LatencyTaskInput = {
|
||||
name: "",
|
||||
task_type: "icmp",
|
||||
target: "",
|
||||
interval_seconds: 60,
|
||||
default_enabled: false,
|
||||
server_ids: [],
|
||||
};
|
||||
|
||||
function validHost(value: string) {
|
||||
const host = value.trim();
|
||||
if (!host || host.length > 60 || /\s|:\/\/|[\/@?#\\\[\]]/.test(host) || (host.match(/:/g)?.length ?? 0) > 1) return false;
|
||||
const [name, port] = host.split(":");
|
||||
if (port && (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535)) return false;
|
||||
if (!name || name.startsWith(".") || name.endsWith(".")) return false;
|
||||
return name.split(".").every((part) => /^[A-Za-z0-9_](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?$/.test(part));
|
||||
}
|
||||
|
||||
export function LatencyManager({
|
||||
servers,
|
||||
onError,
|
||||
onNotice,
|
||||
}: {
|
||||
servers: Server[];
|
||||
onError: (message: string) => void;
|
||||
onNotice: (message: string) => void;
|
||||
}) {
|
||||
const [tasks, setTasks] = useState<LatencyTask[]>([]);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [editing, setEditing] = useState<LatencyTask | "new" | null>(null);
|
||||
const [form, setForm] = useState<LatencyTaskInput>(emptyTask);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setBusy(true);
|
||||
onError("");
|
||||
try {
|
||||
setTasks((await api.latencyTasks()).tasks);
|
||||
} catch (reason) {
|
||||
onError(reason instanceof Error ? reason.message : "读取延迟任务失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const visibleServers = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) return servers;
|
||||
return servers.filter((server) => `${server.name} ${server.region} ${server.group_name}`.toLowerCase().includes(keyword));
|
||||
}, [query, servers]);
|
||||
|
||||
function open(task?: LatencyTask) {
|
||||
setEditing(task ?? "new");
|
||||
setForm(task ? {
|
||||
name: task.name,
|
||||
task_type: task.task_type,
|
||||
target: task.target,
|
||||
interval_seconds: task.interval_seconds,
|
||||
default_enabled: task.default_enabled,
|
||||
server_ids: [...task.server_ids],
|
||||
} : { ...emptyTask, server_ids: [] });
|
||||
setQuery("");
|
||||
onError("");
|
||||
}
|
||||
|
||||
function toggleServer(id: string) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
server_ids: current.server_ids.includes(id)
|
||||
? current.server_ids.filter((serverId) => serverId !== id)
|
||||
: [...current.server_ids, id],
|
||||
}));
|
||||
}
|
||||
|
||||
async function save(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!validHost(form.target) || (form.task_type === "icmp" && form.target.includes(":"))) {
|
||||
onError(form.task_type === "icmp" ? "ICMP 目标应为域名或 IPv4,不能包含端口" : "TCP 目标应为域名、IPv4 或 host:port");
|
||||
return;
|
||||
}
|
||||
if (!form.default_enabled && !form.server_ids.length) {
|
||||
onError("请至少选择一个服务器,或开启默认分配");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
onError("");
|
||||
try {
|
||||
if (editing === "new") await api.createLatencyTask(form);
|
||||
else if (editing) await api.updateLatencyTask(editing.id, form);
|
||||
setEditing(null);
|
||||
await load();
|
||||
onNotice(editing === "new" ? "延迟任务已添加" : "延迟任务已更新");
|
||||
} catch (reason) {
|
||||
onError(reason instanceof Error ? reason.message : "保存延迟任务失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(task: LatencyTask) {
|
||||
if (!window.confirm(`确认删除延迟任务“${task.name}”及其历史结果?`)) return;
|
||||
setBusy(true);
|
||||
onError("");
|
||||
try {
|
||||
await api.deleteLatencyTask(task.id);
|
||||
await load();
|
||||
onNotice("延迟任务已删除");
|
||||
} catch (reason) {
|
||||
onError(reason instanceof Error ? reason.message : "删除延迟任务失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const allSelected = servers.length > 0 && form.server_ids.length === servers.length;
|
||||
|
||||
return <div className="admin-section latency-section">
|
||||
<div className="section-head">
|
||||
<div><h3>延迟任务</h3><span>{tasks.length} 个任务 · TCP / ICMP</span></div>
|
||||
<button className="primary-btn compact" type="button" onClick={() => open()}><Plus size={16} />添加</button>
|
||||
</div>
|
||||
<div className="latency-task-list">
|
||||
{tasks.map((task) => <div className="latency-task-row" key={task.id}>
|
||||
<span className={`latency-type ${task.task_type}`}><RadioTower size={13} />{task.task_type.toUpperCase()}</span>
|
||||
<div className="latency-task-name"><strong>{task.name}</strong><small>{task.target}</small></div>
|
||||
<span className="latency-task-meta">{task.interval_seconds}s</span>
|
||||
<span className="latency-task-meta">{task.server_ids.length} 个节点{task.default_enabled ? " · 默认" : ""}</span>
|
||||
<div className="row-actions"><button className="icon-btn" type="button" onClick={() => open(task)} title="编辑延迟任务"><Pencil size={15} /></button><button className="icon-btn danger" type="button" onClick={() => void remove(task)} title="删除延迟任务"><Trash2 size={15} /></button></div>
|
||||
</div>)}
|
||||
{!tasks.length && !busy ? <div className="list-empty">暂无延迟任务</div> : null}
|
||||
{busy && !tasks.length ? <div className="list-empty">正在读取延迟任务</div> : null}
|
||||
</div>
|
||||
|
||||
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setEditing(null)}><form className="latency-editor glass-panel" onSubmit={save} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<header><div><span className="eyebrow">延迟测试</span><h3>{editing === "new" ? "添加任务" : `编辑 · ${editing.name}`}</h3></div></header>
|
||||
<div className="form-grid"><label><span>名称</span><input autoFocus required maxLength={80} value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} /></label><label><span>类型</span><div className="segmented task-type-control"><button type="button" className={form.task_type === "icmp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "icmp" }))}>ICMP</button><button type="button" className={form.task_type === "tcp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "tcp" }))}>TCP</button></div></label></div>
|
||||
<div className="form-grid"><label><span>目标</span><input required maxLength={60} value={form.target} onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))} placeholder={form.task_type === "tcp" ? "example.com:443" : "1.1.1.1"} /></label><label><span>间隔(秒)</span><input type="number" min="30" max="3600" required value={form.interval_seconds} onChange={(event) => setForm((current) => ({ ...current, interval_seconds: Number(event.target.value) }))} /></label></div>
|
||||
<div className="server-picker">
|
||||
<div className="server-picker-head"><strong>服务器</strong><span>已选 {form.server_ids.length} / 共 {servers.length}</span><button type="button" onClick={() => setForm((current) => ({ ...current, server_ids: allSelected ? [] : servers.map((server) => server.id) }))}>{allSelected ? "取消全选" : "全选"}</button></div>
|
||||
<div className="server-picker-search"><Search size={16} /><input aria-label="搜索服务器" placeholder="搜索" value={query} onChange={(event) => setQuery(event.target.value)} /></div>
|
||||
<div className="server-picker-list">
|
||||
{visibleServers.map((server) => <label className="server-picker-row" key={server.id}><Checkbox checked={form.server_ids.includes(server.id)} onChange={() => toggleServer(server.id)} /><span><strong>{server.name}</strong><small>{server.group_name} · {server.region || "未设置地区"}</small></span></label>)}
|
||||
{!visibleServers.length ? <div className="server-picker-empty">没有匹配的服务器</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-row"><span><b>默认分配给新服务器</b></span><Checkbox checked={form.default_enabled} onChange={(checked) => setForm((current) => ({ ...current, default_enabled: checked }))} /></label>
|
||||
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={16} />{busy ? "保存中" : "保存任务"}</button></div>
|
||||
</form></div> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarDays,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Coins,
|
||||
Download,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
formatBytes,
|
||||
formatCurrency,
|
||||
formatExpire,
|
||||
formatPrice,
|
||||
remainingAssetValue,
|
||||
formatSpeed,
|
||||
isOnline,
|
||||
number,
|
||||
percent,
|
||||
timeAgo,
|
||||
trafficUsed,
|
||||
} from "../format";
|
||||
import type { Config, Server } from "../types";
|
||||
import { ui } from "../locale";
|
||||
import { useNodeLatency, type LatencyBar } from "../hooks/useNodeLatency";
|
||||
import { Flag } from "./Flag";
|
||||
import { OSIcon } from "./OSIcon";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
|
||||
function Metric({ label, value, used, sub, muted = false, valueTone = "" }: { label: string; value: string; used: number; sub: string; muted?: boolean; valueTone?: string }) {
|
||||
return (
|
||||
<div className={`metric ${muted ? "muted" : ""}`}>
|
||||
<div><span>{label}</span><strong className={valueTone}>{value}</strong></div>
|
||||
<ProgressBar value={used} />
|
||||
<small title={sub}>{sub}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactLine({ icon, children, tone = "" }: { icon: React.ReactNode; children: React.ReactNode; tone?: string }) {
|
||||
return <b className={tone}><span>{icon}</span><em>{children}</em></b>;
|
||||
}
|
||||
|
||||
function QualityPanel({ label, value, bars }: {
|
||||
label: string;
|
||||
value: string;
|
||||
bars: LatencyBar[];
|
||||
}) {
|
||||
return (
|
||||
<div className="quality-panel">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeCard({ server, config, onOpen }: { server: Server; config: Config; onOpen: () => void }) {
|
||||
const threshold = config.offline_threshold_seconds;
|
||||
const online = isOnline(server, threshold);
|
||||
const memory = percent(server.mem_used, server.mem_total);
|
||||
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 || "zh-CN";
|
||||
const price = formatPrice(server, locale);
|
||||
const remainingValue = remainingAssetValue(server.price, server.billing_cycle, server.expires_at);
|
||||
|
||||
return (
|
||||
<button className={`node-card glass-panel ${online ? "" : "offline"}`} onClick={onOpen} type="button" aria-label={ui(locale, `查看 ${server.name} 详情`, `View ${server.name} details`)}>
|
||||
<header className="node-header">
|
||||
<span className={`status-dot ${online ? "online" : ""}`} />
|
||||
<strong title={server.name}>{server.name}</strong>
|
||||
{server.message?.trim() ? <AlertTriangle className="node-alert" size={15}><title>{server.message}</title></AlertTriangle> : null}
|
||||
<OSIcon os={server.os} size={16} />
|
||||
<Flag region={server.region} size={19} locale={locale} />
|
||||
</header>
|
||||
|
||||
<div className="node-body">
|
||||
<div className="node-chips">
|
||||
{config.show_uptime ? <span>{ui(locale, `在线 ${Math.floor(number(server.uptime) / 86400)} 天`, `Online ${Math.floor(number(server.uptime) / 86400)} days`)}</span> : null}
|
||||
{config.show_price && price ? <span title={price}>{price}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="metric-grid">
|
||||
<Metric label="CPU" value={`${number(server.cpu).toFixed(1)}%`} used={number(server.cpu)} sub={`${number(server.load1).toFixed(2)}, ${number(server.load5).toFixed(2)}, ${number(server.load15).toFixed(2)}`} muted={!online} />
|
||||
<Metric label={ui(locale, "内存", "Memory")} value={`${memory.toFixed(1)}%`} used={memory} sub={`${formatBytes(server.mem_used)} / ${formatBytes(server.mem_total)}`} muted={!online} />
|
||||
<Metric label={ui(locale, "硬盘", "Disk")} value={`${disk.toFixed(1)}%`} used={disk} sub={`${formatBytes(server.disk_used)} / ${formatBytes(server.disk_total)}`} muted={!online} />
|
||||
<Metric label={ui(locale, "流量", "Traffic")} value={server.traffic_limit > 0 ? `${traffic.toFixed(1)}%` : "∞"} used={traffic} sub={`${formatBytes(usedTraffic)} / ${server.traffic_limit > 0 ? formatBytes(server.traffic_limit) : "∞"}`} muted={!online} valueTone={server.traffic_limit <= 0 ? "" : traffic >= 95 ? "danger-text" : traffic >= 60 ? "warning-text" : "success-text"} />
|
||||
</div>
|
||||
|
||||
<div className={`data-grid ${config.show_expiry ? "" : "two-columns"}`}>
|
||||
<div className="data-panel" aria-label="实时速率">
|
||||
<CompactLine icon={<ChevronUp size={11} />} tone="success-text">{formatSpeed(server.net_out)}</CompactLine>
|
||||
<CompactLine icon={<ChevronDown size={11} />} tone="info-text">{formatSpeed(server.net_in)}</CompactLine>
|
||||
</div>
|
||||
<div className="data-panel" aria-label="累计流量">
|
||||
<CompactLine icon={<Upload size={11} />}>{formatBytes(server.net_tx_total)}</CompactLine>
|
||||
<CompactLine icon={<Download size={11} />}>{formatBytes(server.net_rx_total)}</CompactLine>
|
||||
</div>
|
||||
{config.show_expiry ? <div className="data-panel" aria-label="剩余周期">
|
||||
{(server.price >= -1) ? <>
|
||||
<CompactLine icon={<CalendarDays size={11} />}>{formatExpire(server, locale)}</CompactLine>
|
||||
<CompactLine icon={<Coins size={11} />}>{server.price === -1 ? ui(locale, "免费", "Free") : formatCurrency(remainingValue, server.currency)}</CompactLine>
|
||||
</> : null}
|
||||
</div> : null}
|
||||
</div>
|
||||
|
||||
{config.show_latency && quality.configured ? <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} />
|
||||
</div> : null}
|
||||
|
||||
{!online ? <div className="offline-layer"><strong>{ui(locale, "离线", "Offline")}</strong><span>{ui(locale, `最后更新 ${timeAgo(server.timestamp, locale)}`, `Last update ${timeAgo(server.timestamp, locale)}`)}</span></div> : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
Box,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Network,
|
||||
Radio,
|
||||
RadioTower,
|
||||
ServerCog,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { api } from "../api";
|
||||
import { demoHistory, demoLatencyHistory, demoLatencyTasks } from "../demo";
|
||||
import { formatBytes, formatSpeed, formatUptime, isOnline, number } from "../format";
|
||||
import type { HistoryPoint, LatencySample, LatencyTestPoint, Server } from "../types";
|
||||
import { Flag, regionDisplayName } from "./Flag";
|
||||
import { OSIcon } from "./OSIcon";
|
||||
import { ui } from "../locale";
|
||||
|
||||
type ChartType = "load" | "latency";
|
||||
|
||||
const LATENCY_COLORS = ["#2563eb", "#db2777", "#ea7b1b", "#0f766e", "#7c3aed", "#0891b2"];
|
||||
|
||||
function InfoItem({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
|
||||
return <div className="info-item"><span>{icon}</span><div><small>{label}</small><strong>{value}</strong></div></div>;
|
||||
}
|
||||
|
||||
function InfoGroup({ icon, title, children }: { icon: React.ReactNode; title: string; children: React.ReactNode }) {
|
||||
return <section className="info-group glass-panel"><h2>{icon}{title}</h2><div className="info-group-grid">{children}</div></section>;
|
||||
}
|
||||
|
||||
function ChartCard({ title, value, children }: { title: string; value: string; children: React.ReactNode }) {
|
||||
return <div className="detail-chart glass-panel"><header><h3>{title}</h3><span>{value}</span></header><div>{children}</div></div>;
|
||||
}
|
||||
|
||||
function resourceTicks(total: number): number[] {
|
||||
return Array.from({ length: 5 }, (_, index) => total * index / 4);
|
||||
}
|
||||
|
||||
function average(values: 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;
|
||||
}
|
||||
|
||||
const tooltipStyle = {
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--card-solid)",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
export function NodeDetails({ server, threshold, retentionDays, locale, demo = false, onClose }: {
|
||||
server: Server;
|
||||
threshold: number;
|
||||
retentionDays: number;
|
||||
locale: "zh-CN" | "en";
|
||||
demo?: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [hours, setHours] = useState(0);
|
||||
const [chartType, setChartType] = useState<ChartType>("load");
|
||||
const [points, setPoints] = useState<HistoryPoint[]>([]);
|
||||
const [latencyPoints, setLatencyPoints] = useState<LatencySample[]>([]);
|
||||
const [latencyTasks, setLatencyTasks] = useState<LatencyTestPoint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const requestHours = hours === 0 ? 1 : hours;
|
||||
if (demo) {
|
||||
const taskIds = new Set((server.latency ?? []).map((point) => point.task_id));
|
||||
const tasks = demoLatencyTasks.filter((task) => taskIds.has(task.id));
|
||||
setPoints(demoHistory(server.id, requestHours));
|
||||
setLatencyTasks(tasks);
|
||||
setLatencyPoints(tasks.length ? demoLatencyHistory(server.id, requestHours).filter((point) => taskIds.has(point.task_id)) : []);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
const serverId = server.source_id || server.id;
|
||||
const baseUrl = server.source_url || "";
|
||||
Promise.all([api.history(serverId, requestHours, baseUrl), api.latencyHistory(serverId, requestHours, baseUrl)])
|
||||
.then(([history, latency]) => { if (active) { setPoints(history.points); setLatencyTasks(latency.tasks); setLatencyPoints(latency.points); } })
|
||||
.catch((reason) => { if (active) { setPoints([]); setLatencyTasks([]); setLatencyPoints([]); setError(reason instanceof Error ? reason.message : ui(locale, "历史数据加载失败", "Unable to load history")); } })
|
||||
.finally(() => { if (active) setLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [server.id, server.source_id, server.source_url, hours, demo, locale]);
|
||||
|
||||
const online = isOnline(server, threshold);
|
||||
const loadRanges = [
|
||||
{ value: 0, label: ui(locale, "实时", "Live") },
|
||||
{ value: 1, label: ui(locale, "1 小时", "1 hour") },
|
||||
{ value: 4, label: ui(locale, "4 小时", "4 hours") },
|
||||
{ value: 24, label: ui(locale, "1 天", "1 day") },
|
||||
{ value: 168, label: ui(locale, "7 天", "7 days") },
|
||||
{ value: 720, label: ui(locale, "30 天", "30 days") },
|
||||
].filter((range) => range.value <= Math.max(24, retentionDays * 24));
|
||||
const ranges = chartType === "load" ? loadRanges : loadRanges.filter((range) => range.value > 0);
|
||||
const 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 latencyData = useMemo(() => {
|
||||
const grouped = new Map<number, Record<string, number | string | null>>();
|
||||
for (const point of latencyPoints) {
|
||||
const row = grouped.get(point.timestamp) ?? {
|
||||
timestamp: point.timestamp,
|
||||
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 }),
|
||||
};
|
||||
row[`latency_${point.task_id}`] = point.latency_ms >= 0 ? point.latency_ms : null;
|
||||
row[`loss_${point.task_id}`] = point.packet_loss;
|
||||
grouped.set(point.timestamp, row);
|
||||
}
|
||||
return Array.from(grouped.values()).sort((left, right) => Number(left.timestamp) - Number(right.timestamp));
|
||||
}, [hours, latencyPoints, locale]);
|
||||
const latencySeries = useMemo(() => {
|
||||
return latencyTasks.map((task, index) => {
|
||||
const samples = latencyPoints.filter((point) => point.task_id === task.id);
|
||||
return {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
dataKey: `latency_${task.id}`,
|
||||
color: LATENCY_COLORS[index % LATENCY_COLORS.length],
|
||||
latency: average(samples.map((sample) => sample.latency_ms)),
|
||||
loss: average(samples.map((sample) => sample.packet_loss)),
|
||||
};
|
||||
});
|
||||
}, [latencyPoints, latencyTasks]);
|
||||
const last = data[data.length - 1];
|
||||
const pingEnabled = latencyTasks.length > 0;
|
||||
|
||||
function selectChart(next: ChartType) {
|
||||
setChartType(next);
|
||||
if (next === "latency" && hours === 0) setHours(1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="detail-page">
|
||||
<section className="detail-hero glass-panel">
|
||||
<button className="icon-btn" onClick={onClose} title={ui(locale, "返回首页", "Back")}><ArrowLeft size={19} /></button>
|
||||
<Flag region={server.region} size={24} className="detail-flag" locale={locale} />
|
||||
<div className="detail-title">
|
||||
<div><h1>{server.name}</h1><span className={`status-chip ${online ? "online" : ""}`}>{online ? ui(locale, "在线", "Online") : ui(locale, "离线", "Offline")}</span></div>
|
||||
<p><OSIcon os={server.os} size={14} />{regionDisplayName(server.region, locale)}{server.group_name ? ` · ${server.group_name}` : ""}{server.source_name && server.source_url ? ` · ${server.source_name}` : ""}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="info-groups">
|
||||
<InfoGroup title={ui(locale, "硬件信息", "Hardware")} icon={<Cpu size={16} />}>
|
||||
<InfoItem icon={<Cpu size={14} />} label={ui(locale, "处理器", "Processor")} value={`${server.cpu_model || "--"} (x${server.cpu_cores || 0})`} />
|
||||
<InfoItem icon={<Box size={14} />} label={ui(locale, "架构", "Architecture")} value={server.arch || "--"} />
|
||||
<InfoItem icon={<Cpu size={14} />} label={ui(locale, "图形设备", "Graphics")} value={server.gpus?.length ? server.gpus.map((gpu) => `${gpu.model} ${gpu.usage.toFixed(1)}%`).join(" · ") : server.gpu_model || ui(locale, "未检测到", "Not detected")} />
|
||||
<InfoItem icon={<HardDrive size={14} />} label={ui(locale, "存储设备", "Storage")} value={server.disks?.length ? server.disks.map((disk) => `${disk.mount_point} ${formatBytes(disk.used)} / ${formatBytes(disk.total)}`).join(" · ") : `${formatBytes(server.disk_used)} / ${formatBytes(server.disk_total)}`} />
|
||||
<InfoItem icon={<ServerCog size={14} />} label={ui(locale, "虚拟化", "Virtualization")} value={server.virtualization || "--"} />
|
||||
</InfoGroup>
|
||||
<InfoGroup title={ui(locale, "系统信息", "System")} icon={<ServerCog size={16} />}>
|
||||
<InfoItem icon={<ServerCog size={14} />} label={ui(locale, "操作系统", "Operating system")} value={server.os || "--"} />
|
||||
<InfoItem icon={<Radio size={14} />} label={ui(locale, "运行时间", "Uptime")} value={online ? formatUptime(server.uptime, locale) : "--"} />
|
||||
<InfoItem icon={<Cpu size={14} />} label={ui(locale, "内核版本", "Kernel")} value={server.kernel || "--"} />
|
||||
<InfoItem icon={<Network size={14} />} label={ui(locale, "进程 / 连接", "Processes / connections")} value={online ? `${server.processes || 0} / ${number(server.tcp_connections) + number(server.udp_connections)}` : "--"} />
|
||||
<InfoItem icon={<HardDrive size={14} />} label={ui(locale, "磁盘 IO", "Disk IO")} value={`${number(server.disk_await_ms).toFixed(1)} ms · ${number(server.disk_utilization).toFixed(1)}%`} />
|
||||
</InfoGroup>
|
||||
</div>
|
||||
|
||||
<section className="chart-section">
|
||||
<div className="chart-controls">
|
||||
<div className="segmented" aria-label="图表类型">
|
||||
<button className={chartType === "load" ? "active" : ""} onClick={() => selectChart("load")}><Activity size={14} />{ui(locale, "负载", "Load")}</button>
|
||||
{pingEnabled ? <button className={chartType === "latency" ? "active" : ""} onClick={() => selectChart("latency")}><RadioTower size={14} />{ui(locale, "延迟", "Latency")}</button> : null}
|
||||
</div>
|
||||
<div className="segmented range-control" aria-label="时间范围">
|
||||
{ranges.map((range) => <button className={hours === range.value ? "active" : ""} key={range.value} onClick={() => setHours(range.value)}>{range.label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="error-band">{error}</div> : null}
|
||||
{loading ? <div className="chart-loading">{ui(locale, "正在读取历史数据", "Loading history")}</div> : chartType === "load" && !data.length ? <div className="chart-loading">{ui(locale, "暂无历史数据", "No history")}</div> : chartType === "latency" && !(latencyData.length || data.length) ? <div className="chart-loading">{ui(locale, "暂无延迟数据", "No latency data")}</div> : chartType === "load" ? (
|
||||
<div className="detail-charts-grid">
|
||||
<ChartCard title="CPU" value={`${number(last.cpu).toFixed(1)}%`}>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 10 }} minTickGap={30} /><YAxis domain={[0, 100]} tick={{ fontSize: 10 }} width={36} /><Tooltip contentStyle={tooltipStyle} formatter={(value) => [`${Number(value).toFixed(1)}%`, "CPU"]} /><Area type="monotone" dataKey="cpu" stroke="var(--danger)" fill="var(--danger)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title={ui(locale, "内存", "Memory")} value={`${formatBytes(last.mem_used)} / ${formatBytes(last.mem_total)}`}>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 10 }} minTickGap={30} /><YAxis domain={[0, last.mem_total]} ticks={resourceTicks(last.mem_total)} tick={{ fontSize: 10 }} width={56} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} formatter={(value) => [formatBytes(Number(value)), "内存"]} /><Area type="monotone" dataKey="mem_used" stroke="var(--primary)" fill="var(--primary)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title={ui(locale, "硬盘", "Disk")} value={`${formatBytes(last.disk_used)} / ${formatBytes(last.disk_total)}`}>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 10 }} minTickGap={30} /><YAxis domain={[0, last.disk_total]} ticks={resourceTicks(last.disk_total)} tick={{ fontSize: 10 }} width={56} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} formatter={(value) => [formatBytes(Number(value)), "硬盘"]} /><Area type="monotone" dataKey="disk_used" stroke="var(--warning)" fill="var(--warning)" fillOpacity={0.15} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
</ChartCard>
|
||||
<ChartCard title={ui(locale, "网络", "Network")} value={`↑ ${formatSpeed(last.net_out)} ↓ ${formatSpeed(last.net_in)}`}>
|
||||
<ResponsiveContainer width="100%" height="100%"><AreaChart data={data}><CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 10 }} minTickGap={30} /><YAxis tick={{ fontSize: 10 }} width={48} tickFormatter={(value) => formatBytes(Number(value), 0)} /><Tooltip contentStyle={tooltipStyle} formatter={(value, name) => [formatSpeed(Number(value)), name === "net_out" ? "上行" : "下行"]} /><Area type="monotone" dataKey="net_out" stroke="var(--success)" fill="var(--success)" fillOpacity={0.12} strokeWidth={2} dot={false} isAnimationActive={false} /><Area type="monotone" dataKey="net_in" stroke="var(--info)" fill="var(--info)" fillOpacity={0.1} strokeWidth={2} dot={false} isAnimationActive={false} /></AreaChart></ResponsiveContainer>
|
||||
</ChartCard>
|
||||
</div>
|
||||
) : (
|
||||
<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}>
|
||||
<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-line-chart">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={latencyData.length ? latencyData : data} margin={{ top: 10, right: 12, left: 4, bottom: 2 }}>
|
||||
<CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} minTickGap={40} />
|
||||
<YAxis tick={{ fontSize: 10 }} width={48} unit="ms" />
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={(value, name) => [`${Number(value).toFixed(1)} ms`, name]} />
|
||||
<Legend iconType="rect" iconSize={9} wrapperStyle={{ fontSize: 11, paddingTop: 10 }} />
|
||||
{latencySeries.map((series) => <Line key={series.id} type="monotone" dataKey={series.dataKey} name={series.name} stroke={series.color} strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} />)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MonitorCog } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const systems = [
|
||||
["alma", "os-alma.svg", "AlmaLinux"], ["alpine", "os-alpine.webp", "Alpine Linux"],
|
||||
["arch", "os-arch.svg", "Arch Linux"], ["armbian", "os-armbian.png", "Armbian"],
|
||||
["centos", "os-centos.svg", "CentOS"], ["debian", "os-debian.svg", "Debian"],
|
||||
["fedora", "os-fedora.svg", "Fedora"], ["gentoo", "os-gentoo.svg", "Gentoo"],
|
||||
["red hat", "os-redhat.svg", "Red Hat"], ["rhel", "os-redhat.svg", "Red Hat"],
|
||||
["mint", "os-mint.svg", "Linux Mint"], ["manjaro", "os-manjaro.svg", "Manjaro"],
|
||||
["kali", "os-kali.svg", "Kali Linux"], ["openwrt", "os-openwrt.svg", "OpenWrt"],
|
||||
["istore", "os-istore.png", "iStoreOS"], ["opencloud", "os-opencloud.svg", "OpenCloudOS"],
|
||||
["ubuntu", "os-ubuntu.svg", "Ubuntu"], ["rocky", "os-rocky.svg", "Rocky Linux"],
|
||||
["oracle", "os-oracle.svg", "Oracle Linux"], ["suse", "os-opensuse.svg", "openSUSE"],
|
||||
["nix", "os-nix.svg", "NixOS"], ["proxmox", "os-proxmox.ico", "Proxmox VE"],
|
||||
["synology", "os-synology.ico", "Synology DSM"], ["macos", "os-macos.svg", "macOS"],
|
||||
["darwin", "os-macos.svg", "macOS"], ["windows", "os-windows.svg", "Windows"],
|
||||
] as const;
|
||||
|
||||
function systemInfo(os: string | null | undefined) {
|
||||
const source = (os || "").toLowerCase();
|
||||
const match = systems.find(([keyword]) => source.includes(keyword));
|
||||
return match ? { file: match[1], name: match[2] } : { file: "os-unknown.svg", name: os || "Linux" };
|
||||
}
|
||||
|
||||
export function OSIcon({ os, size = 16 }: { os: string | null | undefined; size?: number }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const info = systemInfo(os);
|
||||
if (failed) return <MonitorCog size={size} className="os-icon-fallback" aria-label={info.name} />;
|
||||
return <img className="os-icon" src={`/os-icons/${info.file}`} width={size} height={size} alt={info.name} title={info.name} loading="lazy" onError={() => setFailed(true)} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function ProgressBar({ value }: { value: number }) {
|
||||
const safe = Math.min(100, Math.max(0, value || 0));
|
||||
const status = safe >= 90 ? "danger" : safe >= 70 ? "warning" : "good";
|
||||
return (
|
||||
<span className="progress-track" aria-label={`${safe.toFixed(1)}%`}>
|
||||
<span className={`progress-fill ${status}`} style={{ width: `${safe}%` }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Activity, ArrowDown, ArrowUp, Database, ServerIcon, WalletCards } from "lucide-react";
|
||||
import { formatBytes, formatCurrency, formatSpeed, isOnline, number, remainingAssetValue } from "../format";
|
||||
import { assetCurrency, themeToggle } from "../theme";
|
||||
import type { Config, ExchangeRates, Server } from "../types";
|
||||
import { ui } from "../locale";
|
||||
|
||||
interface Stat {
|
||||
icon: typeof ServerIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
detail: React.ReactNode;
|
||||
tone?: string;
|
||||
}
|
||||
|
||||
export function StatsBar({ servers, config, exchangeRates }: { servers: Server[]; config: Config; exchangeRates: ExchangeRates | null }) {
|
||||
if (!config.show_stats) return null;
|
||||
const displayCurrency = assetCurrency(config);
|
||||
const locale = config.locale || "zh-CN";
|
||||
const showOnline = themeToggle(config, "showOnline");
|
||||
const onlineServers = servers.filter((server) => isOnline(server, config.offline_threshold_seconds));
|
||||
const trafficUp = servers.reduce((sum, server) => sum + number(server.net_tx_total), 0);
|
||||
const trafficDown = servers.reduce((sum, server) => sum + number(server.net_rx_total), 0);
|
||||
const speedUp = onlineServers.reduce((sum, server) => sum + number(server.net_out), 0);
|
||||
const speedDown = onlineServers.reduce((sum, server) => sum + number(server.net_in), 0);
|
||||
const paid = onlineServers.filter((server) => server.price > 0);
|
||||
const toDisplayCurrency = (value: number, currency: string) => {
|
||||
const code = currency.trim().toUpperCase();
|
||||
const sourceRate = code === "CNY" ? 1 : exchangeRates?.rates[code];
|
||||
const targetRate = displayCurrency === "CNY" ? 1 : exchangeRates?.rates[displayCurrency];
|
||||
if (typeof sourceRate !== "number" || !Number.isFinite(sourceRate) || sourceRate <= 0) return null;
|
||||
if (typeof targetRate !== "number" || !Number.isFinite(targetRate) || targetRate <= 0) return null;
|
||||
return value / sourceRate * targetRate;
|
||||
};
|
||||
const converted = paid.map((server) => ({ server, price: toDisplayCurrency(server.price, server.currency) }));
|
||||
const missingRates = converted.filter(({ price }) => price === null).length;
|
||||
const totalValue = converted.reduce((sum, { price }) => sum + (price ?? 0), 0);
|
||||
const remainingValue = converted.reduce((sum, { server, price }) => {
|
||||
if (price === null) return sum;
|
||||
const remaining = remainingAssetValue(server.price, server.billing_cycle, server.expires_at);
|
||||
return sum + remaining / server.price * price;
|
||||
}, 0);
|
||||
|
||||
const stats: Stat[] = [];
|
||||
if (showOnline) stats.push({
|
||||
icon: ServerIcon,
|
||||
label: ui(locale, "在线节点", "Online nodes"),
|
||||
value: String(onlineServers.length),
|
||||
unit: ui(locale, `/ ${servers.length} 台`, `/ ${servers.length}`),
|
||||
tone: "success-text",
|
||||
detail: servers.length === onlineServers.length ? ui(locale, "全部运行正常", "All systems operational") : ui(locale, `${servers.length - onlineServers.length} 台离线`, `${servers.length - onlineServers.length} offline`),
|
||||
});
|
||||
|
||||
if (config.show_assets) stats.push({
|
||||
icon: WalletCards,
|
||||
label: ui(locale, "在线资产", "Online assets"),
|
||||
value: formatCurrency(totalValue, displayCurrency),
|
||||
detail: <><span>{ui(locale, `剩余 ${formatCurrency(remainingValue, displayCurrency)}`, `Remaining ${formatCurrency(remainingValue, displayCurrency)}`)}</span><span>{ui(locale, `${paid.length} 台计费${missingRates ? ` · ${missingRates} 台汇率缺失` : ""}`, `${paid.length} billed${missingRates ? ` · ${missingRates} missing rates` : ""}`)}</span></>,
|
||||
});
|
||||
if (config.show_traffic) stats.push({
|
||||
icon: Database,
|
||||
label: ui(locale, "累计流量", "Total traffic"),
|
||||
value: formatBytes(trafficUp + trafficDown),
|
||||
detail: <><span className="success-text">{ui(locale, "上传", "Upload")} {formatBytes(trafficUp)}</span><span className="info-text">{ui(locale, "下载", "Download")} {formatBytes(trafficDown)}</span></>,
|
||||
});
|
||||
if (config.show_speed) stats.push({
|
||||
icon: Activity,
|
||||
label: ui(locale, "实时网速", "Live speed"),
|
||||
value: formatSpeed(speedUp + speedDown),
|
||||
detail: <><span className="success-text"><ArrowUp size={11} />{formatSpeed(speedUp)}</span><span className="info-text"><ArrowDown size={11} />{formatSpeed(speedDown)}</span></>,
|
||||
});
|
||||
|
||||
if (!stats.length) return null;
|
||||
return (
|
||||
<section className={`overview glass-panel overview-${Math.min(stats.length, 4)}`} aria-label={ui(locale, "服务器总览", "Server overview")}>
|
||||
{stats.map(({ icon: Icon, label, value, unit, detail, tone }) => (
|
||||
<div className="overview-item" key={label}>
|
||||
<div className="overview-label"><span>{label}</span><span className="stat-icon"><Icon size={17} /></span></div>
|
||||
<div className="overview-value"><strong className={tone}>{value}</strong>{unit ? <b>{unit}</b> : null}</div>
|
||||
<div className="overview-detail">{detail}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type TurnstileApi = {
|
||||
render: (target: HTMLElement, options: Record<string, unknown>) => string;
|
||||
remove: (widgetId: string) => void;
|
||||
reset: (widgetId: string) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileApi;
|
||||
}
|
||||
}
|
||||
|
||||
let scriptPromise: Promise<void> | null = null;
|
||||
|
||||
function loadScript() {
|
||||
if (window.turnstile) return Promise.resolve();
|
||||
if (scriptPromise) return scriptPromise;
|
||||
scriptPromise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => {
|
||||
scriptPromise = null;
|
||||
reject(new Error("Cloudflare Turnstile 加载失败"));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return scriptPromise;
|
||||
}
|
||||
|
||||
export function TurnstileWidget({
|
||||
siteKey,
|
||||
theme,
|
||||
resetKey,
|
||||
onVerify,
|
||||
onError,
|
||||
}: {
|
||||
siteKey: string;
|
||||
theme: "light" | "dark";
|
||||
resetKey?: number;
|
||||
onVerify: (token: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const target = useRef<HTMLDivElement>(null);
|
||||
const widgetId = useRef("");
|
||||
const [verified, setVerified] = useState(false);
|
||||
const verifyCallback = useRef(onVerify);
|
||||
const errorCallback = useRef(onError);
|
||||
verifyCallback.current = onVerify;
|
||||
errorCallback.current = onError;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setVerified(false);
|
||||
void loadScript().then(() => {
|
||||
if (cancelled || !target.current || !window.turnstile) return;
|
||||
target.current.replaceChildren();
|
||||
widgetId.current = window.turnstile.render(target.current, {
|
||||
sitekey: siteKey,
|
||||
theme,
|
||||
size: "flexible",
|
||||
callback: (token: string) => {
|
||||
setVerified(true);
|
||||
verifyCallback.current(token);
|
||||
},
|
||||
"expired-callback": () => {
|
||||
setVerified(false);
|
||||
verifyCallback.current("");
|
||||
},
|
||||
"error-callback": () => errorCallback.current("Cloudflare 验证暂时不可用,请刷新重试"),
|
||||
});
|
||||
}).catch((reason) => errorCallback.current(reason instanceof Error ? reason.message : "验证组件加载失败"));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (widgetId.current && window.turnstile) window.turnstile.remove(widgetId.current);
|
||||
widgetId.current = "";
|
||||
};
|
||||
}, [siteKey, theme, resetKey]);
|
||||
|
||||
const testing = siteKey.startsWith("1x00000000000000000000");
|
||||
return <div className={`turnstile-widget${verified ? " verified" : ""}${testing ? " testing" : ""}`} ref={target} />;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import type { Config, ExchangeRates, HistoryPoint, LatencySample, LatencyTestPoint, Server } from "./types";
|
||||
|
||||
export const demoConfig: Config = {
|
||||
site_name: "CF Monitor",
|
||||
site_description: "边缘节点与核心服务运行状态",
|
||||
site_announcement: "",
|
||||
favicon_url: "",
|
||||
locale: "zh-CN",
|
||||
federation_sites: [],
|
||||
public_dashboard: true,
|
||||
offline_threshold_seconds: 180,
|
||||
history_retention_days: 30,
|
||||
default_theme: "system",
|
||||
background_url: "",
|
||||
theme_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,
|
||||
turnstile_enabled: false,
|
||||
turnstile_login_enabled: false,
|
||||
turnstile_site_key: "",
|
||||
websocket: true,
|
||||
};
|
||||
|
||||
export const demoExchangeRates: ExchangeRates = {
|
||||
base: "CNY",
|
||||
rates: {
|
||||
CNY: 1,
|
||||
USD: 0.139,
|
||||
CAD: 0.2086,
|
||||
EUR: 0.119,
|
||||
GBP: 0.103,
|
||||
JPY: 21.1,
|
||||
HKD: 1.09,
|
||||
RUB: 11.560694,
|
||||
CHF: 0.120661,
|
||||
INR: 14.248668,
|
||||
VND: 3875.968992,
|
||||
THB: 4.97107,
|
||||
},
|
||||
source: "demo",
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
fetched_at: Math.floor(Date.now() / 1000),
|
||||
stale: false,
|
||||
};
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
export const demoLatencyTasks: LatencyTestPoint[] = [
|
||||
{ id: "demo-hk", name: "香港 TCP", task_type: "tcp", target: "hk.example.com:443", interval_seconds: 60 },
|
||||
{ id: "demo-tokyo", name: "东京 ICMP", task_type: "icmp", target: "tokyo.example.com", interval_seconds: 60 },
|
||||
{ id: "demo-sg", name: "新加坡 TCP", task_type: "tcp", target: "sg.example.com:443", interval_seconds: 60 },
|
||||
];
|
||||
|
||||
function demoLatestLatency(serverId: string): LatencySample[] {
|
||||
return demoLatencyTasks.map((task, index) => ({
|
||||
task_id: task.id,
|
||||
server_id: serverId,
|
||||
name: task.name,
|
||||
task_type: task.task_type,
|
||||
target: task.target,
|
||||
timestamp: now - 12,
|
||||
latency_ms: 38 + index * 7,
|
||||
packet_loss: index === 1 ? 0.4 : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
const baseServer: Server = {
|
||||
id: "",
|
||||
name: "",
|
||||
region: "",
|
||||
group_name: "默认",
|
||||
tags: "",
|
||||
note: "",
|
||||
hidden: 0,
|
||||
sort_order: 0,
|
||||
expires_at: null,
|
||||
traffic_limit: 0,
|
||||
traffic_limit_type: "sum",
|
||||
price: -2,
|
||||
billing_cycle: 30,
|
||||
currency: "CNY",
|
||||
auto_renewal: 0,
|
||||
public_remark: "",
|
||||
network_interface: "",
|
||||
reset_day: 1,
|
||||
report_interval: 60,
|
||||
collect_interval: 5,
|
||||
rx_correction: 0,
|
||||
tx_correction: 0,
|
||||
offline_notify_disabled: 0,
|
||||
auto_update: 0,
|
||||
created_at: now - 365 * 86400,
|
||||
updated_at: now,
|
||||
timestamp: now - 12,
|
||||
cpu: 24,
|
||||
load1: 0.42,
|
||||
load5: 0.35,
|
||||
load15: 0.27,
|
||||
mem_used: 3.36 * 1024 ** 3,
|
||||
mem_total: 8 * 1024 ** 3,
|
||||
swap_used: 0,
|
||||
swap_total: 2 * 1024 ** 3,
|
||||
disk_used: 49.6 * 1024 ** 3,
|
||||
disk_total: 160 * 1024 ** 3,
|
||||
net_in: 5.8 * 1024 ** 2,
|
||||
net_out: 1.2 * 1024 ** 2,
|
||||
net_rx_total: 640 * 1024 ** 3,
|
||||
net_tx_total: 220 * 1024 ** 3,
|
||||
uptime: 182 * 86400,
|
||||
processes: 126,
|
||||
tcp_connections: 342,
|
||||
udp_connections: 18,
|
||||
cpu_cores: 4,
|
||||
cpu_model: "AMD EPYC 7B13",
|
||||
os: "Debian GNU/Linux 12",
|
||||
kernel: "6.1.0",
|
||||
arch: "x86_64",
|
||||
virtualization: "KVM",
|
||||
ipv4: "203.0.113.8",
|
||||
ipv6: "2001:db8::8",
|
||||
gpu_usage: 0,
|
||||
gpu_model: "",
|
||||
agent_version: "0.1.0",
|
||||
disk_read_bps: 6.4 * 1024 ** 2,
|
||||
disk_write_bps: 2.1 * 1024 ** 2,
|
||||
disk_read_iops: 128,
|
||||
disk_write_iops: 46,
|
||||
disk_await_ms: 1.2,
|
||||
disk_utilization: 8.4,
|
||||
disks: [],
|
||||
gpus: [],
|
||||
message: "",
|
||||
latency: [],
|
||||
};
|
||||
|
||||
function node(sort_order: number, input: Partial<Server> & Pick<Server, "id" | "name">): Server {
|
||||
const server = { ...baseServer, ...input, sort_order };
|
||||
server.latency = input.latency ?? demoLatestLatency(server.id);
|
||||
return server;
|
||||
}
|
||||
|
||||
export const demoServers: Server[] = [
|
||||
node(0, { id: "hongkong-edge", name: "香港 Edge 01", region: "HK", group_name: "边缘网络", tags: "主力,线路:BGP,用途:网站", price: 128, expires_at: now + 46 * 86400, traffic_limit: 2 * 1024 ** 4 }),
|
||||
node(1, { id: "tokyo-core", name: "东京 Core", region: "JP", group_name: "核心服务", tags: "主力,线路:Premium", price: 9.9, currency: "USD", expires_at: now + 46 * 86400, mem_total: 16 * 1024 ** 3, mem_used: 6.72 * 1024 ** 3, disk_total: 224 * 1024 ** 3, disk_used: 69.44 * 1024 ** 3, traffic_limit: 2 * 1024 ** 4, net_rx_total: 810 * 1024 ** 3, net_tx_total: 310 * 1024 ** 3, cpu: 31 }),
|
||||
node(2, { id: "singapore-data", name: "新加坡 Data", region: "SG", group_name: "数据服务", tags: "数据库,NVMe", price: 88, expires_at: now + 46 * 86400, mem_total: 24 * 1024 ** 3, mem_used: 10.08 * 1024 ** 3, disk_total: 288 * 1024 ** 3, disk_used: 89.28 * 1024 ** 3, traffic_limit: 2 * 1024 ** 4, net_rx_total: 980 * 1024 ** 3, net_tx_total: 400 * 1024 ** 3, cpu: 38 }),
|
||||
node(3, { id: "los-angeles-west", name: "洛杉矶 West", region: "US", group_name: "边缘网络", tags: "备用,线路:CN2", price: 24, currency: "USD", expires_at: now + 46 * 86400, disk_total: 352 * 1024 ** 3, disk_used: 109.12 * 1024 ** 3, traffic_limit: 2 * 1024 ** 4, net_rx_total: 1.12 * 1024 ** 4, net_tx_total: 490 * 1024 ** 3, cpu: 45 }),
|
||||
node(4, { id: "frankfurt-lab", name: "法兰克福 Lab", region: "DE", group_name: "实验服务", tags: "Lab,IPv6", price: -1, expires_at: null, mem_total: 16 * 1024 ** 3, mem_used: 6.72 * 1024 ** 3, cpu: 52, uptime: 134 * 86400 }),
|
||||
node(5, { id: "taipei-homelab", name: "台北 HomeLab", region: "TW", group_name: "家庭网络", tags: "HomeLab,自建", price: -1, expires_at: null, mem_total: 24 * 1024 ** 3, mem_used: 10.08 * 1024 ** 3, cpu: 59, uptime: 122 * 86400 }),
|
||||
node(6, { id: "london-archive", name: "伦敦 Archive", region: "GB", group_name: "存储服务", tags: "Archive,HDD", price: 18, currency: "EUR", billing_cycle: 365, expires_at: now + 110 * 86400, disk_total: 4 * 1024 ** 4, disk_used: 2.7 * 1024 ** 4, cpu: 66, latency: [] }),
|
||||
node(7, { id: "toronto-standby", name: "多伦多 Standby", region: "CA", group_name: "备用节点", tags: "Standby", price: 16, currency: "CAD", expires_at: now + 18 * 86400, timestamp: now - 640, cpu: 0, net_in: 0, net_out: 0, message: "节点维护中" }),
|
||||
];
|
||||
|
||||
export function demoHistory(serverId: string, hours: number): HistoryPoint[] {
|
||||
const count = Math.min(180, Math.max(30, hours * 6));
|
||||
const step = Math.max(60, Math.floor((hours * 3600) / count));
|
||||
const seed = [...serverId].reduce((sum, char) => sum + char.charCodeAt(0), 0);
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const wave = Math.sin((index + seed) / 7);
|
||||
const pulse = Math.cos((index + seed) / 13);
|
||||
return {
|
||||
timestamp: now - (count - index - 1) * step,
|
||||
cpu: Math.max(2, Math.min(96, 34 + wave * 18 + pulse * 7)),
|
||||
load1: 0.8 + wave * 0.45,
|
||||
load5: 0.72 + wave * 0.32,
|
||||
load15: 0.64 + pulse * 0.24,
|
||||
mem_used: (6.2 + wave * 0.45) * 1024 ** 3,
|
||||
mem_total: 16 * 1024 ** 3,
|
||||
swap_used: 0.2 * 1024 ** 3,
|
||||
swap_total: 2 * 1024 ** 3,
|
||||
disk_used: (84 + index / count) * 1024 ** 3,
|
||||
disk_total: 224 * 1024 ** 3,
|
||||
net_in: Math.max(0, (8 + wave * 5) * 1024 ** 2),
|
||||
net_out: Math.max(0, (3 + pulse * 2) * 1024 ** 2),
|
||||
net_rx_total: (800 + index) * 1024 ** 3,
|
||||
net_tx_total: (300 + index * 0.5) * 1024 ** 3,
|
||||
processes: 142 + Math.round(wave * 8),
|
||||
tcp_connections: 380 + Math.round(pulse * 55),
|
||||
udp_connections: 21,
|
||||
gpu_usage: 0,
|
||||
disk_read_bps: Math.max(0, (5 + wave * 3) * 1024 ** 2),
|
||||
disk_write_bps: Math.max(0, (2 + pulse) * 1024 ** 2),
|
||||
disk_read_iops: 110 + wave * 30,
|
||||
disk_write_iops: 45 + pulse * 12,
|
||||
disk_await_ms: 1.2 + Math.abs(wave),
|
||||
disk_utilization: 8 + Math.abs(pulse) * 12,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function demoLatencyHistory(serverId: string, hours: number): LatencySample[] {
|
||||
const count = Math.min(180, Math.max(30, hours * 6));
|
||||
const step = Math.max(60, Math.floor((hours * 3600) / count));
|
||||
const seed = [...serverId].reduce((sum, char) => sum + char.charCodeAt(0), 0);
|
||||
return Array.from({ length: count }, (_, index) => demoLatencyTasks.map((task, taskIndex) => {
|
||||
const wave = Math.sin((index + seed + taskIndex * 5) / 7);
|
||||
return {
|
||||
task_id: task.id,
|
||||
server_id: serverId,
|
||||
name: task.name,
|
||||
task_type: task.task_type,
|
||||
target: task.target,
|
||||
timestamp: now - (count - index - 1) * step,
|
||||
latency_ms: 38 + taskIndex * 7 + wave * (4 + taskIndex),
|
||||
packet_loss: (index + taskIndex * 9) % 37 === 0 ? 2 + taskIndex : 0,
|
||||
};
|
||||
})).flat();
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Server, TrafficLimitType } from "./types";
|
||||
import { ui, type UiLocale } from "./locale";
|
||||
|
||||
export function number(value: number | null | undefined) {
|
||||
return Number.isFinite(value) ? Number(value) : 0;
|
||||
}
|
||||
|
||||
export function percent(used: number | null, total: number | null) {
|
||||
const safeTotal = number(total);
|
||||
return safeTotal > 0 ? Math.min(100, Math.max(0, (number(used) / safeTotal) * 100)) : 0;
|
||||
}
|
||||
|
||||
export function formatBytes(value: number | null | undefined, decimals = 1) {
|
||||
const size = Math.max(0, number(value));
|
||||
if (size === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)));
|
||||
return `${(size / 1024 ** index).toFixed(index === 0 ? 0 : decimals)} ${units[index]}`;
|
||||
}
|
||||
|
||||
export function formatSpeed(value: number | null | undefined) {
|
||||
return `${formatBytes(value)}/s`;
|
||||
}
|
||||
|
||||
export function formatUptime(seconds: number | null | undefined, locale: UiLocale = "zh-CN") {
|
||||
const days = Math.floor(number(seconds) / 86400);
|
||||
if (days > 0) return ui(locale, `${days} 天`, `${days} days`);
|
||||
const hours = Math.floor(number(seconds) / 3600);
|
||||
if (hours > 0) return ui(locale, `${hours} 小时`, `${hours} hours`);
|
||||
const minutes = Math.floor(number(seconds) / 60);
|
||||
return ui(locale, `${minutes} 分钟`, `${minutes} minutes`);
|
||||
}
|
||||
|
||||
export function isOnline(server: Server, threshold: number, at = Date.now() / 1000) {
|
||||
return !!server.timestamp && at - server.timestamp <= threshold;
|
||||
}
|
||||
|
||||
export function timeAgo(timestamp: number | null, locale: UiLocale = "zh-CN") {
|
||||
if (!timestamp) return ui(locale, "尚未上报", "never reported");
|
||||
const seconds = Math.max(0, Math.floor(Date.now() / 1000 - timestamp));
|
||||
if (seconds < 60) return ui(locale, `${seconds} 秒前`, `${seconds}s ago`);
|
||||
if (seconds < 3600) return ui(locale, `${Math.floor(seconds / 60)} 分钟前`, `${Math.floor(seconds / 60)}m ago`);
|
||||
if (seconds < 86400) return ui(locale, `${Math.floor(seconds / 3600)} 小时前`, `${Math.floor(seconds / 3600)}h ago`);
|
||||
return ui(locale, `${Math.floor(seconds / 86400)} 天前`, `${Math.floor(seconds / 86400)}d ago`);
|
||||
}
|
||||
|
||||
export function countryFlag(region: string) {
|
||||
const code = region.trim().slice(0, 2).toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(code)) return "";
|
||||
return String.fromCodePoint(...[...code].map((char) => 127397 + char.charCodeAt(0)));
|
||||
}
|
||||
|
||||
export function trafficUsed(server: Pick<Server, "net_rx_total" | "net_tx_total" | "traffic_limit_type">) {
|
||||
const down = number(server.net_rx_total);
|
||||
const up = number(server.net_tx_total);
|
||||
const type: TrafficLimitType = server.traffic_limit_type || "sum";
|
||||
if (type === "max") return Math.max(up, down);
|
||||
if (type === "min") return Math.min(up, down);
|
||||
if (type === "up") return up;
|
||||
if (type === "down") return down;
|
||||
return up + down;
|
||||
}
|
||||
|
||||
const currencySymbols: Record<string, string> = {
|
||||
CNY: "¥", USD: "$", CAD: "CA$", HKD: "HK$", EUR: "€", GBP: "£", JPY: "¥",
|
||||
RUB: "₽", CHF: "CHF ", INR: "₹", VND: "₫", THB: "฿",
|
||||
};
|
||||
|
||||
export function formatCurrency(value: number, currency = "CNY") {
|
||||
const code = currency.toUpperCase();
|
||||
const symbol = currencySymbols[code] ?? `${code} `;
|
||||
const rounded = (Math.round(number(value) * 100) / 100).toFixed(2).replace(/\.?0+$/, "");
|
||||
return `${symbol}${rounded || "0"}`;
|
||||
}
|
||||
|
||||
export function formatPrice(server: Pick<Server, "price" | "billing_cycle" | "currency">, locale: UiLocale = "zh-CN") {
|
||||
if (server.price === -1) return ui(locale, "免费", "Free");
|
||||
if (server.price === 0) return ui(locale, "未设置", "Not set");
|
||||
if (server.price < 0) return "";
|
||||
const cycle = server.billing_cycle >= 27 && server.billing_cycle <= 32 ? ui(locale, "月", "month")
|
||||
: server.billing_cycle >= 87 && server.billing_cycle <= 95 ? ui(locale, "季", "quarter")
|
||||
: server.billing_cycle >= 175 && server.billing_cycle <= 185 ? ui(locale, "半年", "half-year")
|
||||
: server.billing_cycle >= 360 && server.billing_cycle <= 370 ? ui(locale, "年", "year")
|
||||
: server.billing_cycle === -1 ? ui(locale, "一次", "one-time") : ui(locale, `${server.billing_cycle} 天`, `${server.billing_cycle} days`);
|
||||
return `${formatCurrency(server.price, server.currency)} / ${cycle}`;
|
||||
}
|
||||
|
||||
function daysUntil(timestamp: number | null) {
|
||||
if (!timestamp) return null;
|
||||
return Math.ceil((timestamp * 1000 - Date.now()) / 86_400_000);
|
||||
}
|
||||
|
||||
export function formatExpire(server: Pick<Server, "expires_at" | "price">, locale: UiLocale = "zh-CN") {
|
||||
if (server.price === -1) return ui(locale, "长期", "Lifetime");
|
||||
const days = daysUntil(server.expires_at);
|
||||
if (days === null) return ui(locale, "未设置", "Not set");
|
||||
if (days < 0) return ui(locale, "已过期", "Expired");
|
||||
return ui(locale, `${days} 天`, `${days} days`);
|
||||
}
|
||||
|
||||
export function remainingAssetValue(price: number, billingCycle: number, expiresAt: number | null) {
|
||||
if (price <= 0) return 0;
|
||||
const days = daysUntil(expiresAt);
|
||||
if (days === null || days <= 0) return 0;
|
||||
if (billingCycle <= 0) return price;
|
||||
return price * Math.min(days / billingCycle, 1);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import type { LatencySample, LatencyTestPoint, Server } from "../types";
|
||||
|
||||
export interface LatencyBar {
|
||||
key: string;
|
||||
tone: "good" | "fair" | "warning" | "poor" | "danger" | "empty";
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
const cache = new Map<string, { at: number; tasks: LatencyTestPoint[]; points: LatencySample[] }>();
|
||||
const BAR_COUNT = 20;
|
||||
|
||||
function latencyTone(value: number): LatencyBar["tone"] {
|
||||
if (value <= 60) return "good";
|
||||
if (value <= 100) return "fair";
|
||||
if (value <= 160) return "warning";
|
||||
if (value <= 200) return "poor";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function lossTone(value: number): LatencyBar["tone"] {
|
||||
if (value <= 1) return "good";
|
||||
if (value <= 3) return "fair";
|
||||
if (value <= 6) return "warning";
|
||||
if (value <= 9) return "poor";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function emptyBars(label: string): LatencyBar[] {
|
||||
return Array.from({ length: BAR_COUNT }, (_, index) => ({ key: `empty-${index}`, tone: "empty", tooltip: label }));
|
||||
}
|
||||
|
||||
function summarize(points: LatencySample[], field: "latency_ms" | "packet_loss") {
|
||||
const valid = points.filter((point) => Number.isFinite(point[field]) && point[field] >= 0);
|
||||
if (!valid.length) return { average: -1, bars: emptyBars("无采样数据") };
|
||||
const buckets = Array.from({ length: BAR_COUNT }, () => [] as LatencySample[]);
|
||||
valid.forEach((point, index) => buckets[Math.min(buckets.length - 1, Math.floor(index * buckets.length / valid.length))].push(point));
|
||||
const tone = field === "latency_ms" ? latencyTone : lossTone;
|
||||
const bars = buckets.map((bucket, index) => {
|
||||
if (!bucket.length) return { key: `${field}-empty-${index}`, tone: "empty" as const, tooltip: "无采样数据" };
|
||||
const value = bucket.reduce((sum, point) => sum + point[field], 0) / bucket.length;
|
||||
const timestamp = bucket[bucket.length - 1].timestamp;
|
||||
return {
|
||||
key: `${field}-${timestamp}-${index}`,
|
||||
tone: tone(value),
|
||||
tooltip: `${new Date(timestamp * 1000).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}\n${field === "latency_ms" ? `${Math.round(value)} ms` : `${value.toFixed(1)}%`}`,
|
||||
};
|
||||
});
|
||||
return { average: valid.reduce((sum, point) => sum + point[field], 0) / valid.length, bars };
|
||||
}
|
||||
|
||||
function tasksFromSamples(points: LatencySample[]): LatencyTestPoint[] {
|
||||
return Array.from(new Map(points.map((point) => [point.task_id, {
|
||||
id: point.task_id,
|
||||
name: point.name,
|
||||
task_type: point.task_type,
|
||||
target: point.target,
|
||||
interval_seconds: 60,
|
||||
}])).values());
|
||||
}
|
||||
|
||||
export function useNodeLatency(server: Server, enabled: boolean) {
|
||||
const initial = server.latency ?? [];
|
||||
const [tasks, setTasks] = useState<LatencyTestPoint[]>(() => tasksFromSamples(initial));
|
||||
const [points, setPoints] = useState<LatencySample[]>(initial);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
|
||||
useEffect(() => {
|
||||
if (!server.latency?.length) return;
|
||||
setTasks(tasksFromSamples(server.latency));
|
||||
setPoints(server.latency);
|
||||
}, [server.latency]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) { setLoading(false); return; }
|
||||
let active = true;
|
||||
const hit = cache.get(server.id);
|
||||
if (hit && Date.now() - hit.at < 60_000) {
|
||||
setTasks(hit.tasks);
|
||||
setPoints(hit.points);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
api.latencyHistory(server.id, 1).then((result) => {
|
||||
cache.set(server.id, { at: Date.now(), tasks: result.tasks, points: result.points });
|
||||
if (active) { setTasks(result.tasks); setPoints(result.points); }
|
||||
}).catch(() => {}).finally(() => { if (active) setLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [enabled, server.id]);
|
||||
|
||||
return useMemo(() => {
|
||||
const latency = summarize(points, "latency_ms");
|
||||
const loss = summarize(points, "packet_loss");
|
||||
return {
|
||||
configured: tasks.length > 0,
|
||||
latencyDisplay: latency.average >= 0 ? `${Math.round(latency.average)} ms` : loading ? "加载中" : "-",
|
||||
lossDisplay: loss.average >= 0 ? `${loss.average.toFixed(1)}%` : loading ? "加载中" : "-",
|
||||
latencyBars: latency.bars,
|
||||
lossBars: loss.bars,
|
||||
};
|
||||
}, [loading, points, tasks.length]);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type UiLocale = "zh-CN" | "en";
|
||||
|
||||
export function ui(locale: UiLocale | string | undefined, chinese: string, english: string) {
|
||||
return locale === "en" ? english : chinese;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
:root {
|
||||
--background: #edf1f3;
|
||||
--foreground: #11191f;
|
||||
--card-solid: #f8fafb;
|
||||
--card: rgb(248 250 251 / 0.78);
|
||||
--card-hover: rgb(255 255 255 / 0.92);
|
||||
--node-card-bg: rgb(241 245 249 / 0.74);
|
||||
--node-card-hover: rgb(248 250 252 / 0.8);
|
||||
--node-card-border: rgb(226 232 240 / 0.68);
|
||||
--node-card-shadow: 0 8px 28px rgb(15 23 42 / 0.18);
|
||||
--node-data-bg: rgb(100 116 139 / 0.055);
|
||||
--primary: #0f766e;
|
||||
--primary-foreground: #fff;
|
||||
--muted: #e7ecee;
|
||||
--muted-foreground: #59666f;
|
||||
--border: rgb(83 104 116 / 0.18);
|
||||
--success: #078a63;
|
||||
--warning: #c77a10;
|
||||
--danger: #dc3f4f;
|
||||
--info: #2563b9;
|
||||
--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);
|
||||
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;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0d1011;
|
||||
--foreground: #f3f6f5;
|
||||
--card-solid: #171b1c;
|
||||
--card: rgb(21 27 28 / 0.82);
|
||||
--card-hover: rgb(29 36 37 / 0.92);
|
||||
--node-card-bg: rgb(10 14 22 / 0.82);
|
||||
--node-card-hover: rgb(10 14 22 / 0.9);
|
||||
--node-card-border: rgb(255 255 255 / 0.18);
|
||||
--node-card-shadow: 0 8px 30px rgb(0 0 0 / 0.48);
|
||||
--node-data-bg: rgb(148 163 184 / 0.065);
|
||||
--primary: #39c79a;
|
||||
--primary-foreground: #07130f;
|
||||
--muted: #242a2b;
|
||||
--muted-foreground: #9fa9a7;
|
||||
--border: rgb(218 234 230 / 0.12);
|
||||
--success: #38c895;
|
||||
--warning: #f0ad3d;
|
||||
--danger: #fb7185;
|
||||
--info: #69a7e8;
|
||||
--chart-grid: rgb(218 234 230 / 0.1);
|
||||
--shadow: 0 12px 36px rgb(0 0 0 / 0.28), inset 0 1px 0 rgb(255 255 255 / 0.035);
|
||||
--node-offline-bg: rgb(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--foreground) 18%, transparent) transparent; }
|
||||
html, body, #root { min-width: 320px; min-height: 100%; margin: 0; }
|
||||
html { background: var(--background); }
|
||||
body { min-height: 100vh; overflow-x: hidden; background: var(--background); letter-spacing: 0; -webkit-font-smoothing: antialiased; }
|
||||
button, input, select, textarea { color: inherit; font: inherit; letter-spacing: 0; }
|
||||
button { border: 0; background: none; cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: .42; }
|
||||
input, textarea, select { width: 100%; min-height: 38px; border: 1px solid var(--border); border-radius: 6px; outline: none; padding: 8px 10px; background: color-mix(in srgb, var(--card-solid) 90%, transparent); font-size: 12px; }
|
||||
textarea { resize: vertical; }
|
||||
input:focus, textarea:focus, select:focus { border-color: var(--primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 16%, transparent); }
|
||||
::selection { color: var(--primary-foreground); background: var(--primary); }
|
||||
|
||||
.app-shell { position: relative; z-index: 0; display: flex; min-height: 100vh; flex-direction: column; }
|
||||
.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::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; }
|
||||
.glass-panel { border: 1px solid var(--node-card-border); background: var(--node-card-bg); box-shadow: var(--shadow); backdrop-filter: blur(14px) saturate(1.45); }
|
||||
html[data-blur="off"] .glass-panel { background: color-mix(in srgb, var(--card-solid) 94%, var(--background)); backdrop-filter: none; }
|
||||
.success-text { color: var(--success) !important; }
|
||||
.info-text { color: var(--info) !important; }
|
||||
.warning-text { color: var(--warning) !important; }
|
||||
.danger-text { color: var(--danger) !important; }
|
||||
|
||||
.site-header { position: relative; z-index: 1; }
|
||||
.header-inner { display: flex; min-height: 64px; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.brand { display: flex; min-width: 0; align-items: center; gap: 10px; padding: 0; color: inherit; text-align: left; }
|
||||
.brand img { flex: 0 0 auto; object-fit: contain; }
|
||||
.brand strong { overflow: hidden; font-size: 17px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.header-actions, .header-buttons, .row-actions, .section-actions { display: flex; align-items: center; gap: 3px; }
|
||||
.header-actions { gap: 4px; }
|
||||
.header-actions .icon-btn { color: var(--foreground); }
|
||||
.icon-btn { display: inline-flex; width: 36px; height: 36px; flex: 0 0 auto; align-items: center; justify-content: center; border-radius: 8px; color: var(--muted-foreground); }
|
||||
.icon-btn:hover { color: var(--foreground); background: color-mix(in srgb, var(--foreground) 7%, transparent); }
|
||||
.icon-btn.danger:hover { color: var(--danger); background: color-mix(in srgb, var(--danger) 9%, transparent); }
|
||||
.main-content { flex: 1; padding-top: 20px; padding-bottom: 24px; }
|
||||
.home-content { display: flex; flex-direction: column; gap: 16px; }
|
||||
.site-announcement { display: flex; min-height: 42px; align-items: flex-start; gap: 9px; margin-bottom: 14px; border: 1px solid color-mix(in srgb, var(--primary) 28%, var(--border)); border-radius: 7px; padding: 11px 13px; color: var(--foreground); background: color-mix(in srgb, var(--primary) 6%, var(--card)); font-size: 11px; line-height: 1.7; white-space: pre-wrap; }
|
||||
.site-announcement svg { flex: 0 0 auto; margin-top: 1px; color: var(--primary); }
|
||||
|
||||
.overview { display: grid; overflow: hidden; border-radius: 8px; box-shadow: 0 8px 28px rgb(31 48 58 / 0.07), inset 0 1px 0 rgb(255 255 255 / 0.45); grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.dark .overview { box-shadow: 0 8px 28px rgb(0 0 0 / 0.2); }
|
||||
.overview-1 { grid-template-columns: 1fr; }
|
||||
.overview-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.overview-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.overview-item { min-width: 0; padding: 14px 20px; }
|
||||
.overview-label { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; color: var(--muted-foreground); font-size: 12px; font-weight: 500; }
|
||||
.stat-icon { display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; border-radius: 7px; color: var(--primary); background: color-mix(in srgb, var(--primary) 9%, transparent); }
|
||||
.overview-value { display: flex; min-width: 0; align-items: baseline; gap: 6px; }
|
||||
.overview-value strong { overflow: hidden; font-size: 28px; font-weight: 600; line-height: 1; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.overview-value b { flex: 0 0 auto; color: var(--muted-foreground); font-size: 12px; font-weight: 500; }
|
||||
.overview-detail { display: flex; min-height: 16px; align-items: center; gap: 8px; margin-top: 6px; overflow: hidden; color: var(--muted-foreground); font-size: 12px; white-space: nowrap; }
|
||||
.overview-detail span { display: inline-flex; min-width: 0; align-items: center; gap: 3px; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.toolbar { display: flex; min-height: 36px; align-items: center; gap: 14px; }
|
||||
.search-box { display: flex; width: 220px; height: 36px; flex: 0 0 auto; align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: 7px; padding: 0 10px; color: var(--muted-foreground); background: color-mix(in srgb, var(--card-solid) 58%, transparent); }
|
||||
.search-box:focus-within { border-color: var(--primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 12%, transparent); }
|
||||
.search-box input { min-width: 0; min-height: 0; border: 0; padding: 0; background: transparent; font-size: 12px; box-shadow: none; }
|
||||
.search-box input:focus { box-shadow: none; }
|
||||
.group-tabs { display: flex; min-width: 0; gap: 3px; overflow-x: auto; padding: 3px; border-radius: 7px; background: color-mix(in srgb, var(--muted) 74%, transparent); }
|
||||
.group-tabs button { display: inline-flex; min-height: 29px; flex: 0 0 auto; align-items: center; justify-content: center; gap: 5px; border-radius: 5px; padding: 0 10px; color: var(--muted-foreground); font-size: 11px; white-space: nowrap; }
|
||||
.group-tabs button.active { color: var(--foreground); background: var(--card-solid); box-shadow: 0 1px 4px rgb(20 35 42 / .08); }
|
||||
.result-count { margin-left: auto; color: var(--muted-foreground); font-size: 10px; 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: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:focus-visible, .icon-btn:focus-visible, .drag-handle:focus-visible, .primary-btn:focus-visible, .secondary-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
.node-card.offline { box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 30%, transparent), var(--node-card-shadow); }
|
||||
.node-card.offline:hover { box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 30%, transparent), 0 12px 38px rgb(15 23 42 / .2); }
|
||||
.dark .node-card.offline:hover { box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 30%, transparent), 0 12px 38px rgb(0 0 0 / .54); }
|
||||
.node-header { display: flex; min-width: 0; min-height: 44px; flex: 0 0 auto; align-items: center; gap: 8px; padding: 12px 16px; }
|
||||
.node-header strong { min-width: 0; flex: 1; overflow: hidden; font-size: 14px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.status-dot { width: 10px; height: 10px; flex: 0 0 auto; border-radius: 50%; background: var(--danger); box-shadow: 0 0 0 0 color-mix(in srgb, var(--danger) 40%, transparent); animation: status-pulse 2s ease-out infinite; }
|
||||
.status-dot.online { background: var(--success); box-shadow: 0 0 0 0 color-mix(in srgb, var(--success) 40%, transparent); }
|
||||
@keyframes status-pulse { 70%, 100% { box-shadow: 0 0 0 6px transparent; } }
|
||||
.node-alert { flex: 0 0 auto; color: var(--warning); }
|
||||
.os-icon, .os-icon-fallback { width: 16px; height: 16px; flex: 0 0 auto; object-fit: contain; }
|
||||
.os-icon-fallback { color: var(--muted-foreground); }
|
||||
.region-flag { display: inline-flex; min-width: 18px; flex: 0 0 auto; align-items: center; justify-content: center; line-height: 1; }
|
||||
.node-body { position: relative; display: flex; min-height: 0; flex: 1; flex-direction: column; justify-content: space-between; gap: 14px; padding: 0 16px 16px; }
|
||||
.node-chips { display: flex; height: 19px; flex: 0 0 auto; align-items: center; gap: 6px; overflow: hidden; margin-top: -4px; }
|
||||
.node-chips span { min-width: 0; overflow: hidden; border-radius: 999px; padding: 2px 8px; color: var(--muted-foreground); background: color-mix(in srgb, var(--muted) 82%, transparent); font-size: 11px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.node-chips span + span { margin-left: auto; text-align: right; }
|
||||
.metric-grid { display: grid; gap: 12px 16px; grid-template-columns: 1fr 1fr; }
|
||||
.metric { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.metric.muted { opacity: .55; }
|
||||
.metric > div { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; }
|
||||
.metric span, .metric small { color: var(--muted-foreground); }
|
||||
.metric strong { font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.metric small { overflow: hidden; font-size: 11px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.progress-track { display: block; width: 100%; height: 4px; overflow: hidden; border-radius: 2px; background: color-mix(in srgb, var(--foreground) 9%, transparent); }
|
||||
.progress-fill { display: block; height: 100%; border-radius: 2px; transition: width 300ms ease; }
|
||||
.progress-fill.good { background: var(--success); }
|
||||
.progress-fill.warning { background: var(--warning); }
|
||||
.progress-fill.danger { background: var(--danger); }
|
||||
.data-grid { display: grid; gap: 6px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.data-grid.two-columns { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.data-panel { display: flex; min-width: 0; min-height: 44px; flex-direction: column; justify-content: center; gap: 2px; overflow: hidden; border-radius: 8px; padding: 7px 8px; background: var(--node-data-bg); }
|
||||
.data-panel b { display: flex; min-width: 0; align-items: center; gap: 4px; color: var(--muted-foreground); font-size: 11px; font-weight: 400; font-style: normal; }
|
||||
.data-panel b > span { display: inline-flex; width: 10px; height: 10px; flex: 0 0 10px; align-items: center; justify-content: center; }
|
||||
.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 > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--muted-foreground); font-size: 11px; line-height: 1; }
|
||||
.quality-panel b { color: var(--foreground); font-size: 11px; 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); }
|
||||
.quality-bars i { display: block; width: 100%; height: 100%; min-height: 3px; border-radius: 1px; }
|
||||
.quality-bars i.good { background: var(--success); }
|
||||
.quality-bars i.fair { background: #4ade80; }
|
||||
.quality-bars i.warning { background: #a3e635; }
|
||||
.quality-bars i.poor { background: #facc15; }
|
||||
.quality-bars i.danger { background: var(--danger); }
|
||||
.quality-bars i.empty { background: color-mix(in srgb, var(--muted-foreground) 14%, transparent); }
|
||||
.quality-tooltip { position: absolute; z-index: 20; bottom: calc(100% + 6px); visibility: hidden; width: max-content; max-width: 128px; border-radius: 3px; padding: 4px 6px; color: var(--background); background: color-mix(in srgb, var(--foreground) 92%, transparent); box-shadow: 0 6px 18px rgb(10 18 20 / .18); font-size: 10px; font-weight: 400; font-style: normal; line-height: 1.25; opacity: 0; pointer-events: none; white-space: pre-line; transform: translateY(2px); transition: opacity 150ms ease, transform 150ms ease, visibility 150ms ease; }
|
||||
.quality-tooltip.start { left: 0; }
|
||||
.quality-tooltip.center { left: 50%; transform: translate(-50%, 2px); }
|
||||
.quality-tooltip.end { right: 0; }
|
||||
.quality-bar:hover { z-index: 20; }
|
||||
.quality-bar:hover .quality-tooltip { visibility: visible; opacity: 1; transform: translateY(0); }
|
||||
.quality-bar:hover .quality-tooltip.center { transform: translate(-50%, 0); }
|
||||
.offline-layer { position: absolute; z-index: 3; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; border-radius: 0 0 12px 12px; background: var(--node-offline-bg); backdrop-filter: blur(2px); }
|
||||
html[data-blur="off"] .offline-layer { backdrop-filter: none; }
|
||||
.offline-layer strong { color: var(--danger); font-size: 13px; }
|
||||
.offline-layer span { margin-top: 4px; color: var(--muted-foreground); font-size: 9px; }
|
||||
|
||||
.error-band { display: flex; align-items: center; justify-content: space-between; border: 1px solid color-mix(in srgb, var(--danger) 32%, var(--border)); border-radius: 7px; padding: 12px 14px; color: var(--danger); background: color-mix(in srgb, var(--danger) 7%, transparent); font-size: 11px; }
|
||||
.error-band button { font-weight: 700; }
|
||||
.empty-state, .chart-loading { display: flex; min-height: 340px; align-items: center; justify-content: center; flex-direction: column; gap: 16px; color: var(--muted-foreground); font-size: 12px; }
|
||||
.verification-gate { display: flex; width: min(430px, 100%); min-height: 380px; align-items: center; justify-content: center; flex-direction: column; gap: 14px; margin: 12vh auto 0; border: 1px solid var(--border); border-radius: 8px; padding: 30px; background: var(--card); box-shadow: var(--shadow); text-align: center; }
|
||||
.verification-gate h1 { margin: 0; font-size: 19px; }
|
||||
.verification-gate p { margin: 4px 0 0; color: var(--muted-foreground); font-size: 10px; }
|
||||
.verification-status { color: var(--primary); font-size: 10px; }
|
||||
.turnstile-widget { width: min(100%, 300px); min-height: 66px; }
|
||||
.turnstile-widget.testing { height: 51px; min-height: 51px; overflow: hidden; }
|
||||
.turnstile-widget.verified { display: none; }
|
||||
.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; }
|
||||
@keyframes loading-pulse { to { opacity: .46; } }
|
||||
|
||||
.detail-page { display: flex; flex-direction: column; gap: 14px; }
|
||||
.detail-hero { display: flex; min-width: 0; align-items: center; gap: 12px; border-radius: 8px; padding: 14px 20px; }
|
||||
.detail-hero > .icon-btn { margin-left: -4px; }
|
||||
.detail-flag { flex: 0 0 auto; font-size: 23px; }
|
||||
.detail-title { min-width: 0; flex: 1; }
|
||||
.detail-title > div { display: flex; min-width: 0; align-items: center; gap: 8px; }
|
||||
.detail-title h1 { overflow: hidden; margin: 0; font-size: 20px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-title p { display: flex; overflow: hidden; align-items: center; gap: 6px; margin: 4px 0 0; color: var(--muted-foreground); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.status-chip { flex: 0 0 auto; border: 1px solid color-mix(in srgb, var(--danger) 20%, transparent); border-radius: 999px; padding: 2px 7px; color: var(--danger); background: color-mix(in srgb, var(--danger) 8%, transparent); font-size: 10px; line-height: 1.25; }
|
||||
.status-chip.online { border-color: color-mix(in srgb, var(--success) 20%, transparent); color: var(--success); background: color-mix(in srgb, var(--success) 8%, transparent); }
|
||||
.info-groups { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; }
|
||||
.info-group { border-radius: 8px; padding: 16px; }
|
||||
.info-group h2 { display: flex; align-items: center; gap: 8px; margin: 0 0 4px; font-size: 14px; font-weight: 600; }
|
||||
.info-group h2 svg { color: var(--primary); }
|
||||
.info-group-grid { display: grid; gap: 0 16px; grid-template-columns: minmax(0, 1.08fr) minmax(0, .92fr); }
|
||||
.info-group-grid > :nth-child(even) { padding-left: 6px; }
|
||||
.info-item { display: flex; min-width: 0; align-items: flex-start; gap: 8px; padding: 10px 0; }
|
||||
.info-item:nth-child(n+3) { border-top: 1px solid var(--border); }
|
||||
.info-item > span { display: inline-flex; width: 24px; height: 24px; flex: 0 0 auto; align-items: center; justify-content: center; border-radius: 6px; color: var(--muted-foreground); background: color-mix(in srgb, var(--foreground) 5%, transparent); }
|
||||
.info-item > div { min-width: 0; flex: 1; }
|
||||
.info-item small { display: block; color: var(--muted-foreground); font-size: 11px; }
|
||||
.info-item strong { display: block; margin-top: 2px; font-size: 14px; font-weight: 500; overflow-wrap: anywhere; }
|
||||
.chart-section { display: flex; flex-direction: column; gap: 12px; }
|
||||
.chart-controls { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.segmented { display: inline-flex; min-width: 0; min-height: 36px; align-items: center; gap: 3px; overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; padding: 3px; background: color-mix(in srgb, var(--card-solid) 78%, transparent); }
|
||||
.segmented button { display: inline-flex; min-height: 29px; flex: 0 0 auto; align-items: center; justify-content: center; gap: 5px; border-radius: 6px; padding: 4px 10px; color: var(--muted-foreground); font-size: 12px; white-space: nowrap; }
|
||||
.segmented button.active { color: var(--primary-foreground); background: var(--primary); box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 22%, transparent); }
|
||||
.range-control { max-width: 100%; }
|
||||
.detail-charts-grid { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; }
|
||||
.detail-chart { min-width: 0; overflow: hidden; border-radius: 8px; padding: 16px; }
|
||||
.detail-chart > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 0 8px; }
|
||||
.detail-chart h3 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.detail-chart header span { overflow: hidden; color: var(--muted-foreground); font-size: 12px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-chart > div { width: 100%; height: 208px; }
|
||||
.latency-overview { min-width: 0; overflow: hidden; border-radius: 8px; }
|
||||
.latency-summary-grid { display: grid; min-width: 100%; overflow-x: auto; border-bottom: 1px solid var(--border); }
|
||||
.latency-summary { min-width: 180px; border-right: 1px solid var(--border); padding: 17px 20px; }
|
||||
.latency-summary:last-child { border-right: 0; }
|
||||
.latency-summary > span { display: flex; min-width: 0; align-items: center; gap: 7px; overflow: hidden; color: var(--muted-foreground); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.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: 11px; font-variant-numeric: tabular-nums; }
|
||||
.latency-line-chart { width: 100%; height: 320px; padding: 16px 18px 10px 10px; }
|
||||
|
||||
.modal-backdrop, .submodal-backdrop { position: fixed; z-index: 80; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgb(5 10 12 / .56); backdrop-filter: blur(6px); }
|
||||
.submodal-backdrop { z-index: 90; background: rgb(5 10 12 / .42); }
|
||||
.admin-page { min-height: 100dvh; padding: 16px; background: var(--background); }
|
||||
.admin-loading { display: grid; min-height: 100dvh; place-items: center; color: var(--muted-foreground); font-size: 11px; }
|
||||
.admin-shell { width: min(1280px, 100%); height: calc(100dvh - 32px); min-height: 0; overflow: hidden; margin: 0 auto; border-radius: 8px; background: color-mix(in srgb, var(--card-solid) 96%, transparent); }
|
||||
.admin-login-stage { position: relative; display: grid; min-height: calc(100dvh - 32px); place-items: center; }
|
||||
.admin-back { position: absolute; top: 3px; left: 3px; display: inline-flex; min-height: 36px; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 7px; padding: 7px 12px; color: var(--muted-foreground); background: var(--card-solid); font-size: 10px; }
|
||||
.admin-back:hover { color: var(--foreground); }
|
||||
.modal-header { display: flex; min-width: 0; min-height: 70px; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--border); padding: 17px 20px; }
|
||||
.eyebrow { display: block; margin-bottom: 3px; color: var(--primary); font-size: 8px; font-weight: 750; text-transform: uppercase; }
|
||||
.modal-header h2 { margin: 0; font-size: 18px; }
|
||||
.admin-body { display: grid; height: calc(100% - 70px); min-height: 0; grid-template-columns: 140px minmax(0, 1fr); }
|
||||
.admin-tabs { display: flex; flex-direction: column; gap: 4px; border-right: 1px solid var(--border); padding: 14px 9px; }
|
||||
.admin-tabs button { display: flex; min-height: 38px; align-items: center; gap: 8px; border-radius: 6px; padding: 0 10px; color: var(--muted-foreground); font-size: 11px; text-align: left; }
|
||||
.admin-tabs button.active { color: var(--primary); background: color-mix(in srgb, var(--primary) 10%, transparent); font-weight: 650; }
|
||||
.admin-content { position: relative; min-width: 0; height: 100%; overflow: auto; }
|
||||
.admin-section, .settings-form { min-width: 0; padding: 19px 21px; }
|
||||
.section-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 13px; }
|
||||
.section-head h3 { margin: 0; font-size: 13px; }
|
||||
.section-head span { color: var(--muted-foreground); font-size: 8px; }
|
||||
.section-actions, .data-actions, .batch-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; }
|
||||
.batch-toolbar { justify-content: space-between; min-height: 38px; border-block: 1px solid var(--border); color: var(--muted-foreground); font-size: 9px; }
|
||||
.select-all { display: inline-flex; align-items: center; flex-direction: row; gap: 7px; color: var(--muted-foreground); font-size: 9px; }
|
||||
.primary-btn, .secondary-btn { display: inline-flex; min-height: 36px; align-items: center; justify-content: center; gap: 7px; border-radius: 7px; padding: 7px 13px; font-size: 11px; font-weight: 650; }
|
||||
.primary-btn { color: var(--primary-foreground); background: var(--primary); }
|
||||
.primary-btn:hover { filter: brightness(1.05); }
|
||||
.secondary-btn { border: 1px solid var(--border); background: var(--card-solid); }
|
||||
.primary-btn.compact { min-height: 32px; padding: 5px 10px; }
|
||||
.danger-btn { display: inline-flex; min-height: 36px; align-items: center; justify-content: center; gap: 7px; border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); border-radius: 7px; padding: 7px 13px; color: var(--danger); background: color-mix(in srgb, var(--danger) 7%, transparent); font-size: 11px; font-weight: 650; }
|
||||
.danger-btn.compact { min-height: 29px; padding: 4px 9px; font-size: 9px; }
|
||||
.server-list { border-top: 1px solid var(--border); }
|
||||
.server-row { display: grid; min-width: 0; min-height: 58px; align-items: center; gap: 9px; border-bottom: 1px solid var(--border); grid-template-columns: 18px 20px 9px minmax(0, 1fr) auto auto; transition: opacity .15s ease, background-color .15s ease; }
|
||||
.server-row.dragging { opacity: .45; background: color-mix(in srgb, var(--primary) 8%, transparent); }
|
||||
.drag-handle { display: inline-flex; width: 18px; height: 32px; align-items: center; justify-content: center; padding: 0; color: var(--muted-foreground); cursor: grab; touch-action: none; }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
.server-name { display: flex; min-width: 0; flex-direction: column; }
|
||||
.server-row strong { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.server-row small, .server-usage { color: var(--muted-foreground); font-size: 8px; }
|
||||
.server-row .row-actions { margin-left: 3px; }
|
||||
.server-row .icon-btn { width: 30px; height: 30px; }
|
||||
.list-empty { padding: 72px 0; color: var(--muted-foreground); font-size: 10px; text-align: center; }
|
||||
.login-form { display: flex; width: min(390px, calc(100% - 28px)); align-items: stretch; flex-direction: column; gap: 16px; border-radius: 8px; padding: 30px; background: color-mix(in srgb, var(--card-solid) 97%, transparent); }
|
||||
.login-form img { align-self: center; margin-bottom: 8px; }
|
||||
.login-copy { text-align: center; }
|
||||
.login-copy h1 { margin: 0; font-size: 18px; }
|
||||
.login-copy p { margin: 5px 0 0; color: var(--muted-foreground); font-size: 10px; }
|
||||
.login-turnstile { display: flex; min-height: 66px; justify-content: center; }
|
||||
.login-turnstile:has(.turnstile-widget.testing) { min-height: 51px; }
|
||||
.login-turnstile:has(.turnstile-widget.verified) { min-height: 0; }
|
||||
.login-submit { width: 100%; min-height: 40px; }
|
||||
.login-error { border: 1px solid color-mix(in srgb, var(--danger) 32%, var(--border)); border-radius: 6px; padding: 8px 10px; background: color-mix(in srgb, var(--danger) 6%, transparent); }
|
||||
label { display: flex; min-width: 0; flex-direction: column; gap: 6px; }
|
||||
label > span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.form-error { margin: 0; color: var(--danger); font-size: 9px; }
|
||||
.form-notice { margin: 0 21px 9px; border: 1px solid color-mix(in srgb, var(--success) 32%, var(--border)); border-radius: 6px; padding: 8px 10px; color: var(--success); background: color-mix(in srgb, var(--success) 7%, transparent); font-size: 9px; }
|
||||
.admin-error { margin: 0 21px 10px; border: 1px solid color-mix(in srgb, var(--danger) 32%, var(--border)); border-radius: 6px; padding: 8px 10px; background: color-mix(in srgb, var(--danger) 6%, transparent); line-height: 1.5; }
|
||||
.settings-form, .editor-modal { display: flex; flex-direction: column; gap: 14px; }
|
||||
.form-grid { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; }
|
||||
.form-grid.three { grid-template-columns: repeat(3, 1fr); }
|
||||
.settings-toggles { display: grid; gap: 8px; grid-template-columns: 1fr 1fr; }
|
||||
.toggle-row { display: flex; min-height: 48px; align-items: center; justify-content: space-between; flex-direction: row; gap: 14px; border: 1px solid var(--border); border-radius: 7px; padding: 9px 11px; }
|
||||
.toggle-row > span { color: var(--foreground); }
|
||||
.toggle-row b { font-size: 10px; }
|
||||
.checkbox-control { position: relative; display: inline-flex; width: 20px; height: 20px; flex: 0 0 20px; align-items: center; justify-content: center; overflow: hidden; border: 1px solid var(--border); border-radius: 4px; background: var(--card-solid); transition: border-color .15s ease, background-color .15s ease; }
|
||||
.checkbox-control input { position: absolute; inset: 0; width: 100%; height: 100%; min-height: 0; margin: 0; border: 0; padding: 0; opacity: 0; cursor: pointer; }
|
||||
.checkbox-control svg { width: 14px; height: 14px; color: var(--primary-foreground); opacity: 0; pointer-events: none; stroke-width: 3; }
|
||||
.checkbox-control:has(input:checked) { border-color: var(--primary); background: var(--primary); }
|
||||
.checkbox-control:has(input:checked) svg { opacity: 1; }
|
||||
.checkbox-control:has(input:focus-visible) { outline: 2px solid color-mix(in srgb, var(--primary) 35%, transparent); outline-offset: 2px; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 2px; }
|
||||
.section-title { display: flex; align-items: center; gap: 7px; margin: 1px 0 2px; color: var(--foreground); font-size: 12px; font-weight: 700; }
|
||||
.section-title svg { color: var(--primary); }
|
||||
.section-subtitle { margin-top: 3px; color: var(--muted-foreground); font-size: 10px; font-weight: 650; }
|
||||
.settings-hint, .security-note { margin: 0; color: var(--muted-foreground); font-size: 9px; line-height: 1.7; }
|
||||
.security-note { border: 1px solid var(--border); border-radius: 6px; padding: 9px 10px; }
|
||||
.data-stat-grid { display: grid; gap: 8px; grid-template-columns: repeat(3, 1fr); }
|
||||
.data-stat { display: flex; min-height: 82px; justify-content: center; flex-direction: column; gap: 8px; border: 1px solid var(--border); border-radius: 7px; padding: 12px; background: color-mix(in srgb, var(--foreground) 2%, transparent); }
|
||||
.data-stat span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.data-stat strong { font-size: 21px; }
|
||||
.usage-section { display: flex; flex-direction: column; gap: 10px; border-top: 1px solid var(--border); padding-top: 14px; }
|
||||
.usage-head { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.usage-head .section-title { margin-bottom: 3px; }
|
||||
.usage-config-actions { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||
.usage-config-actions .settings-hint { margin: 0; }
|
||||
.usage-table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 7px; }
|
||||
.usage-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-variant-numeric: tabular-nums; }
|
||||
.usage-table th, .usage-table td { height: 36px; border-bottom: 1px solid var(--border); padding: 6px 9px; text-align: right; font-size: 9px; white-space: nowrap; }
|
||||
.usage-table th:first-child, .usage-table td:first-child { width: 18%; text-align: left; }
|
||||
.usage-table thead th { color: var(--muted-foreground); background: color-mix(in srgb, var(--foreground) 3%, transparent); font-weight: 600; }
|
||||
.usage-table tbody th { font-weight: 650; }
|
||||
.usage-table tbody tr:last-child > * { border-bottom: 0; }
|
||||
.usage-empty { display: flex; min-height: 48px; align-items: center; justify-content: center; border: 1px dashed var(--border); border-radius: 7px; color: var(--muted-foreground); font-size: 9px; }
|
||||
.data-actions { border-top: 1px solid var(--border); padding-top: 14px; }
|
||||
.theme-section { display: flex; flex-direction: column; gap: 15px; }
|
||||
.theme-grid { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.theme-card { min-width: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: color-mix(in srgb, var(--card-solid) 90%, transparent); }
|
||||
.theme-card.active { border-color: color-mix(in srgb, var(--primary) 64%, var(--border)); }
|
||||
.theme-card > img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-bottom: 1px solid var(--border); background: var(--muted); }
|
||||
.theme-card-body { display: flex; min-width: 0; flex-direction: column; gap: 10px; padding: 12px; }
|
||||
.theme-card-title { display: flex; min-width: 0; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||||
.theme-card-title > div { min-width: 0; }
|
||||
.theme-card-title strong, .theme-card-title span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.theme-card-title strong { font-size: 12px; }
|
||||
.theme-card-title span, .theme-card-title b, .theme-branch { color: var(--muted-foreground); font-size: 8px; }
|
||||
.theme-card-title b { flex: 0 0 auto; border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border)); border-radius: 8px; padding: 2px 6px; color: var(--primary); }
|
||||
.theme-card-body p { min-height: 32px; margin: 0; color: var(--muted-foreground); font-size: 9px; line-height: 1.7; }
|
||||
.theme-actions { display: flex; justify-content: flex-end; gap: 7px; }
|
||||
.theme-empty { display: flex; min-height: 160px; align-items: center; justify-content: center; border: 1px dashed var(--border); border-radius: 8px; color: var(--muted-foreground); font-size: 10px; grid-column: 1 / -1; }
|
||||
.custom-theme { display: flex; flex-direction: column; gap: 9px; border-top: 1px solid var(--border); padding-top: 15px; }
|
||||
.custom-theme-controls { display: grid; gap: 8px; grid-template-columns: minmax(0, 1fr) auto auto; }
|
||||
.theme-option-grid { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; }
|
||||
.theme-option-empty { display: grid; min-height: 74px; place-items: center; border: 1px dashed var(--border); border-radius: 7px; color: var(--muted-foreground); font-size: 10px; }
|
||||
.theme-color-option input { width: 100%; min-height: 40px; padding: 4px; cursor: pointer; }
|
||||
.editor-modal, .install-modal { width: min(740px, 100%); max-height: calc(100vh - 48px); overflow: auto; border-radius: 8px; padding: 18px; background: var(--card-solid); }
|
||||
.editor-modal > header, .install-modal > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 2px; }
|
||||
.editor-modal h3, .install-modal h3 { margin: 0; font-size: 14px; }
|
||||
.editor-toggles { grid-template-columns: 1fr 1fr; }
|
||||
.install-list { display: flex; flex-direction: column; gap: 12px; max-height: 55vh; overflow: auto; margin: 15px 0; }
|
||||
.install-list strong { display: block; margin-bottom: 5px; font-size: 10px; }
|
||||
.install-modal pre { overflow-x: auto; margin: 0; border: 1px solid var(--border); border-radius: 6px; padding: 12px; color: var(--foreground); background: var(--background); font: 9px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.latency-section { display: flex; min-height: 100%; flex-direction: column; }
|
||||
.latency-task-list { border-top: 1px solid var(--border); }
|
||||
.latency-task-row { display: grid; min-height: 64px; align-items: center; gap: 12px; border-bottom: 1px solid var(--border); grid-template-columns: 78px minmax(140px, 1fr) 58px 130px auto; }
|
||||
.latency-type { display: inline-flex; width: fit-content; min-height: 27px; align-items: center; gap: 5px; border: 1px solid var(--border); border-radius: 6px; padding: 4px 7px; color: var(--primary); background: color-mix(in srgb, var(--primary) 7%, transparent); font-size: 8px; font-weight: 750; }
|
||||
.latency-type.tcp { color: #b45309; background: rgb(180 83 9 / .07); }
|
||||
.latency-task-name { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.latency-task-name strong, .latency-task-name small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.latency-task-name strong { font-size: 11px; }
|
||||
.latency-task-name small, .latency-task-meta { color: var(--muted-foreground); font-size: 8px; }
|
||||
.latency-editor { display: flex; width: min(620px, 100%); max-height: calc(100dvh - 48px); overflow: auto; flex-direction: column; gap: 14px; border-radius: 8px; padding: 19px; background: var(--card-solid); }
|
||||
.latency-editor > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.latency-editor h3 { margin: 0; font-size: 15px; }
|
||||
.task-type-control { height: 36px; }
|
||||
.task-type-control button { flex: 1; }
|
||||
.server-picker { overflow: hidden; border: 1px solid var(--border); border-radius: 7px; }
|
||||
.server-picker-head { display: grid; min-height: 42px; align-items: center; gap: 10px; border-bottom: 1px solid var(--border); padding: 7px 10px; grid-template-columns: 1fr auto auto; }
|
||||
.server-picker-head strong { font-size: 10px; }
|
||||
.server-picker-head span { color: var(--muted-foreground); font-size: 8px; }
|
||||
.server-picker-head button { border-radius: 5px; padding: 5px 7px; color: var(--primary); background: color-mix(in srgb, var(--primary) 8%, transparent); font-size: 9px; }
|
||||
.server-picker-search { display: flex; height: 39px; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); padding: 0 10px; color: var(--muted-foreground); }
|
||||
.server-picker-search input { min-height: 30px; border: 0; padding: 0; background: transparent; box-shadow: none; }
|
||||
.server-picker-list { max-height: 210px; overflow: auto; }
|
||||
.server-picker-row { display: grid; min-height: 44px; align-items: center; gap: 10px; border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); padding: 7px 10px; grid-template-columns: 20px 1fr; cursor: pointer; }
|
||||
.server-picker-row:last-child { border-bottom: 0; }
|
||||
.server-picker-row:hover { background: color-mix(in srgb, var(--primary) 5%, transparent); }
|
||||
.server-picker-row > span { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
|
||||
.server-picker-row strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.server-picker-row small { color: var(--muted-foreground); font-size: 8px; }
|
||||
.server-picker-empty { display: grid; min-height: 70px; place-items: center; color: var(--muted-foreground); font-size: 9px; }
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.brand strong { font-size: 18px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.container { padding-inline: 24px; }
|
||||
.overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.overview-item { border-bottom: 1px solid var(--border); }
|
||||
.overview-item:last-child, .overview-item:nth-last-child(2):nth-child(odd) { border-bottom: 0; }
|
||||
.overview-1 { grid-template-columns: 1fr; }
|
||||
.info-groups { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.form-grid.three { grid-template-columns: 1fr 1fr; }
|
||||
.theme-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.detail-charts-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.container { padding-inline: 16px; }
|
||||
.main-content { padding-top: 16px; }
|
||||
.home-content { gap: 14px; }
|
||||
.overview-item { min-height: 100px; padding: 12px 16px; }
|
||||
.overview-label { margin-bottom: 8px; }
|
||||
.overview-value strong { font-size: 25px; }
|
||||
.overview-detail { gap: 8px; font-size: 11px; }
|
||||
.toolbar { flex-wrap: wrap; gap: 8px; }
|
||||
.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; }
|
||||
.detail-title h1 { font-size: 18px; }
|
||||
.info-group-grid { grid-template-columns: 1fr; }
|
||||
.info-group-grid > :nth-child(even) { padding-left: 0; }
|
||||
.info-item:nth-child(2) { border-top: 1px solid var(--border); }
|
||||
.chart-controls { align-items: stretch; flex-direction: column; }
|
||||
.chart-controls > .segmented:first-child { width: fit-content; }
|
||||
.range-control { width: 100%; }
|
||||
.range-control button { flex: 0 0 auto; }
|
||||
.detail-chart > div { height: 192px; }
|
||||
.latency-summary { padding: 14px 16px; }
|
||||
.latency-summary strong { font-size: 19px; }
|
||||
.latency-summary-grid { overflow: visible; grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
|
||||
.latency-summary { min-width: 0; }
|
||||
.latency-summary:nth-child(2n) { border-right: 0; }
|
||||
.latency-summary:nth-child(n+3) { border-top: 1px solid var(--border); }
|
||||
.latency-line-chart { height: 270px; padding: 12px 10px 8px 4px; }
|
||||
.modal-backdrop, .submodal-backdrop { align-items: stretch; padding: 0; }
|
||||
.admin-page { padding: 0; }
|
||||
.admin-shell, .editor-modal, .install-modal, .latency-editor { width: 100%; max-height: 100dvh; border: 0; border-radius: 0; }
|
||||
.admin-shell { height: 100dvh; min-height: 100dvh; }
|
||||
.admin-login-stage { min-height: 100dvh; }
|
||||
.admin-back { top: 12px; left: 12px; }
|
||||
.login-form { width: calc(100% - 26px); padding: 24px 20px; }
|
||||
.modal-header { min-height: 62px; padding: 13px 14px; }
|
||||
.admin-body { display: flex; height: calc(100vh - 62px); min-height: 0; flex-direction: column; }
|
||||
.admin-tabs { overflow-x: auto; flex-direction: row; border-right: 0; border-bottom: 1px solid var(--border); padding: 7px 10px; }
|
||||
.admin-tabs button { min-height: 33px; flex: 0 0 auto; }
|
||||
.admin-content { height: calc(100vh - 112px); }
|
||||
.admin-section, .settings-form { padding: 14px; }
|
||||
.admin-error { margin-inline: 14px; }
|
||||
.section-head { align-items: flex-start; }
|
||||
.server-row { padding-block: 6px; grid-template-columns: 18px 20px 9px minmax(0, 1fr) auto; }
|
||||
.server-usage { display: none; }
|
||||
.server-row .row-actions { grid-column: 4 / 6; justify-self: end; }
|
||||
.form-grid, .form-grid.three, .settings-toggles, .theme-option-grid { grid-template-columns: 1fr; }
|
||||
.data-stat-grid { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.usage-table th, .usage-table td { padding-inline: 5px; font-size: 8px; }
|
||||
.editor-modal, .install-modal { padding: 15px 13px; }
|
||||
.editor-toggles { grid-template-columns: 1fr 1fr; }
|
||||
.custom-theme-controls { grid-template-columns: 1fr 1fr; }
|
||||
.custom-theme-controls input { grid-column: 1 / -1; }
|
||||
.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; }
|
||||
.latency-task-row .latency-task-meta:nth-of-type(3) { grid-column: 2; }
|
||||
.latency-task-row .row-actions { grid-column: 3; grid-row: 1 / 3; }
|
||||
.latency-editor { padding: 15px 13px; }
|
||||
}
|
||||
|
||||
/* Management workspace: deliberately independent from the public theme. */
|
||||
.admin-page, .admin-loading {
|
||||
--admin-bg: #f4f6f8;
|
||||
--admin-surface: #fff;
|
||||
--admin-text: #111827;
|
||||
--admin-muted: #4b5563;
|
||||
--admin-border: #d9dee5;
|
||||
--admin-border-strong: #c7ced8;
|
||||
--admin-border-hover: #aeb8c5;
|
||||
--admin-hover: #eef1f5;
|
||||
--admin-active-bg: #e9efff;
|
||||
--admin-accent: #244fc7;
|
||||
--admin-accent-border: #4169e1;
|
||||
}
|
||||
.admin-page.admin-dark, .admin-loading.admin-dark {
|
||||
--admin-bg: #101416;
|
||||
--admin-surface: #1a1f22;
|
||||
--admin-text: #f3f4f6;
|
||||
--admin-muted: #b9c0c7;
|
||||
--admin-border: #343b40;
|
||||
--admin-border-strong: #485159;
|
||||
--admin-border-hover: #65717b;
|
||||
--admin-hover: #252b2f;
|
||||
--admin-active-bg: #1d3557;
|
||||
--admin-accent: #9abaff;
|
||||
--admin-accent-border: #6f98ff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
.admin-page {
|
||||
min-height: 100dvh;
|
||||
padding: 0;
|
||||
color: var(--admin-text);
|
||||
background: var(--admin-bg);
|
||||
font-size: 14px;
|
||||
}
|
||||
.admin-page .glass-panel { border-color: var(--admin-border); background: var(--admin-surface); box-shadow: 0 18px 45px rgb(17 24 39 / .12); backdrop-filter: none; }
|
||||
.admin-page input, .admin-page textarea, .admin-page select { min-height: 40px; border-color: var(--admin-border-strong); color: var(--admin-text); background: var(--admin-surface); font-size: 14px; }
|
||||
.admin-page input::placeholder, .admin-page textarea::placeholder { color: var(--admin-muted); }
|
||||
.admin-page label > span { color: var(--admin-text); font-size: 13px; font-weight: 600; }
|
||||
.admin-page .icon-btn { color: var(--admin-muted); }
|
||||
.admin-page .icon-btn:hover { color: var(--admin-text); background: var(--admin-hover); }
|
||||
.admin-page button svg { width: 15px; height: 15px; }
|
||||
.admin-page .primary-btn, .admin-page .secondary-btn, .admin-page .danger-btn { min-height: 36px; font-size: 13px; }
|
||||
.admin-page .primary-btn.compact { min-height: 34px; padding: 6px 11px; }
|
||||
.admin-page .secondary-btn { border-color: var(--admin-border-strong); color: var(--admin-text); background: var(--admin-surface); }
|
||||
.admin-loading { color: var(--admin-text); background: var(--admin-bg); font-size: 14px; }
|
||||
.admin-login-stage { min-height: 100dvh; background: var(--admin-bg); }
|
||||
.admin-back { top: 22px; left: 26px; min-height: 38px; border-color: var(--admin-border); color: var(--admin-text); background: var(--admin-surface); font-size: 13px; }
|
||||
.admin-login-theme { position: absolute; top: 22px; right: 26px; display: inline-flex; width: 38px; height: 38px; align-items: center; justify-content: center; border: 1px solid var(--admin-border); border-radius: 6px; color: var(--admin-text); background: var(--admin-surface); }
|
||||
.admin-login-theme:hover { background: var(--admin-hover); }
|
||||
.login-form { width: min(410px, calc(100% - 32px)); gap: 16px; border: 1px solid var(--admin-border); padding: 32px; }
|
||||
.login-form img { width: 44px; height: 44px; }
|
||||
.login-copy h1 { color: var(--admin-text); font-size: 22px; }
|
||||
.login-copy p { color: var(--admin-muted); font-size: 13px; }
|
||||
.login-error, .admin-error { font-size: 13px; }
|
||||
.admin-shell { display: grid; width: 100%; max-width: 100vw; height: 100dvh; min-width: 0; min-height: 0; overflow: hidden; border: 0; border-radius: 0; background: var(--admin-surface); grid-template-rows: 64px minmax(0, 1fr); }
|
||||
.admin-topbar { z-index: 2; display: flex; width: 100%; max-width: 100vw; min-width: 0; align-items: center; justify-content: space-between; gap: 24px; overflow: hidden; border-bottom: 1px solid var(--admin-border); padding: 0 24px; background: var(--admin-surface); }
|
||||
.admin-brand { display: flex; min-width: 0; align-items: center; gap: 10px; padding: 0; color: var(--admin-text); text-align: left; text-decoration: none; }
|
||||
.admin-brand img { width: 32px; height: 32px; flex: 0 0 auto; }
|
||||
.admin-brand strong { overflow: hidden; max-width: 320px; font-size: 18px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.admin-topbar-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.admin-topbar-actions button { display: inline-flex; min-height: 36px; align-items: center; gap: 6px; border: 1px solid var(--admin-border); border-radius: 6px; padding: 0 11px; color: var(--admin-text); background: var(--admin-surface); font-size: 13px; font-weight: 600; }
|
||||
.admin-topbar-actions button:hover { border-color: var(--admin-border-hover); background: var(--admin-bg); }
|
||||
.admin-body { display: grid; width: 100%; max-width: 100vw; height: auto; min-width: 0; min-height: 0; overflow: hidden; grid-template-columns: 220px minmax(0, 1fr); }
|
||||
.admin-sidebar { min-height: 0; overflow-y: auto; border-right: 1px solid var(--admin-border); padding: 20px 14px; background: var(--admin-surface); }
|
||||
.admin-nav-label { display: block; margin: 0 11px 8px; color: var(--admin-muted); font-size: 12px; font-weight: 700; }
|
||||
.admin-tabs { gap: 5px; border: 0; padding: 0; }
|
||||
.admin-tabs button { min-height: 44px; gap: 9px; border-radius: 6px; padding: 0 12px; color: var(--admin-text); font-size: 14px; font-weight: 600; }
|
||||
.admin-tabs button svg { width: 17px; height: 17px; }
|
||||
.admin-tabs button:hover { background: var(--admin-hover); }
|
||||
.admin-tabs button.active { border-left: 3px solid var(--admin-accent-border); padding-left: 9px; color: var(--admin-accent); background: var(--admin-active-bg); font-weight: 700; }
|
||||
.admin-content { width: 100%; max-width: 100%; height: 100%; min-width: 0; overflow-y: auto; color: var(--admin-text); background: var(--admin-bg); }
|
||||
.admin-content-header { padding: 25px 28px 17px; }
|
||||
.admin-content-header h1 { margin: 0; color: var(--admin-text); font-size: 22px; line-height: 1.25; }
|
||||
.admin-content-header p { margin: 6px 0 0; color: var(--admin-muted); font-size: 13px; }
|
||||
.admin-section, .settings-form { margin: 0 28px 28px; border: 1px solid var(--admin-border); border-radius: 7px; padding: 21px; color: var(--admin-text); background: var(--admin-surface); }
|
||||
.section-head { margin-bottom: 16px; }
|
||||
.section-head h3 { color: var(--admin-text); font-size: 17px; }
|
||||
.section-head span { color: var(--admin-muted); font-size: 12px; }
|
||||
.batch-toolbar { min-height: 44px; color: var(--admin-muted); font-size: 13px; }
|
||||
.select-all { color: var(--admin-text); font-size: 13px; }
|
||||
.server-row { min-height: 62px; gap: 10px; grid-template-columns: 20px 20px 9px minmax(0, 1fr) auto auto; }
|
||||
.server-row strong { color: var(--admin-text); font-size: 14px; }
|
||||
.server-row small, .server-usage { color: var(--admin-muted); font-size: 12px; }
|
||||
.server-row .icon-btn { width: 32px; height: 32px; }
|
||||
.drag-handle { color: var(--admin-muted); }
|
||||
.list-empty { color: var(--admin-muted); font-size: 13px; }
|
||||
.form-notice { margin: 0 28px 14px; padding: 10px 12px; font-size: 13px; }
|
||||
.admin-error { margin: 0 28px 14px; padding: 10px 12px; }
|
||||
.settings-form, .editor-modal { gap: 17px; }
|
||||
.form-grid { gap: 15px; }
|
||||
.settings-toggles { gap: 12px; }
|
||||
.toggle-row { min-height: 52px; border-color: var(--admin-border); padding: 9px 12px; background: var(--admin-surface); }
|
||||
.toggle-row b { color: var(--admin-text); font-size: 13px; }
|
||||
.admin-page .checkbox-control { border-color: var(--admin-border-strong); background: var(--admin-surface); }
|
||||
.admin-page .checkbox-control:has(input:checked) { border-color: var(--primary); background: var(--primary); }
|
||||
.section-title { color: var(--admin-text); font-size: 16px; }
|
||||
.section-subtitle { color: var(--admin-text); font-size: 14px; }
|
||||
.settings-hint, .security-note { color: var(--admin-muted); font-size: 12px; }
|
||||
.data-stat span { color: var(--admin-muted); font-size: 12px; }
|
||||
.usage-table th, .usage-table td { height: 40px; color: var(--admin-text); font-size: 12px; }
|
||||
.usage-table thead th { color: var(--admin-muted); background: var(--admin-bg); }
|
||||
.usage-empty { color: var(--admin-muted); font-size: 13px; }
|
||||
.theme-card-title strong { color: var(--admin-text); font-size: 14px; }
|
||||
.theme-card-title span, .theme-card-title b, .theme-branch { color: var(--admin-muted); font-size: 11px; }
|
||||
.theme-card-body p { color: var(--admin-muted); font-size: 12px; }
|
||||
.theme-empty { color: var(--admin-muted); font-size: 13px; }
|
||||
.theme-option-empty { color: var(--admin-muted); font-size: 13px; }
|
||||
.editor-modal, .install-modal, .latency-editor { border: 1px solid var(--admin-border); color: var(--admin-text); background: var(--admin-surface); }
|
||||
.editor-modal, .install-modal { padding: 22px; }
|
||||
.editor-modal h3, .install-modal h3, .latency-editor h3 { color: var(--admin-text); font-size: 18px; }
|
||||
.eyebrow { color: var(--admin-accent); font-size: 11px; }
|
||||
.install-list strong { color: var(--admin-text); font-size: 13px; }
|
||||
.install-modal pre { border-color: var(--admin-border); color: var(--admin-text); background: var(--admin-bg); font-size: 12px; }
|
||||
.latency-task-row { min-height: 66px; }
|
||||
.latency-type { font-size: 11px; }
|
||||
.latency-task-name strong { color: var(--admin-text); font-size: 14px; }
|
||||
.latency-task-name small, .latency-task-meta { color: var(--admin-muted); font-size: 12px; }
|
||||
.latency-editor { gap: 16px; padding: 22px; }
|
||||
.server-picker { border-color: var(--admin-border-strong); }
|
||||
.server-picker-head { min-height: 50px; }
|
||||
.server-picker-head strong { color: var(--admin-text); font-size: 13px; }
|
||||
.server-picker-head span { color: var(--admin-muted); font-size: 12px; }
|
||||
.server-picker-head button { font-size: 12px; }
|
||||
.server-picker-search { height: 42px; color: var(--admin-muted); }
|
||||
.server-picker-row { min-height: 49px; }
|
||||
.server-picker-row strong { color: var(--admin-text); font-size: 13px; }
|
||||
.server-picker-row small { color: var(--admin-muted); font-size: 11px; }
|
||||
.server-picker-empty { color: var(--admin-muted); font-size: 12px; }
|
||||
.alert-rule-manager { display: grid; gap: 12px; border-top: 1px solid var(--admin-border); padding-top: 18px; }
|
||||
.alert-rule-list { display: grid; border: 1px solid var(--admin-border); border-radius: 6px; overflow: hidden; }
|
||||
.alert-rule-row { display: grid; min-height: 58px; align-items: center; gap: 10px; border-bottom: 1px solid var(--admin-border); padding: 9px 11px; grid-template-columns: 20px minmax(0, 1fr) 32px 32px; }
|
||||
.alert-rule-row:last-child { border-bottom: 0; }
|
||||
.alert-rule-row > div { display: grid; min-width: 0; gap: 3px; }
|
||||
.alert-rule-row strong { color: var(--admin-text); font-size: 13px; }
|
||||
.alert-rule-row small { overflow: hidden; color: var(--admin-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.alert-rule-editor { display: grid; width: min(680px, calc(100% - 30px)); max-height: min(820px, calc(100dvh - 30px)); gap: 16px; overflow-y: auto; border: 1px solid var(--admin-border); border-radius: 7px; padding: 22px; color: var(--admin-text); background: var(--admin-surface); }
|
||||
.alert-rule-editor header h3 { margin: 2px 0 0; font-size: 18px; }
|
||||
.alert-server-picker { display: grid; max-height: 230px; overflow-y: auto; border: 1px solid var(--admin-border); border-radius: 6px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.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; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.admin-shell { grid-template-rows: 58px minmax(0, 1fr); }
|
||||
.admin-topbar { gap: 10px; padding: 0 12px; }
|
||||
.admin-brand { gap: 8px; }
|
||||
.admin-brand img { width: 29px; height: 29px; }
|
||||
.admin-brand strong { max-width: 125px; font-size: 15px; }
|
||||
.admin-topbar-actions { gap: 5px; }
|
||||
.admin-topbar-actions button { width: 36px; min-height: 36px; justify-content: center; padding: 0; font-size: 0; }
|
||||
.admin-body { display: flex; height: auto; flex-direction: column; }
|
||||
.admin-sidebar { width: 100%; max-width: 100vw; min-width: 0; overflow-x: auto; overflow-y: hidden; flex: 0 0 auto; border-right: 0; border-bottom: 1px solid var(--admin-border); padding: 7px 10px; }
|
||||
.admin-nav-label { display: none; }
|
||||
.admin-tabs { width: max-content; min-width: 100%; overflow: visible; flex-direction: row; padding: 0; }
|
||||
.admin-tabs button { min-height: 39px; flex: 0 0 auto; font-size: 13px; }
|
||||
.admin-tabs button.active { border-bottom: 3px solid var(--admin-accent-border); border-left: 0; padding: 0 13px; }
|
||||
.admin-content { height: auto; flex: 1; }
|
||||
.admin-content-header { padding: 19px 15px 13px; }
|
||||
.admin-content-header h1 { font-size: 20px; }
|
||||
.admin-content-header p { font-size: 12px; }
|
||||
.admin-section, .settings-form { margin: 0 12px 18px; padding: 17px 15px; }
|
||||
.form-notice, .admin-error { margin: 0 12px 12px; }
|
||||
.section-head { min-width: 0; align-items: flex-start; flex-wrap: wrap; }
|
||||
.server-row { grid-template-columns: 20px 20px 9px minmax(0, 1fr) auto; }
|
||||
.server-row .row-actions { grid-column: 4 / 6; }
|
||||
.editor-modal, .install-modal, .latency-editor { padding: 20px 15px; }
|
||||
.alert-rule-editor { padding: 20px 15px; }
|
||||
.alert-server-picker { grid-template-columns: 1fr; }
|
||||
.admin-back { top: 12px; left: 12px; }
|
||||
.admin-login-theme { top: 12px; right: 12px; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ASSET_CURRENCIES, type AssetCurrency, type Config, type ThemeSettingValue } from "./types";
|
||||
|
||||
function option(config: Config, key: string): ThemeSettingValue | undefined {
|
||||
return config.theme_options?.[key];
|
||||
}
|
||||
|
||||
export function assetCurrency(config: Config): AssetCurrency {
|
||||
const value = option(config, "assetCurrency");
|
||||
return typeof value === "string" && (ASSET_CURRENCIES as readonly string[]).includes(value)
|
||||
? value as AssetCurrency
|
||||
: "CNY";
|
||||
}
|
||||
|
||||
export function themeToggle(config: Config, key: string, fallback = true) {
|
||||
const value = option(config, key);
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
export function resolveBackground(raw: string, dark: boolean) {
|
||||
const parts = raw.split("|").map((part) => part.trim());
|
||||
if (!parts[0] && !parts[1]) return "";
|
||||
return dark ? parts[1] || parts[0] : parts[0];
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
export interface Config {
|
||||
site_name: string;
|
||||
site_description: string;
|
||||
site_announcement: string;
|
||||
favicon_url: string;
|
||||
locale: "zh-CN" | "en";
|
||||
federation_sites: FederationSite[];
|
||||
public_dashboard: boolean;
|
||||
offline_threshold_seconds: number;
|
||||
history_retention_days: number;
|
||||
default_theme: "system" | "light" | "dark";
|
||||
background_url: string;
|
||||
theme_url: string;
|
||||
theme_options: Record<string, ThemeSettingValue>;
|
||||
show_search: boolean;
|
||||
show_groups: boolean;
|
||||
show_stats: boolean;
|
||||
show_assets: boolean;
|
||||
show_traffic: boolean;
|
||||
show_speed: boolean;
|
||||
show_price: boolean;
|
||||
show_expiry: boolean;
|
||||
show_latency: boolean;
|
||||
show_uptime: boolean;
|
||||
turnstile_enabled: boolean;
|
||||
turnstile_login_enabled: boolean;
|
||||
turnstile_site_key: string;
|
||||
websocket: boolean;
|
||||
}
|
||||
|
||||
export type ThemeSettingValue = string | number | boolean;
|
||||
|
||||
export const ASSET_CURRENCIES = [
|
||||
"CNY", "USD", "HKD", "EUR", "GBP", "JPY", "RUB", "CHF", "INR", "VND", "THB", "CAD",
|
||||
] as const;
|
||||
export type AssetCurrency = typeof ASSET_CURRENCIES[number];
|
||||
|
||||
interface ThemeSettingChoice {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ThemeSettingField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "textarea" | "url" | "color" | "select" | "toggle" | "number";
|
||||
default?: ThemeSettingValue;
|
||||
placeholder?: string;
|
||||
options?: ThemeSettingChoice[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface ThemeSettingsSchema {
|
||||
schema: number;
|
||||
source: "builtin" | "third-party";
|
||||
settings: ThemeSettingField[];
|
||||
}
|
||||
|
||||
export interface ExchangeRates {
|
||||
base: "CNY";
|
||||
rates: Record<string, number>;
|
||||
source: string;
|
||||
date: string;
|
||||
fetched_at: number;
|
||||
stale: boolean;
|
||||
cny?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface FederationSite {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface DiskMetric {
|
||||
name: string;
|
||||
mount_point: string;
|
||||
used: number;
|
||||
total: number;
|
||||
read_bps: number;
|
||||
write_bps: number;
|
||||
read_iops: number;
|
||||
write_iops: number;
|
||||
await_ms: number;
|
||||
utilization: number;
|
||||
}
|
||||
|
||||
interface GpuMetric {
|
||||
model: string;
|
||||
usage: number;
|
||||
memory_used: number;
|
||||
memory_total: number;
|
||||
}
|
||||
|
||||
export type TrafficLimitType = "sum" | "max" | "min" | "up" | "down";
|
||||
|
||||
export interface LatencySample {
|
||||
task_id: string;
|
||||
server_id: string;
|
||||
name: string;
|
||||
task_type: "tcp" | "icmp";
|
||||
target: string;
|
||||
timestamp: number;
|
||||
latency_ms: number;
|
||||
packet_loss: number;
|
||||
}
|
||||
|
||||
export interface LatencyTestPoint {
|
||||
id: string;
|
||||
name: string;
|
||||
task_type: "tcp" | "icmp";
|
||||
target: string;
|
||||
interval_seconds: number;
|
||||
}
|
||||
|
||||
export interface LatencyTask {
|
||||
id: string;
|
||||
name: string;
|
||||
task_type: "tcp" | "icmp";
|
||||
target: string;
|
||||
interval_seconds: number;
|
||||
default_enabled: boolean;
|
||||
sort_order: number;
|
||||
server_ids: string[];
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export type LatencyTaskInput = Pick<LatencyTask, "name" | "task_type" | "target" | "interval_seconds" | "default_enabled" | "server_ids">;
|
||||
|
||||
export interface Server {
|
||||
id: string;
|
||||
name: string;
|
||||
region: string;
|
||||
group_name: string;
|
||||
tags: string;
|
||||
note: string;
|
||||
hidden: number;
|
||||
sort_order: number;
|
||||
expires_at: number | null;
|
||||
traffic_limit: number;
|
||||
traffic_limit_type: TrafficLimitType;
|
||||
price: number;
|
||||
billing_cycle: number;
|
||||
currency: string;
|
||||
auto_renewal: number;
|
||||
public_remark: string;
|
||||
network_interface: string;
|
||||
reset_day: number;
|
||||
report_interval: number;
|
||||
collect_interval: number;
|
||||
rx_correction: number;
|
||||
tx_correction: number;
|
||||
offline_notify_disabled: number;
|
||||
auto_update: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
timestamp: number | null;
|
||||
cpu: number | null;
|
||||
load1: number | null;
|
||||
load5: number | null;
|
||||
load15: number | null;
|
||||
mem_used: number | null;
|
||||
mem_total: number | null;
|
||||
swap_used: number | null;
|
||||
swap_total: number | null;
|
||||
disk_used: number | null;
|
||||
disk_total: number | null;
|
||||
net_in: number | null;
|
||||
net_out: number | null;
|
||||
net_rx_total: number | null;
|
||||
net_tx_total: number | null;
|
||||
uptime: number | null;
|
||||
processes: number | null;
|
||||
tcp_connections: number | null;
|
||||
udp_connections: number | null;
|
||||
cpu_cores: number | null;
|
||||
cpu_model: string | null;
|
||||
os: string | null;
|
||||
kernel: string | null;
|
||||
arch: string | null;
|
||||
virtualization: string | null;
|
||||
ipv4: string | null;
|
||||
ipv6: string | null;
|
||||
gpu_usage: number | null;
|
||||
gpu_model: string | null;
|
||||
agent_version: string | null;
|
||||
disk_read_bps: number | null;
|
||||
disk_write_bps: number | null;
|
||||
disk_read_iops: number | null;
|
||||
disk_write_iops: number | null;
|
||||
disk_await_ms: number | null;
|
||||
disk_utilization: number | null;
|
||||
disks?: DiskMetric[];
|
||||
gpus?: GpuMetric[];
|
||||
source_url?: string;
|
||||
source_name?: string;
|
||||
source_id?: string;
|
||||
message: string | null;
|
||||
latency?: LatencySample[];
|
||||
}
|
||||
|
||||
export interface HistoryPoint {
|
||||
timestamp: number;
|
||||
cpu: number;
|
||||
load1: number;
|
||||
load5: number;
|
||||
load15: number;
|
||||
mem_used: number;
|
||||
mem_total: number;
|
||||
swap_used: number;
|
||||
swap_total: number;
|
||||
disk_used: number;
|
||||
disk_total: number;
|
||||
net_in: number;
|
||||
net_out: number;
|
||||
net_rx_total: number;
|
||||
net_tx_total: number;
|
||||
processes: number;
|
||||
tcp_connections: number;
|
||||
udp_connections: number;
|
||||
gpu_usage: number;
|
||||
disk_read_bps: number;
|
||||
disk_write_bps: number;
|
||||
disk_read_iops: number;
|
||||
disk_write_iops: number;
|
||||
disk_await_ms: number;
|
||||
disk_utilization: number;
|
||||
}
|
||||
|
||||
export interface ServerInput {
|
||||
name: string;
|
||||
region: string;
|
||||
group_name: string;
|
||||
tags: string;
|
||||
note: string;
|
||||
hidden: boolean;
|
||||
expires_at: number | null;
|
||||
traffic_limit: number;
|
||||
traffic_limit_type: TrafficLimitType;
|
||||
price: number;
|
||||
billing_cycle: number;
|
||||
currency: string;
|
||||
auto_renewal: boolean;
|
||||
public_remark: string;
|
||||
network_interface: string;
|
||||
reset_day: number;
|
||||
report_interval: number;
|
||||
collect_interval: number;
|
||||
rx_correction: number;
|
||||
tx_correction: number;
|
||||
offline_notify_disabled: boolean;
|
||||
auto_update: boolean;
|
||||
}
|
||||
|
||||
export interface Settings extends Omit<Config, "websocket"> {
|
||||
admin_username: string;
|
||||
admin_password_configured: boolean;
|
||||
new_password?: string;
|
||||
turnstile_secret_key: string;
|
||||
notification_enabled: boolean;
|
||||
notification_endpoint: string;
|
||||
notification_target: string;
|
||||
offline_alert_minutes: number;
|
||||
expiry_alert_days: number;
|
||||
cloudflare_account_id: string;
|
||||
cloudflare_api_token: string;
|
||||
cors_allowed_origins: string;
|
||||
csp_asset_origins: string;
|
||||
}
|
||||
|
||||
type AlertMetric = "cpu" | "memory" | "disk" | "net_in" | "net_out";
|
||||
type AlertAggregation = "average" | "continuous";
|
||||
|
||||
export interface AlertRule {
|
||||
id: string;
|
||||
name: string;
|
||||
metric: AlertMetric;
|
||||
threshold: number;
|
||||
duration_minutes: number;
|
||||
aggregation: AlertAggregation;
|
||||
enabled: number;
|
||||
server_ids: string[];
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface AlertRuleInput extends Omit<AlertRule, "id" | "enabled" | "created_at" | "updated_at"> {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DatabaseStats {
|
||||
server_count: number;
|
||||
online_count: number;
|
||||
history_rows: number;
|
||||
oldest_history: number | null;
|
||||
newest_history: number | null;
|
||||
}
|
||||
|
||||
interface CloudflareUsagePeriod {
|
||||
rows_read: number;
|
||||
rows_written: number;
|
||||
workers_requests: number;
|
||||
}
|
||||
|
||||
export interface CloudflareUsage {
|
||||
today: CloudflareUsagePeriod;
|
||||
yesterday: CloudflareUsagePeriod;
|
||||
}
|
||||
|
||||
export interface AgentInstallTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
agent_token: string;
|
||||
}
|
||||
|
||||
interface ThemeVersion {
|
||||
version?: string;
|
||||
short_version?: string;
|
||||
commitid?: string;
|
||||
commitId?: string;
|
||||
theme_url?: string;
|
||||
releaseDate?: string;
|
||||
changelog?: string;
|
||||
}
|
||||
|
||||
export interface ThirdPartyTheme {
|
||||
id: string;
|
||||
title: string;
|
||||
cover: string;
|
||||
tags?: string[];
|
||||
description?: string | Record<string, string>;
|
||||
url: string;
|
||||
branch?: string;
|
||||
author?: string;
|
||||
versions?: ThemeVersion[];
|
||||
}
|
||||
|
||||
export interface ThemeStore {
|
||||
schema: number;
|
||||
themes: ThirdPartyTheme[];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "../node_modules/.cache/cf-monitor/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["vite/client"],
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "../node_modules/.cache/cf-monitor/tsconfig.node.tsbuildinfo",
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["vite.config.ts", "vite.admin.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/admin-assets/",
|
||||
plugins: [react()],
|
||||
publicDir: false,
|
||||
build: {
|
||||
outDir: "admin-dist",
|
||||
emptyOutDir: true,
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
input: "admin.html",
|
||||
output: {
|
||||
entryFileNames: "admin.js",
|
||||
chunkFileNames: "admin-[name].js",
|
||||
assetFileNames: "admin[extname]",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8787",
|
||||
"/agent.sh": "http://localhost:8787",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
region TEXT NOT NULL DEFAULT '',
|
||||
group_name TEXT NOT NULL DEFAULT '默认',
|
||||
tags TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
hidden INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at INTEGER,
|
||||
traffic_limit INTEGER NOT NULL DEFAULT 0,
|
||||
traffic_limit_type TEXT NOT NULL DEFAULT 'sum',
|
||||
price REAL NOT NULL DEFAULT -2,
|
||||
billing_cycle INTEGER NOT NULL DEFAULT 30,
|
||||
currency TEXT NOT NULL DEFAULT 'CNY',
|
||||
auto_renewal INTEGER NOT NULL DEFAULT 0,
|
||||
public_remark TEXT NOT NULL DEFAULT '',
|
||||
network_interface TEXT NOT NULL DEFAULT '',
|
||||
reset_day INTEGER NOT NULL DEFAULT 1,
|
||||
report_interval INTEGER NOT NULL DEFAULT 60,
|
||||
collect_interval INTEGER NOT NULL DEFAULT 5,
|
||||
rx_correction INTEGER NOT NULL DEFAULT 0,
|
||||
tx_correction INTEGER NOT NULL DEFAULT 0,
|
||||
offline_notify_disabled INTEGER NOT NULL DEFAULT 0,
|
||||
auto_update INTEGER NOT NULL DEFAULT 0,
|
||||
token_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS latest_metrics (
|
||||
server_id TEXT PRIMARY KEY 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,
|
||||
uptime 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,
|
||||
cpu_cores INTEGER NOT NULL DEFAULT 0,
|
||||
cpu_model TEXT NOT NULL DEFAULT '',
|
||||
os TEXT NOT NULL DEFAULT '',
|
||||
kernel TEXT NOT NULL DEFAULT '',
|
||||
arch TEXT NOT NULL DEFAULT '',
|
||||
virtualization TEXT NOT NULL DEFAULT '',
|
||||
ipv4 TEXT NOT NULL DEFAULT '',
|
||||
ipv6 TEXT NOT NULL DEFAULT '',
|
||||
gpu_usage REAL NOT NULL DEFAULT 0,
|
||||
gpu_model TEXT NOT NULL DEFAULT '',
|
||||
agent_version TEXT NOT NULL DEFAULT '',
|
||||
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,
|
||||
disk_info TEXT NOT NULL DEFAULT '[]',
|
||||
gpu_info TEXT NOT NULL DEFAULT '[]',
|
||||
message TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metric_history (
|
||||
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,
|
||||
PRIMARY KEY(server_id, timestamp)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metric_history_hourly (
|
||||
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,
|
||||
PRIMARY KEY(server_id, timestamp)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_history_server_time
|
||||
ON metric_history(server_id, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_hourly_server_time
|
||||
ON metric_history_hourly(server_id, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_sort
|
||||
ON servers(sort_order, created_at);
|
||||
|
||||
INSERT OR IGNORE INTO settings(key, value, updated_at) VALUES
|
||||
('site_description', '轻量、实时的服务器运行状态', unixepoch()),
|
||||
('site_announcement', '', unixepoch()),
|
||||
('favicon_url', '', unixepoch()),
|
||||
('locale', 'zh-CN', unixepoch()),
|
||||
('public_dashboard', 'true', unixepoch()),
|
||||
('history_retention_days', '30', unixepoch()),
|
||||
('default_theme', 'system', unixepoch()),
|
||||
('background_url', '', unixepoch()),
|
||||
('theme_url', '', unixepoch()),
|
||||
('theme_options', '{}', unixepoch()),
|
||||
('show_search', 'true', unixepoch()),
|
||||
('show_groups', 'true', unixepoch()),
|
||||
('show_stats', 'true', unixepoch()),
|
||||
('show_assets', 'true', unixepoch()),
|
||||
('show_traffic', 'true', unixepoch()),
|
||||
('show_speed', 'true', unixepoch()),
|
||||
('show_price', 'true', unixepoch()),
|
||||
('show_expiry', 'true', unixepoch()),
|
||||
('show_latency', 'true', unixepoch()),
|
||||
('show_uptime', 'true', unixepoch()),
|
||||
('admin_username', '', unixepoch()),
|
||||
('admin_password_hash', '', unixepoch()),
|
||||
('turnstile_enabled', 'false', unixepoch()),
|
||||
('turnstile_login_enabled', 'false', unixepoch()),
|
||||
('turnstile_site_key', '', unixepoch()),
|
||||
('turnstile_secret_key', '', unixepoch()),
|
||||
('notification_enabled', 'false', unixepoch()),
|
||||
('notification_endpoint', '', unixepoch()),
|
||||
('notification_target', '', unixepoch()),
|
||||
('offline_alert_minutes', '5', unixepoch()),
|
||||
('expiry_alert_days', '7', unixepoch()),
|
||||
('cors_allowed_origins', '', unixepoch()),
|
||||
('csp_asset_origins', '', unixepoch()),
|
||||
('federation_sites', '[]', unixepoch()),
|
||||
('cloudflare_account_id', '', unixepoch()),
|
||||
('cloudflare_api_token', '', unixepoch());
|
||||
|
||||
CREATE TABLE IF NOT EXISTS exchange_rate_snapshots (
|
||||
base_currency TEXT PRIMARY KEY,
|
||||
rates_json TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
rate_date TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL,
|
||||
attempted_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE 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 IF NOT EXISTS latency_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
task_type TEXT NOT NULL CHECK(task_type IN ('tcp', 'icmp')),
|
||||
target TEXT NOT NULL,
|
||||
interval_seconds INTEGER NOT NULL CHECK(interval_seconds BETWEEN 30 AND 3600),
|
||||
default_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS latency_task_servers (
|
||||
task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY(task_id, server_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS latency_latest (
|
||||
task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
timestamp INTEGER NOT NULL,
|
||||
latency_ms REAL NOT NULL,
|
||||
packet_loss REAL NOT NULL,
|
||||
PRIMARY KEY(task_id, server_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS latency_history (
|
||||
task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
timestamp INTEGER NOT NULL,
|
||||
latency_ms REAL NOT NULL,
|
||||
packet_loss REAL NOT NULL,
|
||||
PRIMARY KEY(task_id, server_id, timestamp)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_latency_task_servers_server
|
||||
ON latency_task_servers(server_id, task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_latency_history_server_time
|
||||
ON latency_history(server_id, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_latency_history_task_time
|
||||
ON latency_history(task_id, timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
metric TEXT NOT NULL CHECK(metric IN ('cpu', 'memory', 'disk', 'net_in', 'net_out')),
|
||||
threshold REAL NOT NULL CHECK(threshold > 0),
|
||||
duration_minutes INTEGER NOT NULL CHECK(duration_minutes BETWEEN 1 AND 1440),
|
||||
aggregation TEXT NOT NULL CHECK(aggregation IN ('average', 'continuous')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_servers (
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY(rule_id, server_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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)
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "cf-monitor",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"repository": "https://github.com/imengying/CF-Monitor",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"workspaces": [
|
||||
"frontend"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "bun run build:agent && bun run build:frontend && bun run db:local && CF_MONITOR_ADMIN_READY=1 bunx wrangler dev",
|
||||
"dev:frontend": "bun run --cwd frontend dev",
|
||||
"build": "bun run build:agent && bun run build:frontend && CF_MONITOR_ADMIN_READY=1 sh scripts/build-worker.sh",
|
||||
"build:agent": "sh scripts/build-agent.sh",
|
||||
"build:frontend": "bun run --cwd frontend build",
|
||||
"deploy": "bun run build:agent && bun run build:frontend && CF_MONITOR_ADMIN_READY=1 sh scripts/deploy-worker.sh",
|
||||
"db:local": "bunx wrangler d1 migrations apply DB --local",
|
||||
"db:remote": "bunx wrangler d1 migrations apply DB --remote",
|
||||
"test:smoke": "sh scripts/smoke-test.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.120.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) arch="x86_64" ;;
|
||||
aarch64|arm64) arch="aarch64" ;;
|
||||
*) echo "Unsupported agent build architecture" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
agent_target_dir=${CARGO_TARGET_DIR:-target/agent}
|
||||
CARGO_TARGET_DIR="$agent_target_dir" cargo build --manifest-path agent/Cargo.toml --release --locked
|
||||
install -Dm755 "$agent_target_dir/release/cf-monitor-agent" "frontend/public/agent-linux-$arch"
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "${CF_MONITOR_ADMIN_READY:-0}" != "1" ]; then
|
||||
bun run build:agent
|
||||
bun run build:frontend
|
||||
fi
|
||||
|
||||
if command -v worker-build >/dev/null 2>&1; then
|
||||
worker_build="$(command -v worker-build)"
|
||||
elif [ -x "${CARGO_HOME:-$HOME/.cargo}/bin/worker-build" ]; then
|
||||
worker_build="${CARGO_HOME:-$HOME/.cargo}/bin/worker-build"
|
||||
else
|
||||
cargo install -q "worker-build@^0.8"
|
||||
worker_build="${CARGO_HOME:-$HOME/.cargo}/bin/worker-build"
|
||||
fi
|
||||
|
||||
exec "$worker_build" --release --no-panic-recovery
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Some Cloudflare deployment flows provision bindings before this script runs.
|
||||
# A first deployment without that pre-provisioning needs one upload to create D1.
|
||||
if ! bunx wrangler d1 info DB --json >/dev/null 2>&1; then
|
||||
echo "D1 binding is not provisioned; creating it through Wrangler deploy..."
|
||||
bunx wrangler deploy
|
||||
fi
|
||||
|
||||
bunx wrangler d1 migrations apply DB --remote
|
||||
exec bunx wrangler deploy
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
MONITOR_BASE_URL=${MONITOR_BASE_URL:-http://127.0.0.1:8787}
|
||||
MONITOR_ADMIN_USERNAME=${MONITOR_ADMIN_USERNAME:-admin}
|
||||
MONITOR_TURNSTILE_TOKEN=${MONITOR_TURNSTILE_TOKEN:-XXXX.DUMMY.TOKEN.XXXX}
|
||||
: "${MONITOR_ADMIN_PASSWORD:?Set MONITOR_ADMIN_PASSWORD before running the smoke test}"
|
||||
|
||||
monitor_curl() {
|
||||
case "$MONITOR_BASE_URL" in
|
||||
http://127.0.0.1:*|http://localhost:*) curl --noproxy '*' "$@" ;;
|
||||
*) curl "$@" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
request() {
|
||||
monitor_curl --fail --location --silent --show-error "$@"
|
||||
}
|
||||
|
||||
request "$MONITOR_BASE_URL/api/config" | jq -e '.site_name | length > 0' >/dev/null
|
||||
|
||||
admin_html=$(request "$MONITOR_BASE_URL/admin")
|
||||
case "$admin_html" in
|
||||
*"/admin-assets/admin.js"*) ;;
|
||||
*)
|
||||
echo "Embedded admin script is missing" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$admin_html" in
|
||||
*"/admin-assets/admin.css"*) ;;
|
||||
*)
|
||||
echo "Embedded admin stylesheet is missing" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
request "$MONITOR_BASE_URL/admin-assets/admin.js" >/dev/null
|
||||
request "$MONITOR_BASE_URL/admin-assets/admin.css" >/dev/null
|
||||
|
||||
login_json=$(request -H 'Content-Type: application/json' \
|
||||
--data "$(jq -nc --arg username "$MONITOR_ADMIN_USERNAME" --arg password "$MONITOR_ADMIN_PASSWORD" --arg turnstile_token "$MONITOR_TURNSTILE_TOKEN" '{username:$username,password:$password,turnstile_token:$turnstile_token}')" \
|
||||
"$MONITOR_BASE_URL/api/admin/login")
|
||||
admin_token=$(printf '%s' "$login_json" | jq -er '.token')
|
||||
|
||||
request -H "Authorization: Bearer $admin_token" \
|
||||
"$MONITOR_BASE_URL/api/exchange-rates" | \
|
||||
jq -e '.base == "CNY" and .rates.CNY == 1 and .rates.USD > 0 and .rates.CAD > 0 and .cny.USD == .rates.USD' >/dev/null
|
||||
|
||||
server_id=
|
||||
latency_task_id=
|
||||
alert_rule_id=
|
||||
cleanup() {
|
||||
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
|
||||
fi
|
||||
if [ -n "$latency_task_id" ]; then
|
||||
monitor_curl --silent --show-error -H "Authorization: Bearer $admin_token" \
|
||||
-X DELETE "$MONITOR_BASE_URL/api/admin/latency-tasks/$latency_task_id" >/dev/null || true
|
||||
fi
|
||||
if [ -n "$server_id" ]; then
|
||||
monitor_curl --silent --show-error -H "Authorization: Bearer $admin_token" \
|
||||
-X DELETE "$MONITOR_BASE_URL/api/admin/servers/$server_id" >/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
latency_task_json=$(request -H "Authorization: Bearer $admin_token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"name":"Smoke TCP","task_type":"tcp","target":"example.com:443","interval_seconds":60,"default_enabled":true,"server_ids":[]}' \
|
||||
"$MONITOR_BASE_URL/api/admin/latency-tasks")
|
||||
latency_task_id=$(printf '%s' "$latency_task_json" | jq -er '.id')
|
||||
|
||||
server_json=$(request -H "Authorization: Bearer $admin_token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"name":"Smoke Test Node","region":"JP","group_name":"Test","tags":"smoke","note":"private","public_remark":"public","hidden":false,"expires_at":1893456000,"traffic_limit":107374182400,"traffic_limit_type":"max","price":9.9,"billing_cycle":30,"currency":"USD","auto_renewal":true}' \
|
||||
"$MONITOR_BASE_URL/api/admin/servers")
|
||||
server_id=$(printf '%s' "$server_json" | jq -er '.id')
|
||||
agent_token=$(printf '%s' "$server_json" | jq -er '.agent_token')
|
||||
agent_version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' agent/Cargo.toml | head -1)
|
||||
|
||||
sample=$(jq -nc --arg agent_version "$agent_version" '{
|
||||
timestamp:(now|floor),cpu:18.5,load1:0.42,load5:0.36,load15:0.31,
|
||||
mem_used:2147483648,mem_total:4294967296,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,message:"smoke"
|
||||
}')
|
||||
report=$(jq -nc --arg server_id "$server_id" --argjson sample "$sample" '{server_id:$server_id,samples:[$sample]}')
|
||||
report_response=$(request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data "$report" "$MONITOR_BASE_URL/api/agent/report")
|
||||
printf '%s' "$report_response" | jq -e \
|
||||
--arg task_id "$latency_task_id" \
|
||||
--arg agent_version "$agent_version" \
|
||||
'.success == true and .config.collect_interval == 5 and .config.latest_agent_version == $agent_version and (.config.latency_tasks | any(.id == $task_id and .task_type == "tcp" and .target == "example.com:443"))' >/dev/null
|
||||
|
||||
latency_report=$(printf '%s' "$report" | jq --arg task_id "$latency_task_id" '.samples[0].timestamp=(now|floor) | .samples[0].latency_results=[{task_id:$task_id,timestamp:(now|floor),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 '.success == true' >/dev/null
|
||||
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/servers/$server_id" | jq -e --arg task_id "$latency_task_id" '.cpu == 18.5 and .gpu_usage == 32.5 and .disk_await_ms == 1.4 and (.gpus | length) == 1 and (.disks | length) == 1 and .price == 9.9 and .note == "" and .public_remark == "public" and (.latency | any(.task_id == $task_id and .latency_ms == 28.4 and .packet_loss == 25))' >/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
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/latency/$server_id?hours=1" | jq -e --arg task_id "$latency_task_id" '(.tasks | any(.id == $task_id)) and (.points | any(.task_id == $task_id and .latency_ms == 28.4))' >/dev/null
|
||||
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]}')" \
|
||||
"$MONITOR_BASE_URL/api/admin/alert-rules")
|
||||
alert_rule_id=$(printf '%s' "$alert_rule_json" | jq -er '.id')
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/admin/alert-rules" | jq -e --arg id "$alert_rule_id" --arg server_id "$server_id" '.rules | any(.id == $id and .metric == "cpu" and (.server_ids | index($server_id)))' >/dev/null
|
||||
rotated_json=$(request -H "Authorization: Bearer $admin_token" -X POST \
|
||||
"$MONITOR_BASE_URL/api/admin/servers/$server_id/token")
|
||||
rotated_token=$(printf '%s' "$rotated_json" | jq -er '.agent_token')
|
||||
|
||||
old_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
-H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data "$report" "$MONITOR_BASE_URL/api/agent/report")
|
||||
[ "$old_status" = "401" ]
|
||||
request -H "Authorization: Bearer $rotated_token" -H 'Content-Type: application/json' \
|
||||
--data "$latency_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.success == true' >/dev/null
|
||||
|
||||
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' -X PATCH \
|
||||
--data '{"name":"Smoke Test Node","region":"JP","group_name":"Test","tags":"smoke","note":"private","public_remark":"public","hidden":true,"expires_at":1893456000,"traffic_limit":107374182400,"traffic_limit_type":"max","price":9.9,"billing_cycle":30,"currency":"USD","auto_renewal":true}' \
|
||||
"$MONITOR_BASE_URL/api/admin/servers/$server_id" | jq -e '.success == true' >/dev/null
|
||||
hidden_detail_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
-H "Authorization: Bearer $admin_token" \
|
||||
"$MONITOR_BASE_URL/api/servers/$server_id")
|
||||
hidden_history_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
-H "Authorization: Bearer $admin_token" \
|
||||
"$MONITOR_BASE_URL/api/history/$server_id?hours=1")
|
||||
[ "$hidden_detail_status" = "404" ]
|
||||
[ "$hidden_history_status" = "404" ]
|
||||
|
||||
echo "Smoke test passed"
|
||||
@@ -0,0 +1,188 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use worker::{Date, Env, Request};
|
||||
|
||||
const SESSION_SECONDS: i64 = 7 * 24 * 60 * 60;
|
||||
const TURNSTILE_PROOF_SECONDS: i64 = 60 * 60;
|
||||
const THEME_PREVIEW_SECONDS: i64 = 10 * 60;
|
||||
const PASSWORD_ROUNDS: usize = 10_000;
|
||||
|
||||
pub fn sha256_hex(value: &str) -> String {
|
||||
let digest = Sha256::digest(value.as_bytes());
|
||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn secure_eq(left: &str, right: &str) -> bool {
|
||||
if left.len() != right.len() {
|
||||
return false;
|
||||
}
|
||||
left.as_bytes()
|
||||
.iter()
|
||||
.zip(right.as_bytes())
|
||||
.fold(0_u8, |diff, (a, b)| diff | (a ^ b))
|
||||
== 0
|
||||
}
|
||||
|
||||
fn admin_secret(env: &Env) -> Option<String> {
|
||||
env.secret("ADMIN_PASSWORD").ok().map(|v| v.to_string())
|
||||
}
|
||||
|
||||
pub fn admin_username(env: &Env, configured: &str) -> String {
|
||||
if !configured.trim().is_empty() {
|
||||
return configured.trim().to_string();
|
||||
}
|
||||
env.var("ADMIN_USERNAME")
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|_| "admin".to_string())
|
||||
}
|
||||
|
||||
fn password_digest(password: &str, salt: &str) -> String {
|
||||
let mut digest = Sha256::digest(format!("cf-monitor:{salt}:{password}").as_bytes()).to_vec();
|
||||
for _ in 1..PASSWORD_ROUNDS {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&digest);
|
||||
hasher.update(salt.as_bytes());
|
||||
digest = hasher.finalize().to_vec();
|
||||
}
|
||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
pub fn hash_password(password: &str, salt: &str) -> String {
|
||||
format!("sha256${salt}${}", password_digest(password, salt))
|
||||
}
|
||||
|
||||
fn verify_password_hash(password: &str, encoded: &str) -> bool {
|
||||
let mut parts = encoded.split('$');
|
||||
let Some(algorithm) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
let Some(salt) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
let Some(expected) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
if algorithm != "sha256" || salt.is_empty() || parts.next().is_some() {
|
||||
return false;
|
||||
}
|
||||
secure_eq(&password_digest(password, salt), expected)
|
||||
}
|
||||
|
||||
pub fn verify_credentials(
|
||||
env: &Env,
|
||||
configured_username: &str,
|
||||
password_hash: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> bool {
|
||||
let expected_username = admin_username(env, configured_username);
|
||||
let username_valid = secure_eq(&expected_username, username.trim());
|
||||
let password_valid = if password_hash.is_empty() {
|
||||
admin_secret(env)
|
||||
.map(|expected| secure_eq(&expected, password))
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
verify_password_hash(password, password_hash)
|
||||
};
|
||||
username_valid && password_valid
|
||||
}
|
||||
|
||||
fn signing_secret(env: &Env, password_hash: &str) -> Option<String> {
|
||||
if let Ok(secret) = env.secret("SESSION_SECRET") {
|
||||
return Some(secret.to_string());
|
||||
}
|
||||
if !password_hash.is_empty() {
|
||||
return Some(password_hash.to_string());
|
||||
}
|
||||
admin_secret(env)
|
||||
}
|
||||
|
||||
pub fn create_session(env: &Env, password_hash: &str) -> Option<String> {
|
||||
let secret = signing_secret(env, password_hash)?;
|
||||
let expires = Date::now().as_millis() as i64 / 1000 + SESSION_SECONDS;
|
||||
let signature = sha256_hex(&format!("cf-monitor:{expires}:{secret}"));
|
||||
Some(format!("{expires}.{signature}"))
|
||||
}
|
||||
|
||||
pub fn is_admin(req: &Request, env: &Env, password_hash: &str) -> bool {
|
||||
let Some(secret) = signing_secret(env, password_hash) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(Some(header)) = req.headers().get("Authorization") else {
|
||||
return false;
|
||||
};
|
||||
let Some(token) = header.strip_prefix("Bearer ") else {
|
||||
return false;
|
||||
};
|
||||
let Some((expires_raw, signature)) = token.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(expires) = expires_raw.parse::<i64>() else {
|
||||
return false;
|
||||
};
|
||||
let now = Date::now().as_millis() as i64 / 1000;
|
||||
if expires < now || expires > now + SESSION_SECONDS + 60 {
|
||||
return false;
|
||||
}
|
||||
let expected = sha256_hex(&format!("cf-monitor:{expires}:{secret}"));
|
||||
secure_eq(&expected, signature)
|
||||
}
|
||||
|
||||
pub fn create_turnstile_proof(env: &Env, password_hash: &str) -> Option<String> {
|
||||
let secret = signing_secret(env, password_hash)?;
|
||||
let expires = Date::now().as_millis() as i64 / 1000 + TURNSTILE_PROOF_SECONDS;
|
||||
let signature = sha256_hex(&format!("cf-monitor-turnstile:{expires}:{secret}"));
|
||||
Some(format!("{expires}.{signature}"))
|
||||
}
|
||||
|
||||
pub fn verify_turnstile_proof(value: &str, env: &Env, password_hash: &str) -> bool {
|
||||
let Some(secret) = signing_secret(env, password_hash) else {
|
||||
return false;
|
||||
};
|
||||
let Some((expires_raw, signature)) = value.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(expires) = expires_raw.parse::<i64>() else {
|
||||
return false;
|
||||
};
|
||||
let current = Date::now().as_millis() as i64 / 1000;
|
||||
if expires < current || expires > current + TURNSTILE_PROOF_SECONDS + 60 {
|
||||
return false;
|
||||
}
|
||||
let expected = sha256_hex(&format!("cf-monitor-turnstile:{expires}:{secret}"));
|
||||
secure_eq(&expected, signature)
|
||||
}
|
||||
|
||||
pub fn create_theme_preview(env: &Env, password_hash: &str, theme_url: &str) -> Option<String> {
|
||||
let secret = signing_secret(env, password_hash)?;
|
||||
let expires = Date::now().as_millis() as i64 / 1000 + THEME_PREVIEW_SECONDS;
|
||||
let theme_hash = sha256_hex(theme_url);
|
||||
let signature = sha256_hex(&format!("cf-monitor-theme:{expires}:{theme_hash}:{secret}"));
|
||||
Some(format!("{expires}.{signature}"))
|
||||
}
|
||||
|
||||
pub fn verify_theme_preview(value: &str, env: &Env, password_hash: &str, theme_url: &str) -> bool {
|
||||
let Some(secret) = signing_secret(env, password_hash) else {
|
||||
return false;
|
||||
};
|
||||
let Some((expires_raw, signature)) = value.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(expires) = expires_raw.parse::<i64>() else {
|
||||
return false;
|
||||
};
|
||||
let current = Date::now().as_millis() as i64 / 1000;
|
||||
if expires < current || expires > current + THEME_PREVIEW_SECONDS + 60 {
|
||||
return false;
|
||||
}
|
||||
let theme_hash = sha256_hex(theme_url);
|
||||
let expected = sha256_hex(&format!("cf-monitor-theme:{expires}:{theme_hash}:{secret}"));
|
||||
secure_eq(&expected, signature)
|
||||
}
|
||||
|
||||
pub fn bearer_token(req: &Request) -> Option<String> {
|
||||
req.headers()
|
||||
.get("Authorization")
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|value| value.strip_prefix("Bearer ").map(ToOwned::to_owned))
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use worker::{wasm_bindgen::JsValue, Fetch, Method, Request, RequestInit, Result};
|
||||
|
||||
const GRAPHQL_URL: &str = "https://api.cloudflare.com/client/v4/graphql";
|
||||
const USAGE_QUERY: &str = r#"
|
||||
query CloudflareUsage(
|
||||
$accountTag: string!,
|
||||
$start: Date,
|
||||
$end: Date,
|
||||
$startTime: Time!,
|
||||
$endTime: Time!
|
||||
) {
|
||||
viewer {
|
||||
accounts(filter: { accountTag: $accountTag }) {
|
||||
d1AnalyticsAdaptiveGroups(
|
||||
limit: 10000,
|
||||
filter: { date_geq: $start, date_leq: $end }
|
||||
) {
|
||||
sum { rowsRead rowsWritten }
|
||||
}
|
||||
workersInvocationsAdaptive(
|
||||
limit: 10000,
|
||||
filter: { datetime_geq: $startTime, datetime_leq: $endTime }
|
||||
) {
|
||||
sum { requests }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UsagePeriod {
|
||||
pub rows_read: i64,
|
||||
pub rows_written: i64,
|
||||
pub workers_requests: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CloudflareUsage {
|
||||
pub today: UsagePeriod,
|
||||
pub yesterday: UsagePeriod,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GraphQlRequest<'a> {
|
||||
query: &'a str,
|
||||
variables: GraphQlVariables<'a>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GraphQlVariables<'a> {
|
||||
account_tag: &'a str,
|
||||
start: &'a str,
|
||||
end: &'a str,
|
||||
start_time: &'a str,
|
||||
end_time: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GraphQlResponse {
|
||||
data: Option<GraphQlData>,
|
||||
#[serde(default)]
|
||||
errors: Vec<GraphQlError>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GraphQlError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GraphQlData {
|
||||
viewer: Viewer,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Viewer {
|
||||
accounts: Vec<AccountUsage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountUsage {
|
||||
#[serde(default)]
|
||||
d1_analytics_adaptive_groups: Vec<D1Group>,
|
||||
#[serde(default)]
|
||||
workers_invocations_adaptive: Vec<WorkersGroup>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct D1Group {
|
||||
sum: D1Sum,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct D1Sum {
|
||||
#[serde(default)]
|
||||
rows_read: i64,
|
||||
#[serde(default)]
|
||||
rows_written: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WorkersGroup {
|
||||
sum: WorkersSum,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WorkersSum {
|
||||
#[serde(default)]
|
||||
requests: i64,
|
||||
}
|
||||
|
||||
pub fn date_from_unix_days(days: i64) -> String {
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let day_of_era = z - era * 146_097;
|
||||
let year_of_era =
|
||||
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
||||
let mut year = year_of_era + era * 400;
|
||||
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||
let month_prime = (5 * day_of_year + 2) / 153;
|
||||
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
|
||||
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
|
||||
year += i64::from(month <= 2);
|
||||
format!("{year:04}-{month:02}-{day:02}")
|
||||
}
|
||||
|
||||
async fn query_period(token: &str, account_id: &str, date: &str) -> Result<UsagePeriod> {
|
||||
let start_time = format!("{date}T00:00:00Z");
|
||||
let end_time = format!("{date}T23:59:59Z");
|
||||
let body = serde_json::to_string(&GraphQlRequest {
|
||||
query: USAGE_QUERY,
|
||||
variables: GraphQlVariables {
|
||||
account_tag: account_id,
|
||||
start: date,
|
||||
end: date,
|
||||
start_time: &start_time,
|
||||
end_time: &end_time,
|
||||
},
|
||||
})?;
|
||||
let mut init = RequestInit::new();
|
||||
init.with_method(Method::Post)
|
||||
.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 status = response.status_code();
|
||||
let payload: GraphQlResponse = response.json().await?;
|
||||
if !(200..300).contains(&status) || !payload.errors.is_empty() {
|
||||
let message = payload
|
||||
.errors
|
||||
.into_iter()
|
||||
.map(|value| value.message)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
return Err(worker::Error::RustError(if message.is_empty() {
|
||||
format!("Cloudflare GraphQL HTTP {status}")
|
||||
} else {
|
||||
message
|
||||
}));
|
||||
}
|
||||
let account = payload
|
||||
.data
|
||||
.and_then(|value| value.viewer.accounts.into_iter().next())
|
||||
.ok_or_else(|| worker::Error::RustError("Cloudflare 账户不可用".to_string()))?;
|
||||
let (rows_read, rows_written) = account.d1_analytics_adaptive_groups.into_iter().fold(
|
||||
(0_i64, 0_i64),
|
||||
|(read, written), group| {
|
||||
(
|
||||
read.saturating_add(group.sum.rows_read),
|
||||
written.saturating_add(group.sum.rows_written),
|
||||
)
|
||||
},
|
||||
);
|
||||
let workers_requests = account
|
||||
.workers_invocations_adaptive
|
||||
.into_iter()
|
||||
.fold(0_i64, |total, group| {
|
||||
total.saturating_add(group.sum.requests)
|
||||
});
|
||||
Ok(UsagePeriod {
|
||||
rows_read,
|
||||
rows_written,
|
||||
workers_requests,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn usage(token: &str, account_id: &str, now: i64) -> Result<CloudflareUsage> {
|
||||
let today = date_from_unix_days(now.div_euclid(86_400));
|
||||
let yesterday = date_from_unix_days(now.div_euclid(86_400) - 1);
|
||||
let today_usage = query_period(token, account_id, &today).await?;
|
||||
let yesterday_usage = query_period(token, account_id, &yesterday).await?;
|
||||
Ok(CloudflareUsage {
|
||||
today: today_usage,
|
||||
yesterday: yesterday_usage,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::date_from_unix_days;
|
||||
|
||||
#[test]
|
||||
fn converts_unix_days_to_utc_date() {
|
||||
assert_eq!(date_from_unix_days(0), "1970-01-01");
|
||||
assert_eq!(date_from_unix_days(20_673), "2026-08-08");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
use std::{collections::BTreeMap, time::Duration};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use worker::{AbortController, D1Database, Error, Fetch, Method, Request, Result};
|
||||
|
||||
use crate::{cloudflare, db};
|
||||
|
||||
const BASE_CURRENCY: &str = "CNY";
|
||||
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;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FetchedRates {
|
||||
rates: BTreeMap<String, f64>,
|
||||
source: &'static str,
|
||||
date: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ExchangeRatesView {
|
||||
pub base: String,
|
||||
pub rates: BTreeMap<String, f64>,
|
||||
// Compatibility with themes using the fawazahmed0 CNY response shape.
|
||||
pub cny: BTreeMap<String, f64>,
|
||||
pub source: String,
|
||||
pub date: String,
|
||||
pub fetched_at: i64,
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
fn default_rates() -> BTreeMap<String, f64> {
|
||||
[
|
||||
("CNY", 1.0),
|
||||
("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),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(currency, rate)| (currency.to_string(), rate))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn valid_currency(value: &str) -> bool {
|
||||
value.len() == 3
|
||||
&& value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
fn valid_date(value: &str) -> bool {
|
||||
let bytes = value.as_bytes();
|
||||
bytes.len() == 10
|
||||
&& bytes[4] == b'-'
|
||||
&& bytes[7] == b'-'
|
||||
&& bytes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(index, value)| index == 4 || index == 7 || value.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn sanitize_rates(value: &Value) -> Option<BTreeMap<String, f64>> {
|
||||
let object = value.as_object()?;
|
||||
let mut rates = BTreeMap::new();
|
||||
for (currency, value) in object {
|
||||
let currency = currency.trim().to_ascii_uppercase();
|
||||
let Some(rate) = value.as_f64() else {
|
||||
continue;
|
||||
};
|
||||
if valid_currency(¤cy) && rate.is_finite() && (1e-12..=1e12).contains(&rate) {
|
||||
rates.insert(currency, rate);
|
||||
}
|
||||
}
|
||||
rates.insert(BASE_CURRENCY.to_string(), 1.0);
|
||||
Some(rates)
|
||||
}
|
||||
|
||||
fn required_rates_present(rates: &BTreeMap<String, f64>) -> bool {
|
||||
["CNY", "USD", "CAD", "HKD", "EUR", "GBP", "JPY"]
|
||||
.iter()
|
||||
.all(|currency| rates.get(*currency).is_some_and(|rate| *rate > 0.0))
|
||||
}
|
||||
|
||||
fn parse_frankfurter(value: &Value) -> Option<FetchedRates> {
|
||||
if value
|
||||
.get("base")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|base| !base.eq_ignore_ascii_case(BASE_CURRENCY))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let date = value.get("date")?.as_str()?.to_string();
|
||||
if !valid_date(&date) {
|
||||
return None;
|
||||
}
|
||||
let rates = sanitize_rates(value.get("rates")?)?;
|
||||
required_rates_present(&rates).then_some(FetchedRates {
|
||||
rates,
|
||||
source: "frankfurter",
|
||||
date,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_er_api(value: &Value) -> Option<FetchedRates> {
|
||||
if value.get("result").and_then(Value::as_str) != Some("success")
|
||||
|| value
|
||||
.get("base_code")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|base| !base.eq_ignore_ascii_case(BASE_CURRENCY))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let timestamp = value.get("time_last_update_unix")?.as_i64()?;
|
||||
if timestamp <= 0 {
|
||||
return None;
|
||||
}
|
||||
let rates = sanitize_rates(value.get("rates")?)?;
|
||||
required_rates_present(&rates).then_some(FetchedRates {
|
||||
rates,
|
||||
source: "er-api",
|
||||
date: cloudflare::date_from_unix_days(timestamp.div_euclid(86_400)),
|
||||
})
|
||||
}
|
||||
|
||||
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", "CF-Monitor-Exchange-Rates")?;
|
||||
let controller = AbortController::default();
|
||||
let signal = controller.signal();
|
||||
worker::wasm_bindgen_futures::spawn_local(async move {
|
||||
worker::Delay::from(Duration::from_secs(8)).await;
|
||||
controller.abort();
|
||||
});
|
||||
let mut response = Fetch::Request(request).send_with_signal(&signal).await?;
|
||||
let status = response.status_code();
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(Error::RustError(format!(
|
||||
"exchange-rate upstream returned HTTP {status}"
|
||||
)));
|
||||
}
|
||||
response.json().await
|
||||
}
|
||||
|
||||
async fn fetch_latest() -> Result<FetchedRates> {
|
||||
match fetch_json(PRIMARY_URL).await {
|
||||
Ok(value) => {
|
||||
if let Some(rates) = parse_er_api(&value) {
|
||||
return Ok(rates);
|
||||
}
|
||||
}
|
||||
Err(error) => worker::console_warn!("er-api exchange-rate request failed: {error}"),
|
||||
}
|
||||
match fetch_json(FALLBACK_URL).await {
|
||||
Ok(value) => parse_frankfurter(&value).ok_or_else(|| {
|
||||
Error::RustError("exchange-rate fallback returned invalid data".to_string())
|
||||
}),
|
||||
Err(error) => Err(Error::RustError(format!(
|
||||
"all exchange-rate sources failed: {error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn due(fetched_at: i64, attempted_at: i64, current: i64, force: bool) -> bool {
|
||||
force
|
||||
|| ((fetched_at <= 0 || current.saturating_sub(fetched_at) >= REFRESH_INTERVAL)
|
||||
&& (attempted_at <= 0 || current.saturating_sub(attempted_at) >= RETRY_INTERVAL))
|
||||
}
|
||||
|
||||
fn view_from_snapshot(
|
||||
snapshot: Option<db::ExchangeRateSnapshot>,
|
||||
current: i64,
|
||||
) -> ExchangeRatesView {
|
||||
let snapshot = snapshot.unwrap_or_else(|| db::ExchangeRateSnapshot {
|
||||
base_currency: BASE_CURRENCY.to_string(),
|
||||
rates_json: serde_json::to_string(&default_rates()).unwrap_or_else(|_| "{}".to_string()),
|
||||
source: "default".to_string(),
|
||||
rate_date: String::new(),
|
||||
fetched_at: 0,
|
||||
attempted_at: 0,
|
||||
});
|
||||
let mut rates = serde_json::from_str::<Value>(&snapshot.rates_json)
|
||||
.ok()
|
||||
.and_then(|value| sanitize_rates(&value))
|
||||
.unwrap_or_else(default_rates);
|
||||
for (currency, rate) in default_rates() {
|
||||
rates.entry(currency).or_insert(rate);
|
||||
}
|
||||
let stale =
|
||||
snapshot.fetched_at <= 0 || current.saturating_sub(snapshot.fetched_at) >= REFRESH_INTERVAL;
|
||||
ExchangeRatesView {
|
||||
base: snapshot.base_currency,
|
||||
cny: rates.clone(),
|
||||
rates,
|
||||
source: snapshot.source,
|
||||
date: snapshot.rate_date,
|
||||
fetched_at: snapshot.fetched_at,
|
||||
stale,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn current(database: &D1Database, current: i64) -> Result<ExchangeRatesView> {
|
||||
let snapshot = db::exchange_rate_snapshot(database, BASE_CURRENCY).await?;
|
||||
Ok(view_from_snapshot(snapshot, current))
|
||||
}
|
||||
|
||||
pub async fn refresh(
|
||||
database: &D1Database,
|
||||
current: i64,
|
||||
force: bool,
|
||||
) -> Result<(ExchangeRatesView, bool)> {
|
||||
let snapshot = db::exchange_rate_snapshot(database, BASE_CURRENCY).await?;
|
||||
if !snapshot
|
||||
.as_ref()
|
||||
.is_none_or(|value| due(value.fetched_at, value.attempted_at, current, force))
|
||||
{
|
||||
return Ok((view_from_snapshot(snapshot, current), false));
|
||||
}
|
||||
|
||||
db::mark_exchange_rate_attempt(database, BASE_CURRENCY, current).await?;
|
||||
let fetched = fetch_latest().await?;
|
||||
let mut rates = default_rates();
|
||||
rates.extend(fetched.rates);
|
||||
let rates_json = serde_json::to_string(&rates)?;
|
||||
db::upsert_exchange_rate_snapshot(
|
||||
database,
|
||||
BASE_CURRENCY,
|
||||
&rates_json,
|
||||
fetched.source,
|
||||
&fetched.date,
|
||||
current,
|
||||
)
|
||||
.await?;
|
||||
let snapshot = db::exchange_rate_snapshot(database, BASE_CURRENCY).await?;
|
||||
Ok((view_from_snapshot(snapshot, current), true))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{default_rates, due, parse_er_api, parse_frankfurter};
|
||||
|
||||
#[test]
|
||||
fn provides_all_frontend_asset_currencies() {
|
||||
let rates = default_rates();
|
||||
for currency in [
|
||||
"CNY", "USD", "HKD", "EUR", "GBP", "JPY", "RUB", "CHF", "INR", "VND", "THB", "CAD",
|
||||
] {
|
||||
assert!(rates.get(currency).is_some_and(|rate| *rate > 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_frankfurter_cny_rates() {
|
||||
let value = serde_json::json!({
|
||||
"base": "CNY",
|
||||
"date": "2026-08-08",
|
||||
"rates": {
|
||||
"USD": 0.14,
|
||||
"CAD": 0.20,
|
||||
"HKD": 1.09,
|
||||
"EUR": 0.12,
|
||||
"GBP": 0.10,
|
||||
"JPY": 21.0
|
||||
}
|
||||
});
|
||||
let parsed = parse_frankfurter(&value).expect("valid rates");
|
||||
assert_eq!(parsed.source, "frankfurter");
|
||||
assert_eq!(parsed.rates.get("CNY"), Some(&1.0));
|
||||
assert_eq!(parsed.rates.get("CAD"), Some(&0.20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_er_api_fallback() {
|
||||
let value = serde_json::json!({
|
||||
"result": "success",
|
||||
"base_code": "CNY",
|
||||
"time_last_update_unix": 1786118400,
|
||||
"rates": {
|
||||
"USD": 0.14,
|
||||
"CAD": 0.20,
|
||||
"HKD": 1.09,
|
||||
"EUR": 0.12,
|
||||
"GBP": 0.10,
|
||||
"JPY": 21.0
|
||||
}
|
||||
});
|
||||
let parsed = parse_er_api(&value).expect("valid fallback rates");
|
||||
assert_eq!(parsed.source, "er-api");
|
||||
assert_eq!(parsed.date, "2026-08-07");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incomplete_or_wrong_base_rates() {
|
||||
let wrong_base = serde_json::json!({
|
||||
"base": "USD",
|
||||
"date": "2026-08-08",
|
||||
"rates": {"CNY": 7.0}
|
||||
});
|
||||
assert!(parse_frankfurter(&wrong_base).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshes_daily_and_retries_hourly() {
|
||||
let current = 200_000;
|
||||
assert!(due(0, 0, current, false));
|
||||
assert!(!due(current - 10, 0, current, false));
|
||||
assert!(!due(0, current - 10, current, false));
|
||||
assert!(due(current - 86_400, current - 3_600, current, false));
|
||||
assert!(due(current, current, current, true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use worker::{wasm_bindgen::JsValue, D1Database, Date, Result};
|
||||
|
||||
use crate::models::{AgentLatencyResult, AgentLatencyTask, LatencyTaskInput};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TaskRow {
|
||||
id: String,
|
||||
name: String,
|
||||
task_type: String,
|
||||
target: String,
|
||||
interval_seconds: i64,
|
||||
default_enabled: i64,
|
||||
sort_order: i64,
|
||||
created_at: i64,
|
||||
updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AssignmentRow {
|
||||
task_id: String,
|
||||
server_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LatencyTaskView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub task_type: String,
|
||||
pub target: String,
|
||||
pub interval_seconds: i64,
|
||||
pub default_enabled: bool,
|
||||
pub sort_order: i64,
|
||||
pub server_ids: Vec<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct LatencySample {
|
||||
pub task_id: String,
|
||||
pub server_id: String,
|
||||
pub name: String,
|
||||
pub task_type: String,
|
||||
pub target: String,
|
||||
pub timestamp: i64,
|
||||
pub latency_ms: f64,
|
||||
pub packet_loss: f64,
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
pub async fn list_tasks(db: &D1Database) -> Result<Vec<LatencyTaskView>> {
|
||||
let rows: Vec<TaskRow> = db
|
||||
.prepare(
|
||||
"SELECT id, name, task_type, target, interval_seconds, default_enabled, \
|
||||
sort_order, created_at, updated_at FROM latency_tasks \
|
||||
ORDER BY sort_order ASC, created_at ASC",
|
||||
)
|
||||
.all()
|
||||
.await?
|
||||
.results()?;
|
||||
let assignments: Vec<AssignmentRow> = db
|
||||
.prepare(
|
||||
"SELECT task_id, server_id FROM latency_task_servers \
|
||||
ORDER BY task_id ASC, server_id ASC",
|
||||
)
|
||||
.all()
|
||||
.await?
|
||||
.results()?;
|
||||
let mut by_task: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for assignment in assignments {
|
||||
by_task
|
||||
.entry(assignment.task_id)
|
||||
.or_default()
|
||||
.push(assignment.server_id);
|
||||
}
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| LatencyTaskView {
|
||||
server_ids: by_task.remove(&row.id).unwrap_or_default(),
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
task_type: row.task_type,
|
||||
target: row.target,
|
||||
interval_seconds: row.interval_seconds,
|
||||
default_enabled: row.default_enabled != 0,
|
||||
sort_order: row.sort_order,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
.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.interval_seconds \
|
||||
FROM latency_tasks t \
|
||||
INNER JOIN latency_task_servers a ON a.task_id = t.id \
|
||||
WHERE a.server_id = ?1 ORDER BY t.sort_order ASC, t.created_at ASC",
|
||||
)
|
||||
.bind(&[text(server_id)])?
|
||||
.all()
|
||||
.await?
|
||||
.results()
|
||||
}
|
||||
|
||||
pub async fn create_task(
|
||||
db: &D1Database,
|
||||
id: &str,
|
||||
input: &LatencyTaskInput,
|
||||
timestamp: i64,
|
||||
) -> Result<()> {
|
||||
let mut statements = vec![db
|
||||
.prepare(
|
||||
"INSERT INTO latency_tasks( \
|
||||
id, name, task_type, target, interval_seconds, default_enabled, \
|
||||
sort_order, created_at, updated_at \
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, \
|
||||
COALESCE((SELECT MAX(sort_order) + 1 FROM latency_tasks), 0), ?7, ?7)",
|
||||
)
|
||||
.bind(&[
|
||||
text(id),
|
||||
text(input.name.trim()),
|
||||
text(input.task_type.trim()),
|
||||
text(input.target.trim()),
|
||||
number(input.interval_seconds),
|
||||
JsValue::from_bool(input.default_enabled),
|
||||
number(timestamp),
|
||||
])?];
|
||||
let assignment =
|
||||
db.prepare("INSERT INTO latency_task_servers(task_id, server_id) VALUES (?1, ?2)");
|
||||
for server_id in &input.server_ids {
|
||||
statements.push(assignment.clone().bind(&[text(id), text(server_id)])?);
|
||||
}
|
||||
db.batch(statements).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_task(
|
||||
db: &D1Database,
|
||||
id: &str,
|
||||
input: &LatencyTaskInput,
|
||||
timestamp: i64,
|
||||
) -> Result<bool> {
|
||||
let exists: Option<TaskRow> = db
|
||||
.prepare(
|
||||
"SELECT id, name, task_type, target, interval_seconds, default_enabled, \
|
||||
sort_order, created_at, updated_at FROM latency_tasks WHERE id = ?1",
|
||||
)
|
||||
.bind(&[text(id)])?
|
||||
.first(None)
|
||||
.await?;
|
||||
if exists.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut statements = vec![
|
||||
db.prepare(
|
||||
"UPDATE latency_tasks SET name = ?2, task_type = ?3, target = ?4, \
|
||||
interval_seconds = ?5, default_enabled = ?6, updated_at = ?7 WHERE id = ?1",
|
||||
)
|
||||
.bind(&[
|
||||
text(id),
|
||||
text(input.name.trim()),
|
||||
text(input.task_type.trim()),
|
||||
text(input.target.trim()),
|
||||
number(input.interval_seconds),
|
||||
JsValue::from_bool(input.default_enabled),
|
||||
number(timestamp),
|
||||
])?,
|
||||
db.prepare("DELETE FROM latency_task_servers WHERE task_id = ?1")
|
||||
.bind(&[text(id)])?,
|
||||
];
|
||||
let assignment =
|
||||
db.prepare("INSERT INTO latency_task_servers(task_id, server_id) VALUES (?1, ?2)");
|
||||
for server_id in &input.server_ids {
|
||||
statements.push(assignment.clone().bind(&[text(id), text(server_id)])?);
|
||||
}
|
||||
db.batch(statements).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn delete_task(db: &D1Database, id: &str) -> Result<bool> {
|
||||
let result = db
|
||||
.prepare("DELETE FROM latency_tasks WHERE id = ?1")
|
||||
.bind(&[text(id)])?
|
||||
.run()
|
||||
.await?;
|
||||
Ok(result.meta()?.and_then(|meta| meta.changes).unwrap_or(0) > 0)
|
||||
}
|
||||
|
||||
pub async fn assign_defaults(db: &D1Database, server_id: &str) -> Result<()> {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO latency_task_servers(task_id, server_id) \
|
||||
SELECT id, ?1 FROM latency_tasks WHERE default_enabled = 1",
|
||||
)
|
||||
.bind(&[text(server_id)])?
|
||||
.run()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn save_results(
|
||||
db: &D1Database,
|
||||
server_id: &str,
|
||||
results: &[AgentLatencyResult],
|
||||
received_at: i64,
|
||||
) -> Result<()> {
|
||||
if results.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let assigned: HashSet<String> = tasks_for_server(db, server_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|task| task.id)
|
||||
.collect();
|
||||
if results
|
||||
.iter()
|
||||
.any(|result| !assigned.contains(&result.task_id))
|
||||
{
|
||||
return Err(worker::Error::RustError(
|
||||
"延迟结果包含未分配给该节点的任务".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let latest = db.prepare(
|
||||
"INSERT INTO latency_latest( \
|
||||
task_id, server_id, timestamp, latency_ms, packet_loss \
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5) \
|
||||
ON CONFLICT(task_id, server_id) DO UPDATE SET \
|
||||
timestamp=excluded.timestamp, latency_ms=excluded.latency_ms, \
|
||||
packet_loss=excluded.packet_loss \
|
||||
WHERE excluded.timestamp >= latency_latest.timestamp",
|
||||
);
|
||||
let historical = db.prepare(
|
||||
"INSERT INTO latency_history( \
|
||||
task_id, server_id, timestamp, latency_ms, packet_loss \
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5) \
|
||||
ON CONFLICT(task_id, server_id, timestamp) DO UPDATE SET \
|
||||
latency_ms=excluded.latency_ms, packet_loss=excluded.packet_loss",
|
||||
);
|
||||
let mut statements = Vec::with_capacity(results.len() * 2);
|
||||
for result in results {
|
||||
let timestamp = if (result.timestamp - received_at).abs() <= 86_400 {
|
||||
result.timestamp
|
||||
} else {
|
||||
received_at
|
||||
};
|
||||
let values = [
|
||||
text(&result.task_id),
|
||||
text(server_id),
|
||||
number(timestamp),
|
||||
number(result.latency_ms),
|
||||
number(result.packet_loss),
|
||||
];
|
||||
statements.push(latest.clone().bind(&values)?);
|
||||
statements.push(historical.clone().bind(&values)?);
|
||||
}
|
||||
db.batch(statements).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn latest_all(db: &D1Database) -> Result<Vec<LatencySample>> {
|
||||
db.prepare(
|
||||
"SELECT l.task_id, l.server_id, t.name, t.task_type, t.target, \
|
||||
l.timestamp, l.latency_ms, l.packet_loss \
|
||||
FROM latency_latest l INNER JOIN latency_tasks t ON t.id = l.task_id \
|
||||
ORDER BY t.sort_order ASC, t.created_at ASC",
|
||||
)
|
||||
.all()
|
||||
.await?
|
||||
.results()
|
||||
}
|
||||
|
||||
pub async fn history(db: &D1Database, server_id: &str, hours: i64) -> Result<Vec<LatencySample>> {
|
||||
let cutoff = (Date::now().as_millis() as i64 / 1000) - hours.clamp(1, 24 * 365) * 3600;
|
||||
db.prepare(
|
||||
"SELECT h.task_id, h.server_id, t.name, t.task_type, t.target, \
|
||||
h.timestamp, h.latency_ms, h.packet_loss \
|
||||
FROM latency_history h INNER JOIN latency_tasks t ON t.id = h.task_id \
|
||||
WHERE h.server_id = ?1 AND h.timestamp >= ?2 \
|
||||
ORDER BY h.timestamp ASC, t.sort_order ASC",
|
||||
)
|
||||
.bind(&[text(server_id), number(cutoff)])?
|
||||
.all()
|
||||
.await?
|
||||
.results()
|
||||
}
|
||||
|
||||
pub async fn cleanup_history(db: &D1Database, cutoff: i64) -> Result<()> {
|
||||
db.prepare("DELETE FROM latency_history WHERE timestamp < ?1")
|
||||
.bind(&[number(cutoff)])?
|
||||
.run()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_history(db: &D1Database) -> Result<()> {
|
||||
db.prepare("DELETE FROM latency_history").run().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use worker::*;
|
||||
|
||||
#[durable_object(websocket)]
|
||||
pub struct LiveHub {
|
||||
state: State,
|
||||
}
|
||||
|
||||
impl DurableObject for LiveHub {
|
||||
fn new(state: State, _env: Env) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
async fn fetch(&self, mut req: Request) -> Result<Response> {
|
||||
if req.method() == Method::Post {
|
||||
let payload = req.text().await?;
|
||||
let server_id = req.headers().get("X-Server-ID")?.unwrap_or_default();
|
||||
let mut sockets = self.state.get_websockets_with_tag("all");
|
||||
if !server_id.is_empty() {
|
||||
sockets.extend(
|
||||
self.state
|
||||
.get_websockets_with_tag(&format!("server:{server_id}")),
|
||||
);
|
||||
}
|
||||
for socket in sockets {
|
||||
if socket.send_with_str(&payload).is_err() {
|
||||
let _ = socket.close(Some(1011), Some("send failed"));
|
||||
}
|
||||
}
|
||||
return Response::empty();
|
||||
}
|
||||
|
||||
let is_upgrade = req
|
||||
.headers()
|
||||
.get("Upgrade")?
|
||||
.map(|value| value.eq_ignore_ascii_case("websocket"))
|
||||
.unwrap_or(false);
|
||||
if !is_upgrade {
|
||||
return Response::error("WebSocket upgrade required", 426);
|
||||
}
|
||||
|
||||
let pair = WebSocketPair::new()?;
|
||||
let server_id = req
|
||||
.url()?
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "server_id").then(|| value.to_string()))
|
||||
.filter(|value| !value.is_empty() && value.len() <= 80 && !value.contains('/'));
|
||||
let tag = server_id.map_or_else(|| "all".to_string(), |id| format!("server:{id}"));
|
||||
self.state.accept_websocket_with_tags(&pair.server, &[&tag]);
|
||||
Response::from_websocket(pair.client)
|
||||
}
|
||||
|
||||
async fn websocket_message(
|
||||
&self,
|
||||
ws: WebSocket,
|
||||
message: WebSocketIncomingMessage,
|
||||
) -> Result<()> {
|
||||
if matches!(message, WebSocketIncomingMessage::String(ref value) if value == "ping") {
|
||||
ws.send_with_str("pong")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn websocket_close(
|
||||
&self,
|
||||
ws: WebSocket,
|
||||
code: usize,
|
||||
reason: String,
|
||||
_was_clean: bool,
|
||||
) -> Result<()> {
|
||||
ws.close(Some(code as u16), Some(reason))
|
||||
}
|
||||
|
||||
async fn websocket_error(&self, ws: WebSocket, _error: Error) -> Result<()> {
|
||||
ws.close(Some(1011), Some("socket error"))
|
||||
}
|
||||
}
|
||||
|
||||
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()?;
|
||||
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(())
|
||||
}
|
||||
|
||||
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()?;
|
||||
stub.fetch_with_request(req).await
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn default_group() -> String {
|
||||
"默认".to_string()
|
||||
}
|
||||
|
||||
fn default_price() -> f64 {
|
||||
-2.0
|
||||
}
|
||||
|
||||
fn default_billing_cycle() -> i64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_currency() -> String {
|
||||
"CNY".to_string()
|
||||
}
|
||||
|
||||
fn default_traffic_limit_type() -> String {
|
||||
"sum".to_string()
|
||||
}
|
||||
|
||||
fn default_reset_day() -> i64 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_report_interval() -> i64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_collect_interval() -> i64 {
|
||||
5
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub turnstile_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TurnstileVerifyRequest {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ThemePreviewInput {
|
||||
pub theme_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ServerInput {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub region: String,
|
||||
#[serde(default = "default_group")]
|
||||
pub group_name: String,
|
||||
#[serde(default)]
|
||||
pub tags: String,
|
||||
#[serde(default)]
|
||||
pub note: String,
|
||||
#[serde(default)]
|
||||
pub hidden: bool,
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub traffic_limit: i64,
|
||||
#[serde(default = "default_traffic_limit_type")]
|
||||
pub traffic_limit_type: String,
|
||||
#[serde(default = "default_price")]
|
||||
pub price: f64,
|
||||
#[serde(default = "default_billing_cycle")]
|
||||
pub billing_cycle: i64,
|
||||
#[serde(default = "default_currency")]
|
||||
pub currency: String,
|
||||
#[serde(default)]
|
||||
pub auto_renewal: bool,
|
||||
#[serde(default)]
|
||||
pub public_remark: String,
|
||||
#[serde(default)]
|
||||
pub network_interface: String,
|
||||
#[serde(default = "default_reset_day")]
|
||||
pub reset_day: i64,
|
||||
#[serde(default = "default_report_interval")]
|
||||
pub report_interval: i64,
|
||||
#[serde(default = "default_collect_interval")]
|
||||
pub collect_interval: i64,
|
||||
#[serde(default)]
|
||||
pub rx_correction: i64,
|
||||
#[serde(default)]
|
||||
pub tx_correction: i64,
|
||||
#[serde(default)]
|
||||
pub offline_notify_disabled: bool,
|
||||
#[serde(default)]
|
||||
pub auto_update: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SettingsInput {
|
||||
pub site_name: Option<String>,
|
||||
pub site_description: Option<String>,
|
||||
pub site_announcement: Option<String>,
|
||||
pub favicon_url: Option<String>,
|
||||
pub locale: Option<String>,
|
||||
pub public_dashboard: Option<bool>,
|
||||
pub offline_threshold_seconds: Option<i64>,
|
||||
pub history_retention_days: Option<i64>,
|
||||
pub default_theme: Option<String>,
|
||||
pub background_url: Option<String>,
|
||||
pub theme_url: Option<String>,
|
||||
pub theme_options: Option<serde_json::Value>,
|
||||
pub show_search: Option<bool>,
|
||||
pub show_groups: Option<bool>,
|
||||
pub show_stats: Option<bool>,
|
||||
pub show_assets: Option<bool>,
|
||||
pub show_traffic: Option<bool>,
|
||||
pub show_speed: Option<bool>,
|
||||
pub show_price: Option<bool>,
|
||||
pub show_expiry: Option<bool>,
|
||||
pub show_latency: Option<bool>,
|
||||
pub show_uptime: Option<bool>,
|
||||
pub admin_username: Option<String>,
|
||||
pub new_password: Option<String>,
|
||||
pub turnstile_enabled: Option<bool>,
|
||||
pub turnstile_login_enabled: Option<bool>,
|
||||
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 cloudflare_account_id: Option<String>,
|
||||
pub cloudflare_api_token: Option<String>,
|
||||
pub cors_allowed_origins: Option<String>,
|
||||
pub csp_asset_origins: Option<String>,
|
||||
pub federation_sites: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ServerOrderInput {
|
||||
pub ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ServerBatchInput {
|
||||
pub ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LatencyTaskInput {
|
||||
pub name: String,
|
||||
pub task_type: String,
|
||||
pub target: String,
|
||||
pub interval_seconds: i64,
|
||||
#[serde(default)]
|
||||
pub default_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub server_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct AgentLatencyTask {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub task_type: String,
|
||||
pub target: String,
|
||||
pub interval_seconds: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AgentLatencyResult {
|
||||
pub task_id: String,
|
||||
pub timestamp: i64,
|
||||
pub latency_ms: f64,
|
||||
pub packet_loss: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct AgentDiskMetric {
|
||||
pub name: String,
|
||||
pub mount_point: String,
|
||||
pub used: i64,
|
||||
pub total: i64,
|
||||
pub read_bps: f64,
|
||||
pub write_bps: f64,
|
||||
pub read_iops: f64,
|
||||
pub write_iops: f64,
|
||||
pub await_ms: f64,
|
||||
pub utilization: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct AgentGpuMetric {
|
||||
pub model: String,
|
||||
pub usage: f64,
|
||||
pub memory_used: i64,
|
||||
pub memory_total: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AgentReportBatch {
|
||||
pub server_id: String,
|
||||
pub samples: Vec<AgentReport>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AgentReport {
|
||||
#[serde(default)]
|
||||
pub timestamp: i64,
|
||||
#[serde(default)]
|
||||
pub cpu: f64,
|
||||
#[serde(default)]
|
||||
pub load1: f64,
|
||||
#[serde(default)]
|
||||
pub load5: f64,
|
||||
#[serde(default)]
|
||||
pub load15: f64,
|
||||
#[serde(default)]
|
||||
pub mem_used: i64,
|
||||
#[serde(default)]
|
||||
pub mem_total: i64,
|
||||
#[serde(default)]
|
||||
pub swap_used: i64,
|
||||
#[serde(default)]
|
||||
pub swap_total: i64,
|
||||
#[serde(default)]
|
||||
pub disk_used: i64,
|
||||
#[serde(default)]
|
||||
pub disk_total: i64,
|
||||
#[serde(default)]
|
||||
pub net_in: f64,
|
||||
#[serde(default)]
|
||||
pub net_out: f64,
|
||||
#[serde(default)]
|
||||
pub net_rx_total: i64,
|
||||
#[serde(default)]
|
||||
pub net_tx_total: i64,
|
||||
#[serde(default)]
|
||||
pub uptime: i64,
|
||||
#[serde(default)]
|
||||
pub processes: i64,
|
||||
#[serde(default)]
|
||||
pub tcp_connections: i64,
|
||||
#[serde(default)]
|
||||
pub udp_connections: i64,
|
||||
#[serde(default)]
|
||||
pub cpu_cores: i64,
|
||||
#[serde(default)]
|
||||
pub cpu_model: String,
|
||||
#[serde(default)]
|
||||
pub os: String,
|
||||
#[serde(default)]
|
||||
pub kernel: String,
|
||||
#[serde(default)]
|
||||
pub arch: String,
|
||||
#[serde(default)]
|
||||
pub virtualization: String,
|
||||
#[serde(default)]
|
||||
pub ipv4: String,
|
||||
#[serde(default)]
|
||||
pub ipv6: String,
|
||||
#[serde(default)]
|
||||
pub gpu_usage: f64,
|
||||
#[serde(default)]
|
||||
pub gpu_model: String,
|
||||
#[serde(default)]
|
||||
pub agent_version: String,
|
||||
#[serde(default)]
|
||||
pub disk_read_bps: f64,
|
||||
#[serde(default)]
|
||||
pub disk_write_bps: f64,
|
||||
#[serde(default)]
|
||||
pub disk_read_iops: f64,
|
||||
#[serde(default)]
|
||||
pub disk_write_iops: f64,
|
||||
#[serde(default)]
|
||||
pub disk_await_ms: f64,
|
||||
#[serde(default)]
|
||||
pub disk_utilization: f64,
|
||||
#[serde(default)]
|
||||
pub disks: Vec<AgentDiskMetric>,
|
||||
#[serde(default)]
|
||||
pub gpus: Vec<AgentGpuMetric>,
|
||||
#[serde(default)]
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub latency_results: Vec<AgentLatencyResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ServerView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub region: String,
|
||||
pub group_name: String,
|
||||
pub tags: String,
|
||||
pub note: String,
|
||||
pub hidden: i64,
|
||||
pub sort_order: i64,
|
||||
pub expires_at: Option<i64>,
|
||||
pub traffic_limit: i64,
|
||||
pub traffic_limit_type: String,
|
||||
pub price: f64,
|
||||
pub billing_cycle: i64,
|
||||
pub currency: String,
|
||||
pub auto_renewal: i64,
|
||||
pub public_remark: String,
|
||||
pub network_interface: String,
|
||||
pub reset_day: i64,
|
||||
pub report_interval: i64,
|
||||
pub collect_interval: i64,
|
||||
pub rx_correction: i64,
|
||||
pub tx_correction: i64,
|
||||
pub offline_notify_disabled: i64,
|
||||
pub auto_update: i64,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub timestamp: Option<i64>,
|
||||
pub cpu: Option<f64>,
|
||||
pub load1: Option<f64>,
|
||||
pub load5: Option<f64>,
|
||||
pub load15: Option<f64>,
|
||||
pub mem_used: Option<i64>,
|
||||
pub mem_total: Option<i64>,
|
||||
pub swap_used: Option<i64>,
|
||||
pub swap_total: Option<i64>,
|
||||
pub disk_used: Option<i64>,
|
||||
pub disk_total: Option<i64>,
|
||||
pub net_in: Option<f64>,
|
||||
pub net_out: Option<f64>,
|
||||
pub net_rx_total: Option<i64>,
|
||||
pub net_tx_total: Option<i64>,
|
||||
pub uptime: Option<i64>,
|
||||
pub processes: Option<i64>,
|
||||
pub tcp_connections: Option<i64>,
|
||||
pub udp_connections: Option<i64>,
|
||||
pub cpu_cores: Option<i64>,
|
||||
pub cpu_model: Option<String>,
|
||||
pub os: Option<String>,
|
||||
pub kernel: Option<String>,
|
||||
pub arch: Option<String>,
|
||||
pub virtualization: Option<String>,
|
||||
pub ipv4: Option<String>,
|
||||
pub ipv6: Option<String>,
|
||||
pub gpu_usage: Option<f64>,
|
||||
pub gpu_model: Option<String>,
|
||||
pub agent_version: Option<String>,
|
||||
pub disk_read_bps: Option<f64>,
|
||||
pub disk_write_bps: Option<f64>,
|
||||
pub disk_read_iops: Option<f64>,
|
||||
pub disk_write_iops: Option<f64>,
|
||||
pub disk_await_ms: Option<f64>,
|
||||
pub disk_utilization: Option<f64>,
|
||||
pub disk_info: Option<String>,
|
||||
pub gpu_info: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct HistoryPoint {
|
||||
pub timestamp: i64,
|
||||
pub cpu: f64,
|
||||
pub load1: f64,
|
||||
pub load5: f64,
|
||||
pub load15: f64,
|
||||
pub mem_used: i64,
|
||||
pub mem_total: i64,
|
||||
pub swap_used: i64,
|
||||
pub swap_total: i64,
|
||||
pub disk_used: i64,
|
||||
pub disk_total: i64,
|
||||
pub net_in: f64,
|
||||
pub net_out: f64,
|
||||
pub net_rx_total: i64,
|
||||
pub net_tx_total: i64,
|
||||
pub processes: i64,
|
||||
pub tcp_connections: i64,
|
||||
pub udp_connections: i64,
|
||||
pub gpu_usage: f64,
|
||||
pub disk_read_bps: f64,
|
||||
pub disk_write_bps: f64,
|
||||
pub disk_read_iops: f64,
|
||||
pub disk_write_iops: f64,
|
||||
pub disk_await_ms: f64,
|
||||
pub disk_utilization: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TokenHashRow {
|
||||
pub token_hash: String,
|
||||
pub hidden: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct AgentConfigView {
|
||||
pub report_interval: i64,
|
||||
pub collect_interval: i64,
|
||||
pub network_interface: String,
|
||||
pub auto_update: i64,
|
||||
pub latest_agent_version: String,
|
||||
#[serde(default)]
|
||||
pub latency_tasks: Vec<AgentLatencyTask>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AlertRuleInput {
|
||||
pub name: String,
|
||||
pub metric: String,
|
||||
pub threshold: f64,
|
||||
pub duration_minutes: i64,
|
||||
pub aggregation: String,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub server_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AlertRuleView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub metric: String,
|
||||
pub threshold: f64,
|
||||
pub duration_minutes: i64,
|
||||
pub aggregation: String,
|
||||
pub enabled: i64,
|
||||
#[serde(default)]
|
||||
pub server_ids: Vec<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ApiError<'a> {
|
||||
pub error: &'a str,
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use worker::{wasm_bindgen::JsValue, D1Database, Fetch, Method, Request, RequestInit, Result};
|
||||
|
||||
use crate::db::{self, SettingsView};
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
struct AlertState {
|
||||
offline: HashSet<String>,
|
||||
expiry: HashSet<String>,
|
||||
}
|
||||
|
||||
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, '_' | '-'))
|
||||
})
|
||||
}
|
||||
|
||||
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(),
|
||||
));
|
||||
}
|
||||
|
||||
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)?;
|
||||
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()) {
|
||||
return Err(worker::Error::RustError(format!(
|
||||
"通知服务返回 HTTP {}",
|
||||
response.status_code()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Result<()> {
|
||||
if !settings.notification_enabled || settings.notification_endpoint.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let servers = db::list_servers(db_conn, true).await?;
|
||||
let stored = db::get_setting(db_conn, "alert_state").await?;
|
||||
let mut previous: AlertState = stored
|
||||
.as_deref()
|
||||
.and_then(|value| serde_json::from_str(value).ok())
|
||||
.unwrap_or_default();
|
||||
let mut current = AlertState::default();
|
||||
let current_time = worker::Date::now().as_millis() as i64 / 1000;
|
||||
let offline_after = settings.offline_alert_minutes.clamp(2, 1440) * 60;
|
||||
let mut messages = 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);
|
||||
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
|
||||
));
|
||||
}
|
||||
} else if previous.offline.contains(&server_id) {
|
||||
messages.push(format!("[恢复] {} 已恢复上报", server.name));
|
||||
}
|
||||
|
||||
let mut expires_at = server.expires_at;
|
||||
if server.auto_renewal != 0 {
|
||||
if let Some(mut value) = expires_at {
|
||||
let cycle = server.billing_cycle.clamp(1, 3650) * 86_400;
|
||||
if value <= current_time + 86_400 {
|
||||
while value <= current_time + 86_400 {
|
||||
value += cycle;
|
||||
}
|
||||
db::update_expiry(db_conn, &server_id, value).await?;
|
||||
expires_at = Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if settings.expiry_alert_days > 0 {
|
||||
if let Some(value) = expires_at {
|
||||
let days = (value - current_time + 86_399) / 86_400;
|
||||
if days >= 0 && days <= settings.expiry_alert_days {
|
||||
let key = format!("{server_id}:{value}");
|
||||
current.expiry.insert(key.clone());
|
||||
if !previous.expiry.contains(&key) {
|
||||
messages.push(format!("[到期] {} 剩余 {} 天", server.name, days));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let previous_resources = db::active_alert_states(db_conn).await?;
|
||||
let mut current_resources = HashSet::new();
|
||||
for rule in db::list_alert_rules(db_conn).await? {
|
||||
if rule.enabled == 0 {
|
||||
continue;
|
||||
}
|
||||
let metric_label = match rule.metric.as_str() {
|
||||
"cpu" => "CPU",
|
||||
"memory" => "内存",
|
||||
"disk" => "磁盘",
|
||||
"net_in" => "下行",
|
||||
"net_out" => "上行",
|
||||
_ => continue,
|
||||
};
|
||||
let unit = if matches!(rule.metric.as_str(), "net_in" | "net_out") {
|
||||
"MiB/s"
|
||||
} else {
|
||||
"%"
|
||||
};
|
||||
for value in db::alert_metric_values(db_conn, &rule, current_time).await? {
|
||||
let key = format!("{}:{}", rule.id, value.server_id);
|
||||
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 {
|
||||
"平均"
|
||||
},
|
||||
));
|
||||
}
|
||||
} else if previous_resources.contains(&key) {
|
||||
messages.push(format!("[恢复] {} · {} 已恢复正常", value.name, rule.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !messages.is_empty() {
|
||||
let body = format!("CF Monitor 告警\n\n{}", messages.join("\n"));
|
||||
send(settings, &body).await?;
|
||||
}
|
||||
previous = current;
|
||||
db::save_setting(db_conn, "alert_state", &serde_json::to_string(&previous)?).await?;
|
||||
db::replace_active_alert_states(db_conn, ¤t_resources).await
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use worker::{Fetch, Headers, Method, Request, Response, Result};
|
||||
|
||||
const STORE_URL: &str =
|
||||
"https://raw.githubusercontent.com/huilang-me/CFSM-Theme-Store/refs/heads/main/themes.json";
|
||||
const COMPAT_ASSET_BASE: &str =
|
||||
"https://raw.githubusercontent.com/huilang-me/CF-Server-Monitor/main/public";
|
||||
const THEME_BOOTSTRAP: &str = r#"<style>[data-cf-monitor-turnstile-verified="true"]{display:none!important;min-height:0!important}</style><script>(()=>{sessionStorage.removeItem('cf-monitor-admin-token');localStorage.removeItem('cf-monitor-admin-token');const seen=new WeakSet,holders=new Map,wrap=api=>{if(!api||typeof api.render!=='function'||seen.has(api))return api;seen.add(api);const render=api.render.bind(api),reset=typeof api.reset==='function'?api.reset.bind(api):null;api.render=(target,options={})=>{const holder=typeof target==='string'?document.querySelector(target):target,callback=options.callback,id=render(target,{...options,callback:token=>{holder?.setAttribute('data-cf-monitor-turnstile-verified','true');return callback?.(token)}});holders.set(id,holder);return id};if(reset)api.reset=id=>{if(id==null)holders.forEach(holder=>holder?.removeAttribute('data-cf-monitor-turnstile-verified'));else holders.get(id)?.removeAttribute('data-cf-monitor-turnstile-verified');return reset(id)};return api};const watch=node=>{if(node?.tagName==='SCRIPT'&&node.src.includes('challenges.cloudflare.com/turnstile/'))node.addEventListener('load',()=>wrap(window.turnstile),{once:true})},observer=new MutationObserver(records=>records.forEach(record=>record.addedNodes.forEach(watch)));observer.observe(document,{childList:true,subtree:true});document.addEventListener('load',event=>watch(event.target),true);setInterval(()=>{if(window.turnstile)wrap(window.turnstile)},250)})()</script>
|
||||
"#;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ThemeSource {
|
||||
pub normalized: String,
|
||||
raw_base: String,
|
||||
immutable: bool,
|
||||
}
|
||||
|
||||
fn safe_part(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value != "."
|
||||
&& value != ".."
|
||||
&& value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
|
||||
}
|
||||
|
||||
pub fn parse_theme_url(value: &str) -> Option<ThemeSource> {
|
||||
let raw = value.trim().trim_end_matches('/');
|
||||
let path = raw.strip_prefix("https://github.com/")?;
|
||||
if raw.contains(['?', '#', '\\', '%']) {
|
||||
return None;
|
||||
}
|
||||
let parts = path.split('/').collect::<Vec<_>>();
|
||||
if parts.len() < 4 || parts[2] != "tree" || parts.iter().any(|part| !safe_part(part)) {
|
||||
return None;
|
||||
}
|
||||
let owner = parts[0];
|
||||
let repo = parts[1].trim_end_matches(".git");
|
||||
let reference = parts[3];
|
||||
if !safe_part(repo) {
|
||||
return None;
|
||||
}
|
||||
let suffix = if parts.len() > 4 {
|
||||
format!("/{}", parts[4..].join("/"))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Some(ThemeSource {
|
||||
normalized: format!("https://github.com/{owner}/{repo}/tree/{reference}{suffix}"),
|
||||
raw_base: format!("https://raw.githubusercontent.com/{owner}/{repo}/{reference}{suffix}"),
|
||||
immutable: reference.len() == 40 && reference.chars().all(|ch| ch.is_ascii_hexdigit()),
|
||||
})
|
||||
}
|
||||
|
||||
fn safe_asset_path(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& !value.contains(['?', '#', '\\'])
|
||||
&& value.split('/').all(|part| {
|
||||
!part.is_empty()
|
||||
&& part != "."
|
||||
&& part != ".."
|
||||
&& part
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '@'))
|
||||
})
|
||||
}
|
||||
|
||||
fn content_type(path: &str) -> &'static str {
|
||||
let path = path.to_ascii_lowercase();
|
||||
if path.ends_with(".js") || path.ends_with(".mjs") {
|
||||
"application/javascript; charset=utf-8"
|
||||
} else if path.ends_with(".css") {
|
||||
"text/css; charset=utf-8"
|
||||
} else if path.ends_with(".json") || path.ends_with(".map") {
|
||||
"application/json; charset=utf-8"
|
||||
} else if path.ends_with(".svg") {
|
||||
"image/svg+xml"
|
||||
} else if path.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
|
||||
"image/jpeg"
|
||||
} else if path.ends_with(".webp") {
|
||||
"image/webp"
|
||||
} else if path.ends_with(".gif") {
|
||||
"image/gif"
|
||||
} else if path.ends_with(".ico") {
|
||||
"image/x-icon"
|
||||
} else if path.ends_with(".woff2") {
|
||||
"font/woff2"
|
||||
} else if path.ends_with(".woff") {
|
||||
"font/woff"
|
||||
} else if path.ends_with(".ttf") {
|
||||
"font/ttf"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_exchange_rate_urls(script: &str) -> String {
|
||||
[
|
||||
"https://api.frankfurter.dev/v1/latest?base=CNY",
|
||||
"https://api.frankfurter.app/latest?from=CNY",
|
||||
"https://open.er-api.com/v6/latest/CNY",
|
||||
"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/cny.json",
|
||||
"https://latest.currency-api.pages.dev/v1/currencies/cny.json",
|
||||
]
|
||||
.into_iter()
|
||||
.fold(script.to_string(), |script, url| {
|
||||
script.replace(url, "/api/exchange-rates")
|
||||
})
|
||||
}
|
||||
|
||||
async fn request(url: &str, timeout: Duration) -> Result<worker::Response> {
|
||||
let request = Request::new(url, Method::Get)?;
|
||||
request.headers().set("Accept", "*/*")?;
|
||||
request
|
||||
.headers()
|
||||
.set("User-Agent", "CF-Monitor-Theme-Proxy")?;
|
||||
let controller = worker::AbortController::default();
|
||||
let signal = controller.signal();
|
||||
worker::wasm_bindgen_futures::spawn_local(async move {
|
||||
worker::Delay::from(timeout).await;
|
||||
controller.abort();
|
||||
});
|
||||
Fetch::Request(request).send_with_signal(&signal).await
|
||||
}
|
||||
|
||||
fn fallback_store() -> Value {
|
||||
serde_json::json!({
|
||||
"schema": 1,
|
||||
"themes": [
|
||||
{
|
||||
"id": "emerald",
|
||||
"title": "Emerald",
|
||||
"cover": "https://raw.githubusercontent.com/Tokinx/cf-server-monitor-theme-emerald/main/docs/preview.png",
|
||||
"tags": ["Emerald", "Earth"],
|
||||
"description": { "zh-CN": "翡翠配色的 CF Server Monitor 主题。", "en": "An emerald theme for CF Server Monitor." },
|
||||
"url": "https://github.com/Tokinx/cf-server-monitor-theme-emerald",
|
||||
"branch": "build",
|
||||
"author": "Tokinx"
|
||||
},
|
||||
{
|
||||
"id": "pulse",
|
||||
"title": "Pulse",
|
||||
"cover": "https://raw.githubusercontent.com/loongkong/cf-server-monitor-theme-pulse/main/docs/preview.svg",
|
||||
"tags": ["Pulse", "Minimal", "Mono"],
|
||||
"description": { "zh-CN": "极简风格的 CF Server Monitor 主题。", "en": "A minimal CF Server Monitor theme." },
|
||||
"url": "https://github.com/loongkong/cf-server-monitor-theme-pulse",
|
||||
"branch": "main",
|
||||
"author": "loongkong"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
async fn mutable_response(mut response: Response) -> Result<Response> {
|
||||
let status = response.status_code();
|
||||
let headers = Headers::new();
|
||||
for (name, value) in response.headers().entries() {
|
||||
headers.append(&name, &value)?;
|
||||
}
|
||||
let body = response.bytes().await?;
|
||||
Ok(Response::from_bytes(body)?
|
||||
.with_status(status)
|
||||
.with_headers(headers))
|
||||
}
|
||||
|
||||
async fn cached_request(url: &str, ttl: u32, timeout: Duration) -> Result<Response> {
|
||||
let response = request(url, timeout).await?;
|
||||
let mut response = mutable_response(response).await?;
|
||||
if (200..300).contains(&response.status_code()) {
|
||||
response.headers_mut().set(
|
||||
"Cache-Control",
|
||||
&format!("public, max-age={ttl}, s-maxage={ttl}"),
|
||||
)?;
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn store() -> Result<Value> {
|
||||
let mut response = match cached_request(STORE_URL, 300, Duration::from_secs(8)).await {
|
||||
Ok(response) => response,
|
||||
Err(_) => return Ok(fallback_store()),
|
||||
};
|
||||
if !(200..300).contains(&response.status_code()) {
|
||||
return Ok(fallback_store());
|
||||
}
|
||||
response.json().await.or_else(|_| Ok(fallback_store()))
|
||||
}
|
||||
|
||||
fn valid_setting_key(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 64
|
||||
&& value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
|
||||
}
|
||||
|
||||
fn short_text(value: Option<&Value>, limit: usize) -> Option<String> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| value.chars().count() <= limit)
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn sanitize_settings_schema(value: Value) -> Value {
|
||||
let Some(settings) = value.get("settings").and_then(Value::as_array) else {
|
||||
return serde_json::json!({ "schema": 1, "source": "third-party", "settings": [] });
|
||||
};
|
||||
let mut fields = Vec::new();
|
||||
for field in settings.iter().take(40) {
|
||||
let Some(object) = field.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(key) = object
|
||||
.get("key")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|key| valid_setting_key(key))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(label) = short_text(object.get("label"), 80) else {
|
||||
continue;
|
||||
};
|
||||
let Some(kind) = object.get("type").and_then(Value::as_str).filter(|kind| {
|
||||
matches!(
|
||||
*kind,
|
||||
"text" | "textarea" | "url" | "color" | "select" | "toggle" | "number"
|
||||
)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let mut clean = serde_json::Map::new();
|
||||
clean.insert("key".into(), Value::String(key.to_string()));
|
||||
clean.insert("label".into(), Value::String(label));
|
||||
clean.insert("type".into(), Value::String(kind.to_string()));
|
||||
if let Some(placeholder) = short_text(object.get("placeholder"), 160) {
|
||||
clean.insert("placeholder".into(), Value::String(placeholder));
|
||||
}
|
||||
if let Some(default) = object.get("default").filter(|value| {
|
||||
value.is_boolean()
|
||||
|| value.is_number()
|
||||
|| value
|
||||
.as_str()
|
||||
.is_some_and(|text| text.chars().count() <= 500)
|
||||
}) {
|
||||
clean.insert("default".into(), default.clone());
|
||||
}
|
||||
for key in ["min", "max", "step"] {
|
||||
if let Some(number) = object
|
||||
.get(key)
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|number| number.is_finite())
|
||||
{
|
||||
clean.insert(key.into(), serde_json::json!(number));
|
||||
}
|
||||
}
|
||||
if kind == "select" {
|
||||
let options = object
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.map(|options| {
|
||||
options
|
||||
.iter()
|
||||
.take(40)
|
||||
.filter_map(|option| {
|
||||
let label = short_text(option.get("label"), 80)?;
|
||||
let value = short_text(option.get("value"), 160)?;
|
||||
Some(serde_json::json!({ "label": label, "value": value }))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if options.is_empty() {
|
||||
continue;
|
||||
}
|
||||
clean.insert("options".into(), Value::Array(options));
|
||||
}
|
||||
fields.push(Value::Object(clean));
|
||||
}
|
||||
serde_json::json!({ "schema": 1, "source": "third-party", "settings": fields })
|
||||
}
|
||||
|
||||
fn builtin_settings_schema() -> Value {
|
||||
let currencies = [
|
||||
"CNY", "USD", "HKD", "EUR", "GBP", "JPY", "RUB", "CHF", "INR", "VND", "THB", "CAD",
|
||||
];
|
||||
serde_json::json!({
|
||||
"schema": 1,
|
||||
"source": "builtin",
|
||||
"settings": [
|
||||
{
|
||||
"key": "assetCurrency",
|
||||
"label": "资产折算币种",
|
||||
"type": "select",
|
||||
"default": "CNY",
|
||||
"options": currencies.iter().map(|currency| {
|
||||
serde_json::json!({ "label": currency, "value": currency })
|
||||
}).collect::<Vec<_>>()
|
||||
},
|
||||
{
|
||||
"key": "enableBlur",
|
||||
"label": "启用毛玻璃效果",
|
||||
"type": "toggle",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"key": "showOnline",
|
||||
"label": "总览显示在线节点",
|
||||
"type": "toggle",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn settings_schema(theme_url: &str) -> Result<Value> {
|
||||
if theme_url.trim().is_empty() {
|
||||
return Ok(builtin_settings_schema());
|
||||
}
|
||||
let Some(source) = parse_theme_url(theme_url) else {
|
||||
return Ok(serde_json::json!({ "schema": 1, "source": "third-party", "settings": [] }));
|
||||
};
|
||||
let mut response = match cached_request(
|
||||
&format!("{}/theme-settings.json", source.raw_base),
|
||||
300,
|
||||
Duration::from_secs(8),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => {
|
||||
return Ok(serde_json::json!({ "schema": 1, "source": "third-party", "settings": [] }))
|
||||
}
|
||||
};
|
||||
if !(200..300).contains(&response.status_code()) {
|
||||
return Ok(serde_json::json!({ "schema": 1, "source": "third-party", "settings": [] }));
|
||||
}
|
||||
let value = response
|
||||
.json()
|
||||
.await
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
Ok(sanitize_settings_schema(value))
|
||||
}
|
||||
|
||||
pub async fn load_index(theme_url: &str, title: &str) -> Result<Option<String>> {
|
||||
let Some(source) = parse_theme_url(theme_url) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut response = match cached_request(
|
||||
&format!("{}/index.html", source.raw_base),
|
||||
300,
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if !(200..300).contains(&response.status_code()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut html = response.text().await?;
|
||||
let lower = html.to_ascii_lowercase();
|
||||
if lower.contains("/src/main.ts")
|
||||
|| lower.contains("/src/main.tsx")
|
||||
|| lower.contains("src=\"src/")
|
||||
|| lower.contains("src='./src/")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
for (from, to) in [
|
||||
("src=\"./assets/", "src=\"/assets/"),
|
||||
("href=\"./assets/", "href=\"/assets/"),
|
||||
("src='\x2e/assets/", "src='/assets/"),
|
||||
("href='\x2e/assets/", "href='/assets/"),
|
||||
("src=\"assets/", "src=\"/assets/"),
|
||||
("href=\"assets/", "href=\"/assets/"),
|
||||
] {
|
||||
html = html.replace(from, to);
|
||||
}
|
||||
let safe_title = title
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """);
|
||||
if let Some(index) = html.to_ascii_lowercase().find("</head>") {
|
||||
html.insert_str(index, &format!("<title>{safe_title}</title>\n"));
|
||||
}
|
||||
html.insert_str(0, THEME_BOOTSTRAP);
|
||||
Ok(Some(html))
|
||||
}
|
||||
|
||||
pub async fn asset(theme_url: &str, path: &str) -> Result<Response> {
|
||||
let Some(source) = parse_theme_url(theme_url) else {
|
||||
return Response::error("Invalid theme", 400);
|
||||
};
|
||||
if !safe_asset_path(path) {
|
||||
return Response::error("Not Found", 404);
|
||||
}
|
||||
let ttl = if source.immutable { 31_536_000 } else { 3600 };
|
||||
let mut remote = cached_request(
|
||||
&format!("{}/assets/{path}", source.raw_base),
|
||||
ttl,
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await?;
|
||||
let status = remote.status_code();
|
||||
if !(200..300).contains(&status) {
|
||||
return Response::error("Theme asset not found", status);
|
||||
}
|
||||
let bytes = remote.bytes().await?;
|
||||
let bytes = if path.ends_with(".js") || path.ends_with(".mjs") {
|
||||
match String::from_utf8(bytes) {
|
||||
Ok(script) => rewrite_exchange_rate_urls(&script).into_bytes(),
|
||||
Err(error) => error.into_bytes(),
|
||||
}
|
||||
} else {
|
||||
bytes
|
||||
};
|
||||
let mut response = Response::from_bytes(bytes)?.with_status(status);
|
||||
response
|
||||
.headers_mut()
|
||||
.set("Content-Type", content_type(path))?;
|
||||
response.headers_mut().set(
|
||||
"Cache-Control",
|
||||
if source.immutable {
|
||||
"public, max-age=31536000, immutable"
|
||||
} else {
|
||||
"public, max-age=3600"
|
||||
},
|
||||
)?;
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-Content-Type-Options", "nosniff")?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn compatibility_asset(path: &str) -> Result<Response> {
|
||||
let allowed = path == "favicon.ico"
|
||||
|| path.strip_prefix("flags/").is_some_and(safe_asset_path)
|
||||
|| path.strip_prefix("os-icons/").is_some_and(safe_asset_path);
|
||||
if !allowed {
|
||||
return Response::error("Not Found", 404);
|
||||
}
|
||||
let mut remote = cached_request(
|
||||
&format!("{COMPAT_ASSET_BASE}/{path}"),
|
||||
86_400,
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await?;
|
||||
let status = remote.status_code();
|
||||
if !(200..300).contains(&status) {
|
||||
return Response::error("Theme asset not found", status);
|
||||
}
|
||||
let bytes = remote.bytes().await?;
|
||||
let mut response = Response::from_bytes(bytes)?.with_status(status);
|
||||
response
|
||||
.headers_mut()
|
||||
.set("Content-Type", content_type(path))?;
|
||||
response
|
||||
.headers_mut()
|
||||
.set("Cache-Control", "public, max-age=86400")?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
builtin_settings_schema, parse_theme_url, rewrite_exchange_rate_urls, safe_asset_path,
|
||||
sanitize_settings_schema,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn accepts_only_github_tree_themes() {
|
||||
let valid =
|
||||
parse_theme_url("https://github.com/Tokinx/cf-server-monitor-theme-emerald/tree/build")
|
||||
.expect("valid theme");
|
||||
assert_eq!(
|
||||
valid.normalized,
|
||||
"https://github.com/Tokinx/cf-server-monitor-theme-emerald/tree/build"
|
||||
);
|
||||
assert!(parse_theme_url("https://example.com/theme/tree/main").is_none());
|
||||
assert!(parse_theme_url("https://github.com/a/b/blob/main").is_none());
|
||||
assert!(parse_theme_url("https://github.com/a/b/tree/main/../private").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_theme_asset_traversal() {
|
||||
assert!(safe_asset_path("chunks/index-Ab_1.js"));
|
||||
assert!(safe_asset_path("@scope/theme.js"));
|
||||
assert!(!safe_asset_path("../secret"));
|
||||
assert!(!safe_asset_path("assets/./index.js"));
|
||||
assert!(!safe_asset_path("assets//index.js"));
|
||||
assert!(!safe_asset_path("a\\b.js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_theme_exchange_rates_through_the_worker() {
|
||||
let script = "fetch('https://api.frankfurter.dev/v1/latest?base=CNY');\
|
||||
fetch('https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/cny.json')";
|
||||
let rewritten = rewrite_exchange_rate_urls(script);
|
||||
assert_eq!(rewritten.matches("/api/exchange-rates").count(), 2);
|
||||
assert!(!rewritten.contains("frankfurter.dev"));
|
||||
assert!(!rewritten.contains("jsdelivr.net"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_frontend_theme_settings() {
|
||||
let schema = sanitize_settings_schema(serde_json::json!({
|
||||
"settings": [
|
||||
{ "key": "accent", "label": "强调色", "type": "color", "default": "#0f766e" },
|
||||
{ "key": "layout", "label": "布局", "type": "select", "options": [{ "label": "紧凑", "value": "compact" }] },
|
||||
{ "key": "bad key", "label": "Bad", "type": "text" }
|
||||
]
|
||||
}));
|
||||
assert_eq!(schema["settings"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(schema["settings"][0]["key"], "accent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposes_builtin_glass_theme_settings() {
|
||||
let schema = builtin_settings_schema();
|
||||
let settings = schema["settings"].as_array().expect("settings");
|
||||
assert!(settings.iter().any(|field| field["key"] == "assetCurrency"));
|
||||
assert!(settings.iter().any(|field| field["key"] == "enableBlur"));
|
||||
assert!(settings.iter().any(|field| field["key"] == "showOnline"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use worker::{wasm_bindgen::JsValue, Fetch, Method, Request, RequestInit, Result};
|
||||
|
||||
const SITEVERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct VerifyRequest<'a> {
|
||||
secret: &'a str,
|
||||
response: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
remoteip: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VerifyResponse {
|
||||
success: bool,
|
||||
}
|
||||
|
||||
pub async fn verify(token: &str, secret: &str, remote_ip: Option<&str>) -> Result<bool> {
|
||||
if token.is_empty() || token.len() > 2048 || secret.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let body = serde_json::to_string(&VerifyRequest {
|
||||
secret,
|
||||
response: token,
|
||||
remoteip: remote_ip,
|
||||
})?;
|
||||
let mut init = RequestInit::new();
|
||||
init.with_method(Method::Post)
|
||||
.with_body(Some(JsValue::from_str(&body)));
|
||||
let request = Request::new_with_init(SITEVERIFY_URL, &init)?;
|
||||
request.headers().set("Content-Type", "application/json")?;
|
||||
|
||||
let mut response = Fetch::Request(request).send().await?;
|
||||
if !(200..300).contains(&response.status_code()) {
|
||||
return Ok(false);
|
||||
}
|
||||
let result: VerifyResponse = response.json().await?;
|
||||
Ok(result.success)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
name = "cf-monitor"
|
||||
main = "build/index.js"
|
||||
compatibility_date = "2026-08-07"
|
||||
|
||||
[build]
|
||||
command = "sh scripts/build-worker.sh"
|
||||
|
||||
[assets]
|
||||
directory = "./frontend/dist"
|
||||
binding = "ASSETS"
|
||||
not_found_handling = "single-page-application"
|
||||
run_worker_first = true
|
||||
|
||||
[vars]
|
||||
SITE_NAME = "CF Monitor"
|
||||
OFFLINE_THRESHOLD_SECONDS = "180"
|
||||
HISTORY_RETENTION_DAYS = "30"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
# Wrangler 4.45+ provisions and binds this database when database_id is omitted.
|
||||
database_name = "cf-monitor-db"
|
||||
|
||||
[[durable_objects.bindings]]
|
||||
name = "LIVE_HUB"
|
||||
class_name = "LiveHub"
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_sqlite_classes = ["LiveHub"]
|
||||
|
||||
[triggers]
|
||||
crons = ["*/5 * * * *"]
|
||||
|
||||
[dev]
|
||||
port = 8787
|
||||