1use serde::{Deserialize, Serialize};
14
15const ALPHA: f64 = 0.3;
17pub const MIN_SPEED_SAMPLE_BYTES: u64 = 256 * 1024;
19const RECENT_MAX: usize = 12;
21const OFFLINE_WINDOW_SECS: i64 = 30 * 60;
23const MAXED_WINDOW_SECS: i64 = 24 * 3600;
25
26pub const FAIL_OFFLINE: &str = "offline";
27pub const FAIL_MAXED: &str = "maxed";
28
29#[derive(Clone, Copy, Debug, PartialEq)]
33pub struct Sample {
34 pub mbps: f32,
35 pub bytes: u64,
36}
37
38impl Serialize for Sample {
39 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
40 (self.mbps, self.bytes).serialize(s)
41 }
42}
43
44impl<'de> Deserialize<'de> for Sample {
45 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
46 #[derive(Deserialize)]
47 #[serde(untagged)]
48 enum Shape {
49 Pair(f32, u64),
50 Bare(f32),
51 }
52 Ok(match Shape::deserialize(d)? {
53 Shape::Pair(mbps, bytes) => Sample { mbps, bytes },
54 Shape::Bare(mbps) => Sample { mbps, bytes: 0 },
55 })
56 }
57}
58
59#[derive(Clone, Debug, Serialize, PartialEq)]
60pub struct ServerStats {
61 pub latency_ms: Option<f64>,
62 pub mbps: Option<f64>,
63 pub best_mbps: Option<f64>,
64 pub uploads: u64,
65 pub bytes_total: u64,
66 pub last_ok_at: Option<i64>,
67 pub last_fail_at: Option<i64>,
68 pub last_fail_kind: Option<String>,
69 pub recent: Vec<Sample>,
71}
72
73#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
75pub struct ServerStatus {
76 pub state: &'static str,
78 pub label: &'static str,
79 pub tone: &'static str,
81 pub latency_ms: Option<u32>,
82}
83
84fn now_secs() -> i64 {
85 web_time::SystemTime::now()
86 .duration_since(web_time::UNIX_EPOCH)
87 .map(|d| d.as_secs() as i64)
88 .unwrap_or(0)
89}
90
91fn norm_url(url: &str) -> String {
92 url.trim().trim_end_matches('/').to_lowercase()
93}
94
95fn ewma(prev: Option<f64>, sample: f64) -> f64 {
96 match prev {
97 Some(p) => p + ALPHA * (sample - p),
98 None => sample,
99 }
100}
101
102pub fn migrate(tx: &rusqlite::Transaction<'_>) -> Result<(), String> {
107 tx.execute_batch(
108 "CREATE TABLE IF NOT EXISTS blossom_server_stats (
109 server_url TEXT PRIMARY KEY,
110 latency_ms REAL,
111 mbps REAL,
112 best_mbps REAL,
113 uploads INTEGER NOT NULL DEFAULT 0,
114 bytes_total INTEGER NOT NULL DEFAULT 0,
115 last_ok_at INTEGER,
116 last_fail_at INTEGER,
117 last_fail_kind TEXT,
118 recent TEXT NOT NULL DEFAULT '[]',
119 updated_at INTEGER NOT NULL
120 );",
121 )
122 .map_err(|e| format!("Failed to create blossom_server_stats table: {}", e))
123}
124
125fn read_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ServerStats> {
126 let recent_json: String = row.get(8)?;
127 Ok(ServerStats {
128 latency_ms: row.get(0)?,
129 mbps: row.get(1)?,
130 best_mbps: row.get(2)?,
131 uploads: row.get::<_, i64>(3)? as u64,
132 bytes_total: row.get::<_, i64>(4)? as u64,
133 last_ok_at: row.get(5)?,
134 last_fail_at: row.get(6)?,
135 last_fail_kind: row.get(7)?,
136 recent: serde_json::from_str(&recent_json).unwrap_or_default(),
137 })
138}
139
140const SELECT: &str = "SELECT latency_ms, mbps, best_mbps, uploads, bytes_total, last_ok_at, last_fail_at, last_fail_kind, recent
141 FROM blossom_server_stats WHERE server_url = ?1";
142
143pub fn stats_for(server_url: &str) -> Option<ServerStats> {
144 let conn = crate::db::get_db_connection_guard_static().ok()?;
145 conn.query_row(SELECT, rusqlite::params![norm_url(server_url)], read_row).ok()
146}
147
148fn load_or_default(conn: &rusqlite::Connection, server: &str) -> ServerStats {
149 conn.query_row(SELECT, rusqlite::params![server], read_row)
150 .unwrap_or(ServerStats {
151 latency_ms: None,
152 mbps: None,
153 best_mbps: None,
154 uploads: 0,
155 bytes_total: 0,
156 last_ok_at: None,
157 last_fail_at: None,
158 last_fail_kind: None,
159 recent: Vec::new(),
160 })
161}
162
163fn save(conn: &rusqlite::Connection, server: &str, s: &ServerStats) -> Result<(), String> {
164 conn.execute(
165 "INSERT INTO blossom_server_stats
166 (server_url, latency_ms, mbps, best_mbps, uploads, bytes_total, last_ok_at, last_fail_at, last_fail_kind, recent, updated_at)
167 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
168 ON CONFLICT(server_url) DO UPDATE SET
169 latency_ms = ?2, mbps = ?3, best_mbps = ?4, uploads = ?5, bytes_total = ?6,
170 last_ok_at = ?7, last_fail_at = ?8, last_fail_kind = ?9, recent = ?10, updated_at = ?11",
171 rusqlite::params![
172 server, s.latency_ms, s.mbps, s.best_mbps, s.uploads as i64, s.bytes_total as i64,
173 s.last_ok_at, s.last_fail_at, s.last_fail_kind,
174 serde_json::to_string(&s.recent).unwrap_or_else(|_| "[]".to_string()),
175 now_secs(),
176 ],
177 )
178 .map_err(|e| format!("Failed to save server stats: {}", e))?;
179 Ok(())
180}
181
182fn update(server_url: &str, f: impl FnOnce(&mut ServerStats)) {
183 let Ok(conn) = crate::db::get_write_connection_guard_static() else { return };
184 let server = norm_url(server_url);
185 let mut s = load_or_default(&conn, &server);
186 f(&mut s);
187 if let Err(e) = save(&conn, &server, &s) {
188 crate::log_warn!("[Blossom Stats] {}: {}", server, e);
189 }
190 drop(conn);
191 crate::traits::emit_event("blossom_status_updated", &());
193}
194
195pub fn record_latency(server_url: &str, ms: f64) {
197 if ms <= 0.0 { return; }
198 update(server_url, |s| {
199 s.latency_ms = Some(ewma(s.latency_ms, ms));
200 s.last_ok_at = Some(now_secs());
201 });
202}
203
204pub fn record_upload(server_url: &str, bytes: u64, ms: f64) {
207 if bytes < MIN_SPEED_SAMPLE_BYTES || ms <= 0.0 { return; }
208 let mbps = (bytes as f64 * 8.0) / (ms / 1000.0) / 1_000_000.0;
209 update(server_url, |s| {
210 s.mbps = Some(ewma(s.mbps, mbps));
211 s.best_mbps = Some(s.best_mbps.map_or(mbps, |b| b.max(mbps)));
212 s.uploads += 1;
213 s.bytes_total += bytes;
214 s.last_ok_at = Some(now_secs());
215 s.recent.push(Sample { mbps: mbps as f32, bytes });
216 if s.recent.len() > RECENT_MAX {
217 let drop = s.recent.len() - RECENT_MAX;
218 s.recent.drain(..drop);
219 }
220 });
221}
222
223pub fn record_ok(server_url: &str) {
225 update(server_url, |s| s.last_ok_at = Some(now_secs()));
226}
227
228pub fn record_failure(server_url: &str, kind: &str) {
230 update(server_url, |s| {
231 s.last_fail_at = Some(now_secs());
232 s.last_fail_kind = Some(kind.to_string());
233 });
234}
235
236pub fn derive_status(
243 enabled: bool,
244 info: Option<&crate::blossom_info::ServerInfo>,
245 stats: Option<&ServerStats>,
246 now: i64,
247) -> ServerStatus {
248 let latency_ms = stats.and_then(|s| s.latency_ms).map(|l| l.round() as u32);
249 let status = |state: &'static str, label: &'static str, tone: &'static str| ServerStatus {
250 state, label, tone, latency_ms,
251 };
252 if !enabled {
253 return status("disabled", "Disabled", "disabled");
254 }
255 if let Some(c) = info.and_then(|i| i.caller.as_ref()) {
256 if !c.allowed {
257 return status("no_access", "No access", "disconnected");
258 }
259 let full = |used: u64, limit: u64| limit > 0 && used >= limit;
260 if full(c.storage_used, c.storage_limit) || full(c.daily_used, c.daily_limit) {
261 return status("maxed", "Maxed", "connecting");
262 }
263 }
264 if let Some(s) = stats {
265 let failed_since_ok = match (s.last_fail_at, s.last_ok_at) {
266 (Some(f), Some(o)) => f > o,
267 (Some(_), None) => true,
268 _ => false,
269 };
270 if failed_since_ok {
271 let age = now - s.last_fail_at.unwrap_or(now);
272 match s.last_fail_kind.as_deref() {
273 Some(FAIL_MAXED) if age <= MAXED_WINDOW_SECS => {
274 return status("maxed", "Maxed", "connecting");
275 }
276 Some(FAIL_OFFLINE) if age <= OFFLINE_WINDOW_SECS => {
277 return status("offline", "Offline", "disconnected");
278 }
279 _ => {}
280 }
281 }
282 if s.last_ok_at.is_some() {
283 return status("online", "Online", "connected");
284 }
285 }
286 if info.is_some() {
287 return status("online", "Online", "connected");
288 }
289 status("untested", "Untested", "disabled")
290}
291
292pub fn status_for(server_url: &str, enabled: bool) -> ServerStatus {
293 let info = crate::blossom_info::cached(server_url);
294 let stats = stats_for(server_url);
295 derive_status(enabled, info.as_ref(), stats.as_ref(), now_secs())
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::blossom_info::{CallerInfo, ServerInfo};
302
303 fn stats() -> ServerStats {
304 ServerStats {
305 latency_ms: Some(48.4), mbps: Some(12.0), best_mbps: Some(20.0), uploads: 3,
306 bytes_total: 3_000_000, last_ok_at: Some(1000), last_fail_at: None,
307 last_fail_kind: None,
308 recent: vec![Sample { mbps: 10.0, bytes: 0 }, Sample { mbps: 12.0, bytes: 2_000_000 }],
309 }
310 }
311
312 fn doc(caller: Option<CallerInfo>) -> ServerInfo {
313 ServerInfo {
314 name: None, software: None, version: None, description: None, buds: vec![],
315 extensions: vec![], max_blob: None, mime: vec![], capacity_used: None,
316 capacity_limit: None, caller, fetched_at: 0, raw: serde_json::Value::Null,
317 }
318 }
319
320 fn caller(allowed: bool, storage: (u64, u64), daily: (u64, u64)) -> CallerInfo {
321 CallerInfo {
322 tier: "recognised".into(), max_blob: 1, storage_used: storage.0, storage_limit: storage.1,
323 blobs: 0, daily_used: daily.0, daily_limit: daily.1, allowed, reasons: vec![],
324 plan_title: None, plan_description: None,
325 }
326 }
327
328 #[test]
329 fn disabled_wins_over_everything() {
330 let s = derive_status(false, Some(&doc(Some(caller(false, (0, 1), (0, 1))))), Some(&stats()), 2000);
331 assert_eq!(s.state, "disabled");
332 assert_eq!(s.latency_ms, Some(48), "the number still rides along");
333 }
334
335 #[test]
336 fn the_document_decides_access_and_quota() {
337 assert_eq!(derive_status(true, Some(&doc(Some(caller(false, (0, 1), (0, 1))))), None, 0).state, "no_access");
338 assert_eq!(derive_status(true, Some(&doc(Some(caller(true, (5, 5), (0, 1))))), None, 0).state, "maxed");
339 assert_eq!(derive_status(true, Some(&doc(Some(caller(true, (0, 5), (1, 1))))), None, 0).state, "maxed");
340 assert_eq!(derive_status(true, Some(&doc(Some(caller(true, (0, 5), (0, 1))))), None, 0).state, "online");
341 assert_eq!(derive_status(true, Some(&doc(Some(caller(true, (5, 0), (0, 0))))), None, 0).state, "online");
343 }
344
345 #[test]
346 fn a_recent_failure_with_nothing_since_is_offline_then_forgotten() {
347 let mut s = stats();
348 s.last_fail_at = Some(1500);
349 s.last_fail_kind = Some(FAIL_OFFLINE.into());
350 assert_eq!(derive_status(true, None, Some(&s), 1600).state, "offline");
351 assert_eq!(derive_status(true, None, Some(&s), 1500 + OFFLINE_WINDOW_SECS + 1).state, "online");
352 s.last_ok_at = Some(1501);
354 assert_eq!(derive_status(true, None, Some(&s), 1600).state, "online");
355 }
356
357 #[test]
358 fn a_quota_refusal_reads_maxed_for_the_day() {
359 let mut s = stats();
360 s.last_fail_at = Some(1500);
361 s.last_fail_kind = Some(FAIL_MAXED.into());
362 assert_eq!(derive_status(true, None, Some(&s), 1600).state, "maxed");
363 assert_eq!(derive_status(true, None, Some(&s), 1500 + MAXED_WINDOW_SECS + 1).state, "online");
364 }
365
366 #[test]
367 fn nothing_known_is_untested_and_a_document_alone_is_online() {
368 assert_eq!(derive_status(true, None, None, 0).state, "untested");
369 assert_eq!(derive_status(true, Some(&doc(None)), None, 0).state, "online");
370 }
371
372 #[test]
373 fn samples_round_trip_as_pairs_and_read_old_bare_numbers() {
374 let ring = vec![Sample { mbps: 4.5, bytes: 300 }];
375 let json = serde_json::to_string(&ring).unwrap();
376 assert_eq!(json, "[[4.5,300]]");
377 let back: Vec<Sample> = serde_json::from_str("[[4.5,300],7.25]").unwrap();
378 assert_eq!(back, vec![Sample { mbps: 4.5, bytes: 300 }, Sample { mbps: 7.25, bytes: 0 }]);
379 }
380
381 #[test]
382 fn ewma_starts_at_the_first_sample_and_then_leans_on_history() {
383 assert_eq!(ewma(None, 50.0), 50.0);
384 assert_eq!(ewma(Some(50.0), 100.0), 65.0);
385 }
386}