From d144cb5ea6ef0095ce8835373f674e519f878d7a Mon Sep 17 00:00:00 2001 From: Jaret Burkett Date: Thu, 7 May 2026 07:38:00 -0600 Subject: [PATCH] Switch to uplot for loss graph and rework performance of graph. It is significantly more perfromant now. --- ui/package-lock.json | 389 +------------------ ui/package.json | 2 +- ui/src/app/jobs/[jobID]/page.tsx | 2 +- ui/src/components/JobLossGraph.tsx | 574 ++++++++++++++--------------- ui/src/globals.d.ts | 1 + version.py | 2 +- 6 files changed, 297 insertions(+), 673 deletions(-) create mode 100644 ui/src/globals.d.ts diff --git a/ui/package-lock.json b/ui/package-lock.json index b9dce7d1..db13014f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index 62524ce2..e1a828b7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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" }, diff --git a/ui/src/app/jobs/[jobID]/page.tsx b/ui/src/app/jobs/[jobID]/page.tsx index db1baa0d..f257eb90 100644 --- a/ui/src/app/jobs/[jobID]/page.tsx +++ b/ui/src/app/jobs/[jobID]/page.tsx @@ -48,7 +48,7 @@ const pages: Page[] = [ value: 'loss_log', icon: MdShowChart, component: JobLossGraph, - mainCss: 'pt-24', + mainCss: 'pt-24 pb-4', jobTypes: ['train'], }, { diff --git a/ui/src/components/JobLossGraph.tsx b/ui/src/components/JobLossGraph.tsx index ad284937..a77a4d1c 100644 --- a/ui/src/components/JobLossGraph.tsx +++ b/ui/src/components/JobLossGraph.tsx @@ -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>({}); + 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(null); - const [zoomRight, setZoomRight] = useState(null); - const [isDragging, setIsDragging] = useState(false); - // Selection tracked entirely in refs to avoid re-renders during drag - const selectStartLabel = useRef(null); - const selectStartPx = useRef(null); - const overlayRef = useRef(null); - const chartWrapperRef = useRef(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 = - {}; + // Union of all steps across active series. + const stepSet = new Set(); + 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(); + 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(); - - 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 (2nd–98th 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(null); + const containerRef = useRef(null); + const uplotRef = useRef(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 ( -
-
+
+

Loss graph

@@ -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'}
@@ -330,126 +394,36 @@ export default function JobLossGraph({ job }: Props) {
{/* Chart */} -
-
- {/* Drag selection overlay — positioned via refs, no re-renders */} -
+
+
{!hasData ? ( -
+
{status === 'error' ? 'Failed to load loss logs.' : 'Waiting for loss points...'}
) : ( <> - {isZoomed && ( - - )} - - - - - - {!isDragging && ( - `step ${label}`} - formatter={(value: any, name: any) => [formatNum(Number(value)), name]} - /> - )} - - - - {/* Raw lines */} - {showRaw && activeKeys.map(k => ( - - ))} - {/* Smoothed lines */} - {showSmoothed && activeKeys.map(k => ( - - ))} - {/* Full-smooth trend overlay — rendered last so it's on top, hidden from legend/tooltip */} - {activeKeys.map(k => ( - - ))} - - - + {isZoomed && ( + + )} +
+
+
)}
{/* Controls */} -
+
@@ -520,9 +494,31 @@ export default function JobLossGraph({ job }: Props) { />
UI downsample for huge runs.
-
+ +
); } diff --git a/ui/src/globals.d.ts b/ui/src/globals.d.ts new file mode 100644 index 00000000..35306c6f --- /dev/null +++ b/ui/src/globals.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/version.py b/version.py index 73e5053a..70e84c5b 100644 --- a/version.py +++ b/version.py @@ -1 +1 @@ -VERSION = "0.9.7" +VERSION = "0.9.8"