diff --git a/Cargo.lock b/Cargo.lock index 7e088a1..317455c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/agent/Cargo.lock b/agent/Cargo.lock index dcf40aa..3b97dc1 100644 --- a/agent/Cargo.lock +++ b/agent/Cargo.lock @@ -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]] diff --git a/agent/agent.sh b/agent/agent.sh index 0fcd8a2..dc5acae 100644 --- a/agent/agent.sh +++ b/agent/agent.sh @@ -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' \ '' \ diff --git a/agent/src/main.rs b/agent/src/main.rs index 76044f9..fb7b6a5 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -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>; struct LiveSender { pending: Arc<(Mutex>, Condvar)>, - report_interval: Arc>, + send_interval: Arc>, } impl LiveSender { fn start(config: &RuntimeConfig, clock: SharedClock) -> Result { 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, + 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> { +fn read_live_ack(socket: &mut LiveSocket) -> Result> { match socket.read() { Ok(Message::Text(text)) => { let Ok(ack) = serde_json::from_str::(text.as_ref()) else { @@ -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] diff --git a/bun.lock b/bun.lock index 6ac87ff..a2c59f1 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 511543d..3dcf95a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,19 +25,37 @@ interface LiveSample { data: Partial & { latency_results?: LiveLatencyResult[] }; } +function mergeLiveResults(previous: LiveLatencyResult[] | undefined, incoming: LiveLatencyResult[]): LiveLatencyResult[] { + const results = new Map(); + for (const result of [...(previous ?? []), ...incoming]) { + if (!result.task_id || !Number.isFinite(result.timestamp) || result.timestamp <= 0) continue; + results.set(`${result.task_id}:${result.timestamp}`, result); + } + 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(); + 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. */ } }; }; diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 68e56ba..8c7e33d 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -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 = { 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(null); const [editing, setEditing] = useState(null); const [form, setForm] = useState(emptyServer); - const [install, setInstall] = useState([]); + const [install, setInstall] = useState(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("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({ +
@@ -559,7 +569,7 @@ export function AdminPanel({ toggleSelected(server.id)} ariaLabel={`选择 ${server.name}`} /> -
{server.name}{server.group_name} · {server.region || "未设置地区"} · {server.last_ip || "尚未上报 IP"} · {server.agent_version ? `Agent v${server.agent_version}` : "Agent 未上报版本"} · {server.report_interval}s
+
{server.name}{server.last_ip || "尚未上报 IP"} · {server.agent_version ? `Agent v${server.agent_version}` : "Agent 未上报版本"}
))} @@ -575,7 +585,7 @@ export function AdminPanel({
{themes.map((theme) =>
-
{theme.name}{theme.builtin ? "默认主题" : "远程主题"}
+
{theme.name}{theme.builtin ? "默认主题" : "远程主题"}{theme.version ? v{theme.version} : null}
{!theme.builtin ?

{theme.description || "暂无主题说明"}

: null} {!theme.builtin ? {theme.url} : null}
@@ -648,8 +658,17 @@ export function AdminPanel({

清空历史不会删除节点、密钥或最新状态。

: null} - {tab !== "data" ?
: null} + {tab !== "data" && tab !== "about" ?
: null} + ) : tab === "about" ? ( +
+
NodeFlare基于 Rust、WebAssembly 和 Cloudflare Workers 的服务器监控
+
+
版本v{VERSION}
+ +
开源协议MIT License
+
+
) : null}
@@ -661,12 +680,12 @@ export function AdminPanel({
-
+
updateForm("auto_renewal", value)} /> updateForm("auto_update", value)} /> updateForm("hidden", value)} /> updateForm("offline_notify_disabled", value)} />
: null} - {commands.length ?
setInstall([])}>
event.stopPropagation()}>
Agent 部署

安装命令

{commands.map((item) =>
{item.name}
{item.command}
)}
: null} + {installCommand ?
setInstall(null)}>
event.stopPropagation()}>
Agent 部署

安装命令

{installCommand}
: null} ); } diff --git a/frontend/src/components/LatencyManager.tsx b/frontend/src/components/LatencyManager.tsx index c569079..cf2f018 100644 --- a/frontend/src/components/LatencyManager.tsx +++ b/frontend/src/components/LatencyManager.tsx @@ -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 ?
setEditing(null)}>
event.stopPropagation()}>
延迟检测

{editing === "new" ? "添加任务" : `编辑 · ${editing.name}`}

-
-
{form.task_type === "tcp" ? :
}
+
+
{form.task_type === "tcp" ? :
}
服务器已选 {form.server_ids.length} / 共 {servers.length}
setQuery(event.target.value)} />
- {visibleServers.map((server) => )} + {visibleServers.map((server) => )} {!visibleServers.length ?
没有匹配的服务器
: null}
diff --git a/frontend/src/components/NodeDetails.tsx b/frontend/src/components/NodeDetails.tsx index eb67763..aac0f7a 100644 --- a/frontend/src/components/NodeDetails.tsx +++ b/frontend/src/components/NodeDetails.tsx @@ -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>(); - 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(); + 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
{latencyData.length ? - + - + latencyChartTime(Number(value), latencyHours, locale)} /> - [`${Number(value).toFixed(1)} ms`, name]} /> + latencyChartTime(Number(value), latencyHours, locale)} formatter={(value, name) => [value == null ? "--" : `${Number(value).toFixed(1)} ms`, name]} /> - {latencySeries.map((series) => )} + {latencySeries.map((series) => )} :
{ui(locale, "暂无延迟数据", "No latency data")}
}
diff --git a/frontend/src/hooks/useNodeLatency.ts b/frontend/src/hooks/useNodeLatency.ts index 0d8c98f..db4e22b 100644 --- a/frontend/src/hooks/useNodeLatency.ts +++ b/frontend/src/hooks/useNodeLatency.ts @@ -10,6 +10,8 @@ export interface LatencyBar { const cache = new Map(); 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(); + 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(); + 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(initial); + const [fetched, setFetched] = useState(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]); } diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index a672597..7ce3956 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -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; } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 285d4cf..8bc078a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -62,6 +62,7 @@ export interface Theme { name: string; description: string; url: string; + version: string; builtin: boolean; active: boolean; } diff --git a/migrations/0001_initial.sql b/migrations/initial_01.sql similarity index 99% rename from migrations/0001_initial.sql rename to migrations/initial_01.sql index d48c4f6..8c6a8e0 100644 --- a/migrations/0001_initial.sql +++ b/migrations/initial_01.sql @@ -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 ); diff --git a/package.json b/package.json index bffd7f5..1faee20 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/db.rs b/src/db.rs index 510ef53..7e33819 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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> { let rows: Vec = 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 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 { let row: Option = 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 { pub async fn theme_url(db: &D1Database, id: &str) -> Result> { let row: Option = 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> { .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 { if id != crate::theme::BUILTIN_THEME_ID && !theme_exists(db, id).await? { return Ok(false); diff --git a/src/latency.rs b/src/latency.rs index 94e4409..3afb4a1 100644 --- a/src/latency.rs +++ b/src/latency.rs @@ -364,11 +364,14 @@ fn compact_results( pub async fn latest_all(db: &D1Database) -> Result> { 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> { pub async fn latest_for_server(db: &D1Database, server_id: &str) -> Result> { 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)])? diff --git a/src/lib.rs b/src/lib.rs index 82248dc..038d046 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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); } diff --git a/src/live.rs b/src/live.rs index 44e97da..2d94a74 100644 --- a/src/live.rs +++ b/src/live.rs @@ -38,13 +38,8 @@ struct SocketAttachment { traffic: Option, } -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 + ); } } diff --git a/src/models.rs b/src/models.rs index 6eb03ab..ad4676b 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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, } diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 309cd2b..984d9cd 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -396,6 +396,7 @@ async fn themes_get(ctx: &RouteContext) -> Result { 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 ); 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 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); diff --git a/src/theme.rs b/src/theme.rs index 9bbe3e3..8b8b5cc 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -342,6 +342,30 @@ pub async fn remote_settings_schema(base: &str) -> Result { .ok_or_else(|| Error::RustError("主题 theme.json 设置格式无效".to_string())) } +/// 读取远程主题 theme.json 中的 version 字段;无 theme.json 或无版本时返回 None。 +pub async fn remote_theme_version(base: &str) -> Option { + 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::{ diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..33ea753 --- /dev/null +++ b/wrangler.jsonc @@ -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 + } +} diff --git a/wrangler.toml b/wrangler.toml deleted file mode 100644 index 7262803..0000000 --- a/wrangler.toml +++ /dev/null @@ -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