Switch to uplot for loss graph and rework performance of graph. It is significantly more perfromant now.

This commit is contained in:
Jaret Burkett
2026-05-07 07:38:00 -06:00
parent a12ddd72a1
commit d144cb5ea6
6 changed files with 297 additions and 673 deletions

389
ui/package-lock.json generated
View File

@@ -24,9 +24,9 @@
"react-global-hooks": "^1.3.5",
"react-icons": "^5.5.0",
"react-select": "^5.10.1",
"recharts": "^3.6.0",
"sqlite3": "^5.1.7",
"systeminformation": "^5.27.11",
"uplot": "^1.6.32",
"uuid": "^11.1.0",
"yaml": "^2.7.0"
},
@@ -1314,54 +1314,6 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz",
"integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1438,69 +1390,6 @@
"@types/readdir-glob": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.17.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.19.tgz",
@@ -1561,12 +1450,6 @@
"integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==",
"dev": true
},
"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/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -2751,127 +2634,6 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@@ -2889,12 +2651,6 @@
}
}
},
"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/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -3113,6 +2869,7 @@
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
"license": "MIT",
"optional": true,
"workspaces": [
"docs",
"benchmarks"
@@ -3146,12 +2903,6 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -3684,16 +3435,6 @@
],
"license": "BSD-3-Clause"
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3919,15 +3660,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
@@ -5399,29 +5131,6 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-select": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.1.tgz",
@@ -5511,51 +5220,6 @@
"node": ">=8.10.0"
}
},
"node_modules/recharts": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
"integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -5565,12 +5229,6 @@
"node": ">=0.10.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -6499,12 +6157,6 @@
"node": ">=0.8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -6755,6 +6407,12 @@
"imurmurhash": "^0.1.4"
}
},
"node_modules/uplot": {
"version": "1.6.32",
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT"
},
"node_modules/use-isomorphic-layout-effect": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz",
@@ -6768,15 +6426,6 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -6800,28 +6449,6 @@
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true
},
"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/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -28,9 +28,9 @@
"react-global-hooks": "^1.3.5",
"react-icons": "^5.5.0",
"react-select": "^5.10.1",
"recharts": "^3.6.0",
"sqlite3": "^5.1.7",
"systeminformation": "^5.27.11",
"uplot": "^1.6.32",
"uuid": "^11.1.0",
"yaml": "^2.7.0"
},

View File

@@ -48,7 +48,7 @@ const pages: Page[] = [
value: 'loss_log',
icon: MdShowChart,
component: JobLossGraph,
mainCss: 'pt-24',
mainCss: 'pt-24 pb-4',
jobTypes: ['train'],
},
{

View File

@@ -2,8 +2,9 @@
import { Job } from '@prisma/client';
import useJobLossLog, { LossPoint } from '@/hooks/useJobLossLog';
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from 'recharts';
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
interface Props {
job: Job;
@@ -21,22 +22,36 @@ function clamp01(x: number) {
return Math.max(0, Math.min(1, x));
}
// EMA smoothing that works on a per-series list.
// alpha=1 -> no smoothing, alpha closer to 0 -> more smoothing.
function emaSmoothPoints(points: { step: number; value: number }[], alpha: number) {
if (points.length === 0) return [];
const a = clamp01(alpha);
const out: { step: number; value: number }[] = new Array(points.length);
// Fallback canvas height used before the container has been measured.
const FALLBACK_CANVAS_HEIGHT = 360;
const MIN_CANVAS_HEIGHT = 160;
let prev = points[0].value;
out[0] = { step: points[0].step, value: prev };
// Compute canvas size so uPlot's canvas + its HTML legend fit inside `host`.
// `host` should be a layout-controlled wrapper (NOT the uPlot mount node, since
// uPlot's stylesheet sets `width: min-content` on its mount node).
function computeCanvasSize(host: HTMLElement): { width: number; height: number } | null {
const { width, height } = host.getBoundingClientRect();
if (width <= 0 || height <= 0) return null;
const legend = host.querySelector('.u-legend') as HTMLElement | null;
const legendH = legend?.getBoundingClientRect().height ?? 0;
return { width, height: Math.max(MIN_CANVAS_HEIGHT, height - legendH) };
}
for (let i = 1; i < points.length; i++) {
const x = points[i].value;
prev = a * x + (1 - a) * prev;
out[i] = { step: points[i].step, value: prev };
// EMA over a (number|null)[] series. Nulls are preserved as gaps and do not
// advance the running average.
function emaWithNulls(ys: (number | null)[], alpha: number): (number | null)[] {
const out: (number | null)[] = new Array(ys.length);
let prev: number | null = null;
for (let i = 0; i < ys.length; i++) {
const v = ys[i];
if (v === null || !Number.isFinite(v)) {
out[i] = null;
continue;
}
if (prev === null) prev = v as number;
else prev = alpha * (v as number) + (1 - alpha) * prev;
out[i] = prev;
}
return out;
}
@@ -64,7 +79,6 @@ function strokeForKey(key: string) {
return PALETTE[hashToIndex(key, PALETTE.length)];
}
// Returns a solid but duller/darker version of an rgba color string for the trend overlay.
function dulledColor(rgba: string): string {
const m = rgba.match(/rgba?\((\d+),(\d+),(\d+)/);
if (!m) return 'rgba(120,120,120,1)';
@@ -97,6 +111,8 @@ export default function JobLossGraph({ job }: Props) {
// which loss series are enabled (default: all enabled)
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
const [isZoomed, setIsZoomed] = useState(false);
// keep enabled map in sync with discovered keys (enable new ones automatically)
useEffect(() => {
setEnabled(prev => {
@@ -104,7 +120,6 @@ export default function JobLossGraph({ job }: Props) {
for (const k of lossKeys) {
if (next[k] === undefined) next[k] = true;
}
// drop removed keys
for (const k of Object.keys(next)) {
if (!lossKeys.includes(k)) delete next[k];
}
@@ -114,200 +129,249 @@ export default function JobLossGraph({ job }: Props) {
const activeKeys = useMemo(() => lossKeys.filter(k => enabled[k] !== false), [lossKeys, enabled]);
// Zoom state for drag-to-zoom
const [zoomLeft, setZoomLeft] = useState<number | null>(null);
const [zoomRight, setZoomRight] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
// Selection tracked entirely in refs to avoid re-renders during drag
const selectStartLabel = useRef<number | null>(null);
const selectStartPx = useRef<number | null>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const chartWrapperRef = useRef<HTMLDivElement>(null);
const perSeries = useMemo(() => {
// Build per-series processed point arrays (raw + smoothed + fullSmooth), then merge by step for charting.
// Build uPlot-aligned data + series configs.
const built = useMemo(() => {
const stride = Math.max(1, plotStride | 0);
// smoothing%: 0 => no smoothing (alpha=1.0), 100 => heavy smoothing (alpha=0.02)
const t = clamp01(smoothing / 100);
const alpha = 1.0 - t * 0.98; // 1.0 -> 0.02
// Full smoothing overlay: always max smoothing (alpha=0.02)
const fullAlpha = 0.005;
const out: Record<string, { raw: { step: number; value: number }[]; smooth: { step: number; value: number }[]; fullSmooth: { step: number; value: number }[] }> =
{};
// Union of all steps across active series.
const stepSet = new Set<number>();
for (const key of activeKeys) {
const pts: LossPoint[] = series[key] ?? [];
for (const p of pts) {
if (p.value === null || !Number.isFinite(p.value as number)) continue;
if (useLogScale && (p.value as number) <= 0) continue;
stepSet.add(p.step);
}
}
let xs = Array.from(stepSet).sort((a, b) => a - b);
if (stride > 1) xs = xs.filter((_, i) => i % stride === 0);
if (windowSize > 0 && xs.length > windowSize) xs = xs.slice(xs.length - windowSize);
const xsSet = new Set(xs);
const data: (number[] | (number | null)[])[] = [xs];
const seriesConfigs: uPlot.Series[] = [{}]; // x
for (const key of activeKeys) {
const pts: LossPoint[] = series[key] ?? [];
let raw = pts
.filter(p => p.value !== null && Number.isFinite(p.value as number))
.map(p => ({ step: p.step, value: p.value as number }))
.filter(p => (useLogScale ? p.value > 0 : true))
.filter((_, idx) => idx % stride === 0);
// windowing (applies after stride)
if (windowSize > 0 && raw.length > windowSize) {
raw = raw.slice(raw.length - windowSize);
const map = new Map<number, number>();
for (const p of pts) {
if (p.value === null || !Number.isFinite(p.value as number)) continue;
if (useLogScale && (p.value as number) <= 0) continue;
if (!xsSet.has(p.step)) continue;
map.set(p.step, p.value as number);
}
const raw: (number | null)[] = xs.map(s => (map.has(s) ? (map.get(s) as number) : null));
const smooth = emaWithNulls(raw, alpha);
const fullSmooth = emaWithNulls(raw, fullAlpha);
const smooth = emaSmoothPoints(raw, alpha);
const fullSmooth = emaSmoothPoints(raw, fullAlpha);
const color = strokeForKey(key);
const colorFaded = color.replace('1)', '0.40)');
const colorDull = dulledColor(color);
out[key] = { raw, smooth, fullSmooth };
if (showRaw) {
data.push(raw);
seriesConfigs.push({
label: `${key} (raw)`,
stroke: colorFaded,
width: 1.25,
spanGaps: false,
points: { show: false },
});
}
if (showSmoothed) {
data.push(smooth);
seriesConfigs.push({
label: key,
stroke: color,
width: 2,
spanGaps: false,
points: { show: false },
});
}
data.push(fullSmooth);
seriesConfigs.push({
label: `${key} (trend)`,
stroke: colorDull,
width: 2.5,
spanGaps: false,
points: { show: false },
});
}
return out;
}, [series, activeKeys, smoothing, plotStride, windowSize, useLogScale]);
const chartData = useMemo(() => {
// Merge series into one array of objects keyed by step.
// Fields: `${key}__raw` and `${key}__smooth`
const map = new Map<number, any>();
for (const key of activeKeys) {
const s = perSeries[key];
if (!s) continue;
for (const p of s.raw) {
const row = map.get(p.step) ?? { step: p.step };
row[`${key}__raw`] = p.value;
map.set(p.step, row);
// y-domain clipping (2nd98th percentile of all visible y values).
let yClip: { min: number; max: number } | null = null;
if (clipOutliers && xs.length >= 10) {
const vals: number[] = [];
for (let s = 1; s < data.length; s++) {
const arr = data[s] as (number | null)[];
for (const v of arr) {
if (v !== null && Number.isFinite(v)) vals.push(v as number);
}
}
for (const p of s.smooth) {
const row = map.get(p.step) ?? { step: p.step };
row[`${key}__smooth`] = p.value;
map.set(p.step, row);
}
for (const p of s.fullSmooth) {
const row = map.get(p.step) ?? { step: p.step };
row[`${key}__fullsmooth`] = p.value;
map.set(p.step, row);
if (vals.length >= 10) {
vals.sort((a, b) => a - b);
const lo = vals[Math.floor(vals.length * 0.02)];
const hi = vals[Math.ceil(vals.length * 0.98) - 1];
if (Number.isFinite(lo) && Number.isFinite(hi) && lo !== hi) {
yClip = { min: lo, max: hi };
}
}
}
const arr = Array.from(map.values());
arr.sort((a, b) => a.step - b.step);
return arr;
}, [activeKeys, perSeries]);
return { data: data as uPlot.AlignedData, seriesConfigs, yClip };
}, [series, activeKeys, smoothing, plotStride, windowSize, useLogScale, showRaw, showSmoothed, clipOutliers]);
// Zoomed slice of chartData
const visibleData = useMemo(() => {
if (zoomLeft == null || zoomRight == null) return chartData;
const lo = Math.min(zoomLeft, zoomRight);
const hi = Math.max(zoomLeft, zoomRight);
return chartData.filter(d => d.step >= lo && d.step <= hi);
}, [chartData, zoomLeft, zoomRight]);
// Layout wrapper we measure for sizing — uPlot collapses its own mount node
// to width:min-content, so we can't read sizes off it.
const chartHostRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const uplotRef = useRef<uPlot | null>(null);
// Convert a pixel x within the wrapper to a fractional position [0,1] across the plot area
const pxToFraction = useCallback((clientX: number) => {
const wrapper = chartWrapperRef.current;
if (!wrapper) return 0;
const rect = wrapper.getBoundingClientRect();
// chart margin left (8) + yAxis width (72) = 80, margin right = 16
const plotLeft = 80;
const plotRight = rect.width - 16;
const plotWidth = plotRight - plotLeft;
const localX = clientX - rect.left;
return Math.max(0, Math.min(1, (localX - plotLeft) / plotWidth));
}, []);
const fractionToStep = useCallback((frac: number) => {
const data = visibleData;
if (data.length === 0) return 0;
const idx = Math.round(frac * (data.length - 1));
return data[Math.max(0, Math.min(data.length - 1, idx))].step;
}, [visibleData]);
// Native DOM events for drag selection
// Latest yClip read by the y-scale range fn — kept current via effect.
const yClipRef = useRef<{ min: number; max: number } | null>(null);
useEffect(() => {
const wrapper = chartWrapperRef.current;
if (!wrapper) return;
yClipRef.current = built.yClip;
}, [built.yClip]);
const onDown = (e: MouseEvent) => {
selectStartPx.current = e.clientX;
selectStartLabel.current = fractionToStep(pxToFraction(e.clientX));
setIsDragging(true);
if (overlayRef.current) overlayRef.current.style.display = 'none';
// Track zoom state via ref so the data-update effect can decide whether to refit scales.
const isZoomedRef = useRef(false);
useEffect(() => {
isZoomedRef.current = isZoomed;
}, [isZoomed]);
// Structural recreate key — recreate uPlot only when the series shape or
// axis distribution changes. Data updates go through setData.
const hasData = (built.data[0]?.length ?? 0) > 1;
const structuralKey = useMemo(
() => `${activeKeys.join('|')}|raw=${showRaw}|sm=${showSmoothed}|log=${useLogScale}|has=${hasData}`,
[activeKeys, showRaw, showSmoothed, useLogScale, hasData],
);
useEffect(() => {
if (uplotRef.current) {
uplotRef.current.destroy();
uplotRef.current = null;
}
if (!containerRef.current || !chartHostRef.current) return;
if (!hasData) return;
const host = chartHostRef.current;
const rect = host.getBoundingClientRect();
const initialHeight = rect.height > 0 ? Math.max(MIN_CANVAS_HEIGHT, rect.height - 40) : FALLBACK_CANVAS_HEIGHT;
const opts: uPlot.Options = {
width: rect.width || 800,
height: initialHeight,
padding: [12, 16, 0, 4],
series: built.seriesConfigs,
scales: {
x: { time: false },
y: {
distr: useLogScale ? 3 : 1,
range: (_u, dataMin, dataMax) => {
const c = yClipRef.current;
if (c) return [c.min, c.max];
return [dataMin, dataMax];
},
},
},
axes: [
{
stroke: 'rgba(255,255,255,0.55)',
grid: { stroke: 'rgba(255,255,255,0.06)' },
ticks: { stroke: 'rgba(255,255,255,0.15)' },
},
{
stroke: 'rgba(255,255,255,0.55)',
grid: { stroke: 'rgba(255,255,255,0.06)' },
ticks: { stroke: 'rgba(255,255,255,0.15)' },
size: 60,
values: (_u, ticks) => ticks.map(tk => formatNum(tk)),
},
],
cursor: {
drag: { x: true, y: false, setScale: true },
points: { size: 6 },
},
legend: { show: true },
hooks: {
setScale: [
(u, key) => {
if (key !== 'x') return;
const xs = u.data[0] as number[];
if (!xs || !xs.length) return;
const sx = u.scales.x;
const zoomed = sx.min !== xs[0] || sx.max !== xs[xs.length - 1];
setIsZoomed(zoomed);
},
],
},
};
const onMove = (e: MouseEvent) => {
if (selectStartPx.current == null) return;
const rect = wrapper.getBoundingClientRect();
const plotLeft = 80;
const plotRight = rect.width - 16;
const startLocal = selectStartPx.current - rect.left;
const curLocal = e.clientX - rect.left;
// Clamp to plot area
const clampedStart = Math.max(plotLeft, Math.min(plotRight, startLocal));
const clampedCur = Math.max(plotLeft, Math.min(plotRight, curLocal));
const left = Math.min(clampedStart, clampedCur);
const width = Math.abs(clampedCur - clampedStart);
if (overlayRef.current) {
overlayRef.current.style.display = width > 3 ? 'block' : 'none';
overlayRef.current.style.left = `${left}px`;
overlayRef.current.style.width = `${width}px`;
}
};
uplotRef.current = new uPlot(opts, built.data, containerRef.current);
setIsZoomed(false);
const onUp = (e: MouseEvent) => {
if (selectStartPx.current == null) return;
const startStep = selectStartLabel.current!;
const endStep = fractionToStep(pxToFraction(e.clientX));
selectStartPx.current = null;
selectStartLabel.current = null;
setIsDragging(false);
if (overlayRef.current) overlayRef.current.style.display = 'none';
if (startStep !== endStep) {
setZoomLeft(Math.min(startStep, endStep));
setZoomRight(Math.max(startStep, endStep));
}
};
// After uPlot mounts its legend, right-size the canvas against the actual
// legend height so the canvas fills the remaining vertical space.
const fitted = computeCanvasSize(host);
if (fitted) uplotRef.current.setSize(fitted);
wrapper.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
wrapper.removeEventListener('mousedown', onDown);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
uplotRef.current?.destroy();
uplotRef.current = null;
};
}, [pxToFraction, fractionToStep]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [structuralKey]);
// Push new data without recreating — preserves zoom & cursor state.
useEffect(() => {
const u = uplotRef.current;
if (!u) return;
// When zoomed, pass resetScales=false so the user's view stays put. uPlot
// skips its commit() in that branch though, so force a redraw to actually
// re-render the new smoothed/strided values within the zoom window.
if (isZoomedRef.current) {
u.setData(built.data, false);
u.redraw(true, true);
} else {
u.setData(built.data, true);
}
}, [built]);
// Resize observer — fit canvas to the wrapper's available space minus the
// HTML legend uPlot renders below it. Observe the layout wrapper, not the
// uPlot mount node (which uPlot pins to width:min-content).
// Re-runs on `hasData` because the observed element only exists once data
// has loaded (see the `!hasData` branch below).
useEffect(() => {
const el = chartHostRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
const u = uplotRef.current;
if (!u) return;
const fitted = computeCanvasSize(el);
if (fitted) u.setSize(fitted);
});
ro.observe(el);
return () => ro.disconnect();
}, [hasData]);
const handleResetZoom = useCallback(() => {
setZoomLeft(null);
setZoomRight(null);
const u = uplotRef.current;
if (!u) return;
const xs = u.data[0] as number[];
if (!xs || !xs.length) return;
u.setScale('x', { min: xs[0], max: xs[xs.length - 1] });
}, []);
const hasData = chartData.length > 1;
const isZoomed = zoomLeft != null && zoomRight != null;
const yDomain = useMemo((): [number | 'auto', number | 'auto'] => {
if (!clipOutliers || chartData.length < 10) return ['auto', 'auto'];
// Collect visible values (prefer smoothed if shown, else raw)
const vals: number[] = [];
for (const row of chartData) {
for (const key of activeKeys) {
const k = showSmoothed ? `${key}__smooth` : `${key}__raw`;
const v = row[k];
if (typeof v === 'number' && Number.isFinite(v)) vals.push(v);
}
}
if (vals.length < 10) return ['auto', 'auto'];
vals.sort((a, b) => a - b);
const lo = vals[Math.floor(vals.length * 0.02)];
const hi = vals[Math.ceil(vals.length * 0.98) - 1];
if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo === hi) return ['auto', 'auto'];
return [lo, hi];
}, [clipOutliers, chartData, activeKeys, showSmoothed]);
const totalPoints = built.data[0]?.length ?? 0;
return (
<div className="bg-gray-900 rounded-xl shadow-lg overflow-hidden border border-gray-800 flex flex-col">
<div className="bg-gray-800 px-4 py-3 flex items-center justify-between">
<div className="bg-gray-900 rounded-xl shadow-lg overflow-hidden border border-gray-800 flex flex-col h-full">
<div className="bg-gray-800 px-4 py-3 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-blue-400" />
<h2 className="text-gray-100 text-sm font-medium">Loss graph</h2>
@@ -315,7 +379,7 @@ export default function JobLossGraph({ job }: Props) {
{status === 'loading' && 'Loading...'}
{status === 'refreshing' && 'Refreshing...'}
{status === 'error' && 'Error'}
{status === 'success' && hasData && `${chartData.length.toLocaleString()} steps`}
{status === 'success' && hasData && `${totalPoints.toLocaleString()} steps`}
{status === 'success' && !hasData && 'No data yet'}
</span>
</div>
@@ -330,126 +394,36 @@ export default function JobLossGraph({ job }: Props) {
</div>
{/* Chart */}
<div className="px-4 pt-4 pb-4">
<div ref={chartWrapperRef} className="bg-gray-950 rounded-lg border border-gray-800 h-96 relative select-none">
{/* Drag selection overlay — positioned via refs, no re-renders */}
<div
ref={overlayRef}
style={{ display: 'none', position: 'absolute', top: 10, bottom: 10, pointerEvents: 'none', background: 'rgba(59,130,246,0.15)', border: '1px solid rgba(59,130,246,0.4)', zIndex: 5 }}
/>
<div className="px-4 pt-4 pb-4 flex-1 min-h-0 flex flex-col">
<div
className="bg-gray-950 rounded-lg border border-gray-800 relative select-none flex-1 min-h-0"
style={{ minHeight: 240 }}
>
{!hasData ? (
<div className="h-full w-full flex items-center justify-center text-sm text-gray-400">
<div className="absolute inset-0 flex items-center justify-center text-sm text-gray-400">
{status === 'error' ? 'Failed to load loss logs.' : 'Waiting for loss points...'}
</div>
) : (
<>
{isZoomed && (
<button
type="button"
onClick={handleResetZoom}
className="absolute top-2 right-2 z-10 px-2 py-1 rounded text-xs bg-blue-600/80 hover:bg-blue-600 text-white border border-blue-500/50"
>
Reset zoom
</button>
)}
<ResponsiveContainer width="100%" height="100%" style={isDragging ? { pointerEvents: 'none' } : undefined}>
<LineChart
data={visibleData}
margin={{ top: 10, right: 16, bottom: 10, left: 8 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
<XAxis
dataKey="step"
tick={{ fill: 'rgba(255,255,255,0.55)', fontSize: 12 }}
tickLine={{ stroke: 'rgba(255,255,255,0.15)' }}
axisLine={{ stroke: 'rgba(255,255,255,0.15)' }}
minTickGap={40}
/>
<YAxis
scale={useLogScale ? 'log' : 'linear'}
tick={{ fill: 'rgba(255,255,255,0.55)', fontSize: 12 }}
tickLine={{ stroke: 'rgba(255,255,255,0.15)' }}
axisLine={{ stroke: 'rgba(255,255,255,0.15)' }}
width={72}
tickFormatter={formatNum}
domain={yDomain}
allowDataOverflow={clipOutliers}
/>
{!isDragging && (
<Tooltip
cursor={{ stroke: 'rgba(59,130,246,0.25)', strokeWidth: 1 }}
contentStyle={{
background: 'rgba(17,24,39,0.96)',
border: '1px solid rgba(31,41,55,1)',
borderRadius: 10,
color: 'rgba(255,255,255,0.9)',
fontSize: 12,
}}
labelStyle={{ color: 'rgba(255,255,255,0.75)' }}
labelFormatter={(label: any) => `step ${label}`}
formatter={(value: any, name: any) => [formatNum(Number(value)), name]}
/>
)}
<Legend
wrapperStyle={{
paddingTop: 8,
color: 'rgba(255,255,255,0.7)',
fontSize: 12,
}}
/>
{/* Raw lines */}
{showRaw && activeKeys.map(k => (
<Line
key={`${k}__raw`}
type="monotone"
dataKey={`${k}__raw`}
name={`${k} (raw)`}
stroke={strokeForKey(k).replace('1)', '0.40)')}
strokeWidth={1.25}
dot={false}
isAnimationActive={false}
/>
))}
{/* Smoothed lines */}
{showSmoothed && activeKeys.map(k => (
<Line
key={`${k}__smooth`}
type="monotone"
dataKey={`${k}__smooth`}
name={`${k}`}
stroke={strokeForKey(k)}
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
))}
{/* Full-smooth trend overlay — rendered last so it's on top, hidden from legend/tooltip */}
{activeKeys.map(k => (
<Line
key={`${k}__fullsmooth`}
type="monotone"
dataKey={`${k}__fullsmooth`}
name={`${k}__fullsmooth`}
stroke={dulledColor(strokeForKey(k))}
strokeWidth={2.5}
dot={false}
isAnimationActive={false}
legendType="none"
tooltipType="none"
/>
))}
</LineChart>
</ResponsiveContainer>
{isZoomed && (
<button
type="button"
onClick={handleResetZoom}
className="absolute top-2 right-2 z-10 px-2 py-1 rounded text-xs bg-blue-600/80 hover:bg-blue-600 text-white border border-blue-500/50"
>
Reset zoom
</button>
)}
<div ref={chartHostRef} className="absolute top-0 left-0 right-0 bottom-2 overflow-hidden">
<div ref={containerRef} />
</div>
</>
)}
</div>
</div>
{/* Controls */}
<div className="px-4 pb-2">
<div className="px-4 pb-2 shrink-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="bg-gray-950 border border-gray-800 rounded-lg p-3">
<label className="block text-xs text-gray-400 mb-2">Display</label>
@@ -520,9 +494,31 @@ export default function JobLossGraph({ job }: Props) {
/>
<div className="mt-2 text-[11px] text-gray-500">UI downsample for huge runs.</div>
</div>
</div>
</div>
<style jsx global>{`
.uplot,
.uplot * {
font-family: inherit;
}
.uplot .u-legend {
color: rgba(255, 255, 255, 0.85);
font-size: 12px;
margin-top: 4px;
}
.uplot .u-legend th,
.uplot .u-legend td {
color: rgba(255, 255, 255, 0.85);
}
.uplot .u-legend .u-marker {
border-radius: 2px;
}
.uplot .u-select {
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.4);
}
`}</style>
</div>
);
}

1
ui/src/globals.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module '*.css';

View File

@@ -1 +1 @@
VERSION = "0.9.7"
VERSION = "0.9.8"