From 5446875113065b58dabf7208dd75e7ab5c65d6f1 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 9 Oct 2025 10:29:37 +0800 Subject: [PATCH] feat: add customizable widget sizes with chart visualizations Add three-tier size system (small/medium/large) for server stats widgets. Integrate recharts library for visualizing trends in large widgets with line charts (CPU), area charts (Memory), and radial bar charts (Disk). Fix layout overflow issues with proper flexbox patterns. --- package-lock.json | 355 +++++++++++++++++- package.json | 1 + src/components/ui/chart.tsx | 24 ++ src/types/stats-widgets.ts | 9 +- src/ui/Desktop/Apps/Server/Server.tsx | 51 ++- .../Apps/Server/widgets/AddWidgetDialog.tsx | 32 +- .../Desktop/Apps/Server/widgets/CpuWidget.tsx | 201 ++++++++-- .../Apps/Server/widgets/DiskWidget.tsx | 200 ++++++++-- .../Apps/Server/widgets/MemoryWidget.tsx | 238 +++++++++--- src/ui/Desktop/Apps/Server/widgets/index.ts | 1 + .../Desktop/Apps/Server/widgets/registry.ts | 37 +- 11 files changed, 1014 insertions(+), 135 deletions(-) create mode 100644 src/components/ui/chart.tsx diff --git a/package-lock.json b/package-lock.json index dd61081b..d618e92e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,7 @@ "react-simple-keyboard": "^3.8.120", "react-syntax-highlighter": "^15.6.6", "react-xtermjs": "^1.0.10", + "recharts": "^3.2.1", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "speakeasy": "^2.0.0", @@ -3356,6 +3357,32 @@ "version": "1.1.1", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", + "integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@replit/codemirror-lang-nix": { "version": "6.0.1", "license": "MIT", @@ -3656,6 +3683,69 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "license": "MIT", @@ -3876,6 +3966,12 @@ "version": "3.0.3", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "dev": true, @@ -5752,6 +5848,127 @@ "version": "1.4.5", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/dargs": { "version": "8.1.0", "dev": true, @@ -5809,6 +6026,12 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "license": "MIT", @@ -6579,6 +6802,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.40.0.tgz", + "integrity": "sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/es6-error": { "version": "4.1.1", "dev": true, @@ -6854,7 +7087,6 @@ }, "node_modules/eventemitter3": { "version": "5.0.1", - "dev": true, "license": "MIT" }, "node_modules/expand-template": { @@ -8108,6 +8340,16 @@ "version": "3.0.6", "license": "MIT" }, + "node_modules/immer": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", + "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "dev": true, @@ -8185,6 +8427,15 @@ "version": "0.2.4", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.0.1", "dev": true, @@ -11511,6 +11762,29 @@ "react-dom": "^17.0.2 || ^18 || ^19" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.1", "license": "MIT", @@ -11674,6 +11948,48 @@ "version": "5.1.2", "license": "MIT" }, + "node_modules/recharts": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.1.tgz", + "integrity": "sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==", + "license": "MIT", + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/refractor": { "version": "3.6.0", "license": "MIT", @@ -11863,6 +12179,12 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -13349,6 +13671,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "dev": true, @@ -13389,6 +13720,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vimeo-video-element": { "version": "1.6.0", "license": "MIT", diff --git a/package.json b/package.json index 9175d02c..bdc1ce32 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "react-simple-keyboard": "^3.8.120", "react-syntax-highlighter": "^15.6.6", "react-xtermjs": "^1.0.10", + "recharts": "^3.2.1", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "speakeasy": "^2.0.0", diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx new file mode 100644 index 00000000..c0479b5a --- /dev/null +++ b/src/components/ui/chart.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; + +import { cn } from "@/lib/utils"; + +// Chart Container +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return ( +
+ ); +}); +ChartContainer.displayName = "ChartContainer"; + +export { ChartContainer, RechartsPrimitive }; diff --git a/src/types/stats-widgets.ts b/src/types/stats-widgets.ts index 632dfd98..818c01d5 100644 --- a/src/types/stats-widgets.ts +++ b/src/types/stats-widgets.ts @@ -7,9 +7,12 @@ export type WidgetType = // | 'processes' // 进程数 // | 'uptime'; // 运行时间 +export type WidgetSize = "small" | "medium" | "large"; + export interface Widget { id: string; // 唯一 ID:"cpu-1", "memory-2" type: WidgetType; // 卡片类型 + size: WidgetSize; // 尺寸:small/medium/large x: number; // 网格X坐标 (0-11) y: number; // 网格Y坐标 w: number; // 宽度(网格单位 1-12) @@ -22,9 +25,9 @@ export interface StatsConfig { export const DEFAULT_STATS_CONFIG: StatsConfig = { widgets: [ - { id: "cpu-1", type: "cpu", x: 0, y: 0, w: 4, h: 2 }, - { id: "memory-1", type: "memory", x: 4, y: 0, w: 4, h: 2 }, - { id: "disk-1", type: "disk", x: 8, y: 0, w: 4, h: 2 }, + { id: "cpu-1", type: "cpu", size: "medium", x: 0, y: 0, w: 4, h: 2 }, + { id: "memory-1", type: "memory", size: "medium", x: 4, y: 0, w: 4, h: 2 }, + { id: "disk-1", type: "disk", size: "medium", x: 8, y: 0, w: 4, h: 2 }, ], }; diff --git a/src/ui/Desktop/Apps/Server/Server.tsx b/src/ui/Desktop/Apps/Server/Server.tsx index 36062298..60207a51 100644 --- a/src/ui/Desktop/Apps/Server/Server.tsx +++ b/src/ui/Desktop/Apps/Server/Server.tsx @@ -25,6 +25,7 @@ import { DiskWidget, generateWidgetId, getWidgetConfig, + getWidgetSize, } from "./widgets"; import { AddWidgetDialog } from "./widgets/AddWidgetDialog"; import "react-grid-layout/css/styles.css"; @@ -54,6 +55,9 @@ export function Server({ "offline", ); const [metrics, setMetrics] = React.useState(null); + const [metricsHistory, setMetricsHistory] = React.useState( + [], + ); const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig); const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false); const [isRefreshing, setIsRefreshing] = React.useState(false); @@ -120,10 +124,36 @@ export function Server({ setHasUnsavedChanges(true); }; - const handleAddWidget = (widgetType: string) => { + const handleChangeWidgetSize = ( + widgetId: string, + newSize: any, + e: React.MouseEvent, + ) => { + e.stopPropagation(); + e.preventDefault(); + + setWidgets((prev) => + prev.map((widget) => { + if (widget.id === widgetId) { + const sizeConfig = getWidgetSize(widget.type, newSize); + return { + ...widget, + size: newSize, + w: sizeConfig.w, + h: sizeConfig.h, + }; + } + return widget; + }), + ); + setHasUnsavedChanges(true); + }; + + const handleAddWidget = (widgetType: string, size: any) => { const existingIds = widgets.map((w) => w.id); const newId = generateWidgetId(widgetType as any, existingIds); const config = getWidgetConfig(widgetType as any); + const sizeConfig = getWidgetSize(widgetType as any, size); // Find the next available position const maxY = widgets.reduce((max, w) => Math.max(max, w.y + w.h), 0); @@ -131,10 +161,11 @@ export function Server({ const newWidget: Widget = { id: newId, type: widgetType as any, + size: size, x: 0, y: maxY, - w: config.defaultSize.w, - h: config.defaultSize.h, + w: sizeConfig.w, + h: sizeConfig.h, }; setWidgets((prev) => [...prev, newWidget]); @@ -173,9 +204,12 @@ export function Server({ return ( ); @@ -183,9 +217,12 @@ export function Server({ return ( ); @@ -193,9 +230,12 @@ export function Server({ return ( ); @@ -275,6 +315,11 @@ export function Server({ const data = await getServerMetricsById(currentHostConfig.id); if (!cancelled) { setMetrics(data); + setMetricsHistory((prev) => { + const newHistory = [...prev, data]; + // Keep last 20 data points for chart + return newHistory.slice(-20); + }); setShowStatsUI(true); } } catch (error: any) { diff --git a/src/ui/Desktop/Apps/Server/widgets/AddWidgetDialog.tsx b/src/ui/Desktop/Apps/Server/widgets/AddWidgetDialog.tsx index 1001fec8..dc6cbe1b 100644 --- a/src/ui/Desktop/Apps/Server/widgets/AddWidgetDialog.tsx +++ b/src/ui/Desktop/Apps/Server/widgets/AddWidgetDialog.tsx @@ -1,11 +1,12 @@ import React from "react"; import { getAvailableWidgets, type WidgetRegistryItem } from "./registry"; import { Plus, X } from "lucide-react"; +import type { WidgetSize } from "@/types/stats-widgets"; interface AddWidgetDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - onAddWidget: (widgetType: string) => void; + onAddWidget: (widgetType: string, size: WidgetSize) => void; existingWidgetTypes: string[]; } @@ -16,6 +17,13 @@ export function AddWidgetDialog({ existingWidgetTypes, }: AddWidgetDialogProps) { const availableWidgets = getAvailableWidgets(); + const [selectedSize, setSelectedSize] = React.useState("medium"); + + const sizeLabels: Record = { + small: "Small", + medium: "Medium", + large: "Large", + }; if (!open) return null; @@ -36,8 +44,26 @@ export function AddWidgetDialog({

- Choose a widget to add to your dashboard + Choose a widget and size to add to your dashboard

+ + {/* Size selector */} +
+ {(["small", "medium", "large"] as WidgetSize[]).map((size) => ( + + ))} +
+
{availableWidgets.map((widget: WidgetRegistryItem) => { const Icon = widget.icon; @@ -45,7 +71,7 @@ export function AddWidgetDialog({ + <> + + + )}

CPU Usage

-
-
- - {(() => { - const pct = metrics?.cpu?.percent; - const cores = metrics?.cpu?.cores; - const pctText = typeof pct === "number" ? `${pct}%` : "N/A"; - const coresText = - typeof cores === "number" - ? t("serverStats.cpuCores", { count: cores }) - : t("serverStats.naCpus"); - return `${pctText} ${t("serverStats.of")} ${coresText}`; - })()} - + + {widgetSize === "small" && ( +
+
+ {typeof metrics?.cpu?.percent === "number" + ? `${metrics.cpu.percent}%` + : "N/A"} +
+
+ {typeof metrics?.cpu?.cores === "number" + ? t("serverStats.cpuCores", { count: metrics.cpu.cores }) + : t("serverStats.naCpus")} +
-
- + )} + + {widgetSize === "medium" && ( +
+
+ + {(() => { + const pct = metrics?.cpu?.percent; + const cores = metrics?.cpu?.cores; + const pctText = typeof pct === "number" ? `${pct}%` : "N/A"; + const coresText = + typeof cores === "number" + ? t("serverStats.cpuCores", { count: cores }) + : t("serverStats.naCpus"); + return `${pctText} ${t("serverStats.of")} ${coresText}`; + })()} + +
+
+ +
+
+ {metrics?.cpu?.load + ? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}` + : "Load: N/A"} +
-
- {metrics?.cpu?.load - ? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}` - : "Load: N/A"} + )} + + {widgetSize === "large" && ( +
+
+
+ {typeof metrics?.cpu?.percent === "number" + ? `${metrics.cpu.percent}%` + : "N/A"} +
+
+ {typeof metrics?.cpu?.cores === "number" + ? t("serverStats.cpuCores", { count: metrics.cpu.cores }) + : t("serverStats.naCpus")} +
+
+
+ {metrics?.cpu?.load + ? `Load: ${metrics.cpu.load[0].toFixed(2)} / ${metrics.cpu.load[1].toFixed(2)} / ${metrics.cpu.load[2].toFixed(2)}` + : "Load: N/A"} +
+
+ + + + + + [`${value.toFixed(1)}%`, "CPU"]} + /> + + + +
-
+ )}
); } diff --git a/src/ui/Desktop/Apps/Server/widgets/DiskWidget.tsx b/src/ui/Desktop/Apps/Server/widgets/DiskWidget.tsx index 6c360762..ddb43aa1 100644 --- a/src/ui/Desktop/Apps/Server/widgets/DiskWidget.tsx +++ b/src/ui/Desktop/Apps/Server/widgets/DiskWidget.tsx @@ -1,74 +1,200 @@ import React from "react"; -import { HardDrive, X } from "lucide-react"; +import { HardDrive, X, Maximize2 } from "lucide-react"; import { Progress } from "@/components/ui/progress.tsx"; import { useTranslation } from "react-i18next"; import type { ServerMetrics } from "@/ui/main-axios.ts"; +import type { WidgetSize } from "@/types/stats-widgets"; +import { ChartContainer, RechartsPrimitive } from "@/components/ui/chart.tsx"; + +const { RadialBarChart, RadialBar, PolarAngleAxis, ResponsiveContainer } = + RechartsPrimitive; interface DiskWidgetProps { metrics: ServerMetrics | null; + metricsHistory: ServerMetrics[]; isEditMode: boolean; widgetId: string; - onDelete: (widgetId: string, e: React.MouseEvent) => void; + widgetSize: WidgetSize; + onDelete: (widgetId: string, e: React.MouseEvent) => void; + onChangeSize: ( + widgetId: string, + newSize: WidgetSize, + e: React.MouseEvent, + ) => void; } export function DiskWidget({ metrics, + metricsHistory, isEditMode, widgetId, + widgetSize, onDelete, + onChangeSize, }: DiskWidgetProps) { const { t } = useTranslation(); + const sizeOrder: WidgetSize[] = ["small", "medium", "large"]; + const nextSize = + sizeOrder[(sizeOrder.indexOf(widgetSize) + 1) % sizeOrder.length]; + + // Prepare radial chart data + const radialData = React.useMemo(() => { + const percent = metrics?.disk?.percent || 0; + return [ + { + name: "Disk", + value: percent, + fill: "#fb923c", + }, + ]; + }, [metrics]); + return ( -
+
{isEditMode && ( - + <> + + + )}

Disk Usage

-
-
- + + {widgetSize === "small" && ( +
+
+ {typeof metrics?.disk?.percent === "number" + ? `${metrics.disk.percent}%` + : "N/A"} +
+
{(() => { - const pct = metrics?.disk?.percent; const used = metrics?.disk?.usedHuman; const total = metrics?.disk?.totalHuman; - const pctText = typeof pct === "number" ? `${pct}%` : "N/A"; - const usedText = used ?? "N/A"; - const totalText = total ?? "N/A"; - return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`; + if (used && total) { + return `${used} / ${total}`; + } + return "N/A"; })()} - +
-
- + )} + + {widgetSize === "medium" && ( +
+
+ + {(() => { + const pct = metrics?.disk?.percent; + const used = metrics?.disk?.usedHuman; + const total = metrics?.disk?.totalHuman; + const pctText = typeof pct === "number" ? `${pct}%` : "N/A"; + const usedText = used ?? "N/A"; + const totalText = total ?? "N/A"; + return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`; + })()} + +
+
+ +
+
+ {(() => { + const available = metrics?.disk?.availableHuman; + return available ? `Available: ${available}` : "Available: N/A"; + })()} +
-
- {(() => { - const available = metrics?.disk?.availableHuman; - return available ? `Available: ${available}` : "Available: N/A"; - })()} + )} + + {widgetSize === "large" && ( +
+
+ + + + + + {typeof metrics?.disk?.percent === "number" + ? `${metrics.disk.percent}%` + : "N/A"} + + + +
+
+
+ {(() => { + const used = metrics?.disk?.usedHuman; + const total = metrics?.disk?.totalHuman; + if (used && total) { + return `${used} / ${total}`; + } + return "N/A"; + })()} +
+
+ {(() => { + const available = metrics?.disk?.availableHuman; + return available ? `Available: ${available}` : "Available: N/A"; + })()} +
+
-
+ )}
); } diff --git a/src/ui/Desktop/Apps/Server/widgets/MemoryWidget.tsx b/src/ui/Desktop/Apps/Server/widgets/MemoryWidget.tsx index b00e98de..bd56f322 100644 --- a/src/ui/Desktop/Apps/Server/widgets/MemoryWidget.tsx +++ b/src/ui/Desktop/Apps/Server/widgets/MemoryWidget.tsx @@ -1,81 +1,233 @@ import React from "react"; -import { MemoryStick, X } from "lucide-react"; +import { MemoryStick, X, Maximize2 } from "lucide-react"; import { Progress } from "@/components/ui/progress.tsx"; import { useTranslation } from "react-i18next"; import type { ServerMetrics } from "@/ui/main-axios.ts"; +import type { WidgetSize } from "@/types/stats-widgets"; +import { ChartContainer, RechartsPrimitive } from "@/components/ui/chart.tsx"; + +const { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} = RechartsPrimitive; interface MemoryWidgetProps { metrics: ServerMetrics | null; + metricsHistory: ServerMetrics[]; isEditMode: boolean; widgetId: string; + widgetSize: WidgetSize; onDelete: (widgetId: string, e: React.MouseEvent) => void; + onChangeSize: ( + widgetId: string, + newSize: WidgetSize, + e: React.MouseEvent, + ) => void; } export function MemoryWidget({ metrics, + metricsHistory, isEditMode, widgetId, + widgetSize, onDelete, + onChangeSize, }: MemoryWidgetProps) { const { t } = useTranslation(); + const sizeOrder: WidgetSize[] = ["small", "medium", "large"]; + const nextSize = + sizeOrder[(sizeOrder.indexOf(widgetSize) + 1) % sizeOrder.length]; + + // Prepare chart data + const chartData = React.useMemo(() => { + return metricsHistory.map((m, index) => ({ + index, + memory: m.memory?.percent || 0, + })); + }, [metricsHistory]); + return ( -
+
{isEditMode && ( - + <> + + + )}

Memory Usage

-
-
- + + {widgetSize === "small" && ( +
+
+ {typeof metrics?.memory?.percent === "number" + ? `${metrics.memory.percent}%` + : "N/A"} +
+
{(() => { - const pct = metrics?.memory?.percent; const used = metrics?.memory?.usedGiB; const total = metrics?.memory?.totalGiB; - const pctText = typeof pct === "number" ? `${pct}%` : "N/A"; - const usedText = - typeof used === "number" ? `${used.toFixed(1)} GiB` : "N/A"; - const totalText = - typeof total === "number" ? `${total.toFixed(1)} GiB` : "N/A"; - return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`; + if (typeof used === "number" && typeof total === "number") { + return `${used.toFixed(1)} / ${total.toFixed(1)} GiB`; + } + return "N/A"; })()} - +
-
- + )} + + {widgetSize === "medium" && ( +
+
+ + {(() => { + const pct = metrics?.memory?.percent; + const used = metrics?.memory?.usedGiB; + const total = metrics?.memory?.totalGiB; + const pctText = typeof pct === "number" ? `${pct}%` : "N/A"; + const usedText = + typeof used === "number" ? `${used.toFixed(1)} GiB` : "N/A"; + const totalText = + typeof total === "number" ? `${total.toFixed(1)} GiB` : "N/A"; + return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`; + })()} + +
+
+ +
+
+ {(() => { + const used = metrics?.memory?.usedGiB; + const total = metrics?.memory?.totalGiB; + const free = + typeof used === "number" && typeof total === "number" + ? (total - used).toFixed(1) + : "N/A"; + return `Free: ${free} GiB`; + })()} +
-
- {(() => { - const used = metrics?.memory?.usedGiB; - const total = metrics?.memory?.totalGiB; - const free = - typeof used === "number" && typeof total === "number" - ? (total - used).toFixed(1) - : "N/A"; - return `Free: ${free} GiB`; - })()} + )} + + {widgetSize === "large" && ( +
+
+
+ {typeof metrics?.memory?.percent === "number" + ? `${metrics.memory.percent}%` + : "N/A"} +
+
+ {(() => { + const used = metrics?.memory?.usedGiB; + const total = metrics?.memory?.totalGiB; + if (typeof used === "number" && typeof total === "number") { + return `${used.toFixed(1)} / ${total.toFixed(1)} GiB`; + } + return "N/A"; + })()} +
+
+
+ {(() => { + const used = metrics?.memory?.usedGiB; + const total = metrics?.memory?.totalGiB; + const free = + typeof used === "number" && typeof total === "number" + ? (total - used).toFixed(1) + : "N/A"; + return `Free: ${free} GiB`; + })()} +
+
+ + + + + + + + + + + + [ + `${value.toFixed(1)}%`, + "Memory", + ]} + /> + + + +
-
+ )}
); } diff --git a/src/ui/Desktop/Apps/Server/widgets/index.ts b/src/ui/Desktop/Apps/Server/widgets/index.ts index 55e897ff..f4ef1263 100644 --- a/src/ui/Desktop/Apps/Server/widgets/index.ts +++ b/src/ui/Desktop/Apps/Server/widgets/index.ts @@ -5,6 +5,7 @@ export { WIDGET_REGISTRY, getAvailableWidgets, getWidgetConfig, + getWidgetSize, generateWidgetId, type WidgetRegistryItem, } from "./registry"; diff --git a/src/ui/Desktop/Apps/Server/widgets/registry.ts b/src/ui/Desktop/Apps/Server/widgets/registry.ts index 3f41e990..807a5cf3 100644 --- a/src/ui/Desktop/Apps/Server/widgets/registry.ts +++ b/src/ui/Desktop/Apps/Server/widgets/registry.ts @@ -1,5 +1,10 @@ import { Cpu, MemoryStick, HardDrive, type LucideIcon } from "lucide-react"; -import type { WidgetType } from "@/types/stats-widgets"; +import type { WidgetType, WidgetSize } from "@/types/stats-widgets"; + +export interface WidgetSizeConfig { + w: number; + h: number; +} export interface WidgetRegistryItem { type: WidgetType; @@ -7,7 +12,7 @@ export interface WidgetRegistryItem { description: string; icon: LucideIcon; iconColor: string; - defaultSize: { w: number; h: number }; + sizes: Record; minSize: { w: number; h: number }; maxSize: { w: number; h: number }; } @@ -19,7 +24,11 @@ export const WIDGET_REGISTRY: Record = { description: "Monitor CPU utilization and load average", icon: Cpu, iconColor: "text-blue-400", - defaultSize: { w: 4, h: 2 }, + sizes: { + small: { w: 3, h: 2 }, // 紧凑:大号百分比+核心数 + medium: { w: 4, h: 2 }, // 标准:进度条+load average + large: { w: 7, h: 3 }, // 图表:折线图需要宽度展示趋势 + }, minSize: { w: 3, h: 2 }, maxSize: { w: 12, h: 4 }, }, @@ -29,7 +38,11 @@ export const WIDGET_REGISTRY: Record = { description: "Track RAM usage and availability", icon: MemoryStick, iconColor: "text-green-400", - defaultSize: { w: 4, h: 2 }, + sizes: { + small: { w: 3, h: 2 }, // 紧凑:百分比+用量 + medium: { w: 4, h: 2 }, // 标准:进度条+详细信息 + large: { w: 6, h: 3 }, // 图表:面积图展示 + }, minSize: { w: 3, h: 2 }, maxSize: { w: 12, h: 4 }, }, @@ -39,7 +52,11 @@ export const WIDGET_REGISTRY: Record = { description: "View disk space consumption", icon: HardDrive, iconColor: "text-orange-400", - defaultSize: { w: 4, h: 2 }, + sizes: { + small: { w: 3, h: 2 }, // 紧凑:百分比+用量 + medium: { w: 4, h: 2 }, // 标准:进度条+可用空间 + large: { w: 4, h: 4 }, // 图表:径向图(方形,不需要太宽) + }, minSize: { w: 3, h: 2 }, maxSize: { w: 12, h: 4 }, }, @@ -59,6 +76,16 @@ export function getWidgetConfig(type: WidgetType): WidgetRegistryItem { return WIDGET_REGISTRY[type]; } +/** + * Get widget size configuration + */ +export function getWidgetSize( + type: WidgetType, + size: WidgetSize, +): WidgetSizeConfig { + return WIDGET_REGISTRY[type].sizes[size]; +} + /** * Generate unique widget ID */