diff --git a/package-lock.json b/package-lock.json index fd1debe..671ebf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,13 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.544.0", + "radix-ui": "^1.4.3", "react": "^19.1.1", "react-dom": "^19.1.1", "react-toastify": "^11.0.5", @@ -1311,12 +1313,118 @@ "dev": true, "license": "MIT" }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -1340,6 +1448,86 @@ } } }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", @@ -1444,6 +1632,34 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", @@ -1609,6 +1825,65 @@ } } }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -1627,30 +1902,614 @@ } } }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", - "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", "license": "MIT", "dependencies": { + "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -1667,10 +2526,10 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -1685,22 +2544,19 @@ } } }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -1717,14 +2573,20 @@ } } }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", "license": "MIT", "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -1741,14 +2603,24 @@ } } }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", "license": "MIT", "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -1765,13 +2637,15 @@ } } }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -1788,38 +2662,18 @@ } } }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { + "node_modules/@radix-ui/react-toggle-group": { "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { @@ -1837,37 +2691,53 @@ } } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-switch": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", - "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" + "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -1884,6 +2754,24 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -1954,6 +2842,24 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -2020,6 +2926,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", @@ -5606,6 +6535,101 @@ "node": ">=6" } }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -6382,6 +7406,15 @@ } } }, + "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/vite": { "version": "7.2.4", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", diff --git a/package.json b/package.json index 8ca4e50..92d4d3a 100644 --- a/package.json +++ b/package.json @@ -36,11 +36,13 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.544.0", + "radix-ui": "^1.4.3", "react": "^19.1.1", "react-dom": "^19.1.1", "react-toastify": "^11.0.5", diff --git a/src/App.tsx b/src/App.tsx index 68e5bba..9bf153f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { ToastContainer, toast } from 'react-toastify'; import { Menu, X } from 'lucide-react'; import 'react-toastify/dist/ReactToastify.css'; import { Button } from '@/components/ui/button'; +import { TooltipProvider } from '@/components/ui/tooltip'; import { EntityTreeSidebar } from '@/components/EntityTreeSidebar'; import { EntityDetailPanel } from '@/components/EntityDetailPanel'; import { ServerConnectionDialog } from '@/components/ServerConnectionDialog'; @@ -82,73 +83,75 @@ function App() { toast.error(`Application error: ${error.message}`); }} > -
- {/* Mobile menu toggle */} - + +
+ {/* Mobile menu toggle */} + - {/* Sidebar with responsive behavior */} -
- setShowConnectionDialog(true)} - onFaultsDashboardClick={handleFaultsDashboardClick} - /> -
- - {/* Overlay for mobile when sidebar is open */} - {sidebarOpen && ( -
- {/* Main content */} -
- - setShowConnectionDialog(true)} - viewMode={viewMode} - onEntitySelect={handleEntitySelect} + {/* Overlay for mobile when sidebar is open */} + {sidebarOpen && ( +
+ )} + + {/* Main content */} +
+ + setShowConnectionDialog(true)} + viewMode={viewMode} + onEntitySelect={handleEntitySelect} + /> + +
- - - -
+ + + + + ); } diff --git a/src/components/FaultsDashboard.tsx b/src/components/FaultsDashboard.tsx index 360e069..e73428d 100644 --- a/src/components/FaultsDashboard.tsx +++ b/src/components/FaultsDashboard.tsx @@ -27,34 +27,16 @@ import { } from '@/components/ui/dropdown-menu'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Skeleton } from '@/components/ui/skeleton'; +import { SnapshotCard } from './SnapshotCard'; import { useAppStore } from '@/lib/store'; -import type { Fault, FaultSeverity, FaultStatus } from '@/lib/types'; -import type { SovdResourceEntityType } from '@/lib/sovd-api'; +import type { Fault, FaultSeverity, FaultStatus, FaultResponse } from '@/lib/types'; +import { mapFaultEntityTypeToResourceType } from '@/lib/sovd-api'; /** * Default polling interval in milliseconds */ const DEFAULT_POLL_INTERVAL = 5000; -/** - * Map fault entity_type (may be singular or plural) to SovdResourceEntityType (always plural) - */ -function mapFaultEntityTypeToResourceType(entityType: string): SovdResourceEntityType { - const type = entityType.toLowerCase(); - if (type === 'area' || type === 'areas') return 'areas'; - if (type === 'app' || type === 'apps') return 'apps'; - if (type === 'function' || type === 'functions') return 'functions'; - if (type === 'component' || type === 'components') return 'components'; - - // Log unexpected entity types to aid debugging - console.warn( - '[FaultsDashboard] Unexpected fault entity_type received:', - entityType, - '- defaulting to "components".' - ); - return 'components'; -} - /** * Get badge variant for fault severity */ @@ -121,65 +103,171 @@ function formatTimestamp(timestamp: string): string { } /** - * Single fault row component + * Single fault row component with collapsible environment data */ function FaultRow({ fault, onClear, isClearing, + isExpanded, + onToggle, + environmentData, + isLoadingDetails, }: { fault: Fault; onClear: (code: string) => void; isClearing: boolean; + isExpanded: boolean; + onToggle: () => void; + environmentData?: FaultResponse['environment_data']; + isLoadingDetails: boolean; }) { const canClear = fault.status === 'active' || fault.status === 'pending'; return ( -
- {/* Severity Icon */} -
- {getSeverityIcon(fault.severity)} -
+ +
+ +
+ {/* Expand/Collapse Icon */} +
+ {isExpanded ? ( + + ) : ( + + )} +
- {/* Fault details */} -
-
- {fault.code} - - {fault.severity} - - - {fault.status} - -
-

{fault.message}

-
- {formatTimestamp(fault.timestamp)} - - {fault.entity_type}: {fault.entity_id} - -
-
+ {/* Severity Icon */} +
+ {getSeverityIcon(fault.severity)} +
- {/* Clear button */} - {canClear && ( - - )} -
+ {/* Fault details */} +
+
+ {fault.code} + + {fault.severity} + + + {fault.status} + +
+

{fault.message}

+
+ {formatTimestamp(fault.timestamp)} + + {fault.entity_type}: {fault.entity_id} + +
+
+ + {/* Clear button */} + {canClear && ( + + )} +
+ + + +
+ {isLoadingDetails ? ( +
+ + Loading environment data... +
+ ) : environmentData ? ( +
+ {/* Extended Data Records */} + {environmentData.extended_data_records && ( +
+
+ Extended Data Records +
+
+ {environmentData.extended_data_records.first_occurrence && ( + <> +
First Occurrence
+
+ {new Date( + environmentData.extended_data_records.first_occurrence + ).toLocaleString()} +
+ + )} + {environmentData.extended_data_records.last_occurrence && ( + <> +
Last Occurrence
+
+ {new Date( + environmentData.extended_data_records.last_occurrence + ).toLocaleString()} +
+ + )} +
+
+ )} + + {/* Snapshots */} + {environmentData.snapshots && environmentData.snapshots.length > 0 && ( +
+
+ Snapshots ({environmentData.snapshots.length}) +
+
+ {environmentData.snapshots.map((snapshot, idx) => ( + + ))} +
+
+ )} + + {/* No environment data message */} + {!environmentData.extended_data_records && + (!environmentData.snapshots || environmentData.snapshots.length === 0) && ( +

+ No environment data available +

+ )} +
+ ) : ( +

No environment data available

+ )} +
+
+
+ ); } @@ -192,12 +280,20 @@ function FaultGroup({ faults, onClear, clearingCodes, + expandedFaults, + onToggleFault, + faultDetails, + loadingDetails, }: { entityId: string; entityType: string; faults: Fault[]; onClear: (code: string) => void; clearingCodes: Set; + expandedFaults: Set; + onToggleFault: (fault: Fault) => void; + faultDetails: Map; + loadingDetails: Set; }) { const [isOpen, setIsOpen] = useState(true); @@ -236,6 +332,10 @@ function FaultGroup({ fault={fault} onClear={onClear} isClearing={clearingCodes.has(fault.code)} + isExpanded={expandedFaults.has(fault.code)} + onToggle={() => onToggleFault(fault)} + environmentData={faultDetails.get(fault.code)?.environment_data} + isLoadingDetails={loadingDetails.has(fault.code)} /> ))} @@ -287,6 +387,9 @@ export function FaultsDashboard() { const [isRefreshing, setIsRefreshing] = useState(false); const [autoRefresh, setAutoRefresh] = useState(true); const [clearingCodes, setClearingCodes] = useState>(new Set()); + const [expandedFaults, setExpandedFaults] = useState>(new Set()); + const [faultDetails, setFaultDetails] = useState>(new Map()); + const [loadingDetails, setLoadingDetails] = useState>(new Set()); // Filters const [severityFilters, setSeverityFilters] = useState>( @@ -296,13 +399,15 @@ export function FaultsDashboard() { const [groupByEntity, setGroupByEntity] = useState(true); // Use shared faults state from store - const { faults, isLoadingFaults, isConnected, fetchFaults, clearFault } = useAppStore( + const { faults, isLoadingFaults, isConnected, fetchFaults, clearFault, client, hasFaultStream } = useAppStore( useShallow((state) => ({ faults: state.faults, isLoadingFaults: state.isLoadingFaults, isConnected: state.isConnected, fetchFaults: state.fetchFaults, clearFault: state.clearFault, + client: state.client, + hasFaultStream: state.faultStreamCleanup !== null, })) ); @@ -313,16 +418,17 @@ export function FaultsDashboard() { } }, [isConnected, fetchFaults]); - // Auto-refresh polling using shared store + // Auto-refresh polling using shared store. + // Skip polling when SSE fault stream is active (it provides real-time updates). useEffect(() => { - if (!autoRefresh || !isConnected) return; + if (!autoRefresh || !isConnected || hasFaultStream) return; const interval = setInterval(() => { fetchFaults(); }, DEFAULT_POLL_INTERVAL); return () => clearInterval(interval); - }, [autoRefresh, isConnected, fetchFaults]); + }, [autoRefresh, isConnected, hasFaultStream, fetchFaults]); // Manual refresh handler const handleRefresh = useCallback(async () => { @@ -358,6 +464,45 @@ export function FaultsDashboard() { [faults, fetchFaults, clearFault] ); + // Toggle fault expansion and lazy-load environment data + const handleToggleFault = useCallback( + async (fault: Fault) => { + const faultCode = fault.code; + const newExpanded = new Set(expandedFaults); + + if (newExpanded.has(faultCode)) { + newExpanded.delete(faultCode); + } else { + newExpanded.add(faultCode); + + // Fetch details if not cached + if (!faultDetails.has(faultCode) && client) { + setLoadingDetails((prev) => new Set([...prev, faultCode])); + try { + const entityGroup = mapFaultEntityTypeToResourceType(fault.entity_type); + const details = await client.getFaultWithEnvironmentData( + entityGroup, + fault.entity_id, + faultCode + ); + setFaultDetails((prev) => new Map(prev).set(faultCode, details)); + } catch (err) { + console.error('Failed to fetch fault details:', err); + } finally { + setLoadingDetails((prev) => { + const next = new Set(prev); + next.delete(faultCode); + return next; + }); + } + } + } + + setExpandedFaults(newExpanded); + }, + [client, expandedFaults, faultDetails] + ); + // Filter faults const filteredFaults = useMemo(() => { return faults.filter((f) => severityFilters.has(f.severity) && statusFilters.has(f.status)); @@ -640,6 +785,10 @@ export function FaultsDashboard() { faults={entityFaults} onClear={handleClear} clearingCodes={clearingCodes} + expandedFaults={expandedFaults} + onToggleFault={handleToggleFault} + faultDetails={faultDetails} + loadingDetails={loadingDetails} /> ))} @@ -653,6 +802,10 @@ export function FaultsDashboard() { fault={fault} onClear={handleClear} isClearing={clearingCodes.has(fault.code)} + isExpanded={expandedFaults.has(fault.code)} + onToggle={() => handleToggleFault(fault)} + environmentData={faultDetails.get(fault.code)?.environment_data} + isLoadingDetails={loadingDetails.has(fault.code)} /> ))} @@ -669,29 +822,34 @@ export function FaultsDashboard() { * The main polling happens in FaultsDashboard or when faults are fetched elsewhere. */ export function FaultsCountBadge() { - const { faults, isConnected, fetchFaults } = useAppStore( + const { faults, isConnected, fetchFaults, hasFaultStream } = useAppStore( useShallow((state) => ({ faults: state.faults, isConnected: state.isConnected, fetchFaults: state.fetchFaults, + hasFaultStream: state.faultStreamCleanup !== null, })) ); - // Trigger initial fetch and set up polling when connected + // Trigger initial fetch and set up fallback polling when connected. + // Skip polling when SSE fault stream is active (provides real-time updates). useEffect(() => { if (!isConnected) return; // Initial fetch fetchFaults(); - // Poll for updates when document is visible - const interval = setInterval(() => { - if (!document.hidden) { - fetchFaults(); - } - }, DEFAULT_POLL_INTERVAL); + // Only poll as fallback when SSE stream is not active + let interval: ReturnType | null = null; + if (!hasFaultStream) { + interval = setInterval(() => { + if (!document.hidden) { + fetchFaults(); + } + }, DEFAULT_POLL_INTERVAL); + } - // Also listen for visibility changes to refresh when tab becomes visible + // Refresh when tab becomes visible const handleVisibilityChange = () => { if (!document.hidden) { fetchFaults(); @@ -700,10 +858,10 @@ export function FaultsCountBadge() { document.addEventListener('visibilitychange', handleVisibilityChange); return () => { - clearInterval(interval); + if (interval) clearInterval(interval); document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [isConnected, fetchFaults]); + }, [isConnected, hasFaultStream, fetchFaults]); // Count active critical/error faults const count = useMemo(() => { diff --git a/src/components/FaultsPanel.tsx b/src/components/FaultsPanel.tsx index 48f9af2..83995a5 100644 --- a/src/components/FaultsPanel.tsx +++ b/src/components/FaultsPanel.tsx @@ -1,11 +1,25 @@ import { useEffect, useState, useCallback } from 'react'; import { useShallow } from 'zustand/shallow'; -import { AlertTriangle, Loader2, RefreshCw, Trash2, AlertCircle, AlertOctagon, Info, CheckCircle } from 'lucide-react'; +import { + AlertTriangle, + Loader2, + RefreshCw, + Trash2, + AlertCircle, + AlertOctagon, + Info, + CheckCircle, + ChevronDown, + ChevronRight, +} from 'lucide-react'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { SnapshotCard } from './SnapshotCard'; import { useAppStore, type AppState } from '@/lib/store'; -import type { Fault, FaultSeverity, FaultStatus } from '@/lib/types'; +import type { Fault, FaultSeverity, FaultStatus, FaultResponse } from '@/lib/types'; +import { mapFaultEntityTypeToResourceType } from '@/lib/sovd-api'; import type { SovdResourceEntityType } from '@/lib/sovd-api'; interface FaultsPanelProps { @@ -77,67 +91,169 @@ function formatTimestamp(timestamp: string): string { } /** - * Single fault row component + * Single fault row component with collapsible environment data */ function FaultRow({ fault, onClear, isClearing, + isExpanded, + onToggle, + environmentData, + isLoadingDetails, }: { fault: Fault; onClear: (code: string) => void; isClearing: boolean; + isExpanded: boolean; + onToggle: () => void; + environmentData?: FaultResponse['environment_data']; + isLoadingDetails: boolean; }) { const canClear = fault.status === 'active' || fault.status === 'pending'; return ( -
- {/* Severity Icon */} -
- {getSeverityIcon(fault.severity)} -
+ +
+ +
+ {/* Expand/Collapse Icon */} +
+ {isExpanded ? ( + + ) : ( + + )} +
- {/* Fault details */} -
-
- {fault.code} - - {fault.severity} - - - {fault.status} - -
-

{fault.message}

-
- {formatTimestamp(fault.timestamp)} - - {fault.entity_type}: {fault.entity_id} - -
-
+ {/* Severity Icon */} +
+ {getSeverityIcon(fault.severity)} +
+ + {/* Fault details */} +
+
+ {fault.code} + + {fault.severity} + + + {fault.status} + +
+

{fault.message}

+
+ {formatTimestamp(fault.timestamp)} + + {fault.entity_type}: {fault.entity_id} + +
+
+ + {/* Clear button */} + {canClear && ( + + )} +
+
+ + +
+ {isLoadingDetails ? ( +
+ + Loading environment data... +
+ ) : environmentData ? ( +
+ {/* Extended Data Records */} + {environmentData.extended_data_records && ( +
+
+ Extended Data Records +
+
+ {environmentData.extended_data_records.first_occurrence && ( + <> +
First Occurrence
+
+ {new Date( + environmentData.extended_data_records.first_occurrence + ).toLocaleString()} +
+ + )} + {environmentData.extended_data_records.last_occurrence && ( + <> +
Last Occurrence
+
+ {new Date( + environmentData.extended_data_records.last_occurrence + ).toLocaleString()} +
+ + )} +
+
+ )} - {/* Clear button */} - {canClear && ( - - )} -
+ {/* Snapshots */} + {environmentData.snapshots && environmentData.snapshots.length > 0 && ( +
+
+ Snapshots ({environmentData.snapshots.length}) +
+
+ {environmentData.snapshots.map((snapshot, idx) => ( + + ))} +
+
+ )} + + {/* No environment data message */} + {!environmentData.extended_data_records && + (!environmentData.snapshots || environmentData.snapshots.length === 0) && ( +

+ No environment data available +

+ )} +
+ ) : ( +

No environment data available

+ )} +
+ +
+ ); } @@ -149,6 +265,9 @@ export function FaultsPanel({ entityId, entityType = 'components' }: FaultsPanel const [isLoading, setIsLoading] = useState(true); const [clearingCodes, setClearingCodes] = useState>(new Set()); const [error, setError] = useState(null); + const [expandedFaults, setExpandedFaults] = useState>(new Set()); + const [faultDetails, setFaultDetails] = useState>(new Map()); + const [loadingDetails, setLoadingDetails] = useState>(new Set()); const { client } = useAppStore( useShallow((state: AppState) => ({ @@ -177,6 +296,50 @@ export function FaultsPanel({ entityId, entityType = 'components' }: FaultsPanel loadFaults(); }, [loadFaults]); + const handleToggleFault = useCallback( + async (faultCode: string) => { + const newExpanded = new Set(expandedFaults); + + if (newExpanded.has(faultCode)) { + newExpanded.delete(faultCode); + } else { + newExpanded.add(faultCode); + + // Fetch details if not cached + if (!faultDetails.has(faultCode) && client) { + setLoadingDetails((prev) => new Set([...prev, faultCode])); + try { + // Use the fault's own entity info (app-level) for correct bulk_data_uri. + // Components have a synthetic FQN that doesn't match fault reporting sources, + // so fetching via /components/{id}/faults/{code} produces an unusable bulk_data_uri. + const fault = faults.find((f) => f.code === faultCode); + const detailEntityType: SovdResourceEntityType = fault?.entity_type + ? mapFaultEntityTypeToResourceType(fault.entity_type) + : entityType; + const detailEntityId = fault?.entity_id || entityId; + const details = await client.getFaultWithEnvironmentData( + detailEntityType, + detailEntityId, + faultCode + ); + setFaultDetails((prev) => new Map(prev).set(faultCode, details)); + } catch (err) { + console.error('Failed to fetch fault details:', err); + } finally { + setLoadingDetails((prev) => { + const next = new Set(prev); + next.delete(faultCode); + return next; + }); + } + } + } + + setExpandedFaults(newExpanded); + }, + [client, entityType, entityId, expandedFaults, faultDetails, faults] + ); + const handleClear = useCallback( async (code: string) => { if (!client) return; @@ -258,6 +421,10 @@ export function FaultsPanel({ entityId, entityType = 'components' }: FaultsPanel fault={fault} onClear={handleClear} isClearing={clearingCodes.has(fault.code)} + isExpanded={expandedFaults.has(fault.code)} + onToggle={() => handleToggleFault(fault.code)} + environmentData={faultDetails.get(fault.code)?.environment_data} + isLoadingDetails={loadingDetails.has(fault.code)} /> ))} diff --git a/src/components/RosbagDownloadButton.test.tsx b/src/components/RosbagDownloadButton.test.tsx new file mode 100644 index 0000000..1fc287d --- /dev/null +++ b/src/components/RosbagDownloadButton.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { RosbagDownloadButton } from './RosbagDownloadButton'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import type { RosbagSnapshot } from '@/lib/types'; + +// Mock the store +const mockDownloadBulkData = vi.fn(); + +vi.mock('@/lib/store', () => ({ + useAppStore: vi.fn((selector) => + selector({ + client: { + downloadBulkData: mockDownloadBulkData, + }, + }) + ), +})); + +const mockSnapshot: RosbagSnapshot = { + type: 'rosbag', + name: 'MOTOR_OVERHEAT', + bulk_data_uri: '/apps/motor/bulk-data/rosbags/uuid-123', + size_bytes: 1048576, + duration_sec: 30.5, + format: 'mcap', + 'x-medkit': { + captured_at: '2026-02-04T10:00:00Z', + fault_code: 'MOTOR_OVERHEAT', + }, +}; + +const renderWithTooltip = (ui: React.ReactElement) => { + return render({ui}); +}; + +describe('RosbagDownloadButton', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('renders download button with size label', () => { + renderWithTooltip(); + + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByText(/Download.*1 MB/)).toBeInTheDocument(); + }); + + it('renders nothing when bulk_data_uri is missing', () => { + const snapshotWithoutUri = { ...mockSnapshot, bulk_data_uri: '' }; + const { container } = renderWithTooltip(); + + expect(container.firstChild).toBeNull(); + }); + + it('triggers download on click', async () => { + // Mock downloadBulkData to return a blob + const mockBlob = new Blob(['test data'], { type: 'application/octet-stream' }); + mockDownloadBulkData.mockResolvedValue({ blob: mockBlob, filename: 'MOTOR_OVERHEAT.mcap' }); + + // Mock URL.createObjectURL / revokeObjectURL + const mockObjectUrl = 'blob:http://localhost/mock-blob-url'; + vi.spyOn(URL, 'createObjectURL').mockReturnValue(mockObjectUrl); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + + const { getByRole } = renderWithTooltip(); + const button = getByRole('button'); + + // Setup link mock + const mockClick = vi.fn(); + let createdLink: Partial = {}; + + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => { + if (tagName === 'a') { + const link = originalCreateElement('a') as HTMLAnchorElement; + createdLink = link; + link.click = mockClick; + return link; + } + return originalCreateElement(tagName); + }); + + fireEvent.click(button); + + await waitFor(() => { + expect(mockDownloadBulkData).toHaveBeenCalledWith('apps', 'motor', 'rosbags', 'uuid-123'); + expect(mockClick).toHaveBeenCalled(); + expect(createdLink.href).toContain('blob:'); + expect(createdLink.download).toBe('MOTOR_OVERHEAT.mcap'); + }); + }); + + it('renders as icon button when size="icon"', () => { + renderWithTooltip(); + + // Should have icon but no text + expect(screen.queryByText(/Download/)).not.toBeInTheDocument(); + expect(screen.getByRole('button').querySelector('svg')).toBeInTheDocument(); + }); +}); diff --git a/src/components/RosbagDownloadButton.tsx b/src/components/RosbagDownloadButton.tsx new file mode 100644 index 0000000..7c7230a --- /dev/null +++ b/src/components/RosbagDownloadButton.tsx @@ -0,0 +1,116 @@ +import { Download, Loader2 } from 'lucide-react'; +import { useState, useCallback } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useAppStore } from '@/lib/store'; +import { formatBytes, formatDuration } from '@/lib/sovd-api'; +import type { SovdResourceEntityType } from '@/lib/sovd-api'; +import type { RosbagSnapshot } from '@/lib/types'; + +interface RosbagDownloadButtonProps { + snapshot: RosbagSnapshot; + variant?: 'default' | 'outline' | 'ghost'; + size?: 'default' | 'sm' | 'icon'; +} + +/** + * Parse a bulk_data_uri like "/apps/motor/bulk-data/rosbags/FAULT_CODE" + * into { entityType, entityId, category, id } for the downloadBulkData API. + */ +function parseBulkDataUri(uri: string): { + entityType: SovdResourceEntityType; + entityId: string; + category: string; + id: string; +} | null { + // Pattern: ///bulk-data// + const match = uri.match(/^\/(apps|components|areas|functions)\/([^/]+)\/bulk-data\/([^/]+)\/(.+)$/); + if (!match) return null; + return { + entityType: match[1]! as SovdResourceEntityType, + entityId: match[2]!, + category: match[3]!, + id: match[4]!, + }; +} + +export function RosbagDownloadButton({ snapshot, variant = 'outline', size = 'sm' }: RosbagDownloadButtonProps) { + const [isDownloading, setIsDownloading] = useState(false); + const [error, setError] = useState(null); + + const { client } = useAppStore( + useShallow((state) => ({ + client: state.client, + })) + ); + + const handleDownload = useCallback(async () => { + if (!client || !snapshot.bulk_data_uri) return; + + setIsDownloading(true); + setError(null); + + try { + const parsed = parseBulkDataUri(snapshot.bulk_data_uri); + if (!parsed) { + throw new Error('Invalid bulk_data_uri format'); + } + + const { blob, filename } = await client.downloadBulkData( + parsed.entityType, + parsed.entityId, + parsed.category, + parsed.id + ); + + // Create object URL and trigger download + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (err) { + setError(err instanceof Error ? err.message : 'Download failed'); + } finally { + setIsDownloading(false); + } + }, [client, snapshot]); + + if (!snapshot.bulk_data_uri) { + return null; + } + + const label = snapshot.size_bytes ? `Download (${formatBytes(snapshot.size_bytes)})` : 'Download rosbag'; + + return ( + + + + + + {error ? ( +

{error}

+ ) : ( +
+

{snapshot.name}

+ {snapshot.duration_sec &&

Duration: {formatDuration(snapshot.duration_sec)}

} + {snapshot.format &&

Format: {snapshot.format}

} +
+ )} +
+
+ ); +} diff --git a/src/components/SnapshotCard.test.tsx b/src/components/SnapshotCard.test.tsx new file mode 100644 index 0000000..d34d512 --- /dev/null +++ b/src/components/SnapshotCard.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SnapshotCard } from './SnapshotCard'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import type { FreezeFrameSnapshot, RosbagSnapshot } from '@/lib/types'; + +// Mock the store +vi.mock('@/lib/store', () => ({ + useAppStore: vi.fn((selector) => + selector({ + client: { + getBulkDataUrl: vi.fn((uri: string) => `http://localhost:8080/api/v1${uri}`), + }, + }) + ), +})); + +const renderWithTooltip = (ui: React.ReactElement) => { + return render({ui}); +}; + +describe('SnapshotCard', () => { + it('renders freeze frame snapshot', () => { + const freezeFrame: FreezeFrameSnapshot = { + type: 'freeze_frame', + name: 'temperature', + data: { temperature: 85.5, rpm: 3000 }, + 'x-medkit': { + topic: '/motor/temp', + message_type: 'sensor_msgs/msg/Temperature', + full_data: { temperature: 85.5, rpm: 3000 }, + captured_at: '2026-02-04T10:00:00Z', + }, + }; + + renderWithTooltip(); + + expect(screen.getByText('Snapshot #1')).toBeInTheDocument(); + expect(screen.getByText('Freeze Frame')).toBeInTheDocument(); + expect(screen.getByText(/Data.*2 fields/)).toBeInTheDocument(); + }); + + it('renders rosbag snapshot with download button', () => { + const rosbag: RosbagSnapshot = { + type: 'rosbag', + name: 'fault_recording', + bulk_data_uri: '/apps/motor/bulk-data/rosbags/rb-1', + size_bytes: 2097152, + duration_sec: 60, + format: 'mcap', + 'x-medkit': { + captured_at: '2026-02-04T10:00:00Z', + fault_code: 'MOTOR_OVERHEAT', + }, + }; + + renderWithTooltip(); + + expect(screen.getByText('Snapshot #3')).toBeInTheDocument(); + expect(screen.getByText('Rosbag')).toBeInTheDocument(); + expect(screen.getByText('2 MB')).toBeInTheDocument(); + expect(screen.getByText('1m 0s')).toBeInTheDocument(); + expect(screen.getByText('mcap')).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeInTheDocument(); // Download button + }); + + it('displays captured_at from x-medkit', () => { + const snapshot: FreezeFrameSnapshot = { + type: 'freeze_frame', + name: 'test', + data: {}, + 'x-medkit': { + topic: '/test', + message_type: 'std_msgs/msg/String', + full_data: {}, + captured_at: '2026-02-04T10:00:00Z', + }, + }; + + renderWithTooltip(); + + // Check that timestamp is rendered (format depends on locale) + expect(screen.getByText(/2026/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/SnapshotCard.tsx b/src/components/SnapshotCard.tsx new file mode 100644 index 0000000..bb3b992 --- /dev/null +++ b/src/components/SnapshotCard.tsx @@ -0,0 +1,99 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Clock, Database, FileBox } from 'lucide-react'; +import { RosbagDownloadButton } from './RosbagDownloadButton'; +import { formatBytes, formatDuration } from '@/lib/sovd-api'; +import type { RosbagSnapshot, Snapshot } from '@/lib/types'; +import { isRosbagSnapshot } from '@/lib/types'; + +interface SnapshotCardProps { + snapshot: Snapshot; + index: number; +} + +/** + * Helper to safely get data from freeze frame snapshot + * Returns the data object or null if not available + */ +function getFreezeFrameData(snapshot: Snapshot): Record | null { + if (snapshot.type === 'freeze_frame' && snapshot.data && typeof snapshot.data === 'object') { + return snapshot.data as Record; + } + return null; +} + +export function SnapshotCard({ snapshot, index }: SnapshotCardProps) { + const isRosbag = isRosbagSnapshot(snapshot); + const freezeFrameData = getFreezeFrameData(snapshot); + const fieldCount = freezeFrameData ? Object.keys(freezeFrameData).length : 0; + + return ( + + +
+ + + Snapshot #{index + 1} + +
+ + {isRosbag ? 'Rosbag' : 'Freeze Frame'} + + {isRosbag && } +
+
+
+ +
+
+ + Captured At +
+
+ {snapshot['x-medkit']?.captured_at + ? new Date(snapshot['x-medkit'].captured_at).toLocaleString() + : 'N/A'} +
+ + {isRosbag && (snapshot as RosbagSnapshot).duration_sec && ( + <> +
Duration
+
{formatDuration((snapshot as RosbagSnapshot).duration_sec)}
+ + )} + + {isRosbag && (snapshot as RosbagSnapshot).size_bytes && ( + <> +
+ + Size +
+
{formatBytes((snapshot as RosbagSnapshot).size_bytes)}
+ + )} + + {isRosbag && (snapshot as RosbagSnapshot).format && ( + <> +
Format
+
{(snapshot as RosbagSnapshot).format}
+ + )} +
+ + {/* Freeze frame data display */} + {!isRosbag && freezeFrameData && fieldCount > 0 && ( +
+
+ + Data ({fieldCount} fields) + +
+                                {JSON.stringify(freezeFrameData, null, 2)}
+                            
+
+
+ )} +
+
+ ); +} diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..bd733a6 --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; + +import { cn } from '@/lib/utils'; + +function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps) { + return ; +} + +function Tooltip({ ...props }: React.ComponentProps) { + return ; +} + +function TooltipTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ); +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/src/lib/sovd-api.test.ts b/src/lib/sovd-api.test.ts new file mode 100644 index 0000000..a6df2b9 --- /dev/null +++ b/src/lib/sovd-api.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SovdApiClient, formatBytes, formatDuration } from './sovd-api'; + +describe('SovdApiClient', () => { + let client: SovdApiClient; + + beforeEach(() => { + client = new SovdApiClient('http://localhost:8080', 'api/v1'); + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('getFaultWithEnvironmentData', () => { + it('returns FaultResponse with environment_data', async () => { + const mockResponse = { + item: { + code: 'TEST_FAULT', + fault_name: 'Test', + severity: 2, + status: { aggregatedStatus: 'active', testFailed: '1', confirmedDTC: '1', pendingDTC: '0' }, + }, + environment_data: { + extended_data_records: { + first_occurrence: '2026-02-04T10:00:00Z', + last_occurrence: '2026-02-04T10:05:00Z', + }, + snapshots: [], + }, + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response); + + const result = await client.getFaultWithEnvironmentData('apps', 'motor', 'TEST_FAULT'); + + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/apps/motor/faults/TEST_FAULT', + expect.objectContaining({ method: 'GET' }) + ); + expect(result.item.code).toBe('TEST_FAULT'); + expect(result.environment_data).toBeDefined(); + }); + + it('throws on failure', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 404, + json: () => Promise.resolve({ message: 'Fault not found' }), + } as Response); + + await expect(client.getFaultWithEnvironmentData('apps', 'motor', 'UNKNOWN')).rejects.toThrow( + 'Fault not found' + ); + }); + }); + + describe('listBulkDataCategories', () => { + it('returns categories array', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ items: ['rosbags'] }), + } as Response); + + const result = await client.listBulkDataCategories('apps', 'motor'); + + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/apps/motor/bulk-data', + expect.objectContaining({ method: 'GET' }) + ); + expect(result.items).toContain('rosbags'); + }); + + it('returns empty array on 404', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 404, + } as Response); + + const result = await client.listBulkDataCategories('apps', 'motor'); + expect(result.items).toHaveLength(0); + }); + }); + + describe('listBulkData', () => { + it('returns BulkDataDescriptor array', async () => { + const mockDescriptor = { + id: 'uuid-123', + name: 'FAULT recording', + mimetype: 'application/x-mcap', + size: 12345, + creation_date: '2026-02-04T10:00:00Z', + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ items: [mockDescriptor] }), + } as Response); + + const result = await client.listBulkData('apps', 'motor', 'rosbags'); + + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/apps/motor/bulk-data/rosbags', + expect.objectContaining({ method: 'GET' }) + ); + expect(result.items[0]?.id).toBe('uuid-123'); + }); + }); + + describe('getBulkDataUrl', () => { + it('builds correct URL from absolute bulk_data_uri', () => { + const url = client.getBulkDataUrl('/apps/motor_controller/bulk-data/rosbags/550e8400-uuid'); + + expect(url).toBe('http://localhost:8080/api/v1/apps/motor_controller/bulk-data/rosbags/550e8400-uuid'); + }); + + it('handles nested entity paths', () => { + const url = client.getBulkDataUrl('/areas/perception/subareas/lidar/bulk-data/rosbags/uuid'); + + expect(url).toBe('http://localhost:8080/api/v1/areas/perception/subareas/lidar/bulk-data/rosbags/uuid'); + }); + }); + + describe('downloadBulkData', () => { + it('downloads blob and extracts filename', async () => { + const mockBlob = new Blob(['test data'], { type: 'application/x-mcap' }); + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(mockBlob), + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="MOTOR_OVERHEAT.mcap"', + }), + } as Response); + + const result = await client.downloadBulkData('apps', 'motor', 'rosbags', 'uuid-123'); + + expect(result.blob).toBe(mockBlob); + expect(result.filename).toBe('MOTOR_OVERHEAT.mcap'); + }); + + it('uses default filename if header missing', async () => { + const mockBlob = new Blob(['test data']); + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(mockBlob), + headers: new Headers({}), + } as Response); + + const result = await client.downloadBulkData('apps', 'motor', 'rosbags', 'my-uuid'); + + expect(result.filename).toBe('my-uuid.mcap'); + }); + + it('throws on failure', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 404, + } as Response); + + await expect(client.downloadBulkData('apps', 'motor', 'rosbags', 'uuid')).rejects.toThrow('HTTP 404'); + }); + }); +}); + +describe('Utility Functions', () => { + describe('formatBytes', () => { + it('formats bytes correctly', () => { + expect(formatBytes(0)).toBe('0 B'); + expect(formatBytes(500)).toBe('500 B'); + expect(formatBytes(1024)).toBe('1 KB'); + expect(formatBytes(1536)).toBe('1.5 KB'); + expect(formatBytes(1048576)).toBe('1 MB'); + expect(formatBytes(1234567)).toBe('1.2 MB'); + }); + }); + + describe('formatDuration', () => { + it('formats seconds correctly', () => { + expect(formatDuration(5)).toBe('5.0s'); + expect(formatDuration(30.5)).toBe('30.5s'); + expect(formatDuration(60)).toBe('1m 0s'); + expect(formatDuration(90)).toBe('1m 30s'); + expect(formatDuration(125)).toBe('2m 5s'); + }); + }); +}); diff --git a/src/lib/sovd-api.ts b/src/lib/sovd-api.ts index 8646380..9bf1d2e 100644 --- a/src/lib/sovd-api.ts +++ b/src/lib/sovd-api.ts @@ -33,6 +33,10 @@ import type { VersionInfo, SovdError, SovdResourceEntityType, + // SOVD Bulk Data and Environment Data types + FaultResponse, + BulkDataCategory, + BulkDataList, } from './types'; import { convertJsonSchemaToTopicSchema } from './schema-utils'; @@ -1677,6 +1681,7 @@ export class SovdApiClient { /** * Get fault snapshots + * @deprecated Use getFaultWithEnvironmentData() instead - snapshots are now inline in fault response * @param faultCode Fault code */ async getFaultSnapshots(faultCode: string): Promise { @@ -1697,6 +1702,7 @@ export class SovdApiClient { /** * Get fault snapshots for a specific entity + * @deprecated Use getFaultWithEnvironmentData() instead - snapshots are now inline in fault response * @param entityType Entity type * @param entityId Entity identifier * @param faultCode Fault code @@ -1724,6 +1730,151 @@ export class SovdApiClient { return await response.json(); } + // =========================================================================== + // SOVD FAULT WITH ENVIRONMENT DATA (new SOVD-compliant endpoint) + // =========================================================================== + + /** + * Get fault details with environment data (SOVD-compliant response) + * Returns FaultResponse with item, environment_data (snapshots), and x-medkit extensions + * @param entityType Entity type (areas, components, apps, functions) + * @param entityId Entity identifier + * @param faultCode Fault code + */ + async getFaultWithEnvironmentData( + entityType: SovdResourceEntityType, + entityId: string, + faultCode: string + ): Promise { + const response = await fetchWithTimeout( + this.getUrl(`${entityType}/${entityId}/faults/${encodeURIComponent(faultCode)}`), + { + method: 'GET', + headers: { Accept: 'application/json' }, + } + ); + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as SovdError; + throw new Error(errorData.message || `HTTP ${response.status}`); + } + + return await response.json(); + } + + // =========================================================================== + // BULK DATA ENDPOINTS (SOVD-compliant binary data downloads) + // =========================================================================== + + /** + * List bulk-data categories for an entity + * @param entityType Entity type (areas, components, apps, functions) + * @param entityId Entity identifier + * @returns BulkDataCategory with items array (e.g., ['rosbags']) + */ + async listBulkDataCategories(entityType: SovdResourceEntityType, entityId: string): Promise { + const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/bulk-data`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + if (response.status === 404) { + return { items: [] }; + } + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); + } + + /** + * List bulk-data items in a category + * @param entityType Entity type + * @param entityId Entity identifier + * @param category Category name (e.g., 'rosbags') + * @returns BulkDataList with descriptor items + */ + async listBulkData(entityType: SovdResourceEntityType, entityId: string, category: string): Promise { + const response = await fetchWithTimeout( + this.getUrl(`${entityType}/${entityId}/bulk-data/${encodeURIComponent(category)}`), + { + method: 'GET', + headers: { Accept: 'application/json' }, + } + ); + + if (!response.ok) { + if (response.status === 404) { + return { items: [] }; + } + throw new Error(`HTTP ${response.status}`); + } + + return await response.json(); + } + + /** + * Get download URL for bulk-data item + * Use this URL directly in anchor href or fetch + * @param bulkDataUri Absolute URI from fault response (e.g., '/apps/motor/bulk-data/rosbags/uuid') + * @returns Full URL for download + */ + getBulkDataUrl(bulkDataUri: string): string { + // bulkDataUri is an absolute path like "/apps/motor/bulk-data/rosbags/FAULT_CODE" + // Strip leading slash to make it a relative endpoint for getUrl() + return this.getUrl(bulkDataUri.replace(/^\//, '')); + } + + /** + * Download bulk-data file as Blob + * @param entityType Entity type + * @param entityId Entity identifier + * @param category Category name (e.g., 'rosbags') + * @param id Bulk data item ID (UUID) + * @returns Blob and filename from Content-Disposition header + */ + async downloadBulkData( + entityType: SovdResourceEntityType, + entityId: string, + category: string, + id: string + ): Promise<{ blob: Blob; filename: string }> { + const response = await fetchWithTimeout( + this.getUrl(`${entityType}/${entityId}/bulk-data/${encodeURIComponent(category)}/${id}`), + { + method: 'GET', + }, + 300000 // 5 minute timeout for large file downloads + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const blob = await response.blob(); + + // Extract filename from Content-Disposition header + const disposition = response.headers.get('Content-Disposition'); + let filename = `${id}.mcap`; // Default + + if (disposition) { + // Try RFC 5987 encoded filename first (filename*=UTF-8''encoded) + const utf8Match = disposition.match(/filename\*=(?:UTF-8|utf-8)''(.+?)(?:;|$)/); + if (utf8Match && utf8Match[1]) { + filename = decodeURIComponent(utf8Match[1]); + } else { + // Fall back to standard filename="..." or filename=... + const match = disposition.match(/filename="?([^";]+)"?/); + if (match && match[1]) { + filename = match[1].trim(); + } + } + } + + return { blob, filename }; + } + /** * Subscribe to real-time fault events via SSE * @param onFault Callback for new fault events @@ -1880,3 +2031,56 @@ export class SovdApiClient { export function createSovdClient(serverUrl: string, baseEndpoint: string = ''): SovdApiClient { return new SovdApiClient(serverUrl, baseEndpoint); } + +// =========================================================================== +// UTILITY FUNCTIONS +// =========================================================================== + +/** + * Map fault entity_type (may be singular or plural) to SovdResourceEntityType (always plural). + * Shared utility used by FaultsDashboard and FaultsPanel. + */ +export function mapFaultEntityTypeToResourceType(entityType: string): SovdResourceEntityType { + const type = entityType.toLowerCase(); + if (type === 'area' || type === 'areas') return 'areas'; + if (type === 'app' || type === 'apps') return 'apps'; + if (type === 'function' || type === 'functions') return 'functions'; + if (type === 'component' || type === 'components') return 'components'; + + console.warn( + '[mapFaultEntityTypeToResourceType] Unexpected entity_type:', + entityType, + '- defaulting to "components".' + ); + return 'components'; +} + +/** + * Format bytes as human-readable string + * @param bytes Number of bytes + * @returns Formatted string (e.g., '1.5 MB') + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +/** + * Format duration in seconds as human-readable string + * @param seconds Duration in seconds + * @returns Formatted string (e.g., '1m 30s') + */ +export function formatDuration(seconds: number): string { + if (seconds < 60) { + return `${seconds.toFixed(1)}s`; + } + + const mins = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + return `${mins}m ${secs}s`; +} diff --git a/src/lib/store.ts b/src/lib/store.ts index 76c8c9a..ad257d4 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -1357,14 +1357,26 @@ export const useAppStore = create()( // =========================================================================== fetchFaults: async () => { - const { client } = get(); + const { client, faults: currentFaults } = get(); if (!client) return; - set({ isLoadingFaults: true }); + // Only show loading spinner on initial load, not background polls + const isInitialLoad = currentFaults.length === 0; + if (isInitialLoad) { + set({ isLoadingFaults: true }); + } try { const result = await client.listAllFaults(); - set({ faults: result.items, isLoadingFaults: false }); + // Skip state update if faults haven't changed to avoid unnecessary re-renders. + // Compare by serializing fault codes + statuses (cheap and covers all meaningful changes). + const newKey = result.items.map((f) => `${f.code}:${f.status}:${f.severity}`).join('|'); + const oldKey = currentFaults.map((f) => `${f.code}:${f.status}:${f.severity}`).join('|'); + if (newKey !== oldKey) { + set({ faults: result.items, isLoadingFaults: false }); + } else if (isInitialLoad) { + set({ isLoadingFaults: false }); + } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; toast.error(`Failed to load faults: ${message}`); @@ -1407,6 +1419,16 @@ export const useAppStore = create()( (f) => f.code === fault.code && f.entity_id === fault.entity_id ); if (existingIndex >= 0) { + // Skip update if fault data hasn't changed (avoids re-render flicker) + const existing = faults[existingIndex]!; + if ( + existing.status === fault.status && + existing.severity === fault.severity && + existing.message === fault.message && + existing.timestamp === fault.timestamp + ) { + return; + } const newFaults = [...faults]; newFaults[existingIndex] = fault; set({ faults: newFaults }); @@ -1421,6 +1443,10 @@ export const useAppStore = create()( const newFaults = faults.filter( (f) => !(f.code === fault.code && f.entity_id === fault.entity_id) ); + // Skip update if no fault was actually removed + if (newFaults.length === faults.length) { + return; + } set({ faults: newFaults }); }, // onError diff --git a/src/lib/types.test.ts b/src/lib/types.test.ts new file mode 100644 index 0000000..9d12e75 --- /dev/null +++ b/src/lib/types.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from 'vitest'; +import { + isFreezeFrameSnapshot, + isRosbagSnapshot, + type Snapshot, + type FreezeFrameSnapshot, + type RosbagSnapshot, + type FaultResponse, + type BulkDataDescriptor, +} from './types'; + +describe('Snapshot Type Guards', () => { + it('identifies freeze_frame snapshot', () => { + const snapshot: Snapshot = { + type: 'freeze_frame', + name: 'temperature', + data: 42.5, + }; + + expect(isFreezeFrameSnapshot(snapshot)).toBe(true); + expect(isRosbagSnapshot(snapshot)).toBe(false); + }); + + it('identifies rosbag snapshot', () => { + const snapshot: Snapshot = { + type: 'rosbag', + name: 'fault_recording', + bulk_data_uri: '/apps/motor/bulk-data/rosbags/123', + size_bytes: 12345, + duration_sec: 6.0, + format: 'mcap', + }; + + expect(isRosbagSnapshot(snapshot)).toBe(true); + expect(isFreezeFrameSnapshot(snapshot)).toBe(false); + }); + + it('freeze_frame has x-medkit extensions', () => { + const snapshot: FreezeFrameSnapshot = { + type: 'freeze_frame', + name: 'temperature', + data: 42.5, + 'x-medkit': { + topic: '/motor/temp', + message_type: 'sensor_msgs/msg/Temperature', + full_data: { temperature: 42.5, variance: 0.1 }, + captured_at: '2026-02-04T10:00:00Z', + }, + }; + + expect(snapshot['x-medkit']?.topic).toBe('/motor/temp'); + }); + + it('rosbag has required fields', () => { + const snapshot: RosbagSnapshot = { + type: 'rosbag', + name: 'recording', + bulk_data_uri: '/apps/motor/bulk-data/rosbags/uuid', + size_bytes: 1000, + duration_sec: 5, + format: 'mcap', + }; + + expect(snapshot.bulk_data_uri).toContain('/bulk-data/rosbags/'); + expect(snapshot.format).toBe('mcap'); + }); +}); + +describe('FaultResponse Types', () => { + it('has required structure', () => { + const response: FaultResponse = { + item: { + code: 'TEST', + fault_name: 'Test fault', + severity: 2, + status: { + aggregatedStatus: 'active', + testFailed: '1', + confirmedDTC: '1', + pendingDTC: '0', + }, + }, + environment_data: { + extended_data_records: { + first_occurrence: '2026-02-04T10:00:00Z', + last_occurrence: '2026-02-04T10:05:00Z', + }, + snapshots: [], + }, + }; + + expect(response.item.code).toBe('TEST'); + expect(response.item.status.aggregatedStatus).toBe('active'); + }); + + it('includes optional x-medkit extensions', () => { + const response: FaultResponse = { + item: { + code: 'TEST', + fault_name: 'Test fault', + severity: 2, + status: { + aggregatedStatus: 'active', + testFailed: '1', + confirmedDTC: '1', + pendingDTC: '0', + }, + }, + environment_data: { + extended_data_records: { + first_occurrence: '2026-02-04T10:00:00Z', + last_occurrence: '2026-02-04T10:05:00Z', + }, + snapshots: [], + }, + 'x-medkit': { + occurrence_count: 3, + reporting_sources: ['/powertrain/motor'], + severity_label: 'ERROR', + }, + }; + + expect(response['x-medkit']?.occurrence_count).toBe(3); + expect(response['x-medkit']?.reporting_sources).toContain('/powertrain/motor'); + }); +}); + +describe('BulkData Types', () => { + it('BulkDataDescriptor has required fields', () => { + const descriptor: BulkDataDescriptor = { + id: 'uuid-123', + name: 'FAULT recording', + mimetype: 'application/x-mcap', + size: 12345, + creation_date: '2026-02-04T10:00:00Z', + }; + + expect(descriptor.id).toBeTruthy(); + expect(descriptor.mimetype).toBe('application/x-mcap'); + }); + + it('BulkDataDescriptor has optional x-medkit', () => { + const descriptor: BulkDataDescriptor = { + id: 'uuid-123', + name: 'FAULT recording', + mimetype: 'application/x-mcap', + size: 12345, + creation_date: '2026-02-04T10:00:00Z', + 'x-medkit': { + fault_code: 'MOTOR_OVERHEAT', + duration_sec: 6.0, + format: 'mcap', + }, + }; + + expect(descriptor['x-medkit']?.fault_code).toBe('MOTOR_OVERHEAT'); + }); +}); diff --git a/src/lib/types.ts b/src/lib/types.ts index af0c77f..9bca2b9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -554,12 +554,141 @@ export interface FunctionCapabilities { export type FaultSeverity = 'info' | 'warning' | 'error' | 'critical'; /** - * Fault status values + * Fault status values (legacy) */ -export type FaultStatus = 'active' | 'pending' | 'cleared'; +export type FaultStatusValue = 'active' | 'pending' | 'cleared'; /** - * Fault entity representing a diagnostic trouble code + * Alias for backwards compatibility + * @deprecated Use FaultStatusValue for clarity + */ +export type FaultStatus = FaultStatusValue; + +// ==================== SOVD Snapshot Types ==================== + +/** + * Base snapshot type - freeze frame or rosbag + */ +export interface SnapshotBase { + type: 'freeze_frame' | 'rosbag'; + name: string; +} + +/** + * Freeze frame snapshot - captured topic data + */ +export interface FreezeFrameSnapshot extends SnapshotBase { + type: 'freeze_frame'; + data: unknown; + 'x-medkit'?: { + topic: string; + message_type: string; + full_data: unknown; + captured_at: string; + parse_error?: string; + }; +} + +/** + * Rosbag snapshot - recording file metadata + */ +export interface RosbagSnapshot extends SnapshotBase { + type: 'rosbag'; + bulk_data_uri: string; + size_bytes: number; + duration_sec: number; + format: 'mcap' | 'sqlite3' | 'db3'; + 'x-medkit'?: { + captured_at: string; + fault_code: string; + }; +} + +/** + * Snapshot union type - discriminated by 'type' field + */ +export type Snapshot = FreezeFrameSnapshot | RosbagSnapshot; + +// ==================== SOVD Environment Data ==================== + +/** + * Extended data records with timestamps + */ +export interface ExtendedDataRecords { + first_occurrence: string; + last_occurrence: string; +} + +/** + * Environment data containing snapshots and record timestamps + */ +export interface EnvironmentData { + extended_data_records: ExtendedDataRecords; + snapshots: Snapshot[]; +} + +// ==================== SOVD Fault Status Object ==================== + +/** + * SOVD-compliant fault status object + */ +export interface SovdFaultStatus { + aggregatedStatus: 'active' | 'passive' | 'cleared'; + testFailed: '0' | '1'; + confirmedDTC: '0' | '1'; + pendingDTC: '0' | '1'; +} + +// ==================== SOVD Fault Item & Response ==================== + +/** + * Fault item in SOVD response + */ +export interface FaultItem { + code: string; + fault_name: string; + severity: number; + status: SovdFaultStatus; +} + +/** + * Extension fields for fault response + */ +export interface FaultExtensions { + occurrence_count: number; + reporting_sources: string[]; + severity_label: string; +} + +/** + * SOVD-compliant fault response with environment data + */ +export interface FaultResponse { + item: FaultItem; + environment_data: EnvironmentData; + 'x-medkit'?: FaultExtensions; +} + +// ==================== Snapshot Type Guards ==================== + +/** + * Type guard for freeze_frame snapshot + */ +export function isFreezeFrameSnapshot(snapshot: Snapshot): snapshot is FreezeFrameSnapshot { + return snapshot.type === 'freeze_frame'; +} + +/** + * Type guard for rosbag snapshot + */ +export function isRosbagSnapshot(snapshot: Snapshot): snapshot is RosbagSnapshot { + return snapshot.type === 'rosbag'; +} + +// ==================== Legacy Fault Types ==================== + +/** + * Legacy fault entity (for backwards compatibility) */ export interface Fault { /** Unique fault code identifier */ @@ -569,7 +698,7 @@ export interface Fault { /** Fault severity level */ severity: FaultSeverity; /** Current fault status */ - status: FaultStatus; + status: FaultStatusValue; /** ISO 8601 timestamp when fault was detected */ timestamp: string; /** Entity ID where fault originated */ @@ -593,7 +722,8 @@ export interface ListFaultsResponse { } /** - * Fault snapshot for debugging + * Legacy fault snapshot (deprecated - use Snapshot instead) + * @deprecated Use FreezeFrameSnapshot or RosbagSnapshot instead */ export interface FaultSnapshot { /** Snapshot identifier */ @@ -605,7 +735,8 @@ export interface FaultSnapshot { } /** - * Response from GET /{entity}/faults/{code}/snapshots + * Legacy response from GET /{entity}/faults/{code}/snapshots + * @deprecated Use environment_data.snapshots from FaultResponse instead */ export interface ListSnapshotsResponse { /** Array of snapshots */ @@ -614,6 +745,46 @@ export interface ListSnapshotsResponse { count: number; } +// ============================================================================= +// BULK DATA (SOVD Binary Data Downloads) +// ============================================================================= + +/** + * Bulk data category listing + */ +export interface BulkDataCategory { + items: string[]; +} + +/** + * Bulk data descriptor for a single item + */ +export interface BulkDataDescriptor { + /** UUID identifier */ + id: string; + /** Display name */ + name: string; + /** MIME type (e.g., 'application/x-mcap') */ + mimetype: string; + /** File size in bytes */ + size: number; + /** ISO 8601 creation timestamp */ + creation_date: string; + /** Extension fields */ + 'x-medkit'?: { + fault_code: string; + duration_sec: number; + format: string; + }; +} + +/** + * Response from GET /{entity}/bulk-data/{category} + */ +export interface BulkDataList { + items: BulkDataDescriptor[]; +} + // ============================================================================= // SERVER CAPABILITIES (SOVD Discovery) // =============================================================================