diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1c852eb..aafc292 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -8,45 +8,107 @@ React 19 + Vite + TypeScript SPA for browsing SOVD (Service-Oriented Vehicle Dia ``` src/ -├── components/ # React components -│ ├── ui/ # shadcn/ui primitives (Button, Card, Dialog, etc.) -│ ├── EntityTreeSidebar.tsx # Main navigation tree -│ ├── EntityDetailPanel.tsx # Entity details view -│ ├── OperationsPanel.tsx # ROS 2 service/action invocation -│ ├── ConfigurationPanel.tsx # ROS 2 parameter management -│ └── DataFolderPanel.tsx # Topic subscriptions +├── components/ # React components +│ ├── ui/ # shadcn/ui primitives (Button, Card, Dialog, etc.) +│ ├── EntityTreeSidebar.tsx # Main navigation tree with collapsible nodes +│ ├── EntityTreeNode.tsx # Tree node component with expand/collapse +│ ├── EntityDetailPanel.tsx # Entity details view (dispatch to type-specific panels) +│ ├── EntityResourceTabs.tsx # Tabbed interface for data/operations/configs/faults +│ ├── ServerInfoPanel.tsx # Server connection info and capabilities +│ ├── OperationsPanel.tsx # ROS 2 service/action invocation +│ ├── ConfigurationPanel.tsx # ROS 2 parameter management +│ ├── FaultsDashboard.tsx # System-wide faults view with filtering +│ ├── FaultsPanel.tsx # Entity-specific faults +│ ├── SearchCommand.tsx # Ctrl+K command palette for entity search +│ └── ServerConnectionDialog.tsx # Server URL input dialog ├── lib/ -│ ├── sovd-api.ts # Typed HTTP client for gateway REST API -│ ├── store.ts # Zustand state management -│ ├── types.ts # TypeScript interfaces for API types -│ ├── schema-utils.ts # JSON Schema utilities -│ └── utils.ts # Utility functions +│ ├── sovd-api.ts # Typed HTTP client for gateway REST API +│ ├── store.ts # Zustand state management (entity tree, selection, faults) +│ ├── types.ts # TypeScript interfaces for API types +│ ├── schema-utils.ts # JSON Schema utilities for form generation +│ └── utils.ts # Utility functions └── test/ - └── setup.ts # Vitest setup + └── setup.ts # Vitest setup ``` +## Entity Model + +**SOVD entity hierarchy:** + +- **Area** → namespace grouping (e.g., `/powertrain`, `/chassis`) +- **Subarea** → nested namespace +- **Component** → logical grouping, contains apps +- **Subcomponent** → nested component +- **App** → individual ROS 2 node +- **Function** → capability grouping (functional view) + +**Resources** (available on components/apps/functions/areas): + +- `data` → ROS 2 topics +- `operations` → ROS 2 services and actions +- `configurations` → ROS 2 parameters +- `faults` → diagnostic trouble codes + ## Key Patterns ### State Management (Zustand) ```typescript // src/lib/store.ts -export const useStore = create()( +export const useAppStore = create()( persist( (set, get) => ({ // Connection state - serverUrl: 'http://localhost:8080', + serverUrl: null, + client: null, + isConnected: false, + // Entity tree - entities: [], - selectedEntityPath: null, + rootEntities: [], + selectedPath: null, + selectedEntity: null, + expandedPaths: [], + + // Shared faults state (used by FaultsDashboard and FaultsCountBadge) + faults: [], + isLoadingFaults: false, + // Actions - selectEntity: (path) => set({ selectedEntityPath: path }), + connect: async (url) => { + /* ... */ + }, + selectEntity: async (path) => { + /* ... */ + }, + loadChildren: async (path) => { + /* ... */ + }, + fetchFaults: async () => { + /* ... */ + }, }), - { name: 'sovd-ui-storage' } + { name: 'sovd_web_ui_server_url', partialize: (state) => ({ serverUrl, baseEndpoint }) } ) ); ``` +### Entity Selection Handlers + +The `selectEntity` action uses type-specific handlers for cleaner code: + +```typescript +// Handlers extracted from selectEntity for maintainability +handleTopicSelection(ctx, client); // Async - may fetch full topic data +handleServerSelection(ctx); // Show server info panel +handleComponentSelection(ctx); // Auto-expand, show resources +handleAreaSelection(ctx); // Auto-expand +handleFunctionSelection(ctx); // Show function with hosts +handleAppSelection(ctx); // Show app details +handleFaultSelection(ctx); // Show fault details +handleParameterSelection(ctx); // Show parameter editor +handleOperationSelection(ctx); // Show operation invocation +``` + ### API Client ```typescript @@ -54,46 +116,64 @@ export const useStore = create()( export class SovdApiClient { constructor(private baseUrl: string) {} - async getComponents(): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/components`); - return response.json(); - } + // Entity listing + async getAreas(): Promise; + async getComponents(): Promise; + async getApps(): Promise; + async getFunctions(): Promise; + + // Entity resources + async getEntityData(entityType, entityId): Promise; + async listOperations(entityId, entityType): Promise; + async listConfigurations(entityId, entityType): Promise; + async listEntityFaults(entityType, entityId): Promise; + + // Operations (SOVD Execution Model) + async createExecution(entityId, operationName, request): Promise; + async getExecutionStatus(entityId, operationName, executionId): Promise; + async cancelExecution(entityId, operationName, executionId): Promise; } ``` ## Conventions -- Use Zustand for client state +- Use `useAppStore` with `useShallow` for selective subscriptions - All API types defined in `lib/types.ts` - Use `@/` path alias for imports from src - Prefer composition over inheritance - Use shadcn/ui components from `components/ui/` +- Resources (data, operations, configurations, faults) shown in detail panel tabs, not as tree nodes +- Lazy load resources per tab in `EntityResourceTabs` to avoid unnecessary API calls - Format with Prettier (automatic via husky pre-commit) ## Testing - Unit tests: `*.test.ts` next to source files -- Integration tests: `src/test/integration/` - Use `@testing-library/react` for component tests - Run tests: `npm test` +- Run lint: `npm run lint` ## Gateway API Reference Default base URL: `http://localhost:8080/api/v1` -| Method | Endpoint | Description | -| ------ | ---------------------------------------- | ------------------------------------ | -| GET | `/areas` | List all areas (namespace groupings) | -| GET | `/components` | List all components | -| GET | `/apps` | List all apps (ROS 2 nodes) | -| GET | `/components/{id}/data` | List data topics for component | -| GET | `/components/{id}/operations` | List operations (services/actions) | -| GET | `/components/{id}/configurations` | List configurations (parameters) | -| POST | `/components/{id}/operations/{name}` | Call operation | -| PUT | `/components/{id}/configurations/{name}` | Update configuration | +| Method | Endpoint | Description | +| ------ | ------------------------------------------- | ------------------------------------ | +| GET | `/areas` | List all areas (namespace groupings) | +| GET | `/components` | List all components | +| GET | `/apps` | List all apps (ROS 2 nodes) | +| GET | `/functions` | List all functions | +| GET | `/{entity_type}/{id}/data` | List data topics for entity | +| GET | `/{entity_type}/{id}/operations` | List operations (services/actions) | +| GET | `/{entity_type}/{id}/configurations` | List configurations (parameters) | +| GET | `/{entity_type}/{id}/faults` | List faults for entity | +| GET | `/faults` | List all faults across system | +| POST | `/{entity_type}/{id}/operations/{name}` | Create execution (call operation) | +| DELETE | `/{entity_type}/{id}/faults/{code}` | Clear a fault | +| PUT | `/{entity_type}/{id}/configurations/{name}` | Update configuration value | ## Important Notes - This UI connects to `ros2_medkit_gateway` running on port 8080 - Entity IDs are alphanumeric + underscore + hyphen only -- Virtual folders (data/, operations/, configurations/) are UI constructs, not API entities +- Entity types for API: `areas`, `components`, `apps`, `functions` (plural) diff --git a/package-lock.json b/package-lock.json index 507df14..fd1debe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,13 @@ "dependencies": { "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", "@tailwindcss/vite": "^4.1.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.544.0", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -1166,6 +1169,44 @@ } } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1276,6 +1317,29 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "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", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "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-collapsible": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", @@ -1306,6 +1370,50 @@ } } }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@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-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-collection/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-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -1390,6 +1498,21 @@ } } }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "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-dismissable-layer": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", @@ -1417,6 +1540,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "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-menu": "2.1.16", + "@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-focus-guards": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", @@ -1475,6 +1627,96 @@ } } }, + "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-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", @@ -1564,6 +1806,37 @@ } } }, + "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-slot": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", @@ -1582,6 +1855,35 @@ } } }, + "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": { + "@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-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-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", @@ -1667,6 +1969,63 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "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-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "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-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "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/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.47", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", @@ -3288,6 +3647,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", diff --git a/package.json b/package.json index efdec0e..8ca4e50 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,13 @@ "dependencies": { "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", "@tailwindcss/vite": "^4.1.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.544.0", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/src/App.tsx b/src/App.tsx index e0e85f9..68e5bba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,25 +1,67 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useShallow } from 'zustand/shallow'; 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 { EntityTreeSidebar } from '@/components/EntityTreeSidebar'; import { EntityDetailPanel } from '@/components/EntityDetailPanel'; import { ServerConnectionDialog } from '@/components/ServerConnectionDialog'; +import { SearchCommand } from '@/components/SearchCommand'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; +import { useSearchShortcut } from '@/hooks/useSearchShortcut'; import { useAppStore } from '@/lib/store'; +type ViewMode = 'entity' | 'faults-dashboard'; + function App() { - const { isConnected, serverUrl, baseEndpoint, connect } = useAppStore( + const { isConnected, serverUrl, baseEndpoint, connect, clearSelection, selectedPath } = useAppStore( useShallow((state) => ({ isConnected: state.isConnected, serverUrl: state.serverUrl, baseEndpoint: state.baseEndpoint, connect: state.connect, + clearSelection: state.clearSelection, + selectedPath: state.selectedPath, })) ); const [showConnectionDialog, setShowConnectionDialog] = useState(false); + const [showSearch, setShowSearch] = useState(false); + const [viewMode, setViewMode] = useState('entity'); + const [sidebarOpen, setSidebarOpen] = useState(true); const autoConnectAttempted = useRef(false); + // Keyboard shortcut: Ctrl+K / Cmd+K to open search + const openSearch = useCallback(() => setShowSearch(true), []); + useSearchShortcut(openSearch); + + // Handle faults dashboard navigation + const handleFaultsDashboardClick = useCallback(() => { + clearSelection(); + setViewMode('faults-dashboard'); + // Close sidebar on mobile when navigating + if (window.innerWidth < 768) { + setSidebarOpen(false); + } + }, [clearSelection]); + + // When entity is selected, switch back to entity view + const handleEntitySelect = useCallback(() => { + setViewMode('entity'); + // Close sidebar on mobile when selecting entity + if (window.innerWidth < 768) { + setSidebarOpen(false); + } + }, []); + + // Close sidebar on mobile when entity is selected from search + useEffect(() => { + if (selectedPath && window.innerWidth < 768) { + setSidebarOpen(false); + } + }, [selectedPath]); + // Auto-connect on mount if we have a stored URL useEffect(() => { if (serverUrl && !isConnected && !autoConnectAttempted.current) { @@ -35,23 +77,79 @@ function App() { }, []); return ( -
- setShowConnectionDialog(true)} /> - setShowConnectionDialog(true)} /> - - -
+ { + toast.error(`Application error: ${error.message}`); + }} + > +
+ {/* Mobile menu toggle */} + + + {/* Sidebar with responsive behavior */} +
+ setShowConnectionDialog(true)} + onFaultsDashboardClick={handleFaultsDashboardClick} + /> +
+ + {/* Overlay for mobile when sidebar is open */} + {sidebarOpen && ( +
+
); } diff --git a/src/components/ActionStatusPanel.tsx b/src/components/ActionStatusPanel.tsx index d892047..3fde416 100644 --- a/src/components/ActionStatusPanel.tsx +++ b/src/components/ActionStatusPanel.tsx @@ -5,12 +5,13 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { useAppStore, type AppState } from '@/lib/store'; -import type { ExecutionStatus } from '@/lib/types'; +import type { ExecutionStatus, SovdResourceEntityType } from '@/lib/types'; interface ActionStatusPanelProps { - componentId: string; + entityId: string; operationName: string; executionId: string; + entityType?: SovdResourceEntityType; } /** @@ -77,7 +78,12 @@ function isActiveStatus(status: ExecutionStatus): boolean { return ['pending', 'running'].includes(status); } -export function ActionStatusPanel({ componentId, operationName, executionId }: ActionStatusPanelProps) { +export function ActionStatusPanel({ + entityId, + operationName, + executionId, + entityType = 'components', +}: ActionStatusPanelProps) { const { activeExecutions, autoRefreshExecutions, @@ -103,31 +109,31 @@ export function ActionStatusPanel({ componentId, operationName, executionId }: A // Manual refresh const handleRefresh = useCallback(() => { - refreshExecutionStatus(componentId, operationName, executionId); - }, [componentId, operationName, executionId, refreshExecutionStatus]); + refreshExecutionStatus(entityId, operationName, executionId, entityType); + }, [entityId, operationName, executionId, refreshExecutionStatus, entityType]); // Cancel action const handleCancel = useCallback(async () => { - await cancelExecution(componentId, operationName, executionId); - }, [componentId, operationName, executionId, cancelExecution]); + await cancelExecution(entityId, operationName, executionId, entityType); + }, [entityId, operationName, executionId, cancelExecution, entityType]); // Auto-refresh effect useEffect(() => { if (!autoRefreshExecutions || isTerminal) return; const interval = setInterval(() => { - refreshExecutionStatus(componentId, operationName, executionId); + refreshExecutionStatus(entityId, operationName, executionId, entityType); }, 1000); // Refresh every second return () => clearInterval(interval); - }, [autoRefreshExecutions, isTerminal, componentId, operationName, executionId, refreshExecutionStatus]); + }, [autoRefreshExecutions, isTerminal, entityId, operationName, executionId, refreshExecutionStatus, entityType]); // Initial fetch useEffect(() => { if (!execution) { - refreshExecutionStatus(componentId, operationName, executionId); + refreshExecutionStatus(entityId, operationName, executionId, entityType); } - }, [executionId, execution, componentId, operationName, refreshExecutionStatus]); + }, [executionId, execution, entityId, operationName, refreshExecutionStatus, entityType]); if (!execution) { return ( diff --git a/src/components/AppsPanel.tsx b/src/components/AppsPanel.tsx new file mode 100644 index 0000000..ff22272 --- /dev/null +++ b/src/components/AppsPanel.tsx @@ -0,0 +1,324 @@ +import { useState, useEffect } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { Cpu, Database, Zap, Settings, AlertTriangle, ChevronRight, Box, Network, FileCode } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { useAppStore } from '@/lib/store'; +import { ConfigurationPanel } from '@/components/ConfigurationPanel'; +import { FaultsPanel } from '@/components/FaultsPanel'; +import { OperationsPanel } from '@/components/OperationsPanel'; +import type { ComponentTopic, Operation, Fault } from '@/lib/types'; + +type AppTab = 'overview' | 'data' | 'operations' | 'configurations' | 'faults'; + +interface TabConfig { + id: AppTab; + label: string; + icon: typeof Database; +} + +const APP_TABS: TabConfig[] = [ + { id: 'overview', label: 'Overview', icon: Cpu }, + { id: 'data', label: 'Data', icon: Database }, + { id: 'operations', label: 'Operations', icon: Zap }, + { id: 'configurations', label: 'Config', icon: Settings }, + { id: 'faults', label: 'Faults', icon: AlertTriangle }, +]; + +interface AppsPanelProps { + appId: string; + appName?: string; + fqn?: string; + nodeName?: string; + namespace?: string; + componentId?: string; + path: string; + onNavigate?: (path: string) => void; +} + +/** + * Apps Panel - displays app (ROS 2 node) entity details + * + * Apps are individual ROS 2 nodes in SOVD. They have: + * - Data (topics they publish/subscribe to) + * - Operations (services/actions they provide) + * - Configurations (parameters) + * - Faults (diagnostic trouble codes) + */ +export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentId, path, onNavigate }: AppsPanelProps) { + const [activeTab, setActiveTab] = useState('overview'); + const [topics, setTopics] = useState([]); + const [operations, setOperations] = useState([]); + const [faults, setFaults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const { client, selectEntity, configurations } = useAppStore( + useShallow((state) => ({ + client: state.client, + selectEntity: state.selectEntity, + configurations: state.configurations, + })) + ); + + // Load app resources on mount (configurations are loaded by ConfigurationPanel) + useEffect(() => { + const loadAppData = async () => { + if (!client) return; + setIsLoading(true); + + try { + // Load resources in parallel (configurations handled by ConfigurationPanel) + const [topicsData, opsData, faultsData] = await Promise.all([ + client.getAppData(appId).catch(() => []), + client.listOperations(appId, 'apps').catch(() => []), + client.listEntityFaults('apps', appId).catch(() => ({ items: [] })), + ]); + + setTopics(topicsData); + setOperations(opsData); + setFaults(faultsData.items); + } catch (error) { + console.error('Failed to load app data:', error); + } finally { + setIsLoading(false); + } + }; + + loadAppData(); + }, [client, appId]); + + const handleResourceClick = (resourcePath: string) => { + if (onNavigate) { + onNavigate(resourcePath); + } else { + selectEntity(resourcePath); + } + }; + + // Count resources for badges + const publishTopics = topics.filter((t) => t.isPublisher); + const subscribeTopics = topics.filter((t) => t.isSubscriber); + const activeFaults = faults.filter((f) => f.status === 'active'); + + return ( +
+ {/* App Header */} + + +
+
+ +
+
+ {appName || nodeName || appId} + + + app + + {componentId && ( + <> + + + + )} + +
+
+
+ + {/* Tab Navigation */} +
+
+ {APP_TABS.map((tab) => { + const TabIcon = tab.icon; + const isActive = activeTab === tab.id; + let count = 0; + if (tab.id === 'data') count = topics.length; + if (tab.id === 'operations') count = operations.length; + if (tab.id === 'configurations') count = configurations.get(appId)?.length || 0; + if (tab.id === 'faults') count = activeFaults.length; + + return ( + + ); + })} +
+
+
+ + {/* Tab Content */} + {activeTab === 'overview' && ( + + + Node Information + + +
+
+
+ + Node Name +
+

{nodeName || appId}

+
+
+
+ + Namespace +
+

{namespace || '/'}

+
+
+
+ + Fully Qualified Name +
+

+ {fqn || `${namespace || '/'}${nodeName || appId}`} +

+
+
+ + {/* Resource Summary */} +
+ + + + +
+
+
+ )} + + {activeTab === 'data' && ( + + + + + Topics + + + {publishTopics.length} published, {subscribeTopics.length} subscribed + + + + {topics.length === 0 ? ( +
+ No topics available for this app. +
+ ) : ( +
+ {topics.map((topic, idx) => { + const cleanName = topic.topic.startsWith('/') ? topic.topic.slice(1) : topic.topic; + const encodedName = encodeURIComponent(topic.uniqueKey || cleanName); + const topicPath = `${path}/data/${encodedName}`; + + return ( +
handleResourceClick(topicPath)} + > + + {topic.isPublisher ? 'pub' : 'sub'} + + {topic.topic} + {topic.type && ( + + {topic.type} + + )} + +
+ ); + })} +
+ )} +
+
+ )} + + {activeTab === 'operations' && } + + {activeTab === 'configurations' && } + + {activeTab === 'faults' && } + + {isLoading &&
Loading app resources...
} +
+ ); +} diff --git a/src/components/AreasPanel.tsx b/src/components/AreasPanel.tsx new file mode 100644 index 0000000..71a5b8f --- /dev/null +++ b/src/components/AreasPanel.tsx @@ -0,0 +1,230 @@ +import { useState } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { Layers, Box, ChevronRight, MapPin, Database } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { useAppStore } from '@/lib/store'; +import { EntityResourceTabs } from '@/components/EntityResourceTabs'; +import type { EntityTreeNode } from '@/lib/types'; + +type AreaTab = 'overview' | 'components' | 'resources'; + +interface TabConfig { + id: AreaTab; + label: string; + icon: typeof Layers; +} + +const AREA_TABS: TabConfig[] = [ + { id: 'overview', label: 'Overview', icon: Layers }, + { id: 'components', label: 'Components', icon: Box }, + { id: 'resources', label: 'Resources', icon: Database }, +]; + +interface AreasPanelProps { + areaId: string; + areaName?: string; + path: string; +} + +/** + * Areas Panel - displays area entity details with related components + * + * Areas are namespace groupings in SOVD. They can have: + * - Subareas (child areas) + * - Related components (components in this area) + * + * Note: Areas don't have direct data/operations/configurations/faults. + * Those resources belong to components and apps within the area. + */ +export function AreasPanel({ areaId, areaName, path }: AreasPanelProps) { + const [activeTab, setActiveTab] = useState('overview'); + + const { rootEntities, selectEntity, expandedPaths, toggleExpanded } = useAppStore( + useShallow((state) => ({ + rootEntities: state.rootEntities, + selectEntity: state.selectEntity, + expandedPaths: state.expandedPaths, + toggleExpanded: state.toggleExpanded, + })) + ); + + // Recursive function to find area node in tree (areas are children of server node) + const findAreaNode = ( + nodes: EntityTreeNode[] | undefined, + targetId: string, + targetPath: string + ): EntityTreeNode | undefined => { + if (!nodes) return undefined; + for (const node of nodes) { + if (node.id === targetId || node.path === targetPath) { + return node; + } + if (node.children && Array.isArray(node.children)) { + const found = findAreaNode(node.children, targetId, targetPath); + if (found) { + return found; + } + } + } + return undefined; + }; + + // Find the area node in the tree (areas are now children of server node) + const areaNode = findAreaNode(rootEntities, areaId, path); + const components = areaNode?.children?.filter((c: EntityTreeNode) => c.type === 'component') || []; + const subareas = areaNode?.children?.filter((c: EntityTreeNode) => c.type === 'subarea') || []; + + const handleComponentClick = (componentPath: string) => { + selectEntity(componentPath); + // Auto-expand the component + if (!expandedPaths.includes(componentPath)) { + toggleExpanded(componentPath); + } + }; + + return ( +
+ {/* Area Header */} + + +
+
+ +
+
+ {areaName || areaId} + + + area + + + {path} + +
+
+
+ + {/* Tab Navigation */} +
+
+ {AREA_TABS.map((tab) => { + const TabIcon = tab.icon; + const isActive = activeTab === tab.id; + const count = tab.id === 'components' ? components.length : 0; + + return ( + + ); + })} +
+
+
+ + {/* Tab Content */} + {activeTab === 'overview' && ( + + +
+
+
+ + Namespace +
+

/{areaId}

+
+ + {subareas.length > 0 && ( +
+ +
{subareas.length}
+
Subareas
+
+ )} + +
+
+
+ )} + + {activeTab === 'components' && ( + + +
+ + Components in this Area + {components.length} +
+ + Components are logical groupings of ROS 2 nodes (apps) within this namespace. + +
+ + {components.length === 0 ? ( +
+ +

No components found in this area.

+

+ Components will appear here when ROS 2 nodes are discovered. +

+
+ ) : ( +
+ {components.map((component) => ( +
handleComponentClick(component.path)} + > +
+ +
+
+
{component.name}
+
+ {component.id} +
+
+ +
+ ))} +
+ )} +
+
+ )} + + {activeTab === 'resources' && } +
+ ); +} diff --git a/src/components/ConfigurationPanel.tsx b/src/components/ConfigurationPanel.tsx index de281dd..d08dfcf 100644 --- a/src/components/ConfigurationPanel.tsx +++ b/src/components/ConfigurationPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { useShallow } from 'zustand/shallow'; import { Settings, Loader2, RefreshCw, Lock, Save, X, RotateCcw } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; @@ -7,13 +7,14 @@ import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { useAppStore, type AppState } from '@/lib/store'; import type { Parameter, ParameterType } from '@/lib/types'; +import type { SovdResourceEntityType } from '@/lib/sovd-api'; interface ConfigurationPanelProps { - componentId: string; + entityId: string; /** Optional parameter name to highlight */ highlightParam?: string; /** Entity type for API calls */ - entityType?: 'components' | 'apps'; + entityType?: SovdResourceEntityType; } /** @@ -261,11 +262,7 @@ function parseValue(input: string, type: ParameterType): unknown { } } -export function ConfigurationPanel({ - componentId, - highlightParam, - entityType = 'components', -}: ConfigurationPanelProps) { +export function ConfigurationPanel({ entityId, highlightParam, entityType = 'components' }: ConfigurationPanelProps) { const { configurations, isLoadingConfigurations, @@ -285,41 +282,44 @@ export function ConfigurationPanel({ ); const [isResettingAll, setIsResettingAll] = useState(false); - const parameters = configurations.get(componentId) || []; + const parameters = configurations.get(entityId) || []; + const prevEntityIdRef = useRef(null); - // Fetch configurations on mount (lazy loading) + // Fetch configurations on mount and when entityId changes useEffect(() => { - if (!configurations.has(componentId)) { - fetchConfigurations(componentId, entityType); + // Always fetch if entityId changed, or if not yet loaded + if (prevEntityIdRef.current !== entityId || !configurations.has(entityId)) { + fetchConfigurations(entityId, entityType); } - }, [componentId, configurations, fetchConfigurations, entityType]); + prevEntityIdRef.current = entityId; + }, [entityId, entityType, fetchConfigurations, configurations]); const handleRefresh = useCallback(() => { - fetchConfigurations(componentId, entityType); - }, [componentId, fetchConfigurations, entityType]); + fetchConfigurations(entityId, entityType); + }, [entityId, fetchConfigurations, entityType]); const handleSetParameter = useCallback( async (name: string, value: unknown) => { - return setParameter(componentId, name, value, entityType); + return setParameter(entityId, name, value, entityType); }, - [componentId, setParameter, entityType] + [entityId, setParameter, entityType] ); const handleResetParameter = useCallback( async (name: string) => { - return resetParameter(componentId, name, entityType); + return resetParameter(entityId, name, entityType); }, - [componentId, resetParameter, entityType] + [entityId, resetParameter, entityType] ); const handleResetAll = useCallback(async () => { setIsResettingAll(true); try { - await resetAllConfigurations(componentId, entityType); + await resetAllConfigurations(entityId, entityType); } finally { setIsResettingAll(false); } - }, [componentId, resetAllConfigurations, entityType]); + }, [entityId, resetAllConfigurations, entityType]); if (isLoadingConfigurations && parameters.length === 0) { return ( diff --git a/src/components/DataFolderPanel.tsx b/src/components/DataFolderPanel.tsx deleted file mode 100644 index 77686ad..0000000 --- a/src/components/DataFolderPanel.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useEffect, useCallback } from 'react'; -import { useShallow } from 'zustand/shallow'; -import { Database, Loader2, RefreshCw, Radio, ChevronRight, ArrowUp, ArrowDown } from 'lucide-react'; -import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { useAppStore, type AppState } from '@/lib/store'; - -interface DataFolderPanelProps { - /** Base path for navigation (e.g., /root/route_server) */ - basePath: string; -} - -export function DataFolderPanel({ basePath }: DataFolderPanelProps) { - const { rootEntities, selectEntity, loadChildren, expandedPaths, toggleExpanded } = useAppStore( - useShallow((state: AppState) => ({ - client: state.client, - rootEntities: state.rootEntities, - selectEntity: state.selectEntity, - loadChildren: state.loadChildren, - expandedPaths: state.expandedPaths, - toggleExpanded: state.toggleExpanded, - })) - ); - - // Find the data folder node in the tree - const dataFolderPath = `${basePath}/data`; - const findNode = useCallback((nodes: typeof rootEntities, path: string): (typeof rootEntities)[0] | null => { - for (const node of nodes) { - if (node.path === path) return node; - if (node.children) { - const found = findNode(node.children, path); - if (found) return found; - } - } - return null; - }, []); - - const dataFolder = findNode(rootEntities, dataFolderPath); - const topics = dataFolder?.children || []; - const isLoading = !dataFolder?.children && dataFolder !== null; - - // Load children if not loaded yet - useEffect(() => { - if (dataFolder && !dataFolder.children) { - loadChildren(dataFolderPath); - } - }, [dataFolder, dataFolderPath, loadChildren]); - - const handleRefresh = useCallback(() => { - loadChildren(dataFolderPath); - }, [dataFolderPath, loadChildren]); - - const handleTopicClick = useCallback( - (topicPath: string) => { - // Expand the data folder if not expanded - if (!expandedPaths.includes(dataFolderPath)) { - toggleExpanded(dataFolderPath); - } - // Navigate to topic - selectEntity(topicPath); - }, - [dataFolderPath, expandedPaths, toggleExpanded, selectEntity] - ); - - if (isLoading) { - return ( - - - - - - ); - } - - return ( - - -
-
- - Topics - ({topics.length} topics) -
- -
-
- - {topics.length === 0 ? ( -
- No topics available for this component. -
- ) : ( -
- {topics.map((topic) => { - // Extract direction info from topic data - const topicData = topic.data as - | { isPublisher?: boolean; isSubscriber?: boolean; type?: string } - | undefined; - const isPublisher = topicData?.isPublisher ?? false; - const isSubscriber = topicData?.isSubscriber ?? false; - const topicType = topicData?.type || 'Unknown'; - - return ( -
handleTopicClick(topic.path)} - > - -
-
{topic.name}
-
{topicType}
-
- {/* Direction indicators */} -
- {isPublisher && ( - - - - )} - {isSubscriber && ( - - - - )} -
- -
- ); - })} -
- )} -
-
- ); -} diff --git a/src/components/TopicDiagnosticsPanel.tsx b/src/components/DataPanel.tsx similarity index 94% rename from src/components/TopicDiagnosticsPanel.tsx rename to src/components/DataPanel.tsx index 9d02ace..ca4262a 100644 --- a/src/components/TopicDiagnosticsPanel.tsx +++ b/src/components/DataPanel.tsx @@ -6,15 +6,17 @@ import { Badge } from '@/components/ui/badge'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { JsonFormViewer } from '@/components/JsonFormViewer'; import { TopicPublishForm } from '@/components/TopicPublishForm'; -import type { ComponentTopic, TopicEndpoint, QosProfile } from '@/lib/types'; +import type { ComponentTopic, TopicEndpoint, QosProfile, SovdResourceEntityType } from '@/lib/types'; import type { SovdApiClient } from '@/lib/sovd-api'; import { cn } from '@/lib/utils'; -interface TopicDiagnosticsPanelProps { - /** Topic data from the API */ +interface DataPanelProps { + /** Data item from the API */ topic: ComponentTopic; - /** Component ID for publishing */ - componentId: string; + /** Entity ID for publishing */ + entityId: string; + /** Entity type for API endpoint */ + entityType?: SovdResourceEntityType; /** API client for publishing */ client: SovdApiClient | null; /** Whether a refresh is in progress */ @@ -188,15 +190,16 @@ function QosDetails({ publishers, subscribers }: { publishers?: TopicEndpoint[]; } /** - * TopicDiagnosticsPanel - Full diagnostic view for a topic + * DataPanel - Full diagnostic view for a data item */ -export function TopicDiagnosticsPanel({ +export function DataPanel({ topic, - componentId, + entityId, + entityType = 'components', client, isRefreshing = false, onRefresh, -}: TopicDiagnosticsPanelProps) { +}: DataPanelProps) { const [publishValue, setPublishValue] = useState(topic.type_info?.default_value || topic.data || {}); const hasData = topic.status === 'data' && topic.data !== null && topic.data !== undefined; @@ -223,9 +226,7 @@ export function TopicDiagnosticsPanel({ )} - - Topic diagnostics and data access - + Data diagnostics and access + {breadcrumbs.map((crumb, index) => ( +
+ + - + {getBreadcrumbIcon(crumb.type)} + {crumb.label} +
- - + ))} + + )} + + {/* Server Entity View */} + {isServer && !hasError && } + + {/* Area Entity View */} + {isArea && !hasError && ( + + )} + + {/* App Entity View */} + {isApp && !hasError && ( + + )} + + {/* Function Entity View */} + {isFunction && !hasError && ( + + )} - {/* Tab Navigation for Components */} - {isComponent && ( -
-
- {COMPONENT_TABS.map((tab) => { - const TabIcon = tab.icon; - const isActive = activeTab === tab.id; - return ( - - ); - })} + {/* Component/Generic Header */} + {!isServer && !isArea && !isApp && !isFunction && ( + + +
+
+
+ {getEntityTypeIcon()} +
+
+ {selectedEntity.name} + + {selectedEntity.type} + + {selectedPath} + +
+
+
+ + +
-
- )} - + + + {/* Tab Navigation for Components */} + {isComponent && ( +
+
+ {COMPONENT_TABS.map((tab) => { + const TabIcon = tab.icon; + const isActive = activeTab === tab.id; + const count = resourceCounts[tab.id]; + return ( + + ); + })} +
+
+ )} + + )} {/* Content based on entity type and active tab */} {hasError ? ( @@ -475,14 +776,15 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { ) : hasTopicData ? ( - // Single Topic View - use TopicDiagnosticsPanel + // Single Data View - use DataPanel (() => { const topic = selectedEntity.topicData!; return ( - - ) : selectedEntity.type === 'service' || selectedEntity.type === 'action' ? ( + ) : isArea || isApp || isFunction || isServer ? null : selectedEntity.type === 'action' || + selectedEntity.type === 'service' ? ( // Already handled above with specialized panels // Service/Action detail view - + ) : selectedEntity.type === 'parameter' ? ( // Parameter detail view - - ) : selectedEntity.folderType ? ( - // Virtual folder selected - show appropriate panel - (() => { - // Extract base path (component path) from folder path - // e.g., /root/route_server/data -> /root/route_server - const folderPathParts = selectedPath.split('/'); - folderPathParts.pop(); // Remove folder name (data/operations/configurations/faults) - const basePath = folderPathParts.join('/'); - // Determine entity type from folder data - const entityType = selectedEntity.entityType === 'app' ? 'apps' : 'components'; - return ( - - ); - })() + ) : ( @@ -541,10 +824,12 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) { ); } - // Fallback - no data loaded yet + // Fallback - show server info while loading return ( -
- +
+
+ +
); } diff --git a/src/components/EntityDetailSkeleton.tsx b/src/components/EntityDetailSkeleton.tsx new file mode 100644 index 0000000..457d929 --- /dev/null +++ b/src/components/EntityDetailSkeleton.tsx @@ -0,0 +1,102 @@ +import { Skeleton } from '@/components/ui/skeleton'; +import { Card, CardHeader, CardContent } from '@/components/ui/card'; + +/** + * Skeleton loading state for entity detail panels + */ +export function EntityDetailSkeleton() { + return ( +
+ {/* Breadcrumb skeleton */} +
+ + + + + +
+ + {/* Header card skeleton */} + + +
+
+ +
+ +
+ + + +
+
+
+
+ + +
+
+
+
+ + {/* Content card skeleton */} + + +
+ + +
+
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+
+
+
+ ); +} + +/** + * Skeleton for statistics cards grid + */ +export function StatCardsSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+ ); +} + +/** + * Skeleton for resource list items + */ +export function ResourceListSkeleton({ count = 5 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ + + + +
+ ))} +
+ ); +} diff --git a/src/components/EntityResourceTabs.tsx b/src/components/EntityResourceTabs.tsx new file mode 100644 index 0000000..7331243 --- /dev/null +++ b/src/components/EntityResourceTabs.tsx @@ -0,0 +1,234 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { Database, Zap, Settings, AlertTriangle, Loader2, MessageSquare } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { useAppStore } from '@/lib/store'; +import { ConfigurationPanel } from '@/components/ConfigurationPanel'; +import { OperationsPanel } from '@/components/OperationsPanel'; +import { FaultsPanel } from '@/components/FaultsPanel'; +import type { SovdResourceEntityType } from '@/lib/sovd-api'; +import type { ComponentTopic, Operation, Parameter, Fault } from '@/lib/types'; + +type ResourceTab = 'data' | 'operations' | 'configurations' | 'faults'; + +interface TabConfig { + id: ResourceTab; + label: string; + icon: typeof Database; +} + +const RESOURCE_TABS: TabConfig[] = [ + { id: 'data', label: 'Data', icon: Database }, + { id: 'operations', label: 'Operations', icon: Zap }, + { id: 'configurations', label: 'Config', icon: Settings }, + { id: 'faults', label: 'Faults', icon: AlertTriangle }, +]; + +interface EntityResourceTabsProps { + entityId: string; + entityType: SovdResourceEntityType; + /** Tree path for navigation (e.g., /server/root for areas) */ + basePath?: string; + onNavigate?: (path: string) => void; +} + +/** Track which resources have been loaded */ +interface LoadedResources { + data: boolean; + operations: boolean; + configurations: boolean; + faults: boolean; +} + +/** + * Reusable component for displaying entity resources (data, operations, configurations, faults) + * Works with areas, components, apps, and functions. + * + * Resources are lazy-loaded per tab to avoid unnecessary API calls. + */ +export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate }: EntityResourceTabsProps) { + const [activeTab, setActiveTab] = useState('data'); + const [isLoading, setIsLoading] = useState(false); + const [loadedTabs, setLoadedTabs] = useState({ + data: false, + operations: false, + configurations: false, + faults: false, + }); + const [data, setData] = useState([]); + const [operations, setOperations] = useState([]); + const [configurations, setConfigurations] = useState([]); + const [faults, setFaults] = useState([]); + + const { client, selectEntity } = useAppStore( + useShallow((state) => ({ + client: state.client, + selectEntity: state.selectEntity, + })) + ); + + // Lazy load resources for the active tab + const loadTabResources = useCallback( + async (tab: ResourceTab) => { + if (!client || loadedTabs[tab]) return; + + setIsLoading(true); + try { + switch (tab) { + case 'data': { + const dataRes = await client + .getEntityData(entityType, entityId) + .catch(() => [] as ComponentTopic[]); + setData(dataRes); + break; + } + case 'operations': { + const opsRes = await client.listOperations(entityId, entityType).catch(() => [] as Operation[]); + setOperations(opsRes); + break; + } + case 'configurations': { + const configRes = await client + .listConfigurations(entityId, entityType) + .catch(() => ({ parameters: [] })); + setConfigurations(configRes.parameters || []); + break; + } + case 'faults': { + const faultsRes = await client + .listEntityFaults(entityType, entityId) + .catch(() => ({ items: [] })); + setFaults(faultsRes.items || []); + break; + } + } + setLoadedTabs((prev) => ({ ...prev, [tab]: true })); + } catch (error) { + console.error(`Failed to load ${tab} resources:`, error); + } finally { + setIsLoading(false); + } + }, + [client, entityId, entityType, loadedTabs] + ); + + // Load resources when tab changes + useEffect(() => { + loadTabResources(activeTab); + }, [activeTab, loadTabResources]); + + const handleNavigate = (path: string) => { + if (onNavigate) { + onNavigate(path); + } else { + selectEntity(path); + } + }; + + return ( +
+ {/* Tab Navigation */} +
+ {RESOURCE_TABS.map((tab) => { + const TabIcon = tab.icon; + const isActive = activeTab === tab.id; + let count = 0; + if (tab.id === 'data') count = data.length; + if (tab.id === 'operations') count = operations.length; + if (tab.id === 'configurations') count = configurations.length; + if (tab.id === 'faults') count = faults.length; + + return ( + + ); + })} +
+ + {isLoading ? ( + + + + + + ) : ( + <> + {/* Data Tab */} + {activeTab === 'data' && ( + + + + + Data Items + + Aggregated data from child entities + + + {data.length === 0 ? ( +
+ +

No data items available.

+
+ ) : ( +
+ {data.map((item, idx) => ( +
{ + // Use basePath for tree navigation, fallback to API path format + const navPath = basePath + ? `${basePath}/data/${encodeURIComponent(item.topic)}` + : `/${entityType}/${entityId}/data/${encodeURIComponent(item.topic)}`; + handleNavigate(navPath); + }} + > + + {item.topic} + {item.type && ( + + {item.type.split('/').pop()} + + )} +
+ ))} +
+ )} +
+
+ )} + + {/* Operations Tab */} + {activeTab === 'operations' && } + + {/* Configurations Tab */} + {activeTab === 'configurations' && ( + + )} + + {/* Faults Tab */} + {activeTab === 'faults' && } + + )} +
+ ); +} diff --git a/src/components/EntityTreeNode.tsx b/src/components/EntityTreeNode.tsx index 9a4e617..4bd8c30 100644 --- a/src/components/EntityTreeNode.tsx +++ b/src/components/EntityTreeNode.tsx @@ -5,25 +5,25 @@ import { Loader2, Server, Folder, + FolderOpen, FileJson, Box, MessageSquare, ArrowUp, ArrowDown, - Database, Zap, Clock, - Settings, Sliders, AlertTriangle, Cpu, - Users, + Layers, + GitBranch, + Package, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { useAppStore } from '@/lib/store'; -import type { EntityTreeNode as EntityTreeNodeType, TopicNodeData, VirtualFolderData, Parameter } from '@/lib/types'; -import { isVirtualFolderData } from '@/lib/types'; +import type { EntityTreeNode as EntityTreeNodeType, TopicNodeData, Parameter } from '@/lib/types'; interface EntityTreeNodeProps { node: EntityTreeNodeType; @@ -32,35 +32,36 @@ interface EntityTreeNodeProps { /** * Get icon for entity type + * + * Entity types (structural): + * - Area: Layers (namespace grouping) + * - Subarea: Layers (nested namespace) + * - Component: Box (logical grouping) + * - Subcomponent: Box (nested component) + * - App: Cpu (ROS 2 node) + * - Function: GitBranch (capability grouping) */ -function getEntityIcon(type: string, data?: unknown) { - // Check for virtual folder types - if (isVirtualFolderData(data)) { - const folderData = data as VirtualFolderData; - switch (folderData.folderType) { - case 'data': - return Database; - case 'operations': - return Zap; - case 'configurations': - return Settings; - case 'faults': - return AlertTriangle; - case 'apps': - return Users; - } - } - - switch (type.toLowerCase()) { - case 'device': - case 'server': - return Server; +function getEntityIcon(type: string, isExpanded?: boolean) { + switch ((type || '').toLowerCase()) { + // Entity types + case 'area': + case 'subarea': + return Layers; case 'component': + case 'subcomponent': case 'ecu': return Box; + case 'app': + return Cpu; + case 'function': + return GitBranch; + // Collection/folder types case 'folder': - case 'area': - return Folder; + return isExpanded ? FolderOpen : Folder; + case 'device': + case 'server': + return Server; + // Resource item types case 'topic': return MessageSquare; case 'service': @@ -69,15 +70,50 @@ function getEntityIcon(type: string, data?: unknown) { return Clock; case 'parameter': return Sliders; - case 'app': - return Cpu; case 'fault': return AlertTriangle; + case 'package': + return Package; default: return FileJson; } } +/** + * Get color class for entity type + */ +function getEntityColor(type: string, isSelected?: boolean): string { + if (isSelected) return 'text-primary'; + + switch (type.toLowerCase()) { + case 'area': + return 'text-cyan-500'; + case 'subarea': + return 'text-cyan-400'; + case 'component': + case 'ecu': + return 'text-indigo-500'; + case 'subcomponent': + return 'text-indigo-400'; + case 'app': + return 'text-emerald-500'; + case 'function': + return 'text-violet-500'; + case 'server': + return 'text-primary'; + case 'topic': + return 'text-blue-400'; + case 'service': + return 'text-amber-400'; + case 'action': + return 'text-orange-400'; + case 'fault': + return 'text-red-400'; + default: + return 'text-muted-foreground'; + } +} + /** * Check if node data is TopicNodeData (from topicsInfo) */ @@ -108,13 +144,14 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { const isLoading = loadingPaths.includes(node.path); const isSelected = selectedPath === node.path; const hasChildren = node.hasChildren !== false; // Default to true if not specified - const Icon = getEntityIcon(node.type, node.data); + const Icon = getEntityIcon(node.type, isExpanded); + const iconColorClass = getEntityColor(node.type, isSelected); // Get topic direction info if available const topicData = isTopicNodeData(node.data) ? node.data : null; const parameterData = isParameterData(node.data) ? node.data : null; - // Load children when expanded and no children loaded yet + // Load children when expanded useEffect(() => { if (isExpanded && !node.children && !isLoading && hasChildren) { loadChildren(node.path); @@ -135,15 +172,34 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { return (
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSelect(e as unknown as React.MouseEvent); + } else if (e.key === 'ArrowRight' && hasChildren && !isExpanded) { + e.preventDefault(); + handleToggle(); + } else if (e.key === 'ArrowLeft' && hasChildren && isExpanded) { + e.preventDefault(); + handleToggle(); + } + }} > e.stopPropagation()}>
diff --git a/src/components/EntityTreeSidebar.tsx b/src/components/EntityTreeSidebar.tsx index 4335f77..e23184a 100644 --- a/src/components/EntityTreeSidebar.tsx +++ b/src/components/EntityTreeSidebar.tsx @@ -1,15 +1,19 @@ import { useState, useMemo } from 'react'; import { useShallow } from 'zustand/shallow'; -import { Server, Settings, RefreshCw, Search, X } from 'lucide-react'; +import { Server, Settings, RefreshCw, Search, X, AlertTriangle, Layers, GitBranch } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { EntityTreeNode } from '@/components/EntityTreeNode'; +import { EntityTreeSkeleton } from '@/components/EntityTreeSkeleton'; +import { ThemeToggle } from '@/components/ThemeToggle'; import { EmptyState } from '@/components/EmptyState'; -import { useAppStore } from '@/lib/store'; +import { FaultsCountBadge } from '@/components/FaultsDashboard'; +import { useAppStore, type TreeViewMode } from '@/lib/store'; import type { EntityTreeNode as EntityTreeNodeType } from '@/lib/types'; interface EntityTreeSidebarProps { onSettingsClick: () => void; + onFaultsDashboardClick?: () => void; } /** @@ -37,17 +41,30 @@ function filterTree(nodes: EntityTreeNodeType[], query: string): EntityTreeNodeT return result; } -export function EntityTreeSidebar({ onSettingsClick }: EntityTreeSidebarProps) { +export function EntityTreeSidebar({ onSettingsClick, onFaultsDashboardClick }: EntityTreeSidebarProps) { const [searchQuery, setSearchQuery] = useState(''); + const [isRefreshing, setIsRefreshing] = useState(false); - const { isConnected, serverUrl, rootEntities, loadRootEntities } = useAppStore( - useShallow((state) => ({ - isConnected: state.isConnected, - serverUrl: state.serverUrl, - rootEntities: state.rootEntities, - loadRootEntities: state.loadRootEntities, - })) - ); + const { isConnected, isConnecting, serverUrl, rootEntities, loadRootEntities, treeViewMode, setTreeViewMode } = + useAppStore( + useShallow((state) => ({ + isConnected: state.isConnected, + isConnecting: state.isConnecting, + serverUrl: state.serverUrl, + rootEntities: state.rootEntities, + loadRootEntities: state.loadRootEntities, + treeViewMode: state.treeViewMode, + setTreeViewMode: state.setTreeViewMode, + })) + ); + + const handleViewModeChange = async (mode: TreeViewMode) => { + if (mode !== treeViewMode) { + setIsRefreshing(true); + await setTreeViewMode(mode); + setIsRefreshing(false); + } + }; const filteredEntities = useMemo(() => { if (!searchQuery.trim()) { @@ -56,23 +73,28 @@ export function EntityTreeSidebar({ onSettingsClick }: EntityTreeSidebarProps) { return filterTree(rootEntities, searchQuery.trim()); }, [rootEntities, searchQuery]); - const handleRefresh = () => { - loadRootEntities(); + const handleRefresh = async () => { + setIsRefreshing(true); + await loadRootEntities(); + setIsRefreshing(false); }; const handleClearSearch = () => { setSearchQuery(''); }; + const isLoading = isConnecting || (isConnected && rootEntities.length === 0 && !searchQuery); + return ( ); } diff --git a/src/components/EntityTreeSkeleton.tsx b/src/components/EntityTreeSkeleton.tsx new file mode 100644 index 0000000..b3556f6 --- /dev/null +++ b/src/components/EntityTreeSkeleton.tsx @@ -0,0 +1,54 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +interface EntityTreeSkeletonProps { + /** Number of root items to show */ + itemCount?: number; +} + +/** + * Skeleton loading state for the entity tree sidebar + */ +export function EntityTreeSkeleton({ itemCount = 4 }: EntityTreeSkeletonProps) { + return ( +
+ {Array.from({ length: itemCount }).map((_, i) => ( +
+ {/* Root level item */} +
+ + + + +
+ {/* Simulated children for first two items */} + {i < 2 && ( +
+ {Array.from({ length: 2 }).map((_, j) => ( +
+ + + + +
+ ))} +
+ )} +
+ ))} +
+ ); +} + +/** + * Skeleton for a single tree node + */ +export function TreeNodeSkeleton() { + return ( +
+ + + + +
+ ); +} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..646c346 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,214 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { AlertCircle, RefreshCw, Home, Bug } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; + /** Called when error is caught */ + onError?: (error: Error, errorInfo: ErrorInfo) => void; + /** Called when user clicks retry */ + onRetry?: () => void; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; + showDetails: boolean; +} + +/** + * Error Boundary component for catching React errors + * + * Features: + * - Catches render errors in child components + * - Provides retry functionality + * - Shows error details in collapsible section + * - Reports errors via onError callback + */ +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + showDetails: false, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + this.setState({ errorInfo }); + + // Report error + if (this.props.onError) { + this.props.onError(error, errorInfo); + } + + // Log to console in development + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + handleRetry = (): void => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + showDetails: false, + }); + + if (this.props.onRetry) { + this.props.onRetry(); + } + }; + + handleReload = (): void => { + window.location.reload(); + }; + + toggleDetails = (): void => { + this.setState((prev) => ({ showDetails: !prev.showDetails })); + }; + + render(): ReactNode { + if (this.state.hasError) { + // Custom fallback provided + if (this.props.fallback) { + return this.props.fallback; + } + + // Default error UI + return ( +
+ + +
+
+ +
+
+ Something went wrong + + An unexpected error occurred while rendering this component. + +
+
+
+ + {/* Error message */} +
+

+ {this.state.error?.message || 'Unknown error'} +

+
+ + {/* Error details (collapsible) */} + + + + + +
+

Error Stack:

+
+                                            {this.state.error?.stack || 'No stack trace available'}
+                                        
+ {this.state.errorInfo?.componentStack && ( + <> +

Component Stack:

+
+                                                    {this.state.errorInfo.componentStack}
+                                                
+ + )} +
+
+
+
+ + + + +
+
+ ); + } + + return this.props.children; + } +} + +/** + * Hook-based error boundary wrapper for functional components + * Useful for wrapping specific sections of the UI + */ +interface ErrorBoundaryWrapperProps { + children: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; + onRetry?: () => void; + title?: string; + description?: string; +} + +export function ErrorBoundaryWrapper({ + children, + onError, + onRetry, + title, + description, +}: ErrorBoundaryWrapperProps): React.JSX.Element { + return ( + + ) : undefined + } + > + {children} + + ); +} + +/** + * Simple error fallback component + */ +interface ErrorFallbackProps { + title?: string; + description?: string; + onRetry?: () => void; +} + +function ErrorFallback({ title, description, onRetry }: ErrorFallbackProps): React.JSX.Element { + return ( +
+ +

{title || 'Error loading content'}

+ {description &&

{description}

} + {onRetry && ( + + )} +
+ ); +} diff --git a/src/components/FaultsDashboard.tsx b/src/components/FaultsDashboard.tsx new file mode 100644 index 0000000..360e069 --- /dev/null +++ b/src/components/FaultsDashboard.tsx @@ -0,0 +1,721 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { + AlertTriangle, + AlertCircle, + AlertOctagon, + Info, + CheckCircle, + RefreshCw, + Filter, + Trash2, + Loader2, + ChevronDown, + ChevronRight, +} from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuCheckboxItem, + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, +} from '@/components/ui/dropdown-menu'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAppStore } from '@/lib/store'; +import type { Fault, FaultSeverity, FaultStatus } from '@/lib/types'; +import type { SovdResourceEntityType } 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 + */ +function getSeverityBadgeVariant(severity: FaultSeverity): 'default' | 'secondary' | 'destructive' | 'outline' { + switch (severity) { + case 'critical': + case 'error': + return 'destructive'; + case 'warning': + return 'default'; + case 'info': + return 'secondary'; + default: + return 'outline'; + } +} + +/** + * Get icon for fault severity + */ +function getSeverityIcon(severity: FaultSeverity) { + switch (severity) { + case 'critical': + return ; + case 'error': + return ; + case 'warning': + return ; + case 'info': + return ; + default: + return ; + } +} + +/** + * Get color class for severity + */ +function getSeverityColorClass(severity: FaultSeverity): string { + switch (severity) { + case 'critical': + return 'text-red-600 dark:text-red-400'; + case 'error': + return 'text-orange-600 dark:text-orange-400'; + case 'warning': + return 'text-yellow-600 dark:text-yellow-400'; + case 'info': + return 'text-blue-600 dark:text-blue-400'; + default: + return 'text-muted-foreground'; + } +} + +/** + * Format timestamp for display + */ +function formatTimestamp(timestamp: string): string { + try { + const date = new Date(timestamp); + return date.toLocaleString(); + } catch { + return timestamp; + } +} + +/** + * Single fault row component + */ +function FaultRow({ + fault, + onClear, + isClearing, +}: { + fault: Fault; + onClear: (code: string) => void; + isClearing: boolean; +}) { + const canClear = fault.status === 'active' || fault.status === 'pending'; + + return ( +
+ {/* 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 && ( + + )} +
+ ); +} + +/** + * Group of faults by entity + */ +function FaultGroup({ + entityId, + entityType, + faults, + onClear, + clearingCodes, +}: { + entityId: string; + entityType: string; + faults: Fault[]; + onClear: (code: string) => void; + clearingCodes: Set; +}) { + const [isOpen, setIsOpen] = useState(true); + + const criticalCount = faults.filter((f) => f.severity === 'critical' || f.severity === 'error').length; + const warningCount = faults.filter((f) => f.severity === 'warning').length; + + return ( + + +
+ {isOpen ? : } + {entityId} + + {entityType} + +
+ {criticalCount > 0 && ( + + {criticalCount} + + )} + {warningCount > 0 && ( + + {warningCount} + + )} + + {faults.length} total + +
+ + + {faults.map((fault) => ( + + ))} + + + ); +} + +/** + * Loading skeleton for dashboard + */ +function DashboardSkeleton() { + return ( +
+
+ + + +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ + + +
+ + +
+ ))} +
+
+ ); +} + +/** + * Faults Dashboard - displays all faults across the system + * + * Features: + * - Real-time updates via shared store polling + * - Filtering by severity and status + * - Grouping by entity + * - Clear fault actions + * + * Uses shared faults state from useAppStore to avoid duplicate API calls + * when both FaultsDashboard and FaultsCountBadge are visible. + */ +export function FaultsDashboard() { + const [isRefreshing, setIsRefreshing] = useState(false); + const [autoRefresh, setAutoRefresh] = useState(true); + const [clearingCodes, setClearingCodes] = useState>(new Set()); + + // Filters + const [severityFilters, setSeverityFilters] = useState>( + new Set(['critical', 'error', 'warning', 'info']) + ); + const [statusFilters, setStatusFilters] = useState>(new Set(['active', 'pending'])); + const [groupByEntity, setGroupByEntity] = useState(true); + + // Use shared faults state from store + const { faults, isLoadingFaults, isConnected, fetchFaults, clearFault } = useAppStore( + useShallow((state) => ({ + faults: state.faults, + isLoadingFaults: state.isLoadingFaults, + isConnected: state.isConnected, + fetchFaults: state.fetchFaults, + clearFault: state.clearFault, + })) + ); + + // Load faults on mount + useEffect(() => { + if (isConnected) { + fetchFaults(); + } + }, [isConnected, fetchFaults]); + + // Auto-refresh polling using shared store + useEffect(() => { + if (!autoRefresh || !isConnected) return; + + const interval = setInterval(() => { + fetchFaults(); + }, DEFAULT_POLL_INTERVAL); + + return () => clearInterval(interval); + }, [autoRefresh, isConnected, fetchFaults]); + + // Manual refresh handler + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + await fetchFaults(); + setIsRefreshing(false); + }, [fetchFaults]); + + // Clear fault handler + const handleClear = useCallback( + async (code: string) => { + setClearingCodes((prev) => new Set([...prev, code])); + + try { + // Find the fault to get entity info + const fault = faults.find((f) => f.code === code); + if (fault) { + // Map the fault's entity_type to the correct resource type for the API + const entityGroup = mapFaultEntityTypeToResourceType(fault.entity_type); + // Use store's clearFault which has proper error handling with toasts + await clearFault(entityGroup, fault.entity_id, code); + } + // Reload faults after clearing + await fetchFaults(); + } finally { + setClearingCodes((prev) => { + const next = new Set(prev); + next.delete(code); + return next; + }); + } + }, + [faults, fetchFaults, clearFault] + ); + + // Filter faults + const filteredFaults = useMemo(() => { + return faults.filter((f) => severityFilters.has(f.severity) && statusFilters.has(f.status)); + }, [faults, severityFilters, statusFilters]); + + // Group faults by entity + const groupedFaults = useMemo(() => { + const groups = new Map(); + + for (const fault of filteredFaults) { + const key = fault.entity_id; + if (!groups.has(key)) { + groups.set(key, { entityType: fault.entity_type, faults: [] }); + } + groups.get(key)!.faults.push(fault); + } + + // Sort groups by number of critical/error faults + return Array.from(groups.entries()).sort((a, b) => { + const aCritical = a[1].faults.filter((f) => f.severity === 'critical' || f.severity === 'error').length; + const bCritical = b[1].faults.filter((f) => f.severity === 'critical' || f.severity === 'error').length; + return bCritical - aCritical; + }); + }, [filteredFaults]); + + // Count by severity + const counts = useMemo(() => { + return { + critical: faults.filter((f) => f.severity === 'critical').length, + error: faults.filter((f) => f.severity === 'error').length, + warning: faults.filter((f) => f.severity === 'warning').length, + info: faults.filter((f) => f.severity === 'info').length, + total: faults.length, + }; + }, [faults]); + + // Toggle severity filter + const toggleSeverity = (severity: FaultSeverity) => { + setSeverityFilters((prev) => { + const next = new Set(prev); + if (next.has(severity)) { + next.delete(severity); + } else { + next.add(severity); + } + return next; + }); + }; + + // Toggle status filter + const toggleStatus = (status: FaultStatus) => { + setStatusFilters((prev) => { + const next = new Set(prev); + if (next.has(status)) { + next.delete(status); + } else { + next.add(status); + } + return next; + }); + }; + + if (!isConnected) { + return ( + + +
+ +

Connect to a server to view faults.

+
+
+
+ ); + } + + if (isLoadingFaults && faults.length === 0) { + return ( +
+ + + + + Faults Dashboard + + + + + + +
+ ); + } + + return ( +
+ {/* Header Card */} + + +
+
+ + + Faults Dashboard + + + {counts.total === 0 + ? 'No faults detected' + : `${counts.total} fault${counts.total !== 1 ? 's' : ''} detected`} + +
+
+ {/* Auto-refresh toggle */} +
+ + +
+ {/* Manual refresh */} + +
+
+
+ + {/* Summary badges */} +
+ {counts.critical > 0 && ( + + + {counts.critical} Critical + + )} + {counts.error > 0 && ( + + + {counts.error} Error + + )} + {counts.warning > 0 && ( + + + {counts.warning} Warning + + )} + {counts.info > 0 && ( + + + {counts.info} Info + + )} + {counts.total === 0 && ( + + + All Clear + + )} +
+ + {/* Filters */} +
+ {/* Severity filter */} + + + + + + Filter by Severity + + toggleSeverity('critical')} + > + + Critical + + toggleSeverity('error')} + > + + Error + + toggleSeverity('warning')} + > + + Warning + + toggleSeverity('info')} + > + + Info + + + + + {/* Status filter */} + + + + + + Filter by Status + + toggleStatus('active')} + > + Active + + toggleStatus('pending')} + > + Pending + + toggleStatus('cleared')} + > + Cleared + + + + + {/* Group by toggle */} +
+ + +
+
+
+
+ + {/* Faults List */} + {filteredFaults.length === 0 ? ( + + +
+ +

No faults to display

+

+ {faults.length > 0 + ? 'Adjust filters to see more faults' + : 'System is operating normally'} +

+
+
+
+ ) : groupByEntity ? ( + + + {groupedFaults.map(([entityId, { entityType, faults: entityFaults }]) => ( + + ))} + + + ) : ( + + + {filteredFaults.map((fault) => ( + + ))} + + + )} +
+ ); +} + +/** + * Faults count badge for sidebar + * + * Uses shared faults state from useAppStore to avoid duplicate polling. + * The main polling happens in FaultsDashboard or when faults are fetched elsewhere. + */ +export function FaultsCountBadge() { + const { faults, isConnected, fetchFaults } = useAppStore( + useShallow((state) => ({ + faults: state.faults, + isConnected: state.isConnected, + fetchFaults: state.fetchFaults, + })) + ); + + // Trigger initial fetch and set up polling when connected + useEffect(() => { + if (!isConnected) return; + + // Initial fetch + fetchFaults(); + + // Poll for updates when document is visible + const interval = setInterval(() => { + if (!document.hidden) { + fetchFaults(); + } + }, DEFAULT_POLL_INTERVAL); + + // Also listen for visibility changes to refresh when tab becomes visible + const handleVisibilityChange = () => { + if (!document.hidden) { + fetchFaults(); + } + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + clearInterval(interval); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [isConnected, fetchFaults]); + + // Count active critical/error faults + const count = useMemo(() => { + return faults.filter((f) => f.status === 'active' && (f.severity === 'critical' || f.severity === 'error')) + .length; + }, [faults]); + + if (count === 0) return null; + + return ( + + {count} + + ); +} diff --git a/src/components/FaultsPanel.tsx b/src/components/FaultsPanel.tsx index 9f3ba70..48f9af2 100644 --- a/src/components/FaultsPanel.tsx +++ b/src/components/FaultsPanel.tsx @@ -6,11 +6,12 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { useAppStore, type AppState } from '@/lib/store'; import type { Fault, FaultSeverity, FaultStatus } from '@/lib/types'; +import type { SovdResourceEntityType } from '@/lib/sovd-api'; interface FaultsPanelProps { - componentId: string; - /** Type of entity: 'components' or 'apps' */ - entityType?: 'components' | 'apps'; + entityId: string; + /** Type of entity */ + entityType?: SovdResourceEntityType; } /** @@ -143,7 +144,7 @@ function FaultRow({ /** * Panel displaying faults for a component or app */ -export function FaultsPanel({ componentId, entityType = 'components' }: FaultsPanelProps) { +export function FaultsPanel({ entityId, entityType = 'components' }: FaultsPanelProps) { const [faults, setFaults] = useState([]); const [isLoading, setIsLoading] = useState(true); const [clearingCodes, setClearingCodes] = useState>(new Set()); @@ -162,7 +163,7 @@ export function FaultsPanel({ componentId, entityType = 'components' }: FaultsPa setError(null); try { - const response = await client.listEntityFaults(entityType, componentId); + const response = await client.listEntityFaults(entityType, entityId); setFaults(response.items || []); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load faults'); @@ -170,7 +171,7 @@ export function FaultsPanel({ componentId, entityType = 'components' }: FaultsPa } finally { setIsLoading(false); } - }, [client, componentId, entityType]); + }, [client, entityId, entityType]); useEffect(() => { loadFaults(); @@ -183,7 +184,7 @@ export function FaultsPanel({ componentId, entityType = 'components' }: FaultsPa setClearingCodes((prev) => new Set([...prev, code])); try { - await client.clearFault(entityType, componentId, code); + await client.clearFault(entityType, entityId, code); // Reload faults after clearing await loadFaults(); } catch { @@ -196,7 +197,7 @@ export function FaultsPanel({ componentId, entityType = 'components' }: FaultsPa }); } }, - [client, componentId, entityType, loadFaults] + [client, entityId, entityType, loadFaults] ); // Count faults by severity diff --git a/src/components/FunctionsPanel.tsx b/src/components/FunctionsPanel.tsx new file mode 100644 index 0000000..431808f --- /dev/null +++ b/src/components/FunctionsPanel.tsx @@ -0,0 +1,369 @@ +import { useState, useEffect } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { + GitBranch, + Cpu, + Database, + Zap, + ChevronRight, + Users, + Info, + Settings, + AlertTriangle, + Loader2, +} from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { useAppStore } from '@/lib/store'; +import { ConfigurationPanel } from '@/components/ConfigurationPanel'; +import { OperationsPanel } from '@/components/OperationsPanel'; +import { FaultsPanel } from '@/components/FaultsPanel'; +import type { ComponentTopic, Operation, Parameter, Fault } from '@/lib/types'; + +/** Host app object returned from /functions/{id}/hosts */ +interface FunctionHost { + id: string; + name: string; + href: string; +} + +type FunctionTab = 'overview' | 'hosts' | 'data' | 'operations' | 'configurations' | 'faults'; + +interface TabConfig { + id: FunctionTab; + label: string; + icon: typeof Database; +} + +const FUNCTION_TABS: TabConfig[] = [ + { id: 'overview', label: 'Overview', icon: Info }, + { id: 'hosts', label: 'Hosts', icon: Cpu }, + { id: 'data', label: 'Data', icon: Database }, + { id: 'operations', label: 'Operations', icon: Zap }, + { id: 'configurations', label: 'Config', icon: Settings }, + { id: 'faults', label: 'Faults', icon: AlertTriangle }, +]; + +interface FunctionsPanelProps { + functionId: string; + functionName?: string; + description?: string; + path: string; + onNavigate?: (path: string) => void; +} + +/** + * Functions Panel - displays function (capability grouping) entity details + * + * Functions are capability groupings in SOVD. They can have: + * - Hosts (apps that implement this function) + * - Data (aggregated from all hosts) + * - Operations (aggregated from all hosts) + */ +export function FunctionsPanel({ functionId, functionName, description, path, onNavigate }: FunctionsPanelProps) { + const [activeTab, setActiveTab] = useState('overview'); + const [hosts, setHosts] = useState([]); + const [topics, setTopics] = useState([]); + const [operations, setOperations] = useState([]); + const [configurations, setConfigurations] = useState([]); + const [faults, setFaults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const { client, selectEntity } = useAppStore( + useShallow((state) => ({ + client: state.client, + selectEntity: state.selectEntity, + })) + ); + + // Load function resources on mount + useEffect(() => { + const loadFunctionData = async () => { + if (!client) return; + setIsLoading(true); + + try { + // Load hosts, data, operations, configurations, and faults in parallel + const [hostsData, topicsData, opsData, configData, faultsData] = await Promise.all([ + client.getFunctionHosts + ? client.getFunctionHosts(functionId).catch(() => [] as FunctionHost[]) + : Promise.resolve([]), + client.getFunctionData + ? client.getFunctionData(functionId).catch(() => [] as ComponentTopic[]) + : Promise.resolve([]), + client.getFunctionOperations + ? client.getFunctionOperations(functionId).catch(() => [] as Operation[]) + : Promise.resolve([]), + client.listConfigurations(functionId, 'functions').catch(() => ({ parameters: [] })), + client.listEntityFaults('functions', functionId).catch(() => ({ items: [] })), + ]); + + // Normalize hosts - API returns objects with {id, name, href} + const normalizedHosts = hostsData.map((h: unknown) => { + if (typeof h === 'string') { + return { id: h, name: h, href: `/api/v1/apps/${h}` }; + } + const hostObj = h as FunctionHost; + return { id: hostObj.id, name: hostObj.name || hostObj.id, href: hostObj.href || '' }; + }); + + setHosts(normalizedHosts); + setTopics(topicsData); + setOperations(opsData); + setConfigurations(configData.parameters || []); + setFaults(faultsData.items || []); + } catch (error) { + console.error('Failed to load function data:', error); + } finally { + setIsLoading(false); + } + }; + + loadFunctionData(); + }, [client, functionId]); + + const handleResourceClick = (resourcePath: string) => { + if (onNavigate) { + onNavigate(resourcePath); + } else { + selectEntity(resourcePath); + } + }; + + return ( +
+ {/* Function Header */} + + +
+
+ +
+
+ {functionName || functionId} + + + function + + + {path} + +
+
+
+ + {/* Tab Navigation */} +
+
+ {FUNCTION_TABS.map((tab) => { + const TabIcon = tab.icon; + const isActive = activeTab === tab.id; + let count = 0; + if (tab.id === 'hosts') count = hosts.length; + if (tab.id === 'data') count = topics.length; + if (tab.id === 'operations') count = operations.length; + if (tab.id === 'configurations') count = configurations.length; + if (tab.id === 'faults') count = faults.length; + + return ( + + ); + })} +
+
+
+ + {/* Tab Content */} + {activeTab === 'overview' && ( + + + Function Information + + + {description && ( +
+
+ + Description +
+

{description}

+
+ )} + + {/* Resource Summary */} +
+ + + + + +
+ + {hosts.length === 0 && !isLoading && ( +
+ +

No host apps are implementing this function yet.

+
+ )} +
+
+ )} + + {activeTab === 'hosts' && ( + + + + + Host Apps + + Apps implementing this function + + + {hosts.length === 0 ? ( +
+ +

No host apps found for this function.

+
+ ) : ( +
+ {hosts.map((host) => ( +
handleResourceClick(`/apps/${host.id}`)} + > +
+ +
+ {host.name} + {host.id} + + app + + +
+ ))} +
+ )} +
+
+ )} + + {activeTab === 'data' && ( + + + + + Aggregated Data + + Data items from all host apps + + + {topics.length === 0 ? ( +
+ +

No data items available.

+
+ ) : ( +
+ {topics.map((topic, idx) => { + const cleanName = topic.topic.startsWith('/') ? topic.topic.slice(1) : topic.topic; + const encodedName = encodeURIComponent(cleanName); + const topicPath = `${path}/data/${encodedName}`; + return ( +
handleResourceClick(topicPath)} + > + + topic + + {topic.topic} + {topic.type && ( + + {topic.type} + + )} + +
+ ); + })} +
+ )} +
+
+ )} + + {activeTab === 'operations' && } + + {activeTab === 'configurations' && } + + {activeTab === 'faults' && } + + {isLoading && ( + + + + + + )} +
+ ); +} diff --git a/src/components/OperationsPanel.tsx b/src/components/OperationsPanel.tsx index 58dfcb0..b2fd2e9 100644 --- a/src/components/OperationsPanel.tsx +++ b/src/components/OperationsPanel.tsx @@ -32,6 +32,7 @@ import { ActionStatusPanel } from './ActionStatusPanel'; import { SchemaForm } from './SchemaFormField'; import { getSchemaDefaults } from '@/lib/schema-utils'; import { OperationResponseDisplay } from './OperationResponse'; +import type { SovdResourceEntityType } from '@/lib/sovd-api'; /** History entry for an operation invocation */ interface OperationHistoryEntry { @@ -42,11 +43,11 @@ interface OperationHistoryEntry { } interface OperationsPanelProps { - componentId: string; + entityId: string; /** Optional: highlight and auto-expand a specific operation */ highlightOperation?: string; /** Entity type for API calls */ - entityType?: 'components' | 'apps'; + entityType?: SovdResourceEntityType; } /** @@ -106,14 +107,16 @@ function isEmptySchema(schema: TopicSchema | null): boolean { */ function OperationRow({ operation, - componentId, + entityId, onInvoke, defaultExpanded = false, + entityType = 'components', }: { operation: Operation; - componentId: string; + entityId: string; onInvoke: (opName: string, payload: unknown) => Promise; defaultExpanded?: boolean; + entityType?: SovdResourceEntityType; }) { const [isExpanded, setIsExpanded] = useState(defaultExpanded); const [useFormView, setUseFormView] = useState(true); @@ -356,9 +359,10 @@ function OperationRow({ {/* Action status monitoring for latest action */} {latestExecutionId && operation.kind === 'action' && ( )} @@ -427,7 +431,7 @@ function OperationRow({ ); } -export function OperationsPanel({ componentId, highlightOperation, entityType = 'components' }: OperationsPanelProps) { +export function OperationsPanel({ entityId, highlightOperation, entityType = 'components' }: OperationsPanelProps) { const { operations, isLoadingOperations, fetchOperations, createExecution } = useAppStore( useShallow((state: AppState) => ({ operations: state.operations, @@ -437,29 +441,28 @@ export function OperationsPanel({ componentId, highlightOperation, entityType = })) ); - const componentOperations = operations.get(componentId) || []; - const services = componentOperations.filter((op) => op.kind === 'service'); - const actions = componentOperations.filter((op) => op.kind === 'action'); + const entityOperations = operations.get(entityId) || []; + const services = entityOperations.filter((op) => op.kind === 'service'); + const actions = entityOperations.filter((op) => op.kind === 'action'); - // Fetch operations on mount (lazy loading) + // Fetch operations on mount or when entityId/entityType changes useEffect(() => { - if (!operations.has(componentId)) { - fetchOperations(componentId, entityType); - } - }, [componentId, operations, fetchOperations, entityType]); + // Always fetch when entityId or entityType changes to ensure fresh data + fetchOperations(entityId, entityType); + }, [entityId, entityType, fetchOperations]); const handleRefresh = useCallback(() => { - fetchOperations(componentId, entityType); - }, [componentId, fetchOperations, entityType]); + fetchOperations(entityId, entityType); + }, [entityId, fetchOperations, entityType]); const handleInvoke = useCallback( async (opName: string, payload: unknown) => { - return createExecution(componentId, opName, payload as Parameters[2], entityType); + return createExecution(entityId, opName, payload as Parameters[2], entityType); }, - [componentId, createExecution, entityType] + [entityId, createExecution, entityType] ); - if (isLoadingOperations && componentOperations.length === 0) { + if (isLoadingOperations && entityOperations.length === 0) { return ( @@ -486,11 +489,11 @@ export function OperationsPanel({ componentId, highlightOperation, entityType =
- {componentOperations.length === 0 ? ( + {entityOperations.length === 0 ? (

No operations available

-

This component has no services or actions

+

This entity has no services or actions

) : (
@@ -506,9 +509,10 @@ export function OperationsPanel({ componentId, highlightOperation, entityType = ))}
@@ -527,9 +531,10 @@ export function OperationsPanel({ componentId, highlightOperation, entityType = ))}
diff --git a/src/components/SearchCommand.tsx b/src/components/SearchCommand.tsx new file mode 100644 index 0000000..994ee4e --- /dev/null +++ b/src/components/SearchCommand.tsx @@ -0,0 +1,190 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { Layers, Box, Cpu, GitBranch, Search } from 'lucide-react'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { useAppStore } from '@/lib/store'; +import type { EntityTreeNode } from '@/lib/types'; + +/** + * Flatten tree nodes for search indexing + * + * TODO: This only indexes nodes currently in tree state. Unexpanded entity children + * (e.g. apps under components) aren't searchable until the parent is expanded. + * Consider fetching a full entity list from API for comprehensive search. + */ +function flattenTree(nodes: EntityTreeNode[]): EntityTreeNode[] { + const result: EntityTreeNode[] = []; + + for (const node of nodes) { + result.push(node); + + if (node.children && node.children.length > 0) { + result.push(...flattenTree(node.children)); + } + } + + return result; +} + +/** + * Get icon for entity type + */ +function getEntityIcon(type: string) { + switch (type) { + case 'area': + return Layers; + case 'component': + return Box; + case 'app': + return Cpu; + case 'function': + return GitBranch; + default: + return Search; + } +} + +/** + * Get color class for entity type + */ +function getEntityColorClass(type: string): string { + switch (type) { + case 'area': + return 'text-cyan-500'; + case 'component': + return 'text-indigo-500'; + case 'app': + return 'text-emerald-500'; + case 'function': + return 'text-violet-500'; + default: + return 'text-muted-foreground'; + } +} + +interface SearchCommandProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * Search Command Palette - Ctrl+K to open + * + * Allows quick navigation to any entity in the tree. + */ +export function SearchCommand({ open, onOpenChange }: SearchCommandProps) { + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + + const { rootEntities, selectEntity, isConnected } = useAppStore( + useShallow((state) => ({ + rootEntities: state.rootEntities, + selectEntity: state.selectEntity, + isConnected: state.isConnected, + })) + ); + + // Debounce search input for better performance on large trees + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(search); + }, 150); + return () => clearTimeout(timer); + }, [search]); + + // Memoize flattened tree to avoid recalculating on every render + const allEntities = useMemo(() => flattenTree(rootEntities), [rootEntities]); + + // Memoize filtered entities based on debounced search + const filteredEntities = useMemo(() => { + if (!debouncedSearch) { + return allEntities.slice(0, 20); // Show first 20 when no search + } + const searchLower = debouncedSearch.toLowerCase(); + return allEntities.filter( + (entity) => + entity.name.toLowerCase().includes(searchLower) || + entity.id.toLowerCase().includes(searchLower) || + entity.path.toLowerCase().includes(searchLower) + ); + }, [allEntities, debouncedSearch]); + + const handleSelect = useCallback( + (path: string) => { + selectEntity(path); + onOpenChange(false); + setSearch(''); + }, + [selectEntity, onOpenChange] + ); + + // Clear search when closing + useEffect(() => { + if (!open) { + setSearch(''); + } + }, [open]); + + if (!isConnected) { + return ( + + + + Connect to a server first. + + + ); + } + + return ( + + + + No entities found. + + {/* Group by entity type */} + {['area', 'component', 'app', 'function'].map((type) => { + const typeEntities = filteredEntities.filter((e) => e.type === type); + if (typeEntities.length === 0) return null; + + const label = type.charAt(0).toUpperCase() + type.slice(1) + 's'; + + return ( + + {typeEntities.map((entity) => { + const EntityIcon = getEntityIcon(entity.type); + return ( + handleSelect(entity.path)} + className="cursor-pointer" + > + +
+ {entity.name} + + {entity.path} + +
+
+ ); + })} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/ServerInfoPanel.tsx b/src/components/ServerInfoPanel.tsx new file mode 100644 index 0000000..9a3ad74 --- /dev/null +++ b/src/components/ServerInfoPanel.tsx @@ -0,0 +1,219 @@ +import { useState, useEffect } from 'react'; +import { useShallow } from 'zustand/shallow'; +import { Server, Info, CheckCircle2, ExternalLink } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAppStore } from '@/lib/store'; +import type { ServerCapabilities, VersionInfo } from '@/lib/types'; + +/** + * Server Info Panel - displays SOVD server capabilities and version info + * + * Shows: + * - SOVD specification version + * - Server implementation name/version + * - Supported features list + * - Available entry points (API collections) + */ +export function ServerInfoPanel() { + const [capabilities, setCapabilities] = useState(null); + const [versionInfo, setVersionInfo] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const { client, isConnected, serverUrl } = useAppStore( + useShallow((state) => ({ + client: state.client, + isConnected: state.isConnected, + serverUrl: state.serverUrl, + })) + ); + + useEffect(() => { + const loadServerInfo = async () => { + if (!client || !isConnected) { + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + + try { + const [caps, version] = await Promise.all([ + client.getServerCapabilities().catch(() => null), + client.getVersionInfo().catch(() => null), + ]); + + setCapabilities(caps); + setVersionInfo(version); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load server info'); + } finally { + setIsLoading(false); + } + }; + + loadServerInfo(); + }, [client, isConnected]); + + if (!isConnected) { + return ( + + +
+ +

Connect to a server to view its information.

+
+
+
+ ); + } + + if (isLoading) { + return ( + + +
+ +
+ + +
+
+
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+
+ ); + } + + if (error) { + return ( + + +
+ +

Could not load server information.

+

{error}

+
+
+
+ ); + } + + // Extract first SOVD info entry for cleaner access + const sovdInfo = versionInfo?.sovd_info?.[0]; + + return ( +
+ {/* Server Overview */} + + +
+
+ +
+
+ + {sovdInfo?.vendor_info?.name || capabilities?.server_name || 'SOVD Server'} + + + + + Connected + + + {serverUrl} + +
+
+
+ +
+
+
SOVD Version
+

+ {sovdInfo?.version || capabilities?.sovd_version || 'Unknown'} +

+
+ {sovdInfo?.vendor_info?.version && ( +
+
Implementation Version
+

{sovdInfo.vendor_info.version}

+
+ )} + {capabilities?.server_version && ( +
+
Server Version
+

{capabilities.server_version}

+
+ )} + {sovdInfo?.base_uri && ( +
+
Base URI
+

{sovdInfo.base_uri}

+
+ )} +
+
+
+ + {/* Supported Features */} + {capabilities?.supported_features && capabilities.supported_features.length > 0 && ( + + + + + Supported Features + + Capabilities available on this server + + +
+ {capabilities.supported_features.map((feature) => ( + + {feature} + + ))} +
+
+
+ )} + + {/* Entry Points / Collections */} + {capabilities?.entry_points && Object.keys(capabilities.entry_points).length > 0 && ( + + + + + API Entry Points + + Available resource collections + + +
+ {Object.entries(capabilities.entry_points).map(([name, url]) => ( +
+ {name} + + {url} + +
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..8126067 --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react'; +import { Moon, Sun, Monitor } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +type Theme = 'light' | 'dark' | 'system'; + +/** + * Hook to manage theme state with localStorage persistence + */ +function useTheme() { + const [theme, setThemeState] = useState(() => { + if (typeof window !== 'undefined') { + return (localStorage.getItem('theme') as Theme) || 'system'; + } + return 'system'; + }); + + useEffect(() => { + const root = window.document.documentElement; + + // Remove existing theme classes + root.classList.remove('light', 'dark'); + + if (theme === 'system') { + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + root.classList.add(systemTheme); + } else { + root.classList.add(theme); + } + + localStorage.setItem('theme', theme); + }, [theme]); + + // Listen for system theme changes + useEffect(() => { + if (theme !== 'system') return; + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = () => { + const root = window.document.documentElement; + root.classList.remove('light', 'dark'); + root.classList.add(mediaQuery.matches ? 'dark' : 'light'); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, [theme]); + + return { theme, setTheme: setThemeState }; +} + +/** + * Theme toggle dropdown component + * Allows switching between light, dark, and system themes + */ +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + const getCurrentIcon = () => { + switch (theme) { + case 'light': + return ; + case 'dark': + return ; + default: + return ; + } + }; + + return ( + + + + + + setTheme('light')}> + + Light + {theme === 'light' && } + + setTheme('dark')}> + + Dark + {theme === 'dark' && } + + setTheme('system')}> + + System + {theme === 'system' && } + + + + ); +} diff --git a/src/components/TopicPublishForm.tsx b/src/components/TopicPublishForm.tsx index ddfd2c9..78be907 100644 --- a/src/components/TopicPublishForm.tsx +++ b/src/components/TopicPublishForm.tsx @@ -5,14 +5,16 @@ import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { SchemaForm } from '@/components/SchemaFormField'; import { getSchemaDefaults, deepMerge } from '@/lib/schema-utils'; -import type { ComponentTopic, TopicSchema } from '@/lib/types'; +import type { ComponentTopic, TopicSchema, SovdResourceEntityType } from '@/lib/types'; import type { SovdApiClient } from '@/lib/sovd-api'; interface TopicPublishFormProps { /** The topic to publish to */ topic: ComponentTopic; - /** Component ID (for API calls) */ - componentId: string; + /** Entity ID (for API calls) */ + entityId: string; + /** Entity type for API endpoint */ + entityType?: SovdResourceEntityType; /** API client instance */ client: SovdApiClient; /** External initial value (overrides topic-based defaults) */ @@ -59,7 +61,14 @@ function getInitialValues(topic: ComponentTopic): Record { * Form for publishing messages to a ROS 2 topic * Supports both schema-based form view and raw JSON editing */ -export function TopicPublishForm({ topic, componentId, client, initialValue, onValueChange }: TopicPublishFormProps) { +export function TopicPublishForm({ + topic, + entityId, + entityType = 'components', + client, + initialValue, + onValueChange, +}: TopicPublishFormProps) { const [viewMode, setViewMode] = useState('form'); const [formValues, setFormValues] = useState>(() => { if (initialValue && typeof initialValue === 'object') { @@ -128,11 +137,9 @@ export function TopicPublishForm({ topic, componentId, client, initialValue, onV }; const handlePublish = async () => { - // Extract topic name - remove leading slash and component namespace prefix - // Full topic path example: "/powertrain/engine/temperature" - // We need just the last segment for the API: "temperature" - const topicSegments = topic.topic.split('/').filter((s) => s); - const topicName = topicSegments[topicSegments.length - 1] || topic.topic; + // Use the full topic path for the API, but strip leading slash for cleaner URL + // The backend adds it back if needed + const topicName = topic.topic.startsWith('/') ? topic.topic.slice(1) : topic.topic; // Validate and get data to publish let dataToPublish: unknown; @@ -158,7 +165,7 @@ export function TopicPublishForm({ topic, componentId, client, initialValue, onV setIsPublishing(true); try { - await client.publishToComponentTopic(componentId, topicName, { + await client.publishToEntityData(entityType, entityId, topicName, { type: messageType, data: dataToPublish, }); @@ -197,7 +204,7 @@ export function TopicPublishForm({ topic, componentId, client, initialValue, onV return (
{/* View mode toggle - only show if we have schema */} - {hasSchema && ( + {hasSchema ? (
+ ) : ( +
+ {topic.type ? ( + Type: {topic.type} (schema not available) + ) : ( + Message type unknown - topic may not exist on ROS 2 graph yet + )} +
)} {/* Form or JSON editor */} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..5d2eea5 --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,137 @@ +'use client'; + +import * as React from 'react'; +import { Command as CommandPrimitive } from 'cmdk'; +import { SearchIcon } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; + +function Command({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandDialog({ + title = 'Command Palette', + description = 'Search for a command to run...', + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ); +} + +function CommandInput({ className, ...props }: React.ComponentProps) { + return ( +
+ + +
+ ); +} + +function CommandList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandEmpty({ ...props }: React.ComponentProps) { + return ; +} + +function CommandGroup({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandItem({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ); +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..bb2e398 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,219 @@ +'use client'; + +import * as React from 'react'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: 'default' | 'destructive'; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..2c690c8 --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,7 @@ +import { cn } from '@/lib/utils'; + +function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +export { Skeleton }; diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..2a92100 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import * as SwitchPrimitive from '@radix-ui/react-switch'; + +import { cn } from '@/lib/utils'; + +function Switch({ + className, + size = 'default', + ...props +}: React.ComponentProps & { + size?: 'sm' | 'default'; +}) { + return ( + + + + ); +} + +export { Switch }; diff --git a/src/hooks/useSearchShortcut.ts b/src/hooks/useSearchShortcut.ts new file mode 100644 index 0000000..2440184 --- /dev/null +++ b/src/hooks/useSearchShortcut.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; + +/** + * Hook for keyboard shortcut (Ctrl+K / Cmd+K) to open search + */ +export function useSearchShortcut(onOpen: () => void) { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + onOpen(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onOpen]); +} diff --git a/src/lib/schema-utils.ts b/src/lib/schema-utils.ts index 4e010b1..cb8714d 100644 --- a/src/lib/schema-utils.ts +++ b/src/lib/schema-utils.ts @@ -1,5 +1,102 @@ import type { SchemaFieldType, TopicSchema } from '@/lib/types'; +// ============================================================================= +// JSON Schema to TopicSchema Conversion +// ============================================================================= + +/** + * JSON Schema format returned by the API + */ +interface JsonSchemaField { + type?: string; + properties?: Record; + items?: JsonSchemaField; +} + +/** + * Map JSON Schema types to ROS 2 primitive types + */ +function mapJsonSchemaType(type: string | undefined): string { + if (!type) return 'object'; + switch (type) { + case 'integer': + return 'int32'; + case 'number': + return 'float64'; + case 'boolean': + return 'bool'; + case 'string': + return 'string'; + case 'array': + return 'array'; + case 'object': + return 'object'; + default: + return type; + } +} + +/** + * Convert a single JSON Schema field to SchemaFieldType + */ +function convertJsonSchemaField(field: JsonSchemaField): SchemaFieldType { + const result: SchemaFieldType = { + type: mapJsonSchemaType(field.type), + }; + + // Handle nested objects (properties -> fields) + if (field.properties) { + result.fields = {}; + for (const [key, value] of Object.entries(field.properties)) { + result.fields[key] = convertJsonSchemaField(value); + } + } + + // Handle arrays + if (field.items) { + result.items = convertJsonSchemaField(field.items); + } + + return result; +} + +/** + * Convert JSON Schema format (from API) to TopicSchema format (for frontend) + * + * API returns: + * ```json + * { "type": "object", "properties": { "field": { "type": "integer" } } } + * ``` + * + * Frontend expects: + * ```json + * { "field": { "type": "int32" } } + * ``` + */ +export function convertJsonSchemaToTopicSchema(jsonSchema: unknown): TopicSchema | undefined { + if (!jsonSchema || typeof jsonSchema !== 'object') { + return undefined; + } + + const schema = jsonSchema as JsonSchemaField; + + // If it has properties at root level, convert them + if (schema.properties) { + const result: TopicSchema = {}; + for (const [key, value] of Object.entries(schema.properties)) { + result[key] = convertJsonSchemaField(value); + } + return result; + } + + // Already in TopicSchema format or unknown format + return jsonSchema as TopicSchema; +} + +// ============================================================================= +// Type Checking Utilities +// ============================================================================= + /** * Check if a type is a primitive ROS 2 type */ diff --git a/src/lib/sovd-api.ts b/src/lib/sovd-api.ts index 0bd36bc..f41eee2 100644 --- a/src/lib/sovd-api.ts +++ b/src/lib/sovd-api.ts @@ -11,7 +11,9 @@ import type { ResetConfigurationResponse, ResetAllConfigurationsResponse, Operation, + OperationKind, DataItemResponse, + Parameter, // New SOVD-compliant types Execution, CreateExecutionRequest, @@ -29,7 +31,23 @@ import type { ServerCapabilities, VersionInfo, SovdError, + SovdResourceEntityType, } from './types'; +import { convertJsonSchemaToTopicSchema } from './schema-utils'; + +// Re-export SovdResourceEntityType for convenience +export type { SovdResourceEntityType }; + +/** Resource collection types available on entities */ +export type ResourceCollectionType = 'data' | 'operations' | 'configurations' | 'faults'; + +/** Map of resource types to their list result types */ +export interface ResourceListResults { + data: ComponentTopic[]; + operations: Operation[]; + configurations: ComponentConfigurations; + faults: ListFaultsResponse; +} /** * Helper to unwrap items from SOVD API response @@ -108,6 +126,15 @@ function normalizeBasePath(path: string): string { return normalized; } +/** + * Strip direction suffix (:publish, :subscribe, :both) from topic name + * The frontend adds these suffixes to uniqueKey for UI purposes, + * but the API expects the original topic ID without the suffix. + */ +function stripDirectionSuffix(topicName: string): string { + return topicName.replace(/:(publish|subscribe|both)$/, ''); +} + /** * SOVD API Client for discovery endpoints */ @@ -150,6 +177,237 @@ export class SovdApiClient { } } + // =========================================================================== + // GENERIC RESOURCE API (unified entry point for all resource collections) + // =========================================================================== + + /** + * Generic method to fetch any resource collection for any entity type. + * This is the single entry point for all resource operations to ensure consistency. + * + * @param entityType The type of entity (areas, components, apps, functions) + * @param entityId The entity identifier + * @param resourceType The resource collection type (data, operations, configurations, faults) + * @returns The resource collection with appropriate type + */ + async getResources( + entityType: SovdResourceEntityType, + entityId: string, + resourceType: T + ): Promise { + const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/${resourceType}`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + if (response.status === 404) { + // Return empty collection for 404 + return this.emptyResourceResult(resourceType); + } + throw new Error(`HTTP ${response.status}`); + } + + const rawData = await response.json(); + return this.transformResourceData(resourceType, rawData, entityId); + } + + /** + * Get empty result for a resource type (used for 404 responses) + */ + private emptyResourceResult(resourceType: T): ResourceListResults[T] { + const results: ResourceListResults = { + data: [], + operations: [], + configurations: { component_id: '', node_name: '', parameters: [] }, + faults: { items: [], count: 0 }, + }; + return results[resourceType] as ResourceListResults[T]; + } + + /** + * Transform raw API response to typed resource collection + */ + private transformResourceData( + resourceType: T, + rawData: unknown, + entityId: string + ): ResourceListResults[T] { + switch (resourceType) { + case 'data': + return this.transformDataResponse(rawData) as ResourceListResults[T]; + case 'operations': + return this.transformOperationsResponse(rawData) as ResourceListResults[T]; + case 'configurations': + return this.transformConfigurationsResponse(rawData, entityId) as ResourceListResults[T]; + case 'faults': + return this.transformFaultsResponse(rawData) as ResourceListResults[T]; + default: + throw new Error(`Unknown resource type: ${resourceType}`); + } + } + + /** + * Transform operations API response to Operation[] + * Extracts kind, type, and type_info from x-medkit extension + */ + private transformOperationsResponse(rawData: unknown): Operation[] { + interface RawOperation { + id: string; + name: string; + asynchronous_execution?: boolean; + 'x-medkit'?: { + entity_id?: string; + ros2?: { + kind?: 'service' | 'action'; + service?: string; + action?: string; + type?: string; + }; + type_info?: { + request?: unknown; + response?: unknown; + goal?: unknown; + result?: unknown; + feedback?: unknown; + }; + }; + } + const rawOps = unwrapItems(rawData); + return rawOps.map((op) => { + const xMedkit = op['x-medkit']; + const ros2Info = xMedkit?.ros2; + const rawTypeInfo = xMedkit?.type_info; + + // Determine kind from x-medkit.ros2.kind or from asynchronous_execution + let kind: OperationKind = 'service'; + if (ros2Info?.kind) { + kind = ros2Info.kind; + } else if (op.asynchronous_execution) { + kind = 'action'; + } + + // Build type_info with appropriate schema structure + let typeInfo: Operation['type_info'] | undefined; + if (rawTypeInfo) { + if (kind === 'service' && (rawTypeInfo.request || rawTypeInfo.response)) { + typeInfo = { + schema: { + request: + (rawTypeInfo.request + ? convertJsonSchemaToTopicSchema(rawTypeInfo.request) + : undefined) ?? {}, + response: + (rawTypeInfo.response + ? convertJsonSchemaToTopicSchema(rawTypeInfo.response) + : undefined) ?? {}, + }, + }; + } else if (kind === 'action' && (rawTypeInfo.goal || rawTypeInfo.result)) { + typeInfo = { + schema: { + goal: + (rawTypeInfo.goal ? convertJsonSchemaToTopicSchema(rawTypeInfo.goal) : undefined) ?? {}, + result: + (rawTypeInfo.result ? convertJsonSchemaToTopicSchema(rawTypeInfo.result) : undefined) ?? + {}, + feedback: + (rawTypeInfo.feedback + ? convertJsonSchemaToTopicSchema(rawTypeInfo.feedback) + : undefined) ?? {}, + }, + }; + } + } + + return { + name: op.name || op.id, + path: ros2Info?.service || ros2Info?.action || `/${op.name}`, + type: ros2Info?.type || '', + kind, + type_info: typeInfo, + }; + }); + } + + /** + * Transform data API response to ComponentTopic[] + */ + private transformDataResponse(rawData: unknown): ComponentTopic[] { + interface DataItem { + id: string; + name: string; + category?: string; + 'x-medkit'?: { + ros2?: { topic?: string; type?: string; direction?: string }; + type_info?: { schema?: unknown; default_value?: unknown }; + }; + } + const dataItems = unwrapItems(rawData); + return dataItems.map((item) => { + const rawTypeInfo = item['x-medkit']?.type_info; + const convertedSchema = rawTypeInfo?.schema + ? convertJsonSchemaToTopicSchema(rawTypeInfo.schema) + : undefined; + const direction = item['x-medkit']?.ros2?.direction; + const topicName = item.name || item['x-medkit']?.ros2?.topic || item.id; + return { + topic: topicName, + timestamp: Date.now() * 1000000, + data: null, + status: 'metadata_only' as const, + type: item['x-medkit']?.ros2?.type, + type_info: convertedSchema + ? { + schema: convertedSchema, + default_value: rawTypeInfo?.default_value as Record, + } + : undefined, + // Direction-based fields for apps/functions + isPublisher: direction === 'publish' || direction === 'both', + isSubscriber: direction === 'subscribe' || direction === 'both', + uniqueKey: direction ? `${topicName}:${direction}` : topicName, + }; + }); + } + + /** + * Transform configurations API response to ComponentConfigurations + */ + private transformConfigurationsResponse(rawData: unknown, entityId: string): ComponentConfigurations { + const data = rawData as { + 'x-medkit'?: { + entity_id?: string; + ros2?: { node?: string }; + parameters?: Parameter[]; + }; + }; + const xMedkit = data['x-medkit'] || {}; + return { + component_id: xMedkit.entity_id || entityId, + node_name: xMedkit.ros2?.node || entityId, + parameters: xMedkit.parameters || [], + }; + } + + /** + * Transform faults API response to ListFaultsResponse + */ + private transformFaultsResponse(rawData: unknown): ListFaultsResponse { + const data = rawData as { + items?: unknown[]; + 'x-medkit'?: { count?: number }; + }; + const items = (data.items || []).map((f: unknown) => + this.transformFault(f as Parameters[0]) + ); + return { items, count: data['x-medkit']?.count || items.length }; + } + + // =========================================================================== + // ENTITY TREE NAVIGATION + // =========================================================================== + /** * Get root entities or children of a specific path * @param path Optional path to get children of (e.g., "/devices/robot1") @@ -265,103 +523,120 @@ export class SovdApiClient { * @param path Entity path (e.g., "/area/component") */ async getEntityDetails(path: string): Promise { - // Path comes from the tree, e.g. "/area_id/component_id" - const parts = path.split('/').filter((p) => p); + // Path comes from the tree, e.g. "/server/area_id/component_id" or "/area_id/component_id" + let parts = path.split('/').filter((p) => p); - // Handle virtual folder paths: /area/component/data/topic or /area/component/apps/app/data/topic - // Transform to: components/{component}/data/{topic} or apps/{app}/data/{topic} for API call - if (parts.length >= 4 && parts.includes('data')) { + // Remove 'server' prefix if present (tree paths start with /server) + if (parts[0] === 'server') { + parts = parts.slice(1); + } + + // Handle virtual folder paths with /data/ + // Patterns: + // /area/data/topic → areas/{area}/data/{topic} + // /area/component/data/topic → components/{component}/data/{topic} + // /area/component/apps/app/data/topic → apps/{app}/data/{topic} + // /functions/function/data/topic → functions/{function}/data/{topic} + if (parts.includes('data')) { const dataIndex = parts.indexOf('data'); - // Check if this is an app topic (path contains 'apps' before 'data') + const topicName = parts[dataIndex + 1]; + + if (!topicName) { + throw new Error('Invalid path: missing topic name after /data/'); + } + + const decodedTopicName = decodeURIComponent(topicName); + + // Determine entity type and ID based on path structure + let entityType: SovdResourceEntityType; + let entityId: string; + const appsIndex = parts.indexOf('apps'); - const isAppTopic = appsIndex !== -1 && appsIndex < dataIndex; + const functionsIndex = parts.indexOf('functions'); - if (isAppTopic && dataIndex >= 2) { + if (appsIndex !== -1 && appsIndex < dataIndex) { // App topic: /area/component/apps/app/data/topic - const appId = parts[appsIndex + 1]!; - const encodedTopicName = parts[dataIndex + 1]!; - const decodedTopicName = decodeURIComponent(encodedTopicName); + entityType = 'apps'; + entityId = parts[appsIndex + 1]!; + } else if (functionsIndex !== -1 && functionsIndex < dataIndex) { + // Function topic: /functions/function/data/topic + entityType = 'functions'; + entityId = parts[functionsIndex + 1]!; + } else if (dataIndex === 1) { + // Area topic: /area/data/topic (dataIndex is 1, meaning only area before data) + entityType = 'areas'; + entityId = parts[0]!; + } else { + // Component topic: /area/component/data/topic (dataIndex is 2+) + entityType = 'components'; + entityId = parts[dataIndex - 1]!; + } - const response = await fetchWithTimeout(this.getUrl(`apps/${appId}/data/${encodedTopicName}`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - if (response.status === 404) { - throw new Error(`Topic ${decodedTopicName} not found for app ${appId}`); - } - throw new Error(`HTTP ${response.status}`); - } + // Use the generic getTopicDetails method + const topic = await this.getTopicDetails(entityType, entityId, decodedTopicName); - // API returns {data, id, x-medkit: {ros2: {type, topic, direction}, ...}} - const item = (await response.json()) as DataItemResponse; - const xMedkit = item['x-medkit']; - const ros2 = xMedkit?.ros2; - - const topic: ComponentTopic = { - topic: ros2?.topic || `/${decodedTopicName}`, - timestamp: xMedkit?.timestamp || Date.now() * 1000000, - data: item.data, - status: (xMedkit?.status as 'data' | 'metadata_only') || 'data', - type: ros2?.type, - publisher_count: xMedkit?.publisher_count, - subscriber_count: xMedkit?.subscriber_count, - isPublisher: ros2?.direction === 'publish', - isSubscriber: ros2?.direction === 'subscribe', - }; + return { + id: topicName, + name: topic.topic, + href: path, + topicData: topic, + rosType: topic.type, + type: 'topic', + }; + } - return { - id: encodedTopicName, - name: topic.topic, - href: path, - topicData: topic, - rosType: topic.type, - type: 'topic', - }; + // Handle virtual folder paths with /operations/ + // Patterns: + // /area/operations/op → areas/{area}/operations/{op} + // /area/component/operations/op → components/{component}/operations/{op} + // /area/component/apps/app/operations/op → apps/{app}/operations/{op} + // /functions/function/operations/op → functions/{function}/operations/{op} + if (parts.includes('operations')) { + const opsIndex = parts.indexOf('operations'); + const operationName = parts[opsIndex + 1]; + + if (!operationName) { + throw new Error('Invalid path: missing operation name after /operations/'); } - // Component topic: /area/component/data/topic - const componentId = parts[1]!; - const encodedTopicName = parts[dataIndex + 1]!; - const decodedTopicName = decodeURIComponent(encodedTopicName); + const decodedOpName = decodeURIComponent(operationName); - const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/data/${encodedTopicName}`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); + // Determine entity type and ID based on path structure + let entityType: SovdResourceEntityType; + let entityId: string; - if (!response.ok) { - if (response.status === 404) { - throw new Error(`Topic ${decodedTopicName} not found for component ${componentId}`); - } - throw new Error(`HTTP ${response.status}`); + const appsIndex = parts.indexOf('apps'); + const functionsIndex = parts.indexOf('functions'); + + if (appsIndex !== -1 && appsIndex < opsIndex) { + // App operation: /area/component/apps/app/operations/op + entityType = 'apps'; + entityId = parts[appsIndex + 1]!; + } else if (functionsIndex !== -1 && functionsIndex < opsIndex) { + // Function operation: /functions/function/operations/op + entityType = 'functions'; + entityId = parts[functionsIndex + 1]!; + } else if (opsIndex === 1) { + // Area operation: /area/operations/op (opsIndex is 1, only area before operations) + entityType = 'areas'; + entityId = parts[0]!; + } else { + // Component operation: /area/component/operations/op (opsIndex is 2+) + entityType = 'components'; + entityId = parts[opsIndex - 1]!; } - // API returns {data, id, x-medkit: {ros2: {type, topic, direction}, ...}} - const item = (await response.json()) as DataItemResponse; - const xMedkit = item['x-medkit']; - const ros2 = xMedkit?.ros2; - - const topic: ComponentTopic = { - topic: ros2?.topic || `/${decodedTopicName}`, - timestamp: xMedkit?.timestamp || Date.now() * 1000000, - data: item.data, - status: (xMedkit?.status as 'data' | 'metadata_only') || 'data', - type: ros2?.type, - publisher_count: xMedkit?.publisher_count, - subscriber_count: xMedkit?.subscriber_count, - isPublisher: ros2?.direction === 'publish', - isSubscriber: ros2?.direction === 'subscribe', - }; + // Fetch the operation details + const operation = await this.getOperation(entityId, decodedOpName, entityType); return { - id: encodedTopicName, - name: topic.topic, + id: operationName, + name: operation.name, href: path, - topicData: topic, - rosType: topic.type, - type: 'topic', + type: operation.kind, // 'service' or 'action' + data: operation, + componentId: entityId, + entityType, }; } @@ -374,36 +649,8 @@ export class SovdApiClient { // e.g., 'powertrain%2Fengine%2Ftemp' -> 'powertrain/engine/temp' const decodedTopicName = decodeURIComponent(encodedTopicName); - // Use the dedicated single-topic endpoint - // The REST API expects percent-encoded topic name in the URL - const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/data/${encodedTopicName}`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - if (response.status === 404) { - throw new Error(`Topic ${decodedTopicName} not found for component ${componentId}`); - } - throw new Error(`HTTP ${response.status}`); - } - - // API returns {data, id, x-medkit: {ros2: {type, topic, direction}, ...}} - const item = (await response.json()) as DataItemResponse; - const xMedkit = item['x-medkit']; - const ros2 = xMedkit?.ros2; - - const topic: ComponentTopic = { - topic: ros2?.topic || `/${decodedTopicName}`, - timestamp: xMedkit?.timestamp || Date.now() * 1000000, - data: item.data, - status: (xMedkit?.status as 'data' | 'metadata_only') || 'data', - type: ros2?.type, - publisher_count: xMedkit?.publisher_count, - subscriber_count: xMedkit?.subscriber_count, - isPublisher: ros2?.direction === 'publish', - isSubscriber: ros2?.direction === 'subscribe', - }; + // Use the generic getTopicDetails method + const topic = await this.getTopicDetails('components', componentId, decodedTopicName); return { id: encodedTopicName, @@ -423,7 +670,8 @@ export class SovdApiClient { }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - const topicsData = unwrapItems(await response.json()); + // Use the proper transformation to convert API format to ComponentTopic[] + const topicsData = this.transformDataResponse(await response.json()); // Build topicsInfo from fetched data for navigation // AND keep full topics array for detailed view (QoS, publishers, etc.) @@ -462,18 +710,20 @@ export class SovdApiClient { } /** - * Publish to a component topic - * @param componentId Component ID - * @param topicName Topic name (relative to component namespace) + * Publish to entity data (generic for components, apps, functions, areas) + * @param entityType Entity type (components, apps, functions, areas) + * @param entityId Entity ID + * @param topicName Topic name (full path without leading slash) * @param request Publish request with type and data */ - async publishToComponentTopic( - componentId: string, + async publishToEntityData( + entityType: SovdResourceEntityType, + entityId: string, topicName: string, request: ComponentTopicPublishRequest ): Promise { const response = await fetchWithTimeout( - this.getUrl(`components/${componentId}/data/${topicName}`), + this.getUrl(`${entityType}/${entityId}/data/${topicName}`), { method: 'PUT', headers: { @@ -532,47 +782,26 @@ export class SovdApiClient { /** * List all configurations (parameters) for an entity - * @param entityId Entity ID (component or app) - * @param entityType Entity type ('components' or 'apps') + * @param entityId Entity ID + * @param entityType Entity type */ async listConfigurations( entityId: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { - const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/configurations`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - error?: string; - details?: string; - }; - throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`); - } - - const data = await response.json(); - // API returns {items: [...], x-medkit: {parameters: [...]}} - // Transform to ComponentConfigurations format - const xMedkit = data['x-medkit'] || {}; - return { - component_id: xMedkit.entity_id || entityId, - node_name: xMedkit.ros2?.node || entityId, - parameters: xMedkit.parameters || [], - }; + return this.getResources(entityType, entityId, 'configurations'); } /** * Get a specific configuration (parameter) value and metadata - * @param entityId Entity ID (component or app) + * @param entityId Entity ID * @param paramName Parameter name - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async getConfiguration( entityId: string, paramName: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/configurations/${encodeURIComponent(paramName)}`), @@ -595,16 +824,16 @@ export class SovdApiClient { /** * Set a configuration (parameter) value - * @param entityId Entity ID (component or app) + * @param entityId Entity ID * @param paramName Parameter name * @param request Request with new value - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async setConfiguration( entityId: string, paramName: string, request: SetConfigurationRequest, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/configurations/${encodeURIComponent(paramName)}`), @@ -631,14 +860,14 @@ export class SovdApiClient { /** * Reset a configuration (parameter) to its default value - * @param entityId Entity ID (component or app) + * @param entityId Entity ID * @param paramName Parameter name - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async resetConfiguration( entityId: string, paramName: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/configurations/${encodeURIComponent(paramName)}`), @@ -658,17 +887,19 @@ export class SovdApiClient { throw new Error(errorData.error || errorData.details || `HTTP ${response.status}`); } - return await response.json(); + // 204 No Content means success - return the response body or defaults + const data = await response.json().catch(() => ({})); + return data as ResetConfigurationResponse; } /** * Reset all configurations for an entity to their default values - * @param entityId Entity ID (component or app) - * @param entityType Entity type ('components' or 'apps') + * @param entityId Entity ID + * @param entityType Entity type */ async resetAllConfigurations( entityId: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/configurations`), { method: 'DELETE', @@ -695,35 +926,23 @@ export class SovdApiClient { /** * List all operations (services + actions) for an entity (component or app) - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type * @param entityId Entity ID */ - async listOperations(entityId: string, entityType: 'components' | 'apps' = 'components'): Promise { - const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/operations`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - if (response.status === 404) { - return []; - } - throw new Error(`HTTP ${response.status}`); - } - - return unwrapItems(await response.json()); + async listOperations(entityId: string, entityType: SovdResourceEntityType = 'components'): Promise { + return this.getResources(entityType, entityId, 'operations'); } /** * Get details of a specific operation * @param entityId Entity ID * @param operationName Operation name - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async getOperation( entityId: string, operationName: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/operations/${encodeURIComponent(operationName)}`), @@ -746,13 +965,13 @@ export class SovdApiClient { * @param entityId Entity ID (component or app) * @param operationName Operation name * @param request Execution request with input data - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async createExecution( entityId: string, operationName: string, request: CreateExecutionRequest, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/operations/${encodeURIComponent(operationName)}/executions`), @@ -779,12 +998,12 @@ export class SovdApiClient { * List all executions for an operation * @param entityId Entity ID * @param operationName Operation name - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async listExecutions( entityId: string, operationName: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/operations/${encodeURIComponent(operationName)}/executions`), @@ -807,13 +1026,13 @@ export class SovdApiClient { * @param entityId Entity ID * @param operationName Operation name * @param executionId Execution ID - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async getExecution( entityId: string, operationName: string, executionId: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl( @@ -838,13 +1057,13 @@ export class SovdApiClient { * @param entityId Entity ID * @param operationName Operation name * @param executionId Execution ID - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type */ async cancelExecution( entityId: string, operationName: string, executionId: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ): Promise { const response = await fetchWithTimeout( this.getUrl( @@ -987,44 +1206,7 @@ export class SovdApiClient { * @param appId App identifier */ async getAppData(appId: string): Promise { - const response = await fetchWithTimeout(this.getUrl(`apps/${appId}/data`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - // API returns {items: [{id, name, category, x-medkit}]} - interface DataItem { - id: string; - name: string; - category?: string; - 'x-medkit'?: { ros2?: { topic?: string; direction?: string; type?: string } }; - } - const dataItems = unwrapItems(await response.json()); - - // Transform to ComponentTopic format - // NOTE: Same topic can appear twice with different directions (publish/subscribe) - // We include direction in the key to make them unique - return dataItems.map((item) => { - const topicName = item.name || item['x-medkit']?.ros2?.topic || item.id; - const direction = item['x-medkit']?.ros2?.direction; - const type = item['x-medkit']?.ros2?.type; - return { - topic: topicName, - timestamp: Date.now() * 1000000, - data: null, - status: 'metadata_only' as const, - // Include direction for unique key generation - isPublisher: direction === 'publish', - isSubscriber: direction === 'subscribe', - // Include unique key combining topic and direction - uniqueKey: direction ? `${topicName}:${direction}` : topicName, - type, - }; - }); + return this.getResources('apps', appId, 'data'); } /** @@ -1069,7 +1251,7 @@ export class SovdApiClient { */ async publishToAppTopic(appId: string, topicName: string, request: ComponentTopicPublishRequest): Promise { const response = await fetchWithTimeout( - this.getUrl(`apps/${appId}/data/${encodeURIComponent(topicName)}`), + this.getUrl(`apps/${appId}/data/${topicName}`), { method: 'PUT', headers: { @@ -1146,8 +1328,9 @@ export class SovdApiClient { /** * Get apps hosting a function * @param functionId Function identifier + * @returns Array of host objects (with id, name, href) */ - async getFunctionHosts(functionId: string): Promise { + async getFunctionHosts(functionId: string): Promise { const response = await fetchWithTimeout(this.getUrl(`functions/${functionId}/hosts`), { method: 'GET', headers: { Accept: 'application/json' }, @@ -1166,16 +1349,7 @@ export class SovdApiClient { * @param functionId Function identifier */ async getFunctionData(functionId: string): Promise { - const response = await fetchWithTimeout(this.getUrl(`functions/${functionId}/data`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - return []; - } - - return unwrapItems(await response.json()); + return this.getResources('functions', functionId, 'data'); } /** @@ -1183,16 +1357,79 @@ export class SovdApiClient { * @param functionId Function identifier */ async getFunctionOperations(functionId: string): Promise { - const response = await fetchWithTimeout(this.getUrl(`functions/${functionId}/operations`), { + return this.getResources('functions', functionId, 'operations'); + } + + // =========================================================================== + // GENERIC ENTITY RESOURCES (for aggregated views) + // =========================================================================== + + /** + * Get data items for any entity type (areas, components, apps, functions) + * Returns aggregated data from child entities + * @param entityType Entity type + * @param entityId Entity identifier + */ + async getEntityData(entityType: SovdResourceEntityType, entityId: string): Promise { + return this.getResources(entityType, entityId, 'data'); + } + + // =========================================================================== + // GENERIC TOPIC DETAILS (works for all entity types) + // =========================================================================== + + /** + * Get topic details for any entity type (areas, components, apps, functions) + * @param entityType The type of entity + * @param entityId The entity identifier + * @param topicName The topic name (will strip direction suffix if present) + */ + async getTopicDetails( + entityType: SovdResourceEntityType, + entityId: string, + topicName: string + ): Promise { + // Strip direction suffix (:publish/:subscribe/:both) that frontend adds for unique keys + const cleanTopicName = stripDirectionSuffix(topicName); + const encodedTopicName = encodeURIComponent(cleanTopicName); + + const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/data/${encodedTopicName}`), { method: 'GET', headers: { Accept: 'application/json' }, }); if (!response.ok) { - return []; + const errorData = (await response.json().catch(() => ({}))) as SovdError; + throw new Error(errorData.message || `HTTP ${response.status}`); } - return unwrapItems(await response.json()); + // API returns {data, id, x-medkit: {ros2: {type, topic, direction}, type_info: {schema, default_value}, ...}} + const item = (await response.json()) as DataItemResponse; + const xMedkit = item['x-medkit']; + const ros2 = xMedkit?.ros2; + + const rawTypeInfo = xMedkit?.type_info as { schema?: unknown; default_value?: unknown } | undefined; + const convertedSchema = rawTypeInfo?.schema ? convertJsonSchemaToTopicSchema(rawTypeInfo.schema) : undefined; + + return { + topic: ros2?.topic || cleanTopicName, + timestamp: xMedkit?.timestamp || Date.now() * 1000000, + data: item.data, + status: (xMedkit?.status as 'data' | 'metadata_only') || 'data', + type: ros2?.type, + type_info: convertedSchema + ? { + schema: convertedSchema, + default_value: rawTypeInfo?.default_value as Record, + } + : undefined, + publisher_count: xMedkit?.publisher_count, + subscriber_count: xMedkit?.subscriber_count, + publishers: xMedkit?.publishers, + subscribers: xMedkit?.subscribers, + isPublisher: ros2?.direction === 'publish' || ros2?.direction === 'both', + isSubscriber: ros2?.direction === 'subscribe' || ros2?.direction === 'both', + }; } // =========================================================================== @@ -1239,9 +1476,14 @@ export class SovdApiClient { } // Extract entity info from reporting_sources + // reporting_sources contains ROS 2 node paths like "/bridge/diagnostic_bridge" + // We need to map this to SOVD entity: node name "diagnostic_bridge" → app ID "diagnostic-bridge" const source = apiFault.reporting_sources?.[0] || ''; - const entity_id = source.split('/').pop() || 'unknown'; - const entity_type = source.includes('/bridge/') ? 'bridge' : 'component'; + const nodeName = source.split('/').pop() || 'unknown'; + // Convert underscores to hyphens to match SOVD app ID convention + const entity_id = nodeName.replace(/_/g, '-'); + // Faults are reported by apps + const entity_type = 'app'; return { code: apiFault.fault_code, @@ -1281,36 +1523,20 @@ export class SovdApiClient { /** * List faults for a specific entity - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type * @param entityId Entity identifier */ - async listEntityFaults(entityType: 'components' | 'apps', entityId: string): Promise { - const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/faults`), { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - if (response.status === 404) { - return { items: [], count: 0 }; - } - throw new Error(`HTTP ${response.status}`); - } - - const data = await response.json(); - const items = (data.items || []).map((f: unknown) => - this.transformFault(f as Parameters[0]) - ); - return { items, count: data['x-medkit']?.count || items.length }; + async listEntityFaults(entityType: SovdResourceEntityType, entityId: string): Promise { + return this.getResources(entityType, entityId, 'faults'); } /** * Get a specific fault by code - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type * @param entityId Entity identifier * @param faultCode Fault code */ - async getFault(entityType: 'components' | 'apps', entityId: string, faultCode: string): Promise { + async getFault(entityType: SovdResourceEntityType, entityId: string, faultCode: string): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/faults/${encodeURIComponent(faultCode)}`), { @@ -1329,11 +1555,11 @@ export class SovdApiClient { /** * Clear a specific fault - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type * @param entityId Entity identifier * @param faultCode Fault code */ - async clearFault(entityType: 'components' | 'apps', entityId: string, faultCode: string): Promise { + async clearFault(entityType: SovdResourceEntityType, entityId: string, faultCode: string): Promise { const response = await fetchWithTimeout( this.getUrl(`${entityType}/${entityId}/faults/${encodeURIComponent(faultCode)}`), { @@ -1350,10 +1576,10 @@ export class SovdApiClient { /** * Clear all faults for an entity - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type * @param entityId Entity identifier */ - async clearAllFaults(entityType: 'components' | 'apps', entityId: string): Promise { + async clearAllFaults(entityType: SovdResourceEntityType, entityId: string): Promise { const response = await fetchWithTimeout(this.getUrl(`${entityType}/${entityId}/faults`), { method: 'DELETE', headers: { Accept: 'application/json' }, @@ -1387,12 +1613,12 @@ export class SovdApiClient { /** * Get fault snapshots for a specific entity - * @param entityType Entity type ('components' or 'apps') + * @param entityType Entity type * @param entityId Entity identifier * @param faultCode Fault code */ async getEntityFaultSnapshots( - entityType: 'components' | 'apps', + entityType: SovdResourceEntityType, entityId: string, faultCode: string ): Promise { @@ -1484,6 +1710,66 @@ export class SovdApiClient { return await response.json(); } + + // =========================================================================== + // HIERARCHY API (Subareas & Subcomponents) + // =========================================================================== + + /** + * List subareas for an area + * @param areaId Area identifier + */ + async listSubareas(areaId: string): Promise { + const response = await fetchWithTimeout(this.getUrl(`areas/${areaId}/subareas`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + if (response.status === 404) { + return []; + } + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + const items = Array.isArray(data) ? data : (data.items ?? data.subareas ?? []); + return items.map((item: { id: string; name?: string }) => ({ + id: item.id, + name: item.name || item.id, + type: 'subarea', + href: `/areas/${areaId}/subareas/${item.id}`, + hasChildren: true, + })); + } + + /** + * List subcomponents for a component + * @param componentId Component identifier + */ + async listSubcomponents(componentId: string): Promise { + const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/subcomponents`), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + if (response.status === 404) { + return []; + } + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + const items = Array.isArray(data) ? data : (data.items ?? data.subcomponents ?? []); + return items.map((item: { id: string; name?: string; fqn?: string }) => ({ + id: item.id, + name: item.fqn || item.name || item.id, + type: 'subcomponent', + href: `/components/${componentId}/subcomponents/${item.id}`, + hasChildren: true, + })); + } } /** diff --git a/src/lib/store.ts b/src/lib/store.ts index 0dbf7d4..6312dd7 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -13,14 +13,16 @@ import type { CreateExecutionRequest, CreateExecutionResponse, Fault, - VirtualFolderData, App, + VersionInfo, + SovdFunction, } from './types'; -import { isVirtualFolderData } from './types'; -import { createSovdClient, type SovdApiClient } from './sovd-api'; +import { createSovdClient, type SovdApiClient, type SovdResourceEntityType } from './sovd-api'; const STORAGE_KEY = 'sovd_web_ui_server_url'; +export type TreeViewMode = 'logical' | 'functional'; + export interface AppState { // Connection state serverUrl: string | null; @@ -31,6 +33,7 @@ export interface AppState { client: SovdApiClient | null; // Entity tree state + treeViewMode: TreeViewMode; rootEntities: EntityTreeNode[]; loadingPaths: string[]; expandedPaths: string[]; @@ -42,11 +45,11 @@ export interface AppState { isRefreshing: boolean; // Configurations state (ROS 2 Parameters) - configurations: Map; // componentId -> parameters + configurations: Map; // entityId -> parameters isLoadingConfigurations: boolean; // Operations state (ROS 2 Services & Actions) - operations: Map; // componentId -> operations + operations: Map; // entityId -> operations isLoadingOperations: boolean; // Active executions (for monitoring async actions) - SOVD Execution Model @@ -61,6 +64,7 @@ export interface AppState { // Actions connect: (url: string, baseEndpoint?: string) => Promise; disconnect: () => void; + setTreeViewMode: (mode: TreeViewMode) => Promise; loadRootEntities: () => Promise; loadChildren: (path: string) => Promise; toggleExpanded: (path: string) => void; @@ -69,177 +73,90 @@ export interface AppState { clearSelection: () => void; // Configurations actions - fetchConfigurations: (entityId: string, entityType?: 'components' | 'apps') => Promise; + fetchConfigurations: (entityId: string, entityType?: SovdResourceEntityType) => Promise; setParameter: ( entityId: string, paramName: string, value: unknown, - entityType?: 'components' | 'apps' + entityType?: SovdResourceEntityType ) => Promise; - resetParameter: (entityId: string, paramName: string, entityType?: 'components' | 'apps') => Promise; + resetParameter: (entityId: string, paramName: string, entityType?: SovdResourceEntityType) => Promise; resetAllConfigurations: ( entityId: string, - entityType?: 'components' | 'apps' + entityType?: SovdResourceEntityType ) => Promise<{ reset_count: number; failed_count: number }>; // Operations actions - updated for SOVD Execution model - fetchOperations: (entityId: string, entityType?: 'components' | 'apps') => Promise; + fetchOperations: (entityId: string, entityType?: SovdResourceEntityType) => Promise; createExecution: ( entityId: string, operationName: string, request: CreateExecutionRequest, - entityType?: 'components' | 'apps' + entityType?: SovdResourceEntityType ) => Promise; refreshExecutionStatus: ( entityId: string, operationName: string, executionId: string, - entityType?: 'components' | 'apps' + entityType?: SovdResourceEntityType ) => Promise; cancelExecution: ( entityId: string, operationName: string, executionId: string, - entityType?: 'components' | 'apps' + entityType?: SovdResourceEntityType ) => Promise; setAutoRefreshExecutions: (enabled: boolean) => void; // Faults actions fetchFaults: () => Promise; - clearFault: (entityType: 'components' | 'apps', entityId: string, faultCode: string) => Promise; + clearFault: (entityType: SovdResourceEntityType, entityId: string, faultCode: string) => Promise; subscribeFaultStream: () => void; unsubscribeFaultStream: () => void; } /** * Convert SovdEntity to EntityTreeNode + * + * Structure - flat hierarchy with type tags: + * - Area: subareas and components loaded as direct children on expand + * - Subarea: same as Area + * - Component: subcomponents and apps loaded as direct children on expand + * - Subcomponent: same as Component + * - App: leaf node (no children in tree) + * + * Resources (data, operations, configurations, faults) are shown in the detail panel, + * not as tree nodes. */ function toTreeNode(entity: SovdEntity, parentPath: string = ''): EntityTreeNode { const path = parentPath ? `${parentPath}/${entity.id}` : `/${entity.id}`; - - // If this is a component, create virtual subfolders: data/, operations/, configurations/, faults/, apps/ - let children: EntityTreeNode[] | undefined; - if (entity.type === 'component') { - // Create virtual subfolder nodes for component - children = [ - { - id: 'data', - name: 'data', - type: 'folder', - href: `${path}/data`, - path: `${path}/data`, - hasChildren: true, // Topics will be loaded here - isLoading: false, - isExpanded: false, - data: { - folderType: 'data', - componentId: entity.id, - entityType: 'component', - topicsInfo: entity.topicsInfo, - }, - }, - { - id: 'operations', - name: 'operations', - type: 'folder', - href: `${path}/operations`, - path: `${path}/operations`, - hasChildren: true, // Services/actions loaded on demand - isLoading: false, - isExpanded: false, - data: { folderType: 'operations', componentId: entity.id, entityType: 'component' }, - }, - { - id: 'configurations', - name: 'configurations', - type: 'folder', - href: `${path}/configurations`, - path: `${path}/configurations`, - hasChildren: true, // Parameters loaded on demand - isLoading: false, - isExpanded: false, - data: { folderType: 'configurations', componentId: entity.id, entityType: 'component' }, - }, - { - id: 'faults', - name: 'faults', - type: 'folder', - href: `${path}/faults`, - path: `${path}/faults`, - hasChildren: true, // Faults loaded on demand - isLoading: false, - isExpanded: false, - data: { folderType: 'faults', componentId: entity.id, entityType: 'component' }, - }, - { - id: 'apps', - name: 'apps', - type: 'folder', - href: `${path}/apps`, - path: `${path}/apps`, - hasChildren: true, // Apps (ROS 2 nodes) loaded on demand - isLoading: false, - isExpanded: false, - data: { folderType: 'apps', componentId: entity.id, entityType: 'component' }, - }, - ]; - } - // If this is an app, create virtual subfolders: data/, operations/, configurations/, faults/ - else if (entity.type === 'app') { - children = [ - { - id: 'data', - name: 'data', - type: 'folder', - href: `${path}/data`, - path: `${path}/data`, - hasChildren: true, - isLoading: false, - isExpanded: false, - data: { folderType: 'data', componentId: entity.id, entityType: 'app' }, - }, - { - id: 'operations', - name: 'operations', - type: 'folder', - href: `${path}/operations`, - path: `${path}/operations`, - hasChildren: true, - isLoading: false, - isExpanded: false, - data: { folderType: 'operations', componentId: entity.id, entityType: 'app' }, - }, - { - id: 'configurations', - name: 'configurations', - type: 'folder', - href: `${path}/configurations`, - path: `${path}/configurations`, - hasChildren: true, - isLoading: false, - isExpanded: false, - data: { folderType: 'configurations', componentId: entity.id, entityType: 'app' }, - }, - { - id: 'faults', - name: 'faults', - type: 'folder', - href: `${path}/faults`, - path: `${path}/faults`, - hasChildren: true, - isLoading: false, - isExpanded: false, - data: { folderType: 'faults', componentId: entity.id, entityType: 'app' }, - }, - ]; + const entityType = entity.type.toLowerCase(); + + // Determine hasChildren based on explicit metadata or type heuristic + // Note: hasChildren controls whether expand button is shown + // children: undefined means "not loaded yet" (lazy loading on expand) + let hasChildren: boolean; + const entityAny = entity as unknown as Record; + if (Object.prototype.hasOwnProperty.call(entityAny, 'hasChildren') && typeof entityAny.hasChildren === 'boolean') { + // Explicit hasChildren metadata from API - use as-is + hasChildren = entityAny.hasChildren as boolean; + } else if (Array.isArray(entityAny.children)) { + // Children array provided - check if non-empty + hasChildren = (entityAny.children as unknown[]).length > 0; + } else { + // No explicit metadata - use type-based heuristic: + // Areas and components typically have children (components, apps, subareas) + // Apps are leaf nodes - their resources shown in detail panel, not tree + hasChildren = entityType !== 'app'; } return { ...entity, path, - children, + children: undefined, // Children always loaded lazily on expand isLoading: false, isExpanded: false, + hasChildren, // Controls whether expand button is shown }; } @@ -281,6 +198,279 @@ function findNode(nodes: EntityTreeNode[], path: string): EntityTreeNode | null return null; } +// ============================================================================= +// Entity Selection Handlers +// ============================================================================= + +/** Result from an entity selection handler */ +interface SelectionResult { + selectedPath: string; + selectedEntity: SovdEntityDetails; + expandedPaths?: string[]; + rootEntities?: EntityTreeNode[]; + isLoadingDetails: boolean; +} + +/** Context passed to entity selection handlers */ +interface SelectionContext { + node: EntityTreeNode; + path: string; + expandedPaths: string[]; + rootEntities: EntityTreeNode[]; +} + +/** + * Handle topic node selection + * Distinguished between TopicNodeData (partial) and ComponentTopic (full) + */ +async function handleTopicSelection(ctx: SelectionContext, client: SovdApiClient): Promise { + const { node, path, rootEntities } = ctx; + if (node.type !== 'topic' || !node.data) return null; + + const data = node.data as TopicNodeData | ComponentTopic; + const isTopicNodeData = 'isPublisher' in data && 'isSubscriber' in data && !('type' in data); + + if (isTopicNodeData) { + // TopicNodeData - need to fetch full details + const { isPublisher, isSubscriber } = data as TopicNodeData; + const apiPath = path.replace(/^\/server/, ''); + const details = await client.getEntityDetails(apiPath); + + // Update tree with full data merged with direction info + const updatedTree = updateNodeInTree(rootEntities, path, (n) => ({ + ...n, + data: { ...details.topicData, isPublisher, isSubscriber }, + })); + + return { + selectedPath: path, + selectedEntity: details, + rootEntities: updatedTree, + isLoadingDetails: false, + }; + } + + // Full ComponentTopic data available + const topicData = data as ComponentTopic; + return { + selectedPath: path, + selectedEntity: { + id: node.id, + name: node.name, + href: node.href, + topicData, + rosType: topicData.type, + type: 'topic', + }, + isLoadingDetails: false, + }; +} + +/** Handle server node selection */ +function handleServerSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path, expandedPaths } = ctx; + if (node.type !== 'server') return null; + + const serverData = node.data as { + versionInfo?: VersionInfo; + serverVersion?: string; + sovdVersion?: string; + serverUrl?: string; + }; + + return { + selectedPath: path, + expandedPaths: expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path], + selectedEntity: { + id: node.id, + name: node.name, + type: 'server', + href: node.href, + versionInfo: serverData?.versionInfo, + serverVersion: serverData?.serverVersion, + sovdVersion: serverData?.sovdVersion, + serverUrl: serverData?.serverUrl, + }, + isLoadingDetails: false, + }; +} + +/** Handle component/subcomponent node selection */ +function handleComponentSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path, expandedPaths } = ctx; + if (node.type !== 'component' && node.type !== 'subcomponent') return null; + + return { + selectedPath: path, + expandedPaths: expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path], + selectedEntity: { + id: node.id, + name: node.name, + type: node.type, + href: node.href, + topicsInfo: node.topicsInfo, + }, + isLoadingDetails: false, + }; +} + +/** Handle area/subarea node selection */ +function handleAreaSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path, expandedPaths } = ctx; + if (node.type !== 'area' && node.type !== 'subarea') return null; + + return { + selectedPath: path, + expandedPaths: expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path], + selectedEntity: { + id: node.id, + name: node.name, + type: node.type, + href: node.href, + }, + isLoadingDetails: false, + }; +} + +/** Handle function node selection */ +function handleFunctionSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path, expandedPaths } = ctx; + if (node.type !== 'function') return null; + + const functionData = node.data as SovdFunction | undefined; + return { + selectedPath: path, + expandedPaths: expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path], + selectedEntity: { + id: node.id, + name: node.name, + type: 'function', + href: node.href, + description: functionData?.description, + }, + isLoadingDetails: false, + }; +} + +/** Handle app node selection */ +function handleAppSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path, expandedPaths } = ctx; + if (node.type !== 'app') return null; + + const appData = node.data as App | undefined; + return { + selectedPath: path, + expandedPaths: expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path], + selectedEntity: { + id: node.id, + name: node.name, + type: 'app', + href: node.href, + fqn: appData?.fqn || node.name, + node_name: appData?.node_name, + namespace: appData?.namespace, + component_id: appData?.component_id, + }, + isLoadingDetails: false, + }; +} + +/** Handle fault node selection */ +function handleFaultSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path } = ctx; + if (node.type !== 'fault' || !node.data) return null; + + const fault = node.data as Fault; + const pathSegments = path.split('/').filter(Boolean); + const entityId = pathSegments.length >= 2 ? pathSegments[pathSegments.length - 3] : ''; + + return { + selectedPath: path, + selectedEntity: { + id: node.id, + name: fault.message, + type: 'fault', + href: node.href, + data: fault, + entityId, + }, + isLoadingDetails: false, + }; +} + +/** Handle parameter node selection */ +function handleParameterSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path } = ctx; + if (node.type !== 'parameter' || !node.data) return null; + + const pathSegments = path.split('/').filter(Boolean); + const componentId = (pathSegments.length >= 2 ? pathSegments[1] : pathSegments[0]) ?? ''; + + return { + selectedPath: path, + selectedEntity: { + id: node.id, + name: node.name, + type: 'parameter', + href: node.href, + data: node.data, + componentId, + }, + isLoadingDetails: false, + }; +} + +/** Handle service/action node selection */ +function handleOperationSelection(ctx: SelectionContext): SelectionResult | null { + const { node, path } = ctx; + if ((node.type !== 'service' && node.type !== 'action') || !node.data) return null; + + const pathSegments = path.split('/').filter(Boolean); + const opsIndex = pathSegments.indexOf('operations'); + const componentId = opsIndex > 0 ? pathSegments[opsIndex - 1] : (pathSegments[0] ?? ''); + + return { + selectedPath: path, + selectedEntity: { + id: node.id, + name: node.name, + type: node.type, + href: node.href, + data: node.data, + componentId, + }, + isLoadingDetails: false, + }; +} + +/** Fallback: fetch entity details from API when not in tree */ +async function fetchEntityFromApi( + path: string, + client: SovdApiClient, + set: (state: Partial) => void +): Promise { + set({ selectedPath: path, isLoadingDetails: true, selectedEntity: null }); + + try { + const apiPath = path.replace(/^\/server/, ''); + const details = await client.getEntityDetails(apiPath); + set({ selectedEntity: details, isLoadingDetails: false }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Failed to load entity details for ${path}: ${message}`); + + // Infer entity type from path structure + const segments = path.split('/').filter(Boolean); + const id = segments[segments.length - 1] || path; + const inferredType = segments.length === 1 ? 'area' : segments.length === 2 ? 'component' : 'unknown'; + + set({ + selectedEntity: { id, name: id, type: inferredType, href: path, error: 'Failed to load details' }, + isLoadingDetails: false, + }); + } +} + export const useAppStore = create()( persist( (set, get) => ({ @@ -292,6 +482,7 @@ export const useAppStore = create()( connectionError: null, client: null, + treeViewMode: 'logical', rootEntities: [], loadingPaths: [], expandedPaths: [], @@ -374,15 +565,93 @@ export const useAppStore = create()( }); }, - // Load root entities + // Set tree view mode (logical vs functional) and reload entities + setTreeViewMode: async (mode: TreeViewMode) => { + set({ treeViewMode: mode, rootEntities: [], expandedPaths: [] }); + await get().loadRootEntities(); + }, + + // Load root entities - creates a server node as root + // In logical mode: Areas -> Components -> Apps + // In functional mode: Functions -> Apps (hosts) loadRootEntities: async () => { - const { client } = get(); + const { client, serverUrl, treeViewMode } = get(); if (!client) return; try { - const entities = await client.getEntities(); - const treeNodes = entities.map((e: SovdEntity) => toTreeNode(e)); - set({ rootEntities: treeNodes }); + // Fetch version info - critical for server identification and feature detection + const versionInfo = await client.getVersionInfo().catch((error: unknown) => { + const message = error instanceof Error ? error.message : 'Unknown error'; + toast.warn( + `Failed to fetch server version info: ${message}. ` + + 'Server will be shown with generic name and version info may be incomplete.' + ); + return null as VersionInfo | null; + }); + + // Extract server info from version-info response (fallback to generic values if unavailable) + const sovdInfo = versionInfo?.sovd_info?.[0]; + const serverName = sovdInfo?.vendor_info?.name || 'SOVD Server'; + const serverVersion = sovdInfo?.vendor_info?.version || ''; + const sovdVersion = sovdInfo?.version || ''; + + let children: EntityTreeNode[] = []; + + if (treeViewMode === 'functional') { + // Functional view: Functions -> Apps (hosts) + const functions = await client.listFunctions().catch(() => [] as SovdFunction[]); + children = functions.map((fn: SovdFunction) => { + // Validate function data quality + if (!fn.id || (typeof fn.id !== 'string' && typeof fn.id !== 'number')) { + console.warn('[Store] Malformed function data - missing or invalid id:', fn); + } + if (!fn.name && !fn.id) { + console.warn('[Store] Malformed function data - missing both name and id:', fn); + } + + const fnName = typeof fn.name === 'string' ? fn.name : fn.id || 'Unknown'; + const fnId = typeof fn.id === 'string' ? fn.id : String(fn.id); + return { + id: fnId, + name: fnName, + type: 'function', + href: fn.href || '', + path: `/server/${fnId}`, + children: undefined, + isLoading: false, + isExpanded: false, + // Functions always potentially have hosts - load on expand + hasChildren: true, + data: fn, + }; + }); + } else { + // Logical view: Areas -> Components -> Apps + const entities = await client.getEntities(); + children = entities.map((e: SovdEntity) => toTreeNode(e, '/server')); + } + + // Create server root node + const serverNode: EntityTreeNode = { + id: 'server', + name: serverName, + type: 'server', + href: serverUrl || '', + path: '/server', + hasChildren: children.length > 0, + isLoading: false, + isExpanded: false, + children, + data: { + versionInfo, + serverVersion, + sovdVersion, + serverUrl, + treeViewMode, + }, + }; + + set({ rootEntities: [serverNode], expandedPaths: ['/server'] }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; toast.error(`Failed to load entities: ${message}`); @@ -402,126 +671,95 @@ export const useAppStore = create()( // Check if we already have this data in the tree const node = findNode(rootEntities, path); - // Handle virtual folders (data/, operations/, configurations/) - if (node && isVirtualFolderData(node.data)) { - const folderData = node.data as VirtualFolderData; + // Regular node loading for entities (server, areas, subareas, components, subcomponents) + // These load their direct children + const nodeType = node?.type?.toLowerCase() || ''; + + // Handle server node - children (areas) are already loaded in loadRootEntities + if (nodeType === 'server') { + // Server children (areas) are pre-loaded, nothing to do + return; + } + + // Check if this is a loadable entity type + const isAreaOrSubarea = nodeType === 'area' || nodeType === 'subarea'; + const isComponentOrSubcomponent = nodeType === 'component' || nodeType === 'subcomponent'; + const isFunction = nodeType === 'function'; - // Skip if already has loaded children + if (node && (isAreaOrSubarea || isComponentOrSubcomponent || isFunction)) { + // Check if we already loaded children if (node.children && node.children.length > 0) { + // Already loaded children, skip fetch return; } set({ loadingPaths: [...loadingPaths, path] }); try { - let children: EntityTreeNode[] = []; - - if (folderData.folderType === 'data') { - // Load topics for data folder - // For apps, use apps API; for components, use getEntities - if (folderData.entityType === 'app') { - const topics = await client.getAppData(folderData.componentId); - children = topics.map((topic) => { - // Use uniqueKey if available (includes direction), otherwise just topic name - const uniqueId = topic.uniqueKey || topic.topic; - const cleanName = uniqueId.startsWith('/') ? uniqueId.slice(1) : uniqueId; - const encodedName = encodeURIComponent(cleanName); - return { - id: encodedName, - name: topic.topic, - type: 'topic', - href: `${path}/${encodedName}`, - path: `${path}/${encodedName}`, - hasChildren: false, - isLoading: false, - isExpanded: false, - data: { - ...topic, - isPublisher: topic.isPublisher ?? false, - isSubscriber: topic.isSubscriber ?? false, - }, - }; - }); - } else { - const topics = await client.getEntities(path.replace('/data', '')); - children = topics.map((topic: SovdEntity & { data?: ComponentTopic }) => { - const cleanName = topic.name.startsWith('/') ? topic.name.slice(1) : topic.name; - const encodedName = encodeURIComponent(cleanName); - return { - id: encodedName, - name: topic.name, - type: 'topic', - href: `${path}/${encodedName}`, - path: `${path}/${encodedName}`, - hasChildren: false, - isLoading: false, - isExpanded: false, - data: topic.data || { - topic: topic.name, - isPublisher: - folderData.topicsInfo?.publishes?.includes(topic.name) ?? false, - isSubscriber: - folderData.topicsInfo?.subscribes?.includes(topic.name) ?? false, - }, - }; - }); - } - } else if (folderData.folderType === 'operations') { - // Load operations for operations folder - const entityType = folderData.entityType === 'app' ? 'apps' : 'components'; - const ops = await client.listOperations(folderData.componentId, entityType); - children = ops.map((op) => ({ - id: op.name, - name: op.name, - type: op.kind === 'service' ? 'service' : 'action', - href: `${path}/${op.name}`, - path: `${path}/${op.name}`, - hasChildren: false, - isLoading: false, - isExpanded: false, - data: op, - })); - } else if (folderData.folderType === 'configurations') { - // Load parameters for configurations folder - const config = await client.listConfigurations(folderData.componentId); - children = config.parameters.map((param) => ({ - id: param.name, - name: param.name, - type: 'parameter', - href: `${path}/${param.name}`, - path: `${path}/${param.name}`, - hasChildren: false, - isLoading: false, - isExpanded: false, - data: param, - })); - } else if (folderData.folderType === 'faults') { - // Load faults for this entity - const entityType = folderData.entityType === 'app' ? 'apps' : 'components'; - const faultsResponse = await client.listEntityFaults(entityType, folderData.componentId); - children = faultsResponse.items.map((fault) => ({ - id: fault.code, - name: `${fault.code}: ${fault.message}`, - type: 'fault', - href: `${path}/${encodeURIComponent(fault.code)}`, - path: `${path}/${encodeURIComponent(fault.code)}`, - hasChildren: false, - isLoading: false, - isExpanded: false, - data: fault, - })); - } else if (folderData.folderType === 'apps') { - // Load apps belonging to this component using efficient server-side filtering - const componentApps = await client.listComponentApps(folderData.componentId); - children = componentApps.map((app) => - toTreeNode({ ...app, type: 'app', hasChildren: true }, path) + let loadedEntities: EntityTreeNode[] = []; + + // Convert tree path to API path (remove /server prefix) + const apiPath = path.replace(/^\/server/, ''); + + if (isAreaOrSubarea) { + // Load both subareas and components for this area + // API returns mixed: components come from getEntities, subareas from listSubareas + const [components, subareas] = await Promise.all([ + client.getEntities(apiPath), + client.listSubareas(node.id).catch(() => []), + ]); + + // Components from getEntities + const componentNodes = components.map((e: SovdEntity) => toTreeNode(e, path)); + // Subareas with type 'subarea' + const subareaNodes = subareas.map((subarea) => + toTreeNode({ ...subarea, type: 'subarea', hasChildren: true }, path) + ); + + loadedEntities = [...subareaNodes, ...componentNodes]; + } else if (isComponentOrSubcomponent) { + // Load both subcomponents and apps for this component + const [apps, subcomponents] = await Promise.all([ + client.listComponentApps(node.id), + client.listSubcomponents(node.id).catch(() => []), + ]); + + // Apps - leaf nodes (no children in tree, resources shown in panel) + const appNodes = apps.map((app) => + toTreeNode({ ...app, type: 'app', hasChildren: false }, path) + ); + // Subcomponents with type 'subcomponent' + const subcompNodes = subcomponents.map((subcomp) => + toTreeNode({ ...subcomp, type: 'subcomponent', hasChildren: true }, path) ); + + loadedEntities = [...subcompNodes, ...appNodes]; + } else if (isFunction) { + // Load hosts (apps) for this function + const hosts = await client.getFunctionHosts(node.id).catch(() => []); + + // Hosts response contains objects with {id, name, href} + loadedEntities = hosts.map((host: unknown) => { + const hostObj = host as { id?: string; name?: string; href?: string }; + const hostId = hostObj.id || ''; + const hostName = hostObj.name || hostObj.id || ''; + return { + id: hostId, + name: hostName, + type: 'app', + href: hostObj.href || `${path}/${hostId}`, + path: `${path}/${hostId}`, + hasChildren: false, + isLoading: false, + isExpanded: false, + }; + }); } const updatedTree = updateNodeInTree(rootEntities, path, (n) => ({ ...n, - children, - hasChildren: children.length > 0, + children: loadedEntities, + hasChildren: loadedEntities.length > 0, isLoading: false, })); @@ -531,30 +769,17 @@ export const useAppStore = create()( }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; - // Don't show error for empty results - some components may not have operations/configs - if (!message.includes('not found')) { - toast.error(`Failed to load ${folderData.folderType}: ${message}`); + if (!message.includes('not found') && !message.includes('404')) { + toast.error(`Failed to load children for ${path}: ${message}`); } - // Still update tree to show empty folder - const updatedTree = updateNodeInTree(rootEntities, path, (n) => ({ - ...n, - children: [], - hasChildren: false, - isLoading: false, - })); - set({ - rootEntities: updatedTree, - loadingPaths: get().loadingPaths.filter((p) => p !== path), - }); + set({ loadingPaths: get().loadingPaths.filter((p) => p !== path) }); } return; } - // Regular node loading (areas, components) + // For non-entity nodes, use regular loading if (node && Array.isArray(node.children) && node.children.length > 0) { // Check if children have full data or just TopicNodeData - // TopicNodeData has isPublisher/isSubscriber but no 'type' field in data - // Full ComponentTopic has 'type' field (e.g., "sensor_msgs/msg/Temperature") const firstChild = node.children[0]; const hasFullData = firstChild?.data && typeof firstChild.data === 'object' && 'type' in firstChild.data; @@ -563,14 +788,15 @@ export const useAppStore = create()( // Already have full data, skip fetch return; } - // Have only TopicNodeData - need to fetch full data } // Mark as loading set({ loadingPaths: [...loadingPaths, path] }); try { - const entities = await client.getEntities(path); + // Convert tree path to API path (remove /server prefix) + const apiPath = path.replace(/^\/server/, ''); + const entities = await client.getEntities(apiPath); const children = entities.map((e: SovdEntity) => toTreeNode(e, path)); // Update tree with children @@ -609,7 +835,6 @@ export const useAppStore = create()( if (!client || path === selectedPath) return; // Auto-expand parent paths and load children if needed - // This ensures navigation to deep paths (like /area/component/data/topic) works const pathParts = path.split('/').filter(Boolean); const newExpandedPaths = [...expandedPaths]; let currentPath = ''; @@ -619,10 +844,8 @@ export const useAppStore = create()( if (!newExpandedPaths.includes(currentPath)) { newExpandedPaths.push(currentPath); } - // Check if this node needs children loaded const parentNode = findNode(rootEntities, currentPath); if (parentNode && parentNode.hasChildren !== false && !parentNode.children) { - // Trigger load but don't await - let it happen in background loadChildren(currentPath); } } @@ -631,245 +854,74 @@ export const useAppStore = create()( set({ expandedPaths: newExpandedPaths }); } - // OPTIMIZATION: Check if tree already has this data const node = findNode(rootEntities, path); + if (!node) { + // Node not in tree - fall back to API fetch + await fetchEntityFromApi(path, client, set); + return; + } - // Optimization for Topic - check if we have TopicNodeData or full ComponentTopic - if (node && node.type === 'topic' && node.data) { - const data = node.data as TopicNodeData | ComponentTopic; - - // Check if it's TopicNodeData (from topicsInfo - only has isPublisher/isSubscriber) - // vs full ComponentTopic (from /components/{id}/data - has type, publishers, QoS etc) - const isTopicNodeData = 'isPublisher' in data && 'isSubscriber' in data && !('type' in data); - - if (isTopicNodeData) { - // Preserve isPublisher/isSubscriber info from TopicNodeData - const { isPublisher, isSubscriber } = data as TopicNodeData; - - // This is TopicNodeData - fetch actual topic details with full metadata - set({ - selectedPath: path, - isLoadingDetails: true, - selectedEntity: null, - }); + const ctx: SelectionContext = { node, path, expandedPaths, rootEntities }; - try { - const details = await client.getEntityDetails(path); - - // Update tree node with full data MERGED with direction info - // This preserves isPublisher/isSubscriber for the tree icons - const updatedTree = updateNodeInTree(rootEntities, path, (n) => ({ - ...n, - data: { - ...details.topicData, - isPublisher, - isSubscriber, - }, - })); - set({ rootEntities: updatedTree }); - - set({ selectedEntity: details, isLoadingDetails: false }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Failed to load topic details: ${message}`); + // Try each handler in order - first match wins + // Topic requires special handling (async + possible error) + if (node.type === 'topic' && node.data) { + set({ selectedPath: path, isLoadingDetails: true, selectedEntity: null }); + try { + const result = await handleTopicSelection(ctx, client); + if (result) { set({ - selectedEntity: { - id: node.id, - name: node.name, - type: 'topic', - href: node.href, - error: 'Failed to load details', - }, - isLoadingDetails: false, + selectedPath: result.selectedPath, + selectedEntity: result.selectedEntity, + isLoadingDetails: result.isLoadingDetails, + ...(result.rootEntities && { rootEntities: result.rootEntities }), }); + return; } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Failed to load topic details: ${message}`); + set({ + selectedEntity: { + id: node.id, + name: node.name, + type: 'topic', + href: node.href, + error: 'Failed to load details', + }, + isLoadingDetails: false, + }); return; } - - // Full ComponentTopic data available (from expanded component children) - const topicData = data as ComponentTopic; - set({ - selectedPath: path, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: node.name, - href: node.href, - topicData, - rosType: topicData.type, - type: 'topic', - }, - }); - return; - } - - // Optimization for Component - just select it and auto-expand - // Don't modify children - virtual folders (data/, operations/, configurations/, faults/, apps/) are already there - if (node && node.type === 'component') { - // Auto-expand component to show virtual folders - const newExpandedPaths = expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path]; - - set({ - selectedPath: path, - expandedPaths: newExpandedPaths, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: node.name, - type: node.type, - href: node.href, - // Pass topicsInfo if available for the Data tab - topicsInfo: node.topicsInfo, - }, - }); - return; - } - - // Handle App entity selection - auto-expand to show virtual folders - if (node && node.type === 'app') { - const newExpandedPaths = expandedPaths.includes(path) ? expandedPaths : [...expandedPaths, path]; - const appData = node.data as App | undefined; - - set({ - selectedPath: path, - expandedPaths: newExpandedPaths, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: node.name, - type: 'app', - href: node.href, - // Pass app-specific data - fqn: appData?.fqn || node.name, - node_name: appData?.node_name, - namespace: appData?.namespace, - component_id: appData?.component_id, - }, - }); - return; - } - - // Handle fault selection - show fault details - if (node && node.type === 'fault' && node.data) { - const fault = node.data as Fault; - // Extract entity info from path: /area/component/faults/code - const pathSegments = path.split('/').filter(Boolean); - const entityId = pathSegments.length >= 2 ? pathSegments[pathSegments.length - 3] : ''; - - set({ - selectedPath: path, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: fault.message, - type: 'fault', - href: node.href, - data: fault, - entityId, - }, - }); - return; - } - - // Handle virtual folder selection - show appropriate panel - if (node && isVirtualFolderData(node.data)) { - const folderData = node.data as VirtualFolderData; - set({ - selectedPath: path, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: `${folderData.componentId} / ${node.name}`, - type: 'folder', - href: node.href, - // Pass folder info so detail panel knows what to show - folderType: folderData.folderType, - componentId: folderData.componentId, - entityType: folderData.entityType, - }, - }); - return; - } - - // Handle parameter selection - show parameter detail with data from tree - if (node && node.type === 'parameter' && node.data) { - // Extract componentId from path: /area/component/configurations/paramName - const pathSegments = path.split('/').filter(Boolean); - const componentId = (pathSegments.length >= 2 ? pathSegments[1] : pathSegments[0]) ?? ''; - - set({ - selectedPath: path, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: node.name, - type: 'parameter', - href: node.href, - data: node.data, - componentId, - }, - }); - return; - } - - // Handle service/action selection - show operation detail with data from tree - if (node && (node.type === 'service' || node.type === 'action') && node.data) { - // Extract componentId from path: /area/component/operations/opName - const pathSegments = path.split('/').filter(Boolean); - const componentId = (pathSegments.length >= 2 ? pathSegments[1] : pathSegments[0]) ?? ''; - - set({ - selectedPath: path, - isLoadingDetails: false, - selectedEntity: { - id: node.id, - name: node.name, - type: node.type, - href: node.href, - data: node.data, - componentId, - }, - }); - return; } - set({ - selectedPath: path, - isLoadingDetails: true, - selectedEntity: null, - }); - - try { - const details = await client.getEntityDetails(path); - set({ selectedEntity: details, isLoadingDetails: false }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Failed to load entity details for ${path}: ${message}`); - - // Set fallback entity to allow panel to render - // Infer entity type from path structure - const segments = path.split('/').filter(Boolean); - const id = segments[segments.length - 1] || path; - let inferredType: string; - if (segments.length === 1) { - inferredType = 'area'; - } else if (segments.length === 2) { - inferredType = 'component'; - } else { - inferredType = 'unknown'; + // Synchronous handlers + const handlers = [ + handleServerSelection, + handleComponentSelection, + handleAreaSelection, + handleFunctionSelection, + handleAppSelection, + handleFaultSelection, + handleParameterSelection, + handleOperationSelection, + ]; + + for (const handler of handlers) { + const result = handler(ctx); + if (result) { + set({ + selectedPath: result.selectedPath, + selectedEntity: result.selectedEntity, + isLoadingDetails: result.isLoadingDetails, + ...(result.expandedPaths && { expandedPaths: result.expandedPaths }), + }); + return; } - - set({ - selectedEntity: { - id, - name: id, - type: inferredType, - href: path, - error: 'Failed to load details', - }, - isLoadingDetails: false, - }); } + + // No handler matched - fall back to API fetch + await fetchEntityFromApi(path, client, set); }, // Refresh the currently selected entity (re-fetch from server) @@ -882,7 +934,9 @@ export const useAppStore = create()( set({ isRefreshing: true }); try { - const details = await client.getEntityDetails(selectedPath); + // Convert tree path to API path (remove /server prefix) + const apiPath = selectedPath.replace(/^\/server/, ''); + const details = await client.getEntityDetails(apiPath); set({ selectedEntity: details, isRefreshing: false }); } catch { toast.error('Failed to refresh data'); @@ -902,7 +956,7 @@ export const useAppStore = create()( // CONFIGURATIONS ACTIONS (ROS 2 Parameters) // =========================================================================== - fetchConfigurations: async (entityId: string, entityType: 'components' | 'apps' = 'components') => { + fetchConfigurations: async (entityId: string, entityType: SovdResourceEntityType = 'components') => { const { client, configurations } = get(); if (!client) return; @@ -924,7 +978,7 @@ export const useAppStore = create()( entityId: string, paramName: string, value: unknown, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ) => { const { client, configurations } = get(); if (!client) return false; @@ -978,20 +1032,16 @@ export const useAppStore = create()( resetParameter: async ( entityId: string, paramName: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ) => { - const { client, configurations } = get(); + const { client, fetchConfigurations } = get(); if (!client) return false; try { - const result = await client.resetConfiguration(entityId, paramName, entityType); + await client.resetConfiguration(entityId, paramName, entityType); - // Update local state with reset value - const newConfigs = new Map(configurations); - const params = newConfigs.get(entityId) || []; - const updatedParams = params.map((p) => (p.name === paramName ? { ...p, value: result.value } : p)); - newConfigs.set(entityId, updatedParams); - set({ configurations: newConfigs }); + // Refetch configurations to get updated value after reset + await fetchConfigurations(entityId, entityType); toast.success(`Parameter ${paramName} reset to default`); return true; } catch (error) { @@ -1001,7 +1051,7 @@ export const useAppStore = create()( } }, - resetAllConfigurations: async (entityId: string, entityType: 'components' | 'apps' = 'components') => { + resetAllConfigurations: async (entityId: string, entityType: SovdResourceEntityType = 'components') => { const { client, fetchConfigurations } = get(); if (!client) return { reset_count: 0, failed_count: 0 }; @@ -1029,7 +1079,7 @@ export const useAppStore = create()( // OPERATIONS ACTIONS (ROS 2 Services & Actions) - SOVD Execution Model // =========================================================================== - fetchOperations: async (entityId: string, entityType: 'components' | 'apps' = 'components') => { + fetchOperations: async (entityId: string, entityType: SovdResourceEntityType = 'components') => { const { client, operations } = get(); if (!client) return; @@ -1051,7 +1101,7 @@ export const useAppStore = create()( entityId: string, operationName: string, request: CreateExecutionRequest, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ) => { const { client, activeExecutions } = get(); if (!client) return null; @@ -1088,7 +1138,7 @@ export const useAppStore = create()( entityId: string, operationName: string, executionId: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ) => { const { client, activeExecutions } = get(); if (!client) return; @@ -1108,7 +1158,7 @@ export const useAppStore = create()( entityId: string, operationName: string, executionId: string, - entityType: 'components' | 'apps' = 'components' + entityType: SovdResourceEntityType = 'components' ) => { const { client, activeExecutions } = get(); if (!client) return false; @@ -1151,7 +1201,7 @@ export const useAppStore = create()( } }, - clearFault: async (entityType: 'components' | 'apps', entityId: string, faultCode: string) => { + clearFault: async (entityType: SovdResourceEntityType, entityId: string, faultCode: string) => { const { client, fetchFaults } = get(); if (!client) return false; diff --git a/src/lib/types.ts b/src/lib/types.ts index 54e4263..4db0171 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -2,6 +2,11 @@ * SOVD Entity types for discovery endpoints */ +/** + * SOVD Resource Entity Type for API endpoints + */ +export type SovdResourceEntityType = 'areas' | 'components' | 'apps' | 'functions'; + /** * QoS profile for a topic endpoint */ @@ -122,27 +127,6 @@ export interface TopicNodeData { isSubscriber: boolean; } -/** - * Virtual folder data for component/app subfolders - */ -export interface VirtualFolderData { - /** Type of virtual folder: data, operations, configurations, faults, or apps */ - folderType: 'data' | 'operations' | 'configurations' | 'faults' | 'apps'; - /** Parent entity ID (component or app) */ - componentId: string; - /** Parent entity type */ - entityType: 'component' | 'app'; - /** Topics info (for data folder) */ - topicsInfo?: ComponentTopicsInfo; -} - -/** - * Type guard for VirtualFolderData - */ -export function isVirtualFolderData(data: unknown): data is VirtualFolderData { - return !!data && typeof data === 'object' && 'folderType' in data && 'componentId' in data; -} - /** * Component topic data from GET /components/{id}/data */ @@ -176,11 +160,11 @@ export interface ComponentTopic { } /** - * API response for data item (topic) from GET /components/{id}/data/{topic} + * API response for data item from GET /components/{id}/data/{dataId} * This is the raw response structure from the gateway API. */ export interface DataItemResponse { - /** Topic data payload */ + /** Data payload */ data: unknown; /** Item identifier */ id: string; @@ -195,6 +179,15 @@ export interface DataItemResponse { status?: string; publisher_count?: number; subscriber_count?: number; + /** Publisher endpoint information */ + publishers?: TopicEndpoint[]; + /** Subscriber endpoint information */ + subscribers?: TopicEndpoint[]; + /** Type information including schema (JSON schema for message fields) */ + type_info?: { + schema?: Record; + default_value?: unknown; + }; }; } @@ -527,8 +520,8 @@ export interface AppCapabilities { export interface SovdFunction extends SovdEntity { /** Description of the function */ description?: string; - /** IDs of apps that host this function */ - hosts: string[]; + /** IDs of apps that host this function (loaded from separate /hosts endpoint) */ + hosts?: string[]; } /** @@ -639,16 +632,34 @@ export interface ServerCapabilities { entry_points: Record; } +/** + * Vendor info in SOVD version response + */ +export interface VendorInfo { + /** Vendor/implementation name */ + name: string; + /** Vendor/implementation version */ + version: string; +} + +/** + * Single SOVD info entry from version-info response + */ +export interface SovdInfoEntry { + /** Base URI for the API */ + base_uri: string; + /** SOVD specification version */ + version: string; + /** Vendor-specific information */ + vendor_info?: VendorInfo; +} + /** * Version info from GET /version-info */ export interface VersionInfo { - /** SOVD specification version */ - sovd_version: string; - /** Server implementation version */ - implementation_version?: string; - /** Additional version details */ - details?: Record; + /** Array of SOVD version info entries */ + sovd_info: SovdInfoEntry[]; } // =============================================================================