diff --git a/.sqlx/query-a2816a117b4955605e4f011d4effee27e1ed4525d48ae2a73f88097796aeaf8e.json b/.sqlx/query-a2816a117b4955605e4f011d4effee27e1ed4525d48ae2a73f88097796aeaf8e.json new file mode 100644 index 0000000000..efaf8aa6a9 --- /dev/null +++ b/.sqlx/query-a2816a117b4955605e4f011d4effee27e1ed4525d48ae2a73f88097796aeaf8e.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'user' THEN u.id END), 0) \"active_users!\", COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'user' THEN d.id END), 0) \"active_user_devices!\", COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'network' THEN d.id END), 0) \"active_network_devices!\" FROM wireguard_peer_stats s JOIN device d ON d.id = s.device_id LEFT JOIN \"user\" u ON u.id = d.user_id WHERE latest_handshake >= $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "active_users!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "active_user_devices!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "active_network_devices!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Timestamp" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "a2816a117b4955605e4f011d4effee27e1ed4525d48ae2a73f88097796aeaf8e" +} diff --git a/.sqlx/query-a8a6b28b4a4bfbd7857795ec3d58ff7d27920c68b04d325e70628954ba85f4fd.json b/.sqlx/query-a8a6b28b4a4bfbd7857795ec3d58ff7d27920c68b04d325e70628954ba85f4fd.json new file mode 100644 index 0000000000..d63288fac8 --- /dev/null +++ b/.sqlx/query-a8a6b28b4a4bfbd7857795ec3d58ff7d27920c68b04d325e70628954ba85f4fd.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT date_trunc($1, collected_at) \"collected_at: NaiveDateTime\", cast(sum(upload) AS bigint) upload, cast(sum(download) AS bigint) download FROM wireguard_peer_stats_view WHERE collected_at >= $2 GROUP BY 1 ORDER BY 1 LIMIT $3", + "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", + "Timestamp", + "Int8" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "a8a6b28b4a4bfbd7857795ec3d58ff7d27920c68b04d325e70628954ba85f4fd" +} diff --git a/.sqlx/query-ab5c925bc572cde131aad371e72158c237823dd9908ec8f02dd6f5eeabe9af3b.json b/.sqlx/query-ab5c925bc572cde131aad371e72158c237823dd9908ec8f02dd6f5eeabe9af3b.json new file mode 100644 index 0000000000..1258b2795b --- /dev/null +++ b/.sqlx/query-ab5c925bc572cde131aad371e72158c237823dd9908ec8f02dd6f5eeabe9af3b.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'user' THEN u.id END), 0) \"active_users!\", COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'user' THEN d.id END), 0) \"active_user_devices!\", COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'network' THEN d.id END), 0) \"active_network_devices!\" FROM wireguard_peer_stats s JOIN device d ON d.id = s.device_id LEFT JOIN \"user\" u ON u.id = d.user_id WHERE latest_handshake >= $1 AND s.network = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "active_users!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "active_user_devices!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "active_network_devices!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Timestamp", + "Int8" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "ab5c925bc572cde131aad371e72158c237823dd9908ec8f02dd6f5eeabe9af3b" +} diff --git a/.sqlx/query-e699a4be7c892b6c3fa44c41970381f11072cba02dd59f7e5fb7f4925e90692b.json b/.sqlx/query-e699a4be7c892b6c3fa44c41970381f11072cba02dd59f7e5fb7f4925e90692b.json deleted file mode 100644 index f94df73709..0000000000 --- a/.sqlx/query-e699a4be7c892b6c3fa44c41970381f11072cba02dd59f7e5fb7f4925e90692b.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COALESCE(COUNT(DISTINCT(u.id)), 0) \"active_users!\", COALESCE(COUNT(DISTINCT(s.device_id)), 0) \"active_devices!\" FROM \"user\" u JOIN device d ON d.user_id = u.id JOIN wireguard_peer_stats s ON s.device_id = d.id WHERE latest_handshake >= $1 AND s.network = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "active_users!", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "active_devices!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Timestamp", - "Int8" - ] - }, - "nullable": [ - null, - null - ] - }, - "hash": "e699a4be7c892b6c3fa44c41970381f11072cba02dd59f7e5fb7f4925e90692b" -} diff --git a/src/db/models/wireguard.rs b/src/db/models/wireguard.rs index 5493309020..e6a0a961a5 100644 --- a/src/db/models/wireguard.rs +++ b/src/db/models/wireguard.rs @@ -1003,11 +1003,12 @@ impl WireguardNetwork { let activity_stats = query_as!( WireguardNetworkActivityStats, "SELECT \ - COALESCE(COUNT(DISTINCT(u.id)), 0) \"active_users!\", \ - COALESCE(COUNT(DISTINCT(s.device_id)), 0) \"active_devices!\" \ - FROM \"user\" u \ - JOIN device d ON d.user_id = u.id \ - JOIN wireguard_peer_stats s ON s.device_id = d.id \ + COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'user' THEN u.id END), 0) \"active_users!\", \ + COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'user' THEN d.id END), 0) \"active_user_devices!\", \ + COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'network' THEN d.id END), 0) \"active_network_devices!\" \ + FROM wireguard_peer_stats s \ + JOIN device d ON d.id = s.device_id \ + LEFT JOIN \"user\" u ON u.id = d.user_id \ WHERE latest_handshake >= $1 AND s.network = $2", from, self.id, @@ -1027,11 +1028,12 @@ impl WireguardNetwork { let activity_stats = query_as!( WireguardNetworkActivityStats, "SELECT \ - COALESCE(COUNT(DISTINCT(u.id)), 0) \"active_users!\", \ - COALESCE(COUNT(DISTINCT(s.device_id)), 0) \"active_devices!\" \ - FROM \"user\" u \ - JOIN device d ON d.user_id = u.id \ - JOIN wireguard_peer_stats s ON s.device_id = d.id \ + COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'user' THEN u.id END), 0) \"active_users!\", \ + COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'user' THEN d.id END), 0) \"active_user_devices!\", \ + COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'network' THEN d.id END), 0) \"active_network_devices!\" \ + FROM wireguard_peer_stats s \ + JOIN device d ON d.id = s.device_id \ + LEFT JOIN \"user\" u ON u.id = d.user_id \ WHERE latest_handshake >= $1 AND s.network = $2", from, self.id @@ -1082,10 +1084,12 @@ impl WireguardNetwork { let current_activity = self.current_activity(conn).await?; let transfer_series = self.transfer_series(conn, from, aggregation).await?; Ok(WireguardNetworkStats { - current_active_users: current_activity.active_users, - current_active_devices: current_activity.active_devices, active_users: total_activity.active_users, - active_devices: total_activity.active_devices, + active_network_devices: total_activity.active_network_devices, + active_user_devices: total_activity.active_user_devices, + current_active_network_devices: current_activity.active_network_devices, + current_active_user_devices: current_activity.active_user_devices, + current_active_users: current_activity.active_users, upload: transfer_series.iter().filter_map(|t| t.upload).sum(), download: transfer_series.iter().filter_map(|t| t.download).sum(), transfer_series, @@ -1181,7 +1185,8 @@ pub struct WireguardUserStatsRow { pub struct WireguardNetworkActivityStats { pub active_users: i64, - pub active_devices: i64, + pub active_user_devices: i64, + pub active_network_devices: i64, } pub struct WireguardNetworkTransferStats { @@ -1192,14 +1197,79 @@ pub struct WireguardNetworkTransferStats { #[derive(Deserialize, Serialize)] pub struct WireguardNetworkStats { pub current_active_users: i64, - pub current_active_devices: i64, + pub current_active_user_devices: i64, + pub current_active_network_devices: i64, pub active_users: i64, - pub active_devices: i64, + pub active_user_devices: i64, + pub active_network_devices: i64, pub upload: i64, pub download: i64, pub transfer_series: Vec, } +pub(crate) async fn networks_stats( + conn: &PgPool, + from: &NaiveDateTime, + aggregation: &DateTimeAggregation, +) -> Result { + let total_activity = query_as!( + WireguardNetworkActivityStats, + "SELECT \ + COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'user' THEN u.id END), 0) \"active_users!\", \ + COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'user' THEN d.id END), 0) \"active_user_devices!\", \ + COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'network' THEN d.id END), 0) \"active_network_devices!\" \ + FROM wireguard_peer_stats s \ + JOIN device d ON d.id = s.device_id \ + LEFT JOIN \"user\" u ON u.id = d.user_id \ + WHERE latest_handshake >= $1", + from + ) + .fetch_one(conn) + .await?; + let current_activity_from = (Utc::now() - WIREGUARD_MAX_HANDSHAKE).naive_utc(); + let current_activity = query_as!( + WireguardNetworkActivityStats, + "SELECT \ + COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'user' THEN u.id END), 0) \"active_users!\", \ + COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'user' THEN d.id END), 0) \"active_user_devices!\", \ + COALESCE(COUNT(DISTINCT CASE WHEN d.device_type = 'network' THEN d.id END), 0) \"active_network_devices!\" \ + FROM wireguard_peer_stats s \ + JOIN device d ON d.id = s.device_id \ + LEFT JOIN \"user\" u ON u.id = d.user_id \ + WHERE latest_handshake >= $1", + current_activity_from + ) + .fetch_one(conn) + .await?; + let transfer_series = query_as!( + WireguardStatsRow, + "SELECT \ + date_trunc($1, collected_at) \"collected_at: NaiveDateTime\", \ + cast(sum(upload) AS bigint) upload, cast(sum(download) AS bigint) download \ + FROM wireguard_peer_stats_view \ + WHERE collected_at >= $2 \ + GROUP BY 1 \ + ORDER BY 1 \ + LIMIT $3", + aggregation.fstring(), + from, + PEER_STATS_LIMIT, + ) + .fetch_all(conn) + .await?; + Ok(WireguardNetworkStats { + current_active_users: current_activity.active_users, + current_active_network_devices: current_activity.active_network_devices, + current_active_user_devices: current_activity.active_user_devices, + active_users: total_activity.active_users, + active_network_devices: total_activity.active_network_devices, + active_user_devices: total_activity.active_user_devices, + download: transfer_series.iter().filter_map(|t| t.download).sum(), + upload: transfer_series.iter().filter_map(|t| t.upload).sum(), + transfer_series, + }) +} + #[cfg(test)] mod test { use chrono::{SubsecRound, TimeDelta}; diff --git a/src/grpc/mod.rs b/src/grpc/mod.rs index 891c24131e..a86c5d9e99 100644 --- a/src/grpc/mod.rs +++ b/src/grpc/mod.rs @@ -110,7 +110,7 @@ use proto::proxy::{ // Helper struct used to handle gateway state // gateways are grouped by network type GatewayHostname = String; -#[derive(Debug)] +#[derive(Debug, Serialize, Clone)] pub struct GatewayMap(HashMap>); #[derive(Error, Debug)] @@ -286,6 +286,22 @@ impl GatewayMap { None => None, } } + + /// Flattens the inner hashmap into an `Vec` + /// + /// Since key information in inner hashmap is within `GatewayState` it's simpler to consume it as Vec on web. + /// + /// # Returns + /// Returns `HashMap>` from `GatewayMap` + pub fn into_flattened(self) -> HashMap> { + self.0 + .into_iter() + .map(|(id, inner_map)| { + let states: Vec = inner_map.into_values().collect(); + (id, states) + }) + .collect() + } } impl Default for GatewayMap { diff --git a/src/handlers/wireguard.rs b/src/handlers/wireguard.rs index bcd88ee54e..bd33334668 100644 --- a/src/handlers/wireguard.rs +++ b/src/handlers/wireguard.rs @@ -28,8 +28,8 @@ use crate::{ WireguardNetworkDevice, }, wireguard::{ - DateTimeAggregation, MappedDevice, WireguardDeviceStatsRow, WireguardNetworkInfo, - WireguardUserStatsRow, + networks_stats, DateTimeAggregation, MappedDevice, WireguardDeviceStatsRow, + WireguardNetworkInfo, WireguardNetworkStats, WireguardUserStatsRow, }, }, AddDevice, Device, GatewayEvent, Id, WireguardNetwork, @@ -396,6 +396,10 @@ pub(crate) async fn network_details( Ok(response) } +/// Returns state of gateways in a given network +/// +/// # Returns +/// Returns `Vec` for requested network pub(crate) async fn gateway_status( Path(network_id): Path, _role: AdminRole, @@ -413,6 +417,27 @@ pub(crate) async fn gateway_status( }) } +/// Returns state of gateways for all networks +/// +/// Returns current state of gateways as `HashMap>` where key is an id of `WireguardNetwork` +pub(crate) async fn all_gateways_status( + _role: AdminRole, + Extension(gateway_state): Extension>>, +) -> ApiResult { + debug!("Displaying gateways status for all networks."); + let gateway_state = { + let lock = gateway_state + .lock() + .expect("Failed to acquire gateway state lock"); + lock.clone() + }; + let flattened = gateway_state.into_flattened(); + Ok(ApiResponse { + json: json!(flattened), + status: StatusCode::OK, + }) +} + pub(crate) async fn remove_gateway( Path((network_id, gateway_id)): Path<(i64, String)>, _role: AdminRole, @@ -1136,6 +1161,10 @@ pub struct DevicesStatsResponse { pub 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, @@ -1169,6 +1198,10 @@ pub(crate) async fn devices_stats( }) } +/// 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, @@ -1182,8 +1215,8 @@ pub(crate) async fn network_stats( ))); }; let from = query_from.parse_timestamp()?.naive_utc(); - let aggregation = get_aggregation(from)?; - let stats = network + let aggregation: DateTimeAggregation = get_aggregation(from)?; + let stats: WireguardNetworkStats = network .network_stats(&appstate.pool, &from, &aggregation) .await?; debug!("Displayed WireGuard network stats for network {network_id}"); @@ -1193,3 +1226,23 @@ pub(crate) async fn network_stats( status: 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: json!(all_networks_stats), + status: StatusCode::OK, + }) +} diff --git a/src/lib.rs b/src/lib.rs index b1cfaa7cbb..5c81f7a9ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,7 @@ use handlers::{ rename_authentication_key, }, updates::check_new_version, + wireguard::{all_gateways_status, networks_overview_stats}, yubikey::{delete_yubikey, rename_yubikey}, }; use ipnetwork::IpNetwork; @@ -534,16 +535,18 @@ pub fn build_webapp( post(start_network_device_setup_for_device), ) .route("/network", post(create_network)) + .route("/network", get(list_networks)) + .route("/network/import", post(import_network)) + .route("/network/stats", get(networks_overview_stats)) + .route("/network/gateways", get(all_gateways_status)) .route("/network/{network_id}", put(modify_network)) .route("/network/{network_id}", delete(delete_network)) - .route("/network", get(list_networks)) .route("/network/{network_id}", get(network_details)) .route("/network/{network_id}/gateways", get(gateway_status)) .route( "/network/{network_id}/gateways/{gateway_id}", delete(remove_gateway), ) - .route("/network/import", post(import_network)) .route("/network/{network_id}/devices", post(add_user_devices)) .route( "/network/{network_id}/device/{device_id}/config", diff --git a/tests/integration/wireguard_network_stats.rs b/tests/integration/wireguard_network_stats.rs index fed960687f..1c7d974552 100644 --- a/tests/integration/wireguard_network_stats.rs +++ b/tests/integration/wireguard_network_stats.rs @@ -264,7 +264,7 @@ async fn test_stats(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::OK); let stats: WireguardNetworkStats = response.json().await; assert_eq!(stats.active_users, 1); - assert_eq!(stats.active_devices, 2); + assert_eq!(stats.active_user_devices, 2); assert_eq!(stats.upload, ten_hours_samples * (10 + 20)); assert_eq!(stats.download, ten_hours_samples * (20 + 40)); assert_eq!(stats.transfer_series.len(), 11); diff --git a/web/package.json b/web/package.json index edc7b27cf4..f625de8c84 100644 --- a/web/package.json +++ b/web/package.json @@ -47,8 +47,8 @@ "@react-rxjs/core": "^0.10.8", "@stablelib/base64": "^2.0.1", "@stablelib/x25519": "^2.0.1", - "@tanstack/query-core": "^5.75.5", - "@tanstack/react-query": "^5.75.5", + "@tanstack/query-core": "^5.76.0", + "@tanstack/react-query": "^5.76.0", "@tanstack/react-virtual": "3.13.8", "@tanstack/virtual-core": "3.13.8", "@use-gesture/react": "^10.3.1", @@ -64,11 +64,11 @@ "events": "^3.3.0", "fast-deep-equal": "^3.1.3", "file-saver": "^2.0.5", - "framer-motion": "^12.10.4", + "framer-motion": "^12.11.0", "fuse.js": "^7.1.0", "get-text-width": "^1.0.3", "hex-rgb": "^5.0.0", - "html-react-parser": "^5.2.3", + "html-react-parser": "^5.2.5", "humanize-duration": "^3.32.1", "ipaddr.js": "^2.2.0", "itertools": "^2.4.1", @@ -113,12 +113,12 @@ "@eslint/js": "^9.26.0", "@hookform/devtools": "^4.4.0", "@stylistic/eslint-plugin-ts": "^4.2.0", - "@tanstack/react-query-devtools": "^5.75.5", + "@tanstack/react-query-devtools": "^5.76.0", "@types/byte-size": "^8.1.2", "@types/file-saver": "^2.0.7", "@types/humanize-duration": "^3.27.4", "@types/lodash-es": "^4.17.12", - "@types/node": "^22.15.16", + "@types/node": "^22.15.17", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@types/react-router-dom": "^5.3.3", @@ -131,7 +131,7 @@ "dotenv": "^16.5.0", "esbuild": "^0.25.4", "eslint": "^9.26.0", - "eslint-config-prettier": "^10.1.3", + "eslint-config-prettier": "^10.1.5", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.4.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 04343b3e75..632679393c 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -30,11 +30,11 @@ importers: specifier: ^2.0.1 version: 2.0.1 '@tanstack/query-core': - specifier: ^5.75.5 - version: 5.75.5 + specifier: ^5.76.0 + version: 5.76.0 '@tanstack/react-query': - specifier: ^5.75.5 - version: 5.75.5(react@18.2.0) + specifier: ^5.76.0 + version: 5.76.0(react@18.2.0) '@tanstack/react-virtual': specifier: 3.13.8 version: 3.13.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -81,8 +81,8 @@ importers: specifier: ^2.0.5 version: 2.0.5 framer-motion: - specifier: ^12.10.4 - version: 12.10.4(@emotion/is-prop-valid@1.2.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + specifier: ^12.11.0 + version: 12.11.0(@emotion/is-prop-valid@1.2.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) fuse.js: specifier: ^7.1.0 version: 7.1.0 @@ -93,8 +93,8 @@ importers: specifier: ^5.0.0 version: 5.0.0 html-react-parser: - specifier: ^5.2.3 - version: 5.2.3(@types/react@18.2.48)(react@18.2.0) + specifier: ^5.2.5 + version: 5.2.5(@types/react@18.2.48)(react@18.2.0) humanize-duration: specifier: ^3.32.1 version: 3.32.1 @@ -223,8 +223,8 @@ importers: specifier: ^4.2.0 version: 4.2.0(eslint@9.26.0)(typescript@5.8.3) '@tanstack/react-query-devtools': - specifier: ^5.75.5 - version: 5.75.5(@tanstack/react-query@5.75.5(react@18.2.0))(react@18.2.0) + specifier: ^5.76.0 + version: 5.76.0(@tanstack/react-query@5.76.0(react@18.2.0))(react@18.2.0) '@types/byte-size': specifier: ^8.1.2 version: 8.1.2 @@ -238,8 +238,8 @@ importers: specifier: ^4.17.12 version: 4.17.12 '@types/node': - specifier: ^22.15.16 - version: 22.15.16 + specifier: ^22.15.17 + version: 22.15.17 '@types/react': specifier: ^18.2.48 version: 18.2.48 @@ -260,7 +260,7 @@ importers: version: 8.32.0(eslint@9.26.0)(typescript@5.8.3) '@vitejs/plugin-react-swc': specifier: ^3.9.0 - version: 3.9.0(vite@6.3.5(@types/node@22.15.16)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) + version: 3.9.0(vite@6.3.5(@types/node@22.15.17)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.3) @@ -277,8 +277,8 @@ importers: specifier: ^9.26.0 version: 9.26.0 eslint-config-prettier: - specifier: ^10.1.3 - version: 10.1.3(eslint@9.26.0) + specifier: ^10.1.5 + version: 10.1.5(eslint@9.26.0) eslint-plugin-import: specifier: ^2.31.0 version: 2.31.0(@typescript-eslint/parser@8.32.0(eslint@9.26.0)(typescript@5.8.3))(eslint@9.26.0) @@ -287,7 +287,7 @@ importers: version: 6.10.2(eslint@9.26.0) eslint-plugin-prettier: specifier: ^5.4.0 - version: 5.4.0(@types/eslint@8.56.2)(eslint-config-prettier@10.1.3(eslint@9.26.0))(eslint@9.26.0)(prettier@3.5.3) + version: 5.4.0(@types/eslint@8.56.2)(eslint-config-prettier@10.1.5(eslint@9.26.0))(eslint@9.26.0)(prettier@3.5.3) eslint-plugin-react: specifier: ^7.37.5 version: 7.37.5(eslint@9.26.0) @@ -326,13 +326,13 @@ importers: version: 5.0.5(@typescript-eslint/parser@8.32.0(eslint@9.26.0)(typescript@5.8.3))(eslint@9.26.0)(typescript@5.8.3) vite: specifier: ^6.3.5 - version: 6.3.5(@types/node@22.15.16)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + version: 6.3.5(@types/node@22.15.17)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) vite-plugin-eslint: specifier: ^1.8.1 - version: 1.8.1(eslint@9.26.0)(vite@6.3.5(@types/node@22.15.16)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) + version: 1.8.1(eslint@9.26.0)(vite@6.3.5(@types/node@22.15.17)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) vite-plugin-package-version: specifier: ^1.1.0 - version: 1.1.0(vite@6.3.5(@types/node@22.15.16)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) + version: 1.1.0(vite@6.3.5(@types/node@22.15.17)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) packages: @@ -1056,20 +1056,20 @@ packages: '@swc/types@0.1.21': resolution: {integrity: sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ==} - '@tanstack/query-core@5.75.5': - resolution: {integrity: sha512-kPDOxtoMn2Ycycb76Givx2fi+2pzo98F9ifHL/NFiahEDpDwSVW6o12PRuQ0lQnBOunhRG5etatAhQij91M3MQ==} + '@tanstack/query-core@5.76.0': + resolution: {integrity: sha512-FN375hb8ctzfNAlex5gHI6+WDXTNpe0nbxp/d2YJtnP+IBM6OUm7zcaoCW6T63BawGOYZBbKC0iPvr41TteNVg==} - '@tanstack/query-devtools@5.74.7': - resolution: {integrity: sha512-nSNlfuGdnHf4yB0S+BoNYOE1o3oAH093weAYZolIHfS2stulyA/gWfSk/9H4ZFk5mAAHb5vNqAeJOmbdcGPEQw==} + '@tanstack/query-devtools@5.76.0': + resolution: {integrity: sha512-1p92nqOBPYVqVDU0Ua5nzHenC6EGZNrLnB2OZphYw8CNA1exuvI97FVgIKON7Uug3uQqvH/QY8suUKpQo8qHNQ==} - '@tanstack/react-query-devtools@5.75.5': - resolution: {integrity: sha512-S31U00nJOQIbxydRH1kOwdLRaLBrda8O5QjzmgkRg60UZzPGdbI6+873Qa0YGUfPeILDbR2ukgWyg7CJQPy4iA==} + '@tanstack/react-query-devtools@5.76.0': + resolution: {integrity: sha512-RoyRzH5XJB//OhAdzQTutesw9uHyNZroLp/I7NDAQf8OVJKTTcoaYBmaw5pmB2e3bVdgqFu6nHFZUr5j5qBdZw==} peerDependencies: - '@tanstack/react-query': ^5.75.5 + '@tanstack/react-query': ^5.76.0 react: ^18 || ^19 - '@tanstack/react-query@5.75.5': - resolution: {integrity: sha512-QrLCJe40BgBVlWdAdf2ZEVJ0cISOuEy/HKupId1aTKU6gPJZVhSvZpH+Si7csRflCJphzlQ77Yx6gUxGW9o0XQ==} + '@tanstack/react-query@5.76.0': + resolution: {integrity: sha512-dZLYzVuUFZJkenxd8o01oyFimeLBmSkaUviPHuDzXe7LSLO4WTTx92jwJlNUXOOHzg6t0XknklZ15cjhYNSDjA==} peerDependencies: react: ^18 || ^19 @@ -1163,8 +1163,8 @@ packages: '@types/ms@0.7.34': resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} - '@types/node@22.15.16': - resolution: {integrity: sha512-3pr+KjwpVujqWqOKT8mNR+rd09FqhBLwg+5L/4t0cNYBzm/yEiYGCxWttjaPBsLtAo+WFNoXzGJfolM1JuRXoA==} + '@types/node@22.15.17': + resolution: {integrity: sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -1972,8 +1972,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-prettier@10.1.3: - resolution: {integrity: sha512-vDo4d9yQE+cS2tdIT4J02H/16veRvkHgiLDRpej+WL67oCfbOb97itZXn8wMPJ/GsiEBVjrjs//AVNw2Cp1EcA==} + eslint-config-prettier@10.1.5: + resolution: {integrity: sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==} hasBin: true peerDependencies: eslint: '>=7.0.0' @@ -2233,8 +2233,8 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - framer-motion@12.10.4: - resolution: {integrity: sha512-gGkavMW3QFDCxI+adVu5sn2NtIRHYPGVLDSJ0S/6B0ZoxPaql2F9foWdg9sGIP6sPA8cbNDfxYf9VlhD3+FkVQ==} + framer-motion@12.11.0: + resolution: {integrity: sha512-BaBPmkhaC2l0n619Kt1nQaxSdUdyyz5V1Z7EKJ1CcraOTZitgVx0RTbL8lmg2XesaFi6o8MPBIhkWDIvzDpGaQ==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2448,11 +2448,11 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} - html-dom-parser@5.0.13: - resolution: {integrity: sha512-B7JonBuAfG32I7fDouUQEogBrz3jK9gAuN1r1AaXpED6dIhtg/JwiSRhjGL7aOJwRz3HU4efowCjQBaoXiREqg==} + html-dom-parser@5.1.1: + resolution: {integrity: sha512-+o4Y4Z0CLuyemeccvGN4bAO20aauB2N9tFEAep5x4OW34kV4PTarBHm6RL02afYt2BMKcr0D2Agep8S3nJPIBg==} - html-react-parser@5.2.3: - resolution: {integrity: sha512-y34oKTu+9T1fKdJE1cN9QWFWu8sx8Qa5tJOafUfMUF5Niah+yF6zlEHhWh7a0iZEcLRPIMw54bY14ajQF7xP7A==} + html-react-parser@5.2.5: + resolution: {integrity: sha512-bRPdv8KTqG9CEQPMNGksDqmbiRfVQeOidry8pVetdh/1jQ1Edx4KX5m0lWvDD89Pt4CqTYjK1BLz6NoNVxN/Uw==} peerDependencies: '@types/react': 0.14 || 15 || 16 || 17 || 18 || 19 react: 0.14 || 15 || 16 || 17 || 18 || 19 @@ -2985,8 +2985,8 @@ packages: resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==} engines: {node: '>=0.10.0'} - motion-dom@12.10.4: - resolution: {integrity: sha512-GSv8kz0ANFfeGrFKi99s3GQjLiL1IKH3KtSNEqrPiVbloHVRiNbNtpsYQq9rkV2AV+7jxvd1X1ObUMVDnAEnXA==} + motion-dom@12.11.0: + resolution: {integrity: sha512-CItkGYJenn5ZsbzTX0D9mE0UWdjdd9r535FrxEXhzR8Kwa9I2dLr1uhEJgQPWbgaIJ6i0sNFnf2T9NvVDWQVBw==} motion-utils@12.9.4: resolution: {integrity: sha512-BW3I65zeM76CMsfh3kHid9ansEJk9Qvl+K5cu4DVHKGsI52n76OJ4z2CUJUV+Mn3uEP9k1JJA3tClG0ggSrRcg==} @@ -4910,19 +4910,19 @@ snapshots: dependencies: '@swc/counter': 0.1.3 - '@tanstack/query-core@5.75.5': {} + '@tanstack/query-core@5.76.0': {} - '@tanstack/query-devtools@5.74.7': {} + '@tanstack/query-devtools@5.76.0': {} - '@tanstack/react-query-devtools@5.75.5(@tanstack/react-query@5.75.5(react@18.2.0))(react@18.2.0)': + '@tanstack/react-query-devtools@5.76.0(@tanstack/react-query@5.76.0(react@18.2.0))(react@18.2.0)': dependencies: - '@tanstack/query-devtools': 5.74.7 - '@tanstack/react-query': 5.75.5(react@18.2.0) + '@tanstack/query-devtools': 5.76.0 + '@tanstack/react-query': 5.76.0(react@18.2.0) react: 18.2.0 - '@tanstack/react-query@5.75.5(react@18.2.0)': + '@tanstack/react-query@5.76.0(react@18.2.0)': dependencies: - '@tanstack/query-core': 5.75.5 + '@tanstack/query-core': 5.76.0 react: 18.2.0 '@tanstack/react-virtual@3.13.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': @@ -5008,7 +5008,7 @@ snapshots: '@types/ms@0.7.34': {} - '@types/node@22.15.16': + '@types/node@22.15.17': dependencies: undici-types: 6.21.0 @@ -5172,10 +5172,10 @@ snapshots: '@use-gesture/core': 10.3.1 react: 18.2.0 - '@vitejs/plugin-react-swc@3.9.0(vite@6.3.5(@types/node@22.15.16)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1))': + '@vitejs/plugin-react-swc@3.9.0(vite@6.3.5(@types/node@22.15.17)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1))': dependencies: '@swc/core': 1.11.21 - vite: 6.3.5(@types/node@22.15.16)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + vite: 6.3.5(@types/node@22.15.17)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) transitivePeerDependencies: - '@swc/helpers' @@ -6100,7 +6100,7 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.3(eslint@9.26.0): + eslint-config-prettier@10.1.5(eslint@9.26.0): dependencies: eslint: 9.26.0 @@ -6170,7 +6170,7 @@ snapshots: safe-regex-test: 1.0.3 string.prototype.includes: 2.0.1 - eslint-plugin-prettier@5.4.0(@types/eslint@8.56.2)(eslint-config-prettier@10.1.3(eslint@9.26.0))(eslint@9.26.0)(prettier@3.5.3): + eslint-plugin-prettier@5.4.0(@types/eslint@8.56.2)(eslint-config-prettier@10.1.5(eslint@9.26.0))(eslint@9.26.0)(prettier@3.5.3): dependencies: eslint: 9.26.0 prettier: 3.5.3 @@ -6178,7 +6178,7 @@ snapshots: synckit: 0.11.2 optionalDependencies: '@types/eslint': 8.56.2 - eslint-config-prettier: 10.1.3(eslint@9.26.0) + eslint-config-prettier: 10.1.5(eslint@9.26.0) eslint-plugin-react-hooks@5.2.0(eslint@9.26.0): dependencies: @@ -6431,9 +6431,9 @@ snapshots: fraction.js@4.3.7: {} - framer-motion@12.10.4(@emotion/is-prop-valid@1.2.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + framer-motion@12.11.0(@emotion/is-prop-valid@1.2.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: - motion-dom: 12.10.4 + motion-dom: 12.11.0 motion-utils: 12.9.4 tslib: 2.8.1 optionalDependencies: @@ -6709,15 +6709,15 @@ snapshots: dependencies: lru-cache: 6.0.0 - html-dom-parser@5.0.13: + html-dom-parser@5.1.1: dependencies: domhandler: 5.0.3 htmlparser2: 10.0.0 - html-react-parser@5.2.3(@types/react@18.2.48)(react@18.2.0): + html-react-parser@5.2.5(@types/react@18.2.48)(react@18.2.0): dependencies: domhandler: 5.0.3 - html-dom-parser: 5.0.13 + html-dom-parser: 5.1.1 react: 18.2.0 react-property: 2.0.2 style-to-js: 1.1.16 @@ -7337,7 +7337,7 @@ snapshots: modify-values@1.0.1: {} - motion-dom@12.10.4: + motion-dom@12.11.0: dependencies: motion-utils: 12.9.4 @@ -8611,19 +8611,19 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-eslint@1.8.1(eslint@9.26.0)(vite@6.3.5(@types/node@22.15.16)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)): + vite-plugin-eslint@1.8.1(eslint@9.26.0)(vite@6.3.5(@types/node@22.15.17)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)): dependencies: '@rollup/pluginutils': 4.2.1 '@types/eslint': 8.56.2 eslint: 9.26.0 rollup: 2.79.2 - vite: 6.3.5(@types/node@22.15.16)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + vite: 6.3.5(@types/node@22.15.17)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) - vite-plugin-package-version@1.1.0(vite@6.3.5(@types/node@22.15.16)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)): + vite-plugin-package-version@1.1.0(vite@6.3.5(@types/node@22.15.17)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)): dependencies: - vite: 6.3.5(@types/node@22.15.16)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + vite: 6.3.5(@types/node@22.15.17)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) - vite@6.3.5(@types/node@22.15.16)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1): + vite@6.3.5(@types/node@22.15.17)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1): dependencies: esbuild: 0.25.4 fdir: 6.4.4(picomatch@4.0.2) @@ -8632,7 +8632,7 @@ snapshots: rollup: 4.34.9 tinyglobby: 0.2.13 optionalDependencies: - '@types/node': 22.15.16 + '@types/node': 22.15.17 fsevents: 2.3.3 sass: 1.70.0 terser: 5.37.0 diff --git a/web/src/components/App/App.tsx b/web/src/components/App/App.tsx index 2ea79c89a8..54a7409553 100644 --- a/web/src/components/App/App.tsx +++ b/web/src/components/App/App.tsx @@ -13,6 +13,7 @@ import { GroupsPage } from '../../pages/groups/GroupsPage'; import { NetworkPage } from '../../pages/network/NetworkPage'; import { OpenidClientsListPage } from '../../pages/openid/OpenidClientsListPage/OpenidClientsListPage'; import { OverviewPage } from '../../pages/overview/OverviewPage'; +import { OverviewIndexPage } from '../../pages/overview-index/OverviewIndexPage'; import { ProvisionersPage } from '../../pages/provisioners/ProvisionersPage'; import { SettingsPage } from '../../pages/settings/SettingsPage'; import { SupportPage } from '../../pages/support/SupportPage'; @@ -59,7 +60,7 @@ const App = () => { + } @@ -97,7 +98,15 @@ const App = () => { } /> + + + } + /> + diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index 352e13126e..4794dc0a2d 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -1072,11 +1072,10 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do gatewaysStatus: { label: 'Gateways', states: { - connected: 'All connected', - partial: 'One or more are not working', - disconnected: 'Disconnected', - error: 'Retrieving connections failed', - loading: 'Retrieving connections', + all: 'All ({count: number}) Connected', + some: 'Some ({count: number}) Connected', + none: 'None connected', + error: 'Status check failed', }, messages: { error: 'Failed to get gateways status', @@ -1817,6 +1816,11 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do }, }, networkOverview: { + networkSelection: { + all: 'All locations summary', + placeholder: 'Select location', + }, + timeRangeSelectionLabel: '{value: number}h period', pageTitle: 'Location overview', controls: { editNetworks: 'Edit Locations settings', @@ -1828,13 +1832,21 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do grid: 'Grid view', list: 'List view', }, + gatewayStatus: { + all: 'All ({count: number}) Connected', + some: 'Some ({count: number}) Connected', + none: 'None connected', + }, stats: { currentlyActiveUsers: 'Currently active users', - currentlyActiveDevices: 'Currently active devices', - activeUsersFilter: 'Active users in {hour: number}H', - activeDevicesFilter: 'Active devices in {hour: number}H', - totalTransfer: 'Total transfer:', + currentlyActiveNetworkDevices: 'Currently active network devices', + totalUserDevices: 'Total user devices: {count: number}', + activeNetworkDevices: 'Active network devices in {hour: number}h', + activeUsersFilter: 'Active users in {hour: number}h', + activeDevicesFilter: 'Active devices in {hour: number}h', activityIn: 'Activity in {hour: number}H', + networkUsage: 'Network usage', + peak: 'Peak', in: 'In:', out: 'Out:', gatewayDisconnected: 'Gateway disconnected', diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 57e3ef7254..6ccb3998c0 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -2615,25 +2615,23 @@ type RootTranslation = { label: string states: { /** - * A​l​l​ ​c​o​n​n​e​c​t​e​d + * A​l​l​ ​(​{​c​o​u​n​t​}​)​ ​C​o​n​n​e​c​t​e​d + * @param {number} count */ - connected: string + all: RequiredParams<'count'> /** - * O​n​e​ ​o​r​ ​m​o​r​e​ ​a​r​e​ ​n​o​t​ ​w​o​r​k​i​n​g + * S​o​m​e​ ​(​{​c​o​u​n​t​}​)​ ​C​o​n​n​e​c​t​e​d + * @param {number} count */ - partial: string + some: RequiredParams<'count'> /** - * D​i​s​c​o​n​n​e​c​t​e​d + * N​o​n​e​ ​c​o​n​n​e​c​t​e​d */ - disconnected: string + none: string /** - * R​e​t​r​i​e​v​i​n​g​ ​c​o​n​n​e​c​t​i​o​n​s​ ​f​a​i​l​e​d + * S​t​a​t​u​s​ ​c​h​e​c​k​ ​f​a​i​l​e​d */ error: string - /** - * R​e​t​r​i​e​v​i​n​g​ ​c​o​n​n​e​c​t​i​o​n​s - */ - loading: string } messages: { /** @@ -4320,6 +4318,21 @@ type RootTranslation = { } } networkOverview: { + networkSelection: { + /** + * A​l​l​ ​l​o​c​a​t​i​o​n​s​ ​s​u​m​m​a​r​y + */ + all: string + /** + * S​e​l​e​c​t​ ​l​o​c​a​t​i​o​n + */ + placeholder: string + } + /** + * {​v​a​l​u​e​}​h​ ​p​e​r​i​o​d + * @param {number} value + */ + timeRangeSelectionLabel: RequiredParams<'value'> /** * L​o​c​a​t​i​o​n​ ​o​v​e​r​v​i​e​w */ @@ -4346,34 +4359,64 @@ type RootTranslation = { */ list: string } + gatewayStatus: { + /** + * A​l​l​ ​(​{​c​o​u​n​t​}​)​ ​C​o​n​n​e​c​t​e​d + * @param {number} count + */ + all: RequiredParams<'count'> + /** + * S​o​m​e​ ​(​{​c​o​u​n​t​}​)​ ​C​o​n​n​e​c​t​e​d + * @param {number} count + */ + some: RequiredParams<'count'> + /** + * N​o​n​e​ ​c​o​n​n​e​c​t​e​d + */ + none: string + } stats: { /** * C​u​r​r​e​n​t​l​y​ ​a​c​t​i​v​e​ ​u​s​e​r​s */ currentlyActiveUsers: string /** - * C​u​r​r​e​n​t​l​y​ ​a​c​t​i​v​e​ ​d​e​v​i​c​e​s + * C​u​r​r​e​n​t​l​y​ ​a​c​t​i​v​e​ ​n​e​t​w​o​r​k​ ​d​e​v​i​c​e​s + */ + currentlyActiveNetworkDevices: string + /** + * T​o​t​a​l​ ​u​s​e​r​ ​d​e​v​i​c​e​s​:​ ​{​c​o​u​n​t​} + * @param {number} count */ - currentlyActiveDevices: string + totalUserDevices: RequiredParams<'count'> /** - * A​c​t​i​v​e​ ​u​s​e​r​s​ ​i​n​ ​{​h​o​u​r​}​H + * A​c​t​i​v​e​ ​n​e​t​w​o​r​k​ ​d​e​v​i​c​e​s​ ​i​n​ ​{​h​o​u​r​}​h * @param {number} hour */ - activeUsersFilter: RequiredParams<'hour'> + activeNetworkDevices: RequiredParams<'hour'> /** - * A​c​t​i​v​e​ ​d​e​v​i​c​e​s​ ​i​n​ ​{​h​o​u​r​}​H + * A​c​t​i​v​e​ ​u​s​e​r​s​ ​i​n​ ​{​h​o​u​r​}​h * @param {number} hour */ - activeDevicesFilter: RequiredParams<'hour'> + activeUsersFilter: RequiredParams<'hour'> /** - * T​o​t​a​l​ ​t​r​a​n​s​f​e​r​: + * A​c​t​i​v​e​ ​d​e​v​i​c​e​s​ ​i​n​ ​{​h​o​u​r​}​h + * @param {number} hour */ - totalTransfer: string + activeDevicesFilter: RequiredParams<'hour'> /** * A​c​t​i​v​i​t​y​ ​i​n​ ​{​h​o​u​r​}​H * @param {number} hour */ activityIn: RequiredParams<'hour'> + /** + * N​e​t​w​o​r​k​ ​u​s​a​g​e + */ + networkUsage: string + /** + * P​e​a​k + */ + peak: string /** * I​n​: */ @@ -8501,25 +8544,21 @@ export type TranslationFunctions = { label: () => LocalizedString states: { /** - * All connected + * All ({count}) Connected */ - connected: () => LocalizedString + all: (arg: { count: number }) => LocalizedString /** - * One or more are not working + * Some ({count}) Connected */ - partial: () => LocalizedString + some: (arg: { count: number }) => LocalizedString /** - * Disconnected + * None connected */ - disconnected: () => LocalizedString + none: () => LocalizedString /** - * Retrieving connections failed + * Status check failed */ error: () => LocalizedString - /** - * Retrieving connections - */ - loading: () => LocalizedString } messages: { /** @@ -10198,6 +10237,20 @@ export type TranslationFunctions = { } } networkOverview: { + networkSelection: { + /** + * All locations summary + */ + all: () => LocalizedString + /** + * Select location + */ + placeholder: () => LocalizedString + } + /** + * {value}h period + */ + timeRangeSelectionLabel: (arg: { value: number }) => LocalizedString /** * Location overview */ @@ -10224,31 +10277,57 @@ export type TranslationFunctions = { */ list: () => LocalizedString } + gatewayStatus: { + /** + * All ({count}) Connected + */ + all: (arg: { count: number }) => LocalizedString + /** + * Some ({count}) Connected + */ + some: (arg: { count: number }) => LocalizedString + /** + * None connected + */ + none: () => LocalizedString + } stats: { /** * Currently active users */ currentlyActiveUsers: () => LocalizedString /** - * Currently active devices + * Currently active network devices */ - currentlyActiveDevices: () => LocalizedString + currentlyActiveNetworkDevices: () => LocalizedString /** - * Active users in {hour}H + * Total user devices: {count} */ - activeUsersFilter: (arg: { hour: number }) => LocalizedString + totalUserDevices: (arg: { count: number }) => LocalizedString /** - * Active devices in {hour}H + * Active network devices in {hour}h */ - activeDevicesFilter: (arg: { hour: number }) => LocalizedString + activeNetworkDevices: (arg: { hour: number }) => LocalizedString /** - * Total transfer: + * Active users in {hour}h */ - totalTransfer: () => LocalizedString + activeUsersFilter: (arg: { hour: number }) => LocalizedString + /** + * Active devices in {hour}h + */ + activeDevicesFilter: (arg: { hour: number }) => LocalizedString /** * Activity in {hour}H */ activityIn: (arg: { hour: number }) => LocalizedString + /** + * Network usage + */ + networkUsage: () => LocalizedString + /** + * Peak + */ + peak: () => LocalizedString /** * In: */ diff --git a/web/src/main.tsx b/web/src/main.tsx index 84c659960b..77f9306f40 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,7 +1,7 @@ import './shared/scss/styles.scss'; import './shared/defguard-ui/scss/index.scss'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import dayjs from 'dayjs'; import LocalizedFormat from 'dayjs/plugin/localizedFormat'; import utc from 'dayjs/plugin/utc'; @@ -11,11 +11,11 @@ import { createRoot } from 'react-dom/client'; import { AppLoader } from './components/AppLoader'; import { I18nProvider } from './components/I18nProvider'; import { ApiProvider } from './shared/hooks/api/provider'; +import queryClient from './shared/query-client'; dayjs.extend(utc); dayjs.extend(LocalizedFormat); -const queryClient = new QueryClient(); const root = createRoot(document.getElementById('root') as HTMLElement); root.render( diff --git a/web/src/pages/network/NetworkGateway/NetworkGateway.tsx b/web/src/pages/network/NetworkGateway/NetworkGateway.tsx index 9b4db6f57c..de53a4df3a 100644 --- a/web/src/pages/network/NetworkGateway/NetworkGateway.tsx +++ b/web/src/pages/network/NetworkGateway/NetworkGateway.tsx @@ -5,7 +5,7 @@ import { useCallback, useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import { useI18nContext } from '../../../i18n/i18n-react'; -import { GatewaysStatus } from '../../../shared/components/network/GatewaysStatus/GatewaysStatus'; +import { NetworkGatewaysStatus } from '../../../shared/components/network/GatewaysStatus/NetworkGatewaysStatus/NetworkGatewaysStatus'; import { ActionButton } from '../../../shared/defguard-ui/components/Layout/ActionButton/ActionButton'; import { ActionButtonVariant } from '../../../shared/defguard-ui/components/Layout/ActionButton/types'; import { Button } from '../../../shared/defguard-ui/components/Layout/Button/Button'; @@ -151,7 +151,7 @@ export const NetworkGatewaySetup = () => { {LL.gatewaySetup.messages.oneLineInstall()} - + ); }; diff --git a/web/src/pages/network/NetworkPage.tsx b/web/src/pages/network/NetworkPage.tsx index 6c8b4fcd1a..b6eb19d254 100644 --- a/web/src/pages/network/NetworkPage.tsx +++ b/web/src/pages/network/NetworkPage.tsx @@ -21,7 +21,7 @@ export const NetworkPage = () => { network: { getNetworks }, } = useApi(); const { LL } = useI18nContext(); - const setPageStore = useNetworkPageStore((state) => state.setState); + const setNetworks = useNetworkPageStore((state) => state.setNetworks); const { breakpoint } = useBreakpoint(deviceBreakpoints); const { data: networksData } = useQuery({ @@ -32,9 +32,9 @@ export const NetworkPage = () => { useEffect(() => { if (networksData) { - setPageStore({ networks: networksData }); + setNetworks(networksData); } - }, [networksData, setPageStore]); + }, [networksData, setNetworks]); return ( diff --git a/web/src/pages/network/hooks/useNetworkPageStore.ts b/web/src/pages/network/hooks/useNetworkPageStore.ts index 7bd06ce4e6..bc5709b925 100644 --- a/web/src/pages/network/hooks/useNetworkPageStore.ts +++ b/web/src/pages/network/hooks/useNetworkPageStore.ts @@ -9,15 +9,22 @@ type NetworkPageStore = { networks: Network[]; selectedNetworkId: number; setState: (data: Partial) => void; + setNetworks: (data: Network[]) => void; }; export const useNetworkPageStore = createWithEqualityFn()( - (set) => ({ + (set, get) => ({ saveSubject: new Subject(), loading: false, networks: [], selectedNetworkId: 1, setState: (newState) => set(() => newState), + setNetworks: (networks) => { + if (get().selectedNetworkId === undefined) { + set({ selectedNetworkId: networks[0]?.id }); + } + set({ networks }); + }, }), Object.is, ); diff --git a/web/src/pages/overview-index/OverviewIndexPage.tsx b/web/src/pages/overview-index/OverviewIndexPage.tsx new file mode 100644 index 0000000000..282c25e329 --- /dev/null +++ b/web/src/pages/overview-index/OverviewIndexPage.tsx @@ -0,0 +1,170 @@ +import './style.scss'; + +import { useQuery } from '@tanstack/react-query'; +import { range } from 'lodash-es'; +import Skeleton from 'react-loading-skeleton'; +import { useLocation, useNavigate } from 'react-router'; + +import { ExpandableSection } from '../../shared/components/Layout/ExpandableSection/ExpandableSection'; +import { PageContainer } from '../../shared/components/Layout/PageContainer/PageContainer'; +import { AllNetworksGatewaysStatus } from '../../shared/components/network/GatewaysStatus/AllNetworksGatewaysStatus/AllNetworksGatewaysStatus'; +import { NetworkGatewaysStatus } from '../../shared/components/network/GatewaysStatus/NetworkGatewaysStatus/NetworkGatewaysStatus'; +import { Button } from '../../shared/defguard-ui/components/Layout/Button/Button'; +import { + ButtonSize, + ButtonStyleVariant, +} from '../../shared/defguard-ui/components/Layout/Button/types'; +import { NoData } from '../../shared/defguard-ui/components/Layout/NoData/NoData'; +import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; +import useApi from '../../shared/hooks/useApi'; +import { Network } from '../../shared/types'; +import { OverviewStats } from '../overview/OverviewStats/OverviewStats'; +import { EditLocationsSettingsButton } from './components/EditLocationsSettingsButton/EditLocationsSettingsButton'; +import { useOverviewTimeSelection } from './components/hooks/useOverviewTimeSelection'; +import { OverviewNetworkSelection } from './components/OverviewNetworkSelection/OverviewNetworkSelection'; +import { OverviewTimeSelection } from './components/OverviewTimeSelection/OverviewTimeSelection'; + +export const OverviewIndexPage = () => { + const { + network: { getNetworks }, + } = useApi(); + + const { data, isLoading } = useQuery({ + queryKey: ['network'], + queryFn: getNetworks, + }); + + return ( + +
+
+

All locations overview

+
+ + +
+ +
+ + + + + {!data && + isLoading && + range(6).map((skeletonIndex) => )} + {isPresent(data) && + !isLoading && + data.length > 0 && + data.map((network) => )} + {isPresent(data) && data.length === 0 && !isLoading && ( + + )} +
+
+ ); +}; + +const NetworkSectionSkeleton = () => { + return ( +
+ + + +
+ ); +}; + +type NetworkSectionProps = { + network: Network; +}; + +const NetworkSection = ({ network }: NetworkSectionProps) => { + const location = useLocation(); + const navigate = useNavigate(); + + const { from } = useOverviewTimeSelection(); + + const { + network: { getNetworkStats }, + } = useApi(); + + const { data } = useQuery({ + queryFn: () => getNetworkStats({ id: network.id, from }), + queryKey: ['network', network.id, 'stats', from], + refetchInterval: 60 * 1000, + placeholderData: (perv) => perv, + }); + + return ( + +
+ +
+ {!data && } + {data && } +
+ ); +}; + +const SummaryStats = () => { + const { from } = useOverviewTimeSelection(); + const { + network: { getAllNetworksStats }, + } = useApi(); + const { data, isLoading } = useQuery({ + queryKey: ['network', 'stats', from], + queryFn: () => getAllNetworksStats({ from }), + refetchInterval: 60 * 1000, + placeholderData: (perv) => perv, + }); + return ( + <> + {!data && isLoading && } + {data && !isLoading && } + + ); +}; + +const StatsSkeleton = () => { + return ( +
+ + +
+ ); +}; diff --git a/web/src/pages/overview-index/components/EditLocationsSettingsButton/EditLocationsSettingsButton.tsx b/web/src/pages/overview-index/components/EditLocationsSettingsButton/EditLocationsSettingsButton.tsx new file mode 100644 index 0000000000..c8ea52b0b9 --- /dev/null +++ b/web/src/pages/overview-index/components/EditLocationsSettingsButton/EditLocationsSettingsButton.tsx @@ -0,0 +1,41 @@ +import { useNavigate, useParams } from 'react-router'; + +import { useI18nContext } from '../../../../i18n/i18n-react'; +import IconEditNetwork from '../../../../shared/components/svg/IconEditNetwork'; +import { Button } from '../../../../shared/defguard-ui/components/Layout/Button/Button'; +import { + ButtonSize, + ButtonStyleVariant, +} from '../../../../shared/defguard-ui/components/Layout/Button/types'; +import { useNetworkPageStore } from '../../../network/hooks/useNetworkPageStore'; + +export const EditLocationsSettingsButton = () => { + const { LL } = useI18nContext(); + const { networkId } = useParams(); + const navigate = useNavigate(); + const selectedNetwork = parseInt(networkId ?? ''); + const setNetworkPageStore = useNetworkPageStore((s) => s.setState); + + const handleClick = () => { + if (!isNaN(selectedNetwork)) { + setNetworkPageStore({ + selectedNetworkId: selectedNetwork, + }); + } + setNetworkPageStore({ + selectedNetworkId: undefined, + }); + navigate('/admin/network'); + }; + + return ( + - )} - - ); -}; diff --git a/web/src/shared/components/network/GatewaysStatus/GatewaysStatusInfo/GatewaysStatusInfo.tsx b/web/src/shared/components/network/GatewaysStatus/GatewaysStatusInfo/GatewaysStatusInfo.tsx new file mode 100644 index 0000000000..8742514a39 --- /dev/null +++ b/web/src/shared/components/network/GatewaysStatus/GatewaysStatusInfo/GatewaysStatusInfo.tsx @@ -0,0 +1,111 @@ +import './style.scss'; + +import clsx from 'clsx'; +import { PropsWithChildren, useMemo, useState } from 'react'; +import Skeleton from 'react-loading-skeleton'; + +import { useI18nContext } from '../../../../../i18n/i18n-react'; +import { ArrowSingle } from '../../../../defguard-ui/components/icons/ArrowSingle/ArrowSingle'; +import { ArrowSingleDirection } from '../../../../defguard-ui/components/icons/ArrowSingle/types'; +import { FloatingMenu } from '../../../../defguard-ui/components/Layout/FloatingMenu/FloatingMenu'; +import { FloatingMenuProvider } from '../../../../defguard-ui/components/Layout/FloatingMenu/FloatingMenuProvider'; +import { FloatingMenuTrigger } from '../../../../defguard-ui/components/Layout/FloatingMenu/FloatingMenuTrigger'; +import { Label } from '../../../../defguard-ui/components/Layout/Label/Label'; +import { GatewayStatusIcon } from '../GatewayStatusIcon'; +import { GatewayConnectionStatus } from '../types'; + +type Props = { + totalCount: number; + connectionCount: number; + isLoading?: boolean; + isError?: boolean; + forceStatus?: GatewayConnectionStatus; +} & PropsWithChildren; + +export const GatewaysStatusInfo = ({ + children, + connectionCount, + totalCount, + forceStatus, + isLoading = false, + isError = false, +}: Props) => { + const { LL } = useI18nContext(); + const localLL = LL.components.gatewaysStatus; + const [floatingOpen, setOpen] = useState(false); + + const status = useMemo((): GatewayConnectionStatus => { + if (forceStatus) { + return forceStatus; + } + if (isError) { + return GatewayConnectionStatus.ERROR; + } + if (isLoading) { + return GatewayConnectionStatus.LOADING; + } + if (totalCount === 0 || connectionCount === 0) { + return GatewayConnectionStatus.DISCONNECTED; + } + if (totalCount !== connectionCount) { + return GatewayConnectionStatus.PARTIAL; + } + return GatewayConnectionStatus.CONNECTED; + }, [connectionCount, forceStatus, isError, isLoading, totalCount]); + + const getInfoText = () => { + switch (status) { + case GatewayConnectionStatus.LOADING: + return ''; + case GatewayConnectionStatus.ERROR: + return localLL.states.error(); + case GatewayConnectionStatus.DISCONNECTED: + return localLL.states.none(); + case GatewayConnectionStatus.PARTIAL: + return localLL.states.some({ + count: connectionCount, + }); + case GatewayConnectionStatus.CONNECTED: + return localLL.states.all({ + count: connectionCount, + }); + } + }; + + return ( +
+ + + +
{ + if (totalCount > 0) { + setOpen(true); + } + }} + > + {isLoading && } + {!isLoading && ( +
+

{getInfoText()}

+ +
+ )} + {totalCount > 0 && } +
+
+ + {children} + +
+
+ ); +}; diff --git a/web/src/shared/components/network/GatewaysStatus/GatewaysStatusInfo/style.scss b/web/src/shared/components/network/GatewaysStatus/GatewaysStatusInfo/style.scss new file mode 100644 index 0000000000..0b7b08280f --- /dev/null +++ b/web/src/shared/components/network/GatewaysStatus/GatewaysStatusInfo/style.scss @@ -0,0 +1,62 @@ +.gateways-status-info { + width: 100%; + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-xs); + min-height: 18px; + + & > label { + user-select: none; + + @include typography(app-modal-3); + } + + .info-track { + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + + .react-loading-skeleton { + height: 18px; + width: 120px; + border-radius: 2px; + } + + & > .info { + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + user-select: none; + cursor: pointer; + + &.connected { + color: var(--text-positive); + } + + &.disconnected { + cursor: default; + color: var(--text-alert); + } + + &.partial { + color: var(--text-important); + } + + p { + color: inherit; + padding-right: 5px; + text-wrap: nowrap; + @include typography(app-modal-3); + } + + svg { + width: 8px; + height: 8px; + } + } + } +} diff --git a/web/src/shared/components/network/GatewaysStatus/NetworkGatewaysStatus/NetworkGatewaysStatus.tsx b/web/src/shared/components/network/GatewaysStatus/NetworkGatewaysStatus/NetworkGatewaysStatus.tsx new file mode 100644 index 0000000000..72072fab1a --- /dev/null +++ b/web/src/shared/components/network/GatewaysStatus/NetworkGatewaysStatus/NetworkGatewaysStatus.tsx @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; + +import useApi from '../../../../hooks/useApi'; +import { GatewaysFloatingStatus } from '../GatewaysFloatingStatus/GatewaysFloatingStatus'; +import { GatewaysStatusInfo } from '../GatewaysStatusInfo/GatewaysStatusInfo'; + +type Props = { + networkId: number; +}; +export const NetworkGatewaysStatus = ({ networkId }: Props) => { + const { + network: { getGatewaysStatus }, + } = useApi(); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['network', networkId, 'gateways'], + queryFn: () => getGatewaysStatus(networkId), + }); + + const [totalConnections, connectedCount] = useMemo(() => { + if (!data) return [0, 0]; + const total = data.length; + const connected = data.reduce( + (count, status) => count + (status.connected ? 1 : 0), + 0, + ); + return [total, connected]; + }, [data]); + + return ( + + {data?.map((status) => )} + + ); +}; diff --git a/web/src/shared/components/svg/IconTagDismiss.tsx b/web/src/shared/components/svg/IconTagDismiss.tsx index 4356bab085..4e5e146120 100644 --- a/web/src/shared/components/svg/IconTagDismiss.tsx +++ b/web/src/shared/components/svg/IconTagDismiss.tsx @@ -1,33 +1,36 @@ -import type { SVGProps } from 'react'; -const SvgIconTagDismiss = (props: SVGProps) => ( - - - - - - - - - - - -); +import { useId, type SVGProps } from 'react'; +const SvgIconTagDismiss = (props: SVGProps) => { + const maskId = useId(); + return ( + + + + + + + + + + + + ); +}; export default SvgIconTagDismiss; diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index f4d97a9ba2..eb27502211 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit f4d97a9ba26c34c1719d07d287f62b2ccee2d620 +Subproject commit eb2750221114d8898755d9d6912517b13e71f667 diff --git a/web/src/shared/hooks/api/api.ts b/web/src/shared/hooks/api/api.ts index 331f035036..bf4953fcd9 100644 --- a/web/src/shared/hooks/api/api.ts +++ b/web/src/shared/hooks/api/api.ts @@ -1,5 +1,6 @@ import { Axios, AxiosResponse } from 'axios'; +import { getNetworkStatsFilterValue } from '../../../pages/overview/helpers/stats'; import { AddDeviceResponse, AddOpenidClientRequest, @@ -217,26 +218,30 @@ export const buildApi = (client: Axios): Api => { const getOverviewStats: Api['network']['getOverviewStats'] = ( data: GetNetworkStatsRequest, - ) => - client + ) => { + const from = getNetworkStatsFilterValue(data.from ?? 1); + return client .get(`/network/${data.id}/stats/users`, { params: { - ...data, + from, }, }) .then(unpackRequest); + }; const getNetworkToken: Api['network']['getNetworkToken'] = (networkId) => client.get(`/network/${networkId}/token`).then(unpackRequest); - const getNetworkStats: Api['network']['getNetworkStats'] = (data) => - client + const getNetworkStats: Api['network']['getNetworkStats'] = (data) => { + const fromParam = getNetworkStatsFilterValue(data.from ?? 1); + return client .get(`/network/${data.id}/stats`, { params: { - ...data, + from: fromParam, }, }) .then(unpackRequest); + }; const getWorkerToken = () => client.get('/worker/token').then(unpackRequest); @@ -502,6 +507,20 @@ export const buildApi = (client: Axios): Api => { }) .then(unpackRequest); + const getAllNetworksStats: Api['network']['getAllNetworksStats'] = (params) => { + const fromParam = getNetworkStatsFilterValue(params.from ?? 1); + return client + .get('/network/stats', { + params: { + from: fromParam, + }, + }) + .then(unpackRequest); + }; + + const getAllGatewaysStatus: Api['network']['getAllGatewaysStatus'] = () => + client.get('/network/gateways').then(unpackRequest); + return { getAppInfo, getNewVersion, @@ -583,6 +602,8 @@ export const buildApi = (client: Axios): Api => { downloadDeviceConfig, }, network: { + getAllNetworksStats, + getAllGatewaysStatus, addNetwork, importNetwork, mapUserDevices: mapUserDevices, diff --git a/web/src/shared/query-client.ts b/web/src/shared/query-client.ts new file mode 100644 index 0000000000..fb9f3dc237 --- /dev/null +++ b/web/src/shared/query-client.ts @@ -0,0 +1,11 @@ +import { QueryClient } from '@tanstack/query-core'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 1000, + }, + }, +}); + +export default queryClient; diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index e26b3d162d..d746ff4984 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -108,6 +108,7 @@ export interface AddDeviceRequest { export type GatewayStatus = { connected: boolean; network_id: number; + network_name: string; name?: string; hostname: string; uid: string; @@ -270,8 +271,7 @@ export interface ChangeUserPasswordRequest { } export interface GetNetworkStatsRequest { - /**UTC date parsed to ISO string. This sets how far back stats will be returned. */ - from?: string; + from?: number; id: Network['id']; } @@ -498,6 +498,8 @@ export type AclRuleInfo = { protocols: number[]; }; +export type AllGateWaysResponse = Record>; + export type Api = { getAppInfo: () => Promise; getNewVersion: () => Promise; @@ -615,6 +617,8 @@ export type Api = { getNetworkStats: (data: GetNetworkStatsRequest) => Promise; getGatewaysStatus: (networkId: number) => Promise; deleteGateway: (data: DeleteGatewayRequest) => Promise; + getAllNetworksStats: (data: { from?: number }) => Promise; + getAllGatewaysStatus: () => Promise; }; auth: { login: (data: LoginData) => Promise; @@ -1137,9 +1141,11 @@ export interface NetworkUserStats { export interface WireguardNetworkStats { active_users: number; - active_devices: number; + active_user_devices: number; + active_network_devices: number; current_active_users: number; - current_active_devices: number; + current_active_user_devices: number; + current_active_network_devices: number; upload: number; download: number; transfer_series: NetworkSpeedStats[];