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 */}
-
setSidebarOpen(!sidebarOpen)}
- aria-label={sidebarOpen ? 'Close menu' : 'Open menu'}
- >
- {sidebarOpen ? : }
-
+
+
+ {/* Mobile menu toggle */}
+
setSidebarOpen(!sidebarOpen)}
+ aria-label={sidebarOpen ? 'Close menu' : 'Open menu'}
+ >
+ {sidebarOpen ? : }
+
- {/* Sidebar with responsive behavior */}
-
- setShowConnectionDialog(true)}
- onFaultsDashboardClick={handleFaultsDashboardClick}
- />
-
-
- {/* Overlay for mobile when sidebar is open */}
- {sidebarOpen && (
-
setSidebarOpen(false)}
- onKeyDown={(event) => {
- if (event.key === 'Escape') {
- setSidebarOpen(false);
- }
- }}
- aria-label="Close sidebar"
- />
- )}
+ >
+ setShowConnectionDialog(true)}
+ onFaultsDashboardClick={handleFaultsDashboardClick}
+ />
+
- {/* Main content */}
-
-
- setShowConnectionDialog(true)}
- viewMode={viewMode}
- onEntitySelect={handleEntitySelect}
+ {/* Overlay for mobile when sidebar is open */}
+ {sidebarOpen && (
+ setSidebarOpen(false)}
+ onKeyDown={(event) => {
+ if (event.key === 'Escape') {
+ setSidebarOpen(false);
+ }
+ }}
+ aria-label="Close sidebar"
/>
-
-
+ )}
+
+ {/* 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 && (
-
onClear(fault.code)}
- disabled={isClearing}
- className="shrink-0"
- title="Clear fault"
- >
- {isClearing ? : }
-
- )}
-
+ {/* Fault details */}
+
+
+ {fault.code}
+
+ {fault.severity}
+
+
+ {fault.status}
+
+
+
{fault.message}
+
+ {formatTimestamp(fault.timestamp)}
+
+ {fault.entity_type}: {fault.entity_id}
+
+
+
+
+ {/* Clear button */}
+ {canClear && (
+ {
+ e.stopPropagation();
+ onClear(fault.code);
+ }}
+ disabled={isClearing}
+ className="shrink-0"
+ title="Clear fault"
+ >
+ {isClearing ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+
+
+ {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 && (
+
{
+ e.stopPropagation();
+ onClear(fault.code);
+ }}
+ disabled={isClearing}
+ className="shrink-0"
+ >
+ {isClearing ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+
+
+ {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 && (
-
onClear(fault.code)}
- disabled={isClearing}
- className="shrink-0"
- >
- {isClearing ? : }
-
- )}
-
+ {/* 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 (
+
+
+
+ {isDownloading ? : }
+ {size !== 'icon' && {isDownloading ? 'Downloading...' : label} }
+
+
+
+ {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)
// =============================================================================