From 41294fafb1959ba9e0661a1c5059705bca9d59b4 Mon Sep 17 00:00:00 2001 From: konojunya Date: Mon, 2 Mar 2026 13:34:38 +0900 Subject: [PATCH] feat: add CPU/memory usage monitoring with real-time charts --- package.json | 1 + pnpm-lock.yaml | 274 ++++++++++++++++++ src-tauri/Cargo.lock | 104 ++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands.rs | 21 +- src-tauri/src/lib.rs | 2 + src-tauri/src/process_stats.rs | 47 ++++ src-tauri/src/types.rs | 8 + src/components/JobDetail.tsx | 14 +- src/components/ResourceMonitor.test.tsx | 51 ++++ src/components/ResourceMonitor.tsx | 132 +++++++++ src/components/ui/card.tsx | 92 ++++++ src/components/ui/chart.tsx | 355 ++++++++++++++++++++++++ src/hooks/useProcessStats.test.ts | 108 +++++++ src/hooks/useProcessStats.ts | 74 +++++ src/lib/invoke.ts | 5 +- src/test-utils/tauri-mock.ts | 11 +- src/types.ts | 7 + 18 files changed, 1288 insertions(+), 19 deletions(-) create mode 100644 src-tauri/src/process_stats.rs create mode 100644 src/components/ResourceMonitor.test.tsx create mode 100644 src/components/ResourceMonitor.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/chart.tsx create mode 100644 src/hooks/useProcessStats.test.ts create mode 100644 src/hooks/useProcessStats.ts diff --git a/package.json b/package.json index d39f1ba..6a360d4 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "recharts": "2.15.4", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0952bfb..676fc0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.4(react@19.2.4) + recharts: + specifier: 2.15.4 + version: 2.15.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -1766,6 +1769,33 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@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.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@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/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2044,6 +2074,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + 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.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + 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'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -2061,6 +2135,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -2113,6 +2190,9 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dotenv@17.3.1: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} @@ -2195,6 +2275,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -2228,6 +2311,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -2402,6 +2489,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ip-address@10.0.1: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} @@ -2629,10 +2720,17 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lru-cache@11.2.6: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} @@ -2883,6 +2981,9 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2924,9 +3025,15 @@ packages: peerDependencies: react: ^19.2.4 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + 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'} @@ -2951,6 +3058,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'} @@ -2961,6 +3074,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.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -2969,6 +3088,16 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + 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 + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -3287,6 +3416,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5000,6 +5132,30 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@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.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} @@ -5266,6 +5422,44 @@ snapshots: csstype@3.2.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.2: {} + + 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.2 + 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: {} + data-uri-to-buffer@4.0.1: {} data-urls@7.0.0(@noble/hashes@1.8.0): @@ -5279,6 +5473,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} dedent@1.7.1: {} @@ -5308,6 +5504,11 @@ snapshots: dom-accessibility-api@0.6.3: {} + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.28.6 + csstype: 3.2.3 + dotenv@17.3.1: {} dunder-proto@1.0.1: @@ -5397,6 +5598,8 @@ snapshots: etag@1.8.1: {} + eventemitter3@4.0.7: {} + eventsource-parser@3.0.6: {} eventsource@3.0.7: @@ -5472,6 +5675,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.4.0: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5639,6 +5844,8 @@ snapshots: inherits@2.0.4: {} + internmap@2.0.3: {} + ip-address@10.0.1: {} ipaddr.js@1.9.1: {} @@ -5804,11 +6011,17 @@ snapshots: lines-and-columns@1.2.4: {} + lodash@4.17.23: {} + log-symbols@6.0.0: dependencies: chalk: 5.6.2 is-unicode-supported: 1.3.0 + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lru-cache@11.2.6: {} lru-cache@5.1.1: @@ -6050,6 +6263,12 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -6140,8 +6359,12 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} + react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): @@ -6163,6 +6386,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-smooth@4.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + fast-equals: 5.4.0 + prop-types: 15.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-transition-group: 4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): dependencies: get-nonce: 1.0.1 @@ -6171,6 +6402,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-transition-group@4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@babel/runtime': 7.28.6 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react@19.2.4: {} recast@0.23.11: @@ -6181,6 +6421,23 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.17.23 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -6534,6 +6791,23 @@ snapshots: vary@1.1.2: {} + 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.8 + '@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.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.27.3 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 774bfbb..c862594 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1587,12 +1587,13 @@ dependencies = [ [[package]] name = "launchd-ui" -version = "1.0.6" +version = "1.0.9" dependencies = [ "dirs", "plist", "serde", "serde_json", + "sysinfo", "tauri", "tauri-build", "tempfile", @@ -1830,6 +1831,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -3120,6 +3130,19 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "sysinfo" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "windows 0.57.0", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3167,7 +3190,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3238,7 +3261,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -3326,7 +3349,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -3352,7 +3375,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4094,10 +4117,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -4118,7 +4141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -4168,6 +4191,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -4190,14 +4223,26 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -4209,8 +4254,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -4227,6 +4272,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -4238,6 +4294,17 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -4271,6 +4338,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4708,7 +4784,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b1e5db1..8ccd295 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ tauri = { version = "2.10.0", features = [] } plist = "1" thiserror = "2" dirs = "6" +sysinfo = { version = "0.33", default-features = false, features = ["system"] } [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index aac5b17..9734f51 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,8 +1,9 @@ use crate::error::AppError; use crate::launchctl; use crate::plist_util; +use crate::process_stats; use crate::types::PlistConfig; -use crate::types::{JobListEntry, JobStatus, LaunchdJob}; +use crate::types::{JobListEntry, JobStatus, LaunchdJob, ProcessStats}; use std::collections::HashMap; fn ensure_user_agent(plist_path: &str) -> Result<(), AppError> { @@ -268,3 +269,21 @@ pub async fn reveal_in_finder(path: String) -> Result<(), AppError> { .spawn()?; Ok(()) } + +#[tauri::command] +pub async fn get_process_stats(pid: u32) -> Result { + let (cpu_percent, memory_bytes) = process_stats::get_process_stats(pid) + .ok_or_else(|| AppError::NotFound(format!("process not found: PID {pid}")))?; + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + Ok(ProcessStats { + pid, + cpu_percent, + memory_bytes, + timestamp, + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d3a1e77..e57f161 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod commands; mod error; mod launchctl; mod plist_util; +mod process_stats; mod types; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -25,6 +26,7 @@ pub fn run() { commands::open_log_in_editor, commands::get_home_dir, commands::reveal_in_finder, + commands::get_process_stats, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/process_stats.rs b/src-tauri/src/process_stats.rs new file mode 100644 index 0000000..04cc876 --- /dev/null +++ b/src-tauri/src/process_stats.rs @@ -0,0 +1,47 @@ +use std::sync::{LazyLock, Mutex}; +use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System}; + +static SYSTEM: LazyLock> = LazyLock::new(|| { + Mutex::new(System::new_with_specifics( + RefreshKind::nothing() + .with_processes(ProcessRefreshKind::nothing().with_cpu().with_memory()), + )) +}); + +/// Returns (cpu_percent, memory_bytes) for the given PID, or None if the process does not exist. +pub fn get_process_stats(pid: u32) -> Option<(f32, u64)> { + let mut sys = SYSTEM.lock().ok()?; + let sysinfo_pid = Pid::from_u32(pid); + + sys.refresh_processes_specifics( + ProcessesToUpdate::Some(&[sysinfo_pid]), + true, + ProcessRefreshKind::nothing().with_cpu().with_memory(), + ); + + sys.process(sysinfo_pid) + .map(|p| (p.cpu_usage(), p.memory())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_stats_for_self() { + let pid = std::process::id(); + // First call may return 0% CPU (needs two data points), but should succeed + let result = get_process_stats(pid); + assert!(result.is_some(), "should get stats for own process"); + let (cpu, mem) = result.unwrap(); + assert!(cpu >= 0.0, "cpu should be non-negative"); + assert!(mem > 0, "memory should be positive for a running process"); + } + + #[test] + fn test_get_stats_for_nonexistent_pid() { + // Very high PIDs should not exist + let result = get_process_stats(4_000_000); + assert!(result.is_none(), "should return None for nonexistent PID"); + } +} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 4b95b20..e92576c 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -63,6 +63,14 @@ pub struct LaunchdJob { pub plist: PlistConfig, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProcessStats { + pub pid: u32, + pub cpu_percent: f32, + pub memory_bytes: u64, + pub timestamp: u64, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/components/JobDetail.tsx b/src/components/JobDetail.tsx index 9b18533..df71bf3 100644 --- a/src/components/JobDetail.tsx +++ b/src/components/JobDetail.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { LogViewer } from "@/components/LogViewer" +import { ResourceMonitor } from "@/components/ResourceMonitor" import type { LaunchdJob } from "@/types" import { getJobDetail, revealInFinder } from "@/lib/invoke" import type { CalendarInterval } from "@/types" @@ -59,6 +60,7 @@ function DetailRow({ label, value }: { label: string; value: string | null | und export function JobDetail({ plistPath, open, onClose, onEdit }: JobDetailProps) { const [job, setJob] = useState(null) const [loading, setLoading] = useState(false) + const [activeTab, setActiveTab] = useState("config") useEffect(() => { if (!plistPath || !open) return @@ -70,7 +72,7 @@ export function JobDetail({ plistPath, open, onClose, onEdit }: JobDetailProps) }, [plistPath, open]) return ( - !isOpen && onClose()}> + { if (!isOpen) { setActiveTab("config"); onClose() } }}> @@ -122,10 +124,11 @@ export function JobDetail({ plistPath, open, onClose, onEdit }: JobDetailProps) - + Configuration Logs + Monitor @@ -221,6 +224,13 @@ export function JobDetail({ plistPath, open, onClose, onEdit }: JobDetailProps) )} + + + + )} diff --git a/src/components/ResourceMonitor.test.tsx b/src/components/ResourceMonitor.test.tsx new file mode 100644 index 0000000..2a9b3e3 --- /dev/null +++ b/src/components/ResourceMonitor.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest" +import { render, screen } from "@testing-library/react" +import { ResourceMonitor } from "./ResourceMonitor" +import { resetFakeHandlers, setFakeHandler } from "@/test-utils/tauri-mock" + +// Mock recharts ResponsiveContainer which doesn't work well in jsdom +vi.mock("recharts", async () => { + const actual = await vi.importActual("recharts") + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + } +}) + +beforeEach(() => { + resetFakeHandlers() + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe("ResourceMonitor", () => { + it("shows not-running message when pid is null", () => { + render() + expect(screen.getByTestId("not-running-message")).toBeInTheDocument() + expect(screen.getByText(/Process is not running/)).toBeInTheDocument() + }) + + it("shows placeholder values before data arrives", () => { + setFakeHandler("get_process_stats", () => ({ + pid: 1234, + cpu_percent: 10.5, + memory_bytes: 104_857_600, + timestamp: Date.now(), + })) + + render() + expect(screen.getByTestId("cpu-value")).toHaveTextContent("—") + expect(screen.getByTestId("memory-value")).toHaveTextContent("—") + }) + + it("renders chart sections when pid is provided", () => { + render() + expect(screen.getByText("CPU Usage")).toBeInTheDocument() + expect(screen.getByText("Memory Usage")).toBeInTheDocument() + }) +}) diff --git a/src/components/ResourceMonitor.tsx b/src/components/ResourceMonitor.tsx new file mode 100644 index 0000000..22dc302 --- /dev/null +++ b/src/components/ResourceMonitor.tsx @@ -0,0 +1,132 @@ +import { LineChart, Line, XAxis, YAxis, CartesianGrid } from "recharts" +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart" +import { useProcessStats } from "@/hooks/useProcessStats" + +type ResourceMonitorProps = { + pid: number | null + enabled: boolean +} + +const cpuChartConfig = { + cpu_percent: { + label: "CPU %", + color: "var(--color-chart-1)", + }, +} satisfies ChartConfig + +const memoryChartConfig = { + memory_mb: { + label: "Memory (MB)", + color: "var(--color-chart-2)", + }, +} satisfies ChartConfig + +function formatTime(timestamp: number): string { + const date = new Date(timestamp) + const m = String(date.getMinutes()).padStart(2, "0") + const s = String(date.getSeconds()).padStart(2, "0") + return `${m}:${s}` +} + +function formatMemoryMB(bytes: number): string { + return (bytes / (1024 * 1024)).toFixed(1) +} + +export function ResourceMonitor({ pid, enabled }: ResourceMonitorProps) { + const { data, latest, error } = useProcessStats(pid, enabled) + + if (pid === null) { + return ( +
+ Process is not running. Start the agent to monitor resource usage. +
+ ) + } + + const chartData = data.map((d) => ({ + time: formatTime(d.timestamp), + cpu_percent: Math.round(d.cpu_percent * 10) / 10, + memory_mb: Number(formatMemoryMB(d.memory_bytes)), + })) + + return ( +
+ {/* Current values summary */} +
+
+ CPU: + + {latest ? `${latest.cpu_percent.toFixed(1)}%` : "—"} + +
+
+ Memory: + + {latest ? `${formatMemoryMB(latest.memory_bytes)} MB` : "—"} + +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* CPU Chart */} +
+

CPU Usage

+ + + + + + } /> + + + +
+ + {/* Memory Chart */} +
+

Memory Usage

+ + + + + + } /> + + + +
+
+ ) +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..681ad98 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx new file mode 100644 index 0000000..48d2724 --- /dev/null +++ b/src/components/ui/chart.tsx @@ -0,0 +1,355 @@ +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +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 +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] +}) { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +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 ( +