Fixes and improvements
This commit is contained in:
1 parent
40ff956461
commit
97e4b3a5f1
23 files changed
+687
-375
No files matched your search
Generated
+45
-44
@@ -136,53 +136,53 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
||||
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
@@ -240,9 +240,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
@@ -254,9 +254,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
@@ -267,9 +267,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
@@ -281,16 +281,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
@@ -301,15 +302,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
@@ -366,9 +367,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
@@ -455,9 +456,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
@@ -643,9 +644,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
@@ -829,9 +830,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
@@ -879,9 +880,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
@@ -890,9 +891,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.6"
|
||||
version = "0.11.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -901,13 +902,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.3"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||
checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+36
-35
@@ -37,9 +37,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.2"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
|
||||
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -205,9 +205,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
@@ -286,9 +286,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
@@ -300,9 +300,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
@@ -313,9 +313,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
@@ -327,16 +327,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
@@ -347,15 +348,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
@@ -411,9 +412,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
@@ -496,9 +497,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
@@ -590,9 +591,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -793,9 +794,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
@@ -1130,9 +1131,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
@@ -1186,9 +1187,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
@@ -1197,9 +1198,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.6"
|
||||
version = "0.11.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -1208,13 +1209,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.3"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||
checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -203,7 +203,7 @@ install_agent() {
|
||||
if [ "$init_system" = "systemd" ]; then
|
||||
printf '%s\n' \
|
||||
'[Unit]' \
|
||||
'Description=NodeFlare Rust Agent' \
|
||||
'Description=nodeflare' \
|
||||
'After=network-online.target' \
|
||||
'Wants=network-online.target' \
|
||||
'' \
|
||||
|
||||
+21
-29
@@ -48,7 +48,7 @@ const LIVE_QUEUE_CAPACITY: usize = 720;
|
||||
// the queue preserves the remaining samples for the next frame.
|
||||
const LIVE_BATCH_CAPACITY: usize = 32;
|
||||
// D1 persistence can take a few hundred milliseconds; allow the ACK to arrive
|
||||
// before falling back to the configured interval.
|
||||
// before continuing with the configured live interval.
|
||||
const LIVE_ACK_READ_TIMEOUT: Duration = Duration::from_millis(1_000);
|
||||
const CLOCK_CALIBRATION_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const CLOCK_CALIBRATION_MIN_CHANGE_MS: i64 = 20_000;
|
||||
@@ -229,19 +229,19 @@ type SharedClock = Arc<Mutex<ClockCalibration>>;
|
||||
|
||||
struct LiveSender {
|
||||
pending: Arc<(Mutex<Vec<Report>>, Condvar)>,
|
||||
report_interval: Arc<Mutex<Duration>>,
|
||||
send_interval: Arc<Mutex<Duration>>,
|
||||
}
|
||||
|
||||
impl LiveSender {
|
||||
fn start(config: &RuntimeConfig, clock: SharedClock) -> Result<Self> {
|
||||
let pending = Arc::new((Mutex::new(Vec::new()), Condvar::new()));
|
||||
let report_interval = Arc::new(Mutex::new(Duration::from_secs(
|
||||
config.report_interval.clamp(15, 3600),
|
||||
let send_interval = Arc::new(Mutex::new(Duration::from_secs(
|
||||
config.collect_interval.clamp(2, 60),
|
||||
)));
|
||||
let endpoint = live_endpoint(&config.endpoint)?;
|
||||
let token = config.token.clone();
|
||||
let sender_state = Arc::clone(&pending);
|
||||
let sender_interval = Arc::clone(&report_interval);
|
||||
let sender_interval = Arc::clone(&send_interval);
|
||||
thread::Builder::new()
|
||||
.name("nodeflare-live".to_string())
|
||||
.spawn(move || {
|
||||
@@ -249,7 +249,7 @@ impl LiveSender {
|
||||
})?;
|
||||
Ok(Self {
|
||||
pending,
|
||||
report_interval,
|
||||
send_interval,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -264,9 +264,9 @@ impl LiveSender {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_report_interval(&self, seconds: u64) {
|
||||
if let Ok(mut interval) = self.report_interval.lock() {
|
||||
*interval = Duration::from_secs(seconds.clamp(15, 3600));
|
||||
fn set_send_interval(&self, seconds: u64) {
|
||||
if let Ok(mut interval) = self.send_interval.lock() {
|
||||
*interval = Duration::from_secs(seconds.clamp(2, 60));
|
||||
}
|
||||
self.pending.1.notify_one();
|
||||
}
|
||||
@@ -1382,17 +1382,15 @@ struct LiveAck {
|
||||
#[serde(rename = "nextD1WriteAfterMs")]
|
||||
next_d1_write_after_ms: u64,
|
||||
#[serde(rename = "nextWssReportAfterMs")]
|
||||
next_wss_report_after_ms: Option<u64>,
|
||||
next_wss_report_after_ms: u64,
|
||||
}
|
||||
|
||||
fn ack_interval(ack: &LiveAck, fallback: Duration) -> Duration {
|
||||
ack.next_wss_report_after_ms
|
||||
.map(Duration::from_millis)
|
||||
.map(|interval| interval.clamp(Duration::from_secs(2), Duration::from_secs(3600)))
|
||||
.unwrap_or(fallback)
|
||||
fn ack_interval(ack: &LiveAck) -> Duration {
|
||||
Duration::from_millis(ack.next_wss_report_after_ms)
|
||||
.clamp(Duration::from_secs(2), Duration::from_secs(60))
|
||||
}
|
||||
|
||||
fn read_live_ack(socket: &mut LiveSocket, current_interval: Duration) -> Result<Option<Duration>> {
|
||||
fn read_live_ack(socket: &mut LiveSocket) -> Result<Option<Duration>> {
|
||||
match socket.read() {
|
||||
Ok(Message::Text(text)) => {
|
||||
let Ok(ack) = serde_json::from_str::<LiveAck>(text.as_ref()) else {
|
||||
@@ -1402,7 +1400,7 @@ fn read_live_ack(socket: &mut LiveSocket, current_interval: Duration) -> Result<
|
||||
return Ok(None);
|
||||
}
|
||||
let _ = (ack.persisted, ack.next_d1_write_after_ms);
|
||||
Ok(Some(ack_interval(&ack, current_interval)))
|
||||
Ok(Some(ack_interval(&ack)))
|
||||
}
|
||||
Ok(Message::Ping(payload)) => {
|
||||
socket.send(Message::Pong(payload))?;
|
||||
@@ -1453,7 +1451,7 @@ fn live_sender_loop(
|
||||
let mut send_interval = configured_interval
|
||||
.lock()
|
||||
.map(|interval| *interval)
|
||||
.unwrap_or(Duration::from_secs(60));
|
||||
.unwrap_or(Duration::from_secs(5));
|
||||
let mut next_send_at = Instant::now();
|
||||
loop {
|
||||
let (queue, ready) = &*pending;
|
||||
@@ -1479,7 +1477,7 @@ fn live_sender_loop(
|
||||
drop(queue);
|
||||
|
||||
if let Ok(interval) = configured_interval.lock() {
|
||||
send_interval = (*interval).min(Duration::from_secs(3600));
|
||||
send_interval = (*interval).clamp(Duration::from_secs(2), Duration::from_secs(60));
|
||||
}
|
||||
if socket.is_none() {
|
||||
match connect_live(endpoint, token) {
|
||||
@@ -1535,7 +1533,7 @@ fn live_sender_loop(
|
||||
next_send_at = Instant::now() + send_interval;
|
||||
let mut drop_socket = false;
|
||||
if let Some(socket) = socket.as_mut() {
|
||||
match read_live_ack(socket, send_interval) {
|
||||
match read_live_ack(socket) {
|
||||
Ok(Some(Duration::ZERO)) => drop_socket = true,
|
||||
Ok(Some(interval)) => {
|
||||
send_interval = interval;
|
||||
@@ -1955,7 +1953,7 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> {
|
||||
next_latency.clear();
|
||||
}
|
||||
if let Some(live) = &live {
|
||||
live.set_report_interval(config.report_interval);
|
||||
live.set_send_interval(config.collect_interval);
|
||||
}
|
||||
}
|
||||
if !once && config.auto_update && Instant::now() >= next_update_check {
|
||||
@@ -2152,18 +2150,12 @@ mod tests {
|
||||
r#"{"type":"ack","ts":100,"persisted":false,"nextD1WriteAfterMs":60000,"nextWssReportAfterMs":5000}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ack_interval(&ack, Duration::from_secs(60)),
|
||||
Duration::from_secs(5)
|
||||
);
|
||||
assert_eq!(ack_interval(&ack), Duration::from_secs(5));
|
||||
let slow: LiveAck = serde_json::from_str(
|
||||
r#"{"type":"ack","ts":100,"persisted":true,"nextD1WriteAfterMs":60000,"nextWssReportAfterMs":1}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ack_interval(&slow, Duration::from_secs(60)),
|
||||
Duration::from_secs(2)
|
||||
);
|
||||
assert_eq!(ack_interval(&slow), Duration::from_secs(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"": {
|
||||
"name": "nodeflare",
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.122.0",
|
||||
"wrangler": "^4.123.0",
|
||||
},
|
||||
},
|
||||
"frontend": {
|
||||
@@ -26,6 +26,9 @@
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": {
|
||||
"nanoid": "3.3.18",
|
||||
},
|
||||
"packages": {
|
||||
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
|
||||
|
||||
@@ -349,9 +352,9 @@
|
||||
|
||||
"lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="],
|
||||
|
||||
"miniflare": ["miniflare@5.20260811.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260811.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-sypXsD5fjY88fZNedPqnwrwR1dwfnfbfW7MfvMyIfPJdtRiCCOpUnjWGeFVYYZ+0fQVICye6Juu+vZgzTEx8XA=="],
|
||||
"miniflare": ["miniflare@5.20260811.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260811.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-DtOG0BeanIxs2sH0smFvExZD89cBQwGckbHiFkRJrrNAUu3NGClZkUxqu+zy7HYfKBAgq935EMY49vIPm3JVdA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="],
|
||||
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
|
||||
"nodeflare-ui": ["nodeflare-ui@workspace:frontend"],
|
||||
|
||||
@@ -413,7 +416,7 @@
|
||||
|
||||
"workerd": ["workerd@1.20260811.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260811.1", "@cloudflare/workerd-darwin-arm64": "1.20260811.1", "@cloudflare/workerd-linux-64": "1.20260811.1", "@cloudflare/workerd-linux-arm64": "1.20260811.1", "@cloudflare/workerd-windows-64": "1.20260811.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kh+FFm55JQ4ssxhHZV9VPdMQq3D1nHxNJgwxMtWGD4dGppJvLySdguTRDKgeNTvgq6heSz+6TTXyPSDGj8Yllw=="],
|
||||
|
||||
"wrangler": ["wrangler@4.122.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260811.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260811.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260811.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-qkskzgQ76Y1qvVe5JARgvc3RISq6BC2rPoxQhFoKH1dKIwQc3GDFttQ/7m2OfeQ+tmQRzynv2dy/DXnxFCj2Lw=="],
|
||||
"wrangler": ["wrangler@4.123.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260811.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260811.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260811.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-VXo2I1oa0x9aGAKIFPRSQPqTh0RBY5Ktl44YOhNmsJQFUdJKDA2vVTU6Xj+FC2koll6orJqWZN8jbXVIk9O67Q=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
||||
+37
-26
@@ -25,19 +25,37 @@ interface LiveSample {
|
||||
data: Partial<Server> & { latency_results?: LiveLatencyResult[] };
|
||||
}
|
||||
|
||||
function mergeLiveResults(previous: LiveLatencyResult[] | undefined, incoming: LiveLatencyResult[]): LiveLatencyResult[] {
|
||||
const results = new Map<string, LiveLatencyResult>();
|
||||
for (const result of [...(previous ?? []), ...incoming]) {
|
||||
if (!result.task_id || !Number.isFinite(result.timestamp) || result.timestamp <= 0) continue;
|
||||
results.set(`${result.task_id}:${result.timestamp}`, result);
|
||||
}
|
||||
return Array.from(results.values())
|
||||
.sort((left, right) => left.timestamp - right.timestamp)
|
||||
.slice(-4096);
|
||||
}
|
||||
|
||||
function mergeLiveLatency(server: Server, results: LiveLatencyResult[]): LatencySample[] {
|
||||
const definitions = new Map(server.latency.map((point) => [point.task_id, point]));
|
||||
return results.flatMap((result) => {
|
||||
const definition = definitions.get(result.task_id);
|
||||
if (!definition || !Number.isFinite(result.timestamp)) return [];
|
||||
return [{
|
||||
const latest = new Map<string, LiveLatencyResult>();
|
||||
for (const result of results) {
|
||||
const current = latest.get(result.task_id);
|
||||
if (!current || result.timestamp >= current.timestamp) latest.set(result.task_id, result);
|
||||
}
|
||||
let changed = false;
|
||||
const merged = server.latency.map((definition) => {
|
||||
const result = latest.get(definition.task_id);
|
||||
if (!result || result.timestamp <= definition.timestamp) return definition;
|
||||
changed = true;
|
||||
return {
|
||||
...definition,
|
||||
server_id: server.id,
|
||||
timestamp: result.timestamp,
|
||||
latency_ms: result.latency_ms,
|
||||
packet_loss: result.packet_loss,
|
||||
}];
|
||||
};
|
||||
});
|
||||
return changed ? merged : server.latency;
|
||||
}
|
||||
|
||||
function routeServerId() {
|
||||
@@ -77,8 +95,11 @@ export default function App() {
|
||||
const blur = themeToggle(config, "enableBlur");
|
||||
const liveServers = useMemo(() => servers.map((server) => {
|
||||
const live = liveMetrics[server.id];
|
||||
if (!live || live.timestamp <= (server.timestamp ?? 0)) return server;
|
||||
if (!live) return server;
|
||||
const latency = live.latencyResults?.length ? mergeLiveLatency(server, live.latencyResults) : server.latency;
|
||||
if (live.timestamp <= (server.timestamp ?? 0)) {
|
||||
return latency === server.latency ? server : { ...server, latency };
|
||||
}
|
||||
return { ...server, ...live.metrics, latency, timestamp: live.timestamp };
|
||||
}), [liveMetrics, servers]);
|
||||
|
||||
@@ -260,33 +281,23 @@ export default function App() {
|
||||
if (!latest) continue;
|
||||
const previous = next[update.serverId];
|
||||
if (previous && previous.timestamp >= latest.ts) continue;
|
||||
const results = latest.data.latency_results;
|
||||
const results = samples.flatMap((sample) => Array.isArray(sample.data.latency_results)
|
||||
? sample.data.latency_results
|
||||
: []);
|
||||
const metrics = { ...latest.data };
|
||||
delete metrics.latency_results;
|
||||
next[update.serverId] = {
|
||||
timestamp: latest.ts,
|
||||
metrics: latest.data,
|
||||
latencyResults: Array.isArray(results) ? results : previous?.latencyResults,
|
||||
metrics,
|
||||
latencyResults: Array.isArray(results) && results.length
|
||||
? mergeLiveResults(previous?.latencyResults, results)
|
||||
: previous?.latencyResults,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type !== "metrics" || !message.server_id || !message.metrics) return;
|
||||
if (!Number.isFinite(message.timestamp)) return;
|
||||
setLiveMetrics((current) => {
|
||||
const previous = current[message.server_id];
|
||||
if (previous && previous.timestamp >= message.timestamp) return current;
|
||||
return {
|
||||
...current,
|
||||
[message.server_id]: {
|
||||
timestamp: message.timestamp,
|
||||
metrics: message.metrics,
|
||||
latencyResults: Array.isArray(message.metrics.latency_results)
|
||||
? message.metrics.latency_results
|
||||
: previous?.latencyResults,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch { /* Ignore non-protocol messages. */ }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Info,
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@@ -38,13 +39,15 @@ import { Checkbox } from "./Checkbox";
|
||||
import { TurnstileWidget } from "./TurnstileWidget";
|
||||
import { LatencyManager } from "./LatencyManager";
|
||||
import { AlertRuleManager } from "./AlertRuleManager";
|
||||
import { Flag } from "./Flag";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
type AdminTab = "servers" | "latency" | "appearance" | "themes" | "themeSettings" | "alerts" | "security" | "data";
|
||||
const { version: VERSION } = pkg;
|
||||
|
||||
type AdminTab = "servers" | "latency" | "appearance" | "themes" | "themeSettings" | "alerts" | "security" | "data" | "about";
|
||||
type AgentPlatform = "linux" | "windows" | "macos";
|
||||
|
||||
interface AgentInstallTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
interface AgentInstallInfo {
|
||||
agent_token: string;
|
||||
agent_mirror: string;
|
||||
}
|
||||
@@ -58,6 +61,7 @@ const adminPages: Record<AdminTab, { title: string; description: string }> = {
|
||||
alerts: { title: "通知", description: "配置 Telegram 通知和资源告警阈值" },
|
||||
security: { title: "登录与安全", description: "管理管理员账号和 Cloudflare Turnstile 防护" },
|
||||
data: { title: "监控数据库", description: "查看 D1 用量、汇率状态并维护历史数据" },
|
||||
about: { title: "关于", description: "版本信息与项目地址" },
|
||||
};
|
||||
|
||||
|
||||
@@ -164,7 +168,11 @@ export function AdminPanel({
|
||||
const [themeSettingsSchema, setThemeSettingsSchema] = useState<ThemeSettingsSchema | null>(null);
|
||||
const [editing, setEditing] = useState<AdminServer | "new" | null>(null);
|
||||
const [form, setForm] = useState<ServerInput>(emptyServer);
|
||||
const [install, setInstall] = useState<AgentInstallTarget[]>([]);
|
||||
const [install, setInstall] = useState<AgentInstallInfo | null>(null);
|
||||
const [rxCurrentGb, setRxCurrentGb] = useState("0");
|
||||
const [txCurrentGb, setTxCurrentGb] = useState("0");
|
||||
const [rxBaseBytes, setRxBaseBytes] = useState(0);
|
||||
const [txBaseBytes, setTxBaseBytes] = useState(0);
|
||||
const [installPlatform, setInstallPlatform] = useState<AgentPlatform>("linux");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -236,6 +244,13 @@ export function AdminPanel({
|
||||
function openEditor(server?: AdminServer) {
|
||||
setEditing(server ?? "new");
|
||||
setForm(server ? toInput(server) : { ...emptyServer });
|
||||
// 修正值输入展示“当前累计用量”(已上报 + 已修正),保存时换算回差值。
|
||||
const rxBase = server ? (server.net_rx_total ?? 0) - server.rx_correction : 0;
|
||||
const txBase = server ? (server.net_tx_total ?? 0) - server.tx_correction : 0;
|
||||
setRxCurrentGb(((server ? (server.net_rx_total ?? 0) : 0) / 1024 ** 3).toFixed(2));
|
||||
setTxCurrentGb(((server ? (server.net_tx_total ?? 0) : 0) / 1024 ** 3).toFixed(2));
|
||||
setRxBaseBytes(rxBase);
|
||||
setTxBaseBytes(txBase);
|
||||
setError("");
|
||||
}
|
||||
|
||||
@@ -243,12 +258,17 @@ export function AdminPanel({
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const payload: ServerInput = {
|
||||
...form,
|
||||
rx_correction: Math.round(((Number(rxCurrentGb) || 0) * 1024 ** 3) - rxBaseBytes),
|
||||
tx_correction: Math.round(((Number(txCurrentGb) || 0) * 1024 ** 3) - txBaseBytes),
|
||||
};
|
||||
try {
|
||||
if (editing === "new") {
|
||||
const result = await api.createServer(form);
|
||||
setInstall([{ id: result.id, name: form.name, agent_token: result.agent_token, agent_mirror: form.agent_mirror }]);
|
||||
const result = await api.createServer(payload);
|
||||
setInstall({ agent_token: result.agent_token, agent_mirror: form.agent_mirror });
|
||||
} else if (editing) {
|
||||
await api.updateServer(editing.id, form);
|
||||
await api.updateServer(editing.id, payload);
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
@@ -281,7 +301,7 @@ export function AdminPanel({
|
||||
setError("");
|
||||
try {
|
||||
const { agent_token } = await api.serverToken(server.id);
|
||||
setInstall([{ id: server.id, name: server.name, agent_token, agent_mirror: server.agent_mirror }]);
|
||||
setInstall({ agent_token, agent_mirror: server.agent_mirror });
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "读取 Agent Token 失败");
|
||||
} finally {
|
||||
@@ -289,12 +309,9 @@ export function AdminPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInstallCommands() {
|
||||
async function copyInstallCommand() {
|
||||
try {
|
||||
const text = commands.length === 1
|
||||
? commands[0].command
|
||||
: commands.map((item) => `# ${item.name.replace(/\s+/g, " ").trim() || item.id}\n${item.command}`).join("\n\n");
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (installCommand) await navigator.clipboard.writeText(installCommand);
|
||||
} catch {
|
||||
setError("复制失败,请手动选择命令复制");
|
||||
}
|
||||
@@ -467,29 +484,21 @@ export function AdminPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const commands = useMemo(() => install.map((server) => {
|
||||
const installCommand = useMemo(() => {
|
||||
if (!install) return "";
|
||||
const origin = window.location.origin;
|
||||
const installer = "https://raw.githubusercontent.com/imengying/NodeFlare/main/agent";
|
||||
const mirror = server.agent_mirror.trim().replace(/\/+$/, "");
|
||||
const mirror = install.agent_mirror.trim().replace(/\/+$/, "");
|
||||
const shellMirror = mirror ? ` -m ${shellLiteral(mirror)}` : "";
|
||||
const powershellMirror = mirror ? ` -Mirror ${powershellLiteral(mirror)}` : "";
|
||||
if (installPlatform === "windows") {
|
||||
return {
|
||||
...server,
|
||||
command: `Invoke-WebRequest -Uri "${installer}/install.ps1" -OutFile "$env:TEMP\\nodeflare-install.ps1"\n& "$env:TEMP\\nodeflare-install.ps1" -e ${powershellLiteral(origin)} -t ${powershellLiteral(server.agent_token)}${powershellMirror}`,
|
||||
};
|
||||
return `Invoke-WebRequest -Uri "${installer}/install.ps1" -OutFile "$env:TEMP\\nodeflare-install.ps1"\n& "$env:TEMP\\nodeflare-install.ps1" -e ${powershellLiteral(origin)} -t ${powershellLiteral(install.agent_token)}${powershellMirror}`;
|
||||
}
|
||||
if (installPlatform === "macos") {
|
||||
return {
|
||||
...server,
|
||||
command: `curl -fsSL ${installer}/install-macos.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(server.agent_token)}${shellMirror}`,
|
||||
};
|
||||
return `curl -fsSL ${installer}/install-macos.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(install.agent_token)}${shellMirror}`;
|
||||
}
|
||||
return {
|
||||
...server,
|
||||
command: `curl -fsSL ${installer}/agent.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(server.agent_token)}${shellMirror}`,
|
||||
};
|
||||
}), [install, installPlatform]);
|
||||
return `curl -fsSL ${installer}/agent.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(install.agent_token)}${shellMirror}`;
|
||||
}, [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;
|
||||
@@ -545,6 +554,7 @@ export function AdminPanel({
|
||||
<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>
|
||||
<button className={tab === "about" ? "active" : ""} onClick={() => selectTab("about")}><Info size={19} />关于</button>
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="admin-content">
|
||||
@@ -559,7 +569,7 @@ export function AdminPanel({
|
||||
<button type="button" className="drag-handle" draggable onDragStart={(event) => startDrag(event, server.id)} onDragEnd={() => setDraggingId("")} title={`拖动排序:${server.name}`}><GripVertical size={15} /></button>
|
||||
<Checkbox checked={selectedIds.includes(server.id)} onChange={() => toggleSelected(server.id)} ariaLabel={`选择 ${server.name}`} />
|
||||
<span className={`status-dot ${settings && isOnline(server, settings.offline_threshold_seconds) ? "online" : ""}`} />
|
||||
<div className="server-name"><strong>{server.name}</strong><small>{server.group_name} · {server.region || "未设置地区"} · {server.last_ip || "尚未上报 IP"} · {server.agent_version ? `Agent v${server.agent_version}` : "Agent 未上报版本"} · {server.report_interval}s</small></div>
|
||||
<div className="server-name"><div className="server-name-main"><Flag region={server.region} size={17} /><strong>{server.name}</strong><small>{server.last_ip || "尚未上报 IP"} · {server.agent_version ? `Agent v${server.agent_version}` : "Agent 未上报版本"}</small></div></div>
|
||||
<div className="row-actions"><button className="icon-btn" disabled={index === 0} onClick={() => void move(index, -1)} title="上移"><ChevronUp size={15} /></button><button className="icon-btn" disabled={index === servers.length - 1} onClick={() => void move(index, 1)} title="下移"><ChevronDown size={15} /></button><button className="icon-btn" disabled={busy} onClick={() => void showInstallCommand(server)} title="显示安装命令"><Download size={15} /></button><button className="icon-btn" onClick={() => openEditor(server)} title="编辑节点"><Pencil size={15} /></button><button className="icon-btn danger" onClick={() => void remove(server)} title="删除节点"><Trash2 size={15} /></button></div>
|
||||
</div>
|
||||
))}
|
||||
@@ -575,7 +585,7 @@ export function AdminPanel({
|
||||
<div className="theme-list">
|
||||
{themes.map((theme) => <article className={`theme-row ${theme.active ? "active" : ""}`} key={theme.id}>
|
||||
<div className="theme-row-main">
|
||||
<div className="theme-row-title"><strong>{theme.name}</strong><span className={`theme-badge ${theme.builtin ? "builtin" : "remote"}`}>{theme.builtin ? "默认主题" : "远程主题"}</span></div>
|
||||
<div className="theme-row-title"><strong>{theme.name}</strong><span className={`theme-badge ${theme.builtin ? "builtin" : "remote"}`}>{theme.builtin ? "默认主题" : "远程主题"}</span>{theme.version ? <span className="theme-badge version">v{theme.version}</span> : null}</div>
|
||||
{!theme.builtin ? <p title={theme.description || undefined}>{theme.description || "暂无主题说明"}</p> : null}
|
||||
{!theme.builtin ? <a href={theme.url} target="_blank" rel="noreferrer"><span>{theme.url}</span><ExternalLink size={13} /></a> : null}
|
||||
</div>
|
||||
@@ -648,8 +658,17 @@ export function AdminPanel({
|
||||
<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}
|
||||
{tab !== "data" && tab !== "about" ? <div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={16} />保存{tab === "security" ? "账号与安全设置" : "设置"}</button></div> : null}
|
||||
</form>
|
||||
) : tab === "about" ? (
|
||||
<div className="admin-section about-page">
|
||||
<div className="about-brand"><img src="/logo.svg" alt="" width="52" height="52" /><div><strong>NodeFlare</strong><small>基于 Rust、WebAssembly 和 Cloudflare Workers 的服务器监控</small></div></div>
|
||||
<div className="about-rows">
|
||||
<div className="about-row"><span>版本</span><strong>v{VERSION}</strong></div>
|
||||
<div className="about-row"><span>项目地址</span><a href="https://github.com/imengying/NodeFlare" target="_blank" rel="noreferrer">github.com/imengying/NodeFlare<ExternalLink size={13} /></a></div>
|
||||
<div className="about-row"><span>开源协议</span><strong>MIT License</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -661,12 +680,12 @@ export function AdminPanel({
|
||||
<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>价格(0 隐藏,-1 免费)</span><input min="-1" 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", Math.round(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", Math.round(Number(event.target.value) * 1024 ** 3))} /></label><label><span>Agent 下载加速(可选)</span><input value={form.agent_mirror} onChange={(event) => updateForm("agent_mirror", event.target.value.trim())} placeholder="https://ghproxy.net" /></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={rxCurrentGb} onChange={(event) => setRxCurrentGb(event.target.value)} /></label><label><span>上行流量当前值(GB)</span><input min="0" step="0.1" type="number" value={txCurrentGb} onChange={(event) => setTxCurrentGb(event.target.value)} /></label><label><span>Agent 下载加速(可选)</span><input value={form.agent_mirror} onChange={(event) => updateForm("agent_mirror", event.target.value.trim())} placeholder="https://ghproxy.net" /></label></div>
|
||||
<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={() => void copyInstallCommands()}><Copy size={16} />复制全部</button></div></section></div> : null}
|
||||
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setInstall(null)}><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"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={16} />复制</button></div></section></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,10 +99,6 @@ export function LatencyManager({
|
||||
onError(form.task_type === "icmp" ? "ICMP 节点应为公网域名或公网 IPv4,不使用端口" : "TCP 节点应为公网域名或公网 IPv4,并填写 1 至 65535 的端口");
|
||||
return;
|
||||
}
|
||||
if (!form.default_enabled && !form.server_ids.length) {
|
||||
onError("请至少选择一个服务器,或开启默认分配");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
onError("");
|
||||
try {
|
||||
@@ -154,14 +150,14 @@ export function LatencyManager({
|
||||
|
||||
{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", port: null }))}>ICMP</button><button type="button" className={form.task_type === "tcp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "tcp", port: current.port ?? 443 }))}>TCP</button></div></label></div>
|
||||
<div className="form-grid"><label><span>节点</span><input required maxLength={50} value={form.target} onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))} placeholder={form.task_type === "tcp" ? "example.com" : "1.1.1.1"} /></label>{form.task_type === "tcp" ? <label><span>端口</span><input type="number" min="1" max="65535" required value={form.port ?? ""} onChange={(event) => setForm((current) => ({ ...current, port: event.target.value ? Number(event.target.value) : null }))} placeholder="443" /></label> : <div />}</div>
|
||||
<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", port: null }))}>ICMP</button><button type="button" className={form.task_type === "tcp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "tcp", port: current.port ?? 80 }))}>TCP</button></div></label></div>
|
||||
<div className="form-grid"><label><span>节点</span><input required maxLength={50} value={form.target} onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))} placeholder={form.task_type === "tcp" ? "example.com" : "1.1.1.1"} /></label>{form.task_type === "tcp" ? <label><span>端口</span><input type="number" min="1" max="65535" required value={form.port ?? ""} onChange={(event) => setForm((current) => ({ ...current, port: event.target.value ? Number(event.target.value) : null }))} placeholder="80" /></label> : <div />}</div>
|
||||
<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 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 || "未设置地区"}{server.last_ip ? ` · ${server.last_ip}` : ""}</small></span></label>)}
|
||||
{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.last_ip || "尚未上报 IP"}</small></span></label>)}
|
||||
{!visibleServers.length ? <div className="server-picker-empty">没有匹配的服务器</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ type ChartType = "load" | "latency";
|
||||
const LATENCY_COLORS = ["#2563eb", "#db2777", "#ea7b1b", "#0f766e", "#7c3aed", "#0891b2"];
|
||||
const REALTIME_WINDOW_SECONDS = 60 * 60;
|
||||
const MAX_REALTIME_POINTS = 720;
|
||||
const MAX_LATENCY_HISTORY_ROWS = 4000;
|
||||
|
||||
function historyPointFromServer(server: Server): HistoryPoint | null {
|
||||
if (!Number.isFinite(server.timestamp) || number(server.timestamp) <= 0) return null;
|
||||
@@ -96,6 +97,76 @@ function average(values: number[]): number | null {
|
||||
return valid.length ? valid.reduce((sum, value) => sum + value, 0) / valid.length : null;
|
||||
}
|
||||
|
||||
function latencyBucketSeconds(hours: number, taskCount: number): number {
|
||||
const boundedHours = Math.max(1, Math.min(24 * 365, Math.trunc(hours)));
|
||||
const boundedTasks = Math.max(1, Math.min(128, Math.trunc(taskCount)));
|
||||
const base = boundedHours === 1 ? 60
|
||||
: boundedHours <= 4 ? 120
|
||||
: boundedHours <= 24 ? 600
|
||||
: boundedHours <= 168 ? 3600
|
||||
: boundedHours <= 720 ? 14_400
|
||||
: 86_400;
|
||||
const pointsPerTask = Math.max(1, Math.floor(MAX_LATENCY_HISTORY_ROWS / boundedTasks));
|
||||
return Math.max(base, Math.ceil(boundedHours * 3600 / pointsPerTask));
|
||||
}
|
||||
|
||||
function mergeLatencySamples(current: LatencySample[], incoming: LatencySample[], hours: number, taskCount: number): LatencySample[] {
|
||||
const validIncoming = incoming.filter((point) => Number.isFinite(point.timestamp) && point.timestamp > 0);
|
||||
if (!validIncoming.length) return current.filter((point) => Number.isFinite(point.timestamp) && point.timestamp > 0);
|
||||
const latest = Math.max(...validIncoming.map((point) => point.timestamp));
|
||||
const cutoff = latest - hours * 3600;
|
||||
const bucket = latencyBucketSeconds(hours, taskCount);
|
||||
const key = (point: LatencySample) => `${point.task_id}:${Math.floor(point.timestamp / bucket)}`;
|
||||
const next = new Map(current
|
||||
.filter((point) => Number.isFinite(point.timestamp) && point.timestamp > 0 && point.timestamp >= cutoff)
|
||||
.map((point) => [key(point), point]));
|
||||
for (const point of validIncoming) {
|
||||
if (point.timestamp < cutoff) continue;
|
||||
const currentPoint = next.get(key(point));
|
||||
if (!currentPoint || point.timestamp >= currentPoint.timestamp) next.set(key(point), point);
|
||||
}
|
||||
return Array.from(next.values()).sort((left, right) => left.timestamp - right.timestamp);
|
||||
}
|
||||
|
||||
interface LatencyChartPoint {
|
||||
timestamp: number;
|
||||
label: string;
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
function latencyChartGapLimit(hours: number): number {
|
||||
return Math.max(5 * 60_000, (hours * 60 * 60_000) / 36);
|
||||
}
|
||||
|
||||
function insertLatencyGaps(points: LatencyChartPoint[], hours: number): LatencyChartPoint[] {
|
||||
if (points.length < 2) return points;
|
||||
const intervals = points.slice(1)
|
||||
.map((point, index) => point.timestamp - points[index].timestamp)
|
||||
.filter((interval) => interval > 0 && Number.isFinite(interval))
|
||||
.sort((left, right) => left - right);
|
||||
if (!intervals.length) return points;
|
||||
const typicalInterval = intervals[Math.floor((intervals.length - 1) / 4)];
|
||||
const threshold = Math.min(Math.max(10_000, typicalInterval * 1.5), latencyChartGapLimit(hours));
|
||||
const result: LatencyChartPoint[] = [points[0]];
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const previous = points[index - 1];
|
||||
const current = points[index];
|
||||
if (current.timestamp - previous.timestamp > threshold) {
|
||||
result.push({ timestamp: previous.timestamp + typicalInterval, label: "", value: null });
|
||||
}
|
||||
result.push(current);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function latencyChartTime(timestamp: number, hours: number, locale: "zh-CN" | "en"): string {
|
||||
const date = new Date(timestamp);
|
||||
if (hours <= 1) return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
|
||||
if (hours <= 4) return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
if (hours <= 24) return date.toLocaleString(locale, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
return date.toLocaleDateString(locale, { month: "2-digit", day: "2-digit" });
|
||||
}
|
||||
|
||||
const tooltipStyle = {
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border)",
|
||||
@@ -155,6 +226,8 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
|
||||
useEffect(() => {
|
||||
setLatencyLoading(true);
|
||||
setLatencyError("");
|
||||
setLatencyTasks([]);
|
||||
setLatencyPoints([]);
|
||||
if (demo) {
|
||||
const taskIds = new Set(server.latency.map((point) => point.task_id));
|
||||
const tasks = demoLatencyTasks.filter((task) => taskIds.has(task.id));
|
||||
@@ -165,36 +238,36 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
|
||||
}
|
||||
let active = true;
|
||||
api.latencyHistory(server.id, latencyHours)
|
||||
.then((latency) => { if (active) { setLatencyTasks(latency.tasks); setLatencyPoints(latency.points); } })
|
||||
.catch((reason) => { if (active) { setLatencyPoints([]); setLatencyError(reason instanceof Error ? reason.message : ui(locale, "延迟数据加载失败", "Unable to load latency")); } })
|
||||
.then((latency) => { if (active) {
|
||||
setLatencyTasks(latency.tasks);
|
||||
setLatencyPoints((current) => mergeLatencySamples(
|
||||
current,
|
||||
latency.points,
|
||||
latencyHours,
|
||||
latency.tasks.length,
|
||||
));
|
||||
} })
|
||||
.catch((reason) => { if (active) { setLatencyTasks([]); setLatencyPoints([]); setLatencyError(reason instanceof Error ? reason.message : ui(locale, "延迟数据加载失败", "Unable to load latency")); } })
|
||||
.finally(() => { if (active) setLatencyLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [server.id, latencyHours, demo, locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (demo || !server.latency.length) return;
|
||||
const latest = Math.max(...server.latency.map((point) => point.timestamp).filter(Number.isFinite));
|
||||
if (!Number.isFinite(latest)) return;
|
||||
const cutoff = latest - latencyHours * 3600;
|
||||
setLatencyPoints((current) => {
|
||||
const next = new Map(current
|
||||
.filter((point) => point.timestamp >= cutoff)
|
||||
.map((point) => [`${point.task_id}:${point.timestamp}`, point]));
|
||||
for (const point of server.latency) {
|
||||
if (point.timestamp >= cutoff) {
|
||||
next.set(`${point.task_id}:${point.timestamp}`, point);
|
||||
}
|
||||
}
|
||||
return Array.from(next.values()).sort((left, right) => left.timestamp - right.timestamp);
|
||||
});
|
||||
}, [demo, latencyHours, server.latency]);
|
||||
setLatencyPoints((current) => mergeLatencySamples(
|
||||
current,
|
||||
server.latency,
|
||||
latencyHours,
|
||||
Math.max(latencyTasks.length, server.latency.length),
|
||||
));
|
||||
}, [demo, latencyHours, latencyTasks.length, server.latency]);
|
||||
|
||||
useEffect(() => {
|
||||
if (demo || !liveLatencyResults.length || !latencyTasks.length) return;
|
||||
const definitions = new Map(latencyTasks.map((task) => [task.id, task]));
|
||||
const samples = liveLatencyResults.flatMap((result): LatencySample[] => {
|
||||
const task = definitions.get(result.task_id);
|
||||
if (!task || !Number.isFinite(result.timestamp)) return [];
|
||||
if (!task || !Number.isFinite(result.timestamp) || result.timestamp <= 0) return [];
|
||||
return [{
|
||||
task_id: task.id,
|
||||
server_id: server.id,
|
||||
@@ -208,15 +281,7 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
|
||||
}];
|
||||
});
|
||||
if (!samples.length) return;
|
||||
const latest = Math.max(...samples.map((point) => point.timestamp));
|
||||
const cutoff = latest - latencyHours * 3600;
|
||||
setLatencyPoints((current) => {
|
||||
const next = new Map(current
|
||||
.filter((point) => point.timestamp >= cutoff)
|
||||
.map((point) => [`${point.task_id}:${point.timestamp}`, point]));
|
||||
for (const point of samples) next.set(`${point.task_id}:${point.timestamp}`, point);
|
||||
return Array.from(next.values()).sort((left, right) => left.timestamp - right.timestamp);
|
||||
});
|
||||
setLatencyPoints((current) => mergeLatencySamples(current, samples, latencyHours, latencyTasks.length));
|
||||
}, [demo, latencyHours, latencyTasks, liveLatencyResults, server.id]);
|
||||
|
||||
const online = isOnline(server, threshold);
|
||||
@@ -238,34 +303,44 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
|
||||
? { 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);
|
||||
const rawPoints: LatencyChartPoint[] = samples
|
||||
.filter((sample) => Number.isFinite(sample.timestamp) && sample.timestamp > 0)
|
||||
.sort((left, right) => left.timestamp - right.timestamp)
|
||||
.map((sample) => ({
|
||||
timestamp: sample.timestamp * 1000,
|
||||
label: latencyChartTime(sample.timestamp * 1000, hours, locale),
|
||||
value: sample.latency_ms >= 0 ? sample.latency_ms : null,
|
||||
}));
|
||||
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)),
|
||||
points: insertLatencyGaps(rawPoints, hours),
|
||||
};
|
||||
});
|
||||
}, [latencyPoints, latencyTasks]);
|
||||
}, [hours, latencyPoints, latencyTasks, locale]);
|
||||
const latencyData = useMemo(() => {
|
||||
const timestamps = new Set<number>();
|
||||
for (const series of latencySeries) {
|
||||
for (const point of series.points) timestamps.add(point.timestamp);
|
||||
}
|
||||
return Array.from(timestamps)
|
||||
.sort((left, right) => left - right)
|
||||
.map((timestamp) => ({ timestamp, label: latencyChartTime(timestamp, hours, locale) }));
|
||||
}, [hours, latencySeries, locale]);
|
||||
const latencyTimeRange = useMemo(() => {
|
||||
const now = Date.now();
|
||||
const start = now - latencyHours * 60 * 60_000;
|
||||
return {
|
||||
domain: [start, now] as [number, number],
|
||||
ticks: Array.from({ length: 5 }, (_, index) => start + ((now - start) * index) / 4),
|
||||
};
|
||||
}, [latencyHours, latencyPoints]);
|
||||
const last = data[data.length - 1];
|
||||
const pingEnabled = latencyTasks.length > 0;
|
||||
|
||||
@@ -334,13 +409,13 @@ export function NodeDetails({ server, liveLatencyResults, threshold, retentionDa
|
||||
</div>
|
||||
<div className="latency-line-chart">
|
||||
{latencyData.length ? <ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={latencyData.length ? latencyData : data} margin={{ top: 10, right: 12, left: 4, bottom: 2 }}>
|
||||
<LineChart data={latencyData} 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} />
|
||||
<XAxis dataKey="timestamp" type="number" domain={latencyTimeRange.domain} ticks={latencyTimeRange.ticks} allowDataOverflow tick={{ fontSize: 10 }} minTickGap={40} tickFormatter={(value) => latencyChartTime(Number(value), latencyHours, locale)} />
|
||||
<YAxis tick={{ fontSize: 10 }} width={48} unit="ms" />
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={(value, name) => [`${Number(value).toFixed(1)} ms`, name]} />
|
||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={(value) => latencyChartTime(Number(value), latencyHours, locale)} formatter={(value, name) => [value == null ? "--" : `${Number(value).toFixed(1)} ms`, name]} />
|
||||
<Legend iconType="rect" iconSize={9} wrapperStyle={{ fontSize: 11, paddingTop: 10 }} />
|
||||
{latencySeries.map((series) => <Line key={series.id} type="monotone" dataKey={series.dataKey} name={series.name} stroke={series.color} strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} />)}
|
||||
{latencySeries.map((series) => <Line key={series.id} data={series.points} type="monotone" dataKey="value" name={series.name} stroke={series.color} strokeWidth={2} dot={false} connectNulls={false} isAnimationActive={false} />)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer> : <div className="latency-empty">{ui(locale, "暂无延迟数据", "No latency data")}</div>}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface LatencyBar {
|
||||
|
||||
const cache = new Map<string, { at: number; points: LatencySample[] }>();
|
||||
const BAR_COUNT = 20;
|
||||
const CACHE_TTL = 60_000;
|
||||
const REFRESH_INTERVAL = 60_000;
|
||||
|
||||
function latencyTone(value: number): LatencyBar["tone"] {
|
||||
if (value <= 60) return "good";
|
||||
@@ -31,59 +33,170 @@ 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 mergePoints(...sources: LatencySample[][]): LatencySample[] {
|
||||
const points = new Map<string, LatencySample>();
|
||||
for (const source of sources) {
|
||||
for (const point of source) {
|
||||
if (!Number.isFinite(point.timestamp) || point.timestamp <= 0) continue;
|
||||
points.set(`${point.task_id}:${point.timestamp}`, point);
|
||||
}
|
||||
}
|
||||
return Array.from(points.values()).sort((left, right) => left.timestamp - right.timestamp);
|
||||
}
|
||||
|
||||
interface LatencyHistoryPoint {
|
||||
timestamp: number;
|
||||
latency: number | null;
|
||||
loss: number | null;
|
||||
}
|
||||
|
||||
function validValue(value: number): number | null {
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the same semantics as Komari-Glass: samples are grouped by elapsed
|
||||
* time, not array position, and each bucket represents the mean of the
|
||||
* configured test points that produced a usable result.
|
||||
*/
|
||||
function buildHistory(points: LatencySample[]): LatencyHistoryPoint[] {
|
||||
const samples = points
|
||||
.filter((point) => Number.isFinite(point.timestamp) && point.timestamp > 0)
|
||||
.map((point) => ({
|
||||
timestamp: point.timestamp * 1000,
|
||||
latency: validValue(point.latency_ms),
|
||||
loss: validValue(point.packet_loss),
|
||||
}))
|
||||
.filter((point) => point.latency !== null || point.loss !== null)
|
||||
.sort((left, right) => left.timestamp - right.timestamp);
|
||||
if (!samples.length) return [];
|
||||
|
||||
const first = samples[0].timestamp;
|
||||
const last = samples[samples.length - 1].timestamp;
|
||||
const count = Math.min(BAR_COUNT, Math.max(samples.length, 1));
|
||||
const bucketSize = Math.max(1, (last - first) / count);
|
||||
const history: LatencyHistoryPoint[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const start = first + bucketSize * index;
|
||||
const end = index === count - 1 ? last + 1 : start + bucketSize;
|
||||
let latencySum = 0;
|
||||
let latencyCount = 0;
|
||||
let lossSum = 0;
|
||||
let lossCount = 0;
|
||||
while (cursor < samples.length) {
|
||||
const sample = samples[cursor];
|
||||
if (sample.timestamp >= end) break;
|
||||
if (sample.timestamp >= start) {
|
||||
if (sample.latency !== null) {
|
||||
latencySum += sample.latency;
|
||||
latencyCount += 1;
|
||||
}
|
||||
if (sample.loss !== null) {
|
||||
lossSum += sample.loss;
|
||||
lossCount += 1;
|
||||
}
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
history.push({
|
||||
timestamp: Math.round(start / 1000),
|
||||
latency: latencyCount ? latencySum / latencyCount : null,
|
||||
loss: lossCount ? lossSum / lossCount : null,
|
||||
});
|
||||
}
|
||||
return history;
|
||||
}
|
||||
|
||||
function summarize(points: LatencySample[]) {
|
||||
// Komari-Glass excludes tasks with no valid latency, then weights the
|
||||
// remaining summary by their actual sample counts.
|
||||
const byTask = new Map<string, LatencySample[]>();
|
||||
for (const point of points) {
|
||||
const samples = byTask.get(point.task_id) ?? [];
|
||||
samples.push(point);
|
||||
byTask.set(point.task_id, samples);
|
||||
}
|
||||
const includedTaskIds = new Set([...byTask.entries()].filter(([, samples]) =>
|
||||
samples.some((sample) => validValue(sample.latency_ms) !== null),
|
||||
).map(([taskId]) => taskId));
|
||||
const included = points.filter((point) => includedTaskIds.has(point.task_id));
|
||||
const latencies = included.map((point) => validValue(point.latency_ms)).filter((value): value is number => value !== null);
|
||||
const losses = included.map((point) => validValue(point.packet_loss)).filter((value): value is number => value !== null);
|
||||
const history = buildHistory(included);
|
||||
return {
|
||||
averageLatency: latencies.length
|
||||
? latencies.reduce((sum, value) => sum + value, 0) / latencies.length
|
||||
: -1,
|
||||
averageLoss: losses.length
|
||||
? losses.reduce((sum, value) => sum + value, 0) / losses.length
|
||||
: -1,
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
export function useNodeLatency(server: Server, enabled: boolean) {
|
||||
const initial = server.latency;
|
||||
const [points, setPoints] = useState<LatencySample[]>(initial);
|
||||
const [fetched, setFetched] = useState<LatencySample[]>(server.latency);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
|
||||
const taskSignature = [...new Set(server.latency.map((point) => point.task_id))].sort().join(",");
|
||||
|
||||
useEffect(() => {
|
||||
setPoints(server.latency);
|
||||
}, [server.latency]);
|
||||
setFetched(server.latency);
|
||||
}, [server.id, taskSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) { setLoading(false); return; }
|
||||
let active = true;
|
||||
const hit = cache.get(server.id);
|
||||
if (hit && Date.now() - hit.at < 60_000) {
|
||||
setPoints(hit.points);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
api.latencyHistory(server.id, 1).then((result) => {
|
||||
cache.set(server.id, { at: Date.now(), points: result.points });
|
||||
if (active) setPoints(result.points);
|
||||
}).catch(() => {}).finally(() => { if (active) setLoading(false); });
|
||||
return () => { active = false; };
|
||||
const load = (force: boolean) => {
|
||||
const hit = cache.get(server.id);
|
||||
if (!force && hit && Date.now() - hit.at < CACHE_TTL) {
|
||||
setFetched(hit.points);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!hit) setLoading(true);
|
||||
void api.latencyHistory(server.id, 1).then((result) => {
|
||||
cache.set(server.id, { at: Date.now(), points: result.points });
|
||||
if (active) setFetched(result.points);
|
||||
}).catch(() => {
|
||||
// Keep the last successful samples visible during transient failures.
|
||||
}).finally(() => { if (active) setLoading(false); });
|
||||
};
|
||||
load(false);
|
||||
const timer = window.setInterval(() => load(true), REFRESH_INTERVAL);
|
||||
return () => { active = false; window.clearInterval(timer); };
|
||||
}, [enabled, server.id]);
|
||||
|
||||
// 保留历史柱,同时用实时推送覆盖同一任务的最新样本。
|
||||
const points = useMemo(() => mergePoints(fetched, server.latency), [server.latency, fetched]);
|
||||
|
||||
return useMemo(() => {
|
||||
const latency = summarize(points, "latency_ms");
|
||||
const loss = summarize(points, "packet_loss");
|
||||
const summary = summarize(points);
|
||||
const latencyBars = summary.history.length
|
||||
? summary.history.map((point, index) => ({
|
||||
key: `latency-${point.timestamp}-${index}`,
|
||||
tone: point.latency === null ? "empty" as const : latencyTone(point.latency),
|
||||
tooltip: point.latency === null
|
||||
? `${new Date(point.timestamp * 1000).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}\n无采样数据`
|
||||
: `${new Date(point.timestamp * 1000).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}\n${Math.round(point.latency)} ms`,
|
||||
}))
|
||||
: emptyBars(loading ? "加载中" : "无采样数据");
|
||||
const lossBars = summary.history.length
|
||||
? summary.history.map((point, index) => ({
|
||||
key: `loss-${point.timestamp}-${index}`,
|
||||
tone: point.loss === null ? "empty" as const : lossTone(point.loss),
|
||||
tooltip: point.loss === null
|
||||
? `${new Date(point.timestamp * 1000).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}\n无采样数据`
|
||||
: `${new Date(point.timestamp * 1000).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}\n${point.loss.toFixed(1)}%`,
|
||||
}))
|
||||
: emptyBars(loading ? "加载中" : "无采样数据");
|
||||
return {
|
||||
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,
|
||||
latencyDisplay: summary.averageLatency >= 0 ? `${Math.round(summary.averageLatency)} ms` : loading ? "加载中" : "-",
|
||||
lossDisplay: summary.averageLoss >= 0 ? `${summary.averageLoss.toFixed(1)}%` : loading ? "加载中" : "-",
|
||||
latencyBars,
|
||||
lossBars,
|
||||
};
|
||||
}, [loading, points]);
|
||||
}
|
||||
@@ -182,7 +182,6 @@
|
||||
.editor-modal h3, .install-modal h3, .latency-editor h3 { margin: 0; color: var(--admin-text); font-size: 18px; }
|
||||
.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; color: var(--admin-text); font-size: 13px; }
|
||||
.install-modal pre { overflow-x: auto; margin: 0; border: 1px solid var(--admin-border); border-radius: 6px; padding: 12px; color: var(--admin-text); background: var(--admin-bg); font: 12px/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; }
|
||||
@@ -287,3 +286,28 @@
|
||||
.admin-back { top: 12px; left: 12px; }
|
||||
.admin-login-theme { top: 12px; right: 12px; }
|
||||
}
|
||||
|
||||
.server-name-main { display: flex; min-width: 0; align-items: center; gap: 7px; }
|
||||
.server-name-main .region-flag { flex: 0 0 auto; font-size: 16px; line-height: 1; }
|
||||
.server-name-main strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.server-name-main small { overflow: hidden; color: var(--admin-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.about-page { display: flex; flex-direction: column; gap: 20px; }
|
||||
.about-brand { display: flex; align-items: center; gap: 14px; }
|
||||
.about-brand img { flex: 0 0 auto; }
|
||||
.about-brand strong { display: block; color: var(--admin-text); font-size: 20px; }
|
||||
.about-brand small { display: block; margin-top: 3px; color: var(--admin-muted); font-size: 13px; }
|
||||
.about-rows { display: grid; border: 1px solid var(--admin-border); border-radius: 7px; overflow: hidden; }
|
||||
.about-row { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--admin-border); padding: 10px 14px; }
|
||||
.about-row:last-child { border-bottom: 0; }
|
||||
.about-row span { color: var(--admin-muted); font-size: 13px; }
|
||||
.about-row strong { color: var(--admin-text); font-size: 14px; font-weight: 600; }
|
||||
.about-row a { display: inline-flex; align-items: center; gap: 5px; color: var(--admin-accent); font-size: 14px; text-decoration: none; }
|
||||
.about-row a:hover { text-decoration: underline; }
|
||||
.theme-badge.version { color: var(--admin-muted); }
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.about-brand { align-items: flex-start; }
|
||||
.about-brand small { line-height: 1.55; }
|
||||
.about-row { align-items: flex-start; flex-direction: column; gap: 5px; }
|
||||
.about-row a { max-width: 100%; overflow-wrap: anywhere; }
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export interface Theme {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
version: string;
|
||||
builtin: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,7 @@ CREATE TABLE themes (
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
+4
-1
@@ -19,6 +19,9 @@
|
||||
"test:smoke": "sh scripts/smoke-test.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.122.0"
|
||||
"wrangler": "^4.123.0"
|
||||
},
|
||||
"overrides": {
|
||||
"nanoid": "3.3.18"
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,8 @@ SELECT
|
||||
s.created_at,
|
||||
m.timestamp, m.cpu, m.load1, m.load5, m.load15, m.mem_used, m.mem_total,
|
||||
m.swap_used, m.swap_total, m.disk_used, m.disk_total, m.net_in, m.net_out,
|
||||
CASE WHEN m.net_rx_total IS NULL THEN NULL ELSE COALESCE(c.used_rx, m.net_rx_total) + s.rx_correction END AS net_rx_total,
|
||||
CASE WHEN m.net_tx_total IS NULL THEN NULL ELSE COALESCE(c.used_tx, m.net_tx_total) + s.tx_correction END AS net_tx_total,
|
||||
CASE WHEN m.net_rx_total IS NULL THEN NULL ELSE MAX(0, COALESCE(c.used_rx, m.net_rx_total) + s.rx_correction) END AS net_rx_total,
|
||||
CASE WHEN m.net_tx_total IS NULL THEN NULL ELSE MAX(0, COALESCE(c.used_tx, m.net_tx_total) + s.tx_correction) END AS net_tx_total,
|
||||
m.uptime, m.processes, m.tcp_connections,
|
||||
m.udp_connections, m.cpu_cores, m.cpu_model, m.os, m.kernel, m.arch,
|
||||
m.virtualization, m.gpu_usage, m.gpu_model, m.agent_version,
|
||||
@@ -60,6 +60,7 @@ struct ThemeRow {
|
||||
name: String,
|
||||
description: String,
|
||||
url: String,
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -1233,7 +1234,7 @@ pub async fn update_settings(
|
||||
|
||||
pub async fn list_themes(db: &D1Database, active_id: &str) -> Result<Vec<ThemeView>> {
|
||||
let rows: Vec<ThemeRow> = db
|
||||
.prepare("SELECT id, name, description, url FROM themes ORDER BY created_at DESC")
|
||||
.prepare("SELECT id, name, description, url, version FROM themes ORDER BY created_at DESC")
|
||||
.all()
|
||||
.await?
|
||||
.results()?;
|
||||
@@ -1246,6 +1247,7 @@ pub async fn list_themes(db: &D1Database, active_id: &str) -> Result<Vec<ThemeVi
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
url: row.url,
|
||||
version: row.version,
|
||||
builtin: false,
|
||||
active,
|
||||
}
|
||||
@@ -1258,17 +1260,19 @@ pub async fn create_theme(
|
||||
id: &str,
|
||||
input: &ThemeInput,
|
||||
source_url: &str,
|
||||
version: &str,
|
||||
timestamp: i64,
|
||||
) -> Result<()> {
|
||||
db.prepare(
|
||||
"INSERT INTO themes(id, name, description, url, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
"INSERT INTO themes(id, name, description, url, version, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
)
|
||||
.bind(&[
|
||||
text(id),
|
||||
text(input.name.trim()),
|
||||
text(input.description.trim()),
|
||||
text(source_url),
|
||||
text(version),
|
||||
number(timestamp),
|
||||
])?
|
||||
.run()
|
||||
@@ -1278,7 +1282,7 @@ pub async fn create_theme(
|
||||
|
||||
pub async fn theme_exists(db: &D1Database, id: &str) -> Result<bool> {
|
||||
let row: Option<ThemeRow> = db
|
||||
.prepare("SELECT id, name, description, url FROM themes WHERE id = ?1")
|
||||
.prepare("SELECT id, name, description, url, version FROM themes WHERE id = ?1")
|
||||
.bind(&[text(id)])?
|
||||
.first(None)
|
||||
.await?;
|
||||
@@ -1287,7 +1291,7 @@ pub async fn theme_exists(db: &D1Database, id: &str) -> Result<bool> {
|
||||
|
||||
pub async fn theme_url(db: &D1Database, id: &str) -> Result<Option<String>> {
|
||||
let row: Option<ThemeRow> = db
|
||||
.prepare("SELECT id, name, description, url FROM themes WHERE id = ?1")
|
||||
.prepare("SELECT id, name, description, url, version FROM themes WHERE id = ?1")
|
||||
.bind(&[text(id)])?
|
||||
.first(None)
|
||||
.await?;
|
||||
@@ -1295,6 +1299,14 @@ pub async fn theme_url(db: &D1Database, id: &str) -> Result<Option<String>> {
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub async fn update_theme_version(db: &D1Database, id: &str, version: &str) -> Result<()> {
|
||||
db.prepare("UPDATE themes SET version = ?2 WHERE id = ?1")
|
||||
.bind(&[text(id), text(version)])?
|
||||
.run()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_active_theme(db: &D1Database, id: &str) -> Result<bool> {
|
||||
if id != crate::theme::BUILTIN_THEME_ID && !theme_exists(db, id).await? {
|
||||
return Ok(false);
|
||||
|
||||
+17
-11
@@ -364,11 +364,14 @@ fn compact_results(
|
||||
|
||||
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, t.port, \
|
||||
l.timestamp, l.latency_ms, l.packet_loss \
|
||||
FROM latency_latest l INNER JOIN latency_tasks t ON t.id = l.task_id \
|
||||
INNER JOIN latency_task_servers a \
|
||||
ON a.task_id = l.task_id AND a.server_id = l.server_id \
|
||||
"SELECT a.task_id, a.server_id, t.name, t.task_type, t.target, t.port, \
|
||||
COALESCE(l.timestamp, 0) AS timestamp, \
|
||||
COALESCE(l.latency_ms, -1) AS latency_ms, \
|
||||
COALESCE(l.packet_loss, -1) AS packet_loss \
|
||||
FROM latency_task_servers a \
|
||||
INNER JOIN latency_tasks t ON t.id = a.task_id \
|
||||
LEFT JOIN latency_latest l \
|
||||
ON l.task_id = a.task_id AND l.server_id = a.server_id \
|
||||
ORDER BY t.sort_order ASC, t.created_at ASC",
|
||||
)
|
||||
.all()
|
||||
@@ -378,12 +381,15 @@ pub async fn latest_all(db: &D1Database) -> Result<Vec<LatencySample>> {
|
||||
|
||||
pub async fn latest_for_server(db: &D1Database, server_id: &str) -> Result<Vec<LatencySample>> {
|
||||
db.prepare(
|
||||
"SELECT l.task_id, l.server_id, t.name, t.task_type, t.target, t.port, \
|
||||
l.timestamp, l.latency_ms, l.packet_loss \
|
||||
FROM latency_latest l INNER JOIN latency_tasks t ON t.id = l.task_id \
|
||||
INNER JOIN latency_task_servers a \
|
||||
ON a.task_id = l.task_id AND a.server_id = l.server_id \
|
||||
WHERE l.server_id = ?1 \
|
||||
"SELECT a.task_id, a.server_id, t.name, t.task_type, t.target, t.port, \
|
||||
COALESCE(l.timestamp, 0) AS timestamp, \
|
||||
COALESCE(l.latency_ms, -1) AS latency_ms, \
|
||||
COALESCE(l.packet_loss, -1) AS packet_loss \
|
||||
FROM latency_task_servers a \
|
||||
INNER JOIN latency_tasks t ON t.id = a.task_id \
|
||||
LEFT JOIN latency_latest l \
|
||||
ON l.task_id = a.task_id AND l.server_id = a.server_id \
|
||||
WHERE a.server_id = ?1 \
|
||||
ORDER BY t.sort_order ASC, t.created_at ASC",
|
||||
)
|
||||
.bind(&[text(server_id)])?
|
||||
|
||||
+2
-10
@@ -524,9 +524,6 @@ pub(crate) fn validate_latency_task(input: &LatencyTaskInput) -> Option<&'static
|
||||
{
|
||||
return Some("服务器选择列表无效");
|
||||
}
|
||||
if !input.default_enabled && input.server_ids.is_empty() {
|
||||
return Some("请至少选择一个服务器,或开启默认分配");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -626,9 +623,6 @@ pub(crate) fn validate_server(input: &ServerInput) -> Option<&'static str> {
|
||||
if input.network_interface.chars().count() > 160 {
|
||||
return Some("统计网卡配置字段过长");
|
||||
}
|
||||
if input.rx_correction < 0 || input.tx_correction < 0 {
|
||||
return Some("流量修正值不能为负数");
|
||||
}
|
||||
if !valid_agent_mirror(&input.agent_mirror) {
|
||||
return Some("Agent 下载加速地址必须是无凭据、查询参数和片段的 HTTPS URL");
|
||||
}
|
||||
@@ -1309,10 +1303,8 @@ mod tests {
|
||||
assert_eq!(validate_latency_task(&task), Some("ICMP 检测不使用端口"));
|
||||
task.port = None;
|
||||
task.server_ids.clear();
|
||||
assert_eq!(
|
||||
validate_latency_task(&task),
|
||||
Some("请至少选择一个服务器,或开启默认分配")
|
||||
);
|
||||
// 空服务器列表且未开默认分配:任务先保存,之后可再分配。
|
||||
assert_eq!(validate_latency_task(&task), None);
|
||||
task.default_enabled = true;
|
||||
assert_eq!(validate_latency_task(&task), None);
|
||||
}
|
||||
|
||||
+28
-19
@@ -38,13 +38,8 @@ struct SocketAttachment {
|
||||
traffic: Option<TrafficAttachment>,
|
||||
}
|
||||
|
||||
fn active_report_interval_ms(report_interval: i64, collect_interval: i64) -> i64 {
|
||||
let report_interval = report_interval.clamp(15, 3600);
|
||||
let collect_interval = collect_interval.clamp(2, 60).min(report_interval);
|
||||
((report_interval + 14) / 15)
|
||||
.max(collect_interval)
|
||||
.clamp(2, 60)
|
||||
* 1000
|
||||
fn live_interval_ms(collect_interval: i64) -> i64 {
|
||||
collect_interval.clamp(2, 60) * 1000
|
||||
}
|
||||
|
||||
fn apply_live_traffic(report: &mut AgentReport, traffic: &mut TrafficAttachment) {
|
||||
@@ -71,8 +66,8 @@ fn apply_live_traffic(report: &mut AgentReport, traffic: &mut TrafficAttachment)
|
||||
traffic.raw_rx = report.net_rx_total;
|
||||
traffic.raw_tx = report.net_tx_total;
|
||||
}
|
||||
report.net_rx_total = traffic.used_rx.saturating_add(traffic.rx_correction);
|
||||
report.net_tx_total = traffic.used_tx.saturating_add(traffic.tx_correction);
|
||||
report.net_rx_total = traffic.used_rx.saturating_add(traffic.rx_correction).max(0);
|
||||
report.net_tx_total = traffic.used_tx.saturating_add(traffic.tx_correction).max(0);
|
||||
}
|
||||
|
||||
fn batch_update_payload(
|
||||
@@ -336,17 +331,12 @@ impl DurableObject for LiveHub {
|
||||
self.state
|
||||
.get_websockets_with_tag(&format!("server:{server_id}")),
|
||||
);
|
||||
let has_dashboard = !sockets.is_empty();
|
||||
for dashboard in sockets {
|
||||
if dashboard.send_with_str(&payload).is_err() {
|
||||
let _ = dashboard.close(Some(1011), Some("send failed"));
|
||||
}
|
||||
}
|
||||
let next_wss_ms = if has_dashboard {
|
||||
active_report_interval_ms(attachment.report_interval, attachment.collect_interval)
|
||||
} else {
|
||||
report_interval * 1000
|
||||
};
|
||||
let next_wss_ms = live_interval_ms(attachment.collect_interval);
|
||||
let next_d1_ms = if persisted {
|
||||
report_interval * 1000
|
||||
} else {
|
||||
@@ -362,6 +352,7 @@ impl DurableObject for LiveHub {
|
||||
"nextWssReportAfterMs": next_wss_ms
|
||||
}))?)?;
|
||||
} else {
|
||||
let next_wss_ms = live_interval_ms(attachment.collect_interval);
|
||||
let next_d1_ms = if persisted {
|
||||
report_interval * 1000
|
||||
} else {
|
||||
@@ -374,7 +365,7 @@ impl DurableObject for LiveHub {
|
||||
"ts": received_at,
|
||||
"persisted": persisted,
|
||||
"nextD1WriteAfterMs": next_d1_ms,
|
||||
"nextWssReportAfterMs": report_interval * 1000
|
||||
"nextWssReportAfterMs": next_wss_ms
|
||||
}))?)?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -494,7 +485,7 @@ pub async fn upgrade_agent(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{active_report_interval_ms, batch_update_payload_at, TrafficAttachment};
|
||||
use super::{batch_update_payload_at, live_interval_ms, TrafficAttachment};
|
||||
use crate::models::AgentReport;
|
||||
|
||||
#[test]
|
||||
@@ -549,7 +540,8 @@ mod tests {
|
||||
tx_correction: 20,
|
||||
};
|
||||
let payload: serde_json::Value = serde_json::from_str(
|
||||
&batch_update_payload_at("node-a", &[report], &mut traffic, 1_235).unwrap(),
|
||||
&batch_update_payload_at("node-a", std::slice::from_ref(&report), &mut traffic, 1_235)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(payload["type"], "batchUpdate");
|
||||
@@ -563,6 +555,23 @@ mod tests {
|
||||
assert!(payload["updates"][0]["samples"][0]["data"]
|
||||
.get("timestamp")
|
||||
.is_none());
|
||||
assert_eq!(active_report_interval_ms(60, 5), 5_000);
|
||||
assert_eq!(live_interval_ms(5), 5_000);
|
||||
assert_eq!(live_interval_ms(1), 2_000);
|
||||
assert_eq!(live_interval_ms(120), 60_000);
|
||||
|
||||
let mut corrected_down = traffic;
|
||||
corrected_down.rx_correction = -800;
|
||||
let mut corrected_report = report.clone();
|
||||
corrected_report.timestamp = 1_235;
|
||||
corrected_report.net_rx_total = 301;
|
||||
let payload: serde_json::Value = serde_json::from_str(
|
||||
&batch_update_payload_at("node-a", &[corrected_report], &mut corrected_down, 1_236)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
payload["updates"][0]["samples"][0]["data"]["net_rx_total"],
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,7 @@ pub struct ThemeView {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub url: String,
|
||||
pub version: String,
|
||||
pub builtin: bool,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
+18
-1
@@ -396,6 +396,7 @@ async fn themes_get(ctx: &RouteContext) -> Result<Response> {
|
||||
name: theme::BUILTIN_THEME_NAME.to_string(),
|
||||
description: "默认主题".to_string(),
|
||||
url: String::new(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
builtin: true,
|
||||
active: ctx.settings.active_theme_id == theme::BUILTIN_THEME_ID,
|
||||
}];
|
||||
@@ -437,7 +438,18 @@ async fn themes_post(req: &mut Request, ctx: &RouteContext) -> Result<Response>
|
||||
);
|
||||
return error("无法读取主题 index.html,请检查 URL 和主题构建产物", 422);
|
||||
}
|
||||
db::create_theme(&ctx.database, &id, &input, &resolved.source_url, now()).await?;
|
||||
let version = theme::remote_theme_version(&resolved.resolved_url)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
db::create_theme(
|
||||
&ctx.database,
|
||||
&id,
|
||||
&input,
|
||||
&resolved.source_url,
|
||||
&version,
|
||||
now(),
|
||||
)
|
||||
.await?;
|
||||
json(&serde_json::json!({ "id": id }), 201)
|
||||
}
|
||||
|
||||
@@ -482,6 +494,11 @@ async fn theme_activate_post(ctx: &RouteContext, path: &str) -> Result<Response>
|
||||
console_warn!("theme activation failed for {url}: {err}");
|
||||
return error("主题当前不可访问,未切换主题", 422);
|
||||
}
|
||||
if let Some(version) = theme::remote_theme_version(&url).await {
|
||||
if let Err(err) = db::update_theme_version(&ctx.database, id, &version).await {
|
||||
console_warn!("theme version refresh failed for {id}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if !db::set_active_theme(&ctx.database, id).await? {
|
||||
return error("主题不存在", 404);
|
||||
|
||||
@@ -342,6 +342,30 @@ pub async fn remote_settings_schema(base: &str) -> Result<Value> {
|
||||
.ok_or_else(|| Error::RustError("主题 theme.json 设置格式无效".to_string()))
|
||||
}
|
||||
|
||||
/// 读取远程主题 theme.json 中的 version 字段;无 theme.json 或无版本时返回 None。
|
||||
pub async fn remote_theme_version(base: &str) -> Option<String> {
|
||||
let url = settings_url(base)?;
|
||||
let request = Request::new(&url, Method::Get).ok()?;
|
||||
let mut response = Fetch::Request(request).send().await.ok()?;
|
||||
if !(200..300).contains(&response.status_code()) {
|
||||
return None;
|
||||
}
|
||||
let body = read_response_limited(&mut response, SETTINGS_MAX_BYTES)
|
||||
.await
|
||||
.ok()??;
|
||||
let value: Value = serde_json::from_slice(body.as_slice()).ok()?;
|
||||
let version = value.get("version")?.as_str()?.trim();
|
||||
if version.is_empty()
|
||||
|| version.len() > 40
|
||||
|| !version
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || ".-_+ ".contains(character))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(version.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||
"name": "nodeflare",
|
||||
"main": "build/index.js",
|
||||
"compatibility_date": "2026-08-17",
|
||||
"keep_vars": true,
|
||||
"workers_dev": true,
|
||||
"preview_urls": false,
|
||||
"build": {
|
||||
"command": "sh scripts/build-worker.sh"
|
||||
},
|
||||
"assets": {
|
||||
"directory": "./frontend/dist",
|
||||
"binding": "ASSETS",
|
||||
"not_found_handling": "single-page-application",
|
||||
"run_worker_first": true
|
||||
},
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "nodeflare",
|
||||
"migrations_dir": "migrations",
|
||||
"migrations_pattern": "migrations/initial_*.sql"
|
||||
}
|
||||
],
|
||||
"ratelimits": [
|
||||
{
|
||||
"name": "API_RATE_LIMITER",
|
||||
"namespace_id": "73120401",
|
||||
"simple": { "limit": 300, "period": 60 }
|
||||
},
|
||||
{
|
||||
"name": "AUTH_RATE_LIMITER",
|
||||
"namespace_id": "73120402",
|
||||
"simple": { "limit": 20, "period": 60 }
|
||||
},
|
||||
{
|
||||
"name": "AGENT_RATE_LIMITER",
|
||||
"namespace_id": "73120403",
|
||||
"simple": { "limit": 120, "period": 60 }
|
||||
}
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{ "name": "LIVE_HUB", "class_name": "LiveHub" }
|
||||
]
|
||||
},
|
||||
"migrations": [
|
||||
{ "tag": "v1", "new_sqlite_classes": ["LiveHub"] }
|
||||
],
|
||||
"triggers": {
|
||||
"crons": ["*/5 * * * *"]
|
||||
},
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1,
|
||||
"invocation_logs": true
|
||||
},
|
||||
"traces": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 0.05
|
||||
}
|
||||
},
|
||||
"dev": {
|
||||
"port": 8787
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
name = "nodeflare"
|
||||
main = "build/index.js"
|
||||
compatibility_date = "2026-08-13"
|
||||
keep_vars = true
|
||||
workers_dev = true
|
||||
preview_urls = false
|
||||
|
||||
[build]
|
||||
command = "sh scripts/build-worker.sh"
|
||||
|
||||
[assets]
|
||||
directory = "./frontend/dist"
|
||||
binding = "ASSETS"
|
||||
not_found_handling = "single-page-application"
|
||||
run_worker_first = true
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
# Wrangler 4.45+ provisions and binds this database when database_id is omitted.
|
||||
database_name = "nodeflare"
|
||||
|
||||
[[ratelimits]]
|
||||
name = "API_RATE_LIMITER"
|
||||
namespace_id = "73120401"
|
||||
|
||||
[ratelimits.simple]
|
||||
limit = 300
|
||||
period = 60
|
||||
|
||||
[[ratelimits]]
|
||||
name = "AUTH_RATE_LIMITER"
|
||||
namespace_id = "73120402"
|
||||
|
||||
[ratelimits.simple]
|
||||
limit = 20
|
||||
period = 60
|
||||
|
||||
[[ratelimits]]
|
||||
name = "AGENT_RATE_LIMITER"
|
||||
namespace_id = "73120403"
|
||||
|
||||
[ratelimits.simple]
|
||||
limit = 120
|
||||
period = 60
|
||||
|
||||
[[durable_objects.bindings]]
|
||||
name = "LIVE_HUB"
|
||||
class_name = "LiveHub"
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_sqlite_classes = ["LiveHub"]
|
||||
|
||||
[triggers]
|
||||
crons = ["*/5 * * * *"]
|
||||
|
||||
[dev]
|
||||
port = 8787
|
||||
Reference in new issue
Block a user