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.
This commit is contained in:
ZacharyZcR
2025-10-09 10:29:37 +08:00
parent 6e6b173e23
commit 5446875113
11 changed files with 1014 additions and 135 deletions

355
package-lock.json generated
View File

@@ -82,6 +82,7 @@
"react-simple-keyboard": "^3.8.120", "react-simple-keyboard": "^3.8.120",
"react-syntax-highlighter": "^15.6.6", "react-syntax-highlighter": "^15.6.6",
"react-xtermjs": "^1.0.10", "react-xtermjs": "^1.0.10",
"recharts": "^3.2.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"speakeasy": "^2.0.0", "speakeasy": "^2.0.0",
@@ -3356,6 +3357,32 @@
"version": "1.1.1", "version": "1.1.1",
"license": "MIT" "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": { "node_modules/@replit/codemirror-lang-nix": {
"version": "6.0.1", "version": "6.0.1",
"license": "MIT", "license": "MIT",
@@ -3656,6 +3683,69 @@
"@types/node": "*" "@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": { "node_modules/@types/debug": {
"version": "4.1.12", "version": "4.1.12",
"license": "MIT", "license": "MIT",
@@ -3876,6 +3966,12 @@
"version": "3.0.3", "version": "3.0.3",
"license": "MIT" "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": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
"dev": true, "dev": true,
@@ -5752,6 +5848,127 @@
"version": "1.4.5", "version": "1.4.5",
"license": "MIT" "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": { "node_modules/dargs": {
"version": "8.1.0", "version": "8.1.0",
"dev": true, "dev": true,
@@ -5809,6 +6026,12 @@
"node": ">=0.10.0" "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": { "node_modules/decode-named-character-reference": {
"version": "1.2.0", "version": "1.2.0",
"license": "MIT", "license": "MIT",
@@ -6579,6 +6802,16 @@
"node": ">= 0.4" "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": { "node_modules/es6-error": {
"version": "4.1.1", "version": "4.1.1",
"dev": true, "dev": true,
@@ -6854,7 +7087,6 @@
}, },
"node_modules/eventemitter3": { "node_modules/eventemitter3": {
"version": "5.0.1", "version": "5.0.1",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/expand-template": { "node_modules/expand-template": {
@@ -8108,6 +8340,16 @@
"version": "3.0.6", "version": "3.0.6",
"license": "MIT" "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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"dev": true, "dev": true,
@@ -8185,6 +8427,15 @@
"version": "0.2.4", "version": "0.2.4",
"license": "MIT" "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": { "node_modules/ip-address": {
"version": "10.0.1", "version": "10.0.1",
"dev": true, "dev": true,
@@ -11511,6 +11762,29 @@
"react-dom": "^17.0.2 || ^18 || ^19" "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": { "node_modules/react-remove-scroll": {
"version": "2.7.1", "version": "2.7.1",
"license": "MIT", "license": "MIT",
@@ -11674,6 +11948,48 @@
"version": "5.1.2", "version": "5.1.2",
"license": "MIT" "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": { "node_modules/refractor": {
"version": "3.6.0", "version": "3.6.0",
"license": "MIT", "license": "MIT",
@@ -11863,6 +12179,12 @@
"url": "https://github.com/sponsors/jet2jet" "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": { "node_modules/resize-observer-polyfill": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", "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": { "node_modules/utf8-byte-length": {
"version": "1.0.5", "version": "1.0.5",
"dev": true, "dev": true,
@@ -13389,6 +13720,28 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/vimeo-video-element": {
"version": "1.6.0", "version": "1.6.0",
"license": "MIT", "license": "MIT",

View File

@@ -103,6 +103,7 @@
"react-simple-keyboard": "^3.8.120", "react-simple-keyboard": "^3.8.120",
"react-syntax-highlighter": "^15.6.6", "react-syntax-highlighter": "^15.6.6",
"react-xtermjs": "^1.0.10", "react-xtermjs": "^1.0.10",
"recharts": "^3.2.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"speakeasy": "^2.0.0", "speakeasy": "^2.0.0",

View File

@@ -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<HTMLDivElement>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
/>
);
});
ChartContainer.displayName = "ChartContainer";
export { ChartContainer, RechartsPrimitive };

View File

@@ -7,9 +7,12 @@ export type WidgetType =
// | 'processes' // 进程数 // | 'processes' // 进程数
// | 'uptime'; // 运行时间 // | 'uptime'; // 运行时间
export type WidgetSize = "small" | "medium" | "large";
export interface Widget { export interface Widget {
id: string; // 唯一 ID"cpu-1", "memory-2" id: string; // 唯一 ID"cpu-1", "memory-2"
type: WidgetType; // 卡片类型 type: WidgetType; // 卡片类型
size: WidgetSize; // 尺寸small/medium/large
x: number; // 网格X坐标 (0-11) x: number; // 网格X坐标 (0-11)
y: number; // 网格Y坐标 y: number; // 网格Y坐标
w: number; // 宽度(网格单位 1-12 w: number; // 宽度(网格单位 1-12
@@ -22,9 +25,9 @@ export interface StatsConfig {
export const DEFAULT_STATS_CONFIG: StatsConfig = { export const DEFAULT_STATS_CONFIG: StatsConfig = {
widgets: [ widgets: [
{ id: "cpu-1", type: "cpu", x: 0, 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", x: 4, 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", x: 8, y: 0, w: 4, h: 2 }, { id: "disk-1", type: "disk", size: "medium", x: 8, y: 0, w: 4, h: 2 },
], ],
}; };

View File

@@ -25,6 +25,7 @@ import {
DiskWidget, DiskWidget,
generateWidgetId, generateWidgetId,
getWidgetConfig, getWidgetConfig,
getWidgetSize,
} from "./widgets"; } from "./widgets";
import { AddWidgetDialog } from "./widgets/AddWidgetDialog"; import { AddWidgetDialog } from "./widgets/AddWidgetDialog";
import "react-grid-layout/css/styles.css"; import "react-grid-layout/css/styles.css";
@@ -54,6 +55,9 @@ export function Server({
"offline", "offline",
); );
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null); const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
const [metricsHistory, setMetricsHistory] = React.useState<ServerMetrics[]>(
[],
);
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig); const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false); const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
const [isRefreshing, setIsRefreshing] = React.useState(false); const [isRefreshing, setIsRefreshing] = React.useState(false);
@@ -120,10 +124,36 @@ export function Server({
setHasUnsavedChanges(true); setHasUnsavedChanges(true);
}; };
const handleAddWidget = (widgetType: string) => { const handleChangeWidgetSize = (
widgetId: string,
newSize: any,
e: React.MouseEvent<HTMLButtonElement>,
) => {
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 existingIds = widgets.map((w) => w.id);
const newId = generateWidgetId(widgetType as any, existingIds); const newId = generateWidgetId(widgetType as any, existingIds);
const config = getWidgetConfig(widgetType as any); const config = getWidgetConfig(widgetType as any);
const sizeConfig = getWidgetSize(widgetType as any, size);
// Find the next available position // Find the next available position
const maxY = widgets.reduce((max, w) => Math.max(max, w.y + w.h), 0); const maxY = widgets.reduce((max, w) => Math.max(max, w.y + w.h), 0);
@@ -131,10 +161,11 @@ export function Server({
const newWidget: Widget = { const newWidget: Widget = {
id: newId, id: newId,
type: widgetType as any, type: widgetType as any,
size: size,
x: 0, x: 0,
y: maxY, y: maxY,
w: config.defaultSize.w, w: sizeConfig.w,
h: config.defaultSize.h, h: sizeConfig.h,
}; };
setWidgets((prev) => [...prev, newWidget]); setWidgets((prev) => [...prev, newWidget]);
@@ -173,9 +204,12 @@ export function Server({
return ( return (
<CpuWidget <CpuWidget
metrics={metrics} metrics={metrics}
metricsHistory={metricsHistory}
isEditMode={isEditMode} isEditMode={isEditMode}
widgetId={widget.id} widgetId={widget.id}
widgetSize={widget.size}
onDelete={handleDeleteWidget} onDelete={handleDeleteWidget}
onChangeSize={handleChangeWidgetSize}
/> />
); );
@@ -183,9 +217,12 @@ export function Server({
return ( return (
<MemoryWidget <MemoryWidget
metrics={metrics} metrics={metrics}
metricsHistory={metricsHistory}
isEditMode={isEditMode} isEditMode={isEditMode}
widgetId={widget.id} widgetId={widget.id}
widgetSize={widget.size}
onDelete={handleDeleteWidget} onDelete={handleDeleteWidget}
onChangeSize={handleChangeWidgetSize}
/> />
); );
@@ -193,9 +230,12 @@ export function Server({
return ( return (
<DiskWidget <DiskWidget
metrics={metrics} metrics={metrics}
metricsHistory={metricsHistory}
isEditMode={isEditMode} isEditMode={isEditMode}
widgetId={widget.id} widgetId={widget.id}
widgetSize={widget.size}
onDelete={handleDeleteWidget} onDelete={handleDeleteWidget}
onChangeSize={handleChangeWidgetSize}
/> />
); );
@@ -275,6 +315,11 @@ export function Server({
const data = await getServerMetricsById(currentHostConfig.id); const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) { if (!cancelled) {
setMetrics(data); setMetrics(data);
setMetricsHistory((prev) => {
const newHistory = [...prev, data];
// Keep last 20 data points for chart
return newHistory.slice(-20);
});
setShowStatsUI(true); setShowStatsUI(true);
} }
} catch (error: any) { } catch (error: any) {

View File

@@ -1,11 +1,12 @@
import React from "react"; import React from "react";
import { getAvailableWidgets, type WidgetRegistryItem } from "./registry"; import { getAvailableWidgets, type WidgetRegistryItem } from "./registry";
import { Plus, X } from "lucide-react"; import { Plus, X } from "lucide-react";
import type { WidgetSize } from "@/types/stats-widgets";
interface AddWidgetDialogProps { interface AddWidgetDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onAddWidget: (widgetType: string) => void; onAddWidget: (widgetType: string, size: WidgetSize) => void;
existingWidgetTypes: string[]; existingWidgetTypes: string[];
} }
@@ -16,6 +17,13 @@ export function AddWidgetDialog({
existingWidgetTypes, existingWidgetTypes,
}: AddWidgetDialogProps) { }: AddWidgetDialogProps) {
const availableWidgets = getAvailableWidgets(); const availableWidgets = getAvailableWidgets();
const [selectedSize, setSelectedSize] = React.useState<WidgetSize>("medium");
const sizeLabels: Record<WidgetSize, string> = {
small: "Small",
medium: "Medium",
large: "Large",
};
if (!open) return null; if (!open) return null;
@@ -36,8 +44,26 @@ export function AddWidgetDialog({
</button> </button>
</div> </div>
<p className="text-gray-400 text-sm mb-4"> <p className="text-gray-400 text-sm mb-4">
Choose a widget to add to your dashboard Choose a widget and size to add to your dashboard
</p> </p>
{/* Size selector */}
<div className="flex gap-2 mb-4">
{(["small", "medium", "large"] as WidgetSize[]).map((size) => (
<button
key={size}
onClick={() => setSelectedSize(size)}
className={`flex-1 py-2 px-4 rounded-lg border transition-all ${
selectedSize === size
? "bg-blue-500 border-blue-500 text-white"
: "bg-dark-bg-darker border-dark-border text-gray-400 hover:border-blue-500/50 hover:text-white"
}`}
>
{sizeLabels[size]}
</button>
))}
</div>
<div className="grid gap-3 max-h-[400px] overflow-y-auto"> <div className="grid gap-3 max-h-[400px] overflow-y-auto">
{availableWidgets.map((widget: WidgetRegistryItem) => { {availableWidgets.map((widget: WidgetRegistryItem) => {
const Icon = widget.icon; const Icon = widget.icon;
@@ -45,7 +71,7 @@ export function AddWidgetDialog({
<button <button
key={widget.type} key={widget.type}
onClick={() => { onClick={() => {
onAddWidget(widget.type); onAddWidget(widget.type, selectedSize);
onOpenChange(false); onOpenChange(false);
}} }}
className="flex items-start gap-4 p-4 rounded-lg border border-dark-border bg-dark-bg/50 hover:bg-dark-bg hover:border-blue-500/50 transition-all duration-200 text-left group" className="flex items-start gap-4 p-4 rounded-lg border border-dark-border bg-dark-bg/50 hover:bg-dark-bg hover:border-blue-500/50 transition-all duration-200 text-left group"

View File

@@ -1,27 +1,72 @@
import React from "react"; import React from "react";
import { Cpu, X } from "lucide-react"; import { Cpu, X, Maximize2 } from "lucide-react";
import { Progress } from "@/components/ui/progress.tsx"; import { Progress } from "@/components/ui/progress.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts"; import type { ServerMetrics } from "@/ui/main-axios.ts";
import type { WidgetSize } from "@/types/stats-widgets";
import { ChartContainer, RechartsPrimitive } from "@/components/ui/chart.tsx";
const {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} = RechartsPrimitive;
interface CpuWidgetProps { interface CpuWidgetProps {
metrics: ServerMetrics | null; metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
isEditMode: boolean; isEditMode: boolean;
widgetId: string; widgetId: string;
widgetSize: WidgetSize;
onDelete: (widgetId: string, e: React.MouseEvent<HTMLButtonElement>) => void; onDelete: (widgetId: string, e: React.MouseEvent<HTMLButtonElement>) => void;
onChangeSize: (
widgetId: string,
newSize: WidgetSize,
e: React.MouseEvent<HTMLButtonElement>,
) => void;
} }
export function CpuWidget({ export function CpuWidget({
metrics, metrics,
metricsHistory,
isEditMode, isEditMode,
widgetId, widgetId,
widgetSize,
onDelete, onDelete,
onChangeSize,
}: CpuWidgetProps) { }: CpuWidgetProps) {
const { t } = useTranslation(); 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,
cpu: m.cpu?.percent || 0,
}));
}, [metricsHistory]);
return ( return (
<div className="h-full w-full space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
{isEditMode && ( {isEditMode && (
<>
<button
onClick={(e) => onChangeSize(widgetId, nextSize, e)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="absolute top-2 right-11 z-[9999] w-7 h-7 bg-blue-500/90 hover:bg-blue-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
type="button"
title={`Change to ${nextSize}`}
>
<Maximize2 className="h-4 w-4" />
</button>
<button <button
onClick={(e) => onDelete(widgetId, e)} onClick={(e) => onDelete(widgetId, e)}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
@@ -31,13 +76,31 @@ export function CpuWidget({
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</>
)} )}
<div <div
className={`flex items-center gap-2 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`} className={`flex items-center gap-2 flex-shrink-0 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
> >
<Cpu className="h-5 w-5 text-blue-400" /> <Cpu className="h-5 w-5 text-blue-400" />
<h3 className="font-semibold text-lg text-white">CPU Usage</h3> <h3 className="font-semibold text-lg text-white">CPU Usage</h3>
</div> </div>
{widgetSize === "small" && (
<div className="flex flex-col items-center justify-center flex-1">
<div className="text-4xl font-bold text-blue-400">
{typeof metrics?.cpu?.percent === "number"
? `${metrics.cpu.percent}%`
: "N/A"}
</div>
<div className="text-sm text-gray-400 mt-2">
{typeof metrics?.cpu?.cores === "number"
? t("serverStats.cpuCores", { count: metrics.cpu.cores })
: t("serverStats.naCpus")}
</div>
</div>
)}
{widgetSize === "medium" && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm text-gray-300"> <span className="text-sm text-gray-300">
@@ -69,6 +132,64 @@ export function CpuWidget({
: "Load: N/A"} : "Load: N/A"}
</div> </div>
</div> </div>
)}
{widgetSize === "large" && (
<div className="flex flex-col flex-1 min-h-0 gap-2">
<div className="flex items-baseline gap-3 flex-shrink-0">
<div className="text-2xl font-bold text-blue-400">
{typeof metrics?.cpu?.percent === "number"
? `${metrics.cpu.percent}%`
: "N/A"}
</div>
<div className="text-xs text-gray-400">
{typeof metrics?.cpu?.cores === "number"
? t("serverStats.cpuCores", { count: metrics.cpu.cores })
: t("serverStats.naCpus")}
</div>
</div>
<div className="text-xs text-gray-500 flex-shrink-0">
{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"}
</div>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="index"
stroke="#9ca3af"
tick={{ fill: "#9ca3af" }}
hide
/>
<YAxis
domain={[0, 100]}
stroke="#9ca3af"
tick={{ fill: "#9ca3af" }}
/>
<Tooltip
contentStyle={{
backgroundColor: "#1f2937",
border: "1px solid #374151",
borderRadius: "6px",
color: "#fff",
}}
formatter={(value: number) => [`${value.toFixed(1)}%`, "CPU"]}
/>
<Line
type="monotone"
dataKey="cpu"
stroke="#60a5fa"
strokeWidth={2}
dot={false}
animationDuration={300}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,27 +1,69 @@
import React from "react"; 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 { Progress } from "@/components/ui/progress.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts"; 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 { interface DiskWidgetProps {
metrics: ServerMetrics | null; metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
isEditMode: boolean; isEditMode: boolean;
widgetId: string; widgetId: string;
onDelete: (widgetId: string, e: React.MouseEvent<HTMLButtonElement>) => void; widgetSize: WidgetSize;
onDelete: (widgetId: string, e: React.MouseEvent<HTMLButtonButton>) => void;
onChangeSize: (
widgetId: string,
newSize: WidgetSize,
e: React.MouseEvent<HTMLButtonButton>,
) => void;
} }
export function DiskWidget({ export function DiskWidget({
metrics, metrics,
metricsHistory,
isEditMode, isEditMode,
widgetId, widgetId,
widgetSize,
onDelete, onDelete,
onChangeSize,
}: DiskWidgetProps) { }: DiskWidgetProps) {
const { t } = useTranslation(); 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 ( return (
<div className="h-full w-full space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
{isEditMode && ( {isEditMode && (
<>
<button
onClick={(e) => onChangeSize(widgetId, nextSize, e)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="absolute top-2 right-11 z-[9999] w-7 h-7 bg-blue-500/90 hover:bg-blue-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
type="button"
title={`Change to ${nextSize}`}
>
<Maximize2 className="h-4 w-4" />
</button>
<button <button
onClick={(e) => onDelete(widgetId, e)} onClick={(e) => onDelete(widgetId, e)}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
@@ -31,13 +73,36 @@ export function DiskWidget({
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</>
)} )}
<div <div
className={`flex items-center gap-2 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`} className={`flex items-center gap-2 flex-shrink-0 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
> >
<HardDrive className="h-5 w-5 text-orange-400" /> <HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white">Disk Usage</h3> <h3 className="font-semibold text-lg text-white">Disk Usage</h3>
</div> </div>
{widgetSize === "small" && (
<div className="flex flex-col items-center justify-center flex-1">
<div className="text-4xl font-bold text-orange-400">
{typeof metrics?.disk?.percent === "number"
? `${metrics.disk.percent}%`
: "N/A"}
</div>
<div className="text-sm text-gray-400 mt-2">
{(() => {
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
if (used && total) {
return `${used} / ${total}`;
}
return "N/A";
})()}
</div>
</div>
)}
{widgetSize === "medium" && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm text-gray-300"> <span className="text-sm text-gray-300">
@@ -69,6 +134,67 @@ export function DiskWidget({
})()} })()}
</div> </div>
</div> </div>
)}
{widgetSize === "large" && (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex-1 min-h-0 flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
cx="50%"
cy="50%"
innerRadius="60%"
outerRadius="90%"
data={radialData}
startAngle={90}
endAngle={-270}
>
<PolarAngleAxis
type="number"
domain={[0, 100]}
angleAxisId={0}
tick={false}
/>
<RadialBar
background
dataKey="value"
cornerRadius={10}
fill="#fb923c"
/>
<text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
className="text-2xl font-bold fill-orange-400"
>
{typeof metrics?.disk?.percent === "number"
? `${metrics.disk.percent}%`
: "N/A"}
</text>
</RadialBarChart>
</ResponsiveContainer>
</div>
<div className="flex-shrink-0 space-y-1 text-center pb-2">
<div className="text-xs text-gray-400">
{(() => {
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
if (used && total) {
return `${used} / ${total}`;
}
return "N/A";
})()}
</div>
<div className="text-xs text-gray-500">
{(() => {
const available = metrics?.disk?.availableHuman;
return available ? `Available: ${available}` : "Available: N/A";
})()}
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,27 +1,72 @@
import React from "react"; 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 { Progress } from "@/components/ui/progress.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts"; 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 { interface MemoryWidgetProps {
metrics: ServerMetrics | null; metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
isEditMode: boolean; isEditMode: boolean;
widgetId: string; widgetId: string;
widgetSize: WidgetSize;
onDelete: (widgetId: string, e: React.MouseEvent<HTMLButtonElement>) => void; onDelete: (widgetId: string, e: React.MouseEvent<HTMLButtonElement>) => void;
onChangeSize: (
widgetId: string,
newSize: WidgetSize,
e: React.MouseEvent<HTMLButtonElement>,
) => void;
} }
export function MemoryWidget({ export function MemoryWidget({
metrics, metrics,
metricsHistory,
isEditMode, isEditMode,
widgetId, widgetId,
widgetSize,
onDelete, onDelete,
onChangeSize,
}: MemoryWidgetProps) { }: MemoryWidgetProps) {
const { t } = useTranslation(); 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 ( return (
<div className="h-full w-full space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
{isEditMode && ( {isEditMode && (
<>
<button
onClick={(e) => onChangeSize(widgetId, nextSize, e)}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="absolute top-2 right-11 z-[9999] w-7 h-7 bg-blue-500/90 hover:bg-blue-600 text-white rounded-full flex items-center justify-center cursor-pointer shadow-lg"
type="button"
title={`Change to ${nextSize}`}
>
<Maximize2 className="h-4 w-4" />
</button>
<button <button
onClick={(e) => onDelete(widgetId, e)} onClick={(e) => onDelete(widgetId, e)}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
@@ -31,13 +76,36 @@ export function MemoryWidget({
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</>
)} )}
<div <div
className={`flex items-center gap-2 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`} className={`flex items-center gap-2 flex-shrink-0 mb-3 ${isEditMode ? "drag-handle cursor-move" : ""}`}
> >
<MemoryStick className="h-5 w-5 text-green-400" /> <MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">Memory Usage</h3> <h3 className="font-semibold text-lg text-white">Memory Usage</h3>
</div> </div>
{widgetSize === "small" && (
<div className="flex flex-col items-center justify-center flex-1">
<div className="text-4xl font-bold text-green-400">
{typeof metrics?.memory?.percent === "number"
? `${metrics.memory.percent}%`
: "N/A"}
</div>
<div className="text-sm text-gray-400 mt-2">
{(() => {
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";
})()}
</div>
</div>
)}
{widgetSize === "medium" && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm text-gray-300"> <span className="text-sm text-gray-300">
@@ -76,6 +144,90 @@ export function MemoryWidget({
})()} })()}
</div> </div>
</div> </div>
)}
{widgetSize === "large" && (
<div className="flex flex-col flex-1 min-h-0 gap-2">
<div className="flex items-baseline gap-3 flex-shrink-0">
<div className="text-2xl font-bold text-green-400">
{typeof metrics?.memory?.percent === "number"
? `${metrics.memory.percent}%`
: "N/A"}
</div>
<div className="text-xs text-gray-400">
{(() => {
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";
})()}
</div>
</div>
<div className="text-xs text-gray-500 flex-shrink-0">
{(() => {
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`;
})()}
</div>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient
id="memoryGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor="#34d399" stopOpacity={0.8} />
<stop offset="95%" stopColor="#34d399" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="index"
stroke="#9ca3af"
tick={{ fill: "#9ca3af" }}
hide
/>
<YAxis
domain={[0, 100]}
stroke="#9ca3af"
tick={{ fill: "#9ca3af" }}
/>
<Tooltip
contentStyle={{
backgroundColor: "#1f2937",
border: "1px solid #374151",
borderRadius: "6px",
color: "#fff",
}}
formatter={(value: number) => [
`${value.toFixed(1)}%`,
"Memory",
]}
/>
<Area
type="monotone"
dataKey="memory"
stroke="#34d399"
strokeWidth={2}
fill="url(#memoryGradient)"
animationDuration={300}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -5,6 +5,7 @@ export {
WIDGET_REGISTRY, WIDGET_REGISTRY,
getAvailableWidgets, getAvailableWidgets,
getWidgetConfig, getWidgetConfig,
getWidgetSize,
generateWidgetId, generateWidgetId,
type WidgetRegistryItem, type WidgetRegistryItem,
} from "./registry"; } from "./registry";

View File

@@ -1,5 +1,10 @@
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from "lucide-react"; 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 { export interface WidgetRegistryItem {
type: WidgetType; type: WidgetType;
@@ -7,7 +12,7 @@ export interface WidgetRegistryItem {
description: string; description: string;
icon: LucideIcon; icon: LucideIcon;
iconColor: string; iconColor: string;
defaultSize: { w: number; h: number }; sizes: Record<WidgetSize, WidgetSizeConfig>;
minSize: { w: number; h: number }; minSize: { w: number; h: number };
maxSize: { w: number; h: number }; maxSize: { w: number; h: number };
} }
@@ -19,7 +24,11 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetRegistryItem> = {
description: "Monitor CPU utilization and load average", description: "Monitor CPU utilization and load average",
icon: Cpu, icon: Cpu,
iconColor: "text-blue-400", 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 }, minSize: { w: 3, h: 2 },
maxSize: { w: 12, h: 4 }, maxSize: { w: 12, h: 4 },
}, },
@@ -29,7 +38,11 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetRegistryItem> = {
description: "Track RAM usage and availability", description: "Track RAM usage and availability",
icon: MemoryStick, icon: MemoryStick,
iconColor: "text-green-400", 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 }, minSize: { w: 3, h: 2 },
maxSize: { w: 12, h: 4 }, maxSize: { w: 12, h: 4 },
}, },
@@ -39,7 +52,11 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetRegistryItem> = {
description: "View disk space consumption", description: "View disk space consumption",
icon: HardDrive, icon: HardDrive,
iconColor: "text-orange-400", 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 }, minSize: { w: 3, h: 2 },
maxSize: { w: 12, h: 4 }, maxSize: { w: 12, h: 4 },
}, },
@@ -59,6 +76,16 @@ export function getWidgetConfig(type: WidgetType): WidgetRegistryItem {
return WIDGET_REGISTRY[type]; 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 * Generate unique widget ID
*/ */