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.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/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.test.tsx b/src/components/dashboard/Sparkline.test.tsx new file mode 100644 index 0000000..b999d19 --- /dev/null +++ b/src/components/dashboard/Sparkline.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { cloneElement, type ReactElement } from "react"; +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: ReactElement }) => + cloneElement(children as ReactElement<{ width?: number; height?: number }>, { + 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,/); + }); + + 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 new file mode 100644 index 0000000..007b0ad --- /dev/null +++ b/src/components/dashboard/Sparkline.tsx @@ -0,0 +1,47 @@ +/** + * 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, 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 { + points: SparklinePoint[]; + formatValue: (value: number) => string; + showCount: boolean; +} + +export function Sparkline({ points, formatValue, showCount }: SparklineProps) { + return ( +
+ + + + } + cursor={{ stroke: "var(--color-muted-foreground)", strokeWidth: 1 }} + isAnimationActive={false} + // Keep tooltip above other elements + wrapperStyle={{ zIndex: 50 }} + /> + + + +
+ ); +} diff --git a/src/components/dashboard/SparklineRow.tsx b/src/components/dashboard/SparklineRow.tsx new file mode 100644 index 0000000..993c6ba --- /dev/null +++ b/src/components/dashboard/SparklineRow.tsx @@ -0,0 +1,42 @@ +/** + * 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 type { SparklinePoint } from "./sparklineSeries"; +import { StatBlock } from "./SystemStat"; + +interface SparklineRowProps { + label: ReactNode; + /** Pre-formatted value string (see `systemMetrics.ts` formatters). */ + value: ReactNode; + points: SparklinePoint[]; + formatValue: (value: number) => string; + showCount: boolean; + loading?: boolean; +} + +export function SparklineRow({ + label, + value, + points, + formatValue, + showCount, + loading, +}: SparklineRowProps) { + return ( +
+
+ +
+ {loading ? ( +
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/dashboard/SparklineTooltip.test.tsx b/src/components/dashboard/SparklineTooltip.test.tsx new file mode 100644 index 0000000..4315bb6 --- /dev/null +++ b/src/components/dashboard/SparklineTooltip.test.tsx @@ -0,0 +1,69 @@ +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 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 new file mode 100644 index 0000000..7b8810b --- /dev/null +++ b/src/components/dashboard/SparklineTooltip.tsx @@ -0,0 +1,53 @@ +/** + * 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.test.tsx b/src/components/dashboard/SystemSparklinesCard.test.tsx new file mode 100644 index 0000000..c2dcfd9 --- /dev/null +++ b/src/components/dashboard/SystemSparklinesCard.test.tsx @@ -0,0 +1,89 @@ +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. */ +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 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("Traffic, 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); + }); + + 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 new file mode 100644 index 0000000..97144f1 --- /dev/null +++ b/src/components/dashboard/SystemSparklinesCard.tsx @@ -0,0 +1,119 @@ +/** + * 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 { SystemStatusButton } from "./SystemStatusButton"; +import { + alignToGrid, + buildGrid, + headlineScalar, + isEmptySeries, + toSparklinePoints, +} 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"); + + // 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, + chart: toSparklinePoints(grid, executions, counts), + value: formatCount(headlineScalar(executions, "sum")), + // 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) => { + const points = latency(key); + return { + key, + label: intl.formatMessage({ id: `dashboard.home.sparklines.${key}` }), + points, + chart: toSparklinePoints(grid, points, counts), + value: formatResponseTime(headlineScalar(points, "latest")), + formatValue: formatResponseTime, + showCount: true, + }; + }), + ]; + }, [intl, timeseries, percentiles]); + + const isEmpty = !loading && !error && rows.every((row) => isEmptySeries(row.points)); + + // Sits under the title + const statusMessage = error + ? intl.formatMessage({ id: "dashboard.home.sparklines.error" }) + : isEmpty + ? intl.formatMessage({ id: "dashboard.home.sparklines.empty" }) + : null; + + return ( +
+
+

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

+ +
+ {statusMessage ? ( +

{statusMessage}

+ ) : null} +
+ {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..bce7ecf --- /dev/null +++ b/src/components/dashboard/SystemSparklinesCardConnected.test.tsx @@ -0,0 +1,67 @@ +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("@/router", () => ({ + useRouter: () => ({ navigate: vi.fn(), path: "/app/", params: {} }), +})); + +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("Traffic, 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/SystemStatusButton.tsx b/src/components/dashboard/SystemStatusButton.tsx new file mode 100644 index 0000000..352a1ca --- /dev/null +++ b/src/components/dashboard/SystemStatusButton.tsx @@ -0,0 +1,41 @@ +/** + * 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 { cn } from "@/lib/utils"; +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/components/dashboard/sparklineSeries.test.ts b/src/components/dashboard/sparklineSeries.test.ts new file mode 100644 index 0000000..885aef2 --- /dev/null +++ b/src/components/dashboard/sparklineSeries.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; + +import { + alignToGrid, + buildGrid, + headlineScalar, + isEmptySeries, + toSparklinePoints, +} 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([]); + }); + + 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", () => { + 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("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("emits one point per grid slot, which is what keeps rows aligned", () => { + expect(toSparklinePoints(grid, [], [])).toHaveLength(3); + }); + + it("defaults a missing count to zero rather than undefined", () => { + expect(toSparklinePoints(grid, [1, 2, 3], [])[0].count).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..ea2cbee --- /dev/null +++ b/src/components/dashboard/sparklineSeries.ts @@ -0,0 +1,116 @@ +/** + * 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`, 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. */ +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; +} + +/** 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; +} + +/** + * 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 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. */ +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..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", () => { @@ -110,11 +129,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..c64e189 --- /dev/null +++ b/src/hooks/useMetrics.test.ts @@ -0,0 +1,177 @@ +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("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)); + + 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..9422b02 --- /dev/null +++ b/src/hooks/useMetrics.ts @@ -0,0 +1,126 @@ +/** + * 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); + // 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(); + + const intervalId = globalThis.setInterval(() => { + if (document.visibilityState === "visible") void fetchOnce(); + }, REFRESH_INTERVAL_MS); + + return () => { + globalThis.clearInterval(intervalId); + 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..a9fe848 100644 --- a/src/i18n/locales/en-US/dashboard.json +++ b/src/i18n/locales/en-US/dashboard.json @@ -76,5 +76,16 @@ "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.error": "Couldn't load metrics.", + "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}}", + "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 3a6a35a..69dfe2f 100644 --- a/src/i18n/locales/es-ES/dashboard.json +++ b/src/i18n/locales/es-ES/dashboard.json @@ -76,5 +76,16 @@ "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.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 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}}", + "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 0977667..82e5524 100644 --- a/src/i18n/locales/pt-BR/dashboard.json +++ b/src/i18n/locales/pt-BR/dashboard.json @@ -76,5 +76,16 @@ "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.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 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}}", + "dashboard.home.sparklines.title": "Tráfego, últimas 24 horas", + "dashboard.home.sparklines.systemStatus": "Status do sistema" } 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. */ 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";