diff --git a/package.json b/package.json index f4c6615..3320389 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "cmdk": "^1.1.1", "color": "^5.0.2", "flag-icons": "^7.5.0", + "lucide-react": "^0.555.0", "motion": "^12.23.24", "next": "^15.5.6", "next-themes": "^0.4.6", @@ -60,6 +61,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-icons": "^5.5.0", + "recharts": "2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tauri-plugin-macos-permissions-api": "^2.3.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50f3067..272b647 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: flag-icons: specifier: ^7.5.0 version: 7.5.0 + lucide-react: + specifier: ^0.555.0 + version: 0.555.0(react@19.2.0) motion: specifier: ^12.23.24 version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -104,6 +107,9 @@ importers: react-icons: specifier: ^5.5.0 version: 5.5.0(react@19.2.0) + recharts: + specifier: 2.15.4 + version: 2.15.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -1819,6 +1825,33 @@ packages: '@types/color@4.2.0': resolution: {integrity: sha512-6+xrIRImMtGAL2X3qYkd02Mgs+gFGs+WsK0b7VVMaO4mYRISwyTjcqNrO0mNSmYEoq++rSLDB2F5HDNmqfOe+A==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -2024,6 +2057,50 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} @@ -2036,6 +2113,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -2058,6 +2138,9 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + donutbrowser-camoufox-js@0.7.0: resolution: {integrity: sha512-wEO2QYx1NhPrMz6mgjxvdJhXEINuAYhh3msIAHMuI3NVBOvRd+Ujc6/0KO+MQCnc9ds6T9fePrZbjFc7VcaXAg==} engines: {node: '>= 20'} @@ -2099,6 +2182,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -2106,6 +2192,10 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + fast-equals@5.3.3: + resolution: {integrity: sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==} + engines: {node: '>=6.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2261,6 +2351,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + intersection-observer@0.12.2: resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019. @@ -2422,9 +2516,18 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@0.555.0: + resolution: {integrity: sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2538,6 +2641,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2587,6 +2694,9 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-chain@2.5.9: resolution: {integrity: sha512-DZZKtRz92WuXd7fzRTKgI/oGhjmSgGMgT3FweLunCztpaG5jDVOJp1jgRPAVLQD1SG6HhkOyRkj6RTF3A214bg==} engines: {node: '>=14'} @@ -2627,6 +2737,12 @@ packages: peerDependencies: react: '*' + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -2651,6 +2767,12 @@ packages: '@types/react': optional: true + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + 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 + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -2661,6 +2783,12 @@ packages: '@types/react': optional: true + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react@19.2.0: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} @@ -2673,6 +2801,16 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -2822,6 +2960,9 @@ packages: tauri-plugin-macos-permissions-api@2.3.0: resolution: {integrity: sha512-pZp0jmDySysBqrGueknd1a7Rr4XEO9aXpMv9TNrT2PDHP0MSH20njieOagsFYJ5MCVb8A+wcaK0cIkjUC2dOww==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tiny-lru@11.4.5: resolution: {integrity: sha512-hkcz3FjNJfKXjV4mjQ1OrXSLAehg8Hw+cEZclOVT+5c/cWQWImQ9wolzTjth+dmmDe++p3bme3fTxz6Q4Etsqw==} engines: {node: '>=12'} @@ -2933,6 +3074,9 @@ packages: resolution: {integrity: sha512-sgECfZthyaCKW10N0fm27cg8HYTFK5qMWgypqkXMQ4Wbl/zZKx7xZICgcoxIIE+WFAP/MBL2EFwC/YvLxw3Zeg==} engines: {node: '>=0.10.0'} + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite@7.0.6: resolution: {integrity: sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4451,6 +4595,30 @@ snapshots: dependencies: '@types/color-convert': 2.0.4 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.8': {} '@types/js-cookie@3.0.6': {} @@ -4663,6 +4831,44 @@ snapshots: csstype@3.1.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.0: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + dayjs@1.11.19: {} debug@4.4.3(supports-color@5.5.0): @@ -4671,6 +4877,8 @@ snapshots: optionalDependencies: supports-color: 5.5.0 + decimal.js-light@2.5.1: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -4685,6 +4893,11 @@ snapshots: diff@4.0.2: {} + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.28.4 + csstype: 3.1.3 + donutbrowser-camoufox-js@0.7.0(playwright-core@1.56.1): dependencies: adm-zip: 0.5.16 @@ -4752,10 +4965,14 @@ snapshots: escalade@3.2.0: {} + eventemitter3@4.0.7: {} + eventemitter3@5.0.1: {} expand-template@2.0.3: {} + fast-equals@5.3.3: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -4865,6 +5082,8 @@ snapshots: ini@1.3.8: {} + internmap@2.0.3: {} + intersection-observer@0.12.2: {} ip-address@10.1.0: {} @@ -4993,10 +5212,18 @@ snapshots: strip-ansi: 7.1.2 wrap-ansi: 9.0.2 + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lucide-react@0.555.0(react@19.2.0): + dependencies: + react: 19.2.0 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5098,6 +5325,8 @@ snapshots: normalize-path@3.0.0: {} + object-assign@4.1.1: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5153,6 +5382,12 @@ snapshots: progress@2.0.3: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + proxy-chain@2.5.9: dependencies: socks: 2.8.7 @@ -5249,6 +5484,10 @@ snapshots: dependencies: react: 19.2.0 + react-is@16.13.1: {} + + react-is@18.3.1: {} + react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.3)(react@19.2.0): @@ -5270,6 +5509,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.3 + react-smooth@4.0.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + fast-equals: 5.3.3 + prop-types: 15.8.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-transition-group: 4.4.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-style-singleton@2.2.3(@types/react@19.2.3)(react@19.2.0): dependencies: get-nonce: 1.0.1 @@ -5278,6 +5525,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.3 + react-transition-group@4.4.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@babel/runtime': 7.28.4 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react@19.2.0: {} readable-stream@3.6.2: @@ -5290,6 +5546,23 @@ snapshots: dependencies: picomatch: 2.3.1 + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.17.21 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + resize-observer-polyfill@1.5.1: {} restore-cursor@5.1.0: @@ -5477,6 +5750,8 @@ snapshots: dependencies: '@tauri-apps/api': 2.9.0 + tiny-invariant@1.3.3: {} + tiny-lru@11.4.5: {} tinyglobby@0.2.15: @@ -5576,6 +5851,23 @@ snapshots: vali-date@1.0.0: {} + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@7.0.6(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1): dependencies: esbuild: 0.25.12 diff --git a/src-tauri/src/bin/proxy_server.rs b/src-tauri/src/bin/proxy_server.rs index 6d906ba..6b5eb65 100644 --- a/src-tauri/src/bin/proxy_server.rs +++ b/src-tauri/src/bin/proxy_server.rs @@ -1,6 +1,6 @@ use clap::{Arg, Command}; use donutbrowser_lib::proxy_runner::{ - start_proxy_process, stop_all_proxy_processes, stop_proxy_process, + start_proxy_process_with_profile, stop_all_proxy_processes, stop_proxy_process, }; use donutbrowser_lib::proxy_server::run_proxy_server; use donutbrowser_lib::proxy_storage::get_proxy_config; @@ -87,6 +87,11 @@ async fn main() { .short('u') .long("upstream") .help("Upstream proxy URL (protocol://[username:password@]host:port)"), + ) + .arg( + Arg::new("profile-id") + .long("profile-id") + .help("ID of the profile this proxy is associated with"), ), ) .subcommand( @@ -138,8 +143,9 @@ async fn main() { } let port = start_matches.get_one::("port").copied(); + let profile_id = start_matches.get_one::("profile-id").cloned(); - match start_proxy_process(upstream_url, port).await { + match start_proxy_process_with_profile(upstream_url, port, profile_id).await { Ok(config) => { // Output the configuration as JSON for the Rust side to parse // Use println! here because this needs to go to stdout for parsing diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 4b1b370..1c734d6 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -149,12 +149,13 @@ impl BrowserRunner { // Start the proxy and get local proxy settings // If proxy startup fails, DO NOT launch Camoufox - it requires local proxy + let profile_id_str = profile.id.to_string(); let local_proxy = PROXY_MANAGER .start_proxy( app_handle.clone(), upstream_proxy.as_ref(), 0, // Use 0 as temporary PID, will be updated later - Some(&profile.name), + Some(&profile_id_str), ) .await .map_err(|e| { @@ -823,6 +824,7 @@ impl BrowserRunner { // Use a temporary PID (1) to start the proxy, we'll update it after browser launch let temp_pid = 1u32; + let profile_id_str = profile.id.to_string(); // Start local proxy - if this fails, DO NOT launch browser let internal_proxy = PROXY_MANAGER @@ -830,7 +832,7 @@ impl BrowserRunner { app_handle.clone(), upstream_proxy.as_ref(), temp_pid, - Some(&profile.name), + Some(&profile_id_str), ) .await .map_err(|e| { @@ -1695,6 +1697,7 @@ pub async fn launch_browser_profile( // Use a temporary PID (1) to start the proxy, we'll update it after browser launch let temp_pid = 1u32; + let profile_id_str = profile.id.to_string(); // Always start a local proxy, even if there's no upstream proxy // This allows for traffic monitoring and future features @@ -1703,7 +1706,7 @@ pub async fn launch_browser_profile( app_handle.clone(), upstream_proxy.as_ref(), temp_pid, - Some(&profile.name), + Some(&profile_id_str), ) .await { diff --git a/src-tauri/src/camoufox_manager.rs b/src-tauri/src/camoufox_manager.rs index 09d161d..1da4296 100644 --- a/src-tauri/src/camoufox_manager.rs +++ b/src-tauri/src/camoufox_manager.rs @@ -349,6 +349,8 @@ impl CamoufoxManager { } /// Find Camoufox server by profile path (for integration with browser_runner) + /// This method first checks in-memory instances, then scans system processes + /// to detect Camoufox instances that may have been started before the app restarted. pub async fn find_camoufox_by_profile( &self, profile_path: &str, @@ -356,41 +358,127 @@ impl CamoufoxManager { // First clean up any dead instances self.cleanup_dead_instances().await?; - let inner = self.inner.lock().await; - // Convert paths to canonical form for comparison let target_path = std::path::Path::new(profile_path) .canonicalize() .unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf()); - for (id, instance) in inner.instances.iter() { - if let Some(instance_profile_path) = &instance.profile_path { - let instance_path = std::path::Path::new(instance_profile_path) - .canonicalize() - .unwrap_or_else(|_| std::path::Path::new(instance_profile_path).to_path_buf()); + // Check in-memory instances first + { + let inner = self.inner.lock().await; - if instance_path == target_path { - // Verify the server is actually running by checking the process - if let Some(process_id) = instance.process_id { - if self.is_server_running(process_id).await { - // Found running Camoufox instance - return Ok(Some(CamoufoxLaunchResult { - id: id.clone(), - processId: instance.process_id, - profilePath: instance.profile_path.clone(), - url: instance.url.clone(), - })); - } else { - // Camoufox instance found but process is not running + for (id, instance) in inner.instances.iter() { + if let Some(instance_profile_path) = &instance.profile_path { + let instance_path = std::path::Path::new(instance_profile_path) + .canonicalize() + .unwrap_or_else(|_| std::path::Path::new(instance_profile_path).to_path_buf()); + + if instance_path == target_path { + // Verify the server is actually running by checking the process + if let Some(process_id) = instance.process_id { + if self.is_server_running(process_id).await { + // Found running Camoufox instance + return Ok(Some(CamoufoxLaunchResult { + id: id.clone(), + processId: instance.process_id, + profilePath: instance.profile_path.clone(), + url: instance.url.clone(), + })); + } } } } } } + // If not found in in-memory instances, scan system processes + // This handles the case where the app was restarted but Camoufox is still running + if let Some((pid, found_profile_path)) = self.find_camoufox_process_by_profile(&target_path) { + log::info!( + "Found running Camoufox process (PID: {}) for profile path via system scan", + pid + ); + + // Register this instance in our tracking + let instance_id = format!("recovered_{}", pid); + let mut inner = self.inner.lock().await; + inner.instances.insert( + instance_id.clone(), + CamoufoxInstance { + id: instance_id.clone(), + process_id: Some(pid), + profile_path: Some(found_profile_path.clone()), + url: None, + }, + ); + + return Ok(Some(CamoufoxLaunchResult { + id: instance_id, + processId: Some(pid), + profilePath: Some(found_profile_path), + url: None, + })); + } + Ok(None) } + /// Scan system processes to find a Camoufox process using a specific profile path + fn find_camoufox_process_by_profile( + &self, + target_path: &std::path::Path, + ) -> Option<(u32, String)> { + use sysinfo::{ProcessRefreshKind, RefreshKind, System}; + + let system = System::new_with_specifics( + RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()), + ); + + let target_path_str = target_path.to_string_lossy(); + + for (pid, process) in system.processes() { + let cmd = process.cmd(); + if cmd.is_empty() { + continue; + } + + // Check if this is a Camoufox/Firefox process + let exe_name = process.name().to_string_lossy().to_lowercase(); + let is_firefox_like = exe_name.contains("firefox") + || exe_name.contains("camoufox") + || exe_name.contains("firefox-bin"); + + if !is_firefox_like { + continue; + } + + // Check if the command line contains our profile path + for (i, arg) in cmd.iter().enumerate() { + if let Some(arg_str) = arg.to_str() { + // Check for -profile argument followed by our path + if arg_str == "-profile" && i + 1 < cmd.len() { + if let Some(next_arg) = cmd.get(i + 1).and_then(|a| a.to_str()) { + let cmd_path = std::path::Path::new(next_arg) + .canonicalize() + .unwrap_or_else(|_| std::path::Path::new(next_arg).to_path_buf()); + + if cmd_path == target_path { + return Some((pid.as_u32(), next_arg.to_string())); + } + } + } + + // Also check if the argument contains the profile path directly + if arg_str.contains(&*target_path_str) { + return Some((pid.as_u32(), target_path_str.to_string())); + } + } + } + } + + None + } + /// Check if servers are still alive and clean up dead instances pub async fn cleanup_dead_instances( &self, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5e404c7..a19efef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -30,6 +30,7 @@ pub mod proxy_runner; pub mod proxy_server; pub mod proxy_storage; mod settings_manager; +pub mod traffic_stats; // mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme mod tag_manager; mod version_updater; @@ -246,6 +247,27 @@ async fn is_geoip_database_available() -> Result { Ok(GeoIPDownloader::is_geoip_database_available()) } +#[tauri::command] +async fn get_all_traffic_stats() -> Result, String> { + Ok(crate::traffic_stats::list_traffic_stats()) +} + +#[tauri::command] +async fn get_all_traffic_snapshots() -> Result, String> { + Ok( + crate::traffic_stats::list_traffic_stats() + .into_iter() + .map(|s| s.to_snapshot()) + .collect(), + ) +} + +#[tauri::command] +async fn clear_all_traffic_stats() -> Result<(), String> { + crate::traffic_stats::clear_all_traffic_stats() + .map_err(|e| format!("Failed to clear traffic stats: {e}")) +} + #[tauri::command] async fn download_geoip_database(app_handle: tauri::AppHandle) -> Result<(), String> { let downloader = GeoIPDownloader::instance(); @@ -756,7 +778,10 @@ pub fn run() { warm_up_nodecar, start_api_server, stop_api_server, - get_api_server_status + get_api_server_status, + get_all_traffic_stats, + get_all_traffic_snapshots, + clear_all_traffic_stats ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 59eb82b..5a23230 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -20,8 +20,8 @@ pub struct ProxyInfo { pub upstream_port: u16, pub upstream_type: String, pub local_port: u16, - // Optional profile name to which this proxy instance is logically tied - pub profile_name: Option, + // Optional profile ID to which this proxy instance is logically tied + pub profile_id: Option, } // Proxy check result cache @@ -594,14 +594,14 @@ impl ProxyManager { app_handle: tauri::AppHandle, proxy_settings: Option<&ProxySettings>, browser_pid: u32, - profile_name: Option<&str>, + profile_id: Option<&str>, ) -> Result { // First, proactively cleanup any dead proxies so we don't accidentally reuse stale ones let _ = self.cleanup_dead_proxies(app_handle.clone()).await; // If we have a previous proxy tied to this profile, and the upstream settings are changing, // stop it before starting a new one so the change takes effect immediately. - if let Some(name) = profile_name { + if let Some(name) = profile_id { // Check if we have an active proxy recorded for this profile let maybe_existing_id = { let map = self.profile_active_proxy_ids.lock().unwrap(); @@ -711,6 +711,11 @@ impl ProxyManager { } } + // Add profile ID if provided for traffic tracking + if let Some(id) = profile_id { + proxy_cmd = proxy_cmd.arg("--profile-id").arg(id); + } + // Execute the command and wait for it to complete // The donut-proxy binary should start the worker and then exit let output = proxy_cmd @@ -755,7 +760,7 @@ impl ProxyManager { .map(|p| p.proxy_type.clone()) .unwrap_or_else(|| "DIRECT".to_string()), local_port, - profile_name: profile_name.map(|s| s.to_string()), + profile_id: profile_id.map(|s| s.to_string()), }; // Wait for the local proxy port to be ready to accept connections @@ -789,14 +794,14 @@ impl ProxyManager { } // Store the profile proxy info for persistence - if let Some(name) = profile_name { + if let Some(id) = profile_id { if let Some(proxy_settings) = proxy_settings { let mut profile_proxies = self.profile_proxies.lock().unwrap(); - profile_proxies.insert(name.to_string(), proxy_settings.clone()); + profile_proxies.insert(id.to_string(), proxy_settings.clone()); } // Also record the active proxy id for this profile for quick cleanup on changes let mut map = self.profile_active_proxy_ids.lock().unwrap(); - map.insert(name.to_string(), proxy_info.id.clone()); + map.insert(id.to_string(), proxy_info.id.clone()); } // Return proxy settings for the browser @@ -815,10 +820,10 @@ impl ProxyManager { app_handle: tauri::AppHandle, browser_pid: u32, ) -> Result<(), String> { - let (proxy_id, profile_name): (String, Option) = { + let (proxy_id, profile_id): (String, Option) = { let mut proxies = self.active_proxies.lock().unwrap(); match proxies.remove(&browser_pid) { - Some(proxy) => (proxy.id, proxy.profile_name.clone()), + Some(proxy) => (proxy.id, proxy.profile_id.clone()), None => return Ok(()), // No proxy to stop } }; @@ -842,11 +847,11 @@ impl ProxyManager { } // Clear profile-to-proxy mapping if it references this proxy - if let Some(name) = profile_name { + if let Some(id) = profile_id { let mut map = self.profile_active_proxy_ids.lock().unwrap(); - if let Some(current_id) = map.get(&name) { + if let Some(current_id) = map.get(&id) { if current_id == &proxy_id { - map.remove(&name); + map.remove(&id); } } } @@ -1035,7 +1040,7 @@ mod tests { upstream_port: 3128, upstream_type: "http".to_string(), local_port: (8000 + i) as u16, - profile_name: None, + profile_id: None, }; // Add proxy diff --git a/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index b071eaa..7e39b06 100644 --- a/src-tauri/src/proxy_runner.rs +++ b/src-tauri/src/proxy_runner.rs @@ -11,6 +11,14 @@ lazy_static::lazy_static! { pub async fn start_proxy_process( upstream_url: Option, port: Option, +) -> Result> { + start_proxy_process_with_profile(upstream_url, port, None).await +} + +pub async fn start_proxy_process_with_profile( + upstream_url: Option, + port: Option, + profile_id: Option, ) -> Result> { let id = generate_proxy_id(); let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string()); @@ -22,7 +30,7 @@ pub async fn start_proxy_process( listener.local_addr().unwrap().port() }); - let config = ProxyConfig::new(id.clone(), upstream, Some(local_port)); + let config = ProxyConfig::new(id.clone(), upstream, Some(local_port)).with_profile_id(profile_id); save_proxy_config(&config)?; // Spawn proxy worker process in the background using std::process::Command diff --git a/src-tauri/src/proxy_server.rs b/src-tauri/src/proxy_server.rs index e06568a..8d5d1e6 100644 --- a/src-tauri/src/proxy_server.rs +++ b/src-tauri/src/proxy_server.rs @@ -1,4 +1,5 @@ use crate::proxy_storage::ProxyConfig; +use crate::traffic_stats::{get_traffic_tracker, init_traffic_tracker}; use http_body_util::{BodyExt, Full}; use hyper::body::Bytes; use hyper::server::conn::http1; @@ -9,12 +10,79 @@ use std::convert::Infallible; use std::io; use std::net::SocketAddr; use std::pin::Pin; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; use std::task::{Context, Poll}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; use tokio::net::TcpListener; use tokio::net::TcpStream; use url::Url; +/// Wrapper stream that counts bytes read and written +struct CountingStream { + inner: S, + bytes_read: Arc, + bytes_written: Arc, +} + +impl CountingStream { + fn new(inner: S) -> Self { + Self { + inner, + bytes_read: Arc::new(AtomicU64::new(0)), + bytes_written: Arc::new(AtomicU64::new(0)), + } + } +} + +impl AsyncRead for CountingStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let filled_before = buf.filled().len(); + let result = Pin::new(&mut self.inner).poll_read(cx, buf); + if let Poll::Ready(Ok(())) = &result { + let bytes_read = buf.filled().len() - filled_before; + self + .bytes_read + .fetch_add(bytes_read as u64, Ordering::Relaxed); + // Update global tracker + if let Some(tracker) = get_traffic_tracker() { + tracker.add_bytes_received(bytes_read as u64); + } + } + result + } +} + +impl AsyncWrite for CountingStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let result = Pin::new(&mut self.inner).poll_write(cx, buf); + if let Poll::Ready(Ok(n)) = &result { + self.bytes_written.fetch_add(*n as u64, Ordering::Relaxed); + // Update global tracker + if let Some(tracker) = get_traffic_tracker() { + tracker.add_bytes_sent(*n as u64); + } + } + result + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + // Wrapper to prepend consumed bytes to a stream struct PrependReader { prepended: Vec, @@ -297,6 +365,13 @@ async fn handle_http( // This is faster and more reliable than trying to use hyper-proxy with version conflicts use reqwest::Client; + // Extract domain for traffic tracking + let domain = req + .uri() + .host() + .map(|h| h.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let client_builder = Client::builder(); let client = if let Some(ref upstream) = upstream_url { if upstream == "DIRECT" { @@ -370,6 +445,12 @@ async fn handle_http( let headers = response.headers().clone(); let body = response.bytes().await.unwrap_or_default(); + // Record request in traffic tracker + let response_size = body.len() as u64; + if let Some(tracker) = get_traffic_tracker() { + tracker.record_request(&domain, body_bytes.len() as u64, response_size); + } + let mut hyper_response = Response::new(Full::new(body)); *hyper_response.status_mut() = StatusCode::from_u16(status.as_u16()).unwrap(); @@ -449,6 +530,14 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box Result<(), Box, pub local_url: Option, pub pid: Option, + #[serde(default)] + pub profile_id: Option, } impl ProxyConfig { @@ -22,8 +24,14 @@ impl ProxyConfig { ignore_proxy_certificate: None, local_url: None, pid: None, + profile_id: None, } } + + pub fn with_profile_id(mut self, profile_id: Option) -> Self { + self.profile_id = profile_id; + self + } } pub fn get_storage_dir() -> PathBuf { diff --git a/src-tauri/src/traffic_stats.rs b/src-tauri/src/traffic_stats.rs new file mode 100644 index 0000000..904f733 --- /dev/null +++ b/src-tauri/src/traffic_stats.rs @@ -0,0 +1,489 @@ +use directories::BaseDirs; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; + +/// Individual bandwidth data point for time-series tracking +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BandwidthDataPoint { + /// Unix timestamp in seconds + pub timestamp: u64, + /// Bytes sent in this interval + pub bytes_sent: u64, + /// Bytes received in this interval + pub bytes_received: u64, +} + +/// Domain access information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DomainAccess { + /// Domain name + pub domain: String, + /// Number of requests to this domain + pub request_count: u64, + /// Total bytes sent to this domain + pub bytes_sent: u64, + /// Total bytes received from this domain + pub bytes_received: u64, + /// First access timestamp + pub first_access: u64, + /// Last access timestamp + pub last_access: u64, +} + +/// Lightweight snapshot for real-time updates (sent via events) +/// Contains only the data needed for the mini chart and summary display +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrafficSnapshot { + /// Profile ID (for matching) + pub profile_id: Option, + /// Session start timestamp + pub session_start: u64, + /// Last update timestamp + pub last_update: u64, + /// Total bytes sent across all time + pub total_bytes_sent: u64, + /// Total bytes received across all time + pub total_bytes_received: u64, + /// Total requests made + pub total_requests: u64, + /// Current bandwidth (bytes per second) sent + pub current_bytes_sent: u64, + /// Current bandwidth (bytes per second) received + pub current_bytes_received: u64, + /// Recent bandwidth history (last 60 seconds only, for mini chart) + pub recent_bandwidth: Vec, +} + +/// Traffic statistics for a profile/proxy session +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrafficStats { + /// Proxy ID this stats belong to + pub proxy_id: String, + /// Profile ID (if associated) + pub profile_id: Option, + /// Session start timestamp + pub session_start: u64, + /// Last update timestamp + pub last_update: u64, + /// Total bytes sent across all time + pub total_bytes_sent: u64, + /// Total bytes received across all time + pub total_bytes_received: u64, + /// Total requests made + pub total_requests: u64, + /// Bandwidth data points (time-series, 1 point per second, max 300 = 5 min) + #[serde(default)] + pub bandwidth_history: Vec, + /// Domain access statistics + #[serde(default)] + pub domains: HashMap, + /// Unique IPs accessed + #[serde(default)] + pub unique_ips: Vec, +} + +impl TrafficStats { + pub fn new(proxy_id: String, profile_id: Option) -> Self { + let now = current_timestamp(); + Self { + proxy_id, + profile_id, + session_start: now, + last_update: now, + total_bytes_sent: 0, + total_bytes_received: 0, + total_requests: 0, + bandwidth_history: Vec::new(), + domains: HashMap::new(), + unique_ips: Vec::new(), + } + } + + /// Create a lightweight snapshot for real-time UI updates + pub fn to_snapshot(&self) -> TrafficSnapshot { + let now = current_timestamp(); + let cutoff = now.saturating_sub(60); // Last 60 seconds for mini chart + + // Get current bandwidth from last data point + let (current_sent, current_recv) = self + .bandwidth_history + .last() + .filter(|dp| dp.timestamp >= now.saturating_sub(2)) // Within last 2 seconds + .map(|dp| (dp.bytes_sent, dp.bytes_received)) + .unwrap_or((0, 0)); + + TrafficSnapshot { + profile_id: self.profile_id.clone(), + session_start: self.session_start, + last_update: self.last_update, + total_bytes_sent: self.total_bytes_sent, + total_bytes_received: self.total_bytes_received, + total_requests: self.total_requests, + current_bytes_sent: current_sent, + current_bytes_received: current_recv, + recent_bandwidth: self + .bandwidth_history + .iter() + .filter(|dp| dp.timestamp >= cutoff) + .cloned() + .collect(), + } + } + + /// Record bandwidth for current second + pub fn record_bandwidth(&mut self, bytes_sent: u64, bytes_received: u64) { + let now = current_timestamp(); + self.last_update = now; + self.total_bytes_sent += bytes_sent; + self.total_bytes_received += bytes_received; + + // Find or create data point for this second + if let Some(last) = self.bandwidth_history.last_mut() { + if last.timestamp == now { + last.bytes_sent += bytes_sent; + last.bytes_received += bytes_received; + return; + } + } + + // Add new data point + self.bandwidth_history.push(BandwidthDataPoint { + timestamp: now, + bytes_sent, + bytes_received, + }); + + // Keep only last 5 minutes (300 seconds) of data + const MAX_HISTORY_SECONDS: usize = 300; + if self.bandwidth_history.len() > MAX_HISTORY_SECONDS { + self.bandwidth_history.remove(0); + } + } + + /// Record a request to a domain + pub fn record_request(&mut self, domain: &str, bytes_sent: u64, bytes_received: u64) { + let now = current_timestamp(); + self.total_requests += 1; + + let entry = self + .domains + .entry(domain.to_string()) + .or_insert(DomainAccess { + domain: domain.to_string(), + request_count: 0, + bytes_sent: 0, + bytes_received: 0, + first_access: now, + last_access: now, + }); + + entry.request_count += 1; + entry.bytes_sent += bytes_sent; + entry.bytes_received += bytes_received; + entry.last_access = now; + } + + /// Record an IP address access + pub fn record_ip(&mut self, ip: &str) { + if !self.unique_ips.contains(&ip.to_string()) { + self.unique_ips.push(ip.to_string()); + } + } + + /// Get bandwidth data for the last N seconds + pub fn get_recent_bandwidth(&self, seconds: u64) -> Vec { + let now = current_timestamp(); + let cutoff = now.saturating_sub(seconds); + self + .bandwidth_history + .iter() + .filter(|dp| dp.timestamp >= cutoff) + .cloned() + .collect() + } +} + +/// Get current Unix timestamp in seconds +fn current_timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Get the traffic stats storage directory +pub fn get_traffic_stats_dir() -> PathBuf { + let base_dirs = BaseDirs::new().expect("Failed to get base directories"); + let mut path = base_dirs.cache_dir().to_path_buf(); + path.push(if cfg!(debug_assertions) { + "DonutBrowserDev" + } else { + "DonutBrowser" + }); + path.push("traffic_stats"); + path +} + +/// Save traffic stats to disk +pub fn save_traffic_stats(stats: &TrafficStats) -> Result<(), Box> { + let storage_dir = get_traffic_stats_dir(); + fs::create_dir_all(&storage_dir)?; + + let file_path = storage_dir.join(format!("{}.json", stats.proxy_id)); + let content = serde_json::to_string(stats)?; + fs::write(&file_path, content)?; + + Ok(()) +} + +/// Load traffic stats from disk +pub fn load_traffic_stats(proxy_id: &str) -> Option { + let storage_dir = get_traffic_stats_dir(); + let file_path = storage_dir.join(format!("{proxy_id}.json")); + + if !file_path.exists() { + return None; + } + + let content = fs::read_to_string(&file_path).ok()?; + serde_json::from_str(&content).ok() +} + +/// List all traffic stats files +pub fn list_traffic_stats() -> Vec { + let storage_dir = get_traffic_stats_dir(); + + if !storage_dir.exists() { + return Vec::new(); + } + + let mut stats = Vec::new(); + if let Ok(entries) = fs::read_dir(&storage_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(s) = serde_json::from_str::(&content) { + stats.push(s); + } + } + } + } + } + + stats +} + +/// Delete traffic stats for a proxy +pub fn delete_traffic_stats(proxy_id: &str) -> bool { + let storage_dir = get_traffic_stats_dir(); + let file_path = storage_dir.join(format!("{proxy_id}.json")); + + if file_path.exists() { + fs::remove_file(&file_path).is_ok() + } else { + false + } +} + +/// Clear all traffic stats (used when clearing cache) +pub fn clear_all_traffic_stats() -> Result<(), Box> { + let storage_dir = get_traffic_stats_dir(); + + if storage_dir.exists() { + for entry in fs::read_dir(&storage_dir)?.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") { + let _ = fs::remove_file(&path); + } + } + } + + Ok(()) +} + +/// Live bandwidth tracker for real-time stats collection in the proxy +/// This is designed to be used from within the proxy server +pub struct LiveTrafficTracker { + pub proxy_id: String, + pub profile_id: Option, + bytes_sent: AtomicU64, + bytes_received: AtomicU64, + requests: AtomicU64, + domain_stats: RwLock>, // domain -> (count, sent, recv) + ips: RwLock>, + #[allow(dead_code)] + session_start: u64, +} + +impl LiveTrafficTracker { + pub fn new(proxy_id: String, profile_id: Option) -> Self { + Self { + proxy_id, + profile_id, + bytes_sent: AtomicU64::new(0), + bytes_received: AtomicU64::new(0), + requests: AtomicU64::new(0), + domain_stats: RwLock::new(HashMap::new()), + ips: RwLock::new(Vec::new()), + session_start: current_timestamp(), + } + } + + pub fn add_bytes_sent(&self, bytes: u64) { + self.bytes_sent.fetch_add(bytes, Ordering::Relaxed); + } + + pub fn add_bytes_received(&self, bytes: u64) { + self.bytes_received.fetch_add(bytes, Ordering::Relaxed); + } + + pub fn record_request(&self, domain: &str, bytes_sent: u64, bytes_received: u64) { + self.requests.fetch_add(1, Ordering::Relaxed); + // Also update total byte counters for HTTP requests (not tunneled) + self.bytes_sent.fetch_add(bytes_sent, Ordering::Relaxed); + self + .bytes_received + .fetch_add(bytes_received, Ordering::Relaxed); + if let Ok(mut stats) = self.domain_stats.write() { + let entry = stats.entry(domain.to_string()).or_insert((0, 0, 0)); + entry.0 += 1; + entry.1 += bytes_sent; + entry.2 += bytes_received; + } + } + + pub fn record_ip(&self, ip: &str) { + if let Ok(mut ips) = self.ips.write() { + if !ips.contains(&ip.to_string()) { + ips.push(ip.to_string()); + } + } + } + + /// Update domain-specific byte counts (called when CONNECT tunnel closes) + pub fn update_domain_bytes(&self, domain: &str, bytes_sent: u64, bytes_received: u64) { + if let Ok(mut stats) = self.domain_stats.write() { + let entry = stats.entry(domain.to_string()).or_insert((0, 0, 0)); + entry.1 += bytes_sent; + entry.2 += bytes_received; + } + } + + /// Get current stats snapshot + pub fn get_snapshot(&self) -> (u64, u64, u64) { + ( + self.bytes_sent.load(Ordering::Relaxed), + self.bytes_received.load(Ordering::Relaxed), + self.requests.load(Ordering::Relaxed), + ) + } + + /// Flush current stats to disk and return the delta + pub fn flush_to_disk(&self) -> Result<(u64, u64), Box> { + let bytes_sent = self.bytes_sent.swap(0, Ordering::Relaxed); + let bytes_received = self.bytes_received.swap(0, Ordering::Relaxed); + + // Load or create stats + let mut stats = load_traffic_stats(&self.proxy_id) + .unwrap_or_else(|| TrafficStats::new(self.proxy_id.clone(), self.profile_id.clone())); + + // Update bandwidth history + stats.record_bandwidth(bytes_sent, bytes_received); + + // Update domain stats + if let Ok(mut domain_map) = self.domain_stats.write() { + for (domain, (count, sent, recv)) in domain_map.drain() { + stats.record_request(&domain, sent, recv); + // Adjust request count (record_request increments total_requests) + stats.total_requests = stats.total_requests.saturating_sub(1) + count; + } + } + + // Update IPs + if let Ok(ips) = self.ips.read() { + for ip in ips.iter() { + stats.record_ip(ip); + } + } + + // Save to disk + save_traffic_stats(&stats)?; + + Ok((bytes_sent, bytes_received)) + } +} + +/// Global traffic tracker that can be accessed from connection handlers +pub static TRAFFIC_TRACKER: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +/// Initialize the global traffic tracker +pub fn init_traffic_tracker(proxy_id: String, profile_id: Option) { + let _ = TRAFFIC_TRACKER.set(Arc::new(LiveTrafficTracker::new(proxy_id, profile_id))); +} + +/// Get the global traffic tracker +pub fn get_traffic_tracker() -> Option> { + TRAFFIC_TRACKER.get().cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_traffic_stats_creation() { + let stats = TrafficStats::new( + "test_proxy".to_string(), + Some("test-profile-id".to_string()), + ); + assert_eq!(stats.proxy_id, "test_proxy"); + assert_eq!(stats.profile_id, Some("test-profile-id".to_string())); + assert_eq!(stats.total_bytes_sent, 0); + assert_eq!(stats.total_bytes_received, 0); + } + + #[test] + fn test_bandwidth_recording() { + let mut stats = TrafficStats::new("test_proxy".to_string(), None); + + stats.record_bandwidth(1000, 2000); + assert_eq!(stats.total_bytes_sent, 1000); + assert_eq!(stats.total_bytes_received, 2000); + assert_eq!(stats.bandwidth_history.len(), 1); + + stats.record_bandwidth(500, 1000); + assert_eq!(stats.total_bytes_sent, 1500); + assert_eq!(stats.total_bytes_received, 3000); + } + + #[test] + fn test_domain_recording() { + let mut stats = TrafficStats::new("test_proxy".to_string(), None); + + stats.record_request("example.com", 100, 500); + stats.record_request("example.com", 200, 1000); + stats.record_request("google.com", 50, 200); + + assert_eq!(stats.domains.len(), 2); + assert_eq!(stats.domains["example.com"].request_count, 2); + assert_eq!(stats.domains["example.com"].bytes_sent, 300); + assert_eq!(stats.domains["google.com"].request_count, 1); + } + + #[test] + fn test_ip_recording() { + let mut stats = TrafficStats::new("test_proxy".to_string(), None); + + stats.record_ip("192.168.1.1"); + stats.record_ip("192.168.1.1"); // Duplicate + stats.record_ip("10.0.0.1"); + + assert_eq!(stats.unique_ips.len(), 2); + } +} diff --git a/src-tauri/tests/donut_proxy_integration.rs b/src-tauri/tests/donut_proxy_integration.rs index 86a8e2d..1b16c11 100644 --- a/src-tauri/tests/donut_proxy_integration.rs +++ b/src-tauri/tests/donut_proxy_integration.rs @@ -462,6 +462,137 @@ async fn test_proxy_list() -> Result<(), Box Result<(), Box> { + let binary_path = setup_test().await?; + let mut tracker = ProxyTestTracker::new(binary_path.clone()); + + println!("Testing traffic tracking through proxy..."); + + // Start a proxy + let output = TestUtils::execute_command(&binary_path, &["proxy", "start"]).await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(format!("Failed to start proxy - stdout: {stdout}, stderr: {stderr}").into()); + } + + let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?; + let proxy_id = config["id"].as_str().unwrap().to_string(); + let local_port = config["localPort"].as_u64().unwrap() as u16; + tracker.track_proxy(proxy_id.clone()); + + println!("Proxy started on port {}", local_port); + + // Wait for proxy to be ready + sleep(Duration::from_millis(500)).await; + + // Make an HTTP request through the proxy + let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?; + let request = + b"GET http://httpbin.org/ip HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n"; + + // Track bytes sent + let bytes_sent = request.len(); + stream.write_all(request).await?; + + // Read response + let mut response = Vec::new(); + stream.read_to_end(&mut response).await?; + let bytes_received = response.len(); + + println!( + "HTTP request completed: sent {} bytes, received {} bytes", + bytes_sent, bytes_received + ); + + // Wait for traffic stats to be flushed (happens every second) + sleep(Duration::from_secs(2)).await; + + // Verify traffic was tracked by checking traffic stats file exists + // Note: Traffic stats are stored in the cache directory + let cache_dir = directories::BaseDirs::new() + .expect("Failed to get base directories") + .cache_dir() + .to_path_buf(); + let traffic_stats_dir = cache_dir.join("DonutBrowserDev").join("traffic_stats"); + let stats_file = traffic_stats_dir.join(format!("{}.json", proxy_id)); + + if stats_file.exists() { + let content = std::fs::read_to_string(&stats_file)?; + let stats: Value = serde_json::from_str(&content)?; + + let total_sent = stats["total_bytes_sent"].as_u64().unwrap_or(0); + let total_received = stats["total_bytes_received"].as_u64().unwrap_or(0); + let total_requests = stats["total_requests"].as_u64().unwrap_or(0); + + println!( + "Traffic stats recorded: sent {} bytes, received {} bytes, {} requests", + total_sent, total_received, total_requests + ); + + // Check if domains are being tracked + let mut domain_traffic = false; + if let Some(domains) = stats.get("domains") { + if let Some(domain_map) = domains.as_object() { + println!("Domains tracked: {}", domain_map.len()); + for (domain, domain_stats) in domain_map { + println!(" - {}", domain); + // Check if any domain has traffic + if let Some(domain_obj) = domain_stats.as_object() { + let domain_sent = domain_obj + .get("bytes_sent") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let domain_recv = domain_obj + .get("bytes_received") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let domain_reqs = domain_obj + .get("request_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + println!( + " sent: {}, received: {}, requests: {}", + domain_sent, domain_recv, domain_reqs + ); + if domain_sent > 0 || domain_recv > 0 || domain_reqs > 0 { + domain_traffic = true; + } + } + } + } + } + + // Verify that some traffic was recorded - check either total bytes or domain traffic + assert!( + total_sent > 0 || total_received > 0 || total_requests > 0 || domain_traffic, + "Traffic stats should record some activity (sent: {}, received: {}, requests: {})", + total_sent, + total_received, + total_requests + ); + + println!("Traffic tracking test passed!"); + } else { + println!("Warning: Traffic stats file not found at {:?}", stats_file); + // This is not necessarily a failure - the file may not have been created yet + // The important thing is that the proxy is working + } + + // Cleanup + tracker.cleanup_all().await; + + // Clean up the traffic stats file + if stats_file.exists() { + let _ = std::fs::remove_file(&stats_file); + } + + Ok(()) +} + /// Test proxy stop #[tokio::test] #[serial] diff --git a/src/components/bandwidth-mini-chart.tsx b/src/components/bandwidth-mini-chart.tsx new file mode 100644 index 0000000..8ebcdb7 --- /dev/null +++ b/src/components/bandwidth-mini-chart.tsx @@ -0,0 +1,118 @@ +"use client"; + +import * as React from "react"; +import { Area, AreaChart, ResponsiveContainer } from "recharts"; +import { cn } from "@/lib/utils"; +import type { BandwidthDataPoint } from "@/types"; + +interface BandwidthMiniChartProps { + data: BandwidthDataPoint[]; + currentBandwidth?: number; + onClick?: () => void; + className?: string; +} + +export function BandwidthMiniChart({ + data, + currentBandwidth: externalBandwidth, + onClick, + className, +}: BandwidthMiniChartProps) { + // Transform data for the chart - combine sent and received for total bandwidth + const chartData = React.useMemo(() => { + // Fill in missing seconds with zeros for smooth chart + if (data.length === 0) { + // Create 60 seconds of zero data for the past minute + const now = Math.floor(Date.now() / 1000); + return Array.from({ length: 60 }, (_, i) => ({ + time: now - (59 - i), + bandwidth: 0, + })); + } + + const now = Math.floor(Date.now() / 1000); + const result: { time: number; bandwidth: number }[] = []; + + // Get the last 60 seconds + for (let i = 59; i >= 0; i--) { + const targetTime = now - i; + const point = data.find((d) => d.timestamp === targetTime); + result.push({ + time: targetTime, + bandwidth: point ? point.bytes_sent + point.bytes_received : 0, + }); + } + + return result; + }, [data]); + + // Find max value for scaling + const _maxBandwidth = React.useMemo(() => { + const max = Math.max(...chartData.map((d) => d.bandwidth), 1); + return max; + }, [chartData]); + + // Use external bandwidth if provided, otherwise calculate from last data point + const currentBandwidth = + externalBandwidth ?? chartData[chartData.length - 1]?.bandwidth ?? 0; + + // Format bytes to human readable + const formatBytes = (bytes: number): string => { + if (bytes === 0) return "0 B/s"; + if (bytes < 1024) return `${bytes} B/s`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB/s`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB/s`; + }; + + return ( + + ); +} diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 48a161a..43a04e3 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -69,7 +69,13 @@ import { } from "@/lib/browser-utils"; import { trimName } from "@/lib/name-utils"; import { cn } from "@/lib/utils"; -import type { BrowserProfile, ProxyCheckResult, StoredProxy } from "@/types"; +import type { + BrowserProfile, + ProxyCheckResult, + StoredProxy, + TrafficSnapshot, +} from "@/types"; +import { BandwidthMiniChart } from "./bandwidth-mini-chart"; import { DataTableActionBar, DataTableActionBarAction, @@ -77,6 +83,7 @@ import { } from "./data-table-action-bar"; import MultipleSelector, { type Option } from "./multiple-selector"; import { ProxyCheckButton } from "./proxy-check-button"; +import { TrafficDetailsDialog } from "./traffic-details-dialog"; import { Input } from "./ui/input"; import { RippleButton } from "./ui/ripple"; @@ -150,6 +157,10 @@ type TableMeta = { // Overflow actions onAssignProfilesToGroup?: (profileIds: string[]) => void; onConfigureCamoufox?: (profile: BrowserProfile) => void; + + // Traffic snapshots (lightweight real-time data) + trafficSnapshots: Record; + onOpenTrafficDialog?: (profileId: string) => void; }; const TagsCell = React.memo<{ @@ -779,6 +790,13 @@ export function ProfilesDataTable({ const [openNoteEditorFor, setOpenNoteEditorFor] = React.useState< string | null >(null); + const [trafficSnapshots, setTrafficSnapshots] = React.useState< + Record + >({}); + const [trafficDialogProfile, setTrafficDialogProfile] = React.useState<{ + id: string; + name?: string; + } | null>(null); // Load cached check results for proxies React.useEffect(() => { @@ -847,6 +865,39 @@ export function ProfilesDataTable({ stoppingProfiles, ); + // Fetch traffic snapshots for running profiles (lightweight, real-time data) + // Using runningProfiles.size as dependency to avoid Set reference comparison issues + const runningCount = runningProfiles.size; + React.useEffect(() => { + if (!browserState.isClient) return; + + if (runningCount === 0) { + setTrafficSnapshots({}); + return; + } + + const fetchTrafficSnapshots = async () => { + try { + const allSnapshots = await invoke( + "get_all_traffic_snapshots", + ); + const newSnapshots: Record = {}; + for (const snapshot of allSnapshots) { + if (snapshot.profile_id) { + newSnapshots[snapshot.profile_id] = snapshot; + } + } + setTrafficSnapshots(newSnapshots); + } catch (error) { + console.error("Failed to fetch traffic snapshots:", error); + } + }; + + void fetchTrafficSnapshots(); + const interval = setInterval(fetchTrafficSnapshots, 1000); + return () => clearInterval(interval); + }, [browserState.isClient, runningCount]); + // Clear launching/stopping spinners when backend reports running status changes React.useEffect(() => { if (!browserState.isClient) return; @@ -1185,6 +1236,13 @@ export function ProfilesDataTable({ // Overflow actions onAssignProfilesToGroup, onConfigureCamoufox, + + // Traffic snapshots (lightweight real-time data) + trafficSnapshots, + onOpenTrafficDialog: (profileId: string) => { + const profile = profiles.find((p) => p.id === profileId); + setTrafficDialogProfile({ id: profileId, name: profile?.name }); + }, }), [ selectedProfiles, @@ -1214,6 +1272,8 @@ export function ProfilesDataTable({ profileToRename, newProfileName, isRenamingSaving, + trafficSnapshots, + profiles, renameError, onKillProfile, onLaunchProfile, @@ -1629,6 +1689,24 @@ export function ProfilesDataTable({ : null; const isSelectorOpen = meta.openProxySelectorFor === profile.id; + // When profile is running, show bandwidth chart instead of proxy selector + if (isRunning && meta.trafficSnapshots) { + // Find the traffic snapshot for this profile by matching profile_id + const snapshot = meta.trafficSnapshots[profile.id]; + const bandwidthData = snapshot?.recent_bandwidth || []; + const currentBandwidth = + (snapshot?.current_bytes_sent || 0) + + (snapshot?.current_bytes_received || 0); + + return ( + meta.onOpenTrafficDialog?.(profile.id)} + /> + ); + } + if (profile.browser === "tor-browser") { return ( @@ -1791,6 +1869,13 @@ export function ProfilesDataTable({ + { + meta.onOpenTrafficDialog?.(profile.id); + }} + > + View Network + { meta.onAssignProfilesToGroup?.([profile.id]); @@ -1952,6 +2037,14 @@ export function ProfilesDataTable({ )} + {trafficDialogProfile && ( + setTrafficDialogProfile(null)} + profileId={trafficDialogProfile.id} + profileName={trafficDialogProfile.name} + /> + )} ); } diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index f02487a..2468955 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -268,6 +268,8 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { setIsClearingCache(true); try { await invoke("clear_all_version_cache_and_refetch"); + // Also clear traffic stats cache + await invoke("clear_all_traffic_stats"); // Don't show immediate success toast - let the version update progress events handle it } catch (error) { console.error("Failed to clear cache:", error); diff --git a/src/components/traffic-details-dialog.tsx b/src/components/traffic-details-dialog.tsx new file mode 100644 index 0000000..50b47c1 --- /dev/null +++ b/src/components/traffic-details-dialog.tsx @@ -0,0 +1,545 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import * as React from "react"; +import type { TooltipProps } from "recharts"; +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { + NameType, + ValueType, +} from "recharts/types/component/DefaultTooltipContent"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { TrafficStats } from "@/types"; + +type TimePeriod = + | "1m" + | "5m" + | "30m" + | "1h" + | "2h" + | "4h" + | "1d" + | "7d" + | "30d" + | "all"; + +interface TrafficDetailsDialogProps { + isOpen: boolean; + onClose: () => void; + profileId?: string; + profileName?: string; +} + +const formatBytes = (bytes: number): string => { + if (bytes === 0) return "0 B"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +}; + +const formatBytesPerSecond = (bytes: number): string => { + if (bytes === 0) return "0 B/s"; + if (bytes < 1024) return `${bytes} B/s`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB/s`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB/s`; +}; + +export function TrafficDetailsDialog({ + isOpen, + onClose, + profileId, + profileName, +}: TrafficDetailsDialogProps) { + const [stats, setStats] = React.useState(null); + const [timePeriod, setTimePeriod] = React.useState("5m"); + + // Fetch stats periodically + React.useEffect(() => { + if (!isOpen || !profileId) return; + + const fetchStats = async () => { + try { + const allStats = await invoke("get_all_traffic_stats"); + const profileStats = allStats.find((s) => s.profile_id === profileId); + setStats(profileStats || null); + } catch (error) { + console.error("Failed to fetch traffic stats:", error); + } + }; + + void fetchStats(); + // Only poll every 2 seconds for full stats (more expensive) + const interval = setInterval(fetchStats, 2000); + + return () => clearInterval(interval); + }, [isOpen, profileId]); + + // Filter data based on time period + const filteredData = React.useMemo(() => { + if (!stats?.bandwidth_history) return []; + + const now = Math.floor(Date.now() / 1000); + + // Get cutoff seconds for time period + let cutoffSeconds: number; + switch (timePeriod) { + case "1m": + cutoffSeconds = 60; + break; + case "5m": + cutoffSeconds = 300; + break; + case "30m": + cutoffSeconds = 1800; + break; + case "1h": + cutoffSeconds = 3600; + break; + case "2h": + cutoffSeconds = 7200; + break; + case "4h": + cutoffSeconds = 14400; + break; + case "1d": + cutoffSeconds = 86400; + break; + case "7d": + cutoffSeconds = 604800; + break; + case "30d": + cutoffSeconds = 2592000; + break; + case "all": + cutoffSeconds = Number.POSITIVE_INFINITY; + break; + default: + cutoffSeconds = 300; + } + + const cutoff = now - cutoffSeconds; + + return stats.bandwidth_history + .filter((d) => d.timestamp >= cutoff) + .map((d) => ({ + time: d.timestamp, + sent: d.bytes_sent, + received: d.bytes_received, + total: d.bytes_sent + d.bytes_received, + })); + }, [stats, timePeriod]); + + // Calculate stats for the selected period + const periodStats = React.useMemo(() => { + if (!filteredData.length) { + return { sent: 0, received: 0, requests: 0 }; + } + + const sent = filteredData.reduce((sum, d) => sum + d.sent, 0); + const received = filteredData.reduce((sum, d) => sum + d.received, 0); + + // Estimate requests based on filtered data time range + // We don't have per-second request data, so use total if "all" or estimate + const requests = + timePeriod === "all" + ? stats?.total_requests || 0 + : Math.round( + ((stats?.total_requests || 0) * filteredData.length) / + (stats?.bandwidth_history?.length || 1), + ); + + return { sent, received, requests }; + }, [filteredData, stats, timePeriod]); + + // Tooltip render function + const renderTooltip = React.useCallback( + (props: TooltipProps) => { + const { active, payload, label } = props; + if (!active || !payload?.length) return null; + + const time = new Date((typeof label === "number" ? label : 0) * 1000); + const formattedTime = time.toLocaleTimeString(); + + return ( +
+

{formattedTime}

+ {payload.map((entry) => ( +

+ + {entry.dataKey === "sent" ? "↑ Sent: " : "↓ Received: "} + + + {formatBytesPerSecond( + typeof entry.value === "number" ? entry.value : 0, + )} + +

+ ))} +
+ ); + }, + [], + ); + + // Top domains sorted by total traffic + const topDomainsByTraffic = React.useMemo(() => { + if (!stats?.domains) return []; + return Object.values(stats.domains) + .sort( + (a, b) => + b.bytes_sent + b.bytes_received - (a.bytes_sent + a.bytes_received), + ) + .slice(0, 10); + }, [stats]); + + // Top domains sorted by request count + const topDomainsByRequests = React.useMemo(() => { + if (!stats?.domains) return []; + return Object.values(stats.domains) + .sort((a, b) => b.request_count - a.request_count) + .slice(0, 10); + }, [stats]); + + return ( + !open && onClose()}> + + + + Traffic Details + {profileName && ( + + — {profileName} + + )} + + + + +
+ {/* Chart with Period Selector */} +
+
+

Bandwidth Over Time

+ +
+ +
+ + + + + + + + + + + + + + + new Date(t * 1000).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + } + className="text-xs" + tick={{ fill: "var(--muted-foreground)" }} + /> + formatBytesPerSecond(v)} + className="text-xs" + tick={{ fill: "var(--muted-foreground)" }} + width={60} + /> + + + + + +
+ +
+
+
+ Sent +
+
+
+ + Received + +
+
+
+ + {/* Period Stats */} +
+
+

+ Sent ({timePeriod === "all" ? "total" : timePeriod}) +

+

+ {formatBytes(periodStats.sent)} +

+
+
+

+ Received ({timePeriod === "all" ? "total" : timePeriod}) +

+

+ {formatBytes(periodStats.received)} +

+
+
+

+ Requests ({timePeriod === "all" ? "total" : `~${timePeriod}`}) +

+

+ {periodStats.requests.toLocaleString()} +

+
+
+ + {/* Total Stats (smaller, under period stats) */} +
+
+ Total:{" "} + {formatBytes( + (stats?.total_bytes_sent || 0) + + (stats?.total_bytes_received || 0), + )} +
+
+ Requests:{" "} + {stats?.total_requests?.toLocaleString() || 0} +
+
+ + {/* Disclaimer about proxy/VPN traffic calculation */} +

+ Note: If you are using a proxy, VPN, or similar service, your + provider may calculate traffic differently due to encryption + overhead and protocol differences. +

+ + {/* Top Domains by Traffic */} + {topDomainsByTraffic.length > 0 && ( +
+

+ Top Domains by Traffic +

+
+
+ Domain + Requests + Sent + Received +
+
+ {topDomainsByTraffic.map((domain, index) => ( +
+
+ + {index + 1} + + + {domain.domain} + +
+ + {domain.request_count.toLocaleString()} + + + {formatBytes(domain.bytes_sent)} + + + {formatBytes(domain.bytes_received)} + +
+ ))} +
+
+
+ )} + + {/* Top Domains by Requests */} + {topDomainsByRequests.length > 0 && ( +
+

+ Top Domains by Requests +

+
+
+ Domain + Requests + Total Traffic +
+
+ {topDomainsByRequests.map((domain, index) => ( +
+
+ + {index + 1} + + + {domain.domain} + +
+ + {domain.request_count.toLocaleString()} + + + {formatBytes( + domain.bytes_sent + domain.bytes_received, + )} + +
+ ))} +
+
+
+ )} + + {/* Unique IPs */} + {stats?.unique_ips && stats.unique_ips.length > 0 && ( +
+

+ Unique IPs ({stats.unique_ips.length}) +

+
+
+ {stats.unique_ips.map((ip) => ( + + {ip} + + ))} +
+
+
+ )} + + {/* No data state */} + {!stats && ( +
+

No traffic data available for this profile.

+

+ Traffic data will appear after you launch the profile. +

+
+ )} +
+ + +
+ ); +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 13631ed..6a9962e 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,5 +1,3 @@ -import type * as React from "react"; - import { cn } from "@/lib/utils"; function Card({ className, ...props }: React.ComponentProps<"div">) { @@ -20,7 +18,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
} + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"]; + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +}); +ChartContainer.displayName = "Chart"; + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( +