1use std::{
2 collections::BTreeMap,
3 path::Path,
4 sync::{Arc, Mutex},
5 time::Duration,
6};
7
8use rusqlite::{Connection, params};
9
10use crate::{
11 Result, SyncwebError,
12 bandwidth_stats::{BandwidthStats, FolderStats, PeerStats},
13};
14
15#[derive(Clone, Debug)]
16pub struct StatsDatabase {
17 connection: Arc<Mutex<Connection>>,
18}
19
20impl StatsDatabase {
21 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
27 let db_path = path.as_ref();
28 if let Some(parent) = db_path.parent() {
29 std::fs::create_dir_all(parent)
30 .map_err(|error| SyncwebError::operation("failed to create stats db directory", error))?;
31 }
32 let connection = Connection::open(db_path)
33 .map_err(|error| SyncwebError::operation("failed to open stats database", error))?;
34 let db = Self {
35 connection: Arc::new(Mutex::new(connection)),
36 };
37 db.init_schema()?;
38 Ok(db)
39 }
40
41 fn init_schema(&self) -> Result<()> {
42 let connection = self
43 .connection
44 .lock()
45 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
46 connection
47 .busy_timeout(std::time::Duration::from_secs(5))
48 .map_err(|error| SyncwebError::operation("failed to configure stats database", error))?;
49 connection
50 .pragma_update(None, "journal_mode", "WAL")
51 .map_err(|error| SyncwebError::operation("failed to set WAL mode", error))?;
52 connection
53 .pragma_update(None, "foreign_keys", "ON")
54 .map_err(|error| SyncwebError::operation("failed to enable foreign keys", error))?;
55 connection
56 .execute_batch(
57 "CREATE TABLE IF NOT EXISTS schema_version (
58 key TEXT PRIMARY KEY,
59 value TEXT NOT NULL
60 );
61 CREATE TABLE IF NOT EXISTS bandwidth_events (
62 id INTEGER PRIMARY KEY AUTOINCREMENT,
63 timestamp INTEGER NOT NULL,
64 direction TEXT NOT NULL CHECK(direction IN ('upload','download')),
65 bytes INTEGER NOT NULL CHECK(bytes > 0),
66 files INTEGER NOT NULL DEFAULT 1,
67 folder_namespace TEXT,
68 peer TEXT
69 );
70 CREATE INDEX IF NOT EXISTS idx_bw_ts ON bandwidth_events(timestamp);
71 CREATE INDEX IF NOT EXISTS idx_bw_folder ON bandwidth_events(folder_namespace);
72 CREATE INDEX IF NOT EXISTS idx_bw_peer ON bandwidth_events(peer);
73 CREATE TABLE IF NOT EXISTS bandwidth_period (
74 period_start INTEGER PRIMARY KEY,
75 period_end INTEGER,
76 total_upload INTEGER NOT NULL DEFAULT 0,
77 total_download INTEGER NOT NULL DEFAULT 0,
78 closed INTEGER NOT NULL DEFAULT 0
79 );
80 CREATE TABLE IF NOT EXISTS bandwidth_folder (
81 period_start INTEGER NOT NULL REFERENCES bandwidth_period(period_start),
82 folder_namespace TEXT NOT NULL,
83 upload INTEGER NOT NULL DEFAULT 0,
84 download INTEGER NOT NULL DEFAULT 0,
85 files_transferred INTEGER NOT NULL DEFAULT 0,
86 PRIMARY KEY(period_start, folder_namespace)
87 );
88 CREATE TABLE IF NOT EXISTS bandwidth_peer (
89 period_start INTEGER NOT NULL REFERENCES bandwidth_period(period_start),
90 peer TEXT NOT NULL,
91 upload INTEGER NOT NULL DEFAULT 0,
92 download INTEGER NOT NULL DEFAULT 0,
93 connection_count INTEGER NOT NULL DEFAULT 0,
94 PRIMARY KEY(period_start, peer)
95 );
96 CREATE TABLE IF NOT EXISTS daemon_log (
97 id INTEGER PRIMARY KEY AUTOINCREMENT,
98 timestamp INTEGER NOT NULL,
99 level TEXT NOT NULL CHECK(level IN ('trace','debug','info','warn','error')),
100 module TEXT,
101 message TEXT NOT NULL
102 );
103 CREATE INDEX IF NOT EXISTS idx_log_ts ON daemon_log(timestamp);
104 CREATE INDEX IF NOT EXISTS idx_log_level ON daemon_log(level);
105 CREATE TABLE IF NOT EXISTS network_events (
106 id INTEGER PRIMARY KEY AUTOINCREMENT,
107 timestamp INTEGER NOT NULL,
108 network_id TEXT NOT NULL,
109 event_type TEXT NOT NULL CHECK(event_type IN (
110 'peer_joined','peer_left','sync_started','sync_finished',
111 'relay_connected','relay_disconnected','relay_failed',
112 'topic_subscribed','topic_unsubscribed',
113 'member_added','member_removed','folder_added','folder_removed',
114 'ticket_created','ticket_accepted','kicked'
115 )),
116 peer TEXT,
117 details TEXT,
118 metadata_json TEXT
119 );
120 CREATE INDEX IF NOT EXISTS idx_network_events_ts ON network_events(timestamp);
121 CREATE INDEX IF NOT EXISTS idx_network_events_network ON network_events(network_id);
122 CREATE INDEX IF NOT EXISTS idx_network_events_type ON network_events(event_type);
123 CREATE TABLE IF NOT EXISTS network_sync_sessions (
124 id INTEGER PRIMARY KEY AUTOINCREMENT,
125 network_id TEXT NOT NULL,
126 folder_namespace TEXT NOT NULL,
127 started_at INTEGER NOT NULL,
128 finished_at INTEGER,
129 files_transferred INTEGER NOT NULL DEFAULT 0,
130 bytes_transferred INTEGER NOT NULL DEFAULT 0,
131 errors INTEGER NOT NULL DEFAULT 0,
132 status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running','completed','failed','cancelled'))
133 );
134 CREATE INDEX IF NOT EXISTS idx_network_sessions_network ON network_sync_sessions(network_id);
135 CREATE TABLE IF NOT EXISTS relay_health (
136 relay_url TEXT NOT NULL,
137 checked_at INTEGER NOT NULL,
138 connected INTEGER NOT NULL,
139 latency_ms INTEGER,
140 error_message TEXT,
141 PRIMARY KEY(relay_url, checked_at)
142 );",
143 )
144 .map_err(|error| SyncwebError::operation("failed to initialize stats database schema", error))?;
145 let _ = connection.execute_batch("ALTER TABLE bandwidth_events ADD COLUMN network_id TEXT");
146 let _ = connection.execute_batch(
147 "CREATE VIEW IF NOT EXISTS network_bandwidth_summary AS
148 SELECT network_id,
149 MIN(timestamp) AS period_start,
150 MAX(timestamp) AS period_end,
151 SUM(CASE WHEN direction='upload' THEN bytes ELSE 0 END) AS total_upload,
152 SUM(CASE WHEN direction='download' THEN bytes ELSE 0 END) AS total_download,
153 COUNT(DISTINCT peer) AS active_peers
154 FROM bandwidth_events
155 WHERE network_id IS NOT NULL
156 GROUP BY network_id",
157 );
158 connection
159 .execute(
160 "INSERT INTO schema_version(key, value) VALUES ('version', '1')
161 ON CONFLICT(key) DO NOTHING",
162 [],
163 )
164 .map_err(|error| SyncwebError::operation("failed to seed schema version", error))?;
165 drop(connection);
166 Ok(())
167 }
168
169 pub fn record_upload(
175 &self,
176 bytes: u64,
177 files: u64,
178 folder: Option<&str>,
179 peer: Option<&str>,
180 network_id: Option<&str>,
181 ) -> Result<()> {
182 self.record_transfer("upload", bytes, files, folder, peer, network_id)
183 }
184
185 pub fn record_download(
191 &self,
192 bytes: u64,
193 files: u64,
194 folder: Option<&str>,
195 peer: Option<&str>,
196 network_id: Option<&str>,
197 ) -> Result<()> {
198 self.record_transfer("download", bytes, files, folder, peer, network_id)
199 }
200
201 pub fn record_transfer(
207 &self,
208 direction: &str,
209 bytes: u64,
210 files: u64,
211 folder: Option<&str>,
212 peer: Option<&str>,
213 network_id: Option<&str>,
214 ) -> Result<()> {
215 let connection = self
216 .connection
217 .lock()
218 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
219 let now = current_timestamp();
220 connection
221 .execute(
222 "INSERT INTO bandwidth_events(timestamp, direction, bytes, files, folder_namespace, peer, network_id)
223 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
224 params![
225 now,
226 direction,
227 bytes.cast_signed(),
228 files.cast_signed(),
229 folder,
230 peer,
231 network_id
232 ],
233 )
234 .map_err(|error| SyncwebError::operation("failed to record transfer event", error))?;
235 drop(connection);
236 Ok(())
237 }
238
239 pub fn record_connection(&self, peer: &str) -> Result<()> {
245 let connection = self
246 .connection
247 .lock()
248 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
249 let now = current_timestamp();
250 let period = find_or_create_period(&connection, now)
251 .map_err(|error| SyncwebError::operation("failed to find or create bandwidth period", error))?;
252 connection
253 .execute(
254 "INSERT INTO bandwidth_peer(period_start, peer, upload, download, connection_count)
255 VALUES (?1, ?2, 0, 0, 1)
256 ON CONFLICT(period_start, peer) DO UPDATE SET
257 connection_count = connection_count + 1",
258 params![period, peer],
259 )
260 .map_err(|error| SyncwebError::operation("failed to record connection", error))?;
261 drop(connection);
262 Ok(())
263 }
264
265 pub fn current_stats(&self) -> Result<BandwidthStats> {
271 let connection = self
272 .connection
273 .lock()
274 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
275 let now = current_timestamp();
276 let period_start = find_or_create_period(&connection, now)
277 .map_err(|error| SyncwebError::operation("failed to find or create bandwidth period", error))?;
278
279 let (total_upload, total_download): (i64, i64) = connection
280 .query_row(
281 "SELECT COALESCE(SUM(CASE WHEN direction='upload' THEN bytes ELSE 0 END), 0),
282 COALESCE(SUM(CASE WHEN direction='download' THEN bytes ELSE 0 END), 0)
283 FROM bandwidth_events WHERE timestamp >= ?1",
284 params![period_start],
285 |row| Ok((row.get(0)?, row.get(1)?)),
286 )
287 .map_err(|error| SyncwebError::operation("failed to query bandwidth totals", error))?;
288
289 let mut per_folder = BTreeMap::new();
290 let mut folder_stmt = connection
291 .prepare(
292 "SELECT folder_namespace, upload, download, files_transferred
293 FROM bandwidth_folder WHERE period_start = ?1",
294 )
295 .map_err(|error| SyncwebError::operation("failed to prepare folder stats query", error))?;
296 let folder_rows = folder_stmt
297 .query_map(params![period_start], |row| {
298 Ok((
299 row.get::<_, String>(0)?,
300 row.get::<_, i64>(1)?,
301 row.get::<_, i64>(2)?,
302 row.get::<_, i64>(3)?,
303 ))
304 })
305 .map_err(|error| SyncwebError::operation("failed to query folder stats", error))?
306 .collect::<std::result::Result<Vec<_>, _>>()
307 .map_err(|error| SyncwebError::operation("failed to read folder stats", error))?;
308 for (ns, upload, download, files) in folder_rows {
309 per_folder.insert(
310 ns,
311 FolderStats {
312 upload: upload.cast_unsigned(),
313 download: download.cast_unsigned(),
314 files_transferred: files.cast_unsigned(),
315 },
316 );
317 }
318
319 let mut per_peer = BTreeMap::new();
320 let mut peer_stmt = connection
321 .prepare(
322 "SELECT peer, upload, download, connection_count
323 FROM bandwidth_peer WHERE period_start = ?1",
324 )
325 .map_err(|error| SyncwebError::operation("failed to prepare peer stats query", error))?;
326 let peer_rows = peer_stmt
327 .query_map(params![period_start], |row| {
328 Ok((
329 row.get::<_, String>(0)?,
330 row.get::<_, i64>(1)?,
331 row.get::<_, i64>(2)?,
332 row.get::<_, i64>(3)?,
333 ))
334 })
335 .map_err(|error| SyncwebError::operation("failed to query peer stats", error))?
336 .collect::<std::result::Result<Vec<_>, _>>()
337 .map_err(|error| SyncwebError::operation("failed to read peer stats", error))?;
338 for (peer, upload, download, conns) in peer_rows {
339 per_peer.insert(
340 peer,
341 PeerStats {
342 upload: upload.cast_unsigned(),
343 download: download.cast_unsigned(),
344 connection_count: conns.cast_unsigned(),
345 },
346 );
347 }
348
349 drop(folder_stmt);
350 drop(peer_stmt);
351 drop(connection);
352 Ok(BandwidthStats {
353 total_upload: total_upload.cast_unsigned(),
354 total_download: total_download.cast_unsigned(),
355 per_folder,
356 per_peer,
357 period_start: period_start.cast_unsigned(),
358 })
359 }
360
361 pub fn stats_since(&self, since_ts: i64) -> Result<BandwidthStats> {
370 let connection = self
371 .connection
372 .lock()
373 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
374
375 let (total_upload, total_download): (i64, i64) = connection
376 .query_row(
377 "SELECT COALESCE(SUM(CASE WHEN direction='upload' THEN bytes ELSE 0 END), 0),
378 COALESCE(SUM(CASE WHEN direction='download' THEN bytes ELSE 0 END), 0)
379 FROM bandwidth_events WHERE timestamp >= ?1",
380 params![since_ts],
381 |row| Ok((row.get(0)?, row.get(1)?)),
382 )
383 .map_err(|error| SyncwebError::operation("failed to query bandwidth totals", error))?;
384
385 let mut per_folder = BTreeMap::new();
386 let mut folder_stmt = connection
387 .prepare(
388 "SELECT folder_namespace,
389 COALESCE(SUM(CASE WHEN direction='upload' THEN bytes ELSE 0 END), 0),
390 COALESCE(SUM(CASE WHEN direction='download' THEN bytes ELSE 0 END), 0),
391 COALESCE(SUM(files), 0)
392 FROM bandwidth_events
393 WHERE timestamp >= ?1 AND folder_namespace IS NOT NULL
394 GROUP BY folder_namespace",
395 )
396 .map_err(|error| SyncwebError::operation("failed to prepare folder stats query", error))?;
397 let folder_rows = folder_stmt
398 .query_map(params![since_ts], |row| {
399 Ok((
400 row.get::<_, Option<String>>(0)?.unwrap_or_default(),
401 row.get::<_, i64>(1)?,
402 row.get::<_, i64>(2)?,
403 row.get::<_, i64>(3)?,
404 ))
405 })
406 .map_err(|error| SyncwebError::operation("failed to query folder stats", error))?
407 .collect::<std::result::Result<Vec<_>, _>>()
408 .map_err(|error| SyncwebError::operation("failed to read folder stats", error))?;
409 for (ns, upload, download, files) in folder_rows {
410 per_folder.insert(
411 ns,
412 FolderStats {
413 upload: upload.cast_unsigned(),
414 download: download.cast_unsigned(),
415 files_transferred: files.cast_unsigned(),
416 },
417 );
418 }
419 drop(folder_stmt);
420
421 let mut per_peer = BTreeMap::new();
422 let mut peer_stmt = connection
423 .prepare(
424 "SELECT peer,
425 COALESCE(SUM(CASE WHEN direction='upload' THEN bytes ELSE 0 END), 0),
426 COALESCE(SUM(CASE WHEN direction='download' THEN bytes ELSE 0 END), 0),
427 COUNT(*)
428 FROM bandwidth_events
429 WHERE timestamp >= ?1 AND peer IS NOT NULL
430 GROUP BY peer",
431 )
432 .map_err(|error| SyncwebError::operation("failed to prepare peer stats query", error))?;
433 let peer_rows = peer_stmt
434 .query_map(params![since_ts], |row| {
435 Ok((
436 row.get::<_, String>(0)?,
437 row.get::<_, i64>(1)?,
438 row.get::<_, i64>(2)?,
439 row.get::<_, i64>(3)?,
440 ))
441 })
442 .map_err(|error| SyncwebError::operation("failed to query peer stats", error))?
443 .collect::<std::result::Result<Vec<_>, _>>()
444 .map_err(|error| SyncwebError::operation("failed to read peer stats", error))?;
445 for (peer, upload, download, conns) in peer_rows {
446 per_peer.insert(
447 peer,
448 PeerStats {
449 upload: upload.cast_unsigned(),
450 download: download.cast_unsigned(),
451 connection_count: conns.cast_unsigned(),
452 },
453 );
454 }
455 drop(peer_stmt);
456 drop(connection);
457 Ok(BandwidthStats {
458 total_upload: total_upload.cast_unsigned(),
459 total_download: total_download.cast_unsigned(),
460 per_folder,
461 per_peer,
462 period_start: since_ts.cast_unsigned(),
463 })
464 }
465
466 pub fn network_events_after(&self, after_id: i64, limit: usize) -> Result<Vec<NetworkEventRecord>> {
472 let connection = self
473 .connection
474 .lock()
475 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
476 let mut stmt = connection
477 .prepare(
478 "SELECT id, timestamp, network_id, event_type, peer, details, metadata_json
479 FROM network_events WHERE id > ?1
480 ORDER BY id ASC LIMIT ?2",
481 )
482 .map_err(|error| SyncwebError::operation("failed to prepare network events query", error))?;
483 let records = stmt
484 .query_map(params![after_id, limit.cast_signed()], |row| {
485 Ok(NetworkEventRecord {
486 id: row.get(0)?,
487 timestamp: row.get(1)?,
488 network_id: row.get(2)?,
489 event_type: row.get(3)?,
490 peer: row.get(4)?,
491 details: row.get(5)?,
492 metadata_json: row.get(6)?,
493 })
494 })
495 .map_err(|error| SyncwebError::operation("failed to query network events", error))?
496 .collect::<std::result::Result<Vec<_>, _>>()
497 .map_err(|error| SyncwebError::operation("failed to read network event rows", error))?;
498 drop(stmt);
499 drop(connection);
500 Ok(records)
501 }
502
503 pub fn sync_sessions_after(&self, after_id: i64, limit: usize) -> Result<Vec<SyncSessionRecord>> {
509 let connection = self
510 .connection
511 .lock()
512 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
513 let mut stmt = connection
514 .prepare(
515 "SELECT id, network_id, folder_namespace, started_at, finished_at,
516 files_transferred, bytes_transferred, errors, status
517 FROM network_sync_sessions WHERE id > ?1
518 ORDER BY id ASC LIMIT ?2",
519 )
520 .map_err(|error| SyncwebError::operation("failed to prepare sync sessions query", error))?;
521 let records = stmt
522 .query_map(params![after_id, limit.cast_signed()], |row| {
523 Ok(SyncSessionRecord {
524 id: row.get(0)?,
525 network_id: row.get(1)?,
526 folder_namespace: row.get(2)?,
527 started_at: row.get(3)?,
528 finished_at: row.get(4)?,
529 files_transferred: row.get::<_, i64>(5)?.cast_unsigned(),
530 bytes_transferred: row.get::<_, i64>(6)?.cast_unsigned(),
531 errors: row.get::<_, i64>(7)?.cast_unsigned(),
532 status: row.get(8)?,
533 })
534 })
535 .map_err(|error| SyncwebError::operation("failed to query sync sessions", error))?
536 .collect::<std::result::Result<Vec<_>, _>>()
537 .map_err(|error| SyncwebError::operation("failed to read sync session rows", error))?;
538 drop(stmt);
539 drop(connection);
540 Ok(records)
541 }
542
543 pub fn reset_bandwidth(&self) -> Result<()> {
550 let connection = self
551 .connection
552 .lock()
553 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
554 connection
555 .execute("DELETE FROM bandwidth_events", [])
556 .map_err(|error| SyncwebError::operation("failed to clear bandwidth events", error))?;
557 connection
558 .execute("DELETE FROM bandwidth_folder", [])
559 .map_err(|error| SyncwebError::operation("failed to clear folder bandwidth", error))?;
560 connection
561 .execute("DELETE FROM bandwidth_peer", [])
562 .map_err(|error| SyncwebError::operation("failed to clear peer bandwidth", error))?;
563 connection
564 .execute("DELETE FROM bandwidth_period", [])
565 .map_err(|error| SyncwebError::operation("failed to clear bandwidth periods", error))?;
566 drop(connection);
567 Ok(())
568 }
569
570 pub fn append_log(&self, level: &str, module: Option<&str>, message: &str) -> Result<()> {
576 let connection = self
577 .connection
578 .lock()
579 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
580 let now = current_timestamp();
581 connection
582 .execute(
583 "INSERT INTO daemon_log(timestamp, level, module, message) VALUES (?1, ?2, ?3, ?4)",
584 params![now, level, module, message],
585 )
586 .map_err(|error| SyncwebError::operation("failed to append log entry", error))?;
587 drop(connection);
588 Ok(())
589 }
590
591 pub fn purge_old_bandwidth(&self, older_than: Duration) -> Result<usize> {
597 let connection = self
598 .connection
599 .lock()
600 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
601 let cutoff = current_timestamp().saturating_sub(older_than.as_secs().cast_signed());
602 let deleted = connection
603 .execute("DELETE FROM bandwidth_events WHERE timestamp < ?1", params![cutoff])
604 .map_err(|error| SyncwebError::operation("failed to purge old bandwidth events", error))?;
605 drop(connection);
606 Ok(deleted)
607 }
608
609 pub fn record_network_event(
615 &self,
616 network_id: &str,
617 event_type: &str,
618 peer: Option<&str>,
619 details: Option<&str>,
620 metadata_json: Option<&str>,
621 ) -> Result<()> {
622 let connection = self
623 .connection
624 .lock()
625 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
626 let now = current_timestamp();
627 connection
628 .execute(
629 "INSERT INTO network_events(timestamp, network_id, event_type, peer, details, metadata_json)
630 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
631 params![now, network_id, event_type, peer, details, metadata_json],
632 )
633 .map_err(|error| SyncwebError::operation("failed to record network event", error))?;
634 drop(connection);
635 Ok(())
636 }
637
638 pub fn record_sync_session_start(&self, network_id: &str, folder_namespace: &str) -> Result<i64> {
644 let connection = self
645 .connection
646 .lock()
647 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
648 let now = current_timestamp();
649 connection
650 .execute(
651 "INSERT INTO network_sync_sessions(network_id, folder_namespace, started_at)
652 VALUES (?1, ?2, ?3)",
653 params![network_id, folder_namespace, now],
654 )
655 .map_err(|error| SyncwebError::operation("failed to record sync session start", error))?;
656 let session_id = connection.last_insert_rowid();
657 drop(connection);
658 Ok(session_id)
659 }
660
661 pub fn record_sync_session_finish(
667 &self,
668 session_id: i64,
669 files: u64,
670 bytes: u64,
671 errors: u64,
672 status: &str,
673 ) -> Result<()> {
674 let connection = self
675 .connection
676 .lock()
677 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
678 let now = current_timestamp();
679 connection
680 .execute(
681 "UPDATE network_sync_sessions SET finished_at = ?1, files_transferred = ?2,
682 bytes_transferred = ?3, errors = ?4, status = ?5 WHERE id = ?6",
683 params![
684 now,
685 files.cast_signed(),
686 bytes.cast_signed(),
687 errors.cast_signed(),
688 status,
689 session_id
690 ],
691 )
692 .map_err(|error| SyncwebError::operation("failed to record sync session finish", error))?;
693 drop(connection);
694 Ok(())
695 }
696
697 pub fn record_relay_check(
703 &self,
704 relay_url: &str,
705 connected: bool,
706 latency_ms: Option<i64>,
707 error_message: Option<&str>,
708 ) -> Result<()> {
709 let connection = self
710 .connection
711 .lock()
712 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
713 let now = current_timestamp();
714 connection
715 .execute(
716 "INSERT INTO relay_health(relay_url, checked_at, connected, latency_ms, error_message)
717 VALUES (?1, ?2, ?3, ?4, ?5)",
718 params![relay_url, now, i64::from(connected), latency_ms, error_message],
719 )
720 .map_err(|error| SyncwebError::operation("failed to record relay check", error))?;
721 drop(connection);
722 Ok(())
723 }
724
725 pub fn recent_network_events(&self, network_id: &str, limit: usize) -> Result<Vec<NetworkEventRecord>> {
731 let connection = self
732 .connection
733 .lock()
734 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
735 let mut stmt = connection
736 .prepare(
737 "SELECT id, timestamp, network_id, event_type, peer, details, metadata_json
738 FROM network_events WHERE network_id = ?1
739 ORDER BY timestamp DESC LIMIT ?2",
740 )
741 .map_err(|error| SyncwebError::operation("failed to prepare network events query", error))?;
742 let records = stmt
743 .query_map(params![network_id, limit.cast_signed()], |row| {
744 Ok(NetworkEventRecord {
745 id: row.get(0)?,
746 timestamp: row.get(1)?,
747 network_id: row.get(2)?,
748 event_type: row.get(3)?,
749 peer: row.get(4)?,
750 details: row.get(5)?,
751 metadata_json: row.get(6)?,
752 })
753 })
754 .map_err(|error| SyncwebError::operation("failed to query network events", error))?
755 .collect::<std::result::Result<Vec<_>, _>>()
756 .map_err(|error| SyncwebError::operation("failed to read network event rows", error))?;
757 drop(stmt);
758 drop(connection);
759 Ok(records)
760 }
761
762 pub fn recent_sync_sessions(&self, network_id: &str, limit: usize) -> Result<Vec<SyncSessionRecord>> {
768 let connection = self
769 .connection
770 .lock()
771 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
772 let mut stmt = connection
773 .prepare(
774 "SELECT id, network_id, folder_namespace, started_at, finished_at,
775 files_transferred, bytes_transferred, errors, status
776 FROM network_sync_sessions WHERE network_id = ?1
777 ORDER BY started_at DESC LIMIT ?2",
778 )
779 .map_err(|error| SyncwebError::operation("failed to prepare sync sessions query", error))?;
780 let records = stmt
781 .query_map(params![network_id, limit.cast_signed()], |row| {
782 Ok(SyncSessionRecord {
783 id: row.get(0)?,
784 network_id: row.get(1)?,
785 folder_namespace: row.get(2)?,
786 started_at: row.get(3)?,
787 finished_at: row.get(4)?,
788 files_transferred: row.get::<_, i64>(5)?.cast_unsigned(),
789 bytes_transferred: row.get::<_, i64>(6)?.cast_unsigned(),
790 errors: row.get::<_, i64>(7)?.cast_unsigned(),
791 status: row.get(8)?,
792 })
793 })
794 .map_err(|error| SyncwebError::operation("failed to query sync sessions", error))?
795 .collect::<std::result::Result<Vec<_>, _>>()
796 .map_err(|error| SyncwebError::operation("failed to read sync session rows", error))?;
797 drop(stmt);
798 drop(connection);
799 Ok(records)
800 }
801
802 pub fn relay_uptime(&self, relay_url: &str, window_secs: i64) -> Result<f64> {
808 let connection = self
809 .connection
810 .lock()
811 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
812 let cutoff = current_timestamp().saturating_sub(window_secs);
813 let (total, connected): (i64, i64) = connection
814 .query_row(
815 "SELECT COUNT(*), SUM(connected) FROM relay_health
816 WHERE relay_url = ?1 AND checked_at >= ?2",
817 params![relay_url, cutoff],
818 |row| Ok((row.get(0)?, row.get::<_, Option<i64>>(1)?.unwrap_or(0))),
819 )
820 .map_err(|error| SyncwebError::operation("failed to query relay uptime", error))?;
821 drop(connection);
822 Ok(if total > 0 {
823 f64::from(i32::try_from(connected).unwrap_or(0))
824 .mul_add(f64::from(i32::try_from(total).unwrap_or(1)).recip(), 0.0)
825 } else {
826 1.0
827 })
828 }
829
830 pub fn vacuum(&self) -> Result<()> {
836 let connection = self
837 .connection
838 .lock()
839 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
840 connection
841 .execute_batch("VACUUM")
842 .map_err(|error| SyncwebError::operation("vacuum failed", error))?;
843 drop(connection);
844 Ok(())
845 }
846
847 pub fn size_on_disk(&self) -> Result<u64> {
853 let connection = self
854 .connection
855 .lock()
856 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
857 let db_path: String = connection
858 .query_row("PRAGMA database_list", [], |row| row.get(2))
859 .map_err(|error| SyncwebError::operation("failed to query database path", error))?;
860 drop(connection);
861 std::fs::metadata(&db_path)
862 .map(|meta| meta.len())
863 .map_err(|error| SyncwebError::operation("failed to get database file size", error))
864 }
865
866 pub fn freelist_count(&self) -> Result<i64> {
872 let connection = self
873 .connection
874 .lock()
875 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
876 let count: i64 = connection
877 .pragma_query_value(None, "freelist_count", |row| row.get(0))
878 .map_err(|error| SyncwebError::operation("failed to query freelist count", error))?;
879 drop(connection);
880 Ok(count)
881 }
882
883 pub fn check_integrity(&self) -> Result<Vec<String>> {
889 let connection = self
890 .connection
891 .lock()
892 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
893 let mut stmt = connection
894 .prepare("PRAGMA integrity_check")
895 .map_err(|error| SyncwebError::operation("failed to prepare integrity check", error))?;
896 let errors: Vec<String> = stmt
897 .query_map([], |row| row.get::<_, String>(0))
898 .map_err(|error| SyncwebError::operation("failed to run integrity check", error))?
899 .filter_map(std::result::Result::ok)
900 .filter(|s| s != "ok")
901 .collect();
902 drop(stmt);
903 drop(connection);
904 Ok(errors)
905 }
906
907 pub fn purge_old_logs(&self, older_than: Duration) -> Result<usize> {
913 let connection = self
914 .connection
915 .lock()
916 .map_err(|error| SyncwebError::operation("stats database mutex is poisoned", error))?;
917 let cutoff = current_timestamp().saturating_sub(older_than.as_secs().cast_signed());
918 let deleted = connection
919 .execute("DELETE FROM daemon_log WHERE timestamp < ?1", params![cutoff])
920 .map_err(|error| SyncwebError::operation("failed to purge old log entries", error))?;
921 drop(connection);
922 Ok(deleted)
923 }
924}
925
926#[derive(Clone, Debug)]
928#[non_exhaustive]
929pub struct NetworkEventRecord {
930 pub id: i64,
931 pub timestamp: i64,
932 pub network_id: String,
933 pub event_type: String,
934 pub peer: Option<String>,
935 pub details: Option<String>,
936 pub metadata_json: Option<String>,
937}
938
939#[derive(Clone, Debug)]
941#[non_exhaustive]
942pub struct SyncSessionRecord {
943 pub id: i64,
944 pub network_id: String,
945 pub folder_namespace: String,
946 pub started_at: i64,
947 pub finished_at: Option<i64>,
948 pub files_transferred: u64,
949 pub bytes_transferred: u64,
950 pub errors: u64,
951 pub status: String,
952}
953
954fn current_timestamp() -> i64 {
955 std::time::SystemTime::now()
956 .duration_since(std::time::UNIX_EPOCH)
957 .map_or(0, |d| d.as_secs().cast_signed())
958}
959
960impl crate::storage::Vacuumable for StatsDatabase {
961 fn vacuum(&self) -> Result<()> {
962 self.vacuum()
963 }
964
965 fn freelist_count(&self) -> Result<i64> {
966 self.freelist_count()
967 }
968}
969
970fn find_or_create_period(connection: &Connection, now: i64) -> std::result::Result<i64, rusqlite::Error> {
971 let period_start = now.div_euclid(3600).saturating_mul(3600);
972 connection.execute(
973 "INSERT OR IGNORE INTO bandwidth_period(period_start, total_upload, total_download, closed)
974 VALUES (?1, 0, 0, 0)",
975 params![period_start],
976 )?;
977 Ok(period_start)
978}