diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 159b34ef..11ce8661 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -1,12 +1,12 @@ { "name": "mp_dashboard", - "version": "3.9.0", + "version": "3.9.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mp_dashboard", - "version": "3.9.0", + "version": "3.9.4", "dependencies": { "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-brands-svg-icons": "6.6.0", @@ -55,6 +55,7 @@ "react": "^18.3.1", "react-day-picker": "^8.10.2", "react-dom": "^18.3.1", + "react-grid-layout": "^1.5.0", "react-json-view-lite": "^2.5.0", "react-router-dom": "^6.30.4", "react-superstore": "^0.1.4", @@ -70,6 +71,7 @@ "@types/node": "^20.19.42", "@types/react": "^18.3.31", "@types/react-dom": "^18.3.7", + "@types/react-grid-layout": "^1.3.5", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@typescript-eslint/typescript-estree": "^8.10.0", @@ -83,8 +85,7 @@ "prettier-plugin-tailwindcss": "^0.5.14", "tailwindcss": "^3.4.19", "typescript": "^5.9.3", - "vite": "^5.4.21", - "vitest": "^2.1.9" + "vite": "^5.4.21" } }, "node_modules/@alloc/quick-lru": { @@ -2772,6 +2773,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-grid-layout": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.6.tgz", + "integrity": "sha512-Cw7+sb3yyjtmxwwJiXtEXcu5h4cgs+sCGkHwHXsFmPyV30bf14LeD/fa2LwQovuD2HWxCcjIdNhDlcYGj95qGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -3314,119 +3325,6 @@ "vite": "^4 || ^5 || ^6 || ^7" } }, - "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@xyflow/react": { "version": "12.11.0", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz", @@ -3595,16 +3493,6 @@ "node": ">=8" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3751,16 +3639,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3812,23 +3690,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3851,16 +3712,6 @@ "integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==", "license": "ISC" }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4279,16 +4130,6 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4443,13 +4284,6 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4723,16 +4557,6 @@ "node": ">=4.0" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4748,16 +4572,6 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5486,13 +5300,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lucide-react": { "version": "0.453.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.453.0.tgz", @@ -5502,16 +5309,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/mapbox-gl": { "version": "3.24.0", "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.24.0.tgz", @@ -5935,23 +5732,6 @@ "node": ">=8" } }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/pbf": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", @@ -6368,6 +6148,44 @@ "react": "^18.3.1" } }, + "node_modules/react-draggable": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.0.tgz", + "integrity": "sha512-kTpANmKWVnFXiZ76Ag2ZowiFStuBYnJ606PI1TbUsOg29/400/JNIxI9+CuenhiAqFuXWJffz6F4UI3R51kUug==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.3.tgz", + "integrity": "sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout/node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -6456,6 +6274,20 @@ } } }, + "node_modules/react-resizable": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz", + "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, "node_modules/react-router": { "version": "6.30.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", @@ -6622,6 +6454,12 @@ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "license": "MIT" }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -6803,13 +6641,6 @@ "node": ">=8" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -6845,20 +6676,6 @@ "integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==", "license": "MIT" }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -7051,20 +6868,6 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -7110,42 +6913,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyqueue": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7609,95 +7382,6 @@ } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7713,23 +7397,6 @@ "node": ">= 8" } }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 1a4ec5a7..2f03467a 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -59,6 +59,7 @@ "react": "^18.3.1", "react-day-picker": "^8.10.2", "react-dom": "^18.3.1", + "react-grid-layout": "^1.5.0", "react-json-view-lite": "^2.5.0", "react-router-dom": "^6.30.4", "react-superstore": "^0.1.4", @@ -74,6 +75,7 @@ "@types/node": "^20.19.42", "@types/react": "^18.3.31", "@types/react-dom": "^18.3.7", + "@types/react-grid-layout": "^1.3.5", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@typescript-eslint/typescript-estree": "^8.10.0", diff --git a/dashboard/src/components/Sidebar.tsx b/dashboard/src/components/Sidebar.tsx index 04b1a99a..9f059f45 100644 --- a/dashboard/src/components/Sidebar.tsx +++ b/dashboard/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { ChevronsUpDown, Flag, LayoutDashboard, + LayoutGrid, LucideIcon, Settings, } from "lucide-react"; @@ -298,6 +299,13 @@ const Sidebar = (props: SidebarProps) => { isSelected={props.selectedPage === "sessions"} isSidebarExpanded={props.isSidebarExpanded} /> +
diff --git a/dashboard/src/components/dashboards/AddWidgetDrawer.tsx b/dashboard/src/components/dashboards/AddWidgetDrawer.tsx new file mode 100644 index 00000000..b46747e7 --- /dev/null +++ b/dashboard/src/components/dashboards/AddWidgetDrawer.tsx @@ -0,0 +1,88 @@ +import { CHART_TYPES } from "@/components/signals/chartTypes"; +import type { ChartType } from "@/components/signals/ChartTypeToggle"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Called with the picked chart type; the dashboard creates a fresh + * `signal` widget seeded with that chart type. */ + onPick: (chartType: ChartType) => void; +} + +/** Right-edge drawer that lists every widget type the dashboard knows + * how to render. PR #1 ships only the signal-driven chart family; + * specialty widgets (gauge, big-number, dedicated map) join this list + * in PR #2 and get the same picker treatment. */ +export function AddWidgetDrawer({ open, onOpenChange, onPick }: Props) { + return ( + + + + Add widget + + Pick a chart type to drop on the dashboard. Every widget is + driven by an MQL query — you can edit it after. + + + +
+ + Charts + +
+ {CHART_TYPES.map((t) => ( + + ))} +
+
+
+
+ ); +} + +// One-line summary per chart type. Kept here (not on the registry) so +// the registry stays a thin data structure and the copy can be tuned +// without rewiring the rest of the codebase. +function describeChartType(t: ChartType): string { + switch (t) { + case "bar": + return "Counts or sums grouped into time buckets."; + case "line": + return "Continuous values over time."; + case "area": + return "Line chart filled under the curve — emphasizes totals."; + case "scatter": + return "Two signals as x/y pairs."; + case "path": + return "Two signals with connecting lines — good for GPS / trajectory."; + case "scatter3d": + return "Three signals as x/y/z pairs (orbit-controllable)."; + case "catbar": + return "Categorical aggregate — one bar per signal name."; + case "pie": + return "Share-of-total across categories."; + } +} diff --git a/dashboard/src/components/dashboards/DashboardWidgetCard.tsx b/dashboard/src/components/dashboards/DashboardWidgetCard.tsx new file mode 100644 index 00000000..b8dd011f --- /dev/null +++ b/dashboard/src/components/dashboards/DashboardWidgetCard.tsx @@ -0,0 +1,208 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { SignalWidget } from "@/components/signals/SignalWidget"; +import { cn } from "@/lib/utils"; +import { parseQuery } from "@/lib/query"; +import type { + DashboardWidget, + SignalWidgetConfig, +} from "@/models/dashboard"; +import type { Lap } from "@/models/session"; +import { GripVertical, Pencil, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import type { ChartType } from "@/components/signals/ChartTypeToggle"; + +interface Props { + widget: DashboardWidget; + vehicleId: string; + vehicleType: string; + signalNames: string[]; + startIso: string; + endIso: string; + rangeSeconds: number; + isRolling: boolean; + groupId: string; + laps?: Lap[] | null; + onRemove: () => void; + onConfigChange: (next: SignalWidgetConfig) => void; +} + +/** Widget shell — drag handle, title, edit, remove. Body renders the + * chart-only SignalWidget; clicking edit opens a dialog with the full + * builder. Card and dialog are mutually-exclusive (the card's + * SignalWidget unmounts while editing) so their internal query/chart + * state can never drift apart. */ +export function DashboardWidgetCard({ + widget, + vehicleId, + vehicleType, + signalNames, + startIso, + endIso, + rangeSeconds, + isRolling, + groupId, + laps, + onRemove, + onConfigChange, +}: Props) { + const [editing, setEditing] = useState(false); + + if (widget.type !== "signal") { + return ( + +
+ Unsupported widget type — update the dashboard to render it. +
+
+ ); + } + + const config = widget.config as SignalWidgetConfig; + const handleTitleChange = (title: string) => + onConfigChange({ ...config, title }); + const handleQueriesChange = (queries: string[]) => + onConfigChange({ ...config, queries }); + const handleChartTypeChange = (chart_type: ChartType) => + onConfigChange({ ...config, chart_type }); + + // Pull every `where(name = "...")` literal out of each query so the + // streaming subscription only sees signals the chart actually plots. + // Queries with no name filter get skipped here (subscribing to "*" + // would flood the wire). + const streamSignalPatterns = useMemo(() => { + const set = new Set(); + for (const mql of config.queries) { + const res = parseQuery(mql); + if (!res.ok) continue; + for (const p of res.query.filters) { + if (p.column !== "name" || p.op !== "=" || !p.value) continue; + set.add(p.value); + } + } + return Array.from(set); + }, [config.queries]); + + // Shared props every SignalWidget instance receives. Used twice — + // once for the card-chart, once for the edit-dialog editor. + const sharedSignalWidgetProps = { + vehicleId, + vehicleType, + signalNames, + startIso, + endIso, + rangeSeconds, + groupId, + hidden: false, + onToggleHide: () => undefined, + onDelete: onRemove, + onBrushSelect: () => undefined, + laps: laps ?? null, + seedQueries: config.queries, + onQueriesChange: handleQueriesChange, + seedChartType: (config.chart_type as ChartType | undefined) ?? "bar", + onChartTypeChange: handleChartTypeChange, + refreshIntervalSec: isRolling ? 5 : undefined, + streamSignalPatterns: isRolling ? streamSignalPatterns : undefined, + }; + + return ( + <> + setEditing(true)} + onRemove={onRemove} + > +
+ {/* Keying on the queries+chart_type causes a remount when the + dialog persists a change, so the card picks up the new + seed instead of being stuck on stale internal state. */} + {!editing && ( + + )} +
+
+ + + + + Edit widget + + {editing && ( + + )} + + + + ); +} + +function WidgetShell({ + title, + onTitleChange, + onEdit, + onRemove, + children, +}: { + title: string; + onTitleChange?: (next: string) => void; + onEdit?: () => void; + onRemove: () => void; + children: React.ReactNode; +}) { + return ( +
+
+ + {onTitleChange ? ( + onTitleChange(e.target.value)} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + placeholder="Untitled widget" + className={cn( + "h-7 flex-1 border-0 bg-transparent px-1 text-sm font-medium shadow-none focus-visible:ring-0", + )} + /> + ) : ( + {title} + )} + {onEdit && ( + + )} + +
+ {children} +
+ ); +} diff --git a/dashboard/src/components/signals/SignalWidget.tsx b/dashboard/src/components/signals/SignalWidget.tsx index 9b13ec47..7a7d68f9 100644 --- a/dashboard/src/components/signals/SignalWidget.tsx +++ b/dashboard/src/components/signals/SignalWidget.tsx @@ -68,6 +68,7 @@ import { } from "@/lib/query"; import { cn } from "@/lib/utils"; import { useDebouncedValue } from "@/lib/useDebouncedValue"; +import { useLiveTrigger } from "@/lib/useLiveTrigger"; import { ChevronDown, ChevronRight, @@ -83,7 +84,7 @@ import { Trash2, X, } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; type Interval = Rollup; @@ -207,6 +208,33 @@ export interface SignalWidgetProps { /** Laps of the currently-selected session, enabling the `lap` highlight * pseudo-variable + the "alternate by lap" shortcut. */ laps?: Lap[] | null; + /** Seed the initial query list — used when the widget is embedded in a + * saved dashboard so its persisted queries replace the default + * `count(signal.name)`. The local chip state still owns subsequent + * edits; the parent receives them via onQueriesChange. */ + seedQueries?: string[]; + /** Notify the parent every time the chip rows commit a change, so a + * dashboard widget can persist its config without polling. */ + onQueriesChange?: (queries: string[]) => void; + /** Seed the initial chart type. Same write-once-then-controlled-locally + * contract as seedQueries — the toolbar inside still owns subsequent + * changes, the parent observes via onChartTypeChange. */ + seedChartType?: ChartType; + onChartTypeChange?: (next: ChartType) => void; + /** Rolling-window refresh interval in seconds. When set, the widget + * re-runs its query every N seconds so the chart's right edge keeps + * tracking `now`. */ + refreshIntervalSec?: number; + /** Signal-name patterns to subscribe to via /live/sse. On each + * arrival the chart triggers a fresh /query/run, giving sub-second + * perceived latency without re-implementing aggregation client-side. + * Empty/undefined → no live subscription. */ + streamSignalPatterns?: string[]; + /** Suppress the query-builder header + chart-type picker, leaving + * only the chart canvas. Dashboards use this so the rendered widget + * is just the visualization; query edits happen in a dialog opened + * from the parent. */ + chartOnly?: boolean; } export function SignalWidget({ @@ -225,14 +253,28 @@ export function SignalWidget({ interactionMode, onInteractionModeChange, laps, + seedQueries, + onQueriesChange, + seedChartType, + onChartTypeChange, + refreshIntervalSec, + streamSignalPatterns, + chartOnly, }: SignalWidgetProps) { // Ordered list of MQL trace statements, classified at render via // `looksLikeFetchQuery`: fetch statements hit /query/run; expression // statements evaluate in-browser over the fetched base series. The chip rows // and the raw MQL editor are two views of this one list. - const [queries, setQueries] = useState([ - { id: newQueryId(), mql: "count(signal.name)" }, - ]); + // + // `seedQueries` lets a parent prefill the list at mount (dashboard + // widget restoring saved MQL); subsequent edits stay local and the + // parent observes via onQueriesChange. The seed is read-once on first + // render — later parent changes won't yank the user's caret mid-edit. + const [queries, setQueries] = useState(() => + seedQueries && seedQueries.length > 0 + ? seedQueries.map((mql) => ({ id: newQueryId(), mql })) + : [{ id: newQueryId(), mql: "count(signal.name)" }], + ); // While a row's field is focused, freeze its kind: `looksLikeFetchQuery` // flips at the `(`, and re-classifying mid-type would swap the input element // and yank the caret. Re-classified on blur. @@ -240,7 +282,22 @@ export function SignalWidget({ id: string; kind: "fetch" | "expr"; } | null>(null); - const [chartType, setChartType] = useState("bar"); + const [chartType, setChartType] = useState(seedChartType ?? "bar"); + + // Bubble query/chart changes up to the parent (dashboard widget) so it + // can persist them. Guarded against firing on the initial render. + const initialQueriesRef = useRef(queries); + useEffect(() => { + if (!onQueriesChange) return; + if (queries === initialQueriesRef.current) return; + onQueriesChange(queries.map((q) => q.mql)); + }, [queries, onQueriesChange]); + const initialChartTypeRef = useRef(chartType); + useEffect(() => { + if (!onChartTypeChange) return; + if (chartType === initialChartTypeRef.current) return; + onChartTypeChange(chartType); + }, [chartType, onChartTypeChange]); // Per-trace y-scaling, keyed by series label. Sparse; absent = default. const [axisSettings, setAxisSettings] = useState< Record @@ -316,14 +373,44 @@ export function SignalWidget({ () => fetchPlan.filter((p) => p.runnable), [fetchPlan], ); + // Rolling-window refresh has two drivers, OR'd into a single tick: + // 1. setInterval at refreshIntervalSec — guarantees the chart still + // moves forward during signal lulls. + // 2. useLiveTrigger SSE — bumps the moment fresh samples land, + // giving sub-second perceived latency under normal traffic. + // Both bumps fold into the same `refreshTick` so the historical fetch + // effect re-fires through its existing fetchKey path — the backend + // stays the source of truth for bucket math. + const [refreshTick, setRefreshTick] = useState(0); + useEffect(() => { + if (!refreshIntervalSec || refreshIntervalSec <= 0) return; + const t = setInterval( + () => setRefreshTick((n) => n + 1), + refreshIntervalSec * 1000, + ); + return () => clearInterval(t); + }, [refreshIntervalSec]); + const liveTrigger = useLiveTrigger({ + vehicleId, + signalPatterns: streamSignalPatterns ?? [], + enabled: Boolean(streamSignalPatterns && streamSignalPatterns.length > 0), + }); + useEffect(() => { + if (liveTrigger.tick === 0) return; + setRefreshTick((n) => n + 1); + }, [liveTrigger.tick]); + // Stable key over the wire form, so the fetch effect fires only on real change. const fetchKey = useMemo( () => JSON.stringify({ ids: runnableFetches.map((p) => `${p.id}:${p.mql}`), interval, + // Rolling-window tick: only contributes to the key when the + // parent opted in to refresh, otherwise stays a constant 0. + tick: refreshIntervalSec || streamSignalPatterns?.length ? refreshTick : 0, }), - [runnableFetches, interval], + [runnableFetches, interval, refreshIntervalSec, streamSignalPatterns, refreshTick], ); // Debounce so editing the MQL line doesn't fire /query/run per keystroke // (each response forces a synchronous re-render that drops typing). Timeframe/ @@ -679,7 +766,8 @@ export function SignalWidget({ }, [path, classified, chartType]); return ( - + + {!chartOnly && (
@@ -885,14 +973,18 @@ export function SignalWidget({
)} + )} {!hidden && ( - + {/* Chart-type lives here (not in the widget header) — it picks what the chart canvas renders, so it belongs with the - chart-content controls. */} + chart-content controls. Suppressed in chartOnly mode + (dashboards manage chart type via the edit dialog). */} + {!chartOnly && (
+ )} {path === "timeseries" && onInteractionModeChange && ( // Left-drag mode sits just above the chart so it's a short hop to // the gesture; "select" brushes a timeframe, "pan" slides the zoom. diff --git a/dashboard/src/components/ui/sheet.tsx b/dashboard/src/components/ui/sheet.tsx new file mode 100644 index 00000000..450fdecb --- /dev/null +++ b/dashboard/src/components/ui/sheet.tsx @@ -0,0 +1,105 @@ +// Side-anchored modal — a Dialog with translate-from-edge animation +// instead of the centered zoom. Used for the dashboard "Add widget" +// drawer; reusable for any right-edge panel that benefits from staying +// connected to a visible page background. + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Sheet = DialogPrimitive.Root; +const SheetTrigger = DialogPrimitive.Trigger; +const SheetClose = DialogPrimitive.Close; +const SheetPortal = DialogPrimitive.Portal; + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetOverlay.displayName = "SheetOverlay"; + +interface SheetContentProps + extends React.ComponentPropsWithoutRef { + /** Which edge to anchor against. Defaults to "right". */ + side?: "right" | "left"; +} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ className, children, side = "right", ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +SheetContent.displayName = "SheetContent"; + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = "SheetTitle"; + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = "SheetDescription"; + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +}; diff --git a/dashboard/src/lib/dashboards.ts b/dashboard/src/lib/dashboards.ts new file mode 100644 index 00000000..1ea2cd57 --- /dev/null +++ b/dashboard/src/lib/dashboards.ts @@ -0,0 +1,87 @@ +// HTTP client for the dashboards CRUD surface. The gateway wraps each +// response once under `data`, so Go endpoints (bare body) read as +// `response.data.data` — matches the convention sessions/api.ts uses. + +import { BACKEND_URL } from "@/consts/config"; +import { http } from "@/lib/http"; +import type { + Dashboard, + DashboardWidget, + WidgetConfig, + WidgetType, +} from "@/models/dashboard"; + +export async function fetchDashboards(): Promise { + const r = await http.get(`${BACKEND_URL}/dashboards`); + return (r.data?.data as Dashboard[]) ?? []; +} + +export async function fetchDashboard(id: string): Promise { + const r = await http.get(`${BACKEND_URL}/dashboards/${id}`); + return r.data?.data as Dashboard; +} + +export async function createDashboard(input: { + name: string; + description?: string; +}): Promise { + const r = await http.post(`${BACKEND_URL}/dashboards`, { + name: input.name, + description: input.description ?? "", + }); + return r.data?.data as Dashboard; +} + +export async function updateDashboard( + id: string, + patch: { name?: string; description?: string }, +): Promise { + // The backend's UpdateDashboard preserves CreatedBy/CreatedAt; we only + // need to send the mutable fields. + const r = await http.put(`${BACKEND_URL}/dashboards/${id}`, patch); + return r.data?.data as Dashboard; +} + +export async function deleteDashboard(id: string): Promise { + await http.delete(`${BACKEND_URL}/dashboards/${id}`); +} + +export async function createWidget( + dashboardID: string, + input: { + type: WidgetType; + config: WidgetConfig; + x: number; + y: number; + w: number; + h: number; + }, +): Promise { + const r = await http.post( + `${BACKEND_URL}/dashboards/${dashboardID}/widgets`, + input, + ); + return r.data?.data as DashboardWidget; +} + +// Drag/resize fires UpdateWidget on every release. The grid is the +// source of truth for x/y/w/h; the page debounces config-only changes +// so each chip edit isn't its own PUT. +export async function updateWidget( + dashboardID: string, + widgetID: string, + widget: Partial, +): Promise { + const r = await http.put( + `${BACKEND_URL}/dashboards/${dashboardID}/widgets/${widgetID}`, + widget, + ); + return r.data?.data as DashboardWidget; +} + +export async function deleteWidget( + dashboardID: string, + widgetID: string, +): Promise { + await http.delete(`${BACKEND_URL}/dashboards/${dashboardID}/widgets/${widgetID}`); +} diff --git a/dashboard/src/lib/useLiveTrigger.ts b/dashboard/src/lib/useLiveTrigger.ts new file mode 100644 index 00000000..77a226e9 --- /dev/null +++ b/dashboard/src/lib/useLiveTrigger.ts @@ -0,0 +1,108 @@ +// Opens an SSE subscription to the live service and surfaces a monotonic +// `tick` that bumps every time fresh samples arrive (throttled to one bump +// per `throttleMs`). Consumers include `tick` in their query/fetch key so +// the data layer re-pulls from /query/run as soon as new signals land. +// +// The hook does NOT do any client-side aggregation — the backend stays the +// source of truth for bucket math (which is gnarly to mirror correctly +// across all the MQL aggregators). SSE is purely a wake-up signal so the +// dashboard's rolling-window chart updates in near-real-time instead of +// the slower setInterval polling cadence. +// +// const { tick } = useLiveTrigger({ +// vehicleId, +// signalPatterns: ["ecu_acc_pedal", "ecu_*"], +// enabled: isRolling, +// throttleMs: 500, +// }); +// +// Empty signalPatterns or enabled=false means "do nothing"; the hook +// returns tick=0 and never opens a connection. + +import { useEffect, useRef, useState } from "react"; +import { BACKEND_URL } from "@/consts/config"; + +interface UseLiveTriggerArgs { + vehicleId: string; + /** Signal names (or globs — same syntax `/live/sse?signals=` accepts). */ + signalPatterns: string[]; + /** When false the hook is a no-op (no connection, no tick bumps). */ + enabled: boolean; + /** Minimum gap between consecutive tick bumps. A high-rate signal + * storm would otherwise flood downstream effects with refetches. */ + throttleMs?: number; +} + +export interface UseLiveTriggerResult { + /** Monotonic counter. Increments on each throttled batch of samples. */ + tick: number; + /** "open" once the SSE handshake completes; "error" on dropped + * connection. Surfaces in the parent's status indicator if it + * wants to show "live" / "live offline" badges. */ + status: "idle" | "open" | "error"; +} + +export function useLiveTrigger({ + vehicleId, + signalPatterns, + enabled, + throttleMs = 500, +}: UseLiveTriggerArgs): UseLiveTriggerResult { + const [tick, setTick] = useState(0); + const [status, setStatus] = useState<"idle" | "open" | "error">("idle"); + + // Stable key for the patterns so identical sets across renders don't + // tear down + reopen the SSE. Sort to absorb caller ordering drift. + const patternsKey = signalPatterns + .slice() + .sort() + .join(","); + + // Track the last tick-bump time so a high-rate signal storm doesn't + // flood the downstream refetch effect. Kept in a ref so the throttle + // doesn't reset across renders. + const lastBumpRef = useRef(0); + + useEffect(() => { + if (!enabled) { + setStatus("idle"); + return; + } + if (!vehicleId || !patternsKey) { + setStatus("idle"); + return; + } + + const url = new URL(`${BACKEND_URL}/live/sse`, window.location.origin); + url.searchParams.set("vehicle_id", vehicleId); + url.searchParams.set("signals", patternsKey); + // `backfill=0` because we just pulled the historical query — we + // don't want SSE to replay the same window we already rendered. + url.searchParams.set("backfill", "0"); + + const es = new EventSource(url.toString()); + + es.addEventListener("open", () => setStatus("open")); + es.addEventListener("error", () => setStatus("error")); + + const onSignal = () => { + const now = Date.now(); + if (now - lastBumpRef.current < throttleMs) return; + lastBumpRef.current = now; + setTick((n) => n + 1); + }; + // The live service emits two named event types: `backfill` (initial, + // possibly empty) and `signal` (each new sample). The backfill event + // is intentionally ignored — historical data is already on the + // chart from /query/run. + es.addEventListener("signal", onSignal); + + return () => { + es.removeEventListener("signal", onSignal); + es.close(); + setStatus("idle"); + }; + }, [enabled, vehicleId, patternsKey, throttleMs]); + + return { tick, status }; +} diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx index 6f2eb11f..32a2addd 100644 --- a/dashboard/src/main.tsx +++ b/dashboard/src/main.tsx @@ -21,6 +21,8 @@ import DebugPage from "@/pages/debug/DebugPage.tsx"; import SessionsPage from "@/pages/sessions/SessionsPage.tsx"; import SessionDetailPage from "@/pages/sessions/SessionDetailPage.tsx"; import SessionEditorPage from "@/pages/sessions/SessionEditorPage.tsx"; +import DashboardsPage from "@/pages/dashboards/DashboardsPage.tsx"; +import DashboardDetailsPage from "@/pages/dashboards/DashboardDetailsPage.tsx"; import { useRoseMode } from "@/lib/store"; import { useEffect } from "react"; @@ -81,6 +83,14 @@ const router = createBrowserRouter([ path: "/sessions/:id/edit", element: , }, + { + path: "/dashboards", + element: , + }, + { + path: "/dashboards/:id", + element: , + }, ]); ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/dashboard/src/models/dashboard.ts b/dashboard/src/models/dashboard.ts new file mode 100644 index 00000000..b538f811 --- /dev/null +++ b/dashboard/src/models/dashboard.ts @@ -0,0 +1,66 @@ +// Mirrors mapache-go's Dashboard + DashboardWidget. Field names are +// snake_case on the wire and camelCase nowhere — the dashboard fetches +// the raw JSON and consumes it without aliasing, so keep these shapes +// byte-identical to the Go structs. + +export interface DashboardWidget { + id: string; + dashboard_id: string; + // Widget renderer key. `signal` is the only one shipped in PR #1; + // future types (`gauge`, `map`, `bignumber`, `table`) plug into the + // same registry. + type: WidgetType; + // Type-specific settings (e.g. for `signal`: queries[], chart_type, + // axis overrides). Stored as jsonb on the backend. + config: WidgetConfig; + // react-grid-layout coordinates. The dashboard grid is 12 cols wide; + // h/w are in cell units, x/y are zero-based. + x: number; + y: number; + w: number; + h: number; + updated_at: string; + created_at: string; +} + +export type WidgetType = "signal"; + +// Each widget renderer carries its own config shape. The signal widget +// stores the MQL statements + chart-type + a couple of display knobs; +// future widget types will widen this union. +export type WidgetConfig = SignalWidgetConfig; + +export interface SignalWidgetConfig { + // Title shown in the widget's header. Empty falls back to "Untitled". + title?: string; + // MQL statements (one per line in the chip editor). At least one. + queries: string[]; + // Chart-type key from the existing chartTypes registry (bar, line, + // area, scatter, …). Defaults to "bar" if missing. + chart_type?: string; +} + +export interface Dashboard { + id: string; + name: string; + description: string; + created_by: string; + widgets: DashboardWidget[]; + updated_at: string; + created_at: string; +} + +export const initDashboard: Dashboard = { + id: "", + name: "", + description: "", + created_by: "", + widgets: [], + updated_at: "", + created_at: "", +}; + +// Default coordinates for a freshly-added widget. The grid is 12 cols +// wide; w=6 / h=8 gives a half-width chart that's tall enough to read. +// Placement (x, y) is set by the page based on whatever's already there. +export const DEFAULT_WIDGET_SIZE = { w: 6, h: 8 } as const; diff --git a/dashboard/src/pages/dashboards/DashboardDetailsPage.tsx b/dashboard/src/pages/dashboards/DashboardDetailsPage.tsx new file mode 100644 index 00000000..2f654d7b --- /dev/null +++ b/dashboard/src/pages/dashboards/DashboardDetailsPage.tsx @@ -0,0 +1,366 @@ +import Layout from "@/components/Layout"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { OutlineButton } from "@/components/ui/outline-button"; +import { getAxiosErrorMessage } from "@/lib/axios-error-handler"; +import { BACKEND_URL } from "@/consts/config"; +import { + createWidget, + deleteWidget, + fetchDashboard, + updateDashboard, + updateWidget, +} from "@/lib/dashboards"; +import { notify } from "@/lib/notify"; +import { cn } from "@/lib/utils"; +import { useVehicle } from "@/lib/store"; +import { + DEFAULT_WIDGET_SIZE, + type Dashboard, + type DashboardWidget, + type SignalWidgetConfig, +} from "@/models/dashboard"; +import axios from "axios"; +import { ArrowLeft, Loader2, Plus } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import GridLayout, { type Layout as RGLLayout } from "react-grid-layout"; +import { useNavigate, useParams } from "react-router-dom"; +import "react-grid-layout/css/styles.css"; +import "react-resizable/css/styles.css"; +import { DashboardWidgetCard } from "@/components/dashboards/DashboardWidgetCard"; +import { AddWidgetDrawer } from "@/components/dashboards/AddWidgetDrawer"; +import { + defaultTimeframe, + type Timeframe, + TimeframePicker, +} from "@/components/signals/TimeframePicker"; +import type { ChartType } from "@/components/signals/ChartTypeToggle"; + +const GRID_COLS = 12; +const ROW_HEIGHT = 30; +const MARGIN: [number, number] = [12, 12]; + +const SYNC_GROUP_ID = "dashboard-widgets"; + +function DashboardDetailsPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const vehicle = useVehicle(); + const [dashboard, setDashboard] = useState(null); + const [loading, setLoading] = useState(true); + const [gridWidth, setGridWidth] = useState(1200); + // Page-level timeframe — every widget plots the same window. Will + // eventually allow per-widget override; for now the dashboard reads + // as one cohesive view. + const [timeframe, setTimeframe] = useState(defaultTimeframe); + // Cached signal-name list for the active vehicle, used by the chip + // builder's autocomplete inside each widget. + const [signalNames, setSignalNames] = useState([]); + const [addOpen, setAddOpen] = useState(false); + + useEffect(() => { + if (!vehicle?.id) return; + let cancelled = false; + axios + .get(`${BACKEND_URL}/query/signals`, { + headers: { + Authorization: `Bearer ${localStorage.getItem("sentinel_access_token")}`, + }, + params: { vehicle_id: vehicle.id }, + }) + .then((res) => { + if (cancelled) return; + const rows = res.data?.data?.data ?? res.data?.data ?? []; + setSignalNames(rows.map((r: { name: string }) => r.name)); + }) + .catch(() => { + // Autocomplete is non-blocking — failure just means the + // dropdown shows nothing. The chart still runs. + }); + return () => { + cancelled = true; + }; + }, [vehicle?.id]); + + // Resolve the timeframe to the iso strings + range every widget needs. + // "Rolling" mode = the window's right edge tracks `now`; that's true + // for every Past-N preset and for any custom range whose end is within + // a few seconds of now. Live-blending widgets watch this flag. + const { startIso, endIso, rangeSeconds, isRolling } = useMemo(() => { + const start = timeframe.start.toISOString(); + const end = timeframe.end.toISOString(); + const range = Math.max( + 1, + Math.round((timeframe.end.getTime() - timeframe.start.getTime()) / 1000), + ); + const rolling = + timeframe.label.startsWith("Past ") || + Math.abs(timeframe.end.getTime() - Date.now()) < 5_000; + return { startIso: start, endIso: end, rangeSeconds: range, isRolling: rolling }; + }, [timeframe]); + + // Track the rendered area's width so the grid sizes columns to the + // container, not the viewport — sidebars and padding both eat real estate. + useEffect(() => { + const measure = () => { + const el = document.getElementById("dashboard-grid-host"); + if (el) setGridWidth(el.clientWidth); + }; + measure(); + const ro = new ResizeObserver(measure); + const el = document.getElementById("dashboard-grid-host"); + if (el) ro.observe(el); + window.addEventListener("resize", measure); + return () => { + ro.disconnect(); + window.removeEventListener("resize", measure); + }; + }, [dashboard]); + + const reload = useCallback(async () => { + if (!id) return; + setLoading(true); + try { + setDashboard(await fetchDashboard(id)); + } catch (e) { + notify.error(getAxiosErrorMessage(e)); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + reload(); + }, [reload]); + + const handleRename = async (name: string) => { + if (!dashboard || name === dashboard.name) return; + try { + const next = await updateDashboard(dashboard.id, { name }); + setDashboard((d) => (d ? { ...d, ...next } : d)); + } catch (e) { + notify.error(getAxiosErrorMessage(e)); + } + }; + + // react-grid-layout calls onLayoutChange on every drag/resize commit. + // The widget IDs are stable so the layout array maps cleanly back to + // our widget rows; fire one PUT per widget whose coords actually moved. + const handleLayoutChange = async (layout: RGLLayout[]) => { + if (!dashboard) return; + const byId = new Map(layout.map((l) => [l.i, l] as const)); + const moved: DashboardWidget[] = []; + for (const w of dashboard.widgets) { + const l = byId.get(w.id); + if (!l) continue; + if (l.x === w.x && l.y === w.y && l.w === w.w && l.h === w.h) continue; + moved.push({ ...w, x: l.x, y: l.y, w: l.w, h: l.h }); + } + if (moved.length === 0) return; + // Optimistic update — assume the PUT succeeds. A failure logs but + // doesn't roll back the grid; the user can resize again to retry. + setDashboard((d) => + d + ? { + ...d, + widgets: d.widgets.map( + (w) => moved.find((m) => m.id === w.id) ?? w, + ), + } + : d, + ); + try { + await Promise.all( + moved.map((w) => updateWidget(dashboard.id, w.id, w)), + ); + } catch (e) { + notify.error(getAxiosErrorMessage(e)); + } + }; + + const handleAddSignalWidget = async (chartType: ChartType = "bar") => { + if (!dashboard) return; + const config: SignalWidgetConfig = { + title: "New widget", + queries: ["count(signal.name)"], + chart_type: chartType, + }; + // Place new widgets at the bottom of the current layout so they + // never overlap. y = max(y + h) across existing widgets. + const yBottom = dashboard.widgets.reduce( + (m, w) => Math.max(m, w.y + w.h), + 0, + ); + try { + const w = await createWidget(dashboard.id, { + type: "signal", + config, + x: 0, + y: yBottom, + w: DEFAULT_WIDGET_SIZE.w, + h: DEFAULT_WIDGET_SIZE.h, + }); + setDashboard((d) => (d ? { ...d, widgets: [...d.widgets, w] } : d)); + } catch (e) { + notify.error(getAxiosErrorMessage(e)); + } + }; + + const handleRemoveWidget = async (widgetID: string) => { + if (!dashboard) return; + try { + await deleteWidget(dashboard.id, widgetID); + setDashboard((d) => + d ? { ...d, widgets: d.widgets.filter((w) => w.id !== widgetID) } : d, + ); + } catch (e) { + notify.error(getAxiosErrorMessage(e)); + } + }; + + const handleUpdateWidgetConfig = async ( + widgetID: string, + config: SignalWidgetConfig, + ) => { + if (!dashboard) return; + const existing = dashboard.widgets.find((w) => w.id === widgetID); + if (!existing) return; + const next = { ...existing, config }; + setDashboard((d) => + d + ? { ...d, widgets: d.widgets.map((w) => (w.id === widgetID ? next : w)) } + : d, + ); + try { + await updateWidget(dashboard.id, widgetID, next); + } catch (e) { + notify.error(getAxiosErrorMessage(e)); + } + }; + + const layout = useMemo( + () => + dashboard?.widgets.map((w) => ({ + i: w.id, + x: w.x, + y: w.y, + w: w.w, + h: w.h, + minW: 2, + minH: 3, + })) ?? [], + [dashboard], + ); + + if (loading) { + return ( + +
+ +
+
+ ); + } + + if (!dashboard) { + return ( + + + Dashboard not found. + + + ); + } + + return ( + +
+
+
+ + handleRename(e.target.value)} + className="h-8 max-w-[360px] text-lg font-semibold" + placeholder="Untitled dashboard" + /> +
+
+ + setAddOpen(true)}> + + Add widget + +
+
+ + { + setAddOpen(false); + handleAddSignalWidget(chartType); + }} + /> + +
+ {dashboard.widgets.length === 0 ? ( + +

Empty dashboard.

+

+ Add a signal widget to start charting. Drag the title bar to + move, resize from any edge. +

+
+ ) : ( + + {dashboard.widgets.map((w) => ( +
+ handleRemoveWidget(w.id)} + onConfigChange={(config) => + handleUpdateWidgetConfig(w.id, config) + } + /> +
+ ))} +
+ )} +
+
+
+ ); +} + +export default DashboardDetailsPage; diff --git a/dashboard/src/pages/dashboards/DashboardsPage.tsx b/dashboard/src/pages/dashboards/DashboardsPage.tsx new file mode 100644 index 00000000..2737eb7a --- /dev/null +++ b/dashboard/src/pages/dashboards/DashboardsPage.tsx @@ -0,0 +1,201 @@ +import Layout from "@/components/Layout"; +import { Card } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { OutlineButton } from "@/components/ui/outline-button"; +import { getAxiosErrorMessage } from "@/lib/axios-error-handler"; +import { + createDashboard, + deleteDashboard, + fetchDashboards, +} from "@/lib/dashboards"; +import { notify } from "@/lib/notify"; +import type { Dashboard } from "@/models/dashboard"; +import { Plus, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +function DashboardsPage() { + const navigate = useNavigate(); + const [dashboards, setDashboards] = useState([]); + const [createOpen, setCreateOpen] = useState(false); + + useEffect(() => { + reload(); + }, []); + + const reload = async () => { + try { + setDashboards(await fetchDashboards()); + } catch (e) { + notify.error(getAxiosErrorMessage(e)); + } + }; + + const handleDelete = async (d: Dashboard) => { + // Plain confirm() is enough here — destructive but trivial to + // recreate, and we don't have a confirm-dialog pattern elsewhere + // on this page yet. + if (!confirm(`Delete "${d.name || "Untitled"}"? This can't be undone.`)) return; + try { + await deleteDashboard(d.id); + notify.success("Dashboard deleted"); + reload(); + } catch (e) { + notify.error(getAxiosErrorMessage(e)); + } + }; + + return ( + +
+
+

+ Custom widget grids over signal data. Drag, resize, save. +

+ + + + + New dashboard + + + { + setCreateOpen(false); + navigate(`/dashboards/${d.id}`); + }} + /> + +
+ + {dashboards.length === 0 ? ( + +

No dashboards yet.

+

+ Create one to start adding charts, gauges, and other widgets. +

+
+ ) : ( +
+ {dashboards.map((d) => ( + navigate(`/dashboards/${d.id}`)} + onDelete={() => handleDelete(d)} + /> + ))} +
+ )} +
+
+ ); +} + +function DashboardCard({ + dashboard, + onOpen, + onDelete, +}: { + dashboard: Dashboard; + onOpen: () => void; + onDelete: () => void; +}) { + return ( + +
+

+ {dashboard.name || "Untitled dashboard"} +

+ +
+ {dashboard.description ? ( +

+ {dashboard.description} +

+ ) : null} +

+ Updated {new Date(dashboard.updated_at).toLocaleString()} +

+
+ ); +} + +function CreateDashboardDialog({ + onCreated, +}: { + onCreated: (d: Dashboard) => void; +}) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (submitting) return; + setSubmitting(true); + try { + const d = await createDashboard({ name, description }); + onCreated(d); + } catch (err) { + notify.error(getAxiosErrorMessage(err)); + setSubmitting(false); + } + }; + + return ( + + + New dashboard + +
+
+ + setName(e.target.value)} + placeholder="GR26 race telemetry" + required + autoFocus + /> +
+
+ + setDescription(e.target.value)} + placeholder="What this dashboard is for" + /> +
+ + {submitting ? "Creating…" : "Create"} + +
+
+ ); +} + +export default DashboardsPage; diff --git a/kerbecs.yaml b/kerbecs.yaml index a2d41ebb..94667a22 100644 --- a/kerbecs.yaml +++ b/kerbecs.yaml @@ -131,6 +131,22 @@ routes: strip_prefix: /api envelope: default + - name: dashboards + match: + path: /api/dashboards + upstream: vehicle + rewrite: + strip_prefix: /api + envelope: default + + - name: dashboards-id + match: + path: /api/dashboards/* + upstream: vehicle + rewrite: + strip_prefix: /api + envelope: default + - name: vehicle-types match: path: /api/vehicle-types diff --git a/mapache-go/dashboard.go b/mapache-go/dashboard.go new file mode 100644 index 00000000..87236b41 --- /dev/null +++ b/mapache-go/dashboard.go @@ -0,0 +1,51 @@ +package mapache + +import "time" + +// Dashboard is a user-curated collection of widgets arranged on a grid. +// Widgets are stored in a separate `dashboard_widget` table and joined +// via DashboardID; the loader populates Widgets here for API responses. +type Dashboard struct { + ID string `json:"id" gorm:"primaryKey"` + Name string `json:"name"` + Description string `json:"description"` + // CreatedBy is the entity_id of the dashboard creator. Recorded for + // audit; access is currently global (any bearer-authenticated user + // can read or edit). + CreatedBy string `json:"created_by"` + Widgets []DashboardWidget `json:"widgets" gorm:"-"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;precision:6"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;precision:6"` +} + +func (Dashboard) TableName() string { + return "dashboard" +} + +// DashboardWidget is one rectangular pane on a dashboard. Type drives +// which renderer the frontend mounts; Config carries the renderer's +// type-specific settings as JSON (queries, chart type, axis overrides, +// color, etc.). X/Y/W/H are react-grid-layout coordinates. +type DashboardWidget struct { + ID string `json:"id" gorm:"primaryKey"` + DashboardID string `json:"dashboard_id" gorm:"index"` + // Type is the widget renderer key — e.g. "signal" for the MQL-driven + // chart, "gauge" for a single-value dial, "map" for a GPS trace. + // Unknown types render as a placeholder so a stale frontend + // doesn't crash on a newer backend's data. + Type string `json:"type"` + Config JSON `json:"config" gorm:"type:jsonb;default:'{}'"` + // react-grid-layout cell coordinates. The grid is 12 columns wide; + // rows are measured in "row units" (configured client-side, typically + // ~30px). H/W are in cells, X/Y are zero-based. + X int `json:"x"` + Y int `json:"y"` + W int `json:"w"` + H int `json:"h"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;precision:6"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;precision:6"` +} + +func (DashboardWidget) TableName() string { + return "dashboard_widget" +} diff --git a/query/uv.lock b/query/uv.lock index 5ed9f582..ccfc3ad4 100644 --- a/query/uv.lock +++ b/query/uv.lock @@ -346,34 +346,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - [[package]] name = "idna" version = "3.11" @@ -383,15 +355,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - [[package]] name = "loguru" version = "0.7.3" @@ -515,15 +478,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, ] -[[package]] -name = "packaging" -version = "26.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, -] - [[package]] name = "pandas" version = "3.0.3" @@ -576,15 +530,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, ] -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - [[package]] name = "psycopg2-binary" version = "2.9.11" @@ -721,15 +666,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - [[package]] name = "pyjwt" version = "2.12.1" @@ -744,22 +680,6 @@ crypto = [ { name = "cryptography" }, ] -[[package]] -name = "pytest" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, -] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -792,7 +712,7 @@ wheels = [ [[package]] name = "query" -version = "3.9.0" +version = "3.9.4" source = { editable = "." } dependencies = [ { name = "clickhouse-connect" }, @@ -810,43 +730,22 @@ dependencies = [ { name = "uvicorn" }, ] -[package.optional-dependencies] -dev = [ - { name = "httpx" }, - { name = "pytest" }, -] - -[package.dev-dependencies] -dev = [ - { name = "httpx" }, - { name = "pytest" }, -] - [package.metadata] requires-dist = [ { name = "clickhouse-connect", specifier = ">=0.8.0,<0.9.0" }, { name = "dotenv", specifier = ">=0.9.9,<0.10.0" }, { name = "fastapi", specifier = ">=0.115.10,<0.116.0" }, { name = "gr-ulid", specifier = ">=1.1.2,<2.0.0" }, - { name = "httpx", marker = "extra == 'dev'" }, { name = "loguru", specifier = ">=0.7.3,<0.8.0" }, { name = "mapache-py", specifier = ">=3.0.1,<4.0.0" }, { name = "numpy", specifier = ">=2.4.6" }, { name = "pandas", specifier = ">=3.0.3" }, { name = "psycopg2-binary", specifier = ">=2.9.10,<3.0.0" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.11.0,<3.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0,<9" }, { name = "requests", specifier = ">=2.32.3,<3.0.0" }, { name = "sqlalchemy", specifier = ">=2.0.38,<3.0.0" }, { name = "uvicorn", specifier = ">=0.34.0,<0.35.0" }, ] -provides-extras = ["dev"] - -[package.metadata.requires-dev] -dev = [ - { name = "httpx" }, - { name = "pytest", specifier = ">=8.0,<9" }, -] [[package]] name = "requests" diff --git a/vehicle/api/api.go b/vehicle/api/api.go index d8943078..db67c2b5 100644 --- a/vehicle/api/api.go +++ b/vehicle/api/api.go @@ -67,6 +67,18 @@ func InitializeRoutes(router *gin.Engine) { router.GET("/sessions/:sessionID/laps", GetLapsForSession) router.PUT("/sessions/:sessionID/laps", ReplaceLapsForSession) + // Dashboards: user-curated grids of widgets driven by the MQL query + // language. Widget endpoints are nested under their dashboard so the + // react-grid-layout drag/resize PUT can hit a stable path per widget. + router.GET("/dashboards", GetAllDashboards) + router.POST("/dashboards", CreateDashboard) + router.GET("/dashboards/:dashboardID", GetDashboardByID) + router.PUT("/dashboards/:dashboardID", UpdateDashboard) + router.DELETE("/dashboards/:dashboardID", DeleteDashboard) + router.POST("/dashboards/:dashboardID/widgets", CreateWidget) + router.PUT("/dashboards/:dashboardID/widgets/:widgetID", UpdateWidget) + router.DELETE("/dashboards/:dashboardID/widgets/:widgetID", DeleteWidget) + router.GET("/vehicle-types", GetVehicleTypes) // Config flag definitions (per vehicle type). diff --git a/vehicle/api/dashboard.go b/vehicle/api/dashboard.go new file mode 100644 index 00000000..0a7bec48 --- /dev/null +++ b/vehicle/api/dashboard.go @@ -0,0 +1,116 @@ +package api + +import ( + "net/http" + + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + "github.com/gaucho-racing/mapache/vehicle/service" + ulid "github.com/gaucho-racing/ulid-go" + "github.com/gin-gonic/gin" +) + +func GetAllDashboards(c *gin.Context) { + c.JSON(http.StatusOK, service.GetAllDashboards()) +} + +func GetDashboardByID(c *gin.Context) { + d, err := service.GetDashboardByID(c.Param("dashboardID")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"message": "dashboard not found"}) + return + } + c.JSON(http.StatusOK, d) +} + +func CreateDashboard(c *gin.Context) { + var input mapache.Dashboard + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) + return + } + input.ID = ulid.Make().Prefixed("dsh") + input.CreatedBy = c.GetString("Auth-UserID") + d, err := service.CreateDashboard(input) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) + return + } + c.JSON(http.StatusOK, d) +} + +func UpdateDashboard(c *gin.Context) { + id := c.Param("dashboardID") + existing, err := service.GetDashboardByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"message": "dashboard not found"}) + return + } + var input mapache.Dashboard + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) + return + } + // Preserve immutable fields. Name/description are the only mutable + // dashboard-level fields; widgets are mutated via the widget endpoints. + input.ID = id + input.CreatedBy = existing.CreatedBy + input.CreatedAt = existing.CreatedAt + d, err := service.UpdateDashboard(input) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) + return + } + c.JSON(http.StatusOK, d) +} + +func DeleteDashboard(c *gin.Context) { + if err := service.DeleteDashboard(c.Param("dashboardID")); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "dashboard deleted"}) +} + +func CreateWidget(c *gin.Context) { + var input mapache.DashboardWidget + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) + return + } + input.ID = ulid.Make().Prefixed("wgt") + input.DashboardID = c.Param("dashboardID") + w, err := service.CreateWidget(input) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) + return + } + c.JSON(http.StatusOK, w) +} + +// UpdateWidget is the hot endpoint — react-grid-layout's drag/resize +// fires a PUT every time the user releases a widget. The handler trusts +// the body to carry the full widget so the client can edit layout and +// config in one round-trip. +func UpdateWidget(c *gin.Context) { + var input mapache.DashboardWidget + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) + return + } + input.ID = c.Param("widgetID") + input.DashboardID = c.Param("dashboardID") + w, err := service.UpdateWidget(input) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) + return + } + c.JSON(http.StatusOK, w) +} + +func DeleteWidget(c *gin.Context) { + if err := service.DeleteWidget(c.Param("widgetID")); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "widget deleted"}) +} diff --git a/vehicle/database/db.go b/vehicle/database/db.go index 4028c879..58f3f1f7 100644 --- a/vehicle/database/db.go +++ b/vehicle/database/db.go @@ -33,6 +33,7 @@ func Init() { logger.SugarLogger.Infoln("Connected to database") db.AutoMigrate(&mapache.Vehicle{}, &mapache.Session{}, &mapache.Marker{}, &mapache.Lap{}, &mapache.Sector{}, + &mapache.Dashboard{}, &mapache.DashboardWidget{}, &model.ConfigFlag{}, &model.VehicleConfigOverride{}, &model.VehicleConfigStatus{}) logger.SugarLogger.Infoln("AutoMigration complete") DB = db diff --git a/vehicle/go.mod b/vehicle/go.mod index 9979d517..fc2e3b2a 100644 --- a/vehicle/go.mod +++ b/vehicle/go.mod @@ -2,6 +2,12 @@ module github.com/gaucho-racing/mapache/vehicle go 1.26 +// Pin mapache-go to the in-repo copy so adding model types to that +// shared package doesn't require a tag-and-release round-trip just to +// build the consuming services. Drop this and bump the version in +// `require` once mapache-go has been released with the new types. +replace github.com/gaucho-racing/mapache/mapache-go/v3 => ../mapache-go + require ( github.com/fatih/color v1.18.0 github.com/gaucho-racing/mapache/mapache-go/v3 v3.5.0 diff --git a/vehicle/go.sum b/vehicle/go.sum index 0aab583a..a7929d83 100644 --- a/vehicle/go.sum +++ b/vehicle/go.sum @@ -13,8 +13,6 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/gaucho-racing/mapache/mapache-go/v3 v3.5.0 h1:sihPvHGJ9CgkoqBySJnjZ1IKoHi9kqh0l1w8nAyNDRE= -github.com/gaucho-racing/mapache/mapache-go/v3 v3.5.0/go.mod h1:2Zb3ztikLtk3UMS0/bg2nhwroSoyFWvQa/tqVGvYFlc= github.com/gaucho-racing/ulid-go v1.1.0 h1:x00XM8EjlegfhlLYIob+U8ba5iX0gDRUr8mgBsjCunk= github.com/gaucho-racing/ulid-go v1.1.0/go.mod h1:HwqoC27UtvXHrmhTO7K2GnXZ1VAeR6tg6EjrSEP5JUU= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= diff --git a/vehicle/service/dashboard.go b/vehicle/service/dashboard.go new file mode 100644 index 00000000..f715d3fb --- /dev/null +++ b/vehicle/service/dashboard.go @@ -0,0 +1,75 @@ +package service + +import ( + mapache "github.com/gaucho-racing/mapache/mapache-go/v3" + "github.com/gaucho-racing/mapache/vehicle/database" +) + +// GetAllDashboards returns every dashboard, most recently updated first. +// Widgets are NOT loaded — the list page only needs the metadata; widget +// payloads can be sizeable (jsonb config blobs per widget). +func GetAllDashboards() []mapache.Dashboard { + var dashboards []mapache.Dashboard + database.DB.Order("updated_at DESC").Find(&dashboards) + return dashboards +} + +// GetDashboardByID returns one dashboard with its widget list populated. +// Widgets are ordered by (y, x) so the response array matches reading +// order on the rendered grid — useful for keyboard navigation and for +// debugging without re-running the grid math. +func GetDashboardByID(id string) (mapache.Dashboard, error) { + var dashboard mapache.Dashboard + if err := database.DB.Where("id = ?", id).First(&dashboard).Error; err != nil { + return mapache.Dashboard{}, err + } + var widgets []mapache.DashboardWidget + database.DB.Where("dashboard_id = ?", id).Order("y, x").Find(&widgets) + dashboard.Widgets = widgets + return dashboard, nil +} + +func CreateDashboard(d mapache.Dashboard) (mapache.Dashboard, error) { + if err := database.DB.Create(&d).Error; err != nil { + return mapache.Dashboard{}, err + } + return d, nil +} + +func UpdateDashboard(d mapache.Dashboard) (mapache.Dashboard, error) { + // GORM's Save() does a full row upsert; we want it because the + // frontend can edit name + description in one request. + if err := database.DB.Save(&d).Error; err != nil { + return mapache.Dashboard{}, err + } + return d, nil +} + +func DeleteDashboard(id string) error { + // Widgets are deleted via the dashboard_id FK constraint at the + // service layer, not via GORM cascade — keeping the foreign-key + // behavior explicit so a dashboard delete doesn't silently orphan + // rows if the constraint is ever dropped. + if err := database.DB.Where("dashboard_id = ?", id).Delete(&mapache.DashboardWidget{}).Error; err != nil { + return err + } + return database.DB.Where("id = ?", id).Delete(&mapache.Dashboard{}).Error +} + +func CreateWidget(w mapache.DashboardWidget) (mapache.DashboardWidget, error) { + if err := database.DB.Create(&w).Error; err != nil { + return mapache.DashboardWidget{}, err + } + return w, nil +} + +func UpdateWidget(w mapache.DashboardWidget) (mapache.DashboardWidget, error) { + if err := database.DB.Save(&w).Error; err != nil { + return mapache.DashboardWidget{}, err + } + return w, nil +} + +func DeleteWidget(id string) error { + return database.DB.Where("id = ?", id).Delete(&mapache.DashboardWidget{}).Error +}