From a518b683c9aa2fb077de415abc10a5c7ea7ae5a3 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 25 May 2026 04:24:15 +0200 Subject: [PATCH] refactor(frontend): swap custom Sparkline SVG for Recharts AreaChart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 368-line hand-rolled SVG sparkline (with manual ResizeObserver, gradient/shadow/glow filters, grid + ticks + tooltip, custom Y-axis label thinning) with a thin Recharts `` wrapper that keeps the same prop API. - Preserved props: data, labels, height, stroke, strokeWidth, maxPoints, showGrid, fillOpacity, showMarker, markerRadius, showAxes, yTickStep, tickCountX, showTooltip, valueMin, valueMax, yFormatter, tooltipFormatter. - Dropped: `vbWidth`, `gridColor`, `paddingLeft/Right/Top/Bottom` — Recharts' ResponsiveContainer handles width, and margins are wired to whether axes are visible. Removed the unused `vbWidth` prop from SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel callsites. - Tooltip, grid, and axis text now use AntD CSS variables for automatic light/dark adaptation; replaced the SVG body.dark forks in Sparkline.css with a single 5-line stylesheet. - Bundle: vendor +~100KB gzip (Recharts + its d3 deps), trade-off for less custom chart code to maintain and a more standard API for future charts (multi-series, brush, etc.). --- frontend/package-lock.json | 347 +++++++++++++++ frontend/package.json | 1 + frontend/src/components/Sparkline.css | 27 -- frontend/src/components/Sparkline.tsx | 412 +++++------------- .../src/pages/index/SystemHistoryModal.tsx | 1 - frontend/src/pages/index/XrayMetricsModal.tsx | 1 - frontend/src/pages/nodes/NodeHistoryPanel.tsx | 2 - 7 files changed, 453 insertions(+), 338 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ef3736c9..2042a04f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "react-dom": "^19.2.6", "react-i18next": "^17.0.8", "react-router-dom": "^7.15.1", + "recharts": "^3.8.1", "swagger-ui-react": "^5.32.6" }, "devDependencies": { @@ -1571,6 +1572,42 @@ "react-dom": ">=18.0.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "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/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", @@ -1860,6 +1897,18 @@ "hasInstallScript": true, "license": "Apache-2.0" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swagger-api/apidom-ast": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.11.1.tgz", @@ -2583,6 +2632,69 @@ "tslib": "^2.4.0" } }, + "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.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "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/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -3456,6 +3568,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "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.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "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/dayjs": { "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", @@ -3479,6 +3712,12 @@ } } }, + "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.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -3637,6 +3876,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3832,6 +4081,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4302,6 +4557,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.3.tgz", @@ -4327,6 +4592,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "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/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -5537,6 +5811,42 @@ "react": ">= 0.14.0" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 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/recharts/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/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -5552,6 +5862,15 @@ "immutable": "^3.8.1 || ^4.0.0-rc.1" } }, + "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": "5.0.0", "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", @@ -6028,6 +6347,12 @@ "node": ">=12.22" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -6280,6 +6605,28 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "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/vite": { "version": "8.0.13", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", diff --git a/frontend/package.json b/frontend/package.json index a62b16f5..905bb63b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,7 @@ "react-dom": "^19.2.6", "react-i18next": "^17.0.8", "react-router-dom": "^7.15.1", + "recharts": "^3.8.1", "swagger-ui-react": "^5.32.6" }, "devDependencies": { diff --git a/frontend/src/components/Sparkline.css b/frontend/src/components/Sparkline.css index 51c91ee0..2aece7ca 100644 --- a/frontend/src/components/Sparkline.css +++ b/frontend/src/components/Sparkline.css @@ -2,30 +2,3 @@ display: block; width: 100%; } - -.sparkline-svg .cpu-grid-y-text, -.sparkline-svg .cpu-grid-x-text { - fill: var(--ant-color-text-tertiary); - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - letter-spacing: 0.2px; -} - -.sparkline-svg .cpu-grid-text { - fill: var(--ant-color-text); -} - -.sparkline-svg .cpu-grid-line { - stroke: var(--ant-color-border-secondary); -} - -.sparkline-svg .cpu-tooltip-text { - pointer-events: none; -} - -.sparkline-svg .cpu-tooltip-pill { - filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18)); -} - -body.dark .sparkline-svg .cpu-tooltip-pill { - filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.6)); -} diff --git a/frontend/src/components/Sparkline.tsx b/frontend/src/components/Sparkline.tsx index c49db262..15a9055f 100644 --- a/frontend/src/components/Sparkline.tsx +++ b/frontend/src/components/Sparkline.tsx @@ -1,27 +1,29 @@ -import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; -import type { MouseEvent } from 'react'; +import { useId, useMemo } from 'react'; +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; import './Sparkline.css'; interface SparklineProps { data: number[]; labels?: (string | number)[]; - vbWidth?: number; height?: number; stroke?: string; strokeWidth?: number; maxPoints?: number; showGrid?: boolean; - gridColor?: string; fillOpacity?: number; showMarker?: boolean; markerRadius?: number; showAxes?: boolean; yTickStep?: number; tickCountX?: number; - paddingLeft?: number; - paddingRight?: number; - paddingTop?: number; - paddingBottom?: number; showTooltip?: boolean; valueMin?: number; valueMax?: number | null; @@ -29,340 +31,136 @@ interface SparklineProps { tooltipFormatter?: ((v: number) => string) | null; } +interface ChartPoint { + index: number; + value: number; + label: string; +} + export default function Sparkline({ data, labels = [], - vbWidth = 320, height = 80, stroke = '#008771', strokeWidth = 2, maxPoints = 120, showGrid = true, - gridColor = 'rgba(0,0,0,0.08)', fillOpacity = 0.22, showMarker = true, markerRadius = 3, showAxes = false, yTickStep = 25, tickCountX = 4, - paddingLeft = 56, - paddingRight = 6, - paddingTop = 6, - paddingBottom = 20, showTooltip = false, valueMin = 0, valueMax = 100, yFormatter = (v: number) => `${Math.round(v)}%`, tooltipFormatter = null, }: SparklineProps) { - const svgRef = useRef(null); - const [measuredWidth, setMeasuredWidth] = useState(0); - const [hoverIdx, setHoverIdx] = useState(-1); - const reactId = useId(); const safeId = reactId.replace(/[^a-zA-Z0-9]/g, ''); const gradId = `spkGrad-${safeId}`; - const shadowId = `spkShadow-${safeId}`; - const glowId = `spkGlow-${safeId}`; - useEffect(() => { - const el = svgRef.current; - if (!el) return; - const measure = () => { - const w = el.getBoundingClientRect?.().width || 0; - if (w > 0) setMeasuredWidth(Math.round(w)); - }; - measure(); - if (typeof ResizeObserver !== 'undefined') { - const ro = new ResizeObserver(measure); - ro.observe(el); - return () => ro.disconnect(); + const points = useMemo(() => { + const n = Math.min(data.length, maxPoints); + if (n === 0) return []; + const sliceStart = data.length - n; + const labelStart = Math.max(0, labels.length - n); + return data.slice(sliceStart).map((value, i) => ({ + index: i, + value: Number(value) || 0, + label: String(labels[labelStart + i] ?? i + 1), + })); + }, [data, labels, maxPoints]); + + const yDomain = useMemo<[number, number]>(() => { + if (valueMax != null) return [valueMin, valueMax]; + let max = valueMin; + for (const p of points) { + if (Number.isFinite(p.value) && p.value > max) max = p.value; } - window.addEventListener('resize', measure); - return () => window.removeEventListener('resize', measure); - }, []); - - const effectiveVbWidth = measuredWidth > 0 ? measuredWidth : vbWidth; - const drawWidth = Math.max(1, effectiveVbWidth - paddingLeft - paddingRight); - const drawHeight = Math.max(1, height - paddingTop - paddingBottom); - const nPoints = Math.min(data.length, maxPoints); - - const dataSlice = useMemo( - () => (nPoints === 0 ? [] : data.slice(data.length - nPoints)), - [data, nPoints], - ); - - const labelsSlice = useMemo(() => { - if (!labels?.length || nPoints === 0) return [] as (string | number)[]; - const start = Math.max(0, labels.length - nPoints); - return labels.slice(start); - }, [labels, nPoints]); - - const yDomain = useMemo(() => { - const min = valueMin; - if (valueMax != null) return { min, max: valueMax }; - let max = min; - for (const v of dataSlice) { - const n = Number(v); - if (Number.isFinite(n) && n > max) max = n; - } - if (max <= min) max = min + 1; - return { min, max: max * 1.1 }; - }, [dataSlice, valueMin, valueMax]); - - const project = useCallback( - (v: number) => { - const { min, max } = yDomain; - const span = max - min; - if (span <= 0) return paddingTop + drawHeight; - const clipped = Math.max(min, Math.min(max, Number(v) || 0)); - const ratio = (clipped - min) / span; - return Math.round(paddingTop + (drawHeight - ratio * drawHeight)); - }, - [yDomain, paddingTop, drawHeight], - ); - - const pointsArr = useMemo<[number, number][]>(() => { - if (nPoints === 0) return []; - const w = drawWidth; - const dx = nPoints > 1 ? w / (nPoints - 1) : 0; - return dataSlice.map((v, i) => { - const x = Math.round(paddingLeft + i * dx); - return [x, project(v)]; - }); - }, [dataSlice, nPoints, drawWidth, paddingLeft, project]); - - const pointsStr = useMemo(() => pointsArr.map((p) => `${p[0]},${p[1]}`).join(' '), [pointsArr]); - - const areaPath = useMemo(() => { - if (pointsArr.length === 0) return ''; - const first = pointsArr[0]; - const last = pointsArr[pointsArr.length - 1]; - const baseY = paddingTop + drawHeight; - const line = pointsStr.replace(/ /g, ' L '); - return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`; - }, [pointsArr, pointsStr, paddingTop, drawHeight]); - - const gridLines = useMemo(() => { - if (!showGrid) return []; - const h = drawHeight; - const w = drawWidth; - return [0, 0.25, 0.5, 0.75, 1].map((r) => { - const y = Math.round(paddingTop + h * r); - return { x1: paddingLeft, y1: y, x2: paddingLeft + w, y2: y }; - }); - }, [showGrid, drawHeight, drawWidth, paddingTop, paddingLeft]); - - const lastPoint = pointsArr.length === 0 ? null : pointsArr[pointsArr.length - 1]; + if (max <= valueMin) max = valueMin + 1; + return [valueMin, max * 1.1]; + }, [points, valueMin, valueMax]); const yTicks = useMemo(() => { - if (!showAxes) return []; - const { min, max } = yDomain; - const out: { y: number; label: string }[] = []; + if (!showAxes) return undefined; + const [min, max] = yDomain; if (valueMax === 100 && valueMin === 0 && yTickStep > 0) { - for (let p = min; p <= max; p += yTickStep) { - out.push({ y: project(p), label: yFormatter(p) }); - } + const out: number[] = []; + for (let v = min; v <= max; v += yTickStep) out.push(v); return out; } - const ticks = 5; - for (let i = 0; i < ticks; i++) { - const v = min + ((max - min) * i) / (ticks - 1); - out.push({ y: project(v), label: yFormatter(v) }); - } - return out; - }, [showAxes, yDomain, valueMax, valueMin, yTickStep, project, yFormatter]); + const n = 5; + return Array.from({ length: n }, (_, i) => min + ((max - min) * i) / (n - 1)); + }, [showAxes, yDomain, valueMin, valueMax, yTickStep]); - const xTicks = useMemo(() => { - if (!showAxes) return []; - if (nPoints === 0) return []; + const xTickIndexes = useMemo(() => { + if (!showAxes || points.length === 0) return undefined; const m = Math.max(2, tickCountX); - const w = drawWidth; - const dx = nPoints > 1 ? w / (nPoints - 1) : 0; - const out: { x: number; label: string }[] = []; - for (let i = 0; i < m; i++) { - const idx = Math.round((i * (nPoints - 1)) / (m - 1)); - const label = labelsSlice[idx] != null ? String(labelsSlice[idx]) : String(idx); - const x = Math.round(paddingLeft + idx * dx); - out.push({ x, label }); - } - return out; - }, [showAxes, labelsSlice, nPoints, tickCountX, drawWidth, paddingLeft]); + return Array.from({ length: m }, (_, i) => Math.round((i * (points.length - 1)) / (m - 1))); + }, [showAxes, tickCountX, points.length]); - const onMouseMove = useCallback( - (evt: MouseEvent) => { - if (!showTooltip || pointsArr.length === 0) return; - const rect = evt.currentTarget.getBoundingClientRect(); - const px = evt.clientX - rect.left; - const x = (px / rect.width) * effectiveVbWidth; - const dx = nPoints > 1 ? drawWidth / (nPoints - 1) : 0; - const idx = Math.max(0, Math.min(nPoints - 1, Math.round((x - paddingLeft) / (dx || 1)))); - setHoverIdx(idx); - }, - [showTooltip, pointsArr.length, effectiveVbWidth, nPoints, drawWidth, paddingLeft], - ); - - const onMouseLeave = useCallback(() => setHoverIdx(-1), []); - - const hoverText = useMemo(() => { - const idx = hoverIdx; - if (idx < 0 || idx >= dataSlice.length) return ''; - const raw = Number(dataSlice[idx] || 0); - const fmt = tooltipFormatter || yFormatter; - const val = fmt(Number.isFinite(raw) ? raw : 0); - const lab = labelsSlice[idx] != null ? labelsSlice[idx] : ''; - return `${val}${lab ? ' • ' + lab : ''}`; - }, [hoverIdx, dataSlice, labelsSlice, tooltipFormatter, yFormatter]); - - const tooltipPillWidth = Math.max(48, hoverText.length * 6.2 + 14); - const hoverPoint = hoverIdx >= 0 ? pointsArr[hoverIdx] : null; - const tooltipX = hoverPoint - ? Math.max( - paddingLeft + 2, - Math.min(effectiveVbWidth - paddingRight - tooltipPillWidth - 2, hoverPoint[0] - tooltipPillWidth / 2), - ) - : 0; + const fmtTooltip = tooltipFormatter ?? yFormatter; return ( - - - - - - - - - - - - - - - - - - - - - - - - - {showGrid && ( - - {gridLines.map((g, i) => ( - - ))} - - )} - - {showAxes && ( - - {yTicks.map((tk, i) => ( - - {tk.label} - - ))} - {xTicks.map((tk, i) => ( - - {tk.label} - - ))} - - )} - - {areaPath && } - - {showMarker && lastPoint && ( - <> - - - - - - - )} - - {showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx] && ( - - + + + + + + + + {showGrid && ( + + )} + points[i]?.label).filter(Boolean) as string[] | undefined} + /> + + {showTooltip && ( + [fmtTooltip(Number(v) || 0), '']} + separator="" /> - - - - - {hoverText} - - - )} - + )} + + + ); } diff --git a/frontend/src/pages/index/SystemHistoryModal.tsx b/frontend/src/pages/index/SystemHistoryModal.tsx index 84b00643..69592550 100644 --- a/frontend/src/pages/index/SystemHistoryModal.tsx +++ b/frontend/src/pages/index/SystemHistoryModal.tsx @@ -142,7 +142,6 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist