Fixes and improvements

This commit is contained in:
imengying committed 2026-08-17 16:17:06 +08:00
1 parent 40ff956461
commit 97e4b3a5f1
23 files changed
+687 -375

No files matched your search

Generated
+45 -44
View File
@@ -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]]
+36 -35
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+7 -4
View File
@@ -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
View File
@@ -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. */ }
};
};
+53 -34
View File
@@ -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>
);
}
+3 -7
View File
@@ -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>
+123 -48
View File
@@ -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>
+152 -39
View File
@@ -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]);
}
+25 -1
View File
@@ -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; }
}
+1
View File
@@ -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
View File
@@ -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 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
);
}
}
+1
View File
@@ -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
View File
@@ -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);
+24
View File
@@ -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::{
+69
View File
@@ -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
}
}
-58
View File
@@ -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