From 872ebacdfb978fc45d2ec30104822c1718dbd88d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 16 Feb 2026 10:02:27 +0100 Subject: [PATCH 01/22] update dependencies --- Cargo.lock | 186 ++++++++++++++++++---------------------- flake.lock | 12 +-- web/package.json | 12 +-- web/pnpm-lock.yaml | 208 +++++++++++++++++---------------------------- 4 files changed, 175 insertions(+), 243 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 17483d38b4..cf5a11c818 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -492,9 +492,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" dependencies = [ "serde_core", ] @@ -618,9 +618,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.55" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "jobserver", @@ -1640,7 +1640,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", ] @@ -2022,9 +2022,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -2037,9 +2037,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -2047,15 +2047,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -2075,15 +2075,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -2092,21 +2092,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -2116,7 +2116,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -2196,7 +2195,7 @@ version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", "libgit2-sys", "log", @@ -2222,7 +2221,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "ignore", "walkdir", ] @@ -2829,7 +2828,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba781c43eb46c3bbf5bfda541139eed9a52b78d7c3aa74d516918885ecd63c40" dependencies = [ "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.11.0", "num-bigint", "serde", "serde_json", @@ -2877,9 +2876,9 @@ dependencies = [ [[package]] name = "keccak" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ "cpufeatures", ] @@ -2962,9 +2961,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.181" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libgit2-sys" @@ -2990,7 +2989,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", "redox_syscall 0.7.1", ] @@ -3212,17 +3211,17 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "9d5d26952a508f321b4d3d2e80e78fc2603eaefcdf0c30783867f19586518bdc" dependencies = [ "libc", "log", "openssl", - "openssl-probe 0.1.6", + "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] @@ -3239,7 +3238,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -3419,7 +3418,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", "objc2-foundation", ] @@ -3440,7 +3439,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dispatch2", "objc2", ] @@ -3451,7 +3450,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -3484,7 +3483,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -3502,7 +3501,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -3515,7 +3514,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", ] @@ -3526,7 +3525,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -3538,7 +3537,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", "objc2", "objc2-cloud-kit", @@ -3657,7 +3656,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "foreign-types", "libc", @@ -3677,12 +3676,6 @@ dependencies = [ "syn", ] -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" @@ -4268,7 +4261,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "getopts", "memchr", "pulldown-cmark-escape", @@ -4451,7 +4444,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -4460,7 +4453,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -4691,7 +4684,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -4720,10 +4713,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", + "security-framework", ] [[package]] @@ -4835,24 +4828,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.11.1" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" -dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4861,9 +4841,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" dependencies = [ "core-foundation-sys", "libc", @@ -5172,9 +5152,9 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simple_asn1" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", @@ -5360,7 +5340,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.11.0", "byteorder", "bytes", "chrono", @@ -5404,7 +5384,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.11.0", "byteorder", "chrono", "crc", @@ -5613,9 +5593,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.115" +version = "2.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" dependencies = [ "proc-macro2", "quote", @@ -5648,7 +5628,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5926,18 +5906,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.7+spec-1.1.0" +version = "1.0.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1" +checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" dependencies = [ "winnow", ] [[package]] name = "tonic" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a" +checksum = "7f32a6f80051a4111560201420c7885d0082ba9efe2ab61875c587bb6b18b9a0" dependencies = [ "async-trait", "axum", @@ -5967,9 +5947,9 @@ dependencies = [ [[package]] name = "tonic-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27aac809edf60b741e2d7db6367214d078856b8a5bff0087e94ff330fb97b6fc" +checksum = "ce6d8958ed3be404120ca43ffa0fb1e1fc7be214e96c8d33bd43a131b6eebc9e" dependencies = [ "prettyplease", "proc-macro2", @@ -5979,9 +5959,9 @@ dependencies = [ [[package]] name = "tonic-health" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dbde2c702c4be12b9b2f6f7e6c824a84a7b7be177070cada8ee575a581af359" +checksum = "163e5ad9be2924d9cef75f02fcd44c1803a5af250f4ef7e085992270ac51fb9b" dependencies = [ "prost", "tokio", @@ -5992,9 +5972,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6c55a2d6a14174563de34409c9f92ff981d006f56da9c6ecd40d9d4a31500b0" +checksum = "9f86539c0089bfd09b1f8c0ab0239d80392af74c21bc9e0f15e1b4aca4c1647f" dependencies = [ "bytes", "prost", @@ -6003,9 +5983,9 @@ dependencies = [ [[package]] name = "tonic-prost-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4556786613791cfef4ed134aa670b61a85cfcacf71543ef33e8d801abae988f" +checksum = "65873ace111e90344b8973e94a1fc817c924473affff24629281f90daed1cd2e" dependencies = [ "prettyplease", "proc-macro2", @@ -6054,7 +6034,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "futures-core", "futures-util", @@ -6216,9 +6196,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" @@ -6358,11 +6338,11 @@ checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" [[package]] name = "uuid" -version = "1.20.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.1", "js-sys", "serde_core", "wasm-bindgen", @@ -6574,7 +6554,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap 2.13.0", "semver", @@ -7109,7 +7089,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags 2.11.0", "indexmap 2.13.0", "log", "serde", diff --git a/flake.lock b/flake.lock index f719372873..386f086f53 100644 --- a/flake.lock +++ b/flake.lock @@ -32,11 +32,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1770562336, - "narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=", + "lastModified": 1771008912, + "narHash": "sha256-gf2AmWVTs8lEq7z/3ZAsgnZDhWIckkb+ZnAo5RzSxJg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "d6c71932130818840fc8fe9509cf50be8c64634f", + "rev": "a82ccc39b39b621151d6732718e3e250109076fa", "type": "github" }, "original": { @@ -74,11 +74,11 @@ ] }, "locked": { - "lastModified": 1770865833, - "narHash": "sha256-oiARqnlvaW6pVGheVi4ye6voqCwhg5hCcGish2ZvQzI=", + "lastModified": 1771211437, + "narHash": "sha256-lcNK438i4DGtyA+bPXXyVLHVmJjYpVKmpux9WASa3ro=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "c8cfbe26238638e2f3a2c0ae7e8d240f5e4ded85", + "rev": "c62195b3d6e1bb11e0c2fb2a494117d3b55d410f", "type": "github" }, "original": { diff --git a/web/package.json b/web/package.json index 9e62f3f39c..03abadd9af 100644 --- a/web/package.json +++ b/web/package.json @@ -15,14 +15,14 @@ "dependencies": { "@axa-ch/react-polymorphic-types": "^1.4.1", "@floating-ui/react": "^0.27.17", - "@inlang/paraglide-js": "^2.11.0", + "@inlang/paraglide-js": "^2.12.0", "@react-hook/resize-observer": "^2.0.2", "@shortercode/webzip": "1.1.1-0", "@stablelib/base64": "^2.0.1", "@stablelib/x25519": "^2.0.1", - "@tanstack/react-form": "^1.28.1", + "@tanstack/react-form": "^1.28.3", "@tanstack/react-query": "^5.90.21", - "@tanstack/react-router": "^1.159.5", + "@tanstack/react-router": "^1.160.0", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.18", "@uidotdev/usehooks": "^2.4.1", @@ -36,7 +36,7 @@ "lodash-es": "^4.17.23", "motion": "^12.34.0", "qrcode.react": "^4.2.0", - "qs": "^6.14.2", + "qs": "^6.15.0", "radashi": "^12.7.1", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -56,8 +56,8 @@ "@tanstack/devtools-vite": "^0.5.1", "@tanstack/react-devtools": "^0.9.5", "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-router-devtools": "^1.159.5", - "@tanstack/router-plugin": "^1.159.5", + "@tanstack/react-router-devtools": "^1.160.0", + "@tanstack/router-plugin": "^1.160.0", "@types/byte-size": "^8.1.2", "@types/humanize-duration": "^3.27.4", "@types/lodash-es": "^4.17.12", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 0e43aeb51e..61349d9cb2 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^0.27.17 version: 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@inlang/paraglide-js': - specifier: ^2.11.0 - version: 2.11.0 + specifier: ^2.12.0 + version: 2.12.0 '@react-hook/resize-observer': specifier: ^2.0.2 version: 2.0.2(react@19.2.4) @@ -30,14 +30,14 @@ importers: specifier: ^2.0.1 version: 2.0.1 '@tanstack/react-form': - specifier: ^1.28.1 - version: 1.28.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^1.28.3 + version: 1.28.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.4) '@tanstack/react-router': - specifier: ^1.159.5 - version: 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^1.160.0 + version: 1.160.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -78,8 +78,8 @@ importers: specifier: ^4.2.0 version: 4.2.0(react@19.2.4) qs: - specifier: ^6.14.2 - version: 6.14.2 + specifier: ^6.15.0 + version: 6.15.0 radashi: specifier: ^12.7.1 version: 12.7.1 @@ -130,11 +130,11 @@ importers: specifier: ^5.91.3 version: 5.91.3(@tanstack/react-query@5.90.21(react@19.2.4))(react@19.2.4) '@tanstack/react-router-devtools': - specifier: ^1.159.5 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.159.4)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^1.160.0 + version: 1.160.0(@tanstack/react-router@1.160.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.160.0)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-plugin': - specifier: ^1.159.5 - version: 1.159.5(@tanstack/react-router@1.159.5(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)) + specifier: ^1.160.0 + version: 1.160.0(@tanstack/react-router@1.160.0(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)) '@types/byte-size': specifier: ^8.1.2 version: 8.1.2 @@ -306,28 +306,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.3.15': resolution: {integrity: sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.3.15': resolution: {integrity: sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.3.15': resolution: {integrity: sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.3.15': resolution: {integrity: sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA==} @@ -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==} @@ -719,8 +699,8 @@ packages: cpu: [x64] os: [win32] - '@inlang/paraglide-js@2.11.0': - resolution: {integrity: sha512-UObGgxglpRpQA7Vt9tfPsLgfHOtZ6I6hnJjHSTNLP+8rqpmOjt5Xto2jubUaEzjteT3oqPV/yYB5O2GDwpIVgA==} + '@inlang/paraglide-js@2.12.0': + resolution: {integrity: sha512-wnqTeSLcMMS2usL8zjS8bDGs9r16X00aeoGk2wVAnPfAgCChYalKdG20pS2XtJVMM1H6nBBBLKt3ZQMnKrusKQ==} hasBin: true '@inlang/recommend-sherlock@0.2.1': @@ -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==} @@ -1212,8 +1169,8 @@ packages: peerDependencies: solid-js: '>=1.9.7' - '@tanstack/form-core@1.28.1': - resolution: {integrity: sha512-+7rOzEOoQjuGFBV9/QoW2XjYjB5hbRYDAJxhooSMAQSkBQrIyoXqV3pqyDS/q4I5kgtDJmfWMnOZU5ieCkhsCQ==} + '@tanstack/form-core@1.28.3': + resolution: {integrity: sha512-DBhnu1d5VfACAYOAZJO8tsEUHjWczZMJY8v/YrtAJNWpwvL/3ogDuz8e6yUB2m/iVTNq6K8yrnVN2nrX0/BX/w==} '@tanstack/history@1.154.14': resolution: {integrity: sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA==} @@ -1238,8 +1195,8 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-form@1.28.1': - resolution: {integrity: sha512-JN5J8p7aAybTzqgomi4rApr9/dDH1y+nFqYErv+dM+lufW30srYqey2PsSqBe7i8eqsZG70E2JjaDCk1fBon8w==} + '@tanstack/react-form@1.28.3': + resolution: {integrity: sha512-84yd0swZRcyC3Q46dYBH6bHf1tlIY1flchbdG3VwArg/wLVW5RdBenIrJhleHjk2OxXuF+9HoKQbHglJyWIXQA==} peerDependencies: '@tanstack/react-start': '*' react: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1258,27 +1215,27 @@ packages: peerDependencies: react: ^18 || ^19 - '@tanstack/react-router-devtools@1.159.5': - resolution: {integrity: sha512-IIyomu+ypWTxyoYT32mxamVmdTs7ZCGcTbdj7HVvtD3xp1lvo/bwRXj9oERENmb+OAPOaWF2doRYC/pmKjK5vg==} + '@tanstack/react-router-devtools@1.160.0': + resolution: {integrity: sha512-VxOPac0Pwd2EWX2/SA/8CvrkEa1HG0Fc6tkvS+eQ8exg/WvS9s94M0O8DUxeSgCSMfFNWkYEmSkn2usMdfM2jw==} engines: {node: '>=12'} peerDependencies: - '@tanstack/react-router': ^1.159.5 - '@tanstack/router-core': ^1.159.4 + '@tanstack/react-router': ^1.160.0 + '@tanstack/router-core': ^1.160.0 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' peerDependenciesMeta: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.159.5': - resolution: {integrity: sha512-rVb0MtKzP5c0BkWIoFgWBiRAJHYSU3bhsEHbT0cRdRLmlJiw21Awb6VEjgYq3hJiEhowcKKm6J8AdRD/8oZ5dQ==} + '@tanstack/react-router@1.160.0': + resolution: {integrity: sha512-leT/nymh9rKFVivy4b/F8/PZiMrLpotNiyemNg0/KjdZNzo5oVEdFnsXVFnBI1lL4WXRbiq7RK8+fI0SKsT6ww==} engines: {node: '>=12'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-store@0.8.0': - resolution: {integrity: sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==} + '@tanstack/react-store@0.8.1': + resolution: {integrity: sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1296,30 +1253,30 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.159.4': - resolution: {integrity: sha512-MFzPH39ijNO83qJN3pe7x4iAlhZyqgao3sJIzv3SJ4Pnk12xMnzuDzIAQT/1WV6JolPQEcw0Wr4L5agF8yxoeg==} + '@tanstack/router-core@1.160.0': + resolution: {integrity: sha512-vbh6OsE0MG+0c+SKh2uk5yEEZlWsxT96Ub2JaTs7ixOvZp3Wu9PTEIe2BA3cShNZhEsDI0Le4NqgY4XIaHLLvA==} engines: {node: '>=12'} - '@tanstack/router-devtools-core@1.159.4': - resolution: {integrity: sha512-qMUeIv+6n1mZOcO2raCIbdOeDeMpJEmgm6oMs/nWEG61lYrzJYaCcpBTviAX0nRhSiQSUCX9cHiosUEA0e2HAw==} + '@tanstack/router-devtools-core@1.160.0': + resolution: {integrity: sha512-P/l0GVd0qmDbskg8/UbkOrCxuFz0t69BCxv2j4+8Xfy8AcqnFtoR1LChKgYyxGPy9sWOxktAneFdy1xA3X/Q6A==} engines: {node: '>=12'} peerDependencies: - '@tanstack/router-core': ^1.159.4 + '@tanstack/router-core': ^1.160.0 csstype: ^3.0.10 peerDependenciesMeta: csstype: optional: true - '@tanstack/router-generator@1.159.4': - resolution: {integrity: sha512-O8tICQoSuvK6vs3mvBdI3zVLFmYfj/AYDCX0a5msSADP/2S0GsgDDTB5ah731TqYCtjeNriaWz9iqst38cjF/Q==} + '@tanstack/router-generator@1.160.0': + resolution: {integrity: sha512-th0le0A1p6ra0BOr00cVqbMiovXN9QjH2Gb0/6elisL9cmmQkJe51RAFueWUnBevkZe1TDGkwlmb0aNsd0ZynQ==} engines: {node: '>=12'} - '@tanstack/router-plugin@1.159.5': - resolution: {integrity: sha512-i2LR3WRaBOAZ1Uab5QBG9UxZIRJ3V56JVu890NysbuX15rgzRiL5yLAbfenOHdhaHy2+4joX35VICAHuVWy7Og==} + '@tanstack/router-plugin@1.160.0': + resolution: {integrity: sha512-a3fCI+Bk9n9ILXOoKqmYuvXpN8HX/Vxcy1BoBx0rYUUToGzxvYiT1J8tfwWgxEtiiX/ZVq8lxAqV1O1RIPGcfQ==} engines: {node: '>=12'} peerDependencies: '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.159.5 + '@tanstack/react-router': ^1.160.0 vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' vite-plugin-solid: ^2.11.10 webpack: '>=5.92.0' @@ -1339,11 +1296,8 @@ packages: resolution: {integrity: sha512-qZ76eaLKU6Ae9iI/mc5zizBX149DXXZkBVVO3/QRIll79uKLJZHQlMKR++2ba7JsciBWz1pgpIBcCJPE9S0LVg==} engines: {node: '>=12'} - '@tanstack/store@0.7.7': - resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} - - '@tanstack/store@0.8.0': - resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + '@tanstack/store@0.8.1': + resolution: {integrity: sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw==} '@tanstack/table-core@8.21.3': resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} @@ -1457,8 +1411,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} @@ -1562,8 +1516,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001769: - resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + caniuse-lite@1.0.30001770: + resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2187,8 +2141,8 @@ packages: mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - mdn-data@2.27.0: - resolution: {integrity: sha512-/pUmP9UebM48q5BTqZd0yPnDjyRGhITbKh8cwa6/ZwjuDu8xq+VzmugLF7QNxpdaqqNH3J5nnv3yc8oARv096A==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} meow@14.0.0: resolution: {integrity: sha512-JhC3R1f6dbspVtmF3vKjAWz1EVIvwFrGGPLSdU6rK79xBwHWTuHoLnRX/t1/zHS1Ch1Y2UtIrih7DAHuH9JFJA==} @@ -2392,8 +2346,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - qs@6.14.2: - resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -3362,7 +3316,7 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inlang/paraglide-js@2.11.0': + '@inlang/paraglide-js@2.12.0': dependencies: '@inlang/recommend-sherlock': 0.2.1 '@inlang/sdk': 2.7.0 @@ -3785,11 +3739,11 @@ snapshots: - csstype - utf-8-validate - '@tanstack/form-core@1.28.1': + '@tanstack/form-core@1.28.3': dependencies: '@tanstack/devtools-event-client': 0.4.0 '@tanstack/pacer-lite': 0.1.1 - '@tanstack/store': 0.7.7 + '@tanstack/store': 0.8.1 '@tanstack/history@1.154.14': {} @@ -3812,10 +3766,10 @@ snapshots: - solid-js - utf-8-validate - '@tanstack/react-form@1.28.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-form@1.28.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/form-core': 1.28.1 - '@tanstack/react-store': 0.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/form-core': 1.28.3 + '@tanstack/react-store': 0.8.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 transitivePeerDependencies: - react-dom @@ -3831,31 +3785,31 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.2.4 - '@tanstack/react-router-devtools@1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.159.4)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router-devtools@1.160.0(@tanstack/react-router@1.160.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.160.0)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/react-router': 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-devtools-core': 1.159.4(@tanstack/router-core@1.159.4)(csstype@3.2.3) + '@tanstack/react-router': 1.160.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-devtools-core': 1.160.0(@tanstack/router-core@1.160.0)(csstype@3.2.3) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) optionalDependencies: - '@tanstack/router-core': 1.159.4 + '@tanstack/router-core': 1.160.0 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router@1.160.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/history': 1.154.14 - '@tanstack/react-store': 0.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-core': 1.159.4 + '@tanstack/react-store': 0.8.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-core': 1.160.0 isbot: 5.1.35 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-store@0.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-store@0.8.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/store': 0.8.0 + '@tanstack/store': 0.8.1 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) use-sync-external-store: 1.6.0(react@19.2.4) @@ -3872,28 +3826,28 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@tanstack/router-core@1.159.4': + '@tanstack/router-core@1.160.0': dependencies: '@tanstack/history': 1.154.14 - '@tanstack/store': 0.8.0 + '@tanstack/store': 0.8.1 cookie-es: 2.0.0 seroval: 1.5.0 seroval-plugins: 1.5.0(seroval@1.5.0) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.159.4(@tanstack/router-core@1.159.4)(csstype@3.2.3)': + '@tanstack/router-devtools-core@1.160.0(@tanstack/router-core@1.160.0)(csstype@3.2.3)': dependencies: - '@tanstack/router-core': 1.159.4 + '@tanstack/router-core': 1.160.0 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) tiny-invariant: 1.3.3 optionalDependencies: csstype: 3.2.3 - '@tanstack/router-generator@1.159.4': + '@tanstack/router-generator@1.160.0': dependencies: - '@tanstack/router-core': 1.159.4 + '@tanstack/router-core': 1.160.0 '@tanstack/router-utils': 1.158.0 '@tanstack/virtual-file-routes': 1.154.7 prettier: 3.8.1 @@ -3904,7 +3858,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.159.5(@tanstack/react-router@1.159.5(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.160.0(@tanstack/react-router@1.160.0(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))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -3912,15 +3866,15 @@ snapshots: '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 - '@tanstack/router-core': 1.159.4 - '@tanstack/router-generator': 1.159.4 + '@tanstack/router-core': 1.160.0 + '@tanstack/router-generator': 1.160.0 '@tanstack/router-utils': 1.158.0 '@tanstack/virtual-file-routes': 1.154.7 chokidar: 3.6.0 unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-router': 1.160.0(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) transitivePeerDependencies: - supports-color @@ -3939,9 +3893,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/store@0.7.7': {} - - '@tanstack/store@0.8.0': {} + '@tanstack/store@0.8.1': {} '@tanstack/table-core@8.21.3': {} @@ -4040,7 +3992,7 @@ snapshots: acorn@8.15.0: {} - ajv@8.17.1: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.0 @@ -4079,7 +4031,7 @@ snapshots: autoprefixer@10.4.24(postcss@8.5.6): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001769 + caniuse-lite: 1.0.30001770 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.6 @@ -4117,7 +4069,7 @@ snapshots: browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 + caniuse-lite: 1.0.30001770 electron-to-chromium: 1.5.286 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -4144,7 +4096,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001769: {} + caniuse-lite@1.0.30001770: {} ccount@2.0.1: {} @@ -4790,7 +4742,7 @@ snapshots: mdn-data@2.12.2: {} - mdn-data@2.27.0: {} + mdn-data@2.27.1: {} meow@14.0.0: {} @@ -5039,7 +4991,7 @@ snapshots: dependencies: react: 19.2.4 - qs@6.14.2: + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -5384,7 +5336,7 @@ snapshots: css-tree: 3.1.0 is-plain-object: 5.0.0 known-css-properties: 0.37.0 - mdn-data: 2.27.0 + mdn-data: 2.27.1 postcss-media-query-parser: 0.2.3 postcss-resolve-nested-selector: 0.1.6 postcss-selector-parser: 7.1.1 @@ -5449,7 +5401,7 @@ snapshots: table@6.9.0: dependencies: - ajv: 8.17.1 + ajv: 8.18.0 lodash.truncate: 4.4.2 slice-ansi: 4.0.0 string-width: 4.2.3 From 1be3805ec33e2932d40eee6f56070377d6819cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 16 Feb 2026 11:46:22 +0100 Subject: [PATCH 02/22] put stats endpoints into a separate module --- .../src/handlers/location_stats.rs | 130 ++++++++++++++++++ crates/defguard_core/src/handlers/mod.rs | 1 + .../defguard_core/src/handlers/wireguard.rs | 126 +---------------- crates/defguard_core/src/lib.rs | 13 +- 4 files changed, 142 insertions(+), 128 deletions(-) create mode 100644 crates/defguard_core/src/handlers/location_stats.rs 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..1a6a6c2c83 --- /dev/null +++ b/crates/defguard_core/src/handlers/location_stats.rs @@ -0,0 +1,130 @@ +use std::str::FromStr; + +use axum::extract::{Path, Query, State}; +use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc}; +use defguard_common::db::models::{ + DeviceType, WireguardNetwork, + wireguard::{ + DateTimeAggregation, WireguardDeviceStatsRow, WireguardNetworkStats, WireguardUserStatsRow, + networks_stats, + }, +}; +use reqwest::StatusCode; + +use crate::{ + appstate::AppState, + auth::AdminRole, + error::WebError, + handlers::{ApiResponse, ApiResult}, +}; + +#[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), + }) + } +} + +/// 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)) +} + +#[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)) +} diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index bb5ff434ba..9f801d5b82 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -33,6 +33,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/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index df54c04d4c..6358b71fe5 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}, @@ -1368,114 +1361,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 ee100c6825..dfea52d3f1 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -50,7 +50,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; @@ -123,6 +123,7 @@ use crate::{ add_group_member, create_group, delete_group, get_group, list_groups, modify_group, remove_group_member, }, + location_stats::{devices_stats, location_stats, locations_overview_stats}, mail::{send_support_data, test_mail}, openid_clients::{ add_openid_client, change_openid_client, change_openid_client_state, @@ -151,9 +152,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}, }, @@ -498,7 +499,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}", @@ -522,7 +523,7 @@ pub fn build_webapp( 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}/snat", get(list_snat_bindings).post(create_snat_binding), From df5a6f09e852b4902e77642802b6aad32f5f43d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 16 Feb 2026 13:47:19 +0100 Subject: [PATCH 03/22] add dedicated API endpoints for connected users & network devices --- .../src/handlers/activity_log.rs | 21 +---- .../src/handlers/location_stats.rs | 94 +++++++++++++++++-- .../defguard_core/src/handlers/pagination.rs | 20 ++++ crates/defguard_core/src/lib.rs | 14 ++- 4 files changed, 121 insertions(+), 28 deletions(-) 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 index 1a6a6c2c83..23695bc784 100644 --- a/crates/defguard_core/src/handlers/location_stats.rs +++ b/crates/defguard_core/src/handlers/location_stats.rs @@ -2,11 +2,14 @@ use std::str::FromStr; use axum::extract::{Path, Query, State}; use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc}; -use defguard_common::db::models::{ - DeviceType, WireguardNetwork, - wireguard::{ - DateTimeAggregation, WireguardDeviceStatsRow, WireguardNetworkStats, WireguardUserStatsRow, - networks_stats, +use defguard_common::db::{ + Id, + models::{ + DeviceType, WireguardNetwork, + wireguard::{ + DateTimeAggregation, WireguardDeviceStatsRow, WireguardNetworkStats, WireguardStatsRow, + WireguardUserStatsRow, networks_stats, + }, }, }; use reqwest::StatusCode; @@ -15,10 +18,13 @@ use crate::{ appstate::AppState, auth::AdminRole, error::WebError, - handlers::{ApiResponse, ApiResult}, + handlers::{ + ApiResponse, ApiResult, DEFAULT_API_PAGE_SIZE, + pagination::{PaginatedApiResponse, PaginatedApiResult, PaginationMeta, PaginationParams}, + }, }; -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] pub(crate) struct QueryFrom { from: Option, } @@ -128,3 +134,77 @@ pub(crate) async fn devices_stats( Ok(ApiResponse::json(response, StatusCode::OK)) } + +#[derive(Serialize)] +pub(crate) struct LocationConnectedUser { + 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, +} + +/// 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 connected_users = todo!(); + let total_items = todo!(); + + let pagination = + PaginationMeta::new(pagination.page, total_items as u32, DEFAULT_API_PAGE_SIZE); + + Ok(PaginatedApiResponse { + data: connected_users, + pagination, + }) +} + +#[derive(Serialize)] +pub(crate) struct LocationConnectedNetworkDevice {} + +/// Returns paginated list of connected users 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 connected_network_devices = todo!(); + let total_items = todo!(); + + let pagination = + PaginationMeta::new(pagination.page, total_items as u32, DEFAULT_API_PAGE_SIZE); + + Ok(PaginatedApiResponse { + data: connected_network_devices, + pagination, + }) +} 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/lib.rs b/crates/defguard_core/src/lib.rs index dfea52d3f1..d7753ebf7e 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -123,7 +123,10 @@ use crate::{ add_group_member, create_group, delete_group, get_group, list_groups, modify_group, remove_group_member, }, - location_stats::{devices_stats, location_stats, locations_overview_stats}, + location_stats::{ + devices_stats, location_connected_network_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, @@ -522,8 +525,15 @@ 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(location_stats)) + .route( + "/network/{location_id}/stats/connected_users", + get(location_connected_users), + ) + .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), From c341c48ca46285813d93b0e0293a795bba5c6dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 17 Feb 2026 09:37:10 +0100 Subject: [PATCH 04/22] adjust connected users table UI for a paginated endpoint --- .../LocationOverviewNetworkDevicesTable.tsx | 10 +- .../LocationOverviewPage.tsx | 44 ++-- .../LocationOverviewUsersTable.tsx | 227 ++++++++++-------- .../DeviceTrafficChartCell.tsx | 11 +- .../LocationOverviewCard.tsx | 1 + web/src/shared/api/api.ts | 21 +- web/src/shared/api/types.ts | 16 ++ 7 files changed, 191 insertions(+), 139 deletions(-) diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx index a3f9e65b5d..5af8ef7ba0 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx @@ -6,7 +6,7 @@ import { } from '@tanstack/react-table'; import { sumBy } from 'lodash-es'; import { useMemo } from 'react'; -import type { DeviceStats, LocationDevicesStats } from '../../shared/api/types'; +import type { DeviceStats } from '../../shared/api/types'; 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'; @@ -24,11 +24,7 @@ type RowData = Omit & { const columnHelper = createColumnHelper(); -export const LocationOverviewNetworkDevicesTable = ({ - data, -}: { - data: LocationDevicesStats['network_devices']; -}) => { +export const LocationOverviewNetworkDevicesTable = () => { const mappedData = useMemo((): RowData[] => { const res: RowData[] = data.map((device) => ({ ...device, @@ -37,7 +33,7 @@ export const LocationOverviewNetworkDevicesTable = ({ download: sumBy(device.stats, (s) => s.download), })); return res; - }, [data]); + }, []); const columns = useMemo( () => [ diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx index 6e1dd4bfee..3279b1a218 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'; @@ -47,16 +46,27 @@ export const LocationOverviewPage = () => { refetchInterval: 30_000, }); - const { data: locationDevicesStats } = useQuery({ - queryFn: () => - api.location.getLocationDevicesStats({ - id: Number(locationId), - from: search.period, - }), - queryKey: ['network', Number(locationId), 'stats', 'users'], - select: (resp) => resp.data, - refetchInterval: 30_000, - }); + // const { data: locationUserDevicesStats } = useQuery({ + // queryFn: () => + // api.location.getLocationDevicesStats({ + // id: Number(locationId), + // from: search.period, + // }), + // queryKey: ['network', Number(locationId), 'stats', 'users'], + // select: (resp) => resp.data, + // refetchInterval: 30_000, + // }); + + // const { data: locationNetworkDevicesStats } = useQuery({ + // queryFn: () => + // api.location.getLocationConnectedUsers({ + // id: Number(locationId), + // from: search.period, + // }), + // queryKey: ['network', Number(locationId), 'stats', 'users'], + // select: (resp) => resp.data, + // refetchInterval: 30_000, + // }); return ( @@ -85,12 +95,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 +122,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..62d254ae87 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx @@ -1,3 +1,5 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; import { createColumnHelper, getCoreRowModel, @@ -9,7 +11,8 @@ import { 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 api from '../../shared/api/api'; +import type { DeviceStats, LocationConnectedUser, LocationUserDeviceStats } from '../../shared/api/types'; 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'; @@ -23,23 +26,19 @@ 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 TableDevice = Omit & { +// stats: TransferChartData[]; +// upload: number; +// download: number; +// }; -type Props = { - data: LocationUserDeviceStats[]; -}; +// type RowData = { +// firstName: string; +// lastName: string; +// devices: TableDevice[]; +// } & TableDevice; -const columnHelper = createColumnHelper(); +const columnHelper = createColumnHelper(); const expansionHeaders = [ 'Device name', @@ -50,37 +49,70 @@ 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, - }; +export const LocationOverviewUsersTable = () => { + const search = useSearch({ from: '/_authorized/_default/vpn-overview/$locationId' }); + const _navigate = useNavigate({ from: '/vpn-overview/$locationId' }); + const { locationId } = useParams({ + from: '/_authorized/_default/vpn-overview/$locationId', + }); + + const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({ + queryKey: ['network', Number(locationId), 'stats', 'connected_users'], + 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 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, + // }; + // }), + // [data], + // ); + const [sortState, setSortState] = useState([ { id: 'name', @@ -88,25 +120,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 +132,8 @@ export const LocationOverviewUsersTable = ({ data }: Props) => { {info.getValue()} @@ -132,7 +148,7 @@ export const LocationOverviewUsersTable = ({ data }: Props) => { ), }), - columnHelper.accessor('wireguard_ips', { + columnHelper.accessor('vpn_ips', { header: 'VPN IP', size: 250, cell: (info) => , @@ -149,7 +165,7 @@ export const LocationOverviewUsersTable = ({ data }: Props) => { cell: (info) => ( - {info.row.original.devices.length} + {info.row.original.connected_devices_count} ), }), @@ -159,9 +175,9 @@ 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 ( - + ); }, }), @@ -169,56 +185,56 @@ export const LocationOverviewUsersTable = ({ data }: Props) => { [], ); - const renderExpansionRow = useCallback( - (row: Row, isLast = false) => - row.original.devices.map((device, expandIndex) => ( - - - - - - - {device.name} - - - {device.public_ip} - - - - - - - )), - [], - ); + // const renderExpansionRow = useCallback( + // (row: Row, isLast = false) => + // row.original.devices.map((device, expandIndex) => ( + // + // + // + // + // + // + // {device.name} + // + // + // {device.public_ip} + // + // + // + // + // + // + // )), + // [], + // ); const table = useReactTable({ state: { sorting: sortState, }, columns, - data: transformedData, + data: flatData, getExpandedRowModel: getExpandedRowModel(), getCoreRowModel: getCoreRowModel(), - getRowCanExpand: (row) => row.original.devices?.length >= 1, + // getRowCanExpand: (row) => row.original.devices?.length >= 1, onSortingChange: setSortState, manualSorting: true, enableSorting: true, - enableExpanding: true, + enableExpanding: false, enableRowSelection: false, columnResizeMode: 'onChange', }); - if (data.length === 0) + if (flatData.length === 0) return ( { { + fetchNextPage(); + }} + hasNextPage={pagination?.next_page !== null} /> ); }; diff --git a/web/src/pages/LocationOverviewPage/components/DeviceTrafficChartCell/DeviceTrafficChartCell.tsx b/web/src/pages/LocationOverviewPage/components/DeviceTrafficChartCell/DeviceTrafficChartCell.tsx index be93e88d89..aeb87cdc4f 100644 --- a/web/src/pages/LocationOverviewPage/components/DeviceTrafficChartCell/DeviceTrafficChartCell.tsx +++ b/web/src/pages/LocationOverviewPage/components/DeviceTrafficChartCell/DeviceTrafficChartCell.tsx @@ -3,17 +3,22 @@ import { Bar, BarChart } from 'recharts'; 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'; +import type { TransferStats } from '../../../../shared/api/types'; +import { useMemo } from 'react'; 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 7ad86695d3..8f4434105b 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -57,6 +57,8 @@ import type { GroupsResponse, IpValidation, LicenseInfoResponse, + LocationConnectedUser, + LocationConnectedUsersRequest, LocationDevicesStats, LocationStats, LocationStatsRequest, @@ -317,14 +319,17 @@ 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: { + from: params.from + ? dayjs.utc().subtract(params.from, 'hour').toISOString() + : undefined, + ...params, + }, + }) + .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 b627527bbe..59e65f1969 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -582,6 +582,8 @@ export interface LocationStatsRequest { from?: number; } +export type LocationConnectedUsersRequest = LocationStatsRequest & PaginationParams; + export interface DeleteGatewayRequest { networkId: number | string; gatewayId: number | string; @@ -606,6 +608,20 @@ 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 const LocationServiceMode = { Disabled: 'disabled', Prelogon: 'prelogon', From f6d6a63867e7535912c3f28926fa26dd1d12fcfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Feb 2026 10:04:08 +0100 Subject: [PATCH 05/22] implement fetching connected users stats --- .../src/db/models/vpn_session_stats.rs | 20 +-- .../src/db/models/wireguard.rs | 135 +++++++++++++++++- .../src/handlers/location_stats.rs | 51 +++---- 3 files changed, 167 insertions(+), 39 deletions(-) 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 0d9208657a..710ccdd8f8 100644 --- a/crates/defguard_common/src/db/models/wireguard.rs +++ b/crates/defguard_common/src/db/models/wireguard.rs @@ -12,8 +12,8 @@ use model_derive::Model; use rand::rngs::OsRng; use serde::{Deserialize, Serialize}; use sqlx::{ - Error as SqlxError, FromRow, PgConnection, PgExecutor, PgPool, Type, query, query_as, - query_scalar, + Error as SqlxError, FromRow, PgConnection, PgExecutor, PgPool, Type, + query, query_as, query_scalar, }; use thiserror::Error; use tracing::{debug, info}; @@ -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,118 @@ 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, \ + COUNT(*) OVER (PARTITION BY vcs.user_id) \"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 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' \ + 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 \ + WHERE s.user_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(), + 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 \ + WHERE vcs.location_id = $1 AND vcs.state = 'connected'", + self.id, + ) + .fetch_one(conn) + .await? + .unwrap_or(0); + + Ok((page_result, total_items as u32)) + } + /// Retrieves total active users/devices since `from` timestamp /// /// A user/device is considered active if a session is currently connected @@ -1112,6 +1224,23 @@ 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, +} + pub async fn networks_stats( pool: &PgPool, from: &NaiveDateTime, diff --git a/crates/defguard_core/src/handlers/location_stats.rs b/crates/defguard_core/src/handlers/location_stats.rs index 23695bc784..e9febe9a61 100644 --- a/crates/defguard_core/src/handlers/location_stats.rs +++ b/crates/defguard_core/src/handlers/location_stats.rs @@ -2,14 +2,11 @@ use std::str::FromStr; use axum::extract::{Path, Query, State}; use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc}; -use defguard_common::db::{ - Id, - models::{ - DeviceType, WireguardNetwork, - wireguard::{ - DateTimeAggregation, WireguardDeviceStatsRow, WireguardNetworkStats, WireguardStatsRow, - WireguardUserStatsRow, networks_stats, - }, +use defguard_common::db::models::{ + DeviceType, WireguardNetwork, + wireguard::{ + DateTimeAggregation, LocationConnectedUserStats, WireguardDeviceStatsRow, + WireguardNetworkStats, WireguardUserStatsRow, networks_stats, }, }; use reqwest::StatusCode; @@ -135,23 +132,6 @@ pub(crate) async fn devices_stats( Ok(ApiResponse::json(response, StatusCode::OK)) } -#[derive(Serialize)] -pub(crate) struct LocationConnectedUser { - 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, -} - /// Returns paginated list of connected users for a given location /// /// # Returns @@ -162,13 +142,28 @@ pub(crate) async fn location_connected_users( Path(location_id): Path, Query(query_from): Query, pagination: Query, -) -> PaginatedApiResult { +) -> PaginatedApiResult { debug!( "Displaying connected users for location {location_id} with time window {query_from:?} and pagination {pagination:?}" ); - let connected_users = todo!(); - let total_items = todo!(); + 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 as u32, DEFAULT_API_PAGE_SIZE); From 6fc273fd533ac234823c20bdf43be61ce6150cd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Feb 2026 10:27:52 +0100 Subject: [PATCH 06/22] fix query params --- web/src/shared/api/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index 8f4434105b..be8f42fdbd 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -323,10 +323,10 @@ const api = { client .get>(`/network/${id}/stats/connected_users`, { params: { + ...params, from: params.from ? dayjs.utc().subtract(params.from, 'hour').toISOString() : undefined, - ...params, }, }) .then((resp) => resp.data), From 26ff432e9423a491d60fc5bccb7ee82a3530a89b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Feb 2026 10:40:43 +0100 Subject: [PATCH 07/22] update connected network devices helper struct --- .../defguard_common/src/db/models/wireguard.rs | 18 ++++++++++++++++-- .../src/handlers/location_stats.rs | 18 +++++++++++------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/crates/defguard_common/src/db/models/wireguard.rs b/crates/defguard_common/src/db/models/wireguard.rs index 710ccdd8f8..8276e23e4c 100644 --- a/crates/defguard_common/src/db/models/wireguard.rs +++ b/crates/defguard_common/src/db/models/wireguard.rs @@ -12,8 +12,8 @@ use model_derive::Model; use rand::rngs::OsRng; use serde::{Deserialize, Serialize}; use sqlx::{ - Error as SqlxError, FromRow, PgConnection, PgExecutor, PgPool, Type, - query, query_as, query_scalar, + Error as SqlxError, FromRow, PgConnection, PgExecutor, PgPool, Type, query, query_as, + query_scalar, }; use thiserror::Error; use tracing::{debug, info}; @@ -1241,6 +1241,20 @@ pub struct LocationConnectedUserStats { 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, +} + pub async fn networks_stats( pool: &PgPool, from: &NaiveDateTime, diff --git a/crates/defguard_core/src/handlers/location_stats.rs b/crates/defguard_core/src/handlers/location_stats.rs index e9febe9a61..63a8a402bd 100644 --- a/crates/defguard_core/src/handlers/location_stats.rs +++ b/crates/defguard_core/src/handlers/location_stats.rs @@ -5,8 +5,8 @@ use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc}; use defguard_common::db::models::{ DeviceType, WireguardNetwork, wireguard::{ - DateTimeAggregation, LocationConnectedUserStats, WireguardDeviceStatsRow, - WireguardNetworkStats, WireguardUserStatsRow, networks_stats, + DateTimeAggregation, LocationConnectedNetworkDevice, LocationConnectedUserStats, + WireguardDeviceStatsRow, WireguardNetworkStats, WireguardUserStatsRow, networks_stats, }, }; use reqwest::StatusCode; @@ -174,9 +174,6 @@ pub(crate) async fn location_connected_users( }) } -#[derive(Serialize)] -pub(crate) struct LocationConnectedNetworkDevice {} - /// Returns paginated list of connected users for a given location /// /// # Returns @@ -192,8 +189,15 @@ pub(crate) async fn location_connected_network_devices( "Displaying connected network devices for location {location_id} with time window {query_from:?} and pagination {pagination:?}" ); - let connected_network_devices = todo!(); - let total_items = todo!(); + 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) = todo!(); let pagination = PaginationMeta::new(pagination.page, total_items as u32, DEFAULT_API_PAGE_SIZE); From 0cdbf194ac8bd602fe75a7210e7e3111133ee7db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Feb 2026 10:46:00 +0100 Subject: [PATCH 08/22] implement the network devices endpoint --- .../src/db/models/wireguard.rs | 111 ++++++++++++++++++ .../src/handlers/location_stats.rs | 12 +- 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/crates/defguard_common/src/db/models/wireguard.rs b/crates/defguard_common/src/db/models/wireguard.rs index 8276e23e4c..6a33994b54 100644 --- a/crates/defguard_common/src/db/models/wireguard.rs +++ b/crates/defguard_common/src/db/models/wireguard.rs @@ -743,6 +743,117 @@ impl WireguardNetwork { 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 total active users/devices since `from` timestamp /// /// A user/device is considered active if a session is currently connected diff --git a/crates/defguard_core/src/handlers/location_stats.rs b/crates/defguard_core/src/handlers/location_stats.rs index 63a8a402bd..e1cf6618c7 100644 --- a/crates/defguard_core/src/handlers/location_stats.rs +++ b/crates/defguard_core/src/handlers/location_stats.rs @@ -174,7 +174,7 @@ pub(crate) async fn location_connected_users( }) } -/// Returns paginated list of connected users for a given location +/// 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 @@ -197,7 +197,15 @@ pub(crate) async fn location_connected_network_devices( let from = query_from.parse_timestamp()?.naive_utc(); let aggregation = get_aggregation(from)?; - let (connected_network_devices, total_items) = todo!(); + 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 as u32, DEFAULT_API_PAGE_SIZE); From e9bfc4ec2473ad64f59be92a042f883f10a387e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Feb 2026 10:47:53 +0100 Subject: [PATCH 09/22] lint fix --- crates/defguard_core/src/handlers/location_stats.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/defguard_core/src/handlers/location_stats.rs b/crates/defguard_core/src/handlers/location_stats.rs index e1cf6618c7..1ed2672869 100644 --- a/crates/defguard_core/src/handlers/location_stats.rs +++ b/crates/defguard_core/src/handlers/location_stats.rs @@ -165,8 +165,7 @@ pub(crate) async fn location_connected_users( ) .await?; - let pagination = - PaginationMeta::new(pagination.page, total_items as u32, DEFAULT_API_PAGE_SIZE); + let pagination = PaginationMeta::new(pagination.page, total_items, DEFAULT_API_PAGE_SIZE); Ok(PaginatedApiResponse { data: connected_users, @@ -207,8 +206,7 @@ pub(crate) async fn location_connected_network_devices( ) .await?; - let pagination = - PaginationMeta::new(pagination.page, total_items as u32, DEFAULT_API_PAGE_SIZE); + let pagination = PaginationMeta::new(pagination.page, total_items, DEFAULT_API_PAGE_SIZE); Ok(PaginatedApiResponse { data: connected_network_devices, From 4bfcfcf4d32bfb16ad4b04e1ce5334435584119b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Feb 2026 11:09:28 +0100 Subject: [PATCH 10/22] assign network ips to devices --- .../src/vpn_session_stats.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tools/defguard_generator/src/vpn_session_stats.rs b/tools/defguard_generator/src/vpn_session_stats.rs index b306abb647..7e22211822 100644 --- a/tools/defguard_generator/src/vpn_session_stats.rs +++ b/tools/defguard_generator/src/vpn_session_stats.rs @@ -6,6 +6,7 @@ use defguard_common::db::{ Id, models::{ WireguardNetwork, + device::WireguardNetworkDevice, gateway::Gateway, vpn_client_session::{VpnClientSession, VpnClientSessionState}, vpn_session_stats::VpnSessionStats, @@ -69,6 +70,27 @@ pub async fn generate_vpn_session_stats( let devices = prepare_user_devices(&pool, &mut rng, &user, config.devices_per_user as usize).await?; + // assign devices to the network if not already assigned + for device in &devices { + if WireguardNetworkDevice::find(&mut *transaction, device.id, location.id) + .await? + .is_none() + { + info!( + "Assigning device {} to network {} with auto-generated IP", + device.name, location.name + ); + device + .assign_next_network_ip(&mut transaction, &location, None, None) + .await?; + } else { + info!( + "Device {} already assigned to network {}", + device.name, location.name + ); + } + } + for device in devices { info!("Generating sessions for device {device}"); // generate requested number of sessions for a device From a94758d42cfad2136c4410b481852f72614b4c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Feb 2026 11:10:00 +0100 Subject: [PATCH 11/22] update network devices table --- .../LocationOverviewNetworkDevicesTable.tsx | 116 +++++++++++------- .../LocationOverviewUsersTable.tsx | 76 +++--------- .../DeviceTrafficChartCell.tsx | 7 +- web/src/shared/api/api.ts | 37 ++++-- web/src/shared/api/types.ts | 18 ++- 5 files changed, 132 insertions(+), 122 deletions(-) diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx index 5af8ef7ba0..78c67ee063 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx @@ -1,55 +1,71 @@ +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 } from '../../shared/api/types'; +import { useMemo, useState } from 'react'; +import api from '../../shared/api/api'; +import type { LocationConnectedNetworkDevice } from '../../shared/api/types'; 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(); +const columnHelper = createColumnHelper(); export const LocationOverviewNetworkDevicesTable = () => { - 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; - }, []); + const search = useSearch({ from: '/_authorized/_default/vpn-overview/$locationId' }); + const { locationId } = useParams({ + from: '/_authorized/_default/vpn-overview/$locationId', + }); + + const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({ + queryKey: ['network', Number(locationId), 'stats', 'connected_network_devices'], + 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 lastItem = flatQueryData ? flatQueryData[flatQueryData?.length - 1] : null; + const pagination = lastItem ? lastItem.pagination : null; + + 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()} @@ -65,7 +81,7 @@ export const LocationOverviewNetworkDevicesTable = () => { ), }), - columnHelper.accessor('wireguard_ips', { + columnHelper.accessor('vpn_ips', { size: 250, header: 'VPN IP', cell: (info) => , @@ -81,11 +97,12 @@ export const LocationOverviewNetworkDevicesTable = () => { size: 500, cell: (info) => { const row = info.row.original; + const { stats, total_download, total_upload } = row; return ( ); }, @@ -95,25 +112,21 @@ 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 (flatData.length === 0) return ( { /> ); - return ; + return ( + { + fetchNextPage(); + }} + hasNextPage={pagination?.next_page !== null} + /> + ); }; diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx index 62d254ae87..588ee7cfbf 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx @@ -1,42 +1,23 @@ import { useInfiniteQuery } from '@tanstack/react-query'; -import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; +import { useParams, useSearch } from '@tanstack/react-router'; import { createColumnHelper, getCoreRowModel, getExpandedRowModel, - type Row, type SortingState, useReactTable, } from '@tanstack/react-table'; -import clsx from 'clsx'; -import { orderBy, sumBy } from 'lodash-es'; -import { useCallback, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import api from '../../shared/api/api'; -import type { DeviceStats, LocationConnectedUser, LocationUserDeviceStats } from '../../shared/api/types'; +import type { LocationConnectedUser } from '../../shared/api/types'; 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 { 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; const columnHelper = createColumnHelper(); @@ -51,7 +32,6 @@ const expansionHeaders = [ export const LocationOverviewUsersTable = () => { const search = useSearch({ from: '/_authorized/_default/vpn-overview/$locationId' }); - const _navigate = useNavigate({ from: '/vpn-overview/$locationId' }); const { locationId } = useParams({ from: '/_authorized/_default/vpn-overview/$locationId', }); @@ -63,7 +43,7 @@ export const LocationOverviewUsersTable = () => { api.location.getLocationConnectedUsers({ id: Number(locationId), from: search.period, - page: pageParam + page: pageParam, }), getNextPageParam: (lastPage) => lastPage?.pagination.next_page, getPreviousPageParam: (page) => { @@ -83,36 +63,6 @@ export const LocationOverviewUsersTable = () => { const lastItem = flatQueryData ? flatQueryData[flatQueryData?.length - 1] : null; const pagination = lastItem ? lastItem.pagination : null; - // 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, - // }; - // }), - // [data], - // ); - const [sortState, setSortState] = useState([ { id: 'name', @@ -175,9 +125,13 @@ export const LocationOverviewUsersTable = () => { header: 'Traffic', cell: (info) => { const row = info.row.original; - const { stats, total_download, total_upload } = row; + const { stats, total_download, total_upload } = row; return ( - + ); }, }), @@ -247,11 +201,11 @@ export const LocationOverviewUsersTable = () => { table={table} expandedHeaders={expansionHeaders} // renderExpandedRow={renderExpansionRow} - loadingNextPage={isFetchingNextPage} - onNextPage={() => { - fetchNextPage(); - }} - hasNextPage={pagination?.next_page !== null} + loadingNextPage={isFetchingNextPage} + onNextPage={() => { + fetchNextPage(); + }} + hasNextPage={pagination?.next_page !== null} /> ); }; diff --git a/web/src/pages/LocationOverviewPage/components/DeviceTrafficChartCell/DeviceTrafficChartCell.tsx b/web/src/pages/LocationOverviewPage/components/DeviceTrafficChartCell/DeviceTrafficChartCell.tsx index aeb87cdc4f..094a641432 100644 --- a/web/src/pages/LocationOverviewPage/components/DeviceTrafficChartCell/DeviceTrafficChartCell.tsx +++ b/web/src/pages/LocationOverviewPage/components/DeviceTrafficChartCell/DeviceTrafficChartCell.tsx @@ -1,11 +1,11 @@ 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 { mapTransferToChart } from '../../../../shared/utils/stats'; -import type { TransferStats } from '../../../../shared/api/types'; -import { useMemo } from 'react'; export const DeviceTrafficChartCell = ({ stats, @@ -16,8 +16,7 @@ export const DeviceTrafficChartCell = ({ upload: number; download: number; }) => { - -const traffic = useMemo(() => mapTransferToChart(stats), [stats]); + const traffic = useMemo(() => mapTransferToChart(stats), [stats]); return ( diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index be8f42fdbd..2382b3c155 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -57,9 +57,10 @@ import type { GroupsResponse, IpValidation, LicenseInfoResponse, + LocationConnectedNetworkDevice, + LocationConnectedNetworkDevicesRequest, LocationConnectedUser, LocationConnectedUsersRequest, - LocationDevicesStats, LocationStats, LocationStatsRequest, LoginRequest, @@ -321,14 +322,34 @@ const api = { client.delete(`/network/${networkId}/gateways/${gatewayId}`), getLocationConnectedUsers: ({ id, ...params }: LocationConnectedUsersRequest) => client - .get>(`/network/${id}/stats/connected_users`, { - params: { - ...params, - from: params.from - ? dayjs.utc().subtract(params.from, 'hour').toISOString() - : undefined, + .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), addLocation: (data: EditNetworkLocation) => client.post('/network', data), diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 59e65f1969..d4f1f56f77 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -584,6 +584,9 @@ export interface LocationStatsRequest { export type LocationConnectedUsersRequest = LocationStatsRequest & PaginationParams; +export type LocationConnectedNetworkDevicesRequest = LocationStatsRequest & + PaginationParams; + export interface DeleteGatewayRequest { networkId: number | string; gatewayId: number | string; @@ -614,8 +617,19 @@ export interface LocationConnectedUser { last_name: string; full_name: string; connected_devices_count: number; -public_ip: string; -vpn_ips: string[]; + 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; From 5d278bcb2c2f7f48a905a2f928264c16ad34cc54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Feb 2026 11:20:51 +0100 Subject: [PATCH 12/22] filter out network devices from connected users queries --- crates/defguard_common/src/db/models/wireguard.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/defguard_common/src/db/models/wireguard.rs b/crates/defguard_common/src/db/models/wireguard.rs index 6a33994b54..c30a679927 100644 --- a/crates/defguard_common/src/db/models/wireguard.rs +++ b/crates/defguard_common/src/db/models/wireguard.rs @@ -668,8 +668,9 @@ impl WireguardNetwork { 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' \ + 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, @@ -693,9 +694,11 @@ impl WireguardNetwork { 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 \ @@ -733,7 +736,8 @@ impl WireguardNetwork { let total_items: i64 = query_scalar!( "SELECT COUNT(DISTINCT vcs.user_id) \ FROM vpn_client_session vcs \ - WHERE vcs.location_id = $1 AND vcs.state = 'connected'", + 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) From ca6feed28b449cb7cb053eb5ef003dcbb9a9a9ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Feb 2026 12:57:30 +0100 Subject: [PATCH 13/22] fix user device count --- crates/defguard_common/src/db/models/wireguard.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/defguard_common/src/db/models/wireguard.rs b/crates/defguard_common/src/db/models/wireguard.rs index c30a679927..b06d483691 100644 --- a/crates/defguard_common/src/db/models/wireguard.rs +++ b/crates/defguard_common/src/db/models/wireguard.rs @@ -658,7 +658,13 @@ impl WireguardNetwork { 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, \ - COUNT(*) OVER (PARTITION BY vcs.user_id) \"connected_devices_count!\" \ + (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 88362a4483f0410175273e012a83476800d81fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Feb 2026 13:09:26 +0100 Subject: [PATCH 14/22] add table skeletons --- .../LocationOverviewNetworkDevicesTable.tsx | 5 ++++- .../LocationOverviewPage/LocationOverviewUsersTable.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx index 78c67ee063..c18f444c2a 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx @@ -9,6 +9,7 @@ import { 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 { TableBody } from '../../shared/defguard-ui/components/table/TableBody/TableBody'; @@ -24,7 +25,7 @@ export const LocationOverviewNetworkDevicesTable = () => { from: '/_authorized/_default/vpn-overview/$locationId', }); - const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({ + const { data, fetchNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({ queryKey: ['network', Number(locationId), 'stats', 'connected_network_devices'], initialPageParam: 1, queryFn: ({ pageParam }) => @@ -126,6 +127,8 @@ export const LocationOverviewNetworkDevicesTable = () => { columnResizeMode: 'onChange', }); + if (isLoading) return ; + if (flatData.length === 0) return ( { from: '/_authorized/_default/vpn-overview/$locationId', }); - const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({ + const { data, fetchNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({ queryKey: ['network', Number(locationId), 'stats', 'connected_users'], initialPageParam: 1, queryFn: ({ pageParam }) => @@ -188,6 +189,8 @@ export const LocationOverviewUsersTable = () => { columnResizeMode: 'onChange', }); + if (isLoading) return ; + if (flatData.length === 0) return ( Date: Wed, 18 Feb 2026 13:26:52 +0100 Subject: [PATCH 15/22] add endpoint for listing active user devices --- .../src/db/models/wireguard.rs | 107 ++++++++++++++++++ .../src/handlers/location_stats.rs | 50 +++++++- crates/defguard_core/src/lib.rs | 8 +- 3 files changed, 161 insertions(+), 4 deletions(-) diff --git a/crates/defguard_common/src/db/models/wireguard.rs b/crates/defguard_common/src/db/models/wireguard.rs index b06d483691..e9eb3c6fef 100644 --- a/crates/defguard_common/src/db/models/wireguard.rs +++ b/crates/defguard_common/src/db/models/wireguard.rs @@ -864,6 +864,99 @@ impl WireguardNetwork { 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 @@ -1376,6 +1469,20 @@ pub struct LocationConnectedNetworkDevice { 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/location_stats.rs b/crates/defguard_core/src/handlers/location_stats.rs index 1ed2672869..4d286e5532 100644 --- a/crates/defguard_core/src/handlers/location_stats.rs +++ b/crates/defguard_core/src/handlers/location_stats.rs @@ -5,8 +5,9 @@ use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc}; use defguard_common::db::models::{ DeviceType, WireguardNetwork, wireguard::{ - DateTimeAggregation, LocationConnectedNetworkDevice, LocationConnectedUserStats, - WireguardDeviceStatsRow, WireguardNetworkStats, WireguardUserStatsRow, networks_stats, + DateTimeAggregation, LocationConnectedNetworkDevice, LocationConnectedUserDevice, + LocationConnectedUserStats, WireguardDeviceStatsRow, WireguardNetworkStats, + WireguardUserStatsRow, networks_stats, }, }; use reqwest::StatusCode; @@ -213,3 +214,48 @@ pub(crate) async fn location_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/lib.rs b/crates/defguard_core/src/lib.rs index d7753ebf7e..ed7b97b20e 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -124,8 +124,8 @@ use crate::{ remove_group_member, }, location_stats::{ - devices_stats, location_connected_network_devices, location_connected_users, - location_stats, locations_overview_stats, + devices_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::{ @@ -530,6 +530,10 @@ pub fn build_webapp( "/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), From 5c6d3a106776adcb18fc4bf4b7dca53c993e33ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Feb 2026 15:26:12 +0100 Subject: [PATCH 16/22] show expanded user devices rows --- .../LocationOverviewUsersTable.tsx | 151 +++++++++++++----- .../components/ConnectionDurationCell.tsx | 6 +- web/src/shared/api/api.ts | 19 +++ web/src/shared/api/types.ts | 17 ++ 4 files changed, 155 insertions(+), 38 deletions(-) diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx index 38b07be1d3..1ddbe9c533 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx @@ -1,13 +1,16 @@ -import { useInfiniteQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { useParams, useSearch } from '@tanstack/react-router'; import { createColumnHelper, getCoreRowModel, getExpandedRowModel, + type Row, type SortingState, useReactTable, } from '@tanstack/react-table'; -import { useMemo, useState } from 'react'; +import clsx from 'clsx'; +import { useCallback, useMemo, useState } from 'react'; +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'; @@ -17,6 +20,9 @@ import { EmptyStateFlexible } from '../../shared/defguard-ui/components/EmptySta 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 { ConnectionDurationCell } from './components/ConnectionDurationCell'; import { DeviceTrafficChartCell } from './components/DeviceTrafficChartCell/DeviceTrafficChartCell'; @@ -31,6 +37,99 @@ const expansionHeaders = [ 'Device traffic', ]; +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({ @@ -140,37 +239,17 @@ export const LocationOverviewUsersTable = () => { [], ); - // const renderExpansionRow = useCallback( - // (row: Row, isLast = false) => - // row.original.devices.map((device, expandIndex) => ( - // - // - // - // - // - // - // {device.name} - // - // - // {device.public_ip} - // - // - // - // - // - // - // )), - // [], - // ); + const renderExpansionRow = useCallback( + (row: Row, isLast = false) => ( + + ), + [locationId, search.period], + ); const table = useReactTable({ state: { @@ -180,11 +259,11 @@ export const LocationOverviewUsersTable = () => { 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, - enableExpanding: false, + enableExpanding: true, enableRowSelection: false, columnResizeMode: 'onChange', }); @@ -203,7 +282,7 @@ export const LocationOverviewUsersTable = () => { { fetchNextPage(); 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/shared/api/api.ts b/web/src/shared/api/api.ts index 2382b3c155..ed02bbf519 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -60,6 +60,8 @@ import type { LocationConnectedNetworkDevice, LocationConnectedNetworkDevicesRequest, LocationConnectedUser, + LocationConnectedUserDevice, + LocationConnectedUserDevicesRequest, LocationConnectedUsersRequest, LocationStats, LocationStatsRequest, @@ -351,6 +353,23 @@ const api = { }, ) .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 d4f1f56f77..d40a157cfe 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -636,6 +636,23 @@ export interface LocationConnectedNetworkDevice { 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', From 6ea8477d689f17da1cf554e443481f3c95490a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Feb 2026 15:37:13 +0100 Subject: [PATCH 17/22] refetch data on period change --- .../LocationOverviewNetworkDevicesTable.tsx | 2 +- web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx | 2 +- .../pages/LocationOverviewPage/LocationOverviewUsersTable.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx index c18f444c2a..e53add0948 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx @@ -26,7 +26,7 @@ export const LocationOverviewNetworkDevicesTable = () => { }); const { data, fetchNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({ - queryKey: ['network', Number(locationId), 'stats', 'connected_network_devices'], + queryKey: ['network', Number(locationId), 'stats', 'connected_network_devices', search.period], initialPageParam: 1, queryFn: ({ pageParam }) => api.location.getLocationConnectedNetworkDevices({ diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx index 3279b1a218..2d87f33d82 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx @@ -41,7 +41,7 @@ export const LocationOverviewPage = () => { id: Number(locationId), from: search.period, }), - queryKey: ['network', Number(locationId), 'stats'], + queryKey: ['network', Number(locationId), 'stats', search.period], select: (resp) => resp.data, refetchInterval: 30_000, }); diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx index 1ddbe9c533..99f1055210 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx @@ -137,7 +137,7 @@ export const LocationOverviewUsersTable = () => { }); const { data, fetchNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({ - queryKey: ['network', Number(locationId), 'stats', 'connected_users'], + queryKey: ['network', Number(locationId), 'stats', 'connected_users', search.period], initialPageParam: 1, queryFn: ({ pageParam }) => api.location.getLocationConnectedUsers({ From 9b5b95b3d67d83b9c9581876cb61a847785cdd29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Feb 2026 15:57:44 +0100 Subject: [PATCH 18/22] cleanup --- .../src/handlers/location_stats.rs | 47 ++----------------- crates/defguard_core/src/lib.rs | 2 +- .../LocationOverviewNetworkDevicesTable.tsx | 8 +++- .../LocationOverviewPage.tsx | 22 --------- web/src/shared/api/api.ts | 4 +- 5 files changed, 12 insertions(+), 71 deletions(-) diff --git a/crates/defguard_core/src/handlers/location_stats.rs b/crates/defguard_core/src/handlers/location_stats.rs index 4d286e5532..0ebbc5bbb8 100644 --- a/crates/defguard_core/src/handlers/location_stats.rs +++ b/crates/defguard_core/src/handlers/location_stats.rs @@ -3,11 +3,10 @@ use std::str::FromStr; use axum::extract::{Path, Query, State}; use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc}; use defguard_common::db::models::{ - DeviceType, WireguardNetwork, + WireguardNetwork, wireguard::{ - DateTimeAggregation, LocationConnectedNetworkDevice, LocationConnectedUserDevice, - LocationConnectedUserStats, WireguardDeviceStatsRow, WireguardNetworkStats, - WireguardUserStatsRow, networks_stats, + DateTimeAggregation, LocationConnectedNetworkDevice, LocationConnectedUserStats, + WireguardNetworkStats, networks_stats, }, }; use reqwest::StatusCode; @@ -93,46 +92,6 @@ pub(crate) async fn location_stats( Ok(ApiResponse::json(stats, StatusCode::OK)) } -#[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 paginated list of connected users for a given location /// /// # Returns diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index ed7b97b20e..7618e5e267 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -124,7 +124,7 @@ use crate::{ remove_group_member, }, location_stats::{ - devices_stats, location_connected_network_devices, location_connected_user_devices, + location_connected_network_devices, location_connected_user_devices, location_connected_users, location_stats, locations_overview_stats, }, mail::{send_support_data, test_mail}, diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx index e53add0948..a3130d8b8f 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewNetworkDevicesTable.tsx @@ -26,7 +26,13 @@ export const LocationOverviewNetworkDevicesTable = () => { }); const { data, fetchNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({ - queryKey: ['network', Number(locationId), 'stats', 'connected_network_devices', search.period], + queryKey: [ + 'network', + Number(locationId), + 'stats', + 'connected_network_devices', + search.period, + ], initialPageParam: 1, queryFn: ({ pageParam }) => api.location.getLocationConnectedNetworkDevices({ diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx index 2d87f33d82..c3f7efb423 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewPage.tsx @@ -46,28 +46,6 @@ export const LocationOverviewPage = () => { refetchInterval: 30_000, }); - // const { data: locationUserDevicesStats } = useQuery({ - // queryFn: () => - // api.location.getLocationDevicesStats({ - // id: Number(locationId), - // from: search.period, - // }), - // queryKey: ['network', Number(locationId), 'stats', 'users'], - // select: (resp) => resp.data, - // refetchInterval: 30_000, - // }); - - // const { data: locationNetworkDevicesStats } = useQuery({ - // queryFn: () => - // api.location.getLocationConnectedUsers({ - // id: Number(locationId), - // from: search.period, - // }), - // queryKey: ['network', Number(locationId), 'stats', 'users'], - // select: (resp) => resp.data, - // refetchInterval: 30_000, - // }); - return ( diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index ed02bbf519..578c485456 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -363,9 +363,7 @@ const api = { `/network/${locationId}/stats/connected_users/${userId}/devices`, { params: { - from: from - ? dayjs.utc().subtract(from, 'hour').toISOString() - : undefined, + from: from ? dayjs.utc().subtract(from, 'hour').toISOString() : undefined, }, }, ) From e88bc0617c0c9993aca71f0ecb0a27f79715560d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Feb 2026 16:03:41 +0100 Subject: [PATCH 19/22] update dependencies --- Cargo.lock | 8 ++++---- flake.lock | 6 +++--- web/package.json | 4 ++-- web/pnpm-lock.yaml | 36 ++++++++++++++++++------------------ 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4ffc9266d..3b46d3e64b 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", @@ -7879,9 +7879,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/flake.lock b/flake.lock index 386f086f53..81e1a6e539 100644 --- a/flake.lock +++ b/flake.lock @@ -74,11 +74,11 @@ ] }, "locked": { - "lastModified": 1771211437, - "narHash": "sha256-lcNK438i4DGtyA+bPXXyVLHVmJjYpVKmpux9WASa3ro=", + "lastModified": 1771384185, + "narHash": "sha256-KvmjUeA7uODwzbcQoN/B8DCZIbhT/Q/uErF1BBMcYnw=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "c62195b3d6e1bb11e0c2fb2a494117d3b55d410f", + "rev": "23dd7fa91602a68bd04847ac41bc10af1e6e2fd2", "type": "github" }, "original": { diff --git a/web/package.json b/web/package.json index 12eec1dd54..8acd905ecd 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", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index e4927abf19..76991d2e08 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) @@ -1823,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 @@ -2227,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 @@ -2362,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 @@ -4368,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: @@ -4892,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 @@ -5004,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: From 9377e2d3667f73816cb1da05b5185cf138ad78e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Feb 2026 16:03:48 +0100 Subject: [PATCH 20/22] update query data --- ...d02a086eb5dd8b8efa330084ba9be30e25485.json | 47 +++++++++++++++ ...af56b9e45a6281509789987b38fc47c755b18.json | 22 +++++++ ...848cac7029a895bde3c312bed9636f07331c3.json | 48 +++++++++++++++ ...f1882eda51ff6ddbdec27f3129f06c92dd4a9.json | 38 ++++++++++++ ...23ebffd0ee78d03e9b256dac6d808dbe6061b.json | 60 +++++++++++++++++++ ...28de8d1395d2233a5f5c24efcb421e12c98cc.json | 38 ++++++++++++ ...436de52298bb2e98e01ce6c3793327b2b4960.json | 22 +++++++ 7 files changed, 275 insertions(+) create mode 100644 .sqlx/query-04a2da85c042070254732c837bfd02a086eb5dd8b8efa330084ba9be30e25485.json create mode 100644 .sqlx/query-0eee6a7f4675b70b495fb06d56faf56b9e45a6281509789987b38fc47c755b18.json create mode 100644 .sqlx/query-4121e51c2b2984cbc598c9f9a48848cac7029a895bde3c312bed9636f07331c3.json create mode 100644 .sqlx/query-7a585bcfa87fa425878052126f9f1882eda51ff6ddbdec27f3129f06c92dd4a9.json create mode 100644 .sqlx/query-89641e4cfdad3b6290f693d7fa923ebffd0ee78d03e9b256dac6d808dbe6061b.json create mode 100644 .sqlx/query-ad54a6c79202f121dcfb03a4d9128de8d1395d2233a5f5c24efcb421e12c98cc.json create mode 100644 .sqlx/query-b3d04286006c95591c10089b8b6436de52298bb2e98e01ce6c3793327b2b4960.json 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" +} From bbe0d54e409066fbe51b721baf9bcb5051d6fa2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 19 Feb 2026 09:19:51 +0100 Subject: [PATCH 21/22] add API test --- .../tests/integration/api/location_stats.rs | 219 ++++++++++++++++++ .../tests/integration/api/mod.rs | 1 + 2 files changed, 220 insertions(+) create mode 100644 crates/defguard_core/tests/integration/api/location_stats.rs 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; From 9f93621bd8cd139829ca261016daccd87de96ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 19 Feb 2026 09:31:55 +0100 Subject: [PATCH 22/22] update dependencies --- Cargo.lock | 8 ++--- flake.lock | 12 ++++---- web/package.json | 2 +- web/pnpm-lock.yaml | 74 +++++++++++++++++++++++----------------------- 4 files changed, 48 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e629f3572e..4605097ac2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/flake.lock b/flake.lock index 81e1a6e539..ee7ca9b5fd 100644 --- a/flake.lock +++ b/flake.lock @@ -32,11 +32,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1771008912, - "narHash": "sha256-gf2AmWVTs8lEq7z/3ZAsgnZDhWIckkb+ZnAo5RzSxJg=", + "lastModified": 1771369470, + "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "a82ccc39b39b621151d6732718e3e250109076fa", + "rev": "0182a361324364ae3f436a63005877674cf45efb", "type": "github" }, "original": { @@ -74,11 +74,11 @@ ] }, "locked": { - "lastModified": 1771384185, - "narHash": "sha256-KvmjUeA7uODwzbcQoN/B8DCZIbhT/Q/uErF1BBMcYnw=", + "lastModified": 1771470520, + "narHash": "sha256-PvytHcaYN5cPUll7FB70mXv1rRsIBRmu47fFfq3haxA=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "23dd7fa91602a68bd04847ac41bc10af1e6e2fd2", + "rev": "a1d4cc1f264c45d3745af0d2ca5e59d460e58777", "type": "github" }, "original": { diff --git a/web/package.json b/web/package.json index 8acd905ecd..f47259ca05 100644 --- a/web/package.json +++ b/web/package.json @@ -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 76991d2e08..b85255b82f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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: @@ -1367,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==} @@ -1849,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: @@ -1908,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: @@ -2567,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: @@ -2745,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==} @@ -3083,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)': @@ -3363,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 @@ -3705,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 @@ -3717,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 @@ -3858,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) @@ -3875,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 @@ -3955,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': {} @@ -3982,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' @@ -4384,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: @@ -4449,7 +4449,7 @@ snapshots: dependencies: has-symbols: 1.1.0 - hashery@1.4.0: + hashery@1.5.0: dependencies: hookified: 1.15.1 @@ -5279,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: @@ -5379,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 @@ -5528,7 +5528,7 @@ snapshots: typescript@5.9.3: {} - undici-types@7.16.0: {} + undici-types@7.18.2: {} unicorn-magic@0.4.0: {} @@ -5622,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) @@ -5639,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