diff --git a/.sqlx/query-04a2da85c042070254732c837bfd02a086eb5dd8b8efa330084ba9be30e25485.json b/.sqlx/query-04a2da85c042070254732c837bfd02a086eb5dd8b8efa330084ba9be30e25485.json new file mode 100644 index 0000000000..a54130d004 --- /dev/null +++ b/.sqlx/query-04a2da85c042070254732c837bfd02a086eb5dd8b8efa330084ba9be30e25485.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (vcs.device_id) vcs.device_id, d.name \"device_name!\", vcs.connected_at \"connected_at!\", wnd.wireguard_ips \"wireguard_ips: Vec\", ss.endpoint FROM vpn_client_session vcs JOIN LATERAL ( SELECT endpoint FROM vpn_session_stats WHERE session_id = vcs.id ORDER BY collected_at DESC LIMIT 1 ) ss ON true JOIN device d ON vcs.device_id = d.id JOIN wireguard_network_device wnd ON vcs.device_id = wnd.device_id AND vcs.location_id = wnd.wireguard_network_id WHERE vcs.location_id = $1 AND vcs.user_id = $2 AND vcs.state = 'connected' AND d.device_type = 'user' ORDER BY vcs.device_id, vcs.connected_at ASC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "device_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "device_name!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "connected_at!", + "type_info": "Timestamp" + }, + { + "ordinal": 3, + "name": "wireguard_ips: Vec", + "type_info": "InetArray" + }, + { + "ordinal": 4, + "name": "endpoint", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "04a2da85c042070254732c837bfd02a086eb5dd8b8efa330084ba9be30e25485" +} diff --git a/.sqlx/query-0eee6a7f4675b70b495fb06d56faf56b9e45a6281509789987b38fc47c755b18.json b/.sqlx/query-0eee6a7f4675b70b495fb06d56faf56b9e45a6281509789987b38fc47c755b18.json new file mode 100644 index 0000000000..a6e3bdddf0 --- /dev/null +++ b/.sqlx/query-0eee6a7f4675b70b495fb06d56faf56b9e45a6281509789987b38fc47c755b18.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(DISTINCT vcs.user_id) FROM vpn_client_session vcs JOIN device d ON vcs.device_id = d.id WHERE vcs.location_id = $1 AND vcs.state = 'connected' AND d.device_type = 'user'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0eee6a7f4675b70b495fb06d56faf56b9e45a6281509789987b38fc47c755b18" +} diff --git a/.sqlx/query-4121e51c2b2984cbc598c9f9a48848cac7029a895bde3c312bed9636f07331c3.json b/.sqlx/query-4121e51c2b2984cbc598c9f9a48848cac7029a895bde3c312bed9636f07331c3.json new file mode 100644 index 0000000000..2aaced8a7b --- /dev/null +++ b/.sqlx/query-4121e51c2b2984cbc598c9f9a48848cac7029a895bde3c312bed9636f07331c3.json @@ -0,0 +1,48 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (vcs.device_id) vcs.device_id, d.name \"device_name!\", vcs.connected_at \"connected_at!\", wnd.wireguard_ips \"wireguard_ips: Vec\", ss.endpoint FROM vpn_client_session vcs JOIN LATERAL ( SELECT endpoint FROM vpn_session_stats WHERE session_id = vcs.id ORDER BY collected_at DESC LIMIT 1 ) ss ON true JOIN device d ON vcs.device_id = d.id JOIN wireguard_network_device wnd ON vcs.device_id = wnd.device_id AND vcs.location_id = wnd.wireguard_network_id WHERE vcs.location_id = $1 AND vcs.state = 'connected' AND d.device_type = 'network' ORDER BY vcs.device_id, vcs.connected_at ASC LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "device_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "device_name!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "connected_at!", + "type_info": "Timestamp" + }, + { + "ordinal": 3, + "name": "wireguard_ips: Vec", + "type_info": "InetArray" + }, + { + "ordinal": 4, + "name": "endpoint", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "4121e51c2b2984cbc598c9f9a48848cac7029a895bde3c312bed9636f07331c3" +} diff --git a/.sqlx/query-7a585bcfa87fa425878052126f9f1882eda51ff6ddbdec27f3129f06c92dd4a9.json b/.sqlx/query-7a585bcfa87fa425878052126f9f1882eda51ff6ddbdec27f3129f06c92dd4a9.json new file mode 100644 index 0000000000..1713d952fe --- /dev/null +++ b/.sqlx/query-7a585bcfa87fa425878052126f9f1882eda51ff6ddbdec27f3129f06c92dd4a9.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT date_trunc($1, collected_at) \"collected_at: NaiveDateTime\", CAST(SUM(upload_diff) AS bigint) upload, CAST(SUM(download_diff) AS bigint) download FROM vpn_session_stats JOIN vpn_client_session s ON session_id = s.id JOIN device d ON s.device_id = d.id WHERE s.user_id = $2 AND s.location_id = $3 AND s.state = 'connected' AND d.device_type = 'user' AND collected_at >= $4 GROUP BY 1 ORDER BY 1 LIMIT $5", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "collected_at: NaiveDateTime", + "type_info": "Timestamp" + }, + { + "ordinal": 1, + "name": "upload", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "download", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8", + "Timestamp", + "Int8" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "7a585bcfa87fa425878052126f9f1882eda51ff6ddbdec27f3129f06c92dd4a9" +} diff --git a/.sqlx/query-89641e4cfdad3b6290f693d7fa923ebffd0ee78d03e9b256dac6d808dbe6061b.json b/.sqlx/query-89641e4cfdad3b6290f693d7fa923ebffd0ee78d03e9b256dac6d808dbe6061b.json new file mode 100644 index 0000000000..e022b2ba3c --- /dev/null +++ b/.sqlx/query-89641e4cfdad3b6290f693d7fa923ebffd0ee78d03e9b256dac6d808dbe6061b.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (vcs.user_id) vcs.user_id, u.first_name, u.last_name, vcs.connected_at \"connected_at!\", wnd.wireguard_ips \"wireguard_ips: Vec\", ss.endpoint, (SELECT COUNT(DISTINCT s.device_id) FROM vpn_client_session s JOIN device d2 ON d2.id = s.device_id WHERE s.user_id = vcs.user_id AND s.location_id = vcs.location_id AND s.state = 'connected' AND d2.device_type = 'user') \"connected_devices_count!\" FROM vpn_client_session vcs JOIN LATERAL ( SELECT endpoint FROM vpn_session_stats WHERE session_id = vcs.id ORDER BY collected_at DESC LIMIT 1 ) ss ON true JOIN \"user\" u ON vcs.user_id = u.id JOIN device d ON vcs.device_id = d.id JOIN wireguard_network_device wnd ON vcs.device_id = wnd.device_id AND vcs.location_id = wnd.wireguard_network_id WHERE vcs.location_id = $1 AND vcs.state = 'connected' AND d.device_type = 'user' ORDER BY vcs.user_id, vcs.connected_at ASC LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "first_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "last_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "connected_at!", + "type_info": "Timestamp" + }, + { + "ordinal": 4, + "name": "wireguard_ips: Vec", + "type_info": "InetArray" + }, + { + "ordinal": 5, + "name": "endpoint", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "connected_devices_count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + null + ] + }, + "hash": "89641e4cfdad3b6290f693d7fa923ebffd0ee78d03e9b256dac6d808dbe6061b" +} diff --git a/.sqlx/query-ad54a6c79202f121dcfb03a4d9128de8d1395d2233a5f5c24efcb421e12c98cc.json b/.sqlx/query-ad54a6c79202f121dcfb03a4d9128de8d1395d2233a5f5c24efcb421e12c98cc.json new file mode 100644 index 0000000000..cd33b4f500 --- /dev/null +++ b/.sqlx/query-ad54a6c79202f121dcfb03a4d9128de8d1395d2233a5f5c24efcb421e12c98cc.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT date_trunc($1, collected_at) \"collected_at: NaiveDateTime\", CAST(SUM(upload_diff) AS bigint) upload, CAST(SUM(download_diff) AS bigint) download FROM vpn_session_stats JOIN vpn_client_session s ON session_id = s.id WHERE s.device_id = $2 AND s.location_id = $3 AND s.state = 'connected' AND collected_at >= $4 GROUP BY 1 ORDER BY 1 LIMIT $5", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "collected_at: NaiveDateTime", + "type_info": "Timestamp" + }, + { + "ordinal": 1, + "name": "upload", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "download", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8", + "Timestamp", + "Int8" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "ad54a6c79202f121dcfb03a4d9128de8d1395d2233a5f5c24efcb421e12c98cc" +} diff --git a/.sqlx/query-b3d04286006c95591c10089b8b6436de52298bb2e98e01ce6c3793327b2b4960.json b/.sqlx/query-b3d04286006c95591c10089b8b6436de52298bb2e98e01ce6c3793327b2b4960.json new file mode 100644 index 0000000000..3c4240b7d6 --- /dev/null +++ b/.sqlx/query-b3d04286006c95591c10089b8b6436de52298bb2e98e01ce6c3793327b2b4960.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(DISTINCT vcs.device_id) FROM vpn_client_session vcs JOIN device d ON vcs.device_id = d.id WHERE vcs.location_id = $1 AND vcs.state = 'connected' AND d.device_type = 'network'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b3d04286006c95591c10089b8b6436de52298bb2e98e01ce6c3793327b2b4960" +} diff --git a/Cargo.lock b/Cargo.lock index d022b44641..4605097ac2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -399,9 +399,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" dependencies = [ "aws-lc-sys", "zeroize", @@ -686,9 +686,9 @@ checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5c6f81257d10a0f602a294ae4182251151ff97dbb504ef9afcdda4a64b24d9b4" [[package]] name = "bytemuck" @@ -3571,9 +3571,9 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "native-tls" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5d26952a508f321b4d3d2e80e78fc2603eaefcdf0c30783867f19586518bdc" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", @@ -7895,9 +7895,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" +checksum = "c745c48e1007337ed136dc99df34128b9faa6ed542d80a1c673cf55a6d7236c8" [[package]] name = "zmij" diff --git a/crates/defguard_common/src/db/models/vpn_session_stats.rs b/crates/defguard_common/src/db/models/vpn_session_stats.rs index 05d2ac08c7..794fe3f426 100644 --- a/crates/defguard_common/src/db/models/vpn_session_stats.rs +++ b/crates/defguard_common/src/db/models/vpn_session_stats.rs @@ -82,15 +82,19 @@ impl VpnSessionStats { /// IPv6: [x::y:z]:p -> x::y:z #[must_use] pub fn endpoint_without_port(&self) -> Option { - // Remove port part - let mut addr = self.endpoint.rsplit_once(':')?.0; + endpoint_without_port(&self.endpoint) + } +} - // Strip square brackets from IPv6 addrs - if addr.starts_with('[') && addr.ends_with(']') { - let end = addr.len() - 1; - addr = &addr[1..end]; - } +pub fn endpoint_without_port(endpoint: &str) -> Option { + // Remove port part + let mut addr = endpoint.rsplit_once(':')?.0; - Some(addr.to_owned()) + // Strip square brackets from IPv6 addrs + if addr.starts_with('[') && addr.ends_with(']') { + let end = addr.len() - 1; + addr = &addr[1..end]; } + + Some(addr.to_owned()) } diff --git a/crates/defguard_common/src/db/models/wireguard.rs b/crates/defguard_common/src/db/models/wireguard.rs index 9e517d0384..4ca4998c5d 100644 --- a/crates/defguard_common/src/db/models/wireguard.rs +++ b/crates/defguard_common/src/db/models/wireguard.rs @@ -32,7 +32,7 @@ use crate::{ Id, NoId, models::{ vpn_client_session::{VpnClientMfaMethod, VpnClientSession, VpnClientSessionState}, - vpn_session_stats::VpnSessionStats, + vpn_session_stats::{VpnSessionStats, endpoint_without_port}, }, }, types::user_info::UserInfo, @@ -631,6 +631,332 @@ impl WireguardNetwork { Ok(stats) } + /// Retrieves network stats for currently active users since `from` timestamp. + pub async fn connected_users_stats( + &self, + conn: &PgPool, + from: &NaiveDateTime, + aggregation: &DateTimeAggregation, + page: u32, + page_size: u32, + ) -> Result<(Vec, u32), sqlx::Error> { + // helper struct used to fetch connected users from the DB + struct ConnectedUserRow { + user_id: Id, + first_name: String, + last_name: String, + connected_devices_count: i64, + connected_at: NaiveDateTime, + wireguard_ips: Vec, + endpoint: String, + } + let limit = page_size; + let offset = (page - 1) * page_size; + + // fetch currently connected users + let connected_users = query_as!( + ConnectedUserRow, + "SELECT DISTINCT ON (vcs.user_id) vcs.user_id, u.first_name, u.last_name, vcs.connected_at \"connected_at!\", \ + wnd.wireguard_ips \"wireguard_ips: Vec\", ss.endpoint, \ + (SELECT COUNT(DISTINCT s.device_id) \ + FROM vpn_client_session s \ + JOIN device d2 ON d2.id = s.device_id \ + WHERE s.user_id = vcs.user_id \ + AND s.location_id = vcs.location_id \ + AND s.state = 'connected' \ + AND d2.device_type = 'user') \"connected_devices_count!\" \ + FROM vpn_client_session vcs \ + JOIN LATERAL ( \ + SELECT endpoint \ + FROM vpn_session_stats \ + WHERE session_id = vcs.id \ + ORDER BY collected_at DESC \ + LIMIT 1 \ + ) ss ON true \ + JOIN \"user\" u ON vcs.user_id = u.id \ + JOIN device d ON vcs.device_id = d.id \ + JOIN wireguard_network_device wnd ON vcs.device_id = wnd.device_id AND vcs.location_id = wnd.wireguard_network_id \ + WHERE vcs.location_id = $1 AND vcs.state = 'connected' AND d.device_type = 'user' \ + ORDER BY vcs.user_id, vcs.connected_at ASC \ + LIMIT $2 OFFSET $3", + self.id, + i64::from(limit), + i64::from(offset) + ) + .fetch_all(conn) + .await?; + + // fetch traffic stats for each user + let mut page_result = Vec::new(); + for user in connected_users { + let full_name = format!("{} {}", user.first_name, user.last_name); + + // fetch transfer stats for all active sessions for this user within specified time window + let stats = query_as!( + WireguardStatsRow, + "SELECT \ + date_trunc($1, collected_at) \"collected_at: NaiveDateTime\", \ + CAST(SUM(upload_diff) AS bigint) upload, \ + CAST(SUM(download_diff) AS bigint) download \ + FROM vpn_session_stats \ + JOIN vpn_client_session s ON session_id = s.id \ + JOIN device d ON s.device_id = d.id \ + WHERE s.user_id = $2 \ + AND s.location_id = $3 \ + AND s.state = 'connected' \ + AND d.device_type = 'user' \ + AND collected_at >= $4 \ + GROUP BY 1 \ + ORDER BY 1 \ + LIMIT $5", + aggregation.fstring(), + user.user_id, + self.id, + from, + PEER_STATS_LIMIT, + ) + .fetch_all(conn) + .await?; + + let total_upload: i64 = stats.iter().filter_map(|s| s.upload).sum(); + let total_download: i64 = stats.iter().filter_map(|s| s.download).sum(); + + let connected_user = LocationConnectedUserStats { + user_id: user.user_id, + first_name: user.first_name, + last_name: user.last_name, + full_name, + connected_devices_count: user.connected_devices_count as u16, + public_ip: endpoint_without_port(&user.endpoint).unwrap_or_default(), + vpn_ips: user.wireguard_ips, + connected_at: user.connected_at, + total_upload, + total_download, + stats, + }; + + page_result.push(connected_user); + } + + // fetch total item count + let total_items: i64 = query_scalar!( + "SELECT COUNT(DISTINCT vcs.user_id) \ + FROM vpn_client_session vcs \ + JOIN device d ON vcs.device_id = d.id \ + WHERE vcs.location_id = $1 AND vcs.state = 'connected' AND d.device_type = 'user'", + self.id, + ) + .fetch_one(conn) + .await? + .unwrap_or(0); + + Ok((page_result, total_items as u32)) + } + + /// Retrieves network stats for currently connected network devices since `from` timestamp. + pub async fn connected_network_devices_stats( + &self, + conn: &PgPool, + from: &NaiveDateTime, + aggregation: &DateTimeAggregation, + page: u32, + page_size: u32, + ) -> Result<(Vec, u32), sqlx::Error> { + // helper struct used to fetch connected network devices from the DB + struct ConnectedNetworkDeviceRow { + device_id: Id, + device_name: String, + connected_at: NaiveDateTime, + wireguard_ips: Vec, + endpoint: String, + } + let limit = page_size; + let offset = (page - 1) * page_size; + + // fetch currently connected network devices + let connected_devices = query_as!( + ConnectedNetworkDeviceRow, + "SELECT DISTINCT ON (vcs.device_id) vcs.device_id, d.name \"device_name!\", \ + vcs.connected_at \"connected_at!\", \ + wnd.wireguard_ips \"wireguard_ips: Vec\", ss.endpoint \ + FROM vpn_client_session vcs \ + JOIN LATERAL ( \ + SELECT endpoint \ + FROM vpn_session_stats \ + WHERE session_id = vcs.id \ + ORDER BY collected_at DESC \ + LIMIT 1 \ + ) ss ON true \ + JOIN device d ON vcs.device_id = d.id \ + JOIN wireguard_network_device wnd ON vcs.device_id = wnd.device_id \ + AND vcs.location_id = wnd.wireguard_network_id \ + WHERE vcs.location_id = $1 \ + AND vcs.state = 'connected' \ + AND d.device_type = 'network' \ + ORDER BY vcs.device_id, vcs.connected_at ASC \ + LIMIT $2 OFFSET $3", + self.id, + i64::from(limit), + i64::from(offset) + ) + .fetch_all(conn) + .await?; + + // fetch traffic stats for each device + let mut page_result = Vec::new(); + for device in connected_devices { + // fetch transfer stats for this device's active session within specified time window + let stats = query_as!( + WireguardStatsRow, + "SELECT \ + date_trunc($1, collected_at) \"collected_at: NaiveDateTime\", \ + CAST(SUM(upload_diff) AS bigint) upload, \ + CAST(SUM(download_diff) AS bigint) download \ + FROM vpn_session_stats \ + JOIN vpn_client_session s ON session_id = s.id \ + WHERE s.device_id = $2 \ + AND s.location_id = $3 \ + AND s.state = 'connected' \ + AND collected_at >= $4 \ + GROUP BY 1 \ + ORDER BY 1 \ + LIMIT $5", + aggregation.fstring(), + device.device_id, + self.id, + from, + PEER_STATS_LIMIT, + ) + .fetch_all(conn) + .await?; + + let total_upload: i64 = stats.iter().filter_map(|s| s.upload).sum(); + let total_download: i64 = stats.iter().filter_map(|s| s.download).sum(); + + let connected_device = LocationConnectedNetworkDevice { + device_id: device.device_id, + device_name: device.device_name, + public_ip: endpoint_without_port(&device.endpoint).unwrap_or_default(), + vpn_ips: device.wireguard_ips, + connected_at: device.connected_at, + total_upload, + total_download, + stats, + }; + + page_result.push(connected_device); + } + + // fetch total item count + let total_items: i64 = query_scalar!( + "SELECT COUNT(DISTINCT vcs.device_id) \ + FROM vpn_client_session vcs \ + JOIN device d ON vcs.device_id = d.id \ + WHERE vcs.location_id = $1 \ + AND vcs.state = 'connected' \ + AND d.device_type = 'network'", + self.id, + ) + .fetch_one(conn) + .await? + .unwrap_or(0); + + Ok((page_result, total_items as u32)) + } + + /// Retrieves stats for all connected user devices for a specific user at this location. + pub async fn connected_user_devices_stats( + &self, + conn: &PgPool, + user_id: Id, + from: &NaiveDateTime, + aggregation: &DateTimeAggregation, + ) -> Result, sqlx::Error> { + // helper struct used to fetch connected user devices from the DB + struct ConnectedUserDeviceRow { + device_id: Id, + device_name: String, + connected_at: NaiveDateTime, + wireguard_ips: Vec, + endpoint: String, + } + + // fetch currently connected user devices for specified user + let connected_devices = query_as!( + ConnectedUserDeviceRow, + "SELECT DISTINCT ON (vcs.device_id) vcs.device_id, d.name \"device_name!\", \ + vcs.connected_at \"connected_at!\", \ + wnd.wireguard_ips \"wireguard_ips: Vec\", ss.endpoint \ + FROM vpn_client_session vcs \ + JOIN LATERAL ( \ + SELECT endpoint \ + FROM vpn_session_stats \ + WHERE session_id = vcs.id \ + ORDER BY collected_at DESC \ + LIMIT 1 \ + ) ss ON true \ + JOIN device d ON vcs.device_id = d.id \ + JOIN wireguard_network_device wnd ON vcs.device_id = wnd.device_id \ + AND vcs.location_id = wnd.wireguard_network_id \ + WHERE vcs.location_id = $1 \ + AND vcs.user_id = $2 \ + AND vcs.state = 'connected' \ + AND d.device_type = 'user' \ + ORDER BY vcs.device_id, vcs.connected_at ASC", + self.id, + user_id, + ) + .fetch_all(conn) + .await?; + + // fetch traffic stats for each device + let mut result = Vec::new(); + for device in connected_devices { + // fetch transfer stats for this device's active session within specified time window + let stats = query_as!( + WireguardStatsRow, + "SELECT \ + date_trunc($1, collected_at) \"collected_at: NaiveDateTime\", \ + CAST(SUM(upload_diff) AS bigint) upload, \ + CAST(SUM(download_diff) AS bigint) download \ + FROM vpn_session_stats \ + JOIN vpn_client_session s ON session_id = s.id \ + WHERE s.device_id = $2 \ + AND s.location_id = $3 \ + AND s.state = 'connected' \ + AND collected_at >= $4 \ + GROUP BY 1 \ + ORDER BY 1 \ + LIMIT $5", + aggregation.fstring(), + device.device_id, + self.id, + from, + PEER_STATS_LIMIT, + ) + .fetch_all(conn) + .await?; + + let total_upload: i64 = stats.iter().filter_map(|s| s.upload).sum(); + let total_download: i64 = stats.iter().filter_map(|s| s.download).sum(); + + let connected_device = LocationConnectedUserDevice { + device_id: device.device_id, + device_name: device.device_name, + public_ip: endpoint_without_port(&device.endpoint).unwrap_or_default(), + vpn_ips: device.wireguard_ips, + connected_at: device.connected_at, + total_upload, + total_download, + stats, + }; + + result.push(connected_device); + } + + Ok(result) + } + /// Retrieves total active users/devices since `from` timestamp /// /// A user/device is considered active if a session is currently connected @@ -1112,6 +1438,51 @@ pub struct WireguardNetworkStats { pub transfer_series: Vec, } +#[derive(Serialize)] +pub struct LocationConnectedUserStats { + user_id: Id, + first_name: String, + last_name: String, + full_name: String, + connected_devices_count: u16, + // oldest active session data + public_ip: String, + vpn_ips: Vec, + connected_at: NaiveDateTime, + // agregated traffic stats + total_upload: i64, + total_download: i64, + stats: Vec, +} + +#[derive(Serialize)] +pub struct LocationConnectedNetworkDevice { + device_id: Id, + device_name: String, + // active session data + public_ip: String, + vpn_ips: Vec, + connected_at: NaiveDateTime, + // agregated traffic stats + total_upload: i64, + total_download: i64, + stats: Vec, +} + +#[derive(Serialize)] +pub struct LocationConnectedUserDevice { + pub device_id: Id, + pub device_name: String, + // active session data + pub public_ip: String, + pub vpn_ips: Vec, + pub connected_at: NaiveDateTime, + // aggregated traffic stats + pub total_upload: i64, + pub total_download: i64, + pub stats: Vec, +} + pub async fn networks_stats( pool: &PgPool, from: &NaiveDateTime, diff --git a/crates/defguard_core/src/handlers/activity_log.rs b/crates/defguard_core/src/handlers/activity_log.rs index 5485d0d8c0..0bd0296688 100644 --- a/crates/defguard_core/src/handlers/activity_log.rs +++ b/crates/defguard_core/src/handlers/activity_log.rs @@ -176,7 +176,8 @@ pub async fn get_activity_log_events( .fetch_one(&appstate.pool) .await?; - let pagination = get_pagination_metadata(pagination.page, total_items as u32); + let pagination = + PaginationMeta::new(pagination.page, total_items as u32, DEFAULT_API_PAGE_SIZE); Ok(PaginatedApiResponse { data: events, @@ -258,21 +259,3 @@ fn apply_sorting(query_builder: &mut QueryBuilder, sorting: &SortParam .push(" ") .push(sorting.sort_order.to_string()); } - -/// Prepares pagination metadata that's part of the response -fn get_pagination_metadata(current_page: u32, total_items: u32) -> PaginationMeta { - let total_pages = (total_items).div_ceil(DEFAULT_API_PAGE_SIZE); - let next_page = if current_page < total_pages { - Some(current_page + 1) - } else { - None - }; - - PaginationMeta { - current_page, - page_size: DEFAULT_API_PAGE_SIZE, - total_items, - total_pages, - next_page, - } -} diff --git a/crates/defguard_core/src/handlers/location_stats.rs b/crates/defguard_core/src/handlers/location_stats.rs new file mode 100644 index 0000000000..0ebbc5bbb8 --- /dev/null +++ b/crates/defguard_core/src/handlers/location_stats.rs @@ -0,0 +1,220 @@ +use std::str::FromStr; + +use axum::extract::{Path, Query, State}; +use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc}; +use defguard_common::db::models::{ + WireguardNetwork, + wireguard::{ + DateTimeAggregation, LocationConnectedNetworkDevice, LocationConnectedUserStats, + WireguardNetworkStats, networks_stats, + }, +}; +use reqwest::StatusCode; + +use crate::{ + appstate::AppState, + auth::AdminRole, + error::WebError, + handlers::{ + ApiResponse, ApiResult, DEFAULT_API_PAGE_SIZE, + pagination::{PaginatedApiResponse, PaginatedApiResult, PaginationMeta, PaginationParams}, + }, +}; + +#[derive(Debug, Deserialize)] +pub(crate) struct QueryFrom { + from: Option, +} + +impl QueryFrom { + /// If `datetime` is Some, parses the date string, otherwise returns `DateTime` one hour ago. + fn parse_timestamp(&self) -> Result, StatusCode> { + Ok(match &self.from { + Some(from) => DateTime::::from_str(from).map_err(|_| StatusCode::BAD_REQUEST)?, + None => Utc::now() - TimeDelta::hours(1), + }) + } +} + +/// Returns appropriate aggregation level depending on the `from` date param +/// If `from` is >= than 6 hours ago, returns `Hour` aggregation +/// Otherwise returns `Minute` aggregation +fn get_aggregation(from: NaiveDateTime) -> Result { + // Use hourly aggregation for longer periods + let aggregation = match Utc::now().naive_utc() - from { + duration if duration >= TimeDelta::hours(6) => Ok(DateTimeAggregation::Hour), + duration if duration < TimeDelta::zero() => Err(StatusCode::BAD_REQUEST), + _ => Ok(DateTimeAggregation::Minute), + }?; + Ok(aggregation) +} + +/// Returns statistics for all locations +/// +/// # Returns +/// Returns an `WireguardNetworkStats` based on stats from all locations in requested time period +pub(crate) async fn locations_overview_stats( + _role: AdminRole, + State(appstate): State, + Query(query_from): Query, +) -> ApiResult { + debug!("Preparing networks overview stats"); + let from = query_from.parse_timestamp()?.naive_utc(); + let aggregation = get_aggregation(from)?; + let all_networks_stats = networks_stats(&appstate.pool, &from, &aggregation).await?; + debug!("Finished processing networks overview stats"); + Ok(ApiResponse::json(all_networks_stats, StatusCode::OK)) +} + +/// Returns statistics for requested location +/// +/// # Returns +/// Returns an `WireguardNetworkStats` based on requested location and time period +pub(crate) async fn location_stats( + _role: AdminRole, + State(appstate): State, + Path(network_id): Path, + Query(query_from): Query, +) -> ApiResult { + debug!("Displaying WireGuard network stats for location {network_id}"); + let Some(location) = WireguardNetwork::find_by_id(&appstate.pool, network_id).await? else { + return Err(WebError::ObjectNotFound(format!( + "Requested location ({network_id}) not found" + ))); + }; + let from = query_from.parse_timestamp()?.naive_utc(); + let aggregation: DateTimeAggregation = get_aggregation(from)?; + let stats: WireguardNetworkStats = location + .network_stats(&appstate.pool, &from, &aggregation) + .await?; + debug!("Displayed WireGuard network stats for location {network_id}"); + + Ok(ApiResponse::json(stats, StatusCode::OK)) +} + +/// Returns paginated list of connected users for a given location +/// +/// # Returns +/// Returns a paginated list of `LocationConnectedUser` objects for requested location and time period +pub(crate) async fn location_connected_users( + _role: AdminRole, + State(appstate): State, + Path(location_id): Path, + Query(query_from): Query, + pagination: Query, +) -> PaginatedApiResult { + debug!( + "Displaying connected users for location {location_id} with time window {query_from:?} and pagination {pagination:?}" + ); + + let Some(location) = WireguardNetwork::find_by_id(&appstate.pool, location_id).await? else { + return Err(WebError::ObjectNotFound(format!( + "Requested location ({location_id}) not found" + ))); + }; + let from = query_from.parse_timestamp()?.naive_utc(); + let aggregation = get_aggregation(from)?; + + let (connected_users, total_items) = location + .connected_users_stats( + &appstate.pool, + &from, + &aggregation, + pagination.page, + DEFAULT_API_PAGE_SIZE, + ) + .await?; + + let pagination = PaginationMeta::new(pagination.page, total_items, DEFAULT_API_PAGE_SIZE); + + Ok(PaginatedApiResponse { + data: connected_users, + pagination, + }) +} + +/// Returns paginated list of connected network devices for a given location +/// +/// # Returns +/// Returns a paginated list of `LocationConnectedNetworkDevice` objects for requested location and time period +pub(crate) async fn location_connected_network_devices( + _role: AdminRole, + State(appstate): State, + Path(location_id): Path, + Query(query_from): Query, + pagination: Query, +) -> PaginatedApiResult { + debug!( + "Displaying connected network devices for location {location_id} with time window {query_from:?} and pagination {pagination:?}" + ); + + let Some(location) = WireguardNetwork::find_by_id(&appstate.pool, location_id).await? else { + return Err(WebError::ObjectNotFound(format!( + "Requested location ({location_id}) not found" + ))); + }; + let from = query_from.parse_timestamp()?.naive_utc(); + let aggregation = get_aggregation(from)?; + + let (connected_network_devices, total_items) = location + .connected_network_devices_stats( + &appstate.pool, + &from, + &aggregation, + pagination.page, + DEFAULT_API_PAGE_SIZE, + ) + .await?; + + let pagination = PaginationMeta::new(pagination.page, total_items, DEFAULT_API_PAGE_SIZE); + + Ok(PaginatedApiResponse { + data: connected_network_devices, + pagination, + }) +} + +#[derive(Deserialize)] +pub(crate) struct ConnectedUserDevicesPath { + location_id: i64, + user_id: i64, +} + +/// Returns list of connected devices for a specific user at a given location +/// +/// # Returns +/// Returns a list of `LocationConnectedUserDevice` objects for requested user, location and time period +pub(crate) async fn location_connected_user_devices( + _role: AdminRole, + State(appstate): State, + Path(path): Path, + Query(query_from): Query, +) -> ApiResult { + debug!( + "Displaying connected devices for user {} at location {} with time window {query_from:?}", + path.user_id, path.location_id + ); + + let Some(location) = WireguardNetwork::find_by_id(&appstate.pool, path.location_id).await? + else { + return Err(WebError::ObjectNotFound(format!( + "Requested location ({}) not found", + path.location_id + ))); + }; + let from = query_from.parse_timestamp()?.naive_utc(); + let aggregation = get_aggregation(from)?; + + let connected_devices = location + .connected_user_devices_stats(&appstate.pool, path.user_id, &from, &aggregation) + .await?; + + debug!( + "Displayed {} connected devices for user {} at location {}", + connected_devices.len(), + path.user_id, + path.location_id + ); + + Ok(ApiResponse::json(connected_devices, StatusCode::OK)) +} diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index f455bb47ad..10a32f4477 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -34,6 +34,7 @@ pub mod auth; pub mod component_setup; pub(crate) mod forward_auth; pub(crate) mod group; +pub(crate) mod location_stats; pub mod mail; pub mod network_devices; pub mod openid_clients; diff --git a/crates/defguard_core/src/handlers/pagination.rs b/crates/defguard_core/src/handlers/pagination.rs index 6a4327a17c..d4d667bf2d 100644 --- a/crates/defguard_core/src/handlers/pagination.rs +++ b/crates/defguard_core/src/handlers/pagination.rs @@ -28,6 +28,26 @@ pub struct PaginationMeta { pub next_page: Option, } +impl PaginationMeta { + /// Prepares pagination metadata that's part of the response + pub fn new(current_page: u32, total_items: u32, page_size: u32) -> Self { + let total_pages = (total_items).div_ceil(page_size); + let next_page = if current_page < total_pages { + Some(current_page + 1) + } else { + None + }; + + Self { + current_page, + page_size, + total_items, + total_pages, + next_page, + } + } +} + pub type PaginatedApiResult = Result, WebError>; #[derive(Debug, Serialize)] diff --git a/crates/defguard_core/src/handlers/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index 286d50fa56..2366d0e8ce 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -1,13 +1,10 @@ -use std::{ - collections::{HashMap, HashSet}, - str::FromStr, -}; +use std::collections::{HashMap, HashSet}; use axum::{ - extract::{Json, Path, Query, State}, + extract::{Json, Path, State}, http::StatusCode, }; -use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc}; +use chrono::NaiveDateTime; use defguard_common::{ csv::AsCsv, db::{ @@ -16,11 +13,7 @@ use defguard_common::{ Device, DeviceConfig, DeviceNetworkInfo, DeviceType, WireguardNetwork, device::{AddDevice, DeviceInfo, ModifyDevice, WireguardNetworkDevice}, gateway::Gateway, - wireguard::{ - DateTimeAggregation, LocationMfaMode, MappedDevice, ServiceLocationMode, - WireguardDeviceStatsRow, WireguardNetworkStats, WireguardUserStatsRow, - networks_stats, - }, + wireguard::{LocationMfaMode, MappedDevice, ServiceLocationMode}, }, }, utils::{parse_address_list, parse_network_address_list}, @@ -1396,114 +1389,3 @@ pub(crate) async fn download_config( ))) } } - -/// Returns appropriate aggregation level depending on the `from` date param -/// If `from` is >= than 6 hours ago, returns `Hour` aggregation -/// Otherwise returns `Minute` aggregation -fn get_aggregation(from: NaiveDateTime) -> Result { - // Use hourly aggregation for longer periods - let aggregation = match Utc::now().naive_utc() - from { - duration if duration >= TimeDelta::hours(6) => Ok(DateTimeAggregation::Hour), - duration if duration < TimeDelta::zero() => Err(StatusCode::BAD_REQUEST), - _ => Ok(DateTimeAggregation::Minute), - }?; - Ok(aggregation) -} - -#[derive(Deserialize)] -pub(crate) struct QueryFrom { - from: Option, -} - -impl QueryFrom { - /// If `datetime` is Some, parses the date string, otherwise returns `DateTime` one hour ago. - fn parse_timestamp(&self) -> Result, StatusCode> { - Ok(match &self.from { - Some(from) => DateTime::::from_str(from).map_err(|_| StatusCode::BAD_REQUEST)?, - None => Utc::now() - TimeDelta::hours(1), - }) - } -} - -#[derive(Serialize)] -pub(crate) struct DevicesStatsResponse { - user_devices: Vec, - network_devices: Vec, -} - -/// Returns network statistics for users and their devices -/// -/// # Returns -/// Returns an `DevicesStatsResponse` for requested network and time period -pub(crate) async fn devices_stats( - _role: AdminRole, - State(appstate): State, - Path(network_id): Path, - Query(query_from): Query, -) -> ApiResult { - debug!("Displaying WireGuard user stats for network {network_id}"); - let Some(network) = WireguardNetwork::find_by_id(&appstate.pool, network_id).await? else { - return Err(WebError::ObjectNotFound(format!( - "Requested network ({network_id}) not found", - ))); - }; - let from = query_from.parse_timestamp()?.naive_utc(); - let aggregation = get_aggregation(from)?; - let user_devices_stats = network - .user_stats(&appstate.pool, &from, &aggregation) - .await?; - let network_devices_stats = network - .distinct_device_stats(&appstate.pool, &from, &aggregation, DeviceType::Network) - .await?; - let response = DevicesStatsResponse { - user_devices: user_devices_stats, - network_devices: network_devices_stats, - }; - - debug!("Displayed WireGuard user stats for network {network_id}"); - - Ok(ApiResponse::json(response, StatusCode::OK)) -} - -/// Returns statistics for requested network -/// -/// # Returns -/// Returns an `WireguardNetworkStats` based on requested network and time period -pub(crate) async fn network_stats( - _role: AdminRole, - State(appstate): State, - Path(network_id): Path, - Query(query_from): Query, -) -> ApiResult { - debug!("Displaying WireGuard network stats for location {network_id}"); - let Some(location) = WireguardNetwork::find_by_id(&appstate.pool, network_id).await? else { - return Err(WebError::ObjectNotFound(format!( - "Requested location ({network_id}) not found" - ))); - }; - let from = query_from.parse_timestamp()?.naive_utc(); - let aggregation: DateTimeAggregation = get_aggregation(from)?; - let stats: WireguardNetworkStats = location - .network_stats(&appstate.pool, &from, &aggregation) - .await?; - debug!("Displayed WireGuard network stats for network {network_id}"); - - Ok(ApiResponse::json(stats, StatusCode::OK)) -} - -/// Returns statistics for all networks -/// -/// # Returns -/// Returns an `WireguardNetworkStats` based on stats from all networks in requested time period -pub(crate) async fn networks_overview_stats( - _role: AdminRole, - State(appstate): State, - Query(query_from): Query, -) -> ApiResult { - debug!("Preparing networks overview stats"); - let from = query_from.parse_timestamp()?.naive_utc(); - let aggregation = get_aggregation(from)?; - let all_networks_stats = networks_stats(&appstate.pool, &from, &aggregation).await?; - debug!("Finished processing networks overview stats"); - Ok(ApiResponse::json(all_networks_stats, StatusCode::OK)) -} diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 00b8e07431..1b900990a2 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -48,7 +48,7 @@ use handlers::{ rename_authentication_key, }, updates::check_new_version, - wireguard::{all_gateways_status, networks_overview_stats}, + wireguard::all_gateways_status, yubikey::{delete_yubikey, rename_yubikey}, }; use ipnetwork::IpNetwork; @@ -121,6 +121,10 @@ use crate::{ add_group_member, create_group, delete_group, get_group, list_groups, modify_group, remove_group_member, }, + location_stats::{ + location_connected_network_devices, location_connected_user_devices, + location_connected_users, location_stats, locations_overview_stats, + }, mail::{send_support_data, test_mail}, openid_clients::{ add_openid_client, change_openid_client, change_openid_client_state, @@ -150,9 +154,9 @@ use crate::{ }, wireguard::{ add_device, add_user_devices, change_gateway, create_network, delete_device, - delete_network, devices_stats, download_config, gateway_status, get_device, - import_network, list_devices, list_networks, list_user_devices, modify_device, - modify_network, network_details, network_stats, remove_gateway, + delete_network, download_config, gateway_status, get_device, import_network, + list_devices, list_networks, list_user_devices, modify_device, modify_network, + network_details, remove_gateway, }, worker::{create_job, create_worker_token, job_status, list_workers, remove_worker}, }, @@ -505,7 +509,7 @@ pub fn build_webapp( ) .route("/network", post(create_network).get(list_networks)) .route("/network/import", post(import_network)) - .route("/network/stats", get(networks_overview_stats)) + .route("/network/stats", get(locations_overview_stats)) .route("/network/gateways", get(all_gateways_status)) .route( "/network/{network_id}", @@ -528,8 +532,19 @@ pub fn build_webapp( "/network/{network_id}/device/{device_id}/config", get(download_config), ) - .route("/network/{network_id}/stats/users", get(devices_stats)) - .route("/network/{network_id}/stats", get(network_stats)) + .route("/network/{network_id}/stats", get(location_stats)) + .route( + "/network/{location_id}/stats/connected_users", + get(location_connected_users), + ) + .route( + "/network/{location_id}/stats/connected_users/{user_id}/devices", + get(location_connected_user_devices), + ) + .route( + "/network/{location_id}/stats/connected_network_devices", + get(location_connected_network_devices), + ) .route( "/network/{location_id}/snat", get(list_snat_bindings).post(create_snat_binding), diff --git a/crates/defguard_core/tests/integration/api/location_stats.rs b/crates/defguard_core/tests/integration/api/location_stats.rs new file mode 100644 index 0000000000..93ba900dd0 --- /dev/null +++ b/crates/defguard_core/tests/integration/api/location_stats.rs @@ -0,0 +1,219 @@ +use std::net::{IpAddr, Ipv4Addr}; + +use chrono::{Duration, Utc}; +use defguard_common::db::{ + Id, + models::{ + Device, DeviceType, WireguardNetwork, device::WireguardNetworkDevice, gateway::Gateway, + vpn_client_session::VpnClientSession, vpn_session_stats::VpnSessionStats, + }, +}; +use defguard_core::handlers::Auth; +use reqwest::StatusCode; +use serde::Deserialize; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + +use super::common::{make_network, make_test_client, setup_pool}; + +static DATE_FORMAT: &str = "%Y-%m-%dT%H:%M:00Z"; + +#[derive(Deserialize)] +struct PaginatedResponse { + data: Vec, +} + +#[derive(Deserialize)] +struct ConnectedUserResponse { + user_id: i64, + connected_devices_count: u16, + public_ip: String, + vpn_ips: Vec, + total_upload: i64, + total_download: i64, +} + +#[derive(Deserialize)] +struct ConnectedNetworkDeviceResponse { + device_id: i64, + device_name: String, + public_ip: String, + vpn_ips: Vec, + total_upload: i64, + total_download: i64, +} + +#[derive(Deserialize)] +struct ConnectedUserDeviceResponse { + device_id: i64, + device_name: String, + public_ip: String, + vpn_ips: Vec, + total_upload: i64, + total_download: i64, +} + +#[sqlx::test] +async fn test_location_connected_devices_stats(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (client, client_state) = make_test_client(pool).await; + + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + let response = make_network(&client, "network").await; + let network: WireguardNetwork = response.json().await; + + let gateway = Gateway::new(network.id, "http://localhost:50055", "gateway") + .save(&client_state.pool) + .await + .unwrap(); + + let user_device = Device::new( + "user-device".to_string(), + "user-device-pubkey".to_string(), + client_state.test_user.id, + DeviceType::User, + None, + true, + ) + .save(&client_state.pool) + .await + .unwrap(); + + let network_device = Device::new( + "network-device".to_string(), + "network-device-pubkey".to_string(), + client_state.test_user.id, + DeviceType::Network, + None, + true, + ) + .save(&client_state.pool) + .await + .unwrap(); + + let user_ip = IpAddr::V4(Ipv4Addr::new(10, 1, 1, 2)); + let network_ip = IpAddr::V4(Ipv4Addr::new(10, 1, 1, 3)); + + WireguardNetworkDevice::new(network.id, user_device.id, vec![user_ip]) + .insert(&client_state.pool) + .await + .unwrap(); + WireguardNetworkDevice::new(network.id, network_device.id, vec![network_ip]) + .insert(&client_state.pool) + .await + .unwrap(); + + let now = Utc::now().naive_utc(); + let user_session = VpnClientSession::new( + network.id, + client_state.test_user.id, + user_device.id, + Some(now), + None, + ) + .save(&client_state.pool) + .await + .unwrap(); + let network_session = VpnClientSession::new( + network.id, + client_state.test_user.id, + network_device.id, + Some(now), + None, + ) + .save(&client_state.pool) + .await + .unwrap(); + + VpnSessionStats::new( + user_session.id, + gateway.id, + now, + now, + "1.1.1.1:51820".to_string(), + 1000, + 2000, + 1000, + 2000, + ) + .save(&client_state.pool) + .await + .unwrap(); + + VpnSessionStats::new( + network_session.id, + gateway.id, + now, + now, + "2.2.2.2:51820".to_string(), + 3000, + 4000, + 3000, + 4000, + ) + .save(&client_state.pool) + .await + .unwrap(); + + let from = (Utc::now().naive_utc() - Duration::minutes(10)).format(DATE_FORMAT); + + let response = client + .get(format!( + "/api/v1/network/{}/stats/connected_users?from={}", + network.id, from + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let users = response + .json::>() + .await; + assert_eq!(users.data.len(), 1); + let user = &users.data[0]; + assert_eq!(user.user_id, client_state.test_user.id); + assert_eq!(user.connected_devices_count, 1); + assert_eq!(user.public_ip, "1.1.1.1"); + assert_eq!(user.vpn_ips, vec![user_ip]); + assert_eq!(user.total_upload, 1000); + assert_eq!(user.total_download, 2000); + + let response = client + .get(format!( + "/api/v1/network/{}/stats/connected_network_devices?from={}", + network.id, from + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let devices = response + .json::>() + .await; + assert_eq!(devices.data.len(), 1); + let device = &devices.data[0]; + assert_eq!(device.device_id, network_device.id); + assert_eq!(device.device_name, "network-device"); + assert_eq!(device.public_ip, "2.2.2.2"); + assert_eq!(device.vpn_ips, vec![network_ip]); + assert_eq!(device.total_upload, 3000); + assert_eq!(device.total_download, 4000); + + let response = client + .get(format!( + "/api/v1/network/{}/stats/connected_users/{}/devices?from={}", + network.id, client_state.test_user.id, from + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let devices = response.json::>().await; + assert_eq!(devices.len(), 1); + let device = &devices[0]; + assert_eq!(device.device_id, user_device.id); + assert_eq!(device.device_name, "user-device"); + assert_eq!(device.public_ip, "1.1.1.1"); + assert_eq!(device.vpn_ips, vec![user_ip]); + assert_eq!(device.total_upload, 1000); + assert_eq!(device.total_download, 2000); +} diff --git a/crates/defguard_core/tests/integration/api/mod.rs b/crates/defguard_core/tests/integration/api/mod.rs index 4ca59b3356..846ed95a47 100644 --- a/crates/defguard_core/tests/integration/api/mod.rs +++ b/crates/defguard_core/tests/integration/api/mod.rs @@ -6,6 +6,7 @@ mod enrollment; mod enterprise_settings; mod forward_auth; mod group; +mod location_stats; mod oauth; mod openid; mod openid_login; diff --git a/flake.lock b/flake.lock index f719372873..ee7ca9b5fd 100644 --- a/flake.lock +++ b/flake.lock @@ -32,11 +32,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1770562336, - "narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=", + "lastModified": 1771369470, + "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "d6c71932130818840fc8fe9509cf50be8c64634f", + "rev": "0182a361324364ae3f436a63005877674cf45efb", "type": "github" }, "original": { @@ -74,11 +74,11 @@ ] }, "locked": { - "lastModified": 1770865833, - "narHash": "sha256-oiARqnlvaW6pVGheVi4ye6voqCwhg5hCcGish2ZvQzI=", + "lastModified": 1771470520, + "narHash": "sha256-PvytHcaYN5cPUll7FB70mXv1rRsIBRmu47fFfq3haxA=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "c8cfbe26238638e2f3a2c0ae7e8d240f5e4ded85", + "rev": "a1d4cc1f264c45d3745af0d2ca5e59d460e58777", "type": "github" }, "original": { diff --git a/web/package.json b/web/package.json index 12eec1dd54..f47259ca05 100644 --- a/web/package.json +++ b/web/package.json @@ -34,13 +34,13 @@ "humanize-duration": "^3.33.2", "ipaddr.js": "^2.3.0", "lodash-es": "^4.17.23", - "motion": "^12.34.1", + "motion": "^12.34.2", "qrcode.react": "^4.2.0", "qs": "^6.15.0", "radashi": "^12.7.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-intersection-observer": "^10.0.2", + "react-intersection-observer": "^10.0.3", "react-loading-skeleton": "^3.5.0", "react-markdown": "^10.1.0", "recharts": "^3.7.0", @@ -61,7 +61,7 @@ "@types/byte-size": "^8.1.2", "@types/humanize-duration": "^3.27.4", "@types/lodash-es": "^4.17.12", - "@types/node": "^25.2.3", + "@types/node": "^25.3.0", "@types/qs": "^6.14.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1703980c38..b85255b82f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -72,8 +72,8 @@ importers: specifier: ^4.17.23 version: 4.17.23 motion: - specifier: ^12.34.1 - version: 12.34.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^12.34.2 + version: 12.34.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) qrcode.react: specifier: ^4.2.0 version: 4.2.0(react@19.2.4) @@ -90,8 +90,8 @@ importers: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) react-intersection-observer: - specifier: ^10.0.2 - version: 10.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^10.0.3 + version: 10.0.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-loading-skeleton: specifier: ^3.5.0 version: 3.5.0(react@19.2.4) @@ -122,7 +122,7 @@ importers: version: 2.4.2 '@tanstack/devtools-vite': specifier: ^0.5.1 - version: 0.5.1(vite@7.3.1(@types/node@25.2.3)(sass@1.97.3)(tsx@4.21.0)) + version: 0.5.1(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0)) '@tanstack/react-devtools': specifier: ^0.9.6 version: 0.9.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9) @@ -134,7 +134,7 @@ importers: version: 1.161.1(@tanstack/react-router@1.161.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.161.1)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-plugin': specifier: ^1.161.1 - version: 1.161.1(@tanstack/react-router@1.161.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.2.3)(sass@1.97.3)(tsx@4.21.0)) + version: 1.161.1(@tanstack/react-router@1.161.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0)) '@types/byte-size': specifier: ^8.1.2 version: 8.1.2 @@ -145,8 +145,8 @@ importers: specifier: ^4.17.12 version: 4.17.12 '@types/node': - specifier: ^25.2.3 - version: 25.2.3 + specifier: ^25.3.0 + version: 25.3.0 '@types/qs': specifier: ^6.14.0 version: 6.14.0 @@ -158,7 +158,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react-swc': specifier: ^4.2.3 - version: 4.2.3(vite@7.3.1(@types/node@25.2.3)(sass@1.97.3)(tsx@4.21.0)) + version: 4.2.3(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0)) autoprefixer: specifier: ^10.4.24 version: 10.4.24(postcss@8.5.6) @@ -188,10 +188,10 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.2.3)(sass@1.97.3)(tsx@4.21.0) + version: 7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0) vite-plugin-image-optimizer: specifier: ^2.0.3 - version: 2.0.3(sharp@0.34.5)(vite@7.3.1(@types/node@25.2.3)(sass@1.97.3)(tsx@4.21.0)) + version: 2.0.3(sharp@0.34.5)(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0)) packages: @@ -306,28 +306,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.2': resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.2': resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.2': resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.2': resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==} @@ -596,105 +592,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -803,42 +783,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -925,79 +899,66 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -1126,28 +1087,24 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.15.11': resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-x64-gnu@1.15.11': resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.15.11': resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.15.11': resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==} @@ -1410,8 +1367,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@25.2.3': - resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} + '@types/node@25.3.0': + resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} @@ -1866,8 +1823,8 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - framer-motion@12.34.1: - resolution: {integrity: sha512-kcZyNaYQfvE2LlH6+AyOaJAQV4rGp5XbzfhsZpiSZcwDMfZUHhuxLWeyRzf5I7jip3qKRpuimPA9pXXfr111kQ==} + framer-motion@12.34.2: + resolution: {integrity: sha512-CcnYTzbRybm1/OE8QLXfXI8gR1cx5T4dF3D2kn5IyqsGNeLAKl2iFHb2BzFyXBGqESntDt6rPYl4Jhrb7tdB8g==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -1892,8 +1849,8 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} - get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} get-intrinsic@1.3.0: @@ -1951,8 +1908,8 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hashery@1.4.0: - resolution: {integrity: sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==} + hashery@1.5.0: + resolution: {integrity: sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==} engines: {node: '>=20'} hasown@2.0.2: @@ -2270,14 +2227,14 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - motion-dom@12.34.1: - resolution: {integrity: sha512-SC7ZC5dRcGwku2g7EsPvI4q/EzHumUbqsDNumBmZTLFg+goBO5LTJvDu9MAxx+0mtX4IA78B2be/A3aRjY0jnw==} + motion-dom@12.34.2: + resolution: {integrity: sha512-n7gknp7gHcW7DUcmet0JVPLVHmE3j9uWwDp5VbE3IkCNnW5qdu0mOhjNYzXMkrQjrgr+h6Db3EDM2QBhW2qNxQ==} motion-utils@12.29.2: resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} - motion@12.34.1: - resolution: {integrity: sha512-N9RVNGn/NSo85OgHX1wGaUWHvReuQ7dZUwuQRhHyzY2wfVOvY3cEgn0Mw4NXOsXMHL/y7EYuzA+b59PYI6EejA==} + motion@12.34.2: + resolution: {integrity: sha512-QAthwCtW6N0TpZ+bBmBMzdwuftoay2yFV2DT44jRcUQhPbFPdAX+pjzmIUNM3sMYDD5OAraJagRGAKE8q5OsmA==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2405,8 +2362,8 @@ packages: peerDependencies: react: ^19.2.4 - react-intersection-observer@10.0.2: - resolution: {integrity: sha512-lAMzxVWrBko6SLd1jx6l84fVrzJu91hpxHlvD2as2Wec9mDCjdYXwc5xNOFBchpeBir0Y7AGBW+C/AYMa7CSFg==} + react-intersection-observer@10.0.3: + resolution: {integrity: sha512-luICLMbs0zxTO/70Zy7K5jOXkABPEVSAF8T3FdZUlctsrIaPLmx8TZe2SSA+CY2HGWfz2INyNTnp82pxNNsShA==} peerDependencies: react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -2610,8 +2567,8 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@8.1.1: - resolution: {integrity: sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==} + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} engines: {node: '>=20'} stringify-entities@4.0.4: @@ -2788,8 +2745,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} unicorn-magic@0.4.0: resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} @@ -3126,7 +3083,7 @@ snapshots: '@cacheable/utils@2.3.4': dependencies: - hashery: 1.4.0 + hashery: 1.5.0 keyv: 5.6.0 '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -3406,7 +3363,7 @@ snapshots: '@keyv/bigmap@1.3.1(keyv@5.6.0)': dependencies: - hashery: 1.4.0 + hashery: 1.5.0 hookified: 1.15.1 keyv: 5.6.0 @@ -3748,7 +3705,7 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-vite@0.5.1(vite@7.3.1(@types/node@25.2.3)(sass@1.97.3)(tsx@4.21.0))': + '@tanstack/devtools-vite@0.5.1(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.1 @@ -3760,7 +3717,7 @@ snapshots: chalk: 5.6.2 launch-editor: 2.13.0 picomatch: 4.0.3 - vite: 7.3.1(@types/node@25.2.3)(sass@1.97.3)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0) transitivePeerDependencies: - bufferutil - supports-color @@ -3901,7 +3858,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.161.1(@tanstack/react-router@1.161.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.2.3)(sass@1.97.3)(tsx@4.21.0))': + '@tanstack/router-plugin@1.161.1(@tanstack/react-router@1.161.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -3918,7 +3875,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.161.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@25.2.3)(sass@1.97.3)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -3998,9 +3955,9 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@25.2.3': + '@types/node@25.3.0': dependencies: - undici-types: 7.16.0 + undici-types: 7.18.2 '@types/qs@6.14.0': {} @@ -4025,11 +3982,11 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react-swc@4.2.3(vite@7.3.1(@types/node@25.2.3)(sass@1.97.3)(tsx@4.21.0))': + '@vitejs/plugin-react-swc@4.2.3(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 '@swc/core': 1.15.11 - vite: 7.3.1(@types/node@25.2.3)(sass@1.97.3)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0) transitivePeerDependencies: - '@swc/helpers' @@ -4411,9 +4368,9 @@ snapshots: fraction.js@5.3.4: {} - framer-motion@12.34.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + framer-motion@12.34.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - motion-dom: 12.34.1 + motion-dom: 12.34.2 motion-utils: 12.29.2 tslib: 2.8.1 optionalDependencies: @@ -4427,7 +4384,7 @@ snapshots: gensync@1.0.0-beta.2: {} - get-east-asian-width@1.4.0: {} + get-east-asian-width@1.5.0: {} get-intrinsic@1.3.0: dependencies: @@ -4492,7 +4449,7 @@ snapshots: dependencies: has-symbols: 1.1.0 - hashery@1.4.0: + hashery@1.5.0: dependencies: hookified: 1.15.1 @@ -4935,15 +4892,15 @@ snapshots: dependencies: mime-db: 1.52.0 - motion-dom@12.34.1: + motion-dom@12.34.2: dependencies: motion-utils: 12.29.2 motion-utils@12.29.2: {} - motion@12.34.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + motion@12.34.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - framer-motion: 12.34.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + framer-motion: 12.34.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tslib: 2.8.1 optionalDependencies: react: 19.2.4 @@ -5047,7 +5004,7 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-intersection-observer@10.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-intersection-observer@10.0.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 optionalDependencies: @@ -5322,9 +5279,9 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@8.1.1: + string-width@8.2.0: dependencies: - get-east-asian-width: 1.4.0 + get-east-asian-width: 1.5.0 strip-ansi: 7.1.2 stringify-entities@4.0.4: @@ -5422,7 +5379,7 @@ snapshots: postcss-safe-parser: 7.0.1(postcss@8.5.6) postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - string-width: 8.1.1 + string-width: 8.2.0 supports-hyperlinks: 4.4.0 svg-tags: 1.0.0 table: 6.9.0 @@ -5571,7 +5528,7 @@ snapshots: typescript@5.9.3: {} - undici-types@7.16.0: {} + undici-types@7.18.2: {} unicorn-magic@0.4.0: {} @@ -5665,15 +5622,15 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-image-optimizer@2.0.3(sharp@0.34.5)(vite@7.3.1(@types/node@25.2.3)(sass@1.97.3)(tsx@4.21.0)): + vite-plugin-image-optimizer@2.0.3(sharp@0.34.5)(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0)): dependencies: ansi-colors: 4.1.3 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.2.3)(sass@1.97.3)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0) optionalDependencies: sharp: 0.34.5 - vite@7.3.1(@types/node@25.2.3)(sass@1.97.3)(tsx@4.21.0): + vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -5682,7 +5639,7 @@ snapshots: rollup: 4.57.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.2.3 + '@types/node': 25.3.0 fsevents: 2.3.3 sass: 1.97.3 tsx: 4.21.0 diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx index a3f9e65b5d..a3130d8b8f 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx @@ -1,59 +1,78 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useParams, useSearch } from '@tanstack/react-router'; import { createColumnHelper, getCoreRowModel, - getSortedRowModel, + type SortingState, useReactTable, } from '@tanstack/react-table'; -import { sumBy } from 'lodash-es'; -import { useMemo } from 'react'; -import type { DeviceStats, LocationDevicesStats } from '../../shared/api/types'; +import { useMemo, useState } from 'react'; +import api from '../../shared/api/api'; +import type { LocationConnectedNetworkDevice } from '../../shared/api/types'; +import { TableSkeleton } from '../../shared/components/skeleton/TableSkeleton/TableSkeleton'; import { TableValuesListCell } from '../../shared/components/TableValuesListCell/TableValuesListCell'; import { EmptyStateFlexible } from '../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; -import { tableActionColumnSize } from '../../shared/defguard-ui/components/table/consts'; import { TableBody } from '../../shared/defguard-ui/components/table/TableBody/TableBody'; import { TableCell } from '../../shared/defguard-ui/components/table/TableCell/TableCell'; -import { mapTransferToChart, type TransferChartData } from '../../shared/utils/stats'; import { ConnectionDurationCell } from './components/ConnectionDurationCell'; import { DeviceTrafficChartCell } from './components/DeviceTrafficChartCell/DeviceTrafficChartCell'; -type RowData = Omit & { - stats: TransferChartData[]; - upload: number; - download: number; -}; +const columnHelper = createColumnHelper(); + +export const LocationOverviewNetworkDevicesTable = () => { + const search = useSearch({ from: '/_authorized/_default/vpn-overview/$locationId' }); + const { locationId } = useParams({ + from: '/_authorized/_default/vpn-overview/$locationId', + }); + + const { data, fetchNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({ + queryKey: [ + 'network', + Number(locationId), + 'stats', + 'connected_network_devices', + search.period, + ], + initialPageParam: 1, + queryFn: ({ pageParam }) => + api.location.getLocationConnectedNetworkDevices({ + id: Number(locationId), + from: search.period, + page: pageParam, + }), + getNextPageParam: (lastPage) => lastPage?.pagination.next_page, + getPreviousPageParam: (page) => { + if (page.pagination.current_page !== 1) { + return page.pagination.current_page - 1; + } + return null; + }, + }); + + const flatQueryData = useMemo(() => data?.pages.flat() ?? null, [data?.pages]); + const flatData = useMemo( + () => flatQueryData?.flatMap((page) => page.data) ?? [], + [flatQueryData], + ); -const columnHelper = createColumnHelper(); + const lastItem = flatQueryData ? flatQueryData[flatQueryData?.length - 1] : null; + const pagination = lastItem ? lastItem.pagination : null; -export const LocationOverviewNetworkDevicesTable = ({ - data, -}: { - data: LocationDevicesStats['network_devices']; -}) => { - const mappedData = useMemo((): RowData[] => { - const res: RowData[] = data.map((device) => ({ - ...device, - stats: mapTransferToChart(device.stats), - upload: sumBy(device.stats, (s) => s.upload), - download: sumBy(device.stats, (s) => s.download), - })); - return res; - }, [data]); + const [sortState, setSortState] = useState([ + { + id: 'device_name', + desc: false, + }, + ]); const columns = useMemo( () => [ - columnHelper.display({ - id: 'empty', - header: '', - size: tableActionColumnSize, - cell: () => , - }), - columnHelper.accessor('name', { + columnHelper.accessor('device_name', { header: 'Device name', - sortingFn: 'text', - enableSorting: true, meta: { flex: true, }, + enableSorting: true, cell: (info) => ( {info.getValue()} @@ -69,7 +88,7 @@ export const LocationOverviewNetworkDevicesTable = ({ ), }), - columnHelper.accessor('wireguard_ips', { + columnHelper.accessor('vpn_ips', { size: 250, header: 'VPN IP', cell: (info) => , @@ -85,11 +104,12 @@ export const LocationOverviewNetworkDevicesTable = ({ size: 500, cell: (info) => { const row = info.row.original; + const { stats, total_download, total_upload } = row; return ( ); }, @@ -99,25 +119,23 @@ export const LocationOverviewNetworkDevicesTable = ({ ); const table = useReactTable({ - initialState: { - sorting: [ - { - id: 'name', - desc: false, - }, - ], + state: { + sorting: sortState, }, columns, - data: mappedData, + data: flatData, getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - enableExpanding: false, + onSortingChange: setSortState, + manualSorting: true, enableSorting: true, + enableExpanding: false, enableRowSelection: false, columnResizeMode: 'onChange', }); - if (data.length === 0) + if (isLoading) return ; + + if (flatData.length === 0) return ( ); - return ; + return ( + { + fetchNextPage(); + }} + hasNextPage={pagination?.next_page !== null} + /> + ); }; diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx index 6e1dd4bfee..c3f7efb423 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx @@ -4,7 +4,6 @@ import './style.scss'; import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; import { useMemo, useState } from 'react'; import api from '../../shared/api/api'; -import type { LocationDevicesStats } from '../../shared/api/types'; import { GatewaysStatusBadge } from '../../shared/components/GatewaysStatusBadge/GatewaysStatusBadge'; import { OverviewPeriodSelect } from '../../shared/components/OverviewPeriodSelect/OverviewPeriodSelect'; import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; @@ -42,18 +41,7 @@ export const LocationOverviewPage = () => { id: Number(locationId), from: search.period, }), - queryKey: ['network', Number(locationId), 'stats'], - select: (resp) => resp.data, - refetchInterval: 30_000, - }); - - const { data: locationDevicesStats } = useQuery({ - queryFn: () => - api.location.getLocationDevicesStats({ - id: Number(locationId), - from: search.period, - }), - queryKey: ['network', Number(locationId), 'stats', 'users'], + queryKey: ['network', Number(locationId), 'stats', search.period], select: (resp) => resp.data, refetchInterval: 30_000, }); @@ -85,12 +73,12 @@ export const LocationOverviewPage = () => { )} - {isPresent(locationDevicesStats) && } + ); }; -const DevicesSection = ({ stats }: { stats: LocationDevicesStats }) => { +const DevicesSection = () => { const [selected, setSelected] = useState<'users' | 'devices'>('users'); const tabItems = useMemo( @@ -112,17 +100,15 @@ const DevicesSection = ({ stats }: { stats: LocationDevicesStats }) => { <>

- {selected === 'users' && "Connected user's devices"} + {selected === 'users' && "Connected users' devices"} {selected === 'devices' && 'Connected network devices'}

- {selected === 'users' && } - {selected === 'devices' && ( - - )} + {selected === 'users' && } + {selected === 'devices' && } ); }; diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx index 92317a7b64..99f1055210 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx @@ -1,3 +1,5 @@ +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { useParams, useSearch } from '@tanstack/react-router'; import { createColumnHelper, getCoreRowModel, @@ -7,39 +9,24 @@ import { useReactTable, } from '@tanstack/react-table'; import clsx from 'clsx'; -import { orderBy, sumBy } from 'lodash-es'; import { useCallback, useMemo, useState } from 'react'; -import type { DeviceStats, LocationUserDeviceStats } from '../../shared/api/types'; +import Skeleton from 'react-loading-skeleton'; +import api from '../../shared/api/api'; +import type { LocationConnectedUser } from '../../shared/api/types'; +import { TableSkeleton } from '../../shared/components/skeleton/TableSkeleton/TableSkeleton'; import { TableValuesListCell } from '../../shared/components/TableValuesListCell/TableValuesListCell'; import { Avatar } from '../../shared/defguard-ui/components/Avatar/Avatar'; import { EmptyStateFlexible } from '../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; import { Icon } from '../../shared/defguard-ui/components/Icon'; import { TableBody } from '../../shared/defguard-ui/components/table/TableBody/TableBody'; import { TableCell } from '../../shared/defguard-ui/components/table/TableCell/TableCell'; +import { TableFlexCell } from '../../shared/defguard-ui/components/table/TableFlexCell/TableFlexCell'; import { TableRowContainer } from '../../shared/defguard-ui/components/table/TableRowContainer/TableRowContainer'; import { ThemeVariable } from '../../shared/defguard-ui/types'; -import { mapTransferToChart, type TransferChartData } from '../../shared/utils/stats'; import { ConnectionDurationCell } from './components/ConnectionDurationCell'; import { DeviceTrafficChartCell } from './components/DeviceTrafficChartCell/DeviceTrafficChartCell'; -import { overviewTableUtils } from './utils/overviewTableUtils'; -type TableDevice = Omit & { - stats: TransferChartData[]; - upload: number; - download: number; -}; - -type RowData = { - firstName: string; - lastName: string; - devices: TableDevice[]; -} & TableDevice; - -type Props = { - data: LocationUserDeviceStats[]; -}; - -const columnHelper = createColumnHelper(); +const columnHelper = createColumnHelper(); const expansionHeaders = [ 'Device name', @@ -50,37 +37,132 @@ const expansionHeaders = [ 'Device traffic', ]; -export const LocationOverviewUsersTable = ({ data }: Props) => { - const mapped = useMemo( - () => - data.map(({ user, devices }): RowData => { - const oldest = orderBy(devices, (d) => d.connected_at, ['asc'])[0]; - const formattedDevices = devices.map((d) => ({ - ...d, - stats: mapTransferToChart(d.stats), - download: sumBy(d.stats, (s) => s.download), - upload: sumBy(d.stats, (s) => s.upload), - })); - - const mergedStats = overviewTableUtils.mergeStats(devices); - - return { - id: user.id, - devices: formattedDevices, - name: `${user.first_name} ${user.last_name}`, - firstName: user.first_name, - lastName: user.last_name, - stats: mergedStats, - download: sumBy(mergedStats, (s) => s.download), - upload: sumBy(mergedStats, (s) => s.upload), - connected_at: oldest.connected_at, - public_ip: oldest.public_ip, - wireguard_ips: oldest.wireguard_ips, - }; +type ExpandedUserDevicesRowProps = { + userId: number; + locationId: number; + period: number | undefined; + isLast: boolean; +}; + +const ExpandedUserDevicesRow = ({ + userId, + locationId, + period, + isLast, +}: ExpandedUserDevicesRowProps) => { + const { data: devices, isLoading } = useQuery({ + queryKey: [ + 'network', + locationId, + 'stats', + 'connected_users', + userId, + 'devices', + period, + ], + queryFn: () => + api.location.getLocationConnectedUserDevices({ + locationId, + userId, + from: period, + }), + }); + + if (isLoading) { + return ( + + + + + + + + + + + + + + + + + + + + + ); + } + + if (!devices || devices.length === 0) { + return null; + } + + return ( + <> + {devices.map((device, index) => ( + + + + + + + {device.device_name} + + + {device.public_ip} + + + + + + + + ))} + + ); +}; + +export const LocationOverviewUsersTable = () => { + const search = useSearch({ from: '/_authorized/_default/vpn-overview/$locationId' }); + const { locationId } = useParams({ + from: '/_authorized/_default/vpn-overview/$locationId', + }); + + const { data, fetchNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({ + queryKey: ['network', Number(locationId), 'stats', 'connected_users', search.period], + initialPageParam: 1, + queryFn: ({ pageParam }) => + api.location.getLocationConnectedUsers({ + id: Number(locationId), + from: search.period, + page: pageParam, }), - [data], + getNextPageParam: (lastPage) => lastPage?.pagination.next_page, + getPreviousPageParam: (page) => { + if (page.pagination.current_page !== 1) { + return page.pagination.current_page - 1; + } + return null; + }, + }); + + const flatQueryData = useMemo(() => data?.pages.flat() ?? null, [data?.pages]); + const flatData = useMemo( + () => flatQueryData?.flatMap((page) => page.data) ?? [], + [flatQueryData], ); + const lastItem = flatQueryData ? flatQueryData[flatQueryData?.length - 1] : null; + const pagination = lastItem ? lastItem.pagination : null; + const [sortState, setSortState] = useState([ { id: 'name', @@ -88,25 +170,9 @@ export const LocationOverviewUsersTable = ({ data }: Props) => { }, ]); - const transformedData = useMemo(() => { - let res = mapped; - const sorting = sortState[0]; - // apply sorting - if (sorting) { - const { id, desc } = sorting; - const direction = desc ? 'desc' : 'asc'; - res = orderBy( - res.map((row) => ({ ...row, devices: orderBy(row.devices, [id], [direction]) })), - [id], - [direction], - ); - } - return res; - }, [mapped, sortState[0]]); - const columns = useMemo( () => [ - columnHelper.accessor('name', { + columnHelper.accessor('full_name', { header: 'User name', meta: { flex: true, @@ -116,8 +182,8 @@ export const LocationOverviewUsersTable = ({ data }: Props) => { {info.getValue()} @@ -132,7 +198,7 @@ export const LocationOverviewUsersTable = ({ data }: Props) => {
), }), - columnHelper.accessor('wireguard_ips', { + columnHelper.accessor('vpn_ips', { header: 'VPN IP', size: 250, cell: (info) => , @@ -149,7 +215,7 @@ export const LocationOverviewUsersTable = ({ data }: Props) => { cell: (info) => ( - {info.row.original.devices.length} + {info.row.original.connected_devices_count} ), }), @@ -159,9 +225,13 @@ export const LocationOverviewUsersTable = ({ data }: Props) => { header: 'Traffic', cell: (info) => { const row = info.row.original; - const { stats, download, upload } = row; + const { stats, total_download, total_upload } = row; return ( - + ); }, }), @@ -170,35 +240,15 @@ export const LocationOverviewUsersTable = ({ data }: Props) => { ); const renderExpansionRow = useCallback( - (row: Row, isLast = false) => - row.original.devices.map((device, expandIndex) => ( - - - - - - - {device.name} - - - {device.public_ip} - - - - - - - )), - [], + (row: Row, isLast = false) => ( + + ), + [locationId, search.period], ); const table = useReactTable({ @@ -206,10 +256,10 @@ export const LocationOverviewUsersTable = ({ data }: Props) => { sorting: sortState, }, columns, - data: transformedData, + data: flatData, getExpandedRowModel: getExpandedRowModel(), getCoreRowModel: getCoreRowModel(), - getRowCanExpand: (row) => row.original.devices?.length >= 1, + getRowCanExpand: (row) => row.original.connected_devices_count > 0, onSortingChange: setSortState, manualSorting: true, enableSorting: true, @@ -218,7 +268,9 @@ export const LocationOverviewUsersTable = ({ data }: Props) => { columnResizeMode: 'onChange', }); - if (data.length === 0) + if (isLoading) return ; + + if (flatData.length === 0) return ( { table={table} expandedHeaders={expansionHeaders} renderExpandedRow={renderExpansionRow} + loadingNextPage={isFetchingNextPage} + onNextPage={() => { + fetchNextPage(); + }} + hasNextPage={pagination?.next_page !== null} /> ); }; diff --git a/web/src/pages/LocationOverviewPage/components/ConnectionDurationCell.tsx b/web/src/pages/LocationOverviewPage/components/ConnectionDurationCell.tsx index d587a3fec2..98ba86b098 100644 --- a/web/src/pages/LocationOverviewPage/components/ConnectionDurationCell.tsx +++ b/web/src/pages/LocationOverviewPage/components/ConnectionDurationCell.tsx @@ -1,3 +1,4 @@ +import type { CSSProperties } from 'react'; import { useCallback, useEffect, useState } from 'react'; import { timer } from 'rxjs'; import { TableCell } from '../../../shared/defguard-ui/components/table/TableCell/TableCell'; @@ -5,9 +6,10 @@ import { formatConnectionTime } from '../../../shared/utils/formatConnectionTime type Props = { connectedAt: string; + style?: CSSProperties; }; -export const ConnectionDurationCell = ({ connectedAt }: Props) => { +export const ConnectionDurationCell = ({ connectedAt, style }: Props) => { const [displayedTime, setDisplayedTime] = useState(); const updateConnectionTime = useCallback(() => { @@ -29,7 +31,7 @@ export const ConnectionDurationCell = ({ connectedAt }: Props) => { }, [updateConnectionTime]); return ( - + {displayedTime} ); diff --git a/web/src/pages/LocationOverviewPage/components/DeviceTrafficChartCell/DeviceTrafficChartCell.tsx b/web/src/pages/LocationOverviewPage/components/DeviceTrafficChartCell/DeviceTrafficChartCell.tsx index be93e88d89..094a641432 100644 --- a/web/src/pages/LocationOverviewPage/components/DeviceTrafficChartCell/DeviceTrafficChartCell.tsx +++ b/web/src/pages/LocationOverviewPage/components/DeviceTrafficChartCell/DeviceTrafficChartCell.tsx @@ -1,19 +1,23 @@ import './style.scss'; +import { useMemo } from 'react'; import { Bar, BarChart } from 'recharts'; +import type { TransferStats } from '../../../../shared/api/types'; import { TransferText } from '../../../../shared/components/TransferText/TransferText'; import { TableCell } from '../../../../shared/defguard-ui/components/table/TableCell/TableCell'; import { ThemeVariable } from '../../../../shared/defguard-ui/types'; -import type { TransferChartData } from '../../../../shared/utils/stats'; +import { mapTransferToChart } from '../../../../shared/utils/stats'; export const DeviceTrafficChartCell = ({ - traffic, + stats, download, upload, }: { - traffic: TransferChartData[]; + stats: TransferStats[]; upload: number; download: number; }) => { + const traffic = useMemo(() => mapTransferToChart(stats), [stats]); + return (
diff --git a/web/src/pages/LocationsOverviewPage/components/LocationOverviewCard/LocationOverviewCard.tsx b/web/src/pages/LocationsOverviewPage/components/LocationOverviewCard/LocationOverviewCard.tsx index 497c3e38c6..6f3b28d8fb 100644 --- a/web/src/pages/LocationsOverviewPage/components/LocationOverviewCard/LocationOverviewCard.tsx +++ b/web/src/pages/LocationsOverviewPage/components/LocationOverviewCard/LocationOverviewCard.tsx @@ -123,6 +123,7 @@ export const OverviewCard = ({ stats.upload === 0 && stats.download === 0 && stats.transfer_series.length === 0 ); }, [stats]); + return (
{children} diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index e2e6542003..cac468834c 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -58,8 +58,13 @@ import type { GroupsResponse, IpValidation, LicenseInfoResponse, + LocationConnectedNetworkDevice, + LocationConnectedNetworkDevicesRequest, + LocationConnectedUser, + LocationConnectedUserDevice, + LocationConnectedUserDevicesRequest, + LocationConnectedUsersRequest, LocationDevicesResponse, - LocationDevicesStats, LocationStats, LocationStatsRequest, LoginRequest, @@ -320,14 +325,52 @@ const api = { client.get(`/network/${id}/gateways`), deleteGateway: ({ gatewayId, networkId }: DeleteGatewayRequest) => client.delete(`/network/${networkId}/gateways/${gatewayId}`), - getLocationDevicesStats: ({ id, ...params }: LocationStatsRequest) => - client.get(`/network/${id}/stats/users`, { - params: { - from: params.from - ? dayjs.utc().subtract(params.from, 'hour').toISOString() - : undefined, - }, - }), + getLocationConnectedUsers: ({ id, ...params }: LocationConnectedUsersRequest) => + client + .get>( + `/network/${id}/stats/connected_users`, + { + params: { + ...params, + from: params.from + ? dayjs.utc().subtract(params.from, 'hour').toISOString() + : undefined, + }, + }, + ) + .then((resp) => resp.data), + getLocationConnectedNetworkDevices: ({ + id, + ...params + }: LocationConnectedNetworkDevicesRequest) => + client + .get>( + `/network/${id}/stats/connected_network_devices`, + { + params: { + ...params, + from: params.from + ? dayjs.utc().subtract(params.from, 'hour').toISOString() + : undefined, + }, + }, + ) + .then((resp) => resp.data), + getLocationConnectedUserDevices: ({ + locationId, + userId, + from, + }: LocationConnectedUserDevicesRequest) => + client + .get( + `/network/${locationId}/stats/connected_users/${userId}/devices`, + { + params: { + from: from ? dayjs.utc().subtract(from, 'hour').toISOString() : undefined, + }, + }, + ) + .then((resp) => resp.data), addLocation: (data: EditNetworkLocation) => client.post('/network', data), editLocation: ({ id, data }: EditNetworkLocationRequest) => diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index c10ac33330..8e6d8f8f4a 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -606,6 +606,11 @@ export interface LocationStatsRequest { from?: number; } +export type LocationConnectedUsersRequest = LocationStatsRequest & PaginationParams; + +export type LocationConnectedNetworkDevicesRequest = LocationStatsRequest & + PaginationParams; + export interface DeleteGatewayRequest { networkId: number | string; gatewayId: number | string; @@ -630,6 +635,48 @@ export interface LocationDevicesStats { network_devices: DeviceStats[]; } +export interface LocationConnectedUser { + user_id: number; + first_name: string; + last_name: string; + full_name: string; + connected_devices_count: number; + public_ip: string; + vpn_ips: string[]; + connected_at: string; + total_upload: number; + total_download: number; + stats: TransferStats[]; +} + +export interface LocationConnectedNetworkDevice { + device_id: number; + device_name: string; + public_ip: string; + vpn_ips: string[]; + connected_at: string; + total_upload: number; + total_download: number; + stats: TransferStats[]; +} + +export interface LocationConnectedUserDevicesRequest { + locationId: number; + userId: number; + from?: number; +} + +export interface LocationConnectedUserDevice { + device_id: number; + device_name: string; + public_ip: string; + vpn_ips: string[]; + connected_at: string; + total_upload: number; + total_download: number; + stats: TransferStats[]; +} + export const LocationServiceMode = { Disabled: 'disabled', Prelogon: 'prelogon',