From 388f8ba729872b8413526e3fb68859968b915c19 Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Tue, 1 Sep 2026 12:52:20 -0700 Subject: [PATCH 01/12] feat: add home system status sparklines Replaces the all-time stats card on the resting home state with a 24 hour trend card: executions and p50/p95/p99 latency, polled every 30s. The metrics endpoints return sparse buckets, so each series is aligned onto a full 24 slot grid before plotting. Counts fill gaps with zero, latencies with null so an idle hour does not read as 0ms. formatResponseTime now scales precision with magnitude, since percentiles reach hundreds of milliseconds and rendered as "123.930ms". recharts is isolated in a vendor-charts chunk. Closes #6430 Signed-off-by: Anna Effort --- package-lock.json | 364 +++++++++++++++++- package.json | 1 + src/api/metrics.ts | 63 +++ src/components/dashboard/Sparkline.tsx | 45 +++ src/components/dashboard/SparklineRow.tsx | 32 ++ .../dashboard/SystemSparklinesCard.test.tsx | 74 ++++ .../dashboard/SystemSparklinesCard.tsx | 94 +++++ .../SystemSparklinesCardConnected.test.tsx | 63 +++ .../SystemSparklinesCardConnected.tsx | 25 ++ .../SystemStatsCardConnected.test.tsx | 61 --- .../dashboard/SystemStatsCardConnected.tsx | 19 - .../dashboard/sparklineSeries.test.ts | 100 +++++ src/components/dashboard/sparklineSeries.ts | 88 +++++ .../dashboard/systemMetrics.test.ts | 9 +- src/components/dashboard/systemMetrics.ts | 12 +- src/hooks/useMetrics.test.ts | 104 +++++ src/hooks/useMetrics.ts | 130 +++++++ src/i18n/locales/en-US/dashboard.json | 9 +- src/i18n/locales/es-ES/dashboard.json | 9 +- src/i18n/locales/pt-BR/dashboard.json | 9 +- src/pages/Dashboard.tsx | 6 +- src/types/metrics.ts | 27 ++ vite.config.ts | 9 + 23 files changed, 1262 insertions(+), 91 deletions(-) create mode 100644 src/api/metrics.ts create mode 100644 src/components/dashboard/Sparkline.tsx create mode 100644 src/components/dashboard/SparklineRow.tsx create mode 100644 src/components/dashboard/SystemSparklinesCard.test.tsx create mode 100644 src/components/dashboard/SystemSparklinesCard.tsx create mode 100644 src/components/dashboard/SystemSparklinesCardConnected.test.tsx create mode 100644 src/components/dashboard/SystemSparklinesCardConnected.tsx delete mode 100644 src/components/dashboard/SystemStatsCardConnected.test.tsx delete mode 100644 src/components/dashboard/SystemStatsCardConnected.tsx create mode 100644 src/components/dashboard/sparklineSeries.test.ts create mode 100644 src/components/dashboard/sparklineSeries.ts create mode 100644 src/hooks/useMetrics.test.ts create mode 100644 src/hooks/useMetrics.ts create mode 100644 src/types/metrics.ts diff --git a/package-lock.json b/package-lock.json index 2fa2782..2cdcd90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-intl": "^10.1.1", + "recharts": "^3.10.1", "shadcn": "^4.2.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -4107,6 +4108,32 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "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/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -4644,7 +4671,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "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/@tailwindcss/node": { @@ -5184,6 +5216,69 @@ "assertion-error": "^2.0.1" } }, + "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.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "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/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -5271,6 +5366,12 @@ "dev": true, "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/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -6738,6 +6839,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/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -6839,6 +7061,12 @@ "dev": true, "license": "MIT" }, + "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/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -7316,6 +7544,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -7665,6 +7905,12 @@ "node": ">= 0.6" } }, + "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/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -8648,6 +8894,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8705,6 +8961,15 @@ "node": ">= 0.4" } }, + "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/intl-messageformat": { "version": "11.2.1", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.1.tgz", @@ -11530,10 +11795,32 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT", "peer": true }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/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-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -11643,6 +11930,36 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "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": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "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/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -11657,6 +11974,21 @@ "node": ">=8" } }, + "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/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -11732,6 +12064,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", @@ -13364,6 +13702,28 @@ "node": ">= 0.8" } }, + "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": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", diff --git a/package.json b/package.json index ccde702..905c0d2 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-intl": "^10.1.1", + "recharts": "^3.10.1", "shadcn": "^4.2.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", diff --git a/src/api/metrics.ts b/src/api/metrics.ts new file mode 100644 index 0000000..82c278b --- /dev/null +++ b/src/api/metrics.ts @@ -0,0 +1,63 @@ +/** + * Observability metrics endpoints. + * + * Both require `metrics:read`, enforced with `allow_admin_bypass=False` and + * `global_only=True`. Platform admins do not bypass it; only `platform_admin` + * and `platform_viewer` hold it by default, so a 403 is a routine outcome. + */ + +import type { PercentilesResponse, TimeseriesResponse } from "@/types/metrics"; + +import { api } from "./client"; + +// Mirrors the Query() bounds in mcpgateway/routers/observability.py. +const HOURS_MIN = 1; +const HOURS_MAX = 168; +const INTERVAL_MIN = 5; +const INTERVAL_MAX = 1440; + +export interface MetricsParams { + /** Time range in hours (1-168). Server defaults to 24. */ + hours?: number; + /** Aggregation bucket size in minutes (5-1440). Server defaults to 60. */ + intervalMinutes?: number; + signal?: AbortSignal; +} + +function clampInt(value: number | undefined, min: number, max: number): number | undefined { + if (value === undefined || !Number.isFinite(value)) return undefined; + return Math.max(min, Math.min(max, Math.floor(value))); +} + +function buildQuery(params: MetricsParams): string { + const search = new URLSearchParams(); + + const hours = clampInt(params.hours, HOURS_MIN, HOURS_MAX); + if (hours !== undefined) search.set("hours", hours.toString()); + + const interval = clampInt(params.intervalMinutes, INTERVAL_MIN, INTERVAL_MAX); + if (interval !== undefined) search.set("interval_minutes", interval.toString()); + + const query = search.toString(); + return query ? `?${query}` : ""; +} + +export const metricsApi = { + /** Execution counts bucketed over time. Buckets are sparse. */ + getTimeseries: (params: MetricsParams = {}): Promise => + api.get( + `/observability/metrics/timeseries${buildQuery(params)}`, + undefined, + params.signal, + ), + + /** Latency percentiles (p50/p95/p99, ms) bucketed over time. Buckets are sparse. */ + getPercentiles: (params: MetricsParams = {}): Promise => + api.get( + `/observability/metrics/percentiles${buildQuery(params)}`, + undefined, + params.signal, + ), +}; + +export const __test__ = { buildQuery, clampInt }; diff --git a/src/components/dashboard/Sparkline.tsx b/src/components/dashboard/Sparkline.tsx new file mode 100644 index 0000000..96545a8 --- /dev/null +++ b/src/components/dashboard/Sparkline.tsx @@ -0,0 +1,45 @@ +/** + * Bare trend line: no axes, grid, legend, tooltip or dots. `aria-hidden`, + * because the row's value is already exposed as text beside it. + * + * The y-domain is anchored at 0 rather than fitted; a fitted domain rescales on + * every poll, making a flat metric appear to jump when only the scale moved. + * `tickCount={2}` is load-bearing: without it recharts rounds the domain up to + * nice tick values and the peak stops short of the top of the band. + * + * `null` slots (hours with no data) are bridged rather than drawn at the floor. + */ + +import { Line, LineChart, ResponsiveContainer, YAxis } from "recharts"; + +/** Row height from the design frame. */ +export const SPARKLINE_HEIGHT = 37; + +interface SparklineProps { + /** One entry per grid slot, ascending. `null` means no data for that slot. */ + points: (number | null)[]; +} + +export function Sparkline({ points }: SparklineProps) { + const data = points.map((v, i) => ({ i, v })); + + return ( +
+ + + + + + +
+ ); +} diff --git a/src/components/dashboard/SparklineRow.tsx b/src/components/dashboard/SparklineRow.tsx new file mode 100644 index 0000000..40d65ec --- /dev/null +++ b/src/components/dashboard/SparklineRow.tsx @@ -0,0 +1,32 @@ +/** + * One metric row: a fixed-width label column, so all four lines share a left + * edge, beside the trend line. + */ + +import type { ReactNode } from "react"; + +import { Sparkline, SPARKLINE_HEIGHT } from "./Sparkline"; +import { StatBlock } from "./SystemStat"; + +interface SparklineRowProps { + label: ReactNode; + /** Pre-formatted value string (see `systemMetrics.ts` formatters). */ + value: ReactNode; + points: (number | null)[]; + loading?: boolean; +} + +export function SparklineRow({ label, value, points, loading }: SparklineRowProps) { + return ( +
+
+ +
+ {loading ? ( +
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/dashboard/SystemSparklinesCard.test.tsx b/src/components/dashboard/SystemSparklinesCard.test.tsx new file mode 100644 index 0000000..b51bd86 --- /dev/null +++ b/src/components/dashboard/SystemSparklinesCard.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { screen } from "@testing-library/react"; + +import { renderWithProviders } from "@/test/test-utils"; + +import { SystemSparklinesCard } from "./SystemSparklinesCard"; + +const HOUR_MS = 3_600_000; + +/** ISO time for a bucket `hoursAgo` before the current interval boundary. */ +function bucket(hoursAgo: number): string { + const current = Math.floor(Date.now() / HOUR_MS) * HOUR_MS; + return new Date(current - hoursAgo * HOUR_MS).toISOString(); +} + +describe("SystemSparklinesCard", () => { + it("renders a row per metric with the window label", () => { + renderWithProviders(); + + expect(screen.getByText("Executions")).toBeInTheDocument(); + expect(screen.getByText("p50 latency")).toBeInTheDocument(); + expect(screen.getByText("p95 latency")).toBeInTheDocument(); + expect(screen.getByText("p99 latency")).toBeInTheDocument(); + expect(screen.getByText("Last 24 hours")).toBeInTheDocument(); + }); + + it("totals executions across the window and takes the latest latency bucket", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("7")).toBeInTheDocument(); + expect(screen.getByText("0.317ms")).toBeInTheDocument(); + expect(screen.getByText("0.472ms")).toBeInTheDocument(); + expect(screen.getByText("0.523ms")).toBeInTheDocument(); + }); + + it("explains an empty window rather than showing it as a failure", () => { + renderWithProviders( + , + ); + + expect(screen.getByText(/No traced requests/)).toBeInTheDocument(); + expect(screen.queryByText("Couldn't load metrics.")).not.toBeInTheDocument(); + }); + + it("surfaces a load failure", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Couldn't load metrics.")).toBeInTheDocument(); + expect(screen.queryByText(/No traced requests/)).not.toBeInTheDocument(); + }); + + it("shows skeletons while the first load is in flight", () => { + const { container } = renderWithProviders( + , + ); + + expect(container.querySelectorAll('[data-slot="skeleton"]')).toHaveLength(4); + }); +}); diff --git a/src/components/dashboard/SystemSparklinesCard.tsx b/src/components/dashboard/SystemSparklinesCard.tsx new file mode 100644 index 0000000..b53b0b2 --- /dev/null +++ b/src/components/dashboard/SystemSparklinesCard.tsx @@ -0,0 +1,94 @@ +/** + * The 24 hour trend card on the resting home state: executions, then p50/p95/p99. + * + * Alignment happens here rather than per row because the two endpoints return + * sparse buckets independently and can disagree on which are populated. One + * shared grid keeps all four rows on the same x-scale. + */ + +import { useMemo } from "react"; +import { useIntl } from "react-intl"; + +import type { PercentilesResponse, TimeseriesResponse } from "@/types/metrics"; +import { WINDOW_HOURS, WINDOW_INTERVAL_MINUTES } from "@/hooks/useMetrics"; + +import { SparklineRow } from "./SparklineRow"; +import { alignToGrid, buildGrid, headlineScalar, isEmptySeries } from "./sparklineSeries"; +import { formatCount, formatResponseTime } from "./systemMetrics"; + +interface SystemSparklinesCardProps { + timeseries: TimeseriesResponse | null; + percentiles: PercentilesResponse | null; + loading?: boolean; + error?: Error | null; +} + +export function SystemSparklinesCard({ + timeseries, + percentiles, + loading, + error, +}: SystemSparklinesCardProps) { + const intl = useIntl(); + + const rows = useMemo(() => { + const grid = buildGrid(Date.now(), WINDOW_HOURS, WINDOW_INTERVAL_MINUTES); + + const executions = alignToGrid( + timeseries?.buckets ?? [], + timeseries?.values ?? [], + grid, + "zero", + ); + const latency = (key: "p50" | "p95" | "p99") => + alignToGrid(percentiles?.buckets ?? [], percentiles?.[key] ?? [], grid, "gap"); + + return [ + { + key: "executions", + label: intl.formatMessage({ id: "dashboard.home.sparklines.executions" }), + points: executions, + value: formatCount(headlineScalar(executions, "sum")), + }, + ...(["p50", "p95", "p99"] as const).map((key) => { + const points = latency(key); + return { + key, + label: intl.formatMessage({ id: `dashboard.home.sparklines.${key}` }), + points, + value: formatResponseTime(headlineScalar(points, "latest")), + }; + }), + ]; + }, [intl, timeseries, percentiles]); + + const isEmpty = !loading && !error && rows.every((row) => isEmptySeries(row.points)); + + return ( +
+
+

+ {error + ? intl.formatMessage({ id: "dashboard.home.sparklines.error" }) + : isEmpty + ? intl.formatMessage({ id: "dashboard.home.sparklines.empty" }) + : null} +

+ + {intl.formatMessage({ id: "dashboard.home.sparklines.window" })} + +
+
+ {rows.map((row) => ( + + ))} +
+
+ ); +} diff --git a/src/components/dashboard/SystemSparklinesCardConnected.test.tsx b/src/components/dashboard/SystemSparklinesCardConnected.test.tsx new file mode 100644 index 0000000..c9b4a15 --- /dev/null +++ b/src/components/dashboard/SystemSparklinesCardConnected.test.tsx @@ -0,0 +1,63 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { screen } from "@testing-library/react"; + +import { useMetrics } from "@/hooks/useMetrics"; +import { renderWithProviders } from "@/test/test-utils"; + +import { SystemSparklinesCardConnected } from "./SystemSparklinesCardConnected"; + +vi.mock("@/hooks/useMetrics", () => ({ + useMetrics: vi.fn(), + WINDOW_HOURS: 24, + WINDOW_INTERVAL_MINUTES: 60, +})); + +const mockUseMetrics = vi.mocked(useMetrics); + +function metricsState(overrides: Partial> = {}) { + return { + timeseries: null, + percentiles: null, + isLoading: false, + error: null, + forbidden: false, + lastUpdated: null, + refetch: vi.fn(), + ...overrides, + }; +} + +describe("SystemSparklinesCardConnected", () => { + beforeEach(() => mockUseMetrics.mockReset()); + + it("renders nothing when the caller lacks metrics:read", () => { + mockUseMetrics.mockReturnValue(metricsState({ forbidden: true })); + + const { container } = renderWithProviders(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the card otherwise", () => { + mockUseMetrics.mockReturnValue( + metricsState({ + timeseries: { buckets: [], values: [] }, + percentiles: { buckets: [], p50: [], p95: [], p99: [] }, + }), + ); + + renderWithProviders(); + + expect(screen.getByText("Last 24 hours")).toBeInTheDocument(); + }); + + it("only shows the loading state on the first load, not on a refresh", () => { + mockUseMetrics.mockReturnValue( + metricsState({ isLoading: true, timeseries: { buckets: [], values: [] } }), + ); + + const { container } = renderWithProviders(); + + expect(container.querySelectorAll('[data-slot="skeleton"]')).toHaveLength(0); + }); +}); diff --git a/src/components/dashboard/SystemSparklinesCardConnected.tsx b/src/components/dashboard/SystemSparklinesCardConnected.tsx new file mode 100644 index 0000000..3785e9b --- /dev/null +++ b/src/components/dashboard/SystemSparklinesCardConnected.tsx @@ -0,0 +1,25 @@ +/** + * Self-fetching wrapper for the resting home state. + * + * Renders nothing on a 403: most users lack `metrics:read`, and a permission + * error they cannot act on does not belong on the landing page. + */ + +import { useMetrics } from "@/hooks/useMetrics"; + +import { SystemSparklinesCard } from "./SystemSparklinesCard"; + +export function SystemSparklinesCardConnected() { + const { timeseries, percentiles, isLoading, error, forbidden } = useMetrics(); + + if (forbidden) return null; + + return ( + + ); +} diff --git a/src/components/dashboard/SystemStatsCardConnected.test.tsx b/src/components/dashboard/SystemStatsCardConnected.test.tsx deleted file mode 100644 index 76f95e9..0000000 --- a/src/components/dashboard/SystemStatsCardConnected.test.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { screen } from "@testing-library/react"; - -import { useQuery } from "@/hooks/useQuery"; -import { renderWithProviders } from "@/test/test-utils"; - -import { SystemStatsCardConnected } from "./SystemStatsCardConnected"; - -vi.mock("@/hooks/useQuery", () => ({ useQuery: vi.fn() })); - -const mockUseQuery = vi.mocked(useQuery); - -function entity(total: number, avg: number | null, min: number | null, max: number | null) { - return { - totalExecutions: total, - successfulExecutions: total, - failedExecutions: 0, - failureRate: 0, - minResponseTime: min, - maxResponseTime: max, - avgResponseTime: avg, - }; -} - -function queryResult(data: unknown, isLoading = false) { - return { - data, - error: null, - isLoading, - execute: vi.fn(), - refetch: vi.fn(), - setData: vi.fn(), - } as unknown as ReturnType; -} - -describe("SystemStatsCardConnected", () => { - beforeEach(() => mockUseQuery.mockReset()); - - it("fetches /metrics and renders the combined executions total", () => { - mockUseQuery.mockReturnValue( - queryResult({ - tools: entity(30, 0.4, 0.1, 0.8), - resources: entity(0, null, null, null), - servers: entity(7, 0.04, 0.02, 0.06), - prompts: entity(0, null, null, null), - }), - ); - - renderWithProviders(); - - expect(mockUseQuery).toHaveBeenCalledWith("/metrics"); - expect(screen.getByText("37")).toBeInTheDocument(); - expect(screen.getByText("0.800ms")).toBeInTheDocument(); - }); - - it("shows skeletons while metrics load", () => { - mockUseQuery.mockReturnValue(queryResult(undefined, true)); - const { container } = renderWithProviders(); - expect(container.querySelectorAll('[data-slot="skeleton"]').length).toBeGreaterThan(0); - }); -}); diff --git a/src/components/dashboard/SystemStatsCardConnected.tsx b/src/components/dashboard/SystemStatsCardConnected.tsx deleted file mode 100644 index 83deae6..0000000 --- a/src/components/dashboard/SystemStatsCardConnected.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/** - * SystemStatsCardConnected — self-fetching wrapper around `SystemStatsCard`. - * - * Used where the card stands alone (the default/resting home state). The System - * view composes `SystemStatsCard` directly instead, because it already fetches - * `/metrics` once for the Virtual Servers card and passes it down. - */ - -import { useQuery } from "@/hooks/useQuery"; - -import { SystemStatsCard } from "./SystemStatsCard"; -import { aggregateSystemStats, type MetricsResponse } from "./systemMetrics"; - -const METRICS_PATH = "/metrics"; - -export function SystemStatsCardConnected() { - const { data, isLoading } = useQuery(METRICS_PATH); - return ; -} diff --git a/src/components/dashboard/sparklineSeries.test.ts b/src/components/dashboard/sparklineSeries.test.ts new file mode 100644 index 0000000..a7e62fe --- /dev/null +++ b/src/components/dashboard/sparklineSeries.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { alignToGrid, buildGrid, headlineScalar, isEmptySeries } from "./sparklineSeries"; + +const HOUR_MS = 3_600_000; + +describe("buildGrid", () => { + it("returns one slot per interval, ending with the in-progress bucket", () => { + const now = Date.parse("2026-09-01T12:34:56Z"); + const grid = buildGrid(now, 24, 60); + + expect(grid).toHaveLength(24); + expect(grid[23]).toBe(Date.parse("2026-09-01T12:00:00Z")); + expect(grid[0]).toBe(Date.parse("2026-08-31T13:00:00Z")); + }); + + it("floors slots to interval boundaries, matching the server's bucketing", () => { + const grid = buildGrid(Date.parse("2026-09-01T12:59:59Z"), 3, 60); + expect(grid.every((slot) => slot % HOUR_MS === 0)).toBe(true); + }); + + it("returns nothing for a non-positive window or interval", () => { + expect(buildGrid(Date.now(), 0, 60)).toEqual([]); + expect(buildGrid(Date.now(), 24, 0)).toEqual([]); + }); +}); + +describe("alignToGrid", () => { + const now = Date.parse("2026-09-01T12:00:00Z"); + const grid = buildGrid(now, 4, 60); // 09:00, 10:00, 11:00, 12:00 + + it("places a sparse bucket at its real slot rather than at the start", () => { + const result = alignToGrid(["2026-09-01T11:00:00Z"], [7], grid, "zero"); + expect(result).toEqual([0, 0, 7, 0]); + }); + + it("fills count gaps with zero and latency gaps with null", () => { + expect(alignToGrid([], [], grid, "zero")).toEqual([0, 0, 0, 0]); + expect(alignToGrid([], [], grid, "gap")).toEqual([null, null, null, null]); + }); + + it("matches buckets that are not floored to the interval", () => { + const result = alignToGrid(["2026-09-01T10:30:00Z"], [3], grid, "zero"); + expect(result).toEqual([0, 3, 0, 0]); + }); + + it("drops buckets outside the window instead of shifting the series", () => { + const result = alignToGrid( + ["2026-08-30T09:00:00Z", "2026-09-01T12:00:00Z"], + [99, 4], + grid, + "zero", + ); + expect(result).toEqual([0, 0, 0, 4]); + }); + + it("ignores unparseable timestamps and non-finite values", () => { + const result = alignToGrid( + ["not-a-date", "2026-09-01T10:00:00Z", "2026-09-01T11:00:00Z"], + [1, Number.NaN, 5], + grid, + "zero", + ); + expect(result).toEqual([0, 0, 5, 0]); + }); + + it("returns nothing for an empty grid", () => { + expect(alignToGrid(["2026-09-01T11:00:00Z"], [7], [], "zero")).toEqual([]); + }); +}); + +describe("headlineScalar", () => { + it("sums the window for counts", () => { + expect(headlineScalar([1, 0, 2, 0], "sum")).toBe(3); + }); + + it("takes the most recent populated slot for percentiles", () => { + expect(headlineScalar([null, 0.5, 0.317, null], "latest")).toBe(0.317); + }); + + it("is null when the window holds no data", () => { + expect(headlineScalar([null, null], "latest")).toBeNull(); + expect(headlineScalar([], "sum")).toBeNull(); + }); + + it("treats a zero-filled count window as zero, not unavailable", () => { + expect(headlineScalar([0, 0, 0], "sum")).toBe(0); + }); +}); + +describe("isEmptySeries", () => { + it("is true when every slot is empty or zero", () => { + expect(isEmptySeries([0, 0, 0])).toBe(true); + expect(isEmptySeries([null, null])).toBe(true); + }); + + it("is false as soon as one slot carries a value", () => { + expect(isEmptySeries([0, 0, 1])).toBe(false); + }); +}); diff --git a/src/components/dashboard/sparklineSeries.ts b/src/components/dashboard/sparklineSeries.ts new file mode 100644 index 0000000..66a74b3 --- /dev/null +++ b/src/components/dashboard/sparklineSeries.ts @@ -0,0 +1,88 @@ +/** + * Aligns the sparse metrics buckets onto an evenly spaced grid, so a busy hour + * lands at its real position in the window rather than beside its neighbours. + * + * Counts fill gaps with 0; latencies fill with `null` and the chart bridges + * them, since an hour with no traces had no latency rather than 0ms. + */ + +/** How a slot with no server-side bucket is represented. */ +export type FillMode = "zero" | "gap"; + +/** How the row's single headline number is derived from the window. */ +export type ScalarMode = "sum" | "latest"; + +const MS_PER_MINUTE = 60_000; + +/** + * Grid slot start times (epoch ms, ascending), ending with the in-progress + * bucket. Floored to interval boundaries to match the server's + * `floor(epoch / interval) * interval`. + */ +export function buildGrid(nowMs: number, hours: number, intervalMinutes: number): number[] { + const intervalMs = intervalMinutes * MS_PER_MINUTE; + if (intervalMs <= 0 || hours <= 0) return []; + + const slotCount = Math.ceil((hours * 60) / intervalMinutes); + const current = Math.floor(nowMs / intervalMs) * intervalMs; + + return Array.from({ length: slotCount }, (_, i) => current - (slotCount - 1 - i) * intervalMs); +} + +/** + * Map a sparse `buckets`/`values` pair onto `grid`, one entry per slot. + * + * Both sides are floored to the interval before matching, so the PostgreSQL + * and Python bucketing paths align identically. Buckets outside the grid and + * unparseable timestamps are dropped rather than shifting the series. + */ +export function alignToGrid( + buckets: string[], + values: number[], + grid: number[], + fill: FillMode, +): (number | null)[] { + const empty = fill === "zero" ? 0 : null; + if (grid.length === 0) return []; + + const intervalMs = grid.length > 1 ? grid[1] - grid[0] : 0; + const bySlot = new Map(); + + for (let i = 0; i < buckets.length; i += 1) { + const value = values[i]; + if (value === undefined || !Number.isFinite(value)) continue; + + const parsed = new Date(buckets[i]).getTime(); + if (Number.isNaN(parsed)) continue; + + const slot = intervalMs > 0 ? Math.floor(parsed / intervalMs) * intervalMs : parsed; + bySlot.set(slot, value); + } + + return grid.map((slot) => bySlot.get(slot) ?? empty); +} + +/** + * The single number shown beside the row. `sum` totals the window; `latest` + * takes the most recent populated slot, used for percentiles because averaging + * per-bucket percentiles does not yield a percentile. + * + * Null when the window holds no data, which formatters render as `—`. + */ +export function headlineScalar(points: (number | null)[], mode: ScalarMode): number | null { + const present = points.filter((p): p is number => p !== null); + if (present.length === 0) return null; + + if (mode === "sum") return present.reduce((total, p) => total + p, 0); + + for (let i = points.length - 1; i >= 0; i -= 1) { + const point = points[i]; + if (point !== null) return point; + } + return null; +} + +/** True when every slot is empty, i.e. nothing to draw. */ +export function isEmptySeries(points: (number | null)[]): boolean { + return points.every((p) => p === null || p === 0); +} diff --git a/src/components/dashboard/systemMetrics.test.ts b/src/components/dashboard/systemMetrics.test.ts index 02a7a03..41d6411 100644 --- a/src/components/dashboard/systemMetrics.test.ts +++ b/src/components/dashboard/systemMetrics.test.ts @@ -110,11 +110,18 @@ describe("countActiveTotal", () => { }); describe("formatters", () => { - it("formats response times with three decimals and a ms suffix", () => { + it("formats sub-millisecond response times with three decimals", () => { expect(formatResponseTime(0.472)).toBe("0.472ms"); expect(formatResponseTime(0)).toBe("0.000ms"); }); + it("drops precision as the magnitude grows, rather than reporting 123.930ms", () => { + expect(formatResponseTime(8.93)).toBe("8.93ms"); + expect(formatResponseTime(99.76)).toBe("99.8ms"); + expect(formatResponseTime(123.93)).toBe("124ms"); + expect(formatResponseTime(1580.4)).toBe("1580ms"); + }); + it("returns the placeholder for missing response times", () => { expect(formatResponseTime(null)).toBe(UNAVAILABLE); expect(formatResponseTime(undefined)).toBe(UNAVAILABLE); diff --git a/src/components/dashboard/systemMetrics.ts b/src/components/dashboard/systemMetrics.ts index 53d6a67..c920e59 100644 --- a/src/components/dashboard/systemMetrics.ts +++ b/src/components/dashboard/systemMetrics.ts @@ -143,10 +143,18 @@ export function countActiveTotal(items: Activatable[] | undefined): ActiveTotal /** Placeholder shown whenever a value is unavailable. */ export const UNAVAILABLE = "—"; -/** Format a response time the way the legacy admin UI does: 3 decimals + "ms". */ +/** + * Format a response time in milliseconds, holding roughly three significant + * figures. Sub-millisecond values keep the legacy admin UI's 3 decimals; the + * hundreds of milliseconds that percentiles actually reach would otherwise + * render as "123.930ms". + */ export function formatResponseTime(value: number | null | undefined): string { if (value == null || Number.isNaN(value)) return UNAVAILABLE; - return `${value.toFixed(3)}ms`; + + const magnitude = Math.abs(value); + const decimals = magnitude < 1 ? 3 : magnitude < 10 ? 2 : magnitude < 100 ? 1 : 0; + return `${value.toFixed(decimals)}ms`; } /** Derive a success-rate percentage from a `failureRate` in [0, 1]. */ diff --git a/src/hooks/useMetrics.test.ts b/src/hooks/useMetrics.test.ts new file mode 100644 index 0000000..d4200a7 --- /dev/null +++ b/src/hooks/useMetrics.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; + +import { metricsApi } from "@/api/metrics"; + +import { REFRESH_INTERVAL_MS, useMetrics, WINDOW_HOURS } from "./useMetrics"; + +vi.mock("@/api/metrics", () => ({ + metricsApi: { getTimeseries: vi.fn(), getPercentiles: vi.fn() }, +})); + +const mockTimeseries = vi.mocked(metricsApi.getTimeseries); +const mockPercentiles = vi.mocked(metricsApi.getPercentiles); + +function resolveBoth() { + mockTimeseries.mockResolvedValue({ buckets: [], values: [] }); + mockPercentiles.mockResolvedValue({ buckets: [], p50: [], p95: [], p99: [] }); +} + +function setVisibility(state: "visible" | "hidden") { + Object.defineProperty(document, "visibilityState", { value: state, configurable: true }); + document.dispatchEvent(new Event("visibilitychange")); +} + +describe("useMetrics", () => { + beforeEach(() => { + vi.clearAllMocks(); + setVisibility("visible"); + resolveBoth(); + }); + + afterEach(() => vi.useRealTimers()); + + it("requests both endpoints over the fixed window", async () => { + const { result } = renderHook(() => useMetrics()); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mockTimeseries).toHaveBeenCalledWith(expect.objectContaining({ hours: WINDOW_HOURS })); + expect(mockPercentiles).toHaveBeenCalledWith(expect.objectContaining({ hours: WINDOW_HOURS })); + }); + + it("reports a 403 as forbidden rather than an error", async () => { + const denied = Object.assign(new Error("Forbidden"), { status: 403 }); + mockTimeseries.mockRejectedValue(denied); + + const { result } = renderHook(() => useMetrics()); + + await waitFor(() => expect(result.current.forbidden).toBe(true)); + expect(result.current.error).toBeNull(); + }); + + it("reports other failures as errors", async () => { + mockTimeseries.mockRejectedValue(Object.assign(new Error("boom"), { status: 500 })); + + const { result } = renderHook(() => useMetrics()); + + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.forbidden).toBe(false); + }); + + it("keeps polling after a failure", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + mockTimeseries.mockRejectedValueOnce(new Error("boom")); + + const { result } = renderHook(() => useMetrics()); + await waitFor(() => expect(result.current.error).not.toBeNull()); + + resolveBoth(); + await act(async () => { + await vi.advanceTimersByTimeAsync(REFRESH_INTERVAL_MS); + }); + + await waitFor(() => expect(result.current.error).toBeNull()); + expect(mockTimeseries.mock.calls.length).toBeGreaterThan(1); + }); + + it("does not poll while the tab is hidden", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const { result } = renderHook(() => useMetrics()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const callsAfterMount = mockTimeseries.mock.calls.length; + setVisibility("hidden"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(REFRESH_INTERVAL_MS * 2); + }); + + expect(mockTimeseries.mock.calls.length).toBe(callsAfterMount); + }); + + it("aborts the in-flight request on unmount", async () => { + const { result, unmount } = renderHook(() => useMetrics()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const { signal } = mockTimeseries.mock.calls[0][0] as { signal: AbortSignal }; + expect(signal.aborted).toBe(false); + + unmount(); + + expect(signal.aborted).toBe(true); + }); +}); diff --git a/src/hooks/useMetrics.ts b/src/hooks/useMetrics.ts new file mode 100644 index 0000000..f77621e --- /dev/null +++ b/src/hooks/useMetrics.ts @@ -0,0 +1,130 @@ +/** + * Polls both metrics endpoints over a fixed 24 hour window. + * + * Refreshes every 30s, pauses while the tab is hidden, and aborts in-flight + * requests on unmount and on refetch. Errors surface without stopping the + * interval — the next tick may succeed. A 403 is reported as `forbidden` + * rather than `error`; see `api/metrics.ts`. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { metricsApi } from "@/api/metrics"; +import type { PercentilesResponse, TimeseriesResponse } from "@/types/metrics"; + +/** 24h at 60m granularity = 24 buckets. Fixed: the card has no range control. */ +export const WINDOW_HOURS = 24; +export const WINDOW_INTERVAL_MINUTES = 60; + +export const REFRESH_INTERVAL_MS = 30_000; + +export interface MetricsState { + timeseries: TimeseriesResponse | null; + percentiles: PercentilesResponse | null; + isLoading: boolean; + error: Error | null; + /** True when the gateway rejected the read with 403 (no `metrics:read`). */ + forbidden: boolean; + lastUpdated: Date | null; + refetch: () => void; +} + +function isForbidden(err: unknown): boolean { + return (err as { status?: number } | null)?.status === 403; +} + +export function useMetrics(): MetricsState { + const [timeseries, setTimeseries] = useState(null); + const [percentiles, setPercentiles] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [forbidden, setForbidden] = useState(false); + const [lastUpdated, setLastUpdated] = useState(null); + + const abortRef = useRef(null); + const intervalRef = useRef | null>(null); + // Read by the visibility handler without being a dependency of it, which + // would re-register the listener on every fetch. + const lastUpdatedRef = useRef(null); + + const fetchOnce = useCallback(async () => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setIsLoading(true); + setError(null); + + const params = { + hours: WINDOW_HOURS, + intervalMinutes: WINDOW_INTERVAL_MINUTES, + signal: controller.signal, + }; + + try { + const [ts, pct] = await Promise.all([ + metricsApi.getTimeseries(params), + metricsApi.getPercentiles(params), + ]); + + if (controller.signal.aborted) return; + + setTimeseries(ts); + setPercentiles(pct); + setForbidden(false); + const now = new Date(); + lastUpdatedRef.current = now; + setLastUpdated(now); + } catch (err) { + if (controller.signal.aborted) return; + if (isForbidden(err)) { + setForbidden(true); + setError(null); + } else { + setError(err instanceof Error ? err : new Error("Failed to load metrics")); + } + } finally { + if (!controller.signal.aborted) setIsLoading(false); + } + }, []); + + useEffect(() => { + void fetchOnce(); + + intervalRef.current = globalThis.setInterval(() => { + if (document.visibilityState === "visible") void fetchOnce(); + }, REFRESH_INTERVAL_MS); + + return () => { + if (intervalRef.current !== null) { + globalThis.clearInterval(intervalRef.current); + intervalRef.current = null; + } + abortRef.current?.abort(); + }; + }, [fetchOnce]); + + // On returning to a hidden tab, refetch straight away if the last success is + // already older than the interval, rather than showing stale numbers. + useEffect(() => { + const handler = () => { + if (document.visibilityState !== "visible") return; + const last = lastUpdatedRef.current; + const ageMs = last ? Date.now() - last.getTime() : Infinity; + if (ageMs >= REFRESH_INTERVAL_MS) void fetchOnce(); + }; + + document.addEventListener("visibilitychange", handler); + return () => document.removeEventListener("visibilitychange", handler); + }, [fetchOnce]); + + return { + timeseries, + percentiles, + isLoading, + error, + forbidden, + lastUpdated, + refetch: () => void fetchOnce(), + }; +} diff --git a/src/i18n/locales/en-US/dashboard.json b/src/i18n/locales/en-US/dashboard.json index 92c24c4..34afe9f 100644 --- a/src/i18n/locales/en-US/dashboard.json +++ b/src/i18n/locales/en-US/dashboard.json @@ -76,5 +76,12 @@ "dashboard.home.mcp.column.lastSeen": "Last seen", "dashboard.home.status.online": "Online", "dashboard.home.status.offline": "Offline", - "dashboard.home.status.activity": "{errors} errors · {warnings} warnings" + "dashboard.home.status.activity": "{errors} errors · {warnings} warnings", + "dashboard.home.sparklines.executions": "Executions", + "dashboard.home.sparklines.p50": "p50 latency", + "dashboard.home.sparklines.p95": "p95 latency", + "dashboard.home.sparklines.p99": "p99 latency", + "dashboard.home.sparklines.window": "Last 24 hours", + "dashboard.home.sparklines.error": "Couldn't load metrics.", + "dashboard.home.sparklines.empty": "No traced requests. If you haven't already, set OBSERVABILITY_ENABLED=true and restart the gateway." } diff --git a/src/i18n/locales/es-ES/dashboard.json b/src/i18n/locales/es-ES/dashboard.json index 3a6a35a..0f0d483 100644 --- a/src/i18n/locales/es-ES/dashboard.json +++ b/src/i18n/locales/es-ES/dashboard.json @@ -76,5 +76,12 @@ "dashboard.home.mcp.column.lastSeen": "Visto por última vez", "dashboard.home.status.online": "En línea", "dashboard.home.status.offline": "Fuera de línea", - "dashboard.home.status.activity": "{errors} errores · {warnings} advertencias" + "dashboard.home.status.activity": "{errors} errores · {warnings} advertencias", + "dashboard.home.sparklines.executions": "Ejecuciones", + "dashboard.home.sparklines.p50": "Latencia p50", + "dashboard.home.sparklines.p95": "Latencia p95", + "dashboard.home.sparklines.p99": "Latencia p99", + "dashboard.home.sparklines.window": "Últimas 24 horas", + "dashboard.home.sparklines.error": "No se pudieron cargar las métricas.", + "dashboard.home.sparklines.empty": "Sin solicitudes rastreadas. Si aún no lo ha hecho, establezca OBSERVABILITY_ENABLED=true y reinicie la puerta de enlace." } diff --git a/src/i18n/locales/pt-BR/dashboard.json b/src/i18n/locales/pt-BR/dashboard.json index 0977667..0ca11e3 100644 --- a/src/i18n/locales/pt-BR/dashboard.json +++ b/src/i18n/locales/pt-BR/dashboard.json @@ -76,5 +76,12 @@ "dashboard.home.mcp.column.lastSeen": "Visto pela última vez", "dashboard.home.status.online": "Online", "dashboard.home.status.offline": "Offline", - "dashboard.home.status.activity": "{errors} erros · {warnings} avisos" + "dashboard.home.status.activity": "{errors} erros · {warnings} avisos", + "dashboard.home.sparklines.executions": "Execuções", + "dashboard.home.sparklines.p50": "Latência p50", + "dashboard.home.sparklines.p95": "Latência p95", + "dashboard.home.sparklines.p99": "Latência p99", + "dashboard.home.sparklines.window": "Últimas 24 horas", + "dashboard.home.sparklines.error": "Não foi possível carregar as métricas.", + "dashboard.home.sparklines.empty": "Nenhuma requisição rastreada. Se ainda não tiver feito isso, defina OBSERVABILITY_ENABLED=true e reinicie o gateway." } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index c632fc8..6eafd21 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -16,7 +16,7 @@ import type { HeadlineCondition } from "@/components/dashboard/resolveHeadline"; import { useMiniCardStatuses } from "@/hooks/useMiniCardStatuses"; import type { SystemHealthResult } from "@/hooks/useSystemHealth"; import { StatusHeadline } from "@/components/dashboard/StatusHeadline"; -import { SystemStatsCardConnected } from "@/components/dashboard/SystemStatsCardConnected"; +import { SystemSparklinesCardConnected } from "@/components/dashboard/SystemSparklinesCardConnected"; import { SystemView } from "@/components/dashboard/SystemView"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/auth/useAuth"; @@ -145,7 +145,7 @@ export function Dashboard() { /** * Default (resting) state: status summary with the activity-feed entry point, - * the all-time system stats card, and the inline source cards. No right column. + * the 24 hour sparklines card, and the inline source cards. No right column. */ function DefaultState({ statuses, @@ -157,7 +157,7 @@ function DefaultState({ return (
} /> - +
{DEFAULT_SOURCE_CARDS.map((id) => ( } /> diff --git a/src/types/metrics.ts b/src/types/metrics.ts new file mode 100644 index 0000000..067752f --- /dev/null +++ b/src/types/metrics.ts @@ -0,0 +1,27 @@ +/** + * Observability metrics types. `mcpgateway/routers/observability.py` is the + * source of truth. + * + * `buckets` is sparse — only intervals containing traces are returned, so a 24h + * window with three busy hours yields three entries. Align onto a full grid + * before plotting; see `components/dashboard/sparklineSeries.ts`. + * + * Both endpoints return empty arrays, not an error, when observability is off. + */ + +/** Execution counts bucketed over time. */ +export interface TimeseriesResponse { + /** ISO 8601 bucket start times, ascending. Sparse. */ + buckets: string[]; + /** Execution count per bucket, index-aligned with `buckets`. */ + values: number[]; +} + +/** Latency percentiles (milliseconds) bucketed over time. */ +export interface PercentilesResponse { + /** ISO 8601 bucket start times, ascending. Sparse. */ + buckets: string[]; + p50: number[]; + p95: number[]; + p99: number[]; +} diff --git a/vite.config.ts b/vite.config.ts index f381f3a..81e77b2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -44,6 +44,15 @@ export default defineConfig({ // helpers that live there. if (/node_modules\/(react|react-dom|scheduler|react-is)\//.test(id)) return "vendor-react"; + // recharts and its d3/redux tree, kept out of `vendor` so charts do + // not double the chunk every page loads. After `vendor-react` so + // react-is stays in that leaf. + if ( + /node_modules\/(recharts|victory-vendor|d3-[\w-]+|internmap|robust-predicates|delaunator|@reduxjs\/toolkit|react-redux|redux|redux-thunk|immer|reselect|es-toolkit|decimal\.js-light|eventemitter3|use-sync-external-store)\//.test( + id, + ) + ) + return "vendor-charts"; if (id.includes("@radix-ui") || id.includes("radix-ui")) return "vendor-radix"; if (id.includes("lucide-react")) return "vendor-lucide"; return "vendor"; From 88767f32a0c36053bcdf440ddb582d8410e0ffb4 Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Tue, 1 Sep 2026 13:54:45 -0700 Subject: [PATCH 02/12] fix: draw sparklines across the full window Latency rows stopped at their first populated bucket, so they rendered as short stubs against a full-width executions line. connectNulls bridges between known points but does not extend to the edges. Idle slots are now flattened to 0 for drawing only. The headline number still reads the nullable series, so an idle window reports the last real measurement rather than 0ms. Signed-off-by: Anna Effort --- src/components/dashboard/Sparkline.tsx | 7 ++---- src/components/dashboard/SparklineRow.tsx | 2 +- .../dashboard/SystemSparklinesCard.tsx | 12 ++++++++-- .../dashboard/sparklineSeries.test.ts | 23 ++++++++++++++++++- src/components/dashboard/sparklineSeries.ts | 13 +++++++++-- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/components/dashboard/Sparkline.tsx b/src/components/dashboard/Sparkline.tsx index 96545a8..d0d63e2 100644 --- a/src/components/dashboard/Sparkline.tsx +++ b/src/components/dashboard/Sparkline.tsx @@ -6,8 +6,6 @@ * every poll, making a flat metric appear to jump when only the scale moved. * `tickCount={2}` is load-bearing: without it recharts rounds the domain up to * nice tick values and the peak stops short of the top of the band. - * - * `null` slots (hours with no data) are bridged rather than drawn at the floor. */ import { Line, LineChart, ResponsiveContainer, YAxis } from "recharts"; @@ -16,8 +14,8 @@ import { Line, LineChart, ResponsiveContainer, YAxis } from "recharts"; export const SPARKLINE_HEIGHT = 37; interface SparklineProps { - /** One entry per grid slot, ascending. `null` means no data for that slot. */ - points: (number | null)[]; + /** One entry per grid slot, ascending. Gapless, so the line spans the window. */ + points: number[]; } export function Sparkline({ points }: SparklineProps) { @@ -36,7 +34,6 @@ export function Sparkline({ points }: SparklineProps) { strokeOpacity={0.9} dot={false} activeDot={false} - connectNulls /> diff --git a/src/components/dashboard/SparklineRow.tsx b/src/components/dashboard/SparklineRow.tsx index 40d65ec..8766501 100644 --- a/src/components/dashboard/SparklineRow.tsx +++ b/src/components/dashboard/SparklineRow.tsx @@ -12,7 +12,7 @@ interface SparklineRowProps { label: ReactNode; /** Pre-formatted value string (see `systemMetrics.ts` formatters). */ value: ReactNode; - points: (number | null)[]; + points: number[]; loading?: boolean; } diff --git a/src/components/dashboard/SystemSparklinesCard.tsx b/src/components/dashboard/SystemSparklinesCard.tsx index b53b0b2..0720c6b 100644 --- a/src/components/dashboard/SystemSparklinesCard.tsx +++ b/src/components/dashboard/SystemSparklinesCard.tsx @@ -13,7 +13,13 @@ import type { PercentilesResponse, TimeseriesResponse } from "@/types/metrics"; import { WINDOW_HOURS, WINDOW_INTERVAL_MINUTES } from "@/hooks/useMetrics"; import { SparklineRow } from "./SparklineRow"; -import { alignToGrid, buildGrid, headlineScalar, isEmptySeries } from "./sparklineSeries"; +import { + alignToGrid, + buildGrid, + headlineScalar, + isEmptySeries, + toLinePoints, +} from "./sparklineSeries"; import { formatCount, formatResponseTime } from "./systemMetrics"; interface SystemSparklinesCardProps { @@ -48,6 +54,7 @@ export function SystemSparklinesCard({ key: "executions", label: intl.formatMessage({ id: "dashboard.home.sparklines.executions" }), points: executions, + line: toLinePoints(executions), value: formatCount(headlineScalar(executions, "sum")), }, ...(["p50", "p95", "p99"] as const).map((key) => { @@ -56,6 +63,7 @@ export function SystemSparklinesCard({ key, label: intl.formatMessage({ id: `dashboard.home.sparklines.${key}` }), points, + line: toLinePoints(points), value: formatResponseTime(headlineScalar(points, "latest")), }; }), @@ -84,7 +92,7 @@ export function SystemSparklinesCard({ key={row.key} label={row.label} value={row.value} - points={row.points} + points={row.line} loading={loading} /> ))} diff --git a/src/components/dashboard/sparklineSeries.test.ts b/src/components/dashboard/sparklineSeries.test.ts index a7e62fe..612fb05 100644 --- a/src/components/dashboard/sparklineSeries.test.ts +++ b/src/components/dashboard/sparklineSeries.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { alignToGrid, buildGrid, headlineScalar, isEmptySeries } from "./sparklineSeries"; +import { + alignToGrid, + buildGrid, + headlineScalar, + isEmptySeries, + toLinePoints, +} from "./sparklineSeries"; const HOUR_MS = 3_600_000; @@ -88,6 +94,21 @@ describe("headlineScalar", () => { }); }); +describe("toLinePoints", () => { + it("flattens idle slots to zero so the line spans the whole window", () => { + expect(toLinePoints([null, 0.5, null, 0.3])).toEqual([0, 0.5, 0, 0.3]); + }); + + it("leaves a gapless series untouched", () => { + expect(toLinePoints([0, 2, 5])).toEqual([0, 2, 5]); + }); + + it("returns the same length as its input, which is what keeps rows aligned", () => { + const points = Array.from({ length: 24 }, (_, i) => (i === 23 ? 4 : null)); + expect(toLinePoints(points)).toHaveLength(24); + }); +}); + describe("isEmptySeries", () => { it("is true when every slot is empty or zero", () => { expect(isEmptySeries([0, 0, 0])).toBe(true); diff --git a/src/components/dashboard/sparklineSeries.ts b/src/components/dashboard/sparklineSeries.ts index 66a74b3..99b8929 100644 --- a/src/components/dashboard/sparklineSeries.ts +++ b/src/components/dashboard/sparklineSeries.ts @@ -2,8 +2,8 @@ * Aligns the sparse metrics buckets onto an evenly spaced grid, so a busy hour * lands at its real position in the window rather than beside its neighbours. * - * Counts fill gaps with 0; latencies fill with `null` and the chart bridges - * them, since an hour with no traces had no latency rather than 0ms. + * Counts fill gaps with 0. Latencies fill with `null`, which keeps idle hours + * out of the headline number; `toLinePoints` flattens them to 0 for drawing. */ /** How a slot with no server-side bucket is represented. */ @@ -82,6 +82,15 @@ export function headlineScalar(points: (number | null)[], mode: ScalarMode): num return null; } +/** + * Drawing-only view of a series: idle slots become 0 so the line spans the whole + * window. Never feed this to `headlineScalar`, which would read the zeros as + * measurements. + */ +export function toLinePoints(points: (number | null)[]): number[] { + return points.map((p) => p ?? 0); +} + /** True when every slot is empty, i.e. nothing to draw. */ export function isEmptySeries(points: (number | null)[]): boolean { return points.every((p) => p === null || p === 0); From 95e7f3a02f60f3c579fc947e503115e24c84fb80 Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Tue, 1 Sep 2026 14:20:27 -0700 Subject: [PATCH 03/12] feat: add sparkline hover readout Each slot now reports its hour, its value, and the request count behind it. The count is the point: a p95 taken over three samples is not a meaningful percentile, and nothing else on the card showed that. Idle slots report "No requests" rather than the zero they are drawn at. The row value stays text beside the chart, so the tooltip adds a pointer-only path to detail rather than becoming the only route to it. Signed-off-by: Anna Effort --- src/components/dashboard/Sparkline.tsx | 34 ++++++---- src/components/dashboard/SparklineRow.tsx | 16 ++++- .../dashboard/SparklineTooltip.test.tsx | 68 +++++++++++++++++++ src/components/dashboard/SparklineTooltip.tsx | 54 +++++++++++++++ .../dashboard/SystemSparklinesCard.tsx | 18 +++-- .../dashboard/sparklineSeries.test.ts | 27 +++++--- src/components/dashboard/sparklineSeries.ts | 29 ++++++-- src/i18n/locales/en-US/dashboard.json | 4 +- src/i18n/locales/es-ES/dashboard.json | 4 +- src/i18n/locales/pt-BR/dashboard.json | 4 +- 10 files changed, 222 insertions(+), 36 deletions(-) create mode 100644 src/components/dashboard/SparklineTooltip.test.tsx create mode 100644 src/components/dashboard/SparklineTooltip.tsx diff --git a/src/components/dashboard/Sparkline.tsx b/src/components/dashboard/Sparkline.tsx index d0d63e2..4abce1b 100644 --- a/src/components/dashboard/Sparkline.tsx +++ b/src/components/dashboard/Sparkline.tsx @@ -1,39 +1,49 @@ /** - * Bare trend line: no axes, grid, legend, tooltip or dots. `aria-hidden`, - * because the row's value is already exposed as text beside it. + * Bare trend line: no axes, grid, legend or dots, plus a hover readout. * * The y-domain is anchored at 0 rather than fitted; a fitted domain rescales on * every poll, making a flat metric appear to jump when only the scale moved. * `tickCount={2}` is load-bearing: without it recharts rounds the domain up to * nice tick values and the peak stops short of the top of the band. + * + * The row's value is text beside the chart, so the line itself carries no + * information a screen reader needs. The tooltip is a pointer-only + * enhancement on top of that, not the only route to the data. */ -import { Line, LineChart, ResponsiveContainer, YAxis } from "recharts"; +import { Line, LineChart, ResponsiveContainer, Tooltip, YAxis } from "recharts"; + +import type { SparklinePoint } from "./sparklineSeries"; +import { SparklineTooltip } from "./SparklineTooltip"; /** Row height from the design frame. */ export const SPARKLINE_HEIGHT = 37; interface SparklineProps { - /** One entry per grid slot, ascending. Gapless, so the line spans the window. */ - points: number[]; + points: SparklinePoint[]; + formatValue: (value: number) => string; + showCount: boolean; } -export function Sparkline({ points }: SparklineProps) { - const data = points.map((v, i) => ({ i, v })); - +export function Sparkline({ points, formatValue, showCount }: SparklineProps) { return ( -
+
- + + } + cursor={{ stroke: "var(--color-muted-foreground)", strokeWidth: 1 }} + isAnimationActive={false} + /> diff --git a/src/components/dashboard/SparklineRow.tsx b/src/components/dashboard/SparklineRow.tsx index 8766501..993c6ba 100644 --- a/src/components/dashboard/SparklineRow.tsx +++ b/src/components/dashboard/SparklineRow.tsx @@ -6,17 +6,27 @@ import type { ReactNode } from "react"; import { Sparkline, SPARKLINE_HEIGHT } from "./Sparkline"; +import type { SparklinePoint } from "./sparklineSeries"; import { StatBlock } from "./SystemStat"; interface SparklineRowProps { label: ReactNode; /** Pre-formatted value string (see `systemMetrics.ts` formatters). */ value: ReactNode; - points: number[]; + points: SparklinePoint[]; + formatValue: (value: number) => string; + showCount: boolean; loading?: boolean; } -export function SparklineRow({ label, value, points, loading }: SparklineRowProps) { +export function SparklineRow({ + label, + value, + points, + formatValue, + showCount, + loading, +}: SparklineRowProps) { return (
@@ -25,7 +35,7 @@ export function SparklineRow({ label, value, points, loading }: SparklineRowProp {loading ? (
) : ( - + )}
); diff --git a/src/components/dashboard/SparklineTooltip.test.tsx b/src/components/dashboard/SparklineTooltip.test.tsx new file mode 100644 index 0000000..2ebff3a --- /dev/null +++ b/src/components/dashboard/SparklineTooltip.test.tsx @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { screen } from "@testing-library/react"; + +import { renderWithProviders } from "@/test/test-utils"; + +import { SparklineTooltip } from "./SparklineTooltip"; +import { formatCount, formatResponseTime } from "./systemMetrics"; +import type { SparklinePoint } from "./sparklineSeries"; + +function point(overrides: Partial = {}): SparklinePoint { + return { + t: Date.parse("2026-09-01T14:00:00Z"), + line: 0.472, + value: 0.472, + count: 3, + ...overrides, + }; +} + +function render(p: SparklinePoint, formatValue = formatResponseTime, showCount = true) { + return renderWithProviders( + , + ); +} + +describe("SparklineTooltip", () => { + it("renders nothing unless recharts marks it active", () => { + const { container } = renderWithProviders( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing without a payload", () => { + const { container } = renderWithProviders( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("qualifies a latency reading with the sample it came from", () => { + render(point()); + expect(screen.getByText("0.472ms")).toBeInTheDocument(); + expect(screen.getByText(/3 requests/)).toBeInTheDocument(); + }); + + it("says no requests for an idle slot instead of reporting the drawn zero", () => { + render(point({ value: null, line: 0, count: 0 })); + expect(screen.getByText("No requests")).toBeInTheDocument(); + expect(screen.queryByText("0.000ms")).not.toBeInTheDocument(); + }); + + it("singularizes a lone request", () => { + render(point({ count: 1 })); + expect(screen.getByText(/1 request(?!s)/)).toBeInTheDocument(); + }); + + it("omits the count on the executions row, where it duplicates the value", () => { + render(point({ value: 22, line: 22, count: 22 }), formatCount, false); + expect(screen.getByText("22")).toBeInTheDocument(); + expect(screen.queryByText(/requests/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/dashboard/SparklineTooltip.tsx b/src/components/dashboard/SparklineTooltip.tsx new file mode 100644 index 0000000..9835e6a --- /dev/null +++ b/src/components/dashboard/SparklineTooltip.tsx @@ -0,0 +1,54 @@ +/** + * Hover readout for one sparkline slot: the hour, the value, and how many + * requests produced it. + * + * The request count is the point of this: a p95 over three samples is not a + * meaningful percentile, and nothing else on the card lets a reader tell that. + */ + +import { useIntl } from "react-intl"; + +import type { SparklinePoint } from "./sparklineSeries"; + +interface SparklineTooltipProps { + /** Injected by recharts. */ + active?: boolean; + payload?: { payload: SparklinePoint }[]; + formatValue: (value: number) => string; + /** Counts are redundant on the executions row, where the value is the count. */ + showCount: boolean; +} + +export function SparklineTooltip({ + active, + payload, + formatValue, + showCount, +}: SparklineTooltipProps) { + const intl = useIntl(); + const point = payload?.[0]?.payload; + + if (!active || !point) return null; + + return ( +
+ + {intl.formatTime(point.t, { hour: "2-digit", minute: "2-digit" })} + {" "} + + {point.value === null + ? intl.formatMessage({ id: "dashboard.home.sparklines.tooltip.noRequests" }) + : formatValue(point.value)} + + {showCount && point.value !== null && ( + + {" · "} + {intl.formatMessage( + { id: "dashboard.home.sparklines.tooltip.requests" }, + { count: point.count }, + )} + + )} +
+ ); +} diff --git a/src/components/dashboard/SystemSparklinesCard.tsx b/src/components/dashboard/SystemSparklinesCard.tsx index 0720c6b..1bb26c0 100644 --- a/src/components/dashboard/SystemSparklinesCard.tsx +++ b/src/components/dashboard/SystemSparklinesCard.tsx @@ -18,7 +18,7 @@ import { buildGrid, headlineScalar, isEmptySeries, - toLinePoints, + toSparklinePoints, } from "./sparklineSeries"; import { formatCount, formatResponseTime } from "./systemMetrics"; @@ -49,13 +49,19 @@ export function SystemSparklinesCard({ const latency = (key: "p50" | "p95" | "p99") => alignToGrid(percentiles?.buckets ?? [], percentiles?.[key] ?? [], grid, "gap"); + // Executions doubles as the per-slot sample count, which is what lets the + // latency tooltips say how many requests a percentile was taken over. + const counts = executions.map((v) => v ?? 0); + return [ { key: "executions", label: intl.formatMessage({ id: "dashboard.home.sparklines.executions" }), points: executions, - line: toLinePoints(executions), + chart: toSparklinePoints(grid, executions, counts), value: formatCount(headlineScalar(executions, "sum")), + formatValue: formatCount, + showCount: false, }, ...(["p50", "p95", "p99"] as const).map((key) => { const points = latency(key); @@ -63,8 +69,10 @@ export function SystemSparklinesCard({ key, label: intl.formatMessage({ id: `dashboard.home.sparklines.${key}` }), points, - line: toLinePoints(points), + chart: toSparklinePoints(grid, points, counts), value: formatResponseTime(headlineScalar(points, "latest")), + formatValue: formatResponseTime, + showCount: true, }; }), ]; @@ -92,7 +100,9 @@ export function SystemSparklinesCard({ key={row.key} label={row.label} value={row.value} - points={row.line} + points={row.chart} + formatValue={row.formatValue} + showCount={row.showCount} loading={loading} /> ))} diff --git a/src/components/dashboard/sparklineSeries.test.ts b/src/components/dashboard/sparklineSeries.test.ts index 612fb05..74b631a 100644 --- a/src/components/dashboard/sparklineSeries.test.ts +++ b/src/components/dashboard/sparklineSeries.test.ts @@ -5,7 +5,7 @@ import { buildGrid, headlineScalar, isEmptySeries, - toLinePoints, + toSparklinePoints, } from "./sparklineSeries"; const HOUR_MS = 3_600_000; @@ -94,18 +94,27 @@ describe("headlineScalar", () => { }); }); -describe("toLinePoints", () => { - it("flattens idle slots to zero so the line spans the whole window", () => { - expect(toLinePoints([null, 0.5, null, 0.3])).toEqual([0, 0.5, 0, 0.3]); +describe("toSparklinePoints", () => { + const grid = [1000, 2000, 3000]; + + it("keeps idle slots drawable at zero while leaving the value null", () => { + const points = toSparklinePoints(grid, [null, 0.5, null], [0, 3, 0]); + expect(points.map((p) => p.line)).toEqual([0, 0.5, 0]); + expect(points.map((p) => p.value)).toEqual([null, 0.5, null]); + }); + + it("carries the bucket time and request count through for the tooltip", () => { + const points = toSparklinePoints(grid, [null, 0.5, 0.2], [0, 3, 7]); + expect(points[1]).toEqual({ t: 2000, line: 0.5, value: 0.5, count: 3 }); + expect(points[2].count).toBe(7); }); - it("leaves a gapless series untouched", () => { - expect(toLinePoints([0, 2, 5])).toEqual([0, 2, 5]); + it("emits one point per grid slot, which is what keeps rows aligned", () => { + expect(toSparklinePoints(grid, [], [])).toHaveLength(3); }); - it("returns the same length as its input, which is what keeps rows aligned", () => { - const points = Array.from({ length: 24 }, (_, i) => (i === 23 ? 4 : null)); - expect(toLinePoints(points)).toHaveLength(24); + it("defaults a missing count to zero rather than undefined", () => { + expect(toSparklinePoints(grid, [1, 2, 3], [])[0].count).toBe(0); }); }); diff --git a/src/components/dashboard/sparklineSeries.ts b/src/components/dashboard/sparklineSeries.ts index 99b8929..ea2cbee 100644 --- a/src/components/dashboard/sparklineSeries.ts +++ b/src/components/dashboard/sparklineSeries.ts @@ -82,13 +82,32 @@ export function headlineScalar(points: (number | null)[], mode: ScalarMode): num return null; } +/** One grid slot, carrying both what to draw and what actually happened. */ +export interface SparklinePoint { + /** Bucket start, epoch ms. */ + t: number; + /** Value for drawing. Idle slots are 0 so the line spans the whole window. */ + line: number; + /** True value, or null when the slot had no requests. */ + value: number | null; + /** Requests observed in the slot, used to qualify a percentile. */ + count: number; +} + /** - * Drawing-only view of a series: idle slots become 0 so the line spans the whole - * window. Never feed this to `headlineScalar`, which would read the zeros as - * measurements. + * Zip a series against the grid and the per-slot request counts. `line` keeps + * idle slots drawable; `value` stays null there so the tooltip says "no + * requests" rather than reporting a fabricated 0. */ -export function toLinePoints(points: (number | null)[]): number[] { - return points.map((p) => p ?? 0); +export function toSparklinePoints( + grid: number[], + series: (number | null)[], + counts: number[], +): SparklinePoint[] { + return grid.map((t, i) => { + const value = series[i] ?? null; + return { t, line: value ?? 0, value, count: counts[i] ?? 0 }; + }); } /** True when every slot is empty, i.e. nothing to draw. */ diff --git a/src/i18n/locales/en-US/dashboard.json b/src/i18n/locales/en-US/dashboard.json index 34afe9f..a57a6e7 100644 --- a/src/i18n/locales/en-US/dashboard.json +++ b/src/i18n/locales/en-US/dashboard.json @@ -83,5 +83,7 @@ "dashboard.home.sparklines.p99": "p99 latency", "dashboard.home.sparklines.window": "Last 24 hours", "dashboard.home.sparklines.error": "Couldn't load metrics.", - "dashboard.home.sparklines.empty": "No traced requests. If you haven't already, set OBSERVABILITY_ENABLED=true and restart the gateway." + "dashboard.home.sparklines.empty": "No traced requests. If you haven't already, set OBSERVABILITY_ENABLED=true and restart the gateway.", + "dashboard.home.sparklines.tooltip.noRequests": "No requests", + "dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# request} other {# requests}}" } diff --git a/src/i18n/locales/es-ES/dashboard.json b/src/i18n/locales/es-ES/dashboard.json index 0f0d483..5760518 100644 --- a/src/i18n/locales/es-ES/dashboard.json +++ b/src/i18n/locales/es-ES/dashboard.json @@ -83,5 +83,7 @@ "dashboard.home.sparklines.p99": "Latencia p99", "dashboard.home.sparklines.window": "Últimas 24 horas", "dashboard.home.sparklines.error": "No se pudieron cargar las métricas.", - "dashboard.home.sparklines.empty": "Sin solicitudes rastreadas. Si aún no lo ha hecho, establezca OBSERVABILITY_ENABLED=true y reinicie la puerta de enlace." + "dashboard.home.sparklines.empty": "Sin solicitudes rastreadas. Si aún no lo ha hecho, establezca OBSERVABILITY_ENABLED=true y reinicie la puerta de enlace.", + "dashboard.home.sparklines.tooltip.noRequests": "Sin solicitudes", + "dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# solicitud} other {# solicitudes}}" } diff --git a/src/i18n/locales/pt-BR/dashboard.json b/src/i18n/locales/pt-BR/dashboard.json index 0ca11e3..ced5855 100644 --- a/src/i18n/locales/pt-BR/dashboard.json +++ b/src/i18n/locales/pt-BR/dashboard.json @@ -83,5 +83,7 @@ "dashboard.home.sparklines.p99": "Latência p99", "dashboard.home.sparklines.window": "Últimas 24 horas", "dashboard.home.sparklines.error": "Não foi possível carregar as métricas.", - "dashboard.home.sparklines.empty": "Nenhuma requisição rastreada. Se ainda não tiver feito isso, defina OBSERVABILITY_ENABLED=true e reinicie o gateway." + "dashboard.home.sparklines.empty": "Nenhuma requisição rastreada. Se ainda não tiver feito isso, defina OBSERVABILITY_ENABLED=true e reinicie o gateway.", + "dashboard.home.sparklines.tooltip.noRequests": "Sem requisições", + "dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# requisição} other {# requisições}}" } From 810a80d592e30474cd6bdacc72abe984e61de03d Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Tue, 1 Sep 2026 14:38:02 -0700 Subject: [PATCH 04/12] style: stack sparkline tooltip content Hour, value and request count each get their own line, at a uniform light weight. Signed-off-by: Anna Effort --- src/components/dashboard/SparklineTooltip.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/dashboard/SparklineTooltip.tsx b/src/components/dashboard/SparklineTooltip.tsx index 9835e6a..cc6af2e 100644 --- a/src/components/dashboard/SparklineTooltip.tsx +++ b/src/components/dashboard/SparklineTooltip.tsx @@ -31,10 +31,10 @@ export function SparklineTooltip({ if (!active || !point) return null; return ( -
+
{intl.formatTime(point.t, { hour: "2-digit", minute: "2-digit" })} - {" "} + {point.value === null ? intl.formatMessage({ id: "dashboard.home.sparklines.tooltip.noRequests" }) @@ -42,7 +42,6 @@ export function SparklineTooltip({ {showCount && point.value !== null && ( - {" · "} {intl.formatMessage( { id: "dashboard.home.sparklines.tooltip.requests" }, { count: point.count }, From 3282873afdbe4ab130fe70a4e2af9a2510cd858e Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Tue, 1 Sep 2026 14:47:40 -0700 Subject: [PATCH 05/12] fix: keep the sparkline tooltip above the rows below it Each row's chart wrapper is position:relative with z-index auto, so the wrappers paint in document order and an upper row's tooltip landed behind the rows underneath. Only the last row looked correct. Gives the tooltip wrapper z-50, matching the ui/ overlays. Signed-off-by: Anna Effort --- src/components/dashboard/Sparkline.test.tsx | 47 +++++++++++++++++++++ src/components/dashboard/Sparkline.tsx | 4 ++ 2 files changed, 51 insertions(+) create mode 100644 src/components/dashboard/Sparkline.test.tsx diff --git a/src/components/dashboard/Sparkline.test.tsx b/src/components/dashboard/Sparkline.test.tsx new file mode 100644 index 0000000..561381b --- /dev/null +++ b/src/components/dashboard/Sparkline.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "@/test/test-utils"; + +import { Sparkline } from "./Sparkline"; +import { formatCount } from "./systemMetrics"; +import type { SparklinePoint } from "./sparklineSeries"; + +// ResponsiveContainer measures its parent, which is 0x0 under jsdom, so the +// chart would render nothing. Only the sizing wrapper is replaced. +vi.mock("recharts", async () => { + const actual = await vi.importActual("recharts"); + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactElement }) => ({ + ...children, + props: { ...children.props, width: 200, height: 37 }, + }), + }; +}); + +function points(): SparklinePoint[] { + return [0, 3, 1].map((v, i) => ({ t: 1000 + i * 1000, line: v, value: v, count: v })); +} + +describe("Sparkline", () => { + it("lifts the tooltip above the rows below it", () => { + // Each row's chart wrapper is position:relative with z-index auto, so + // without this the first row's tooltip paints behind later rows. + const { container } = renderWithProviders( + , + ); + + const tooltip = container.querySelector(".recharts-tooltip-wrapper"); + expect(tooltip).not.toBeNull(); + expect(tooltip!.style.zIndex).toBe("50"); + }); + + it("draws one line across every slot it is given", () => { + const { container } = renderWithProviders( + , + ); + + const curves = container.querySelectorAll(".recharts-line-curve"); + expect(curves).toHaveLength(1); + expect(curves[0].getAttribute("d")).toMatch(/^M0,/); + }); +}); diff --git a/src/components/dashboard/Sparkline.tsx b/src/components/dashboard/Sparkline.tsx index 4abce1b..4994797 100644 --- a/src/components/dashboard/Sparkline.tsx +++ b/src/components/dashboard/Sparkline.tsx @@ -35,6 +35,10 @@ export function Sparkline({ points, formatValue, showCount }: SparklineProps) { content={} cursor={{ stroke: "var(--color-muted-foreground)", strokeWidth: 1 }} isAnimationActive={false} + // Each row's chart wrapper is position:relative with z-index auto, so + // rows paint in document order and an upper row's tooltip lands behind + // the rows below it. Matches the z-50 used by the ui/ overlays. + wrapperStyle={{ zIndex: 50 }} /> Date: Tue, 1 Sep 2026 15:11:00 -0700 Subject: [PATCH 06/12] feat: name the unit in the executions tooltip Reads "8 executions" rather than a bare "8". The card passes a pluralized formatter, so the tooltip stays unaware of what it is rendering. Promotes the count line to the foreground colour so the quantities carry more weight than the timestamp above them. Signed-off-by: Anna Effort --- src/components/dashboard/Sparkline.test.tsx | 10 ++++++---- src/components/dashboard/SparklineTooltip.test.tsx | 7 ++++--- src/components/dashboard/SparklineTooltip.tsx | 2 +- src/components/dashboard/SystemSparklinesCard.tsx | 4 +++- src/i18n/locales/en-US/dashboard.json | 3 ++- src/i18n/locales/es-ES/dashboard.json | 3 ++- src/i18n/locales/pt-BR/dashboard.json | 3 ++- 7 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/components/dashboard/Sparkline.test.tsx b/src/components/dashboard/Sparkline.test.tsx index 561381b..f050903 100644 --- a/src/components/dashboard/Sparkline.test.tsx +++ b/src/components/dashboard/Sparkline.test.tsx @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { cloneElement, type ReactElement } from "react"; import { renderWithProviders } from "@/test/test-utils"; import { Sparkline } from "./Sparkline"; @@ -11,10 +12,11 @@ vi.mock("recharts", async () => { const actual = await vi.importActual("recharts"); return { ...actual, - ResponsiveContainer: ({ children }: { children: React.ReactElement }) => ({ - ...children, - props: { ...children.props, width: 200, height: 37 }, - }), + ResponsiveContainer: ({ children }: { children: ReactElement }) => + cloneElement(children as ReactElement<{ width?: number; height?: number }>, { + width: 200, + height: 37, + }), }; }); diff --git a/src/components/dashboard/SparklineTooltip.test.tsx b/src/components/dashboard/SparklineTooltip.test.tsx index 2ebff3a..4315bb6 100644 --- a/src/components/dashboard/SparklineTooltip.test.tsx +++ b/src/components/dashboard/SparklineTooltip.test.tsx @@ -60,9 +60,10 @@ describe("SparklineTooltip", () => { expect(screen.getByText(/1 request(?!s)/)).toBeInTheDocument(); }); - it("omits the count on the executions row, where it duplicates the value", () => { - render(point({ value: 22, line: 22, count: 22 }), formatCount, false); - expect(screen.getByText("22")).toBeInTheDocument(); + it("omits the count where the formatter already names the unit", () => { + // Mirrors the executions row, whose formatter renders "22 executions". + render(point({ value: 22, line: 22, count: 22 }), (v) => `${v} executions`, false); + expect(screen.getByText("22 executions")).toBeInTheDocument(); expect(screen.queryByText(/requests/)).not.toBeInTheDocument(); }); }); diff --git a/src/components/dashboard/SparklineTooltip.tsx b/src/components/dashboard/SparklineTooltip.tsx index cc6af2e..7b8810b 100644 --- a/src/components/dashboard/SparklineTooltip.tsx +++ b/src/components/dashboard/SparklineTooltip.tsx @@ -41,7 +41,7 @@ export function SparklineTooltip({ : formatValue(point.value)} {showCount && point.value !== null && ( - + {intl.formatMessage( { id: "dashboard.home.sparklines.tooltip.requests" }, { count: point.count }, diff --git a/src/components/dashboard/SystemSparklinesCard.tsx b/src/components/dashboard/SystemSparklinesCard.tsx index 1bb26c0..0618a10 100644 --- a/src/components/dashboard/SystemSparklinesCard.tsx +++ b/src/components/dashboard/SystemSparklinesCard.tsx @@ -60,7 +60,9 @@ export function SystemSparklinesCard({ points: executions, chart: toSparklinePoints(grid, executions, counts), value: formatCount(headlineScalar(executions, "sum")), - formatValue: formatCount, + // The executions tooltip names its own unit, so it needs no count line. + formatValue: (count: number) => + intl.formatMessage({ id: "dashboard.home.sparklines.tooltip.executions" }, { count }), showCount: false, }, ...(["p50", "p95", "p99"] as const).map((key) => { diff --git a/src/i18n/locales/en-US/dashboard.json b/src/i18n/locales/en-US/dashboard.json index a57a6e7..0f573ef 100644 --- a/src/i18n/locales/en-US/dashboard.json +++ b/src/i18n/locales/en-US/dashboard.json @@ -85,5 +85,6 @@ "dashboard.home.sparklines.error": "Couldn't load metrics.", "dashboard.home.sparklines.empty": "No traced requests. If you haven't already, set OBSERVABILITY_ENABLED=true and restart the gateway.", "dashboard.home.sparklines.tooltip.noRequests": "No requests", - "dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# request} other {# requests}}" + "dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# request} other {# requests}}", + "dashboard.home.sparklines.tooltip.executions": "{count, plural, one {# execution} other {# executions}}" } diff --git a/src/i18n/locales/es-ES/dashboard.json b/src/i18n/locales/es-ES/dashboard.json index 5760518..5be0d9e 100644 --- a/src/i18n/locales/es-ES/dashboard.json +++ b/src/i18n/locales/es-ES/dashboard.json @@ -85,5 +85,6 @@ "dashboard.home.sparklines.error": "No se pudieron cargar las métricas.", "dashboard.home.sparklines.empty": "Sin solicitudes rastreadas. Si aún no lo ha hecho, establezca OBSERVABILITY_ENABLED=true y reinicie la puerta de enlace.", "dashboard.home.sparklines.tooltip.noRequests": "Sin solicitudes", - "dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# solicitud} other {# solicitudes}}" + "dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# solicitud} other {# solicitudes}}", + "dashboard.home.sparklines.tooltip.executions": "{count, plural, one {# ejecución} other {# ejecuciones}}" } diff --git a/src/i18n/locales/pt-BR/dashboard.json b/src/i18n/locales/pt-BR/dashboard.json index ced5855..aad526c 100644 --- a/src/i18n/locales/pt-BR/dashboard.json +++ b/src/i18n/locales/pt-BR/dashboard.json @@ -85,5 +85,6 @@ "dashboard.home.sparklines.error": "Não foi possível carregar as métricas.", "dashboard.home.sparklines.empty": "Nenhuma requisição rastreada. Se ainda não tiver feito isso, defina OBSERVABILITY_ENABLED=true e reinicie o gateway.", "dashboard.home.sparklines.tooltip.noRequests": "Sem requisições", - "dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# requisição} other {# requisições}}" + "dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# requisição} other {# requisições}}", + "dashboard.home.sparklines.tooltip.executions": "{count, plural, one {# execução} other {# execuções}}" } From 9563b6f06a899caabb2497e60667eae36e746c6c Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Tue, 1 Sep 2026 16:02:12 -0700 Subject: [PATCH 07/12] style: add bottom padding to the sparklines card Splits py-4 into pt-4/pb-6 so the last row's line is not tight against the card edge. Signed-off-by: Anna Effort --- src/components/dashboard/SystemSparklinesCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/dashboard/SystemSparklinesCard.tsx b/src/components/dashboard/SystemSparklinesCard.tsx index 0618a10..c34825f 100644 --- a/src/components/dashboard/SystemSparklinesCard.tsx +++ b/src/components/dashboard/SystemSparklinesCard.tsx @@ -83,7 +83,7 @@ export function SystemSparklinesCard({ const isEmpty = !loading && !error && rows.every((row) => isEmptySeries(row.points)); return ( -
+

{error From eaeb6eb4350e2c001758d45e75ad61a2e0288bb4 Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Tue, 1 Sep 2026 17:07:56 -0700 Subject: [PATCH 08/12] feat: title the sparklines card and add a System status button Per Figma 5863-13032. The bare "Last 24 hours" marker becomes a titled header, "Traffic, last 24 hours", with a System status button routing to ?view=system. That view has no entry point from the resting home otherwise, since the default row carries only the four source cards. The error and empty messages move below the title, which now owns the left slot they used. Signed-off-by: Anna Effort --- .../dashboard/SystemSparklinesCard.test.tsx | 21 ++++++++++-- .../dashboard/SystemSparklinesCard.tsx | 28 +++++++++------ .../SystemSparklinesCardConnected.test.tsx | 6 +++- .../dashboard/SystemStatusButton.tsx | 34 +++++++++++++++++++ src/i18n/locales/en-US/dashboard.json | 5 +-- src/i18n/locales/es-ES/dashboard.json | 5 +-- src/i18n/locales/pt-BR/dashboard.json | 5 +-- 7 files changed, 83 insertions(+), 21 deletions(-) create mode 100644 src/components/dashboard/SystemStatusButton.tsx diff --git a/src/components/dashboard/SystemSparklinesCard.test.tsx b/src/components/dashboard/SystemSparklinesCard.test.tsx index b51bd86..c2dcfd9 100644 --- a/src/components/dashboard/SystemSparklinesCard.test.tsx +++ b/src/components/dashboard/SystemSparklinesCard.test.tsx @@ -1,10 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "@/test/test-utils"; import { SystemSparklinesCard } from "./SystemSparklinesCard"; +const mockNavigate = vi.fn(); +vi.mock("@/router", () => ({ + useRouter: () => ({ navigate: mockNavigate, path: "/app/", params: {} }), +})); + const HOUR_MS = 3_600_000; /** ISO time for a bucket `hoursAgo` before the current interval boundary. */ @@ -14,14 +20,14 @@ function bucket(hoursAgo: number): string { } describe("SystemSparklinesCard", () => { - it("renders a row per metric with the window label", () => { + it("renders a row per metric under the window title", () => { renderWithProviders(); expect(screen.getByText("Executions")).toBeInTheDocument(); expect(screen.getByText("p50 latency")).toBeInTheDocument(); expect(screen.getByText("p95 latency")).toBeInTheDocument(); expect(screen.getByText("p99 latency")).toBeInTheDocument(); - expect(screen.getByText("Last 24 hours")).toBeInTheDocument(); + expect(screen.getByText("Traffic, last 24 hours")).toBeInTheDocument(); }); it("totals executions across the window and takes the latest latency bucket", () => { @@ -71,4 +77,13 @@ describe("SystemSparklinesCard", () => { expect(container.querySelectorAll('[data-slot="skeleton"]')).toHaveLength(4); }); + + it("routes the System status button to the system view", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /system status/i })); + + expect(mockNavigate).toHaveBeenCalledWith("/app/?view=system"); + }); }); diff --git a/src/components/dashboard/SystemSparklinesCard.tsx b/src/components/dashboard/SystemSparklinesCard.tsx index c34825f..830290c 100644 --- a/src/components/dashboard/SystemSparklinesCard.tsx +++ b/src/components/dashboard/SystemSparklinesCard.tsx @@ -13,6 +13,7 @@ import type { PercentilesResponse, TimeseriesResponse } from "@/types/metrics"; import { WINDOW_HOURS, WINDOW_INTERVAL_MINUTES } from "@/hooks/useMetrics"; import { SparklineRow } from "./SparklineRow"; +import { SystemStatusButton } from "./SystemStatusButton"; import { alignToGrid, buildGrid, @@ -82,21 +83,26 @@ export function SystemSparklinesCard({ const isEmpty = !loading && !error && rows.every((row) => isEmptySeries(row.points)); + // Sits under the title rather than beside it: the header row is the design's + // title plus System status button, with no slot for a status line. + const statusMessage = error + ? intl.formatMessage({ id: "dashboard.home.sparklines.error" }) + : isEmpty + ? intl.formatMessage({ id: "dashboard.home.sparklines.empty" }) + : null; + return (

-
-

- {error - ? intl.formatMessage({ id: "dashboard.home.sparklines.error" }) - : isEmpty - ? intl.formatMessage({ id: "dashboard.home.sparklines.empty" }) - : null} +

+

+ {intl.formatMessage({ id: "dashboard.home.sparklines.title" })}

- - {intl.formatMessage({ id: "dashboard.home.sparklines.window" })} - +
-
+ {statusMessage ? ( +

{statusMessage}

+ ) : null} +
{rows.map((row) => ( ({ + useRouter: () => ({ navigate: vi.fn(), path: "/app/", params: {} }), +})); + vi.mock("@/hooks/useMetrics", () => ({ useMetrics: vi.fn(), WINDOW_HOURS: 24, @@ -48,7 +52,7 @@ describe("SystemSparklinesCardConnected", () => { renderWithProviders(); - expect(screen.getByText("Last 24 hours")).toBeInTheDocument(); + expect(screen.getByText("Traffic, last 24 hours")).toBeInTheDocument(); }); it("only shows the loading state on the first load, not on a refresh", () => { diff --git a/src/components/dashboard/SystemStatusButton.tsx b/src/components/dashboard/SystemStatusButton.tsx new file mode 100644 index 0000000..7c33777 --- /dev/null +++ b/src/components/dashboard/SystemStatusButton.tsx @@ -0,0 +1,34 @@ +/** + * Entry point from the sparklines card into the System view (`/app/?view=system`). + * + * The default state has no System mini card, so this is the only route to it + * from the resting home. + */ + +import { SquareActivity } from "lucide-react"; +import { useIntl } from "react-intl"; + +import { Button } from "@/components/ui/button"; +import { useRouter } from "@/router"; + +import { viewHref } from "./homeStates"; + +export function SystemStatusButton({ className }: { className?: string }) { + const intl = useIntl(); + const { navigate } = useRouter(); + + return ( + + ); +} diff --git a/src/i18n/locales/en-US/dashboard.json b/src/i18n/locales/en-US/dashboard.json index 0f573ef..9913d53 100644 --- a/src/i18n/locales/en-US/dashboard.json +++ b/src/i18n/locales/en-US/dashboard.json @@ -81,10 +81,11 @@ "dashboard.home.sparklines.p50": "p50 latency", "dashboard.home.sparklines.p95": "p95 latency", "dashboard.home.sparklines.p99": "p99 latency", - "dashboard.home.sparklines.window": "Last 24 hours", "dashboard.home.sparklines.error": "Couldn't load metrics.", "dashboard.home.sparklines.empty": "No traced requests. If you haven't already, set OBSERVABILITY_ENABLED=true and restart the gateway.", "dashboard.home.sparklines.tooltip.noRequests": "No requests", "dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# request} other {# requests}}", - "dashboard.home.sparklines.tooltip.executions": "{count, plural, one {# execution} other {# executions}}" + "dashboard.home.sparklines.tooltip.executions": "{count, plural, one {# execution} other {# executions}}", + "dashboard.home.sparklines.title": "Traffic, last 24 hours", + "dashboard.home.sparklines.systemStatus": "System status" } diff --git a/src/i18n/locales/es-ES/dashboard.json b/src/i18n/locales/es-ES/dashboard.json index 5be0d9e..87ed35b 100644 --- a/src/i18n/locales/es-ES/dashboard.json +++ b/src/i18n/locales/es-ES/dashboard.json @@ -81,10 +81,11 @@ "dashboard.home.sparklines.p50": "Latencia p50", "dashboard.home.sparklines.p95": "Latencia p95", "dashboard.home.sparklines.p99": "Latencia p99", - "dashboard.home.sparklines.window": "Últimas 24 horas", "dashboard.home.sparklines.error": "No se pudieron cargar las métricas.", "dashboard.home.sparklines.empty": "Sin solicitudes rastreadas. Si aún no lo ha hecho, establezca OBSERVABILITY_ENABLED=true y reinicie la puerta de enlace.", "dashboard.home.sparklines.tooltip.noRequests": "Sin solicitudes", "dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# solicitud} other {# solicitudes}}", - "dashboard.home.sparklines.tooltip.executions": "{count, plural, one {# ejecución} other {# ejecuciones}}" + "dashboard.home.sparklines.tooltip.executions": "{count, plural, one {# ejecución} other {# ejecuciones}}", + "dashboard.home.sparklines.title": "Tráfico, últimas 24 horas", + "dashboard.home.sparklines.systemStatus": "Estado del sistema" } diff --git a/src/i18n/locales/pt-BR/dashboard.json b/src/i18n/locales/pt-BR/dashboard.json index aad526c..d18117e 100644 --- a/src/i18n/locales/pt-BR/dashboard.json +++ b/src/i18n/locales/pt-BR/dashboard.json @@ -81,10 +81,11 @@ "dashboard.home.sparklines.p50": "Latência p50", "dashboard.home.sparklines.p95": "Latência p95", "dashboard.home.sparklines.p99": "Latência p99", - "dashboard.home.sparklines.window": "Últimas 24 horas", "dashboard.home.sparklines.error": "Não foi possível carregar as métricas.", "dashboard.home.sparklines.empty": "Nenhuma requisição rastreada. Se ainda não tiver feito isso, defina OBSERVABILITY_ENABLED=true e reinicie o gateway.", "dashboard.home.sparklines.tooltip.noRequests": "Sem requisições", "dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# requisição} other {# requisições}}", - "dashboard.home.sparklines.tooltip.executions": "{count, plural, one {# execução} other {# execuções}}" + "dashboard.home.sparklines.tooltip.executions": "{count, plural, one {# execução} other {# execuções}}", + "dashboard.home.sparklines.title": "Tráfego, últimas 24 horas", + "dashboard.home.sparklines.systemStatus": "Status do sistema" } From 9eae2db305ce50aabf39f613f95690bb992b080a Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Tue, 1 Sep 2026 17:31:32 -0700 Subject: [PATCH 09/12] fix: darken the System status button in light theme --status-icon is cyan-500, only 2.43:1 on the white light-theme background. cyan-700 matches the MCP server catalog link and clears AA. Dark keeps the brand cyan from the design. Also nudges the card padding and the button's vertical offset. Signed-off-by: Anna Effort --- src/components/dashboard/Sparkline.tsx | 11 +---------- src/components/dashboard/SystemSparklinesCard.tsx | 7 +++---- src/components/dashboard/SystemStatusButton.tsx | 13 ++++++++++--- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/components/dashboard/Sparkline.tsx b/src/components/dashboard/Sparkline.tsx index 4994797..f997524 100644 --- a/src/components/dashboard/Sparkline.tsx +++ b/src/components/dashboard/Sparkline.tsx @@ -1,11 +1,4 @@ /** - * Bare trend line: no axes, grid, legend or dots, plus a hover readout. - * - * The y-domain is anchored at 0 rather than fitted; a fitted domain rescales on - * every poll, making a flat metric appear to jump when only the scale moved. - * `tickCount={2}` is load-bearing: without it recharts rounds the domain up to - * nice tick values and the peak stops short of the top of the band. - * * The row's value is text beside the chart, so the line itself carries no * information a screen reader needs. The tooltip is a pointer-only * enhancement on top of that, not the only route to the data. @@ -35,9 +28,7 @@ export function Sparkline({ points, formatValue, showCount }: SparklineProps) { content={} cursor={{ stroke: "var(--color-muted-foreground)", strokeWidth: 1 }} isAnimationActive={false} - // Each row's chart wrapper is position:relative with z-index auto, so - // rows paint in document order and an upper row's tooltip lands behind - // the rows below it. Matches the z-50 used by the ui/ overlays. + // Keep tooltip above other elements wrapperStyle={{ zIndex: 50 }} /> isEmptySeries(row.points)); - // Sits under the title rather than beside it: the header row is the design's - // title plus System status button, with no slot for a status line. + // Sits under the title const statusMessage = error ? intl.formatMessage({ id: "dashboard.home.sparklines.error" }) : isEmpty @@ -92,12 +91,12 @@ export function SystemSparklinesCard({ : null; return ( -
+

{intl.formatMessage({ id: "dashboard.home.sparklines.title" })}

- +
{statusMessage ? (

{statusMessage}

diff --git a/src/components/dashboard/SystemStatusButton.tsx b/src/components/dashboard/SystemStatusButton.tsx index 7c33777..c8355d9 100644 --- a/src/components/dashboard/SystemStatusButton.tsx +++ b/src/components/dashboard/SystemStatusButton.tsx @@ -9,6 +9,7 @@ import { SquareActivity } from "lucide-react"; import { useIntl } from "react-intl"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; import { useRouter } from "@/router"; import { viewHref } from "./homeStates"; @@ -22,13 +23,19 @@ export function SystemStatusButton({ className }: { className?: string }) { type="button" variant="ghost" size="sm" - className={className} + // --status-icon is cyan-500, which is only 2.43:1 on the white light-theme + // background. cyan-700 matches the catalog link and clears AA; dark keeps + // the brand cyan from the design. + className={cn( + "text-cyan-700 hover:text-cyan-800 dark:text-status-icon dark:hover:text-status-icon", + className, + )} onClick={() => navigate(viewHref("system"))} > - + {intl.formatMessage({ id: "dashboard.home.sparklines.systemStatus" })} - + ); } From 846d6ddc899705a320ae27bdbb713d5c4aeb5c8a Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Tue, 1 Sep 2026 17:54:56 -0700 Subject: [PATCH 10/12] fix: meet contrast and aria requirements on the sparklines card Adds --sparkline-stroke rather than reusing --status-icon: the lines are non-text graphics needing 3:1, and cyan-500 gave 2.33:1 on the light card. cyan-700 gives 5.13:1. Dark keeps the brand cyan at 14.23:1. Restores aria-hidden on the chart, dropped in 95e7f3a when the tooltip landed, which exposed four unnamed SVGs per card. Marks the button icon decorative, since it sits beside its own text label. Signed-off-by: Anna Effort --- src/components/dashboard/Sparkline.test.tsx | 11 +++++++++++ src/components/dashboard/Sparkline.tsx | 6 +++--- src/components/dashboard/SystemStatusButton.tsx | 2 +- src/index.css | 6 ++++++ 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/components/dashboard/Sparkline.test.tsx b/src/components/dashboard/Sparkline.test.tsx index f050903..b999d19 100644 --- a/src/components/dashboard/Sparkline.test.tsx +++ b/src/components/dashboard/Sparkline.test.tsx @@ -46,4 +46,15 @@ describe("Sparkline", () => { expect(curves).toHaveLength(1); expect(curves[0].getAttribute("d")).toMatch(/^M0,/); }); + + it("hides the chart from assistive tech, since the row value is already text", () => { + // Regression guard: this was dropped when the tooltip landed, exposing four + // unnamed SVGs per card. recharts gives the svg no accessible name. + const { container } = renderWithProviders( + , + ); + + expect(container.querySelector("[aria-hidden]")).not.toBeNull(); + expect(container.querySelector("svg")!.closest("[aria-hidden]")).not.toBeNull(); + }); }); diff --git a/src/components/dashboard/Sparkline.tsx b/src/components/dashboard/Sparkline.tsx index f997524..007b0ad 100644 --- a/src/components/dashboard/Sparkline.tsx +++ b/src/components/dashboard/Sparkline.tsx @@ -20,7 +20,7 @@ interface SparklineProps { export function Sparkline({ points, formatValue, showCount }: SparklineProps) { return ( -
+
@@ -34,11 +34,11 @@ export function Sparkline({ points, formatValue, showCount }: SparklineProps) { diff --git a/src/components/dashboard/SystemStatusButton.tsx b/src/components/dashboard/SystemStatusButton.tsx index c8355d9..352a1ca 100644 --- a/src/components/dashboard/SystemStatusButton.tsx +++ b/src/components/dashboard/SystemStatusButton.tsx @@ -35,7 +35,7 @@ export function SystemStatusButton({ className }: { className?: string }) { {intl.formatMessage({ id: "dashboard.home.sparklines.systemStatus" })} - + ); } diff --git a/src/index.css b/src/index.css index 6d8d6df..b32df18 100644 --- a/src/index.css +++ b/src/index.css @@ -79,6 +79,10 @@ /* Status-headline icon accent */ --color-status-icon: var(--status-icon); + /* Sparkline stroke. Separate from --status-icon because a 1px line has to + clear 3:1 as a non-text graphic, which cyan-500 does not on white. */ + --color-sparkline-stroke: var(--sparkline-stroke); + --radius-xl: calc(var(--radius) * 1.4); --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); @@ -150,6 +154,7 @@ /* Status-headline icon accent — cyan for the light background; the .dark override restores the brand cyan (#70f9ff). */ --status-icon: var(--color-cyan-500); + --sparkline-stroke: var(--color-cyan-700); --chart-1: oklch(0.87 0 0); --chart-2: oklch(0.556 0 0); --chart-3: oklch(0.439 0 0); @@ -207,6 +212,7 @@ --selection: var(--color-orange-500); /* Status-headline icon accent (brand cyan) — bright on the dark background. */ --status-icon: #70f9ff; + --sparkline-stroke: #70f9ff; } /* A transparent track so the scrollbar doesn't paint a solid bar over the rounded corners of the box it scrolls in. */ From 81c3aee69c0b0b0471541cacc12936e664f13b36 Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Tue, 1 Sep 2026 18:34:56 -0700 Subject: [PATCH 11/12] fix: drop 'gateway' from the sparklines empty state The UI does not use the word in rendered copy. 'restart ContextForge' rather than 'the server', which is ambiguous next to MCP servers and Virtual servers in the nav. Signed-off-by: Anna Effort --- src/i18n/locales/en-US/dashboard.json | 2 +- src/i18n/locales/es-ES/dashboard.json | 2 +- src/i18n/locales/pt-BR/dashboard.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/i18n/locales/en-US/dashboard.json b/src/i18n/locales/en-US/dashboard.json index 9913d53..a9fe848 100644 --- a/src/i18n/locales/en-US/dashboard.json +++ b/src/i18n/locales/en-US/dashboard.json @@ -82,7 +82,7 @@ "dashboard.home.sparklines.p95": "p95 latency", "dashboard.home.sparklines.p99": "p99 latency", "dashboard.home.sparklines.error": "Couldn't load metrics.", - "dashboard.home.sparklines.empty": "No traced requests. If you haven't already, set OBSERVABILITY_ENABLED=true and restart the gateway.", + "dashboard.home.sparklines.empty": "No traced requests. If you haven't already, set OBSERVABILITY_ENABLED=true and restart ContextForge.", "dashboard.home.sparklines.tooltip.noRequests": "No requests", "dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# request} other {# requests}}", "dashboard.home.sparklines.tooltip.executions": "{count, plural, one {# execution} other {# executions}}", diff --git a/src/i18n/locales/es-ES/dashboard.json b/src/i18n/locales/es-ES/dashboard.json index 87ed35b..69dfe2f 100644 --- a/src/i18n/locales/es-ES/dashboard.json +++ b/src/i18n/locales/es-ES/dashboard.json @@ -82,7 +82,7 @@ "dashboard.home.sparklines.p95": "Latencia p95", "dashboard.home.sparklines.p99": "Latencia p99", "dashboard.home.sparklines.error": "No se pudieron cargar las métricas.", - "dashboard.home.sparklines.empty": "Sin solicitudes rastreadas. Si aún no lo ha hecho, establezca OBSERVABILITY_ENABLED=true y reinicie la puerta de enlace.", + "dashboard.home.sparklines.empty": "Sin solicitudes rastreadas. Si aún no lo ha hecho, establezca OBSERVABILITY_ENABLED=true y reinicie ContextForge.", "dashboard.home.sparklines.tooltip.noRequests": "Sin solicitudes", "dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# solicitud} other {# solicitudes}}", "dashboard.home.sparklines.tooltip.executions": "{count, plural, one {# ejecución} other {# ejecuciones}}", diff --git a/src/i18n/locales/pt-BR/dashboard.json b/src/i18n/locales/pt-BR/dashboard.json index d18117e..82e5524 100644 --- a/src/i18n/locales/pt-BR/dashboard.json +++ b/src/i18n/locales/pt-BR/dashboard.json @@ -82,7 +82,7 @@ "dashboard.home.sparklines.p95": "Latência p95", "dashboard.home.sparklines.p99": "Latência p99", "dashboard.home.sparklines.error": "Não foi possível carregar as métricas.", - "dashboard.home.sparklines.empty": "Nenhuma requisição rastreada. Se ainda não tiver feito isso, defina OBSERVABILITY_ENABLED=true e reinicie o gateway.", + "dashboard.home.sparklines.empty": "Nenhuma requisição rastreada. Se ainda não tiver feito isso, defina OBSERVABILITY_ENABLED=true e reinicie o ContextForge.", "dashboard.home.sparklines.tooltip.noRequests": "Sem requisições", "dashboard.home.sparklines.tooltip.requests": "{count, plural, one {# requisição} other {# requisições}}", "dashboard.home.sparklines.tooltip.executions": "{count, plural, one {# execução} other {# execuções}}", From 58cb6a83f6c750ceaa26bb5cab2beb79eecb6e29 Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Tue, 1 Sep 2026 18:58:33 -0700 Subject: [PATCH 12/12] test: cover the metrics branches missed by the coverage gate Branch coverage landed at 89.95% against the 90% global threshold. The uncovered paths were all in this PR's new code: the metrics query builder had no test file, and useMetrics left the abort-after-resolve, non-Error rejection and visibility-refetch paths untested. Also drops the interval ref in useMetrics; it was written but never read once the id is captured in the effect closure. Signed-off-by: Anna Effort --- src/api/metrics.test.ts | 41 +++++++++++ .../dashboard/sparklineSeries.test.ts | 8 ++ .../dashboard/systemMetrics.test.ts | 19 +++++ src/hooks/useMetrics.test.ts | 73 +++++++++++++++++++ src/hooks/useMetrics.ts | 8 +- 5 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 src/api/metrics.test.ts diff --git a/src/api/metrics.test.ts b/src/api/metrics.test.ts new file mode 100644 index 0000000..b6fbe35 --- /dev/null +++ b/src/api/metrics.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { __test__ } from "./metrics"; + +const { buildQuery, clampInt } = __test__; + +describe("clampInt", () => { + it("passes through an in-range value", () => { + expect(clampInt(24, 1, 168)).toBe(24); + }); + + it("clamps to the server's bounds", () => { + expect(clampInt(0, 1, 168)).toBe(1); + expect(clampInt(9999, 1, 168)).toBe(168); + }); + + it("floors fractional values", () => { + expect(clampInt(24.9, 1, 168)).toBe(24); + }); + + it("drops values the server cannot parse", () => { + expect(clampInt(undefined, 1, 168)).toBeUndefined(); + expect(clampInt(Number.NaN, 1, 168)).toBeUndefined(); + expect(clampInt(Infinity, 1, 168)).toBeUndefined(); + }); +}); + +describe("buildQuery", () => { + it("returns an empty string when no params are given, deferring to the server defaults", () => { + expect(buildQuery({})).toBe(""); + }); + + it("omits a param that clamped away and keeps the other", () => { + expect(buildQuery({ hours: 24, intervalMinutes: Number.NaN })).toBe("?hours=24"); + expect(buildQuery({ intervalMinutes: 60 })).toBe("?interval_minutes=60"); + }); + + it("serialises both params with the server's snake_case names", () => { + expect(buildQuery({ hours: 24, intervalMinutes: 60 })).toBe("?hours=24&interval_minutes=60"); + }); +}); diff --git a/src/components/dashboard/sparklineSeries.test.ts b/src/components/dashboard/sparklineSeries.test.ts index 74b631a..885aef2 100644 --- a/src/components/dashboard/sparklineSeries.test.ts +++ b/src/components/dashboard/sparklineSeries.test.ts @@ -73,6 +73,14 @@ describe("alignToGrid", () => { it("returns nothing for an empty grid", () => { expect(alignToGrid(["2026-09-01T11:00:00Z"], [7], [], "zero")).toEqual([]); }); + + it("matches on the exact timestamp when the grid holds a single slot", () => { + const single = [now]; + expect(alignToGrid(["2026-09-01T12:00:00Z"], [7], single, "zero")).toEqual([7]); + // A one-slot grid has no interval to floor to, so a mid-bucket timestamp + // cannot be matched. + expect(alignToGrid(["2026-09-01T12:30:00Z"], [7], single, "zero")).toEqual([0]); + }); }); describe("headlineScalar", () => { diff --git a/src/components/dashboard/systemMetrics.test.ts b/src/components/dashboard/systemMetrics.test.ts index 41d6411..b089399 100644 --- a/src/components/dashboard/systemMetrics.test.ts +++ b/src/components/dashboard/systemMetrics.test.ts @@ -88,6 +88,25 @@ describe("aggregateSystemStats", () => { const result = aggregateSystemStats(metrics({ tools: entity({ totalExecutions: 4 }) })); expect(result?.avgResponseTime).toBeNull(); }); + + it("skips an entity the gateway omitted from the response", () => { + const partial = metrics({ tools: entity({ totalExecutions: 4 }) }); + // Older gateways omit whole sections rather than sending a zeroed entity. + delete (partial as Partial).prompts; + + const result = aggregateSystemStats(partial as MetricsResponse); + expect(result?.executions).toBe(4); + }); + + it("treats a missing execution total as zero", () => { + const result = aggregateSystemStats( + metrics({ + tools: entity({ totalExecutions: undefined as unknown as number }), + a2aAgents: { avgResponseTime: 3 }, + }), + ); + expect(result?.executions).toBe(0); + }); }); describe("countActiveTotal", () => { diff --git a/src/hooks/useMetrics.test.ts b/src/hooks/useMetrics.test.ts index d4200a7..c64e189 100644 --- a/src/hooks/useMetrics.test.ts +++ b/src/hooks/useMetrics.test.ts @@ -90,6 +90,79 @@ describe("useMetrics", () => { expect(mockTimeseries.mock.calls.length).toBe(callsAfterMount); }); + it("wraps a non-Error rejection so the card always has a message", async () => { + mockTimeseries.mockRejectedValue("boom"); + + const { result } = renderHook(() => useMetrics()); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(result.current.error?.message).toBe("Failed to load metrics"); + }); + + it("discards a response that resolves after the request was aborted", async () => { + let release: (() => void) | undefined; + mockTimeseries.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve({ buckets: ["2026-09-01T12:00:00Z"], values: [9] }); + }), + ); + + const { result } = renderHook(() => useMetrics()); + await waitFor(() => expect(release).toBeDefined()); + + // A second fetch aborts the first; the first must not publish its result. + act(() => result.current.refetch()); + await act(async () => { + release?.(); + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.timeseries).toEqual({ buckets: [], values: [] }); + }); + + it("refetches on becoming visible when nothing has loaded yet", async () => { + setVisibility("hidden"); + mockTimeseries.mockImplementationOnce(() => new Promise(() => {})); + + renderHook(() => useMetrics()); + await waitFor(() => expect(mockTimeseries).toHaveBeenCalled()); + + await act(async () => { + setVisibility("visible"); + }); + + await waitFor(() => expect(mockTimeseries.mock.calls.length).toBeGreaterThan(1)); + }); + + it("refetches on becoming visible when the last success is older than the interval", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + setVisibility("hidden"); + const { result } = renderHook(() => useMetrics()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const callsAfterMount = mockTimeseries.mock.calls.length; + vi.setSystemTime(Date.now() + REFRESH_INTERVAL_MS); + await act(async () => { + setVisibility("visible"); + }); + + await waitFor(() => expect(mockTimeseries.mock.calls.length).toBeGreaterThan(callsAfterMount)); + }); + + it("does not refetch on becoming visible while the data is still fresh", async () => { + setVisibility("hidden"); + const { result } = renderHook(() => useMetrics()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const callsAfterMount = mockTimeseries.mock.calls.length; + await act(async () => { + setVisibility("visible"); + }); + + expect(mockTimeseries.mock.calls.length).toBe(callsAfterMount); + }); + it("aborts the in-flight request on unmount", async () => { const { result, unmount } = renderHook(() => useMetrics()); await waitFor(() => expect(result.current.isLoading).toBe(false)); diff --git a/src/hooks/useMetrics.ts b/src/hooks/useMetrics.ts index f77621e..9422b02 100644 --- a/src/hooks/useMetrics.ts +++ b/src/hooks/useMetrics.ts @@ -42,7 +42,6 @@ export function useMetrics(): MetricsState { const [lastUpdated, setLastUpdated] = useState(null); const abortRef = useRef(null); - const intervalRef = useRef | null>(null); // Read by the visibility handler without being a dependency of it, which // would re-register the listener on every fetch. const lastUpdatedRef = useRef(null); @@ -91,15 +90,12 @@ export function useMetrics(): MetricsState { useEffect(() => { void fetchOnce(); - intervalRef.current = globalThis.setInterval(() => { + const intervalId = globalThis.setInterval(() => { if (document.visibilityState === "visible") void fetchOnce(); }, REFRESH_INTERVAL_MS); return () => { - if (intervalRef.current !== null) { - globalThis.clearInterval(intervalRef.current); - intervalRef.current = null; - } + globalThis.clearInterval(intervalId); abortRef.current?.abort(); }; }, [fetchOnce]);