diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..fec2c4f --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,3 @@ +# Bulk formatting commits; `git config blame.ignoreRevsFile .git-blame-ignore-revs` +# style: format codebase with Prettier +240c88cfcecad248f9c230e85fd5a9d2779e145c diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..82945b2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist +coverage +package-lock.json +public diff --git a/prettierrc.json b/.prettierrc.json similarity index 97% rename from prettierrc.json rename to .prettierrc.json index 1dc7164..0a72520 100644 --- a/prettierrc.json +++ b/.prettierrc.json @@ -3,4 +3,4 @@ "tabWidth": 2, "semi": true, "singleQuote": true -} \ No newline at end of file +} diff --git a/.releaserc.json b/.releaserc.json index 416f7dc..0c9fae6 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -9,8 +9,9 @@ ] } ], - "@semantic-release/release-notes-generator", - ["@semantic-release/changelog", + "@semantic-release/release-notes-generator", + [ + "@semantic-release/changelog", { "changelogFile": "CHANGELOG.md" } @@ -22,15 +23,15 @@ "publishCmd": "echo \"version=${nextRelease.version}\" >> $GITHUB_OUTPUT && echo \"tag=${nextRelease.gitTag}\" >> $GITHUB_OUTPUT && echo \"type=${nextRelease.type}\" >> $GITHUB_OUTPUT && echo \"channel=${nextRelease.channel}\" >> $GITHUB_OUTPUT" } ] - ], + ], "branches": [ "main", - {"name": "development", "prerelease": "beta", "channel": "beta"}, - {"name": "release", "prerelease": "rc", "channel": "rc"}, + { "name": "development", "prerelease": "beta", "channel": "beta" }, + { "name": "release", "prerelease": "rc", "channel": "rc" }, { "name": "replace-me-feature-branch", "prerelease": "replace-me-prerelease", "channel": "replace-me-prerelease" } ] -} \ No newline at end of file +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..1d7ac85 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json index 302db40..4e51580 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,23 +4,20 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - { - "name": "Launch Edge", - "request": "launch", - "type": "msedge", - "url": "http://localhost:8080", - "webRoot": "${workspaceFolder}" - }, + { + "name": "Launch Edge", + "request": "launch", + "type": "msedge", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + }, { "type": "msedge", "request": "launch", "name": "Launch Edge against localhost", "url": "https://localhost:3000/debug/", "webRoot": "${workspaceFolder}", - "outFiles": [ - "${workspaceFolder}/**/*.js", - "!**/node_modules/**" - ] + "outFiles": ["${workspaceFolder}/**/*.js", "!**/node_modules/**"] } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 499de86..78151b4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,40 +1,16 @@ { - "conventionalCommits.scopes": [ - "force-patch" - ], + "conventionalCommits.scopes": ["force-patch"], "editor.tabSize": 2, "conventionalCommits.gitmoji": false, "conventionalCommits.promptFooter": false, - "eslint.validate": [ - "javascript", - "typescript", - "html" - ], - "eslint.options": { - "extensions": [ - ".js", - ".ts", - "html", - "tsx" - ] - }, - "eslint.rules.customizations": [ - { - "rule": "prettier/prettier", - "severity": "info" - } - ], - "editor.codeActionsOnSave": [ - "source.organizeImports", - "source.fixAll.eslint" - ], - "editor.formatOnSave": false, - "eslint.alwaysShowStatus": true, - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.fixAll.eslint": "explicit" }, "typescript.preferences.quoteStyle": "single", "typescript.updateImportsOnFileMove.enabled": "always", "javascript.updateImportsOnFileMove.enabled": "always", "editor.linkedEditing": true -} \ No newline at end of file +} diff --git a/.vscode/snippets.code-snippets b/.vscode/snippets.code-snippets index 1aed353..29d7d03 100644 --- a/.vscode/snippets.code-snippets +++ b/.vscode/snippets.code-snippets @@ -1,130 +1,121 @@ { - // Place your portal-frontend workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and - // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope - // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is - // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: - // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. - // Placeholders with the same ids are connected. - // Example: - // "Print to console": { - // "scope": "javascript,typescript", - // "prefix": "log", - // "body": [ - // "console.info('$1');", - // "$2" - // ], - // "description": "Log output to console" - // } - "Effects Comment": { - "prefix": "*eff", - "body": [ - "/* EFFECTS *********************************************************/$0" - ], - "description": "Adds EFFECTS comment" - }, - "Functions Comment": { - "prefix": "*fun", - "body": [ - "/* FUNCTIONS *******************************************************/$0" - ], - "description": "Adds FUNCTIONS comment" - }, - "Hooks Comment": { - "prefix": "*hoo", - "body": [ - "/* HOOKS ***********************************************************/$0" - ], - "description": "Adds HOOKS comment" - }, - "Render Comment": { - "prefix": "*ren", - "body": [ - "/* RENDER **********************************************************/$0" - ], - "description": "Adds RENDER comment" - }, - "State Comment": { - "prefix": "*state", - "body": [ - "/* STATE ***********************************************************/$0" - ], - "description": "Adds STATE comment" - }, - "Create Function Component": { - "prefix": "cfc", - "body": [ - "import React from 'react';", - "", - "const ${1:$TM_FILENAME_BASE} = () => {", - " return <>;", - "}", - "", - "export default $1;" - ], - "description": "Adds boilerplate for a function component without props", - "scope": "typescriptreact" - }, - "Create Function Component With Props": { - "prefix": "cfcp", - "body": [ - "import React from 'react';", - "", - "const ${1:$TM_FILENAME_BASE} = ({}: $1Props) => {", - " return <>;", - "}", - "", - "export default $1;", - "", - "interface ${1}Props {", - "", - "}" - ], - "description": "Adds boilerplate for a function component with props and interface", - "scope": "typescriptreact" - }, - "Create Props for Function Component": { - "prefix": "cprops", - "body": [ - "interface ${1:$TM_FILENAME_BASE}Props {", - " $0", - "}" - ], - "description": "Adds props interface to existing component", - "scope": "typescriptreact" - }, - "useAppTitle Hook": { - "prefix": "useapptitle", - "body": "const setTitle = useAppTitle$0();", - "description": "Adds useAppTitle hook", - "scope": "typescriptreact" - }, - "useAppTitleRight Hook": { - "prefix": "useapptitleright", - "body": "const setTitleRight = useAppTitleRight$0();", - "description": "Adds useAppTitleRight hook", - "scope": "typescriptreact" - }, - "useSearchParams Hook": { - "prefix": "usesearchparams", - "body": "const [searchParams, setSearchParams] = useSearchParams$0();", - "description": "Adds useSearchParams hook", - "scope": "typescriptreact" - }, - "useState Hook": { - "prefix": "usestate", - "body": [ - "const [$1, set${1/(.)/${1:/capitalize}/}] = useState$2($3);" - ], - "description": "Adds useState hook", - "scope": "typescriptreact" - }, - "useEffect Hook": { - "prefix":"useeffect", - "body": ["useEffect$0(() => {", - " ", - "}, [$1]);" - ], - "description": "Adds useEffect hook", - "scope": "typescriptreact" - } + // Place your portal-frontend workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and + // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope + // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is + // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: + // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. + // Placeholders with the same ids are connected. + // Example: + // "Print to console": { + // "scope": "javascript,typescript", + // "prefix": "log", + // "body": [ + // "console.info('$1');", + // "$2" + // ], + // "description": "Log output to console" + // } + "Effects Comment": { + "prefix": "*eff", + "body": [ + "/* EFFECTS *********************************************************/$0", + ], + "description": "Adds EFFECTS comment", + }, + "Functions Comment": { + "prefix": "*fun", + "body": [ + "/* FUNCTIONS *******************************************************/$0", + ], + "description": "Adds FUNCTIONS comment", + }, + "Hooks Comment": { + "prefix": "*hoo", + "body": [ + "/* HOOKS ***********************************************************/$0", + ], + "description": "Adds HOOKS comment", + }, + "Render Comment": { + "prefix": "*ren", + "body": [ + "/* RENDER **********************************************************/$0", + ], + "description": "Adds RENDER comment", + }, + "State Comment": { + "prefix": "*state", + "body": [ + "/* STATE ***********************************************************/$0", + ], + "description": "Adds STATE comment", + }, + "Create Function Component": { + "prefix": "cfc", + "body": [ + "import React from 'react';", + "", + "const ${1:$TM_FILENAME_BASE} = () => {", + " return <>;", + "}", + "", + "export default $1;", + ], + "description": "Adds boilerplate for a function component without props", + "scope": "typescriptreact", + }, + "Create Function Component With Props": { + "prefix": "cfcp", + "body": [ + "import React from 'react';", + "", + "const ${1:$TM_FILENAME_BASE} = ({}: $1Props) => {", + " return <>;", + "}", + "", + "export default $1;", + "", + "interface ${1}Props {", + "", + "}", + ], + "description": "Adds boilerplate for a function component with props and interface", + "scope": "typescriptreact", + }, + "Create Props for Function Component": { + "prefix": "cprops", + "body": ["interface ${1:$TM_FILENAME_BASE}Props {", " $0", "}"], + "description": "Adds props interface to existing component", + "scope": "typescriptreact", + }, + "useAppTitle Hook": { + "prefix": "useapptitle", + "body": "const setTitle = useAppTitle$0();", + "description": "Adds useAppTitle hook", + "scope": "typescriptreact", + }, + "useAppTitleRight Hook": { + "prefix": "useapptitleright", + "body": "const setTitleRight = useAppTitleRight$0();", + "description": "Adds useAppTitleRight hook", + "scope": "typescriptreact", + }, + "useSearchParams Hook": { + "prefix": "usesearchparams", + "body": "const [searchParams, setSearchParams] = useSearchParams$0();", + "description": "Adds useSearchParams hook", + "scope": "typescriptreact", + }, + "useState Hook": { + "prefix": "usestate", + "body": ["const [$1, set${1/(.)/${1:/capitalize}/}] = useState$2($3);"], + "description": "Adds useState hook", + "scope": "typescriptreact", + }, + "useEffect Hook": { + "prefix": "useeffect", + "body": ["useEffect$0(() => {", " ", "}, [$1]);"], + "description": "Adds useEffect hook", + "scope": "typescriptreact", + }, } diff --git a/README.md b/README.md index 42e386f..1fceed5 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,13 @@ A powerful web-based configuration and debugging tool for PepperDash Essentials ## πŸš€ Quick Start ### For Users + **New to the application?** Start with the [Getting Started Tutorial](./docs/tutorials/getting-started.md) for a guided introduction. **Need to solve a specific problem?** Check the [How-to Guides](./docs/how-to/) for step-by-step solutions. ### For Developers + **Setting up the development environment:** ```bash @@ -31,21 +33,28 @@ The app will be available at `http://localhost:5173/cws/debug/`. This project uses the [Diataxis framework](https://diataxis.fr/) to provide you with the right information at the right time: ### 🎯 [Tutorials](./docs/tutorials/) - Learning-oriented + **"Take me by the hand and teach me"** + - [Getting Started Tutorial](./docs/tutorials/getting-started.md) - Your first steps with the web config app - [Debug Console Tutorial](./docs/tutorials/debug-console-basics.md) - Master the debug console - [Device Management Tutorial](./docs/tutorials/device-management-basics.md) - Device inspection and management ### πŸ”§ [How-to Guides](./docs/how-to/) - Problem-oriented + **"Show me how to solve this specific problem"** + - [Troubleshooting Connection Issues](./docs/how-to/troubleshoot-connection.md) - [Filter and Search Debug Messages](./docs/how-to/filter-debug-messages.md) - [Export and Analyze Configuration](./docs/how-to/export-configuration.md) - [Monitor System Performance](./docs/how-to/monitor-performance.md) - [Restart and Reload Configuration](./docs/how-to/restart-reload-config.md) +- [Trace Signal Routes and Read the Routing Diagram](./docs/how-to/trace-signal-routes.md) ### πŸ“š [Reference](./docs/reference/) - Information-oriented + **"Tell me the facts"** + - [UI Components Reference](./docs/reference/ui-components.md) - Complete component documentation - [API Endpoints Reference](./docs/reference/api-endpoints.md) - All available REST endpoints - [Configuration Schema Reference](./docs/reference/configuration-schema.md) - Configuration file structure @@ -53,7 +62,9 @@ This project uses the [Diataxis framework](https://diataxis.fr/) to provide you - [Log Levels and Filters Reference](./docs/reference/log-levels.md) - Complete logging reference ### πŸ’‘ [Explanation](./docs/explanation/) - Understanding-oriented + **"Help me understand why and how this works"** + - [System Architecture](./docs/explanation/architecture.md) - How the web app integrates with Essentials - [Debug Console Design](./docs/explanation/debug-console-design.md) - How real-time debugging works - [Configuration Management](./docs/explanation/configuration-management.md) - How configuration is handled @@ -66,6 +77,7 @@ This project uses the [Diataxis framework](https://diataxis.fr/) to provide you - **πŸ“„ Configuration Viewer**: View and analyze complete system configuration - **πŸ“¦ Version Information**: Check loaded assemblies and software versions - **🏷️ Type Registry**: Browse supported device types and capabilities +- **πŸ”€ Signal Routing**: Interactive routing diagram with live feedback and click-to-trace signal paths (PepperDashEssentials.dll 3.0+) - **πŸ”„ System Control**: Restart system and reload configuration safely ## πŸ› οΈ Development @@ -73,17 +85,29 @@ This project uses the [Diataxis framework](https://diataxis.fr/) to provide you ### Available Scripts #### `npm start` + Runs the app in development mode via Vite. The page will reload when you make edits. Available at `http://localhost:5173/cws/debug/`. #### `npm test` + Launches the Vitest test runner in interactive watch mode. #### `npm run build` + Compiles TypeScript and builds the app for production to the `dist/` folder with optimized bundles. #### `npm run preview` + Serves the production build locally for inspection before deployment. +#### `npm run lint` / `npm run lint:fix` + +Checks the code with ESLint (configured in `eslint.config.js`); `lint:fix` applies automatic fixes. + +#### `npm run format` / `npm run format:check` + +Formats files with Prettier (configured in `.prettierrc.json`); `format:check` reports unformatted files without changing them. VS Code formats on save when the recommended Prettier extension is installed. + ### Environment Setup Create a `.env.local` file in the project root (never commit this file): @@ -94,7 +118,8 @@ VITE_PROGRAM_ID=app01 # Optional: Default application slot ``` **Development Prerequisites:** -- Node.js 18+ + +- Node.js 20.19+, 22.13+, or 24+ (required by Vite, Vitest's jsdom environment, and ESLint) - npm - Network access to target PepperDash Essentials processor - Modern web browser @@ -102,6 +127,7 @@ VITE_PROGRAM_ID=app01 # Optional: Default application slot ### Architecture Overview **Frontend Stack:** + - React 19 with TypeScript - Redux Toolkit for state management (including WebSocket middleware) - React Router v7 for navigation @@ -109,6 +135,7 @@ VITE_PROGRAM_ID=app01 # Optional: Default application slot - WebSocket for real-time communication **Integration:** + - Connects to PepperDash Essentials processors via HTTPS API at `/cws//api/` - Uses WebSocket for real-time debug message streaming - In development, Vite proxies all `/cws/` API requests to `PROGRAM_HOST` @@ -124,12 +151,14 @@ VITE_PROGRAM_ID=app01 # Optional: Default application slot ## 🌐 Browser Compatibility **Supported Browsers:** + - Chrome 70+ - Firefox 65+ - Safari 12+ - Edge 79+ **Required Features:** + - WebSocket support for real-time debugging - Modern JavaScript (ES6+) support - CSS Grid and Flexbox for responsive layouts @@ -137,16 +166,19 @@ VITE_PROGRAM_ID=app01 # Optional: Default application slot ## πŸ“– Learning Resources **New Users:** + 1. Start with [Getting Started Tutorial](./docs/tutorials/getting-started.md) 2. Learn [Debug Console Basics](./docs/tutorials/debug-console-basics.md) 3. Explore [Device Management](./docs/tutorials/device-management-basics.md) **Troubleshooting:** + - Check [How-to Guides](./docs/how-to/) for specific solutions - Review [Connection Troubleshooting](./docs/how-to/troubleshoot-connection.md) for access issues - Use [Performance Monitoring](./docs/how-to/monitor-performance.md) for system health -**Advanced Usage:** +**Advanced Usage:** + - Understand [System Architecture](./docs/explanation/architecture.md) - Learn [Debug Console Design](./docs/explanation/debug-console-design.md) - Reference [API Documentation](./docs/reference/api-endpoints.md) @@ -154,6 +186,7 @@ VITE_PROGRAM_ID=app01 # Optional: Default application slot ## 🀝 Contributing This project follows standard React development practices: + - TypeScript for type safety - ESLint and Prettier for code formatting - Component-based architecture with feature organization @@ -166,15 +199,17 @@ MIT License - see [LICENSE](./LICENSE) file for details. ## πŸ†˜ Support **For Application Issues:** + - Check the [How-to Guides](./docs/how-to/) for common solutions - Review system requirements and browser compatibility - Verify network connectivity to target processor **For PepperDash Essentials Questions:** + - Consult PepperDash documentation and support resources - Verify Essentials framework version compatibility - Check processor configuration and network settings --- -*Built with ❀️ for the PepperDash community. This web application makes PepperDash Essentials systems more accessible and manageable through modern web interfaces.* +_Built with ❀️ for the PepperDash community. This web application makes PepperDash Essentials systems more accessible and manageable through modern web interfaces._ diff --git a/docs/README.md b/docs/README.md index e64aada..8c4b408 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ Welcome to the comprehensive documentation for the PepperDash Essentials Web Con This documentation follows the [Diataxis framework](https://diataxis.fr/) to provide you with the right information at the right time: ### 🎯 [Tutorials](./tutorials/) - Learning-oriented + **"Take me by the hand and teach me"** Step-by-step guides that take you through your first experiences with the application. Perfect for newcomers who want to get started quickly. @@ -16,6 +17,7 @@ Step-by-step guides that take you through your first experiences with the applic - **[Device Management Tutorial](./tutorials/device-management-basics.md)** - Basic device inspection and management ### πŸ”§ [How-to Guides](./how-to/) - Problem-oriented + **"Show me how to solve this specific problem"** Practical guides that solve specific problems you might encounter. These assume you have basic familiarity with the system. @@ -25,8 +27,10 @@ Practical guides that solve specific problems you might encounter. These assume - **[Export and Analyze Configuration](./how-to/export-configuration.md)** - **[Monitor System Performance](./how-to/monitor-performance.md)** - **[Restart and Reload Configuration](./how-to/restart-reload-config.md)** +- **[Trace Signal Routes and Read the Routing Diagram](./how-to/trace-signal-routes.md)** ### πŸ“š [Reference](./reference/) - Information-oriented + **"Tell me the facts"** Complete technical information about all features, APIs, and components. Organized for easy lookup. @@ -38,6 +42,7 @@ Complete technical information about all features, APIs, and components. Organiz - **[Log Levels and Filters](./reference/log-levels.md)** - Complete logging reference ### πŸ’‘ [Explanation](./explanation/) - Understanding-oriented + **"Help me understand why and how this works"** Background information and design decisions that help you understand the system's architecture and concepts. @@ -66,7 +71,7 @@ The PepperDash Essentials Web Config App provides several key features: - **βš™οΈ Device Management**: Inspect and interact with connected devices - **πŸ“„ Configuration Viewer**: View and analyze merged configuration files - **πŸ“¦ Version Information**: Check loaded assemblies and versions -- **πŸ”€ Routing**: Visual signal routing diagram between devices and tie lines +- **πŸ”€ Routing**: Interactive signal routing diagram with live current-source feedback, click-to-trace signal paths, and multiview layout panels (PepperDashEssentials.dll 3.0+) - **πŸ“± Mobile Control**: Mobile control interface management - **πŸ—ΊοΈ API Paths**: Browse all available REST API routes on the processor - **🏷️ Type Registry**: Browse supported device types and their properties @@ -84,10 +89,11 @@ The app supports up to 10 simultaneous PepperDash Essentials program slots (`app ## Support For technical support and questions: + - Check the [How-to Guides](./how-to/) for common solutions - Review the [Reference](./reference/) documentation for technical details - Consult the [Explanation](./explanation/) articles for deeper understanding --- -*This documentation is organized using the [Diataxis framework](https://diataxis.fr/) to ensure you get the right type of information for your needs.* +_This documentation is organized using the [Diataxis framework](https://diataxis.fr/) to ensure you get the right type of information for your needs._ diff --git a/docs/explanation/README.md b/docs/explanation/README.md index 1770d8c..c2f6f5a 100644 --- a/docs/explanation/README.md +++ b/docs/explanation/README.md @@ -7,9 +7,11 @@ This section provides background information, design rationale, and conceptual u ## πŸ—οΈ System Design and Architecture ### [System Architecture](./architecture.md) + **Understanding the overall system design and integration patterns** **Key Topics**: + - How the web app fits into the broader PepperDash ecosystem - Architectural layers and their responsibilities - Communication patterns between components @@ -18,6 +20,7 @@ This section provides background information, design rationale, and conceptual u - Design philosophy and trade-offs **Why Read This**: + - Understand how the web app integrates with the Essentials framework - Learn about the technical decisions that shape system behavior - Gain insight into security and performance considerations @@ -26,9 +29,11 @@ This section provides background information, design rationale, and conceptual u --- ### [Debug Console Design](./debug-console-design.md) + **Deep dive into the design principles behind the core debugging feature** **Key Topics**: + - Real-time monitoring design philosophy - WebSocket vs. HTTP polling decision rationale - Information hierarchy and progressive disclosure @@ -37,6 +42,7 @@ This section provides background information, design rationale, and conceptual u - Workflow optimization for common debugging tasks **Why Read This**: + - Understand why the debug console works the way it does - Learn effective debugging strategies based on the design - Appreciate the complexity and sophistication of the feature @@ -47,9 +53,11 @@ This section provides background information, design rationale, and conceptual u ## πŸ”§ Feature Design Concepts ### [Configuration Management](./configuration-management.md) + **How configuration is handled, stored, and presented** **Key Topics**: + - Configuration merging and hierarchy concepts - Real-time vs. static configuration access - Security considerations for configuration data @@ -57,6 +65,7 @@ This section provides background information, design rationale, and conceptual u - Integration with the Essentials configuration system **Why Read This**: + - Understand how your system configuration is processed - Learn about configuration best practices - Understand the relationship between files and runtime configuration @@ -65,9 +74,11 @@ This section provides background information, design rationale, and conceptual u --- ### [Security Considerations](./security.md) + **Security model, threats, and best practices** **Key Topics**: + - Authentication and authorization model - Data sensitivity and protection measures - Network security requirements and recommendations @@ -76,6 +87,7 @@ This section provides background information, design rationale, and conceptual u - Best practices for secure deployment **Why Read This**: + - Understand the security implications of using the web app - Learn about proper deployment in enterprise environments - Understand what data is exposed and how it's protected @@ -114,16 +126,19 @@ Rather than replacing existing Essentials framework functionality, the web app i ### Mental Models for Different User Types **System Administrators**: + - Think in terms of overall system health and performance - Need broad visibility with ability to drill down to specifics - Focus on proactive monitoring and rapid problem resolution **Technicians**: + - Think in terms of specific devices and their behavior - Need detailed diagnostic information for troubleshooting - Focus on understanding device interactions and communication **Developers**: + - Think in terms of code behavior and system integration - Need access to detailed technical information and structured data - Focus on understanding system behavior for development and testing @@ -131,6 +146,7 @@ Rather than replacing existing Essentials framework functionality, the web app i ### Information Architecture Concepts **Hierarchical Information Structure**: + ``` System Level (Global messages, overall health) ↓ @@ -142,6 +158,7 @@ Detail Level (Complete technical information) ``` **Temporal Information Structure**: + ``` Historical (What happened before) ↓ @@ -158,12 +175,14 @@ Predictive (What might happen next) **Complementary Tools**: The web app doesn't replace other PepperDash tools but complements them: + - **Touch Panels**: Provide user control interfaces - **Mobile Apps**: Offer convenient user control - **Configuration Tools**: Handle system setup and management - **Web Config App**: Provides monitoring, debugging, and analysis **Data Flow Relationships**: + ``` Configuration Files ──► Framework ──► Web App Display β”‚ @@ -175,12 +194,14 @@ User Interactions β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ### Operational Context **Development Lifecycle Support**: + - **Design Phase**: Types and configuration reference - **Implementation Phase**: Real-time debugging and testing - **Deployment Phase**: System validation and verification - **Maintenance Phase**: Monitoring and troubleshooting **User Workflow Integration**: + - **Daily Monitoring**: Quick health checks and status verification - **Problem Response**: Detailed investigation and diagnosis - **System Changes**: Configuration backup and change validation @@ -193,29 +214,34 @@ User Interactions β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ### Conceptual Learning Path **Basic Understanding** (Everyone should understand): + - What the web app is and how it fits in the system - Basic navigation and core features - Security model and access patterns **Operational Understanding** (Regular users should understand): + - How real-time monitoring works and why it's valuable - How filtering and search work together - How to interpret different types of messages and data **Deep Understanding** (Advanced users and developers should understand): -- Architectural design decisions and their implications + +- Architectural design decisions and their implications - Performance characteristics and optimization strategies - Integration patterns and extension possibilities ### Conceptual Dependencies **Prerequisites for Deep Understanding**: + - Basic familiarity with PepperDash Essentials framework - Understanding of web application architecture concepts - Knowledge of network communication patterns - Appreciation for user interface design principles **Building Conceptual Knowledge**: + 1. **Start with Architecture**: Understand the overall system design 2. **Focus on Core Features**: Deep dive into debug console design 3. **Expand to Specifics**: Learn about configuration and security @@ -254,6 +280,7 @@ The deep integration with the Essentials framework demonstrates patterns for bui ### Design Alternative Analysis **What If Different Choices Were Made?** + - Server-side filtering instead of client-side - HTTP polling instead of WebSocket streaming - Modal dialogs instead of slide-out drawers @@ -265,15 +292,17 @@ Each alternative choice would have different performance characteristics, user e ### Future Evolution Considerations **Scalability Questions**: + - How would the design handle 10x more devices? - What happens with 100x more message volume? - How would multiple simultaneous users impact performance? **Feature Extension Questions**: + - How would device control capabilities integrate? - What would configuration editing require? - How would multi-system support work? --- -*Explanation documentation helps you understand not just what the system does, but why it does it that way. This understanding enables more effective usage, better troubleshooting, and informed decision-making about how to integrate the tool into your workflows.* +_Explanation documentation helps you understand not just what the system does, but why it does it that way. This understanding enables more effective usage, better troubleshooting, and informed decision-making about how to integrate the tool into your workflows._ diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 077db71..e736a67 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -34,21 +34,25 @@ This article explains the architectural design, system relationships, and techni The web application serves several specific roles within this ecosystem: **Configuration Management**: + - Provides read-only access to the merged system configuration - Allows visualization of complex configuration relationships - Enables configuration backup and documentation **Real-Time Monitoring**: + - Offers live insight into system operations through debug messages - Enables filtering and analysis of system behavior - Provides troubleshooting capabilities for system administrators **System Control** (Limited): + - Allows system restart and configuration reloading - Provides control over debug logging levels - Enables controlled system maintenance operations **Development Support**: + - Assists in system development and debugging - Provides detailed insight into device communications - Enables testing and validation of system configurations @@ -58,6 +62,7 @@ The web application serves several specific roles within this ecosystem: ### Presentation Layer (React Frontend) **Technology Stack**: + - **React 18**: Component-based UI framework - **TypeScript**: Type-safe development - **Bootstrap 5**: Responsive design framework @@ -67,16 +72,19 @@ The web application serves several specific roles within this ecosystem: **Key Design Decisions**: **Single Page Application (SPA)**: + - Eliminates page refreshes for better user experience - Maintains WebSocket connections across navigation - Provides responsive interface suitable for various devices **Component-Based Architecture**: + - Reusable UI components for consistent interface - Separation of concerns between display and logic - Maintainable codebase with clear component boundaries **State Management Strategy**: + - **RTK Query**: Server state management and caching - **Redux Toolkit**: Client state management for authentication, debug console filters, and UI state - `auth` slice: global authentication state and available app slot list @@ -115,17 +123,20 @@ Web Browser ──HTTPS──► Control Processor **API Design Principles**: -**RESTful Endpoints**: +**RESTful Endpoints**: + - Standard HTTP methods (GET for retrieval, POST for actions) - Resource-based URLs that map to system concepts - Consistent response formats across all endpoints **Minimal Surface Area**: + - Exposes only necessary functionality to the web interface - Maintains security by limiting available operations - Reduces complexity by focusing on specific use cases **Real-Time Capability**: + - WebSocket connection for live debug message streaming - Bidirectional communication potential (currently read-only) - Efficient handling of high-volume message streams @@ -136,16 +147,19 @@ Web Browser ──HTTPS──► Control Processor The API layer acts as a bridge between the web interface and the core Essentials framework: **Configuration Access**: + - Reads merged configuration from the framework's configuration system - Provides access to both static configuration and runtime state - Maintains consistency with the framework's configuration model **Device Management**: + - Accesses the framework's device registry for device information - Provides device property and method information - Maintains synchronization with actual device states **Logging Integration**: + - Taps into the framework's structured logging system - Provides real-time access to log messages as they're generated - Supports filtering and level control through the logging framework @@ -163,6 +177,7 @@ Most operations use standard HTTP request-response patterns: 4. **Client Processing**: Web app processes response and updates UI **Caching Strategy**: + - **Static Data**: Versions, device types cached in browser - **Dynamic Data**: Device lists, configurations fetched on demand - **Real-Time Data**: Debug messages streamed, not cached @@ -181,6 +196,7 @@ The debug console uses WebSocket communication for real-time message streaming: ``` **Message Flow Architecture**: + ``` Framework Logging ──► Message Buffer ──► WebSocket ──► Client Browser β”‚ @@ -191,6 +207,7 @@ Framework Logging ──► Message Buffer ──► WebSocket ──► Client ``` **Performance Considerations**: + - **Message Buffering**: Framework buffers messages to handle bursts - **Rate Limiting**: Prevents overwhelming slow clients - **Connection Management**: Handles client disconnections gracefully @@ -213,10 +230,12 @@ The web app implements its own credential-based authentication flow on top of th After credentials are validated, the application probes all 10 possible slots (`app01`–`app10`) in parallel using `Promise.allSettled`. Slots that respond successfully are stored as `availableApps` and populate the app selector dropdown in the top navigation bar. **No Independent Authentication**: Web app doesn't implement its own persistent user system + - **Processor Integration**: Uses whatever authentication the processor has configured - **Session Management**: Relies on processor's session handling **Security Boundaries**: + ``` Internet ──[Firewall]──► Internal Network ──[HTTPS]──► Processor Web Server β”‚ @@ -227,11 +246,13 @@ Internet ──[Firewall]──► Internal Network ──[HTTPS]──► Proce ### Data Protection **In-Transit Security**: + - **HTTPS Required**: All communication encrypted with TLS - **Certificate Handling**: Self-signed certificates common for internal devices - **WebSocket Security**: WSS (secure WebSocket) for debug messages **Data Sensitivity**: + - **Configuration Data**: May contain IP addresses, device information - **Debug Messages**: May contain operational details and error information - **No User Data**: Application doesn't store personal or credential information @@ -241,6 +262,7 @@ Internet ──[Firewall]──► Internal Network ──[HTTPS]──► Proce ### Browser Performance **Client-Side Optimization**: + ``` Message Volume ──► Client Filtering ──► DOM Updates ──► Display β”‚ β”‚ β”‚ @@ -250,6 +272,7 @@ Message Buffers State Updates (Future Enhancement) ``` **Performance Characteristics**: + - **Message Processing**: Can handle 100+ messages per second - **Memory Management**: Messages accumulate in browser memory - **UI Responsiveness**: Maintained through efficient React updates @@ -257,11 +280,13 @@ Message Buffers State Updates (Future Enhancement) ### Server-Side Performance **Framework Integration Impact**: + - **Minimal Framework Load**: Web API designed for minimal impact - **Debug Session Overhead**: Each active session consumes resources - **Multiple Client Support**: Framework can support several concurrent web sessions **Resource Management**: + - **Connection Limits**: Framework may limit concurrent debug sessions - **Memory Usage**: Message buffering uses framework memory - **CPU Impact**: Minimal when not actively debugging @@ -271,6 +296,7 @@ Message Buffers State Updates (Future Enhancement) ### Code Organization **Frontend Structure**: + ``` src/ β”œβ”€β”€ features/ # Feature-based organization @@ -283,6 +309,7 @@ src/ ``` **Design Patterns**: + - **Feature-Based Organization**: Related components grouped together - **Shared Components**: Reusable UI elements across features - **Service Layer**: Abstracted API communication @@ -291,12 +318,14 @@ src/ ### Build and Deployment **Build Process**: + 1. **TypeScript Compilation**: Type checking and compilation 2. **Bundling**: Webpack bundles for browser delivery 3. **Asset Optimization**: Minification and compression 4. **Static File Generation**: Ready for web server deployment **Deployment Model**: + - **Static Files**: Compiled to static HTML, CSS, JavaScript - **Processor Hosting**: Files served by processor's web server - **No Server Requirements**: Pure client-side application @@ -306,12 +335,14 @@ src/ ### Essentials Framework Integration **Framework APIs Used**: + - **Device Manager**: For device information and control - **Configuration System**: For configuration access and management - **Logging Framework**: For debug message access - **System Control**: For restart and reload operations **Framework Dependencies**: + - **Version Compatibility**: Requires specific Essentials framework versions - **API Stability**: Depends on framework API consistency - **Feature Availability**: Some features require specific framework capabilities @@ -319,16 +350,19 @@ src/ ### Future Integration Possibilities **Enhanced Device Control**: + - Direct device method execution from web interface - Real-time device property monitoring - Device configuration modification capabilities **Extended System Management**: + - Configuration file editing and validation - System performance monitoring and analytics - Remote system updates and maintenance **Multi-System Support**: + - Management of multiple processors from single interface - System comparison and synchronization capabilities - Centralized monitoring for multiple installations @@ -338,11 +372,13 @@ src/ ### User-Centered Design **Progressive Disclosure**: + - Start with simple, common operations - Provide access to advanced features when needed - Maintain clear information hierarchy **Real-Time Feedback**: + - Immediate visual feedback for all actions - Real-time data updates where possible - Clear indication of system state and changes @@ -350,16 +386,19 @@ src/ ### Technical Excellence **Maintainability**: + - Clear separation of concerns - Consistent coding patterns and standards - Comprehensive type safety with TypeScript **Reliability**: + - Graceful handling of network issues - Robust error handling and recovery - Consistent behavior across different environments **Performance**: + - Efficient rendering and state management - Minimal impact on processor resources - Responsive interface under various load conditions diff --git a/docs/explanation/configuration-management.md b/docs/explanation/configuration-management.md index 312e278..3bd269a 100644 --- a/docs/explanation/configuration-management.md +++ b/docs/explanation/configuration-management.md @@ -13,6 +13,7 @@ The file system serves as the authoritative configuration source. The web app pr **Layered Configuration Model**: Configuration follows a layered approach where multiple files can contribute to the final system configuration. This allows for: + - Base system defaults - Template configurations for common setups - Site-specific customizations @@ -37,6 +38,7 @@ Rather than requiring a single monolithic configuration file, the system merges **Hot Reload Capability**: The system supports configuration refresh without full restart: + - Configuration files are re-read from disk - New configuration is merged and validated - Existing devices are updated where possible @@ -45,6 +47,7 @@ The system supports configuration refresh without full restart: **State Preservation**: During configuration reload, the system attempts to preserve: + - Device communication states - User interface states - Active connections and sessions @@ -68,6 +71,7 @@ System Configuration **Devices as Building Blocks**: Devices represent individual controllable entities in the system. Each device has: + - Unique identity (key) - Type definition (determines capabilities) - Properties (device-specific configuration) @@ -75,6 +79,7 @@ Devices represent individual controllable entities in the system. Each device ha **Rooms as Orchestrators**: Rooms define logical collections of devices and their relationships: + - Device membership (which devices belong to the room) - User interface bindings (how users interact with devices) - Default behaviors (power-on sequences, preferred sources) @@ -82,6 +87,7 @@ Rooms define logical collections of devices and their relationships: **Tie Lines as Connections**: Tie lines define physical or logical connections between devices: + - Signal routing (audio/video paths) - Control relationships (master/slave configurations) - Communication paths (device-to-device messaging) @@ -92,6 +98,7 @@ Tie lines define physical or logical connections between devices: **Deep Object Merging**: When merging configuration objects, the system performs deep merging: + - Nested objects are merged recursively - Arrays are replaced entirely (not merged) - Primitive values are overwritten @@ -99,6 +106,7 @@ When merging configuration objects, the system performs deep merging: **Precedence Rules**: Configuration files are processed in order of precedence: + 1. System defaults (lowest priority) 2. Template configurations 3. Base configuration files @@ -109,12 +117,14 @@ Configuration files are processed in order of precedence: **Property Conflicts**: When the same property exists in multiple configuration files: + - Later files override earlier ones - Warning messages are logged for reference - Original values are preserved in merge history **Reference Conflicts**: When object references conflict: + - Duplicate keys generate error messages - System attempts to resolve ambiguity using context - Manual intervention may be required for resolution @@ -124,16 +134,19 @@ When object references conflict: ### Multi-Level Validation **Syntax Validation**: + - JSON structure must be valid - Required properties must be present - Property types must match expectations **Semantic Validation**: + - Device types must be registered and available - Cross-references must point to existing objects - Property values must be within acceptable ranges **Runtime Validation**: + - Device communication settings must be reachable - Hardware capabilities must match configuration expectations - System resources must be sufficient for configuration demands @@ -141,12 +154,14 @@ When object references conflict: ### Validation Feedback **Error Categories**: + - **Fatal Errors**: Prevent system startup, require immediate attention - **Warnings**: Allow system operation but indicate potential issues - **Informational**: Provide guidance for optimization or best practices **Error Reporting**: Validation results are reported through multiple channels: + - Debug console messages during system startup - Configuration viewer warnings and errors - System log files for historical reference @@ -157,12 +172,14 @@ Validation results are reported through multiple channels: **Read-Only Architecture**: The web interface provides read-only access to configuration: + - Prevents accidental configuration changes through web interface - Ensures configuration changes go through proper change control - Maintains clear separation between monitoring and administration **File System Security**: Configuration security relies on file system permissions: + - Configuration files should have appropriate read/write permissions - Directory access should be restricted to authorized users - Backup and restore procedures should maintain security @@ -171,12 +188,14 @@ Configuration security relies on file system permissions: **Sensitive Information Handling**: Configuration may contain sensitive information: + - Passwords and credentials for device communication - Network addresses and security settings - Proprietary device configuration parameters **Display Filtering**: The web interface filters sensitive information: + - Passwords are masked or omitted from display - Security-sensitive properties may be hidden - Network topology information may be sanitized @@ -187,12 +206,14 @@ The web interface filters sensitive information: **Lazy Loading**: Configuration objects are loaded and processed on-demand: + - Large configurations don't impact startup time unnecessarily - Memory usage scales with active system components - Network requests are optimized for actual usage patterns **Caching Strategy**: Processed configuration is cached for performance: + - Parsed configuration objects are reused across requests - Expensive validation operations are cached - Change detection minimizes unnecessary processing @@ -201,12 +222,14 @@ Processed configuration is cached for performance: **Modular Configuration**: Large systems benefit from modular configuration: + - Room-specific configuration files - Device-type-specific templates - Feature-specific configuration modules **Configuration Partitioning**: Very large systems can partition configuration: + - Geographic partitioning (building, floor, room) - Functional partitioning (audio, video, control) - Administrative partitioning (different responsible parties) @@ -217,12 +240,14 @@ Very large systems can partition configuration: **Configuration Schema Versioning**: Configuration schemas evolve over time: + - Backward compatibility is maintained where possible - Migration tools help upgrade older configurations - Version-specific validation provides appropriate feedback **Change Management**: Configuration changes follow managed processes: + - Version control integration for configuration files - Rollback capabilities for problematic changes - Change approval workflows for production systems @@ -231,12 +256,14 @@ Configuration changes follow managed processes: **Schema Migration**: When configuration schemas change: + - Automatic migration for simple changes - Manual intervention for complex transformations - Validation and testing of migrated configurations **Data Migration**: When moving between system versions: + - Export/import tools for configuration transfer - Compatibility checking between versions - Validation of migrated system behavior @@ -247,12 +274,14 @@ When moving between system versions: **Logical Grouping**: Organize configuration logically: + - Group related devices together - Separate infrastructure from user-facing configuration - Use consistent naming conventions throughout **Documentation Integration**: Configuration should be self-documenting: + - Use descriptive names for devices and rooms - Include description fields for complex configurations - Maintain separate documentation for configuration rationale @@ -261,12 +290,14 @@ Configuration should be self-documenting: **Regular Review**: Configuration should be reviewed regularly: + - Remove unused devices and rooms - Update outdated property values - Verify that configuration matches physical reality **Change Documentation**: Document configuration changes: + - Maintain change logs for significant modifications - Include rationale for configuration decisions - Track configuration evolution over time @@ -277,11 +308,13 @@ Document configuration changes: **Shared Resources**: Multi-room systems often share expensive resources: + - Central DSP serving multiple rooms - Shared video switching infrastructure - Common source equipment **Configuration Strategy**: + ```json { "devices": { @@ -313,12 +346,14 @@ Multi-room systems often share expensive resources: **Device Templates**: Common device configurations can be templated: + - Standard display configurations - Common DSP setups - Typical room layouts **Template Usage**: Templates are merged with specific configurations: + - Base template provides common properties - Specific configuration overrides as needed - Result is fully merged configuration @@ -328,16 +363,19 @@ Templates are merged with specific configurations: ### Common Configuration Problems **Missing References**: + - Symptoms: Devices or rooms don't appear as expected - Cause: Broken references between configuration objects - Solution: Verify key names match exactly between references **Type Mismatches**: + - Symptoms: Devices don't behave as expected - Cause: Device type doesn't match actual device capabilities - Solution: Verify device type selection and properties **Property Conflicts**: + - Symptoms: Unexpected device behavior or error messages - Cause: Conflicting property values in multiple configuration files - Solution: Review merge precedence and resolve conflicts @@ -346,6 +384,7 @@ Templates are merged with specific configurations: **Use Debug Console**: The debug console provides valuable configuration debugging information: + - Device initialization messages - Configuration validation results - Property merge and override notifications @@ -353,11 +392,13 @@ The debug console provides valuable configuration debugging information: **Configuration Viewer Analysis**: The configuration viewer shows the final merged configuration: + - Compare expected vs. actual configuration - Identify merged property values - Trace configuration source for each property **Systematic Approach**: + 1. Verify basic JSON syntax in all configuration files 2. Check that all referenced objects exist 3. Validate device types and required properties @@ -366,4 +407,4 @@ The configuration viewer shows the final merged configuration: --- -*Understanding how configuration management works helps you make informed decisions about system design, troubleshoot configuration issues effectively, and optimize system performance through proper configuration practices.* +_Understanding how configuration management works helps you make informed decisions about system design, troubleshoot configuration issues effectively, and optimize system performance through proper configuration practices._ diff --git a/docs/explanation/debug-console-design.md b/docs/explanation/debug-console-design.md index 1dad5a3..8e9e215 100644 --- a/docs/explanation/debug-console-design.md +++ b/docs/explanation/debug-console-design.md @@ -10,6 +10,7 @@ The Debug Console represents a sophisticated approach to real-time system monito **The Core Problem**: Traditional system debugging requires either: + - Physical access to the control processor - Remote desktop connections to view local debugging tools - Static log file analysis after problems occur @@ -35,6 +36,7 @@ Complete Message Information ``` **Why This Approach**: + - **Prevents Overwhelm**: Users aren't confronted with all details immediately - **Supports Different Use Cases**: From high-level monitoring to detailed debugging - **Maintains Context**: Users can broaden or narrow focus as needed @@ -48,12 +50,14 @@ Complete Message Information **Alternative Considered**: HTTP polling every few seconds **Why WebSocket**: + - **True Real-Time**: Messages appear immediately when generated - **Efficient**: No repeated HTTP request overhead - **Scalable**: Server can handle multiple concurrent streams - **Responsive**: Instant feedback for system events **Trade-offs Accepted**: + - **Complexity**: WebSocket connections require more sophisticated handling - **Connection Management**: Need to handle disconnections and reconnections - **Browser Compatibility**: Requires modern browser support @@ -64,6 +68,7 @@ Complete Message Information **Alternative Considered**: Server-side filtering to reduce data transmission **Why Client-Side**: + - **Responsive Filtering**: Filter changes apply instantly to existing messages - **Rich Interaction**: Complex filter combinations (including per-device levels) without server round-trips - **Historical Analysis**: Can re-filter previously received messages @@ -71,6 +76,7 @@ Complete Message Information - **Persistent Across Navigation**: Filter state stored in Redux survives route changes within the same session **Trade-offs Accepted**: + - **Bandwidth Usage**: All messages transmitted even if filtered out - **Browser Performance**: High message volumes can impact browser performance - **Memory Usage**: Messages accumulate in browser memory @@ -92,6 +98,7 @@ Messages use structured logging with both human-readable text and machine-readab ``` **Why This Design**: + - **Human-Friendly**: Rendered message is immediately understandable - **Machine-Parseable**: Properties allow sophisticated filtering and analysis - **Consistent**: Template ensures similar events have similar structure @@ -103,6 +110,7 @@ Messages use structured logging with both human-readable text and machine-readab **Multiple Filter Types**: The console provides two distinct filtering mechanisms: + 1. **Device Selection with Per-Device Minimum Level**: Which devices to show, and what minimum severity to require per device 2. **Text Search**: What specific content are you looking for? @@ -110,11 +118,13 @@ The console provides two distinct filtering mechanisms: Each device in the filter list can have its own minimum log level threshold, independent of the server-side global minimum. When a device is checked in the Devices dropdown, it defaults to `Information`. A nested level dropdown next to each checked device allows selecting a higher threshold (e.g. `Warning` or `Error`) to reduce noise from that specific device while keeping other devices at a lower threshold. **Why Per-Device Rather Than Global-Only**: + - **Precision**: A busy device generating many `Information` messages can be silenced at `Warning` while other devices remain visible at `Information` - **Context Preservation**: Keeps the context of multiple devices without overwhelming the view with one device's verbose output - **Flexible Workflow**: Useful when one device is suspected of issues and you want high verbosity from it, but low noise from everything else **Why AND Logic**: + - **Intuitive**: Matches mental model of "show me messages that are X AND Y AND Z" - **Progressively Restrictive**: Each filter narrows results further - **Predictable**: Users can understand what will happen when they add filters @@ -125,17 +135,20 @@ Each device in the filter list can have its own minimum log level threshold, ind Messages display in a table format with fixed columns **Why Tables**: + - **Scannable**: Easy to scan timestamps, devices, and levels quickly - **Sortable**: Natural place for sorting functionality (future enhancement) - **Familiar**: Users understand table interfaces immediately - **Efficient**: Dense information display without clutter **Column Priority Design**: + ``` Timestamp (6) | Device (3) | Level (2) | Message (13) ``` **Why This Allocation**: + - **Timestamp**: Wide enough for full timestamp but not dominant - **Device**: Narrow because device keys are typically short - **Level**: Very narrow because levels are single words @@ -146,12 +159,14 @@ Timestamp (6) | Device (3) | Level (2) | Message (13) **Decision**: Use slide-out drawer instead of modal dialog or inline expansion **Why Drawer**: + - **Context Preservation**: Can see main message list while viewing details - **Non-Modal**: Doesn't interrupt workflow or require dismissal - **Large Display Area**: More space for detailed information than inline - **Consistent Pattern**: Familiar from mobile and desktop applications **Information Hierarchy in Details**: + 1. **Timestamp**: Precise timing information 2. **Rendered Message**: Complete human-readable text 3. **Message Template**: Shows structure for analysis @@ -167,16 +182,19 @@ Busy systems can generate hundreds of messages per minute, potentially overwhelm **Multi-Layer Approach**: **Server-Side Rate Control**: + - Minimum log level setting reduces message volume at source - Message buffering prevents burst impacts - Connection management limits concurrent sessions **Client-Side Optimization**: + - Efficient React rendering for high-frequency updates - Debounced filter updates prevent excessive re-rendering - Message accumulation management (manual session reset) **User Control Mechanisms**: + - Stop/start debug sessions to control data flow - Clear filters to reset and start fresh - Log level adjustment to focus on relevant messages @@ -184,16 +202,19 @@ Busy systems can generate hundreds of messages per minute, potentially overwhelm ### Browser Performance Strategy **DOM Management**: + - Fixed table headers to avoid layout recalculation - Efficient list rendering with consistent row heights - Minimal DOM manipulation for new messages **Memory Management**: + - Messages accumulate during session (intentional for historical analysis) - Page refresh clears accumulated messages - No automatic message trimming (user controls session length) **UI Responsiveness**: + - Debounced search input (1-second delay) - Immediate visual feedback for filter changes - Non-blocking operations for message processing @@ -204,12 +225,14 @@ Busy systems can generate hundreds of messages per minute, potentially overwhelm **Information Exposure**: Debug messages can contain sensitive operational information: + - Device IP addresses and network configuration - User interaction patterns and timing - System performance and reliability information - Error conditions that might reveal vulnerabilities **Design Response**: + - **No Persistent Storage**: Messages not stored long-term - **Session-Based**: Data only exists during active debug sessions - **Network-Only**: Relies on network security for protection @@ -218,11 +241,13 @@ Debug messages can contain sensitive operational information: ### Access Control Integration **Processor-Based Security**: + - Leverages existing processor authentication - No independent user management - Relies on network-level access control **Why This Approach**: + - **Consistency**: Same security model as other processor interfaces - **Simplicity**: No additional authentication system to maintain - **Integration**: Works with existing IT security policies @@ -234,6 +259,7 @@ Debug messages can contain sensitive operational information: **Primary User Workflows Optimized**: **Problem Identification**: + ``` 1. Start debug session 2. Set filters to Error + Warning levels @@ -242,6 +268,7 @@ Debug messages can contain sensitive operational information: ``` **Device Troubleshooting**: + ``` 1. Filter to specific problematic device 2. Monitor device-specific messages @@ -250,6 +277,7 @@ Debug messages can contain sensitive operational information: ``` **System Health Monitoring**: + ``` 1. Keep session running with minimal filters 2. Watch for unusual patterns or error rates @@ -260,12 +288,14 @@ Debug messages can contain sensitive operational information: ### Information Seeking Support **Different User Mental Models**: + - **Time-Based**: "What happened at 10:30 AM?" - **Device-Based**: "What's wrong with the conference room display?" - **Event-Based**: "Show me all power-related events" - **Problem-Based**: "What errors are occurring?" **Design Support**: + - **Timestamp Display**: Supports time-based investigation - **Device Filtering**: Supports device-focused troubleshooting - **Text Search**: Supports event and keyword-based searching @@ -277,6 +307,7 @@ Debug messages can contain sensitive operational information: **High Message Volume Systems**: Current design assumes manageable message rates (< 200/minute). Future enhancements might include: + - **Virtual Scrolling**: Handle thousands of messages efficiently - **Server-Side Filtering**: Reduce bandwidth for high-volume systems - **Message Sampling**: Statistical sampling for very high-rate systems @@ -285,6 +316,7 @@ Current design assumes manageable message rates (< 200/minute). Future enhanceme ### Enhanced Analysis Capabilities **Pattern Recognition**: + - **Message Correlation**: Link related messages across devices - **Timeline Visualization**: Graphical representation of events over time - **Statistical Analysis**: Message rate trends and anomaly detection @@ -293,6 +325,7 @@ Current design assumes manageable message rates (< 200/minute). Future enhanceme ### Multi-System Support **Distributed System Monitoring**: + - **Multiple Processor Support**: Monitor several systems simultaneously - **System Comparison**: Compare behavior across similar systems - **Centralized Dashboard**: High-level view of multiple installations @@ -302,11 +335,13 @@ Current design assumes manageable message rates (< 200/minute). Future enhanceme ### Usability Indicators **Efficient Problem Resolution**: + - Time from problem report to root cause identification - Reduction in site visits for troubleshooting - User ability to resolve issues without expert assistance **Learning Curve**: + - Time for new users to become proficient - Frequency of user errors or confusion - User retention and continued usage @@ -314,11 +349,13 @@ Current design assumes manageable message rates (< 200/minute). Future enhanceme ### Technical Performance **System Impact**: + - Processor resource usage during debug sessions - Network bandwidth consumption - Browser performance under various message loads **Reliability**: + - WebSocket connection stability - Graceful handling of network issues - Data consistency and accuracy diff --git a/docs/explanation/security.md b/docs/explanation/security.md index 0b4575a..72a6883 100644 --- a/docs/explanation/security.md +++ b/docs/explanation/security.md @@ -11,18 +11,21 @@ This document explains the security architecture, threat model, and design decis The web config app implements defense-in-depth through multiple security layers: **Network Layer Security**: + - HTTPS encryption for all web traffic - Internal network isolation - Firewall-based access control - Certificate-based authentication **Application Layer Security**: + - Read-only access model - Session-based authentication - Input validation and sanitization - Cross-site scripting (XSS) protection **Data Layer Security**: + - Sensitive information filtering - Access logging and auditing - Configuration data protection @@ -31,14 +34,33 @@ The web config app implements defense-in-depth through multiple security layers: ### Security-First Design Principles **Principle of Least Privilege**: -The web app operates with minimal necessary permissions: -- Read-only access to configuration data -- No ability to modify system configuration through web interface +The web app reads far more than it writes, and the writes it does perform are narrow: + +- Configuration files are read-only β€” the app cannot edit or replace a config +- Write operations are limited to a defined set (see **Write Operations** below) - Limited system command execution capabilities - Restricted file system access +**Write Operations**: +The app is not read-only, and has not been for some time. These operations change processor state: + +| Operation | Effect | +| ------------------------------------- | ------------------------------------------------ | +| Restart program | Restarts the Essentials program | +| Load configuration | Reloads the configuration from disk | +| Set "do not load config on next boot" | Changes boot behaviour | +| Set minimum log level | Changes debug verbosity | +| Execute device method | Invokes an arbitrary method on a device | +| Routing commands | Makes and clears signal routes | +| Mobile Control clients | Creates and deletes UI client registrations | +| Secrets | Creates, replaces and deletes stored credentials | + +Secrets deserve particular note: values can be **written but never read**, by design. A compromised +session can replace or destroy a credential, but cannot exfiltrate one through this interface. + **Fail-Safe Defaults**: Security defaults assume restrictive access: + - Default configuration denies rather than permits - Unknown users receive minimal access - Error conditions default to secure states @@ -49,18 +71,21 @@ Security defaults assume restrictive access: ### Identified Threat Vectors **Network-Based Attacks**: + - **Man-in-the-middle attacks**: Mitigated by HTTPS encryption - **Network eavesdropping**: Protected by certificate-based encryption -- **Denial of service**: Limited by read-only nature and resource constraints +- **Denial of service**: Limited by resource constraints; note that restart and config-reload are exposed write operations - **Network scanning**: Reduced attack surface through minimal exposed services **Web Application Attacks**: + - **Cross-site scripting (XSS)**: Prevented by input sanitization and output encoding - **Cross-site request forgery (CSRF)**: Protected by same-origin policy - **Injection attacks**: Mitigated by parameterized queries and input validation - **Session hijacking**: Protected by secure session management **Physical Access Attacks**: + - **Console access**: Requires physical processor access - **Network port access**: Requires physical network access - **Storage access**: Configuration files protected by file system permissions @@ -70,18 +95,21 @@ Security defaults assume restrictive access: **High Risk - Network Exposure**: The web interface exposes system information over the network: + - **Impact**: Potential information disclosure about system configuration - **Mitigation**: HTTPS encryption, internal network isolation, access controls - **Residual Risk**: Low with proper network security implementation **Medium Risk - Information Disclosure**: Configuration data may contain sensitive information: + - **Impact**: Exposure of device credentials, network topology, system details -- **Mitigation**: Sensitive data filtering, read-only access, audit logging +- **Mitigation**: Sensitive data filtering and audit logging. Stored secret values are never returned by any endpoint, so they cannot be read back through the web interface - **Residual Risk**: Low to medium depending on configuration content **Low Risk - Denial of Service**: Web interface could be overwhelmed by requests: + - **Impact**: Temporary unavailability of web interface - **Mitigation**: Resource limits, connection throttling, graceful degradation - **Residual Risk**: Very low, system continues operating without web interface @@ -102,6 +130,7 @@ The web app implements a credential-based login flow that gates access to all ap **Session Management**: Web sessions are managed through the Redux store: + - Session exists for the lifetime of the browser tab - Logging out (or reloading) resets all auth state - No session tokens are stored client-side beyond the duration of the session @@ -112,18 +141,22 @@ Web sessions are managed through the Redux store: **Configuration Data Sensitivity**: Configuration files may contain various levels of sensitive information: + - **Highly Sensitive**: Device passwords, encryption keys, security tokens - **Moderately Sensitive**: Network addresses, user names, system topology - **Low Sensitivity**: Device names, room assignments, basic settings **Data Filtering Strategy**: The web interface implements multi-level filtering: + ```javascript // Example of sensitive data filtering const filterSensitiveProperties = (config) => { const sensitiveKeys = ['password', 'key', 'token', 'credential']; return Object.keys(config).reduce((filtered, key) => { - if (sensitiveKeys.some(sensitive => key.toLowerCase().includes(sensitive))) { + if ( + sensitiveKeys.some((sensitive) => key.toLowerCase().includes(sensitive)) + ) { filtered[key] = '[REDACTED]'; } else if (typeof config[key] === 'object') { filtered[key] = filterSensitiveProperties(config[key]); @@ -139,6 +172,7 @@ const filterSensitiveProperties = (config) => { **Encryption in Transit**: All data transmission is encrypted: + - **HTTPS/TLS**: Encrypts all web traffic between browser and processor - **WebSocket Secure (WSS)**: Encrypts real-time debug message streams - **Certificate Validation**: Ensures connection authenticity @@ -146,6 +180,7 @@ All data transmission is encrypted: **Message Integrity**: Data integrity is maintained through: + - TLS message authentication codes (MAC) - Application-level checksums for critical data - Real-time validation of received data @@ -157,6 +192,7 @@ Data integrity is maintained through: **Network Trust Model**: The system assumes deployment on trusted internal networks: + - **Physical Security**: Network infrastructure is physically secured - **Network Segmentation**: System networks are isolated from public internet - **Access Controls**: Network access is controlled through VLANs, firewalls @@ -164,6 +200,7 @@ The system assumes deployment on trusted internal networks: **Certificate Management in Internal Networks**: Internal deployments often use self-signed certificates: + - **Trust Establishment**: Users must explicitly accept certificates - **Certificate Rotation**: Manual certificate updates required - **Trust Validation**: Certificate fingerprint verification recommended @@ -172,6 +209,7 @@ Internal deployments often use self-signed certificates: ### Firewall and Network Controls **Recommended Firewall Rules**: + ``` # Allow HTTPS access from management network ALLOW tcp/443 from MGMT_NETWORK to PROCESSOR_IP @@ -184,6 +222,7 @@ DENY ALL from ANY to PROCESSOR_IP ``` **Network Segmentation Best Practices**: + - Isolate control systems from corporate networks - Use VLANs to separate management and operational traffic - Implement network access control (NAC) for device authentication @@ -195,6 +234,7 @@ DENY ALL from ANY to PROCESSOR_IP **Client-Side Validation**: User inputs are validated in the browser: + - Form validation prevents submission of invalid data - Input type constraints limit acceptable values - Length limits prevent buffer overflow attempts @@ -202,6 +242,7 @@ User inputs are validated in the browser: **Server-Side Validation**: All inputs are re-validated on the server: + - Never trust client-side validation alone - Parameterized queries prevent SQL injection - Input sanitization removes potentially harmful content @@ -210,17 +251,19 @@ All inputs are re-validated on the server: ### Cross-Site Scripting (XSS) Protection **Content Security Policy (CSP)**: + ```http -Content-Security-Policy: - default-src 'self'; - script-src 'self' 'unsafe-inline'; - style-src 'self' 'unsafe-inline'; - img-src 'self' data:; +Content-Security-Policy: + default-src 'self'; + script-src 'self' 'unsafe-inline'; + style-src 'self' 'unsafe-inline'; + img-src 'self' data:; connect-src 'self' wss:; ``` **Output Encoding**: All dynamic content is properly encoded: + - HTML entity encoding for text content - JavaScript escaping for script contexts - URL encoding for URL parameters @@ -229,6 +272,7 @@ All dynamic content is properly encoded: ### Session Security **Secure Session Configuration**: + ```javascript // Session cookie configuration { @@ -240,6 +284,7 @@ All dynamic content is properly encoded: ``` **Session Lifecycle Management**: + - Automatic session creation on first access - Session regeneration on privilege escalation - Proper session cleanup on logout @@ -250,6 +295,7 @@ All dynamic content is properly encoded: ### Security Event Logging **Logged Security Events**: + - User authentication attempts (success and failure) - Session creation, timeout, and termination - Access to sensitive configuration data @@ -257,6 +303,7 @@ All dynamic content is properly encoded: - Network connection establishment and termination **Log Format and Storage**: + ```json { "timestamp": "2024-01-15T10:30:00Z", @@ -272,12 +319,14 @@ All dynamic content is properly encoded: ### Audit Trail Maintenance **Log Retention Policy**: + - Security logs retained for minimum 90 days - Critical security events retained for 1 year - Log rotation to prevent storage exhaustion - Secure log storage with integrity protection **Monitoring and Alerting**: + - Real-time monitoring of authentication failures - Alerting on suspicious access patterns - Integration with security information and event management (SIEM) systems @@ -288,6 +337,7 @@ All dynamic content is properly encoded: ### Secure Installation Guidelines **Initial Setup Security**: + 1. **Change Default Credentials**: Update all default passwords immediately 2. **Certificate Installation**: Install proper SSL certificates for production 3. **Network Configuration**: Configure firewalls and network access controls @@ -295,6 +345,7 @@ All dynamic content is properly encoded: 5. **Security Testing**: Perform vulnerability assessment before production use **Production Hardening**: + - Disable unnecessary services and protocols - Apply security patches and updates regularly - Configure secure logging and monitoring @@ -304,6 +355,7 @@ All dynamic content is properly encoded: ### Ongoing Security Maintenance **Regular Security Tasks**: + - **Certificate Renewal**: Monitor and renew SSL certificates before expiration - **Access Review**: Regularly review user accounts and permissions - **Log Analysis**: Review security logs for suspicious activities @@ -311,6 +363,7 @@ All dynamic content is properly encoded: - **Incident Response**: Maintain procedures for security incident response **Security Update Management**: + - Monitor security advisories for PepperDash Essentials - Test security updates in development environment - Plan and execute security updates during maintenance windows @@ -321,12 +374,14 @@ All dynamic content is properly encoded: ### Regulatory Compliance **Industry Standards**: + - **NIST Cybersecurity Framework**: Align security practices with NIST guidelines - **ISO 27001**: Information security management system standards - **SOC 2**: Security controls for service organizations - **GDPR**: Data protection requirements (if applicable) **Compliance Documentation**: + - Security policy documentation - Risk assessment and mitigation documentation - Audit trail and logging documentation @@ -335,12 +390,14 @@ All dynamic content is properly encoded: ### Privacy Protection **Data Minimization**: + - Collect only necessary system information - Avoid logging personally identifiable information - Implement data retention policies - Provide data deletion capabilities where required **Consent and Transparency**: + - Clear documentation of data collection practices - User consent for optional data collection - Transparency about security measures and limitations @@ -350,12 +407,18 @@ All dynamic content is properly encoded: ### Known Limitations -**Read-Only Security Model**: -- Prevents web-based configuration changes (positive security feature) +**No Authorization Model**: + +- Authentication is a shared session against the processor's web server; the app stores only + whether you are signed in and which program slots answered +- There are **no roles and no permission tiers** β€” any authenticated user can perform every write + operation the app exposes +- Access control is therefore entirely a matter of who can reach and authenticate to the processor - Does not protect against attacks through other system interfaces - Relies on processor-level security for comprehensive protection **Internal Network Dependency**: + - Security model assumes trusted internal network deployment - May not be suitable for internet-facing deployments without additional security measures - Self-signed certificates require manual trust establishment @@ -363,12 +426,14 @@ All dynamic content is properly encoded: ### Security Assumptions **Environmental Assumptions**: + - Physical security of processor and network infrastructure - Trusted internal network with appropriate access controls - Competent system administration and security management - Regular security updates and maintenance **Operational Assumptions**: + - Users receive appropriate security training - Security policies and procedures are followed - Incident response capabilities are available @@ -379,12 +444,14 @@ All dynamic content is properly encoded: ### Emerging Threats **Evolving Threat Landscape**: + - Internet of Things (IoT) security challenges - Advanced persistent threats (APT) - Supply chain security concerns - Zero-day vulnerability exploitation **Adaptation Strategies**: + - Continuous security monitoring and improvement - Integration with threat intelligence feeds - Automated security testing and validation @@ -393,6 +460,7 @@ All dynamic content is properly encoded: ### Security Enhancement Roadmap **Planned Security Improvements**: + - Enhanced multi-factor authentication options - Improved certificate management automation - Advanced threat detection and response @@ -400,4 +468,4 @@ All dynamic content is properly encoded: --- -*Understanding the security model helps you deploy and operate the web config app safely in your environment. Security is a shared responsibility between the application developers, system administrators, and end users.* +_Understanding the security model helps you deploy and operate the web config app safely in your environment. Security is a shared responsibility between the application developers, system administrators, and end users._ diff --git a/docs/how-to/README.md b/docs/how-to/README.md index 0afe54c..8f04372 100644 --- a/docs/how-to/README.md +++ b/docs/how-to/README.md @@ -7,15 +7,18 @@ These guides provide step-by-step solutions to specific problems you might encou ## πŸ”§ Connection and Access Issues ### [Troubleshooting Connection Issues](./troubleshoot-connection.md) + **Problem**: Can't access the web application or it's not loading properly **Common symptoms:** + - Browser can't reach the application URL -- Page won't load or shows errors +- Page won't load or shows errors - Security certificate warnings - Application loads but doesn't function **You'll learn to fix:** + - Network connectivity problems - Browser security settings - URL formatting issues @@ -26,15 +29,18 @@ These guides provide step-by-step solutions to specific problems you might encou ## πŸ” Debug Console Problems ### [Filter and Search Debug Messages](./filter-debug-messages.md) + **Problem**: Too many debug messages to find what you need **Common symptoms:** + - Debug console overwhelmed with messages - Can't find specific device information - Need to focus on particular types of events - Looking for specific errors or patterns **You'll learn to:** + - Use advanced filtering techniques effectively - Combine multiple search criteria - Find specific message types quickly @@ -45,15 +51,18 @@ These guides provide step-by-step solutions to specific problems you might encou ## πŸ“„ Configuration Management ### [Export and Analyze Configuration](./export-configuration.md) + **Problem**: Need to examine, backup, or analyze system configuration **Common symptoms:** + - Need to document current system setup - Want to compare configurations between systems - Need to backup configuration before changes - Troubleshooting requires configuration analysis **You'll learn to:** + - Export complete configuration data - Analyze configuration structure and content - Compare configurations between systems @@ -61,33 +70,103 @@ These guides provide step-by-step solutions to specific problems you might encou --- +## πŸ”€ Routing + +### [Trace Signal Routes and Read the Routing Diagram](./trace-signal-routes.md) + +**Problem**: Need to find what's feeding a display, verify a route change, or make sense of a busy routing diagram + +**Common symptoms:** + +- Wrong source appears on a display or output +- Need to confirm a route or multiview layout change took effect +- Routing diagram has too many devices/tie lines to read easily +- Live feedback badge shows "Offline" or a certificate warning appears + +**You'll learn to:** + +- Filter the diagram by signal type and device +- Trace a signal path by clicking an edge, device, or multiview tile +- Read and reposition multiview layout panels +- Troubleshoot the live feedback connection + +### [Change Routes from the Routing Diagram](./change-routes.md) + +**Problem**: Need to send a source to a display, switch a matrix output, or tear down a route without leaving the diagram + +**Common symptoms:** + +- Verifying during commissioning that a wiring path actually works +- Need to switch one matrix output in isolation to test it +- A display is showing the wrong source and needs rerouting +- A multiview tile needs a different source + +**You'll learn to:** + +- Route a source to a destination input, through any midpoints in between +- Switch a single midpoint device without affecting the rest of the path +- Break audio and video away onto separate paths +- Route multiview tiles from the node or the layout panel +- Clear a route, and read the pending/timeout indicators + +--- + +## πŸ” Credentials and Secrets + +### [Manage Stored Secrets](./manage-secrets.md) + +**Problem**: Need to see which credentials a processor has stored, change one, or set up several programs with the same set + +**Common symptoms:** + +- A device fails to authenticate and you cannot tell whether its secret exists +- You inherited a system and do not know what credentials are stored +- The same credentials must be entered on several processors +- A credential needs rotating + +**You'll learn to:** + +- See which secrets are stored, and which were created elsewhere +- Add, replace and delete a secret +- Apply a JSON file of secrets in bulk, with a preview before anything is written +- Download a key-only template to copy a credential set between programs +- Understand why a stored value can never be read back + +--- + ## ⚑ Performance and Monitoring ### [Monitor System Performance](./monitor-performance.md) + **Problem**: Need to monitor system health and identify performance issues **Common symptoms:** + - System seems slow or unresponsive - Want to establish performance baselines - Need to identify problematic devices - Monitoring for proactive maintenance **You'll learn to:** + - Establish performance baselines - Identify performance problems early - Monitor device health systematically - Create performance reports and documentation ### [Restart and Reload Configuration](./restart-reload-config.md) + **Problem**: Need to restart the system or reload configuration changes **Common symptoms:** + - Made configuration changes that require restart - System is unresponsive and needs restart - Need to reload configuration without full restart - Troubleshooting requires clean system state **You'll learn to:** + - Choose appropriate restart methods - Safely restart without losing data - Reload configuration changes efficiently @@ -98,15 +177,27 @@ These guides provide step-by-step solutions to specific problems you might encou ## πŸ“š Guide Categories ### Connection and Access (Getting Connected) + Essential for basic application access and resolving connectivity problems -### Debug Console (Information Gathering) +### Debug Console (Information Gathering) + Core troubleshooting skills for monitoring and analyzing system behavior ### Configuration Management (System Understanding) + Tools for understanding, documenting, and managing system configuration +### Credentials and Secrets (Commissioning) + +Managing the credentials devices use to authenticate, individually or in bulk + +### Routing (Signal Visibility and Control) + +Filtering, tracing, and troubleshooting the live signal routing diagram, and making route changes from it + ### Performance and Monitoring (System Health) + Proactive monitoring and maintenance techniques for system reliability --- @@ -114,18 +205,21 @@ Proactive monitoring and maintenance techniques for system reliability ## 🎯 How to Use These Guides ### Quick Problem Solving + 1. **Identify your symptom** from the descriptions above 2. **Jump directly to the relevant guide** 3. **Follow the step-by-step instructions** 4. **Apply the solution to your specific situation** ### Systematic Troubleshooting + 1. **Start with connection issues** if you can't access the app 2. **Use filtering guides** to focus on relevant information 3. **Apply performance monitoring** to understand system state 4. **Use configuration analysis** for deep troubleshooting ### Preventive Maintenance + 1. **Monitor performance regularly** using monitoring guides 2. **Export configurations periodically** for backup and documentation 3. **Use restart procedures** for planned maintenance @@ -150,12 +244,15 @@ Proactive monitoring and maintenance techniques for system reliability ## πŸ”— Related Resources ### For Learning the Basics + Start with **[Tutorials](../tutorials/)** if you're new to the application -### For Technical Details +### For Technical Details + Check **[Reference](../reference/)** documentation for complete technical information ### For Understanding Concepts + Read **[Explanation](../explanation/)** articles for background and design rationale --- @@ -170,6 +267,7 @@ Read **[Explanation](../explanation/)** articles for background and design ratio 4. **Check explanation articles**: Understanding the underlying concepts might help **When to seek additional help:** + - Multiple guides haven't resolved the issue - You encounter error messages not covered in the guides - The problem seems to be with system hardware or network infrastructure @@ -187,4 +285,4 @@ These guides are designed to address the most common problems users encounter. T --- -*Remember: These guides assume you have basic familiarity with the application. If you're completely new, start with the [Getting Started Tutorial](../tutorials/getting-started.md) first.* +_Remember: These guides assume you have basic familiarity with the application. If you're completely new, start with the [Getting Started Tutorial](../tutorials/getting-started.md) first._ diff --git a/docs/how-to/change-routes.md b/docs/how-to/change-routes.md new file mode 100644 index 0000000..a10c230 --- /dev/null +++ b/docs/how-to/change-routes.md @@ -0,0 +1,102 @@ +# How to Change Routes from the Routing Diagram + +**Problem**: You need to send a source to a display, switch a matrix output, or tear down a route β€” and you'd rather do it from the routing diagram you're already looking at than from a touch panel or a console command. + +**When to use this guide**: When commissioning or troubleshooting, and you want to verify a path actually works by making the route and watching the diagram update. + +## Before You Start + +Route editing requires a processor whose Essentials version exposes the routing command endpoint. The app checks for it automatically: + +- **If editing is available**, port names become clickable buttons that highlight blue on hover. +- **If it isn't**, the diagram looks and behaves exactly as it always has β€” read-only, no clickable ports. There's no error and nothing to turn on; the processor simply doesn't support it. + +Check the **Live** badge in the toolbar before making changes. If it shows "Offline", commands may still execute, but the diagram can't confirm them and every change will show a timeout warning. See [Trace Signal Routes](./trace-signal-routes.md#troubleshooting-live-feedback) for fixing the feedback connection. + +## Quick Actions + +**To route a source to a display:** + +1. Click the **input port name** on the destination device +2. Pick a signal type (skipped if the port only carries one) +3. Pick a source from the list + +**To switch a single matrix output:** + +1. Click the **output port name** on the matrix +2. Pick a signal type +3. Pick an input port on that same device + +**To clear a route:** open either popover and choose **None β€” clear route**. + +## Routing a Source to a Destination + +Click an input port on any destination device β€” a display, a DSP, or a multiview decoder. + +The popover asks for two things: + +1. **Signal type.** An `AudioVideo` port offers `AudioVideo`, `Audio`, and `Video`, so you can break audio and video away onto separate paths. A port that carries only one type skips this step. +2. **Source.** This list contains only the sources that actually have a physical path to that specific port for that signal type, traced through every tie line and intermediate switcher. If a source isn't listed, no wiring path exists β€” picking a different signal type may reveal more. + +Choosing a source routes it through every midpoint along the way and switches the destination's own input. + +**Badges you may see:** a source marked `video only` (or `audio only`) has a path for just half of an `AudioVideo` request. It's still routable β€” the half with a path gets routed, the other half doesn't. + +**A check mark** marks the source currently feeding that port. + +## Switching a Midpoint + +Click an **output port** on a matrix switcher, DSP, or any other midpoint device. Pick a signal type, then pick one of that device's own input ports. + +This switches that one device only. Nothing upstream or downstream is touched, and no path is traced β€” which is exactly what you want when you're testing a single switcher in isolation. Input ports that can't carry the chosen signal type aren't listed. + +## Routing Multiview Tiles + +Multiview decoder tiles can be routed two ways: + +- **From the device node** β€” tiles appear as input ports labeled `Tile 1`, `Tile 2`, and so on. Click one like any other input port. +- **From the layout panel** β€” open the device's layout panel with the grid button in its node header, then hover a tile and click the pencil badge in its corner. + +Both open the same popover and behave identically. The candidate list is specific to the tile you picked, not to the decoder as a whole. + +## Clearing a Route + +Choose **None β€” clear route** at the top of either popover. + +On a destination, this tears down the route through every midpoint feeding it _and_ deselects the destination's own input, so the display stops showing its last source rather than freezing on it. On a midpoint output, it clears just that output. + +## Confirming a Change Took Effect + +Routes don't complete instantly. The processor validates the request, then runs it through a serialized routing queue β€” and a display that's still cooling down can hold its request until the cooldown finishes. + +So after you commit a change, the port shows a **pulsing blue dot** while it waits for the processor to confirm. When confirmation arrives over the live feedback connection, the dot disappears and the diagram redraws with the new route. + +An **amber `!`** means no confirmation arrived within about ten seconds. That doesn't necessarily mean the route failed β€” check whether the Live badge went offline, and use the **Refresh** button to reload the current state. + +## When a Route Is Refused + +If the processor rejects the command, the popover stays open with the reason: + +| Message | What it means | +| ----------------------------------------------- | ----------------------------------------------------------------------------------------- | +| No path exists… | The tie-line graph has no wiring path for that signal type. Nothing to retry. | +| …does not implement… | The device can't perform the requested kind of switch. | +| …has no input/output port… | The port key no longer exists β€” hit **Refresh** to reload the diagram. | +| …carries _X_, which cannot serve a _Y_ request… | The port can't carry that signal type. Pick a narrower type. | +| Could not reach the processor. | Network or session problem β€” see [Troubleshoot Connection](./troubleshoot-connection.md). | + +An empty source list is not an error. It means exactly what it says: nothing is wired to reach that port with that signal type. + +## Notes and Gotchas + +- **Clicking a port name opens the popover; clicking anywhere else on a device still traces its signal path.** The two interactions don't interfere. +- **Panning or zooming the canvas closes the popover**, since it's anchored to a fixed screen position rather than to the canvas. +- **Audio-only requests may execute as AudioVideo** when every port along the path is declared `AudioVideo`. The processor routes what the ports support; it can't break away signals the hardware doesn't separate. +- **Source ports are never clickable** β€” routing is always driven from the destination or midpoint end. +- **Filters don't limit what you can route.** Hiding a device or signal type changes only the view; the source list always reflects the real wiring. + +## Related + +- [Trace Signal Routes and Read the Routing Diagram](./trace-signal-routes.md) β€” reading the diagram, filtering, and tracing existing paths +- [UI Components Reference](../reference/ui-components.md#routing-diagram) β€” version requirements and component details +- [API Endpoints Reference](../reference/api-endpoints.md) β€” the underlying routing command endpoint diff --git a/docs/how-to/export-configuration.md b/docs/how-to/export-configuration.md index 5df9c33..9061f9b 100644 --- a/docs/how-to/export-configuration.md +++ b/docs/how-to/export-configuration.md @@ -7,6 +7,7 @@ ## Quick Export **For immediate configuration access:** + 1. Navigate to **Config File** in the top menu 2. Wait for the configuration to load (shows "Loading..." initially) 3. Use browser's copy/paste to capture the displayed JSON @@ -21,6 +22,7 @@ 3. **Verify completeness** - Scroll to ensure the entire configuration loaded **What you'll see:** + - JSON-formatted configuration data - Hierarchical structure with devices, settings, and properties - Properly formatted and indented for readability @@ -28,12 +30,14 @@ ### 2. Copy Configuration Data **Method 1: Select All and Copy** + 1. Click in the configuration display area 2. Use `Ctrl+A` (Windows/Linux) or `Cmd+A` (Mac) to select all 3. Use `Ctrl+C` (Windows/Linux) or `Cmd+C` (Mac) to copy 4. Paste into your preferred text editor **Method 2: Browser Save Function** + 1. Right-click in the configuration area 2. Select "Save As" or "Save Page As" 3. Choose location and filename @@ -42,6 +46,7 @@ ### 3. Save and Organize **Recommended file naming:** + ``` [SystemName]_config_[YYYY-MM-DD].json Examples: @@ -51,6 +56,7 @@ Examples: ``` **Storage recommendations:** + - Create a dedicated folder for configuration backups - Include date and system identification in filenames - Store in version control system if available @@ -61,10 +67,11 @@ Examples: ### 4. Understanding Configuration Structure **Top-level sections** (common elements): + ```json { "devices": { ... }, // All configured devices - "routing": { ... }, // Signal routing configuration + "routing": { ... }, // Signal routing configuration "rooms": { ... }, // Room/space definitions "controlSystem": { ... }, // Control processor settings "applicationSettings": { ... } // Application-specific settings @@ -72,6 +79,7 @@ Examples: ``` **Device structure example:** + ```json "Display-Room1": { "key": "Display-Room1", @@ -91,21 +99,25 @@ Examples: ### 5. Common Analysis Tasks **Find all devices of a specific type:** + 1. Search for `"type": "samsungMDC"` (or other device type) 2. Note the device keys and names 3. Document IP addresses and settings **Identify network settings:** + 1. Search for `"address":` to find all IP addresses 2. Look for `"port":` to find communication ports 3. Check for `"username"` and authentication settings **Locate routing configuration:** + 1. Find the `"routing"` section 2. Examine input/output mappings 3. Check for audio/video route definitions **Review room definitions:** + 1. Look for `"rooms"` or similar sections 2. Check device assignments to rooms 3. Verify user interface configurations @@ -115,11 +127,13 @@ Examples: ### 6. Using External Tools **JSON viewers and editors:** + - **Online**: JSONLint, JSONFormatter, JSON Editor Online - **Desktop**: Visual Studio Code with JSON extensions - **Command line**: `jq` tool for JSON processing **Example using jq to extract device information:** + ```bash # Extract all device names jq '.devices | keys[]' config.json @@ -134,12 +148,14 @@ jq -r '.. | .address? // empty' config.json ### 7. Configuration Comparison **Compare configurations between systems:** + 1. Export configurations from multiple systems 2. Use diff tools (WinMerge, Beyond Compare, or `diff` command) 3. Identify differences in device settings 4. Document configuration variations **Version comparison workflow:** + 1. Export current configuration 2. Compare with previous backups 3. Identify what changed @@ -148,11 +164,13 @@ jq -r '.. | .address? // empty' config.json ### 8. Documentation Generation **Create device inventory:** + 1. Extract device information from configuration 2. Create spreadsheet with: Key, Name, Type, IP Address, Location 3. Add columns for maintenance notes and status **Network documentation:** + 1. List all IP addresses and ports used 2. Document network requirements 3. Create network diagram based on configuration @@ -162,17 +180,20 @@ jq -r '.. | .address? // empty' config.json ### 9. Configuration-Based Problem Solving **Device not responding:** + 1. Find the device in configuration 2. Verify IP address and port settings 3. Check if device type matches physical device 4. Confirm required properties are configured **Missing functionality:** + 1. Check if feature is configured in the device properties 2. Verify room assignments include necessary devices 3. Look for routing configurations that might be missing **Performance issues:** + 1. Count total number of devices 2. Check for excessive polling intervals 3. Look for redundant or unused device configurations @@ -180,6 +201,7 @@ jq -r '.. | .address? // empty' config.json ### 10. Configuration Validation **Common configuration errors to check:** + - Duplicate device keys - Invalid IP addresses or ports - Missing required properties for device types @@ -187,6 +209,7 @@ jq -r '.. | .address? // empty' config.json - Unused or orphaned device references **Validation checklist:** + - [ ] All devices have unique keys - [ ] IP addresses are valid and reachable - [ ] Device types match physical hardware @@ -199,12 +222,14 @@ jq -r '.. | .address? // empty' config.json ### 11. Configuration Security **Before sharing configuration files:** + - Remove or redact IP addresses if sharing externally - Remove authentication credentials (usernames/passwords) - Be cautious with network topology information - Consider what information reveals about your infrastructure **Sanitized configuration example:** + ```json { "Display-Room1": { @@ -224,12 +249,14 @@ jq -r '.. | .address? // empty' config.json ### 12. Backup and Version Control **Regular backup schedule:** + - Export configurations before making changes - Schedule automatic exports if possible - Store backups in multiple locations - Test restore procedures periodically **Change management:** + 1. Export configuration before changes 2. Document what changes are being made 3. Export configuration after changes @@ -241,6 +268,7 @@ jq -r '.. | .address? // empty' config.json ### 13. Cross-Reference with Device List **Verify configuration completeness:** + 1. Compare devices shown in **Devices** section 2. Cross-reference with configuration file 3. Identify devices configured but not active @@ -249,6 +277,7 @@ jq -r '.. | .address? // empty' config.json ### 14. Use with Debug Console **Configuration-guided troubleshooting:** + 1. Find device configuration details 2. Use device key to filter debug messages 3. Verify actual behavior matches configuration @@ -257,21 +286,25 @@ jq -r '.. | .address? // empty' config.json ## Common Use Cases ### System Documentation + - Create comprehensive system documentation - Generate device inventories and network maps - Document configuration standards and conventions ### Troubleshooting Support + - Provide configuration context to support teams - Compare working vs. non-working system configurations - Identify recent configuration changes ### System Planning + - Analyze current configuration before expansions - Plan IP address allocations and network requirements - Understand system complexity and dependencies ### Compliance and Auditing + - Document system configurations for compliance - Track configuration changes over time - Verify systems match approved standards @@ -279,6 +312,7 @@ jq -r '.. | .address? // empty' config.json ## Quick Reference ### Export Checklist + - [ ] Navigate to Config File section - [ ] Wait for complete loading - [ ] Select and copy all content @@ -287,6 +321,7 @@ jq -r '.. | .address? // empty' config.json - [ ] Store in organized backup location ### Analysis Tools + - **Built-in browser**: Search functionality (Ctrl+F) - **Text editors**: Syntax highlighting for JSON - **Online tools**: JSON formatters and validators @@ -294,6 +329,7 @@ jq -r '.. | .address? // empty' config.json - **Diff tools**: For comparing configurations ### Key Sections to Examine + - `devices`: All device configurations - `routing`: Signal routing settings - `rooms`: Room and space definitions @@ -301,6 +337,7 @@ jq -r '.. | .address? // empty' config.json - Authentication: Usernames and security settings ### Security Reminders + - Remove sensitive information before sharing - Redact IP addresses and credentials - Store backups securely diff --git a/docs/how-to/filter-debug-messages.md b/docs/how-to/filter-debug-messages.md index c4f123e..e4db546 100644 --- a/docs/how-to/filter-debug-messages.md +++ b/docs/how-to/filter-debug-messages.md @@ -7,6 +7,7 @@ ## Quick Filtering **For immediate results:** + 1. **Device filter**: Click "Devices" dropdown β†’ Select specific devices 2. **Per-device level**: Once a device is checked, use its inline level dropdown to set a minimum severity 3. **Search box**: Type keywords related to your issue @@ -22,12 +23,14 @@ The Devices dropdown combines two capabilities: - **Level dropdown per device**: When checked, each device gets an inline level dropdown defaulting to `Information` **To show only warnings and above from a specific device**: + 1. Click the **Devices** dropdown 2. Check the device 3. Click its inline level dropdown and select **Warning** 4. Messages from that device below `Warning` are now hidden Multiple devices can each have different thresholds. For example: + - `Display-Room1` β†’ `Error` (only show errors from this noisy device) - `Codec-Main` β†’ `Information` (show all normal activity) - Global β†’ `Warning` (only warnings from system-level messages) @@ -41,15 +44,17 @@ Type keywords in the search box to find messages whose rendered text, template, ### 1. Search by Keywords **Single keywords** (finds messages containing the word): + ``` error - All error-related messages -connection - Connection events and issues +connection - Connection events and issues power - Power-related events button - Button press events display - Display-related messages ``` **Multiple keywords** (finds messages containing ALL words): + ``` display power - Display power events specifically button press - Button press events only @@ -58,6 +63,7 @@ error device - Device-specific errors ``` **Technical terms** (use exact terminology from error messages): + ``` "connection refused" - Exact phrase matching "device not responding" - Specific error conditions @@ -67,16 +73,19 @@ error device - Device-specific errors ### 2. Filter by Device **Global system messages:** + - Select "Global" to see system-wide events - Includes startup, shutdown, and system status messages - Use for overall system health monitoring **Specific device focus:** + - Select one device to trace its complete activity - Useful for device-specific troubleshooting - Shows all messages from that device only **Multiple device comparison:** + - Select 2-3 related devices - Compare behavior between similar devices - Identify which device is behaving differently @@ -84,16 +93,19 @@ error device - Device-specific errors ### 3. Filter by Log Level **Error and Warning only** (recommended for problem identification): + - Focus on actual problems - Reduces noise from normal operations - Best for quick issue identification **Information level** (recommended for normal monitoring): + - Shows normal operations plus issues - Good balance of detail vs. noise - Default setting for most use cases **Debug and Verbose** (use sparingly): + - Extremely detailed technical information - Only use when specifically debugging code issues - Can overwhelm the interface with messages @@ -103,32 +115,37 @@ error device - Device-specific errors ### 4. Combine Multiple Filters **Example: Find display power errors** + 1. Device filter: Select display devices only 2. Log level: Select "Error" and "Warning" 3. Search: Type "power" 4. Result: Only power-related issues from displays **Example: Trace button press handling** + 1. Device filter: Select "Global" and control panel devices 2. Log level: Select "Information" and above 3. Search: Type "button press" 4. Result: Complete button press event chain **Example: Monitor system startup** + 1. Device filter: Select "Global" -2. Log level: Select "Information" and above +2. Log level: Select "Information" and above 3. Search: Type "startup" or "initializing" 4. Result: System startup sequence ### 5. Time-Based Analysis **Clear and restart** for fresh analysis: + 1. Stop the debug session 2. Clear the browser page (refresh) 3. Start a new debug session 4. Apply filters before activity occurs **Historical analysis** (within current session): + - Scroll up to see earlier messages - Use browser's find function (Ctrl+F) for additional searching - Look for patterns in timestamps @@ -140,6 +157,7 @@ error device - Device-specific errors **Goal**: Find why a specific device isn't working **Filtering approach:** + 1. **Device filter**: Select the problematic device only 2. **Log level**: Start with "Warning" and "Error" 3. **Search terms**: Try these in order: @@ -149,6 +167,7 @@ error device - Device-specific errors - `failed` **What to look for:** + - Connection establishment messages (or lack thereof) - Repeated error patterns - Timeout messages @@ -159,6 +178,7 @@ error device - Device-specific errors **Goal**: Identify what's causing system slowdowns **Filtering approach:** + 1. **Device filter**: Start with "Global" messages 2. **Log level**: "Warning" and "Error" to see problems 3. **Search terms**: @@ -168,6 +188,7 @@ error device - Device-specific errors - `performance` **What to look for:** + - High frequency of messages from one device - Timeout errors from multiple devices - Resource allocation warnings @@ -178,6 +199,7 @@ error device - Device-specific errors **Goal**: Follow what happens when a user presses a button **Filtering approach:** + 1. **Device filter**: Include control panels and target devices 2. **Log level**: "Information" and above 3. **Search terms**: @@ -186,6 +208,7 @@ error device - Device-specific errors - `command` **What to look for:** + - Button press detection - Command routing messages - Device response confirmations @@ -196,6 +219,7 @@ error device - Device-specific errors **Goal**: Diagnose network-related problems **Filtering approach:** + 1. **Device filter**: All network-connected devices 2. **Log level**: "Warning" and "Error" 3. **Search terms**: @@ -205,6 +229,7 @@ error device - Device-specific errors - `unreachable` **What to look for:** + - Connection retry attempts - Network timeout messages - IP address resolution issues @@ -215,26 +240,31 @@ error device - Device-specific errors ### Effective Search Terms **For connection issues:** + ``` connection, connect, disconnect, timeout, unreachable, refused ``` **For device control:** + ``` command, response, control, status, state, property ``` **For errors and problems:** + ``` error, exception, failed, timeout, denied, invalid ``` **For user interactions:** + ``` button, press, touch, input, selection, change ``` **For system events:** + ``` startup, shutdown, restart, initialize, load, ready ``` @@ -242,14 +272,17 @@ startup, shutdown, restart, initialize, load, ready ### Search Patterns **Negation** (use carefully): + - Most browsers support Ctrl+F with exclusion - Better to use positive filters in the application **Partial matching**: + - "conn" matches "connection", "connected", "disconnect" - "disp" matches "display", "displayed", "displaying" **Case insensitivity**: + - "ERROR" and "error" produce same results - "Display" and "display" are equivalent @@ -258,6 +291,7 @@ startup, shutdown, restart, initialize, load, ready ### Efficient Filter Workflows **Start broad, narrow down:** + 1. Begin with no filters (see everything) 2. Add device filter to focus area 3. Add log level filter to reduce noise @@ -265,6 +299,7 @@ startup, shutdown, restart, initialize, load, ready 5. Clear and restart when changing focus **Save mental notes** of effective filter combinations: + - Document filter combinations that work well - Remember search terms that find specific issues - Note which devices typically need monitoring together @@ -272,14 +307,16 @@ startup, shutdown, restart, initialize, load, ready ### Clear Filters Strategically **When to clear filters:** + - Switching between different troubleshooting tasks - When filters are too restrictive (no results) - Starting investigation of a new issue - Periodically to see the "big picture" **What gets cleared:** + - Device selections -- Log level selections +- Log level selections - Search text - Filter state resets to defaults @@ -288,11 +325,13 @@ startup, shutdown, restart, initialize, load, ready ### Managing Message Volume **High message rates** (>50 messages/second): + - Use more restrictive log levels - Filter to fewer devices - Consider if system has problems causing excessive logging **Browser performance:** + - Too many messages can slow browser - Refresh page periodically to clear accumulation - Use filters to reduce processing load @@ -300,6 +339,7 @@ startup, shutdown, restart, initialize, load, ready ### Network Considerations **Debug session impact:** + - Each active session uses network bandwidth - Multiple users can impact processor performance - Stop sessions when not actively debugging @@ -307,6 +347,7 @@ startup, shutdown, restart, initialize, load, ready ## Best Practices Summary ### Do's: + - βœ… Start with broader filters, then narrow down - βœ… Use device filters to focus on specific components - βœ… Combine multiple filtering methods for precise results @@ -314,6 +355,7 @@ startup, shutdown, restart, initialize, load, ready - βœ… Use appropriate log levels for your investigation type ### Don'ts: + - ❌ Leave all filters active when switching tasks - ❌ Use "Verbose" log level unless absolutely necessary - ❌ Search for overly generic terms without other filters @@ -325,26 +367,31 @@ startup, shutdown, restart, initialize, load, ready ### Filter Combinations for Common Tasks **Problem identification:** + - Log Level: Warning + Error - Device: All or problematic area - Search: "error" or "failed" **Device troubleshooting:** + - Device: Specific device only - Log Level: Information and above - Search: Related to suspected issue **System monitoring:** + - Device: Global - Log Level: Warning and above - Search: "system" or "startup" **User interaction tracing:** + - Device: Control panels + target devices - Log Level: Information and above - Search: "button" or "command" ### Quick Actions + - **Reset everything**: Click "Clear" button - **Focus device**: Select one device in dropdown - **Problem focus**: Set log level to "Warning" + "Error" diff --git a/docs/how-to/manage-secrets.md b/docs/how-to/manage-secrets.md new file mode 100644 index 0000000..564e81b --- /dev/null +++ b/docs/how-to/manage-secrets.md @@ -0,0 +1,149 @@ +# How to Manage Stored Secrets + +**Problem**: You need to see which credentials a processor has stored, add or replace one, or push the same set of credentials to several programs without retyping them into a console. + +**When to use this guide**: When commissioning a system that uses secrets in its config, when a device stops authenticating, or when standing up a new program that needs the same credentials as an existing one. + +## What a secret is + +Essentials stores credentials β€” device passwords, codec logins β€” separately from the configuration file, so the config can be shared and version-controlled without the passwords in it. A device config refers to one like this: + +```json +"tcpSshProperties": { + "address": "10.0.0.5", + "username": "admin", + "password": { "secret": { "provider": "default", "key": "displayPassword" } } +} +``` + +At startup the processor swaps that object for the stored value. If the secret is missing, the device simply fails to authenticate β€” usually with no obvious clue why, which is what this page exists to fix. + +### Providers + +| Provider | Scope | +| ----------------------- | --------------------------------------------- | +| `default` | This program slot only | +| `CrestronGlobalSecrets` | Shared by every program slot on the processor | + +Pick the provider with the dropdown at the top of the page. Both are independent β€” the same key can exist in each with different values. + +## The one rule that governs everything here + +**A stored value can never be read back.** Not by this page, not by the API, not by any console command. The processor will accept a new value and tell you a key exists, and that is all. + +So: + +- Replacing a secret means typing the **whole new value**, not editing the old one. +- If nobody remembers a credential, it must be recovered from the device itself and re-entered. +- A compromised browser session can destroy or replace your credentials, but cannot steal them. + +## Quick Actions + +**To add a secret:** click **Add +** in the table header, fill in the key and value, save. + +**To replace a value:** click **Replace value** on its row. + +**To delete one:** click **Delete** on its row and confirm. + +**To copy a credential set to another program:** **Download template** here β†’ fill in the values β†’ **Apply file…** on the other program. + +## Adding and replacing + +The **Key** is what the device config refers to, so it has to match the config exactly. Keys are limited to **32 characters** and values to **1600** β€” Crestron Data Store limits, not ours; the form will tell you before you hit them. + +The **Description** is optional and is only ever shown on this page. It is worth filling in: six months later, `codecPwd` means nothing without one. + +If the key already exists, the form says so and switches to replacing it. If it exists but was **not created by this tool**, you get a much louder warning and a checkbox to confirm β€” see below. + +## Managed vs. other records + +The page shows two tables. + +**Managed secrets** were created through this tool. It knows their description and when they changed. + +**Other data store records** exist on the processor but were created elsewhere β€” by the console commands, by an older version, or by another part of the system entirely. **Mobile Control keeps its paired-client tokens in the same storage**, and it will show up here. + +These are deliberately harder to touch: no **Replace value** button, and deleting one requires typing the key to confirm. Deleting Mobile Control's token record silently un-pairs every touchpanel, and nothing about the key name warns you of that. + +To adopt an existing record so it shows as managed, replace its value through this page. That records it without changing anything else about it. + +## Bulk apply + +Use **Apply file…** to write many secrets at once β€” the point being to set up several programs with the same credentials. + +### The file + +```json +{ + "provider": "default", + "secrets": { + "displayPassword": "…", + "codecPassword": "…" + } +} +``` + +A plain array also works, if you want per-entry descriptions or providers: + +```json +[ + { "key": "displayPassword", "value": "…", "description": "Display admin" }, + { "key": "codecPassword", "value": "…", "provider": "CrestronGlobalSecrets" } +] +``` + +**Download template** gives you the first form, pre-filled with the keys already stored here and blank values β€” so the workflow is: download from a working program, fill in the values, apply to the others. + +### What happens when you apply one + +1. The file is checked in your browser first. Bad JSON, wrong shape, oversized files, over-long keys and blank values are all caught before anything is sent. +2. The processor is asked what the file _would_ do β€” **nothing is written at this stage**. +3. You get a preview table, one row per entry: + +| Action | Meaning | +| ------------- | --------------------------------------------------------- | +| **Create** | The key does not exist yet | +| **Overwrite** | It exists and will be replaced | +| **Skip** | It exists and will be left alone | +| **Invalid** | Something is wrong with the entry; it will not be written | +| **Failed** | The processor refused the write (commit only) | + +4. Only when you press **Apply** does anything get written. + +**Existing secrets are skipped by default.** Turn on _Replace secrets that already exist_ to overwrite them β€” the preview updates immediately so you can see exactly what changes. + +**If any entry is invalid, nothing is written at all.** Fix the file and try again rather than applying a partial batch. + +**Bulk never deletes.** A key that exists on the processor but is absent from your file is left alone. Applying a file is not a sync. + +### Blank values are rejected + +A blank value would _delete_ the secret on the processor, so the file checker treats one as an error rather than a no-op. If every value in the file is blank, you are told you are probably applying an unfilled template. + +### Handle the file carefully + +It contains plaintext credentials. Delete it when you are done, and keep it out of source control. The page warns you about this every time, on purpose. + +## Troubleshooting + +### A device won't authenticate after commissioning + +Check the key here matches the config exactly β€” including case. A missing secret resolves to an empty string, so the device sees a blank password and fails with no clear error. + +### The Secrets page isn't in the menu + +The processor's Essentials version doesn't expose the secrets API. The menu entry is driven by what the processor actually reports, not by a version number, so it appears automatically once a supporting version is loaded. + +### Everything shows as "not managed here" + +The record of which secrets were created here is missing or damaged, so the page can't classify them. **The secrets themselves are unaffected and still work** β€” you have lost the descriptions and timestamps, not the credentials. Replacing a value re-adopts that key. + +### "The processor could not list every record" + +The Data Store listing was cut short, so the page may be incomplete. Refresh; if it persists, the processor's storage is worth investigating. + +## Related + +- [Export and Analyze Configuration](./export-configuration.md) β€” how config files reference secrets +- [API Endpoints Reference](../reference/api-endpoints.md) β€” the underlying endpoints +- [Security Model](../explanation/security.md) β€” who can do this, and what it means diff --git a/docs/how-to/monitor-performance.md b/docs/how-to/monitor-performance.md index 402bf2e..b1d5c65 100644 --- a/docs/how-to/monitor-performance.md +++ b/docs/how-to/monitor-performance.md @@ -7,6 +7,7 @@ ## Quick Performance Check **For immediate system health assessment:** + 1. Start a debug session in the **Debug Console** 2. Set log level to **"Warning"** and **"Error"** only 3. Monitor for 2-3 minutes to see error frequency @@ -18,16 +19,19 @@ ### 1. Message Rate Analysis **Normal message rates** (varies by system size): + - **Small systems** (5-10 devices): 5-20 messages per minute -- **Medium systems** (10-50 devices): 20-100 messages per minute +- **Medium systems** (10-50 devices): 20-100 messages per minute - **Large systems** (50+ devices): 100+ messages per minute **Performance warning signs:** + - **Very high rates** (>200 messages/minute): May indicate device errors or loops - **Message bursts**: Sudden spikes in message volume - **Continuous error streams**: Same error repeating rapidly **How to check:** + 1. Start debug session and note starting message count 2. Wait exactly 1 minute 3. Note ending message count @@ -37,12 +41,14 @@ ### 2. Error Pattern Recognition **Healthy system indicators:** + - Occasional informational messages - Infrequent warnings (less than 1 per minute) - Very rare errors (less than 1 per 10 minutes) - Clean startup sequences **Performance problem indicators:** + - Repeated timeout errors - Connection retry loops - Resource allocation failures @@ -53,6 +59,7 @@ ### 3. Establish Baseline Performance **Initial baseline creation:** + 1. **Choose monitoring period**: 15-30 minutes during normal operation 2. **Record message statistics**: - Total message count @@ -66,6 +73,7 @@ 4. **Save baseline data** for future comparison **Baseline documentation template:** + ``` System: [System Name] Date: [Date] @@ -81,6 +89,7 @@ User Load: [Description] ### 4. Regular Health Checks **Daily monitoring routine:** + 1. **Quick status check** (2-3 minutes): - Start debug session - Set to "Warning" and "Error" levels @@ -102,6 +111,7 @@ User Load: [Description] ### 5. Device-Specific Performance Monitoring **Individual device health:** + 1. **Filter to single device** in debug console 2. **Monitor for 5-10 minutes** 3. **Look for patterns**: @@ -111,6 +121,7 @@ User Load: [Description] - Retry attempts (concerning) **Device performance checklist:** + - [ ] Device responds to commands consistently - [ ] No timeout errors in normal operation - [ ] Status updates occur regularly @@ -118,6 +129,7 @@ User Load: [Description] - [ ] Connection remains stable **Red flags for individual devices:** + - Multiple timeout errors per minute - Connection retry loops - Commands not acknowledged @@ -172,13 +184,16 @@ User Load: [Description] **Network-related performance issues:** **Symptoms to monitor**: + - Frequent "connection timeout" messages - "Device unreachable" errors - Long delays between commands and responses - Intermittent device connectivity **Network performance checks**: + 1. **Search for network-related terms**: + ``` timeout connection @@ -203,12 +218,14 @@ User Load: [Description] ### 9. Memory and Processing Indicators **Signs of resource constraints:** + - Increasing response times over time - "Out of memory" or resource allocation errors - System becoming unresponsive - Debug sessions failing to start **Monitoring approach**: + 1. **Track response times**: - Note delays between commands and responses - Monitor how long operations take @@ -222,12 +239,14 @@ User Load: [Description] ### 10. Database and Storage Performance **Storage-related performance indicators:** + - Slow configuration loading - Delays in log message display - "Disk full" or storage errors - Database connection issues **Monitoring steps**: + 1. **Time configuration loading**: - Note how long Config File section takes to load - Compare loading times over time @@ -243,6 +262,7 @@ User Load: [Description] ### 11. Reducing Debug Session Impact **Minimize monitoring overhead:** + 1. **Use appropriate log levels**: - "Information" for normal monitoring - "Warning"+"Error" for problem identification @@ -261,6 +281,7 @@ User Load: [Description] ### 12. System Configuration for Performance **Configuration best practices:** + 1. **Device polling intervals**: - Don't poll devices more frequently than necessary - Increase intervals for stable devices @@ -281,6 +302,7 @@ User Load: [Description] ### 13. Performance Documentation **Regular performance reports should include:** + - Message rate trends over time - Error frequency and types - Device response time measurements @@ -288,6 +310,7 @@ User Load: [Description] - System resource utilization **Report template:** + ``` Performance Report - [Date Range] ================================ @@ -314,12 +337,14 @@ Recommendations: ### 14. Alerting and Escalation **When to escalate performance issues:** + - Error rates exceed 50% above baseline - System becomes unresponsive - Critical devices fail repeatedly - Performance degrades significantly over time **Escalation information to provide:** + - Current vs. baseline performance metrics - Specific error messages and frequencies - Affected devices and functionality @@ -329,6 +354,7 @@ Recommendations: ## Quick Reference ### Performance Monitoring Checklist + - [ ] Establish baseline performance metrics - [ ] Monitor message rates regularly - [ ] Track error frequencies and types @@ -340,6 +366,7 @@ Recommendations: ### Normal vs. Concerning Indicators **Normal (Healthy System):** + - Steady, predictable message rates - Infrequent errors (< 1 per 10 minutes) - Consistent device response times @@ -347,6 +374,7 @@ Recommendations: - Stable network connectivity **Concerning (Performance Issues):** + - Message rates >200% of baseline - Frequent errors (> 1 per minute) - Increasing response times @@ -354,6 +382,7 @@ Recommendations: - System unresponsiveness ### Key Search Terms for Performance Monitoring + ``` Performance Issues: timeout, delay, slow, performance Network Issues: connection, unreachable, network, ping @@ -362,6 +391,7 @@ Error Patterns: error, failed, exception, retry ``` ### Quick Performance Assessment (5 minutes) + 1. Start debug session with "Warning" + "Error" filters 2. Monitor for 2-3 minutes 3. Note message count and rate diff --git a/docs/how-to/restart-reload-config.md b/docs/how-to/restart-reload-config.md index 5aa2924..802341a 100644 --- a/docs/how-to/restart-reload-config.md +++ b/docs/how-to/restart-reload-config.md @@ -7,12 +7,14 @@ ## Quick Actions **For immediate restart:** + 1. Go to **Debug Console** 2. Click **"Restart Program"** button 3. Confirm the restart in the modal dialog 4. Wait 2-3 minutes for system to fully restart **For configuration reload only:** + 1. Ensure **"Do Not Load Config on Next Boot"** is unchecked 2. Click **"Load Config"** button (if enabled) 3. Monitor debug messages for configuration loading progress @@ -22,11 +24,13 @@ ### 1. Configuration Loading Control **"Do Not Load Config on Next Boot" Checkbox:** + - **Checked**: System will start without loading configuration file - **Unchecked**: System will load configuration on startup (normal operation) - **Use case**: Useful when configuration file has errors that prevent startup **When to use "Do Not Load Config":** + - Configuration file contains errors preventing startup - Testing system behavior without configuration - Troubleshooting startup issues @@ -39,6 +43,7 @@ **What it does**: Loads and applies the current configuration file **Benefits of "Load Config" vs. full restart:** + - Faster than complete system restart - Preserves current system state where possible - Allows testing configuration changes quickly @@ -47,7 +52,8 @@ ### 3. Restart Program Button **Purpose**: Complete restart of the PepperDash Essentials framework -**What happens**: +**What happens**: + 1. All current operations stop 2. All device connections close 3. System reinitializes completely @@ -55,6 +61,7 @@ 5. All devices reconnect **When to use full restart:** + - Major configuration changes that require complete reload - System becomes unresponsive - Memory leaks or resource issues @@ -99,6 +106,7 @@ - Document current issues before restart 2. **Prepare for restart**: + ``` Before restart checklist: - [ ] Users notified of downtime @@ -144,12 +152,14 @@ ### 7. Managing Configuration Loading **Normal configuration loading:** + 1. System starts with "Do Not Load Config on Next Boot" unchecked 2. Configuration file loads automatically during startup 3. All devices initialize based on configuration 4. System becomes fully operational **Controlled configuration loading:** + 1. Check "Do Not Load Config on Next Boot" 2. Restart system (starts without configuration) 3. System runs in minimal state @@ -157,6 +167,7 @@ 5. Configuration loads without full restart **Use cases for controlled loading:** + - Testing new configurations safely - Troubleshooting configuration-related startup issues - Loading configuration after making changes @@ -192,16 +203,18 @@ ### 9. Normal Startup Sequence **Expected startup messages:** + ``` Information: System initializing Information: Loading configuration file -Information: Device [DeviceName] initializing +Information: Device [DeviceName] initializing Information: Device [DeviceName] connection established Information: Device [DeviceName] ready Information: System startup complete ``` **Startup timing expectations:** + - **System initialization**: 30-60 seconds - **Configuration loading**: 10-30 seconds - **Device connections**: 1-5 minutes (depends on device count) @@ -210,12 +223,14 @@ Information: System startup complete ### 10. Identifying Startup Problems **Warning signs during startup:** + - Devices failing to initialize - Repeated connection timeout errors - Configuration loading errors - System taking longer than usual **Common startup error patterns:** + ``` Error: Failed to load configuration file Warning: Device [Name] connection timeout @@ -224,6 +239,7 @@ Warning: Network unreachable for device [Name] ``` **Troubleshooting startup issues:** + 1. **Configuration errors**: Use "Do Not Load Config" mode 2. **Network issues**: Check device connectivity 3. **Device failures**: Investigate specific device problems @@ -234,6 +250,7 @@ Warning: Network unreachable for device [Name] ### 11. Restart Planning **Before performing restarts:** + - βœ… Notify users of planned downtime - βœ… Complete or pause critical operations - βœ… Document current system state @@ -241,12 +258,14 @@ Warning: Network unreachable for device [Name] - βœ… Plan restart during low-usage periods **During restarts:** + - βœ… Monitor progress through debug console - βœ… Be patient - don't interrupt restart process - βœ… Document any errors that occur - βœ… Don't perform other system operations during restart **After restarts:** + - βœ… Verify all devices reconnected properly - βœ… Test critical functionality - βœ… Monitor system for a few minutes @@ -256,6 +275,7 @@ Warning: Network unreachable for device [Name] ### 12. Configuration Change Workflow **Safe configuration update process:** + 1. **Backup current configuration** 2. **Make configuration changes** (externally) 3. **Test configuration** (validation tools if available) @@ -269,51 +289,60 @@ Warning: Network unreachable for device [Name] ## Troubleshooting Common Issues ### Restart Button Not Responding + **Cause**: System may be completely unresponsive **Solution**: Wait 5 minutes, try browser refresh, contact administrator if needed ### Configuration Won't Load + **Cause**: Syntax errors or invalid configuration **Solution**: Use "Do Not Load Config" mode, fix configuration externally, test reload ### Devices Not Reconnecting After Restart + **Cause**: Network issues, device problems, or configuration errors **Solution**: Check debug messages for specific device errors, verify network connectivity ### System Takes Too Long to Restart + **Cause**: Large number of devices, network delays, or system issues **Solution**: Wait up to 10 minutes for large systems, check for specific error messages ### "Load Config" Button Not Available + **Cause**: "Do Not Load Config on Next Boot" is not checked **Solution**: Check the checkbox first, or use full restart instead ## Quick Reference ### Restart Options Quick Guide + - **Load Config**: Quick configuration reload (when checkbox is checked) - **Restart Program**: Complete system restart - **Do Not Load Config**: Start system without configuration (troubleshooting mode) ### When to Use Each Option -| Situation | Recommended Action | -|-----------|-------------------| -| Minor configuration changes | Load Config | -| Major configuration changes | Restart Program | -| System unresponsive | Restart Program | -| Configuration has errors | Check "Do Not Load Config", then Restart | -| Testing new configuration | Use "Do Not Load Config" mode | -| After software updates | Restart Program | +| Situation | Recommended Action | +| --------------------------- | ---------------------------------------- | +| Minor configuration changes | Load Config | +| Major configuration changes | Restart Program | +| System unresponsive | Restart Program | +| Configuration has errors | Check "Do Not Load Config", then Restart | +| Testing new configuration | Use "Do Not Load Config" mode | +| After software updates | Restart Program | ### Normal Timing Expectations + - **Load Config**: 30 seconds - 2 minutes - **Restart Program**: 2-6 minutes total - **Device reconnection**: 1-5 minutes after restart - **System fully operational**: 3-8 minutes after restart initiated ### Emergency Contacts + If restart procedures don't resolve issues: + 1. Check system documentation for emergency procedures 2. Contact system administrator 3. Contact PepperDash support if needed diff --git a/docs/how-to/trace-signal-routes.md b/docs/how-to/trace-signal-routes.md new file mode 100644 index 0000000..6a5b2eb --- /dev/null +++ b/docs/how-to/trace-signal-routes.md @@ -0,0 +1,82 @@ +# How to Trace Signal Routes and Read the Routing Diagram + +**Problem**: You need to find out what's actually feeding a display or output right now, verify a route change took effect, or make sense of a large, busy routing diagram. + +**When to use this guide**: When you're troubleshooting "wrong source on screen" issues, verifying a route or multiview layout change, or trying to focus a large routing diagram down to just the devices and signals you care about. + +## Quick Actions + +**To find where a signal is coming from:** + +1. Open the **Routing** page for the app +2. Click the tie line edge feeding the device you're investigating (or click the device node itself) +3. The full path highlights from source to destination; everything else dims + +**To declutter a busy diagram:** + +1. Use the **signal type buttons** in the toolbar to hide types you don't need +2. Open the **Devices** filter dropdown and search for/uncheck devices you don't need +3. Turn on **Hide unconnected devices** and/or **Hide unconnected ports** + +## Understanding the Toolbar + +| Control | What it does | +| ------------------------ | ---------------------------------------------------------------------------------------- | +| Signal type buttons | Click to show/hide all tie lines of that type. Button color matches its edges. | +| Devices dropdown | Search devices, then check/uncheck individual devices, or use Select all / Deselect all | +| Hide unconnected devices | Removes any device with no visible tie line endpoint from the canvas | +| Hide unconnected ports | Shows only the ports on each device that currently have a visible tie line | +| Dark mode | Switches the canvas between dark and light styling | +| Live / Offline badge | Green "Live" means the routing feedback WebSocket is connected and route data is current | +| Refresh button | Reloads the device/tie-line snapshot and reconnects the feedback WebSocket | + +**Note**: Signal path tracing and live route curves inside device cards require PepperDashEssentials.dll **3.0 or later**. On earlier versions (2.29–2.x) the diagram still shows devices and static tie lines, but there's no live feedback to trace β€” see [UI Components Reference](../reference/ui-components.md#routing-diagram) for the full version breakdown. + +## Tracing a Signal Path + +1. **Click a tie line edge** to trace that specific connection end-to-end, including through any midpoint switching devices (matrix switchers, DSPs, etc.) it passes through +2. **Click a device node** to trace every signal currently passing into or out of that device at once +3. **Click a tile inside an open Multiview Layout Panel** to trace the path feeding just that tile +4. In all three cases, the matched tie lines and the internal route curves inside each device card light up in the signal's color; everything not part of the path dims and thins +5. Click the same edge/node/tile again, or click empty canvas space, to clear the highlight + +**Tip**: A **dashed** tie line means the route was made through a device-specific bulk API (for example, a dynamic multiview layout) rather than a normal tie line β€” it's real, live-only, and traces just like a solid one. + +## Checking a Multiview Display's Layout + +If a device shows the small tile icon in its card header, it currently has an active multiview/window layout: + +1. Click the tile icon to open a floating **Multiview Layout Panel** for that device +2. The panel shows a scaled mock-up of the device's canvas, with each tile labeled by number and current source +3. Drag the panel's title bar to move it out of the way β€” its position doesn't change when the diagram re-lays-out +4. Click a tile to trace the signal path feeding it, exactly like clicking a tie line +5. Open as many panels as you need (one per device); click a panel's `Γ—` to close it + +## Troubleshooting the Live Feedback Connection + +### Badge stuck on "Offline" + +**Cause**: The feedback WebSocket hasn't connected, isn't supported on this Essentials version, or the connection dropped. +**Solution**: Confirm PepperDashEssentials.dll is 3.0+ on the app's **Versions** page, then click the **Refresh** button to reconnect. + +### Yellow certificate warning banner + +**Cause**: The routing feedback server is using a certificate your browser doesn't trust (common with self-signed certificates on internal networks). +**Solution**: Click the link in the banner to open that URL directly in a new tab, accept/proceed past the certificate warning there, then reload the Routing page. + +### Route curves or live data never appear + +**Cause**: Either the Essentials version is below 3.0 (no live feedback exists), or the WebSocket never connected. +**Solution**: Check the Live/Offline badge β€” if it's stuck Offline, use Refresh; if the version is below 3.0, live tracing isn't available and only the static diagram applies. + +## Quick Reference + +- **Declutter**: signal type buttons + Devices dropdown + Hide unconnected switches +- **Trace a path**: click an edge, a device, or a multiview tile +- **Clear a trace**: click it again, or click empty canvas +- **Dashed edge**: live-only route (no static tie line behind it) +- **Live badge Offline**: click Refresh; confirm Essentials 3.0+ for live tracing + +See also: [UI Components Reference β€” Routing Diagram](../reference/ui-components.md#routing-diagram) for the complete technical reference of every element and control. + +**Need to change a route rather than just read one?** On supported processors, port names on destination and midpoint devices are clickable β€” see [Change Routes from the Routing Diagram](./change-routes.md). diff --git a/docs/how-to/troubleshoot-connection.md b/docs/how-to/troubleshoot-connection.md index 906d508..55767ca 100644 --- a/docs/how-to/troubleshoot-connection.md +++ b/docs/how-to/troubleshoot-connection.md @@ -17,13 +17,16 @@ Try these quick checks first: ### 1. Verify Basic Network Connectivity **Check network connectivity:** + ```bash ping [processor-ip] ``` + - βœ… **Success**: You get replies β†’ Network connectivity is working - ❌ **Failure**: Request timeout or unreachable β†’ Network issue **If ping fails:** + - Verify you're on the same network/VLAN as the processor - Check your IP configuration and subnet mask - Confirm the processor IP address is correct @@ -32,12 +35,15 @@ ping [processor-ip] ### 2. Verify the Web Service is Running **Test HTTPS connectivity:** + ```bash telnet [processor-ip] 443 ``` + Or try the base URL in your browser: `https://[processor-ip]` **Expected results:** + - βœ… **Connection successful**: Port 443 is open and accepting connections - ❌ **Connection refused**: Web service may not be running - ❌ **Timeout**: Firewall blocking or service not responding @@ -45,10 +51,12 @@ Or try the base URL in your browser: `https://[processor-ip]` ### 3. Check Browser-Specific Issues **Try a different browser:** + - Chrome, Firefox, Safari, or Edge - Use incognito/private mode to avoid cache issues **Clear browser data:** + 1. Clear cookies and cache for the processor's IP 2. Disable browser extensions temporarily 3. Check if popup blockers are interfering @@ -56,11 +64,13 @@ Or try the base URL in your browser: `https://[processor-ip]` ### 4. Handle Certificate Issues **Common certificate warnings:** + - "Your connection is not private" - "NET::ERR_CERT_AUTHORITY_INVALID" - "This site can't provide a secure connection" **How to proceed safely:** + 1. Click "Advanced" or "Show details" 2. Click "Proceed to [IP] (unsafe)" or "Accept the risk" 3. This is safe on your internal network @@ -70,17 +80,20 @@ Or try the base URL in your browser: `https://[processor-ip]` ### 5. Verify Correct URL Format **Correct URL format:** + ``` https://[processor-ip]/debug/ ``` **Common mistakes:** + - ❌ `http://` instead of `https://` - ❌ Missing `/debug/` path - ❌ Wrong IP address - ❌ Extra characters or typos **Examples of correct URLs:** + - `https://192.168.1.100/debug/` - `https://10.0.0.50/debug/` - `https://processor.local/debug/` (if DNS is configured) @@ -88,11 +101,13 @@ https://[processor-ip]/debug/ ### 6. Check Processor Status **Physical indicators:** + - Power LED should be solid (not blinking) - Network LED should show activity - Any error displays on the processor **If processor seems unresponsive:** + - Try power cycling (unplug for 30 seconds, reconnect) - Check for overheating or physical damage - Verify all network cables are secure @@ -100,11 +115,13 @@ https://[processor-ip]/debug/ ### 7. Test from Different Locations **Try connecting from:** + - Different computer on same network - Different network location (if processor accessible) - Mobile device on same WiFi network **This helps identify:** + - Whether the issue is device-specific - Network routing problems - Firewall or access control issues @@ -112,26 +129,32 @@ https://[processor-ip]/debug/ ## Common Error Messages and Solutions ### "This site can't be reached" + **Cause**: Network connectivity issue **Solution**: Check network configuration, IP address, and physical connections ### "Your connection is not private" / "Certificate error" + **Cause**: Self-signed certificate (normal for internal devices) **Solution**: Click "Advanced" β†’ "Proceed to [IP] (unsafe)" ### "404 Not Found" + **Cause**: Wrong URL path **Solution**: Ensure URL ends with `/debug/` (include the trailing slash) ### "500 Internal Server Error" + **Cause**: Web service error on processor **Solution**: Try power cycling the processor, check processor logs ### "Connection timed out" + **Cause**: Firewall blocking connection or service not running **Solution**: Check firewall rules, verify processor is running properly -### Page loads but shows "Loading..." indefinitely +### Page loads but shows "Loading..." indefinitely + **Cause**: JavaScript errors or API connectivity issues **Solution**: Check browser console for errors, try different browser @@ -145,6 +168,7 @@ https://[processor-ip]/debug/ 4. **Check Network tab** for failed requests **Common console errors:** + - CORS errors: May indicate proxy configuration issues - Network errors: API endpoints not responding - JavaScript errors: Browser compatibility issues @@ -154,6 +178,7 @@ https://[processor-ip]/debug/ If you have access to the development setup: **Check required environment variables:** + ```bash # Should be set to processor IP echo $PROGRAM_HOST @@ -165,6 +190,7 @@ echo $PROGRAM_ID ### Test API Endpoints Directly Try accessing API endpoints directly: + ``` https://[processor-ip]/cws/app01/api/versions ``` @@ -174,16 +200,19 @@ Should return JSON data if the API is working. ## Network Configuration Issues ### Subnet and VLAN Issues + - Ensure your device and processor are on the same network segment - Check VLAN configuration if using managed switches - Verify subnet masks allow communication ### Firewall and Security + - Corporate firewalls may block HTTPS to internal devices - Some networks block self-signed certificates - Guest networks may restrict device-to-device communication ### DNS Resolution + - If using hostnames instead of IP addresses - Check DNS configuration and host entries - Try IP address directly to bypass DNS issues @@ -191,11 +220,13 @@ Should return JSON data if the API is working. ## Prevention and Monitoring ### Regular Health Checks + - Test connectivity periodically - Monitor processor uptime and performance - Keep browser bookmarks updated with correct URLs ### Documentation + - Document working IP addresses and URLs - Note any special network configuration requirements - Keep contact information for network administrators @@ -203,11 +234,13 @@ Should return JSON data if the API is working. ## When to Escalate **Contact your network administrator if:** + - Multiple users report the same connectivity issues - Network infrastructure changes preceded the problems - Firewall or security policy changes are suspected **Contact PepperDash support if:** + - Processor hardware appears to be failing - Software updates may have caused issues - Configuration changes are needed @@ -215,6 +248,7 @@ Should return JSON data if the API is working. ## Quick Reference ### Connection Checklist + - [ ] Correct URL format: `https://[ip]/debug/` - [ ] Network connectivity (ping works) - [ ] HTTPS port 443 accessible @@ -223,11 +257,13 @@ Should return JSON data if the API is working. - [ ] No firewall blocking access ### URLs to Test + 1. `https://[processor-ip]/debug/` - Main application 2. `https://[processor-ip]/` - Base web service 3. `https://[processor-ip]/cws/app01/api/versions` - API test ### Browser Settings + - Allow self-signed certificates for internal networks - Disable popup blockers for the processor IP - Clear cache/cookies if problems persist diff --git a/docs/reference/README.md b/docs/reference/README.md index d96531f..69df4f7 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -7,9 +7,11 @@ This section provides complete technical information about all features, APIs, a ## πŸ“± User Interface Reference ### [UI Components Reference](./ui-components.md) + **Complete technical specification for all user interface elements** **Coverage**: + - Navigation components and behavior - Debug console interface elements - Configuration display components @@ -19,6 +21,7 @@ This section provides complete technical information about all features, APIs, a - Accessibility features and browser compatibility **Use when you need**: + - Detailed information about specific UI elements - Technical specifications for interface behavior - Accessibility and compatibility requirements @@ -29,9 +32,11 @@ This section provides complete technical information about all features, APIs, a ## πŸ”Œ API and Integration Reference ### [API Endpoints Reference](./api-endpoints.md) + **Complete technical specification for all REST API endpoints** **Coverage**: + - All available REST endpoints with request/response formats - WebSocket debug protocol specification - Authentication and security requirements @@ -40,6 +45,7 @@ This section provides complete technical information about all features, APIs, a - Integration examples and best practices **Use when you need**: + - Complete API endpoint documentation - Request and response format specifications - WebSocket protocol implementation details @@ -50,9 +56,11 @@ This section provides complete technical information about all features, APIs, a ## πŸ“Š Data and Filtering Reference ### [Log Levels and Filters Reference](./log-levels.md) + **Complete technical specification for logging system and filtering mechanisms** **Coverage**: + - Log level hierarchy and severity classification - Message structure and property definitions - Filtering mechanisms and combination logic @@ -61,6 +69,7 @@ This section provides complete technical information about all features, APIs, a - Advanced filtering strategies **Use when you need**: + - Understanding log level semantics - Implementing custom filtering logic - Analyzing message patterns and volumes @@ -71,9 +80,11 @@ This section provides complete technical information about all features, APIs, a ## πŸ”§ Configuration Reference ### [Configuration Schema Reference](./configuration-schema.md) + **Complete technical specification for system configuration structure** **Coverage**: + - Complete JSON schema for configuration files - Device configuration patterns by type - Room and routing configuration structures @@ -82,6 +93,7 @@ This section provides complete technical information about all features, APIs, a - Migration and compatibility information **Use when you need**: + - Understanding configuration file structure - Validating configuration syntax - Creating or modifying configurations @@ -92,9 +104,11 @@ This section provides complete technical information about all features, APIs, a ## πŸ“¦ Device and Types Reference ### [Device Types Reference](./device-types.md) + **Complete catalog of supported device types and their properties** **Coverage**: + - All supported device types with descriptions - Required and optional properties for each type - Available methods and commands by device type @@ -103,6 +117,7 @@ This section provides complete technical information about all features, APIs, a - Compatibility and version requirements **Use when you need**: + - Understanding what device types are available - Configuring specific device types - Troubleshooting device-specific issues @@ -113,11 +128,13 @@ This section provides complete technical information about all features, APIs, a ## πŸ“‹ Quick Reference Sections ### API Quick Reference + ``` Base URL: https://[processor-ip]/cws/app01/api ``` **Common Endpoints**: + - `GET /versions` - System version information - `GET /devices` - All configured devices - `GET /config` - Complete configuration @@ -125,11 +142,13 @@ Base URL: https://[processor-ip]/cws/app01/api - `POST /restartProgram` - Restart system ### Log Levels Quick Reference + ``` Fatal (5) > Error (4) > Warning (3) > Information (2) > Debug (1) > Verbose (0) ``` **Common Filters**: + - **Problem identification**: Error + Warning levels - **Normal monitoring**: Information + Warning + Error levels - **Device troubleshooting**: Single device + Information+ levels @@ -137,6 +156,7 @@ Fatal (5) > Error (4) > Warning (3) > Information (2) > Debug (1) > Verbose (0) ### UI Components Quick Reference **Main Navigation**: + - Home (`/home`) - Welcome and starting point - Debug Console (`/console`) - Real-time monitoring - Versions (`/versions`) - Assembly information @@ -145,6 +165,7 @@ Fatal (5) > Error (4) > Warning (3) > Information (2) > Debug (1) > Verbose (0) - Types (`/types`) - Available device types **Debug Console Controls**: + - Start/Stop Debug Session - Session management - Device Filter - Filter by device - Log Level Filter - Filter by severity @@ -156,16 +177,19 @@ Fatal (5) > Error (4) > Warning (3) > Information (2) > Debug (1) > Verbose (0) ## 🎯 How to Use Reference Documentation ### Quick Lookup + - **Use the search function** (Ctrl+F) to find specific information - **Check the table of contents** for the section you need - **Reference the quick reference sections** for common information ### Comprehensive Research + - **Start with the relevant section** based on your area of interest - **Cross-reference between sections** for complete understanding - **Use examples and specifications** to understand implementation details ### Development and Integration + - **API Reference** for building integrations or understanding data flows - **Configuration Schema** for system setup and validation - **UI Components** for understanding interface behavior and capabilities @@ -175,21 +199,25 @@ Fatal (5) > Error (4) > Warning (3) > Information (2) > Debug (1) > Verbose (0) ## πŸ“– Reference vs. Other Documentation Types ### Reference (This Section) + - **Purpose**: Complete technical facts and specifications - **Organization**: By topic and feature area - **Use**: When you need specific technical information ### Tutorials (Learning) + - **Purpose**: Step-by-step learning experiences - **Organization**: By learning progression - **Use**: When you're new to the system ### How-to Guides (Problem-solving) + - **Purpose**: Solutions to specific problems - **Organization**: By problem type - **Use**: When you need to solve a specific issue ### Explanation (Understanding) + - **Purpose**: Background concepts and design rationale - **Organization**: By concept and system area - **Use**: When you want to understand why and how things work @@ -199,16 +227,19 @@ Fatal (5) > Error (4) > Warning (3) > Information (2) > Debug (1) > Verbose (0) ## πŸ”„ Reference Maintenance ### Accuracy Commitment + - **Verified Information**: All specifications tested against actual system behavior - **Version Alignment**: Documentation matches current application version - **Regular Updates**: Reference updated with application changes ### Completeness Goals + - **Comprehensive Coverage**: All public APIs, UI elements, and features documented - **Technical Depth**: Sufficient detail for implementation and troubleshooting - **Practical Examples**: Real-world examples for complex specifications ### User Feedback Integration + - **Missing Information**: Gaps identified through user feedback are addressed - **Clarity Improvements**: Technical explanations refined based on user questions - **Example Requests**: Additional examples added based on common use cases @@ -218,20 +249,23 @@ Fatal (5) > Error (4) > Warning (3) > Information (2) > Debug (1) > Verbose (0) ## πŸ’‘ Tips for Effective Reference Use ### Search Strategies + - **Use specific terms**: Search for exact API endpoints, component names, or property names - **Try variations**: Search for both technical terms and common descriptions - **Cross-reference**: Use information from one section to find related details in other sections ### Bookmarking Recommendations + - **Bookmark frequently used sections** for quick access - **Save links to specific endpoints or components** you work with regularly - **Create personal quick-reference notes** combining information from multiple sections ### Integration with Other Tools + - **Use with development tools**: Reference API specifications while building integrations - **Combine with system documentation**: Use alongside your specific system configuration details - **Reference during troubleshooting**: Cross-reference with debug console output and system behavior --- -*Reference documentation is designed to be comprehensive and authoritative. When you need facts, specifications, or technical details, this is your primary resource. For learning and problem-solving, consider the other documentation types as well.* +_Reference documentation is designed to be comprehensive and authoritative. When you need facts, specifications, or technical details, this is your primary resource. For learning and problem-solving, consider the other documentation types as well._ diff --git a/docs/reference/api-endpoints.md b/docs/reference/api-endpoints.md index c0a711d..d320ba7 100644 --- a/docs/reference/api-endpoints.md +++ b/docs/reference/api-endpoints.md @@ -15,6 +15,7 @@ All API endpoints are accessed through the base path `/cws/:appId/api` where `:a ## Authentication Endpoints ### Set Login Credentials + **Purpose**: Authenticate with the processor. The backend uses a single shared authentication mechanism for all program slots. ```http @@ -22,6 +23,7 @@ POST /loginCredentials ``` **Request Body**: + ```json { "username": "admin", @@ -32,6 +34,7 @@ POST /loginCredentials **Response**: `200 OK` (empty body) on success **Notes**: + - A successful response with any `appId` authenticates the session for all running slots - The app probes all 10 slots in parallel after initial auth to discover which are running - A `4xx` or network error indicates invalid credentials or that the slot is not running @@ -41,6 +44,7 @@ POST /loginCredentials ## System Information Endpoints ### Get Versions + **Purpose**: Retrieve all loaded assemblies and their versions ```http @@ -48,6 +52,7 @@ GET /versions ``` **Response**: + ```json [ { @@ -62,17 +67,20 @@ GET /versions ``` **Response Fields**: + - `Name` (string): Full assembly name - `Version` (string): Version number in dotted format **Usage**: Displayed on Versions page for system documentation and troubleshooting **Error Conditions**: + - `500`: Server error if version information cannot be retrieved --- ### Get API Paths + **Purpose**: Retrieve all available REST API routes registered on the processor ```http @@ -80,6 +88,7 @@ GET /apiPaths ``` **Response**: + ```json { "url": "https://192.168.1.100/cws/app01", @@ -95,6 +104,7 @@ GET /apiPaths ``` **Response Fields**: + - `url` (string): Base URL of the processor web server for this app slot - `routes` (array): List of route objects - `Name` (string): Route name @@ -104,11 +114,13 @@ GET /apiPaths **Usage**: Displayed on the API Paths page; routes are sorted alphabetically and shown with clickable URLs **Error Conditions**: + - `500`: Server error if route information cannot be retrieved --- ### Get Device Types + **Purpose**: Retrieve all available device types supported by current plugins ```http @@ -116,6 +128,7 @@ GET /types ``` **Response**: + ```json [ { @@ -132,6 +145,7 @@ GET /types ``` **Response Fields**: + - `Type` (string): Configuration identifier used in device configuration - `Description` (string): Human-readable description of device purpose - `CType` (string): Full .NET class name that implements the device @@ -139,6 +153,7 @@ GET /types **Usage**: Displayed on Types page for configuration reference and development **Error Conditions**: + - `500`: Server error if type information cannot be retrieved --- @@ -146,6 +161,7 @@ GET /types ## Device Management Endpoints ### Get Devices + **Purpose**: Retrieve all configured devices in the system ```http @@ -153,6 +169,7 @@ GET /devices ``` **Response**: + ```json [ { @@ -160,24 +177,27 @@ GET /devices "Name": "Conference Room Display" }, { - "Key": "Codec-Main", + "Key": "Codec-Main", "Name": "Main Video Codec" } ] ``` **Response Fields**: + - `Key` (string): Unique device identifier used in configuration and debug messages - `Name` (string): Human-readable device name for user interfaces **Usage**: Device list display and debug message filtering **Error Conditions**: + - `500`: Server error if device information cannot be retrieved --- ### Get Device Properties + **Purpose**: Retrieve current properties and values for a specific device ```http @@ -185,9 +205,11 @@ GET /deviceProperties/{deviceKey} ``` **Path Parameters**: + - `deviceKey` (string): The unique Key of the device **Response**: + ```json [ { @@ -199,7 +221,7 @@ GET /deviceProperties/{deviceKey} }, { "Name": "CurrentInput", - "Type": "String", + "Type": "String", "Value": "HDMI1", "CanRead": true, "CanWrite": false @@ -208,6 +230,7 @@ GET /deviceProperties/{deviceKey} ``` **Response Fields**: + - `Name` (string): Property name - `Type` (string): Data type of the property value - `Value` (string): Current property value (always string representation) @@ -217,22 +240,26 @@ GET /deviceProperties/{deviceKey} **Usage**: Device detail inspection and monitoring **Error Conditions**: + - `404`: Device with specified key not found - `500`: Server error retrieving device properties --- ### Get Device Methods -**Purpose**: Retrieve available methods (commands) for a specific device + +**Purpose**: Retrieve available methods (commands) for a specific device ```http GET /deviceMethods/{deviceKey} ``` **Path Parameters**: + - `deviceKey` (string): The unique Key of the device **Response**: + ```json [ { @@ -252,6 +279,7 @@ GET /deviceMethods/{deviceKey} ``` **Response Fields**: + - `Name` (string): Method name - `Params` (array): Array of parameter definitions - `Name` (string): Parameter name @@ -260,6 +288,7 @@ GET /deviceMethods/{deviceKey} **Usage**: Device control interface and method execution **Error Conditions**: + - `404`: Device with specified key not found - `500`: Server error retrieving device methods @@ -268,6 +297,7 @@ GET /deviceMethods/{deviceKey} ## Configuration Endpoints ### Get Configuration + **Purpose**: Retrieve the complete merged system configuration ```http @@ -277,6 +307,7 @@ GET /config **Response**: Complete JSON configuration object (structure varies by system) **Example Response Structure**: + ```json { "devices": { @@ -308,6 +339,7 @@ GET /config **Response Size**: Can be very large (10KB - 1MB+) depending on system complexity **Error Conditions**: + - `500`: Server error if configuration cannot be retrieved or merged --- @@ -315,6 +347,7 @@ GET /config ## Debug and Monitoring Endpoints ### Start Debug Session + **Purpose**: Initiate a WebSocket debug session for real-time message monitoring ```http @@ -322,6 +355,7 @@ GET /debugSession ``` **Response**: + ```json { "url": "wss://192.168.1.100/cws/app01/api/debug-websocket" @@ -329,16 +363,19 @@ GET /debugSession ``` **Response Fields**: + - `url` (string): WebSocket URL for establishing debug connection **Usage**: Real-time debug message monitoring in Debug Console **WebSocket Protocol**: + - **Connection**: Use returned URL to establish WebSocket connection - **Messages**: Server sends JSON-formatted log messages - **Client**: Client receives messages, no need to send data to server **WebSocket Message Format**: + ```json { "Timestamp": "2024-01-15T10:30:45.123Z", @@ -353,12 +390,14 @@ GET /debugSession ``` **Error Conditions**: + - `500`: Server error if debug session cannot be started - WebSocket connection errors handled by client WebSocket implementation --- ### Stop Debug Session + **Purpose**: Stop an active debug session and close WebSocket connections ```http @@ -372,6 +411,7 @@ POST /debugSession **Usage**: Clean termination of debug sessions **Error Conditions**: + - `500`: Server error stopping debug session --- @@ -379,6 +419,7 @@ POST /debugSession ## System Control Endpoints ### Get Minimum Log Level + **Purpose**: Retrieve the current minimum log level for debug output ```http @@ -386,6 +427,7 @@ GET /appdebug ``` **Response**: + ```json { "minimumLevel": "Information" @@ -393,9 +435,11 @@ GET /appdebug ``` **Response Fields**: + - `minimumLevel` (string): Current minimum log level setting **Valid Log Levels** (in order of severity): + - `Verbose`: Most detailed logging - `Debug`: Detailed technical information - `Information`: General informational messages @@ -406,11 +450,13 @@ GET /appdebug **Usage**: Display current log level setting and provide options for changes **Error Conditions**: + - `500`: Server error retrieving log level setting --- ### Set Minimum Log Level + **Purpose**: Change the minimum log level for debug output ```http @@ -418,6 +464,7 @@ POST /appdebug ``` **Request Body**: + ```json { "minimumLevel": "Warning" @@ -425,6 +472,7 @@ POST /appdebug ``` **Request Fields**: + - `minimumLevel` (string): New minimum log level (must be valid level) **Response**: Empty (204 No Content on success) @@ -432,12 +480,14 @@ POST /appdebug **Usage**: Adjust debug verbosity from Debug Console interface **Error Conditions**: + - `400`: Invalid log level specified - `500`: Server error setting log level --- ### Get Configuration Loading Setting + **Purpose**: Check if configuration loading on boot is disabled ```http @@ -445,6 +495,7 @@ GET /doNotLoadConfigOnNextBoot ``` **Response**: + ```json { "doNotLoadConfigOnNextBoot": false @@ -452,16 +503,19 @@ GET /doNotLoadConfigOnNextBoot ``` **Response Fields**: + - `doNotLoadConfigOnNextBoot` (boolean): Whether config loading is disabled **Usage**: Display current setting and allow user control **Error Conditions**: + - `500`: Server error retrieving setting --- ### Set Configuration Loading Setting + **Purpose**: Enable or disable configuration loading on next boot ```http @@ -469,6 +523,7 @@ POST /doNotLoadConfigOnNextBoot ``` **Request Body**: + ```json { "doNotLoadConfigOnNextBoot": true @@ -476,6 +531,7 @@ POST /doNotLoadConfigOnNextBoot ``` **Request Fields**: + - `doNotLoadConfigOnNextBoot` (boolean): New setting value **Response**: Empty (204 No Content on success) @@ -483,12 +539,14 @@ POST /doNotLoadConfigOnNextBoot **Usage**: Control configuration loading behavior for troubleshooting **Error Conditions**: + - `400`: Invalid boolean value specified - `500`: Server error setting configuration --- ### Restart Program + **Purpose**: Restart the entire PepperDash Essentials framework ```http @@ -502,17 +560,20 @@ POST /restartProgram **Usage**: Complete system restart from web interface **Behavior**: + - System begins restart immediately - All connections will be closed - Response may not be received due to restart timing - System will be unavailable for 2-5 minutes during restart **Error Conditions**: + - `500`: Server error initiating restart (system may still restart) --- ### Load Configuration + **Purpose**: Manually reload system configuration without full restart ```http @@ -528,21 +589,440 @@ POST /loadConfig **Prerequisites**: Usually used when "doNotLoadConfigOnNextBoot" is true **Error Conditions**: + - `400`: Configuration cannot be loaded (syntax errors, etc.) - `500`: Server error during configuration loading --- +## Routing Endpoints + +Requires PepperDashEssentials.dll 3.0 or later. + +### Get Routing Devices and Tie Lines + +**Purpose**: Retrieve the complete routing graph β€” devices, ports, tie lines, and current route state + +```http +GET /routingDevicesAndTieLines +``` + +**Response**: + +```json +{ + "devices": [ + { + "key": "display-1", + "name": "Main Display", + "hasInputs": true, + "hasOutputs": false, + "hasInputsAndOutputs": false, + "inputPorts": [ + { + "key": "hdmiIn1", + "signalType": "AudioVideo", + "connectionType": "Hdmi", + "isInternal": false + } + ] + } + ], + "tieLines": [ + { + "sourceDeviceKey": "laptop-1", + "sourcePortKey": "out1", + "destinationDeviceKey": "display-1", + "destinationPortKey": "hdmiIn1", + "signalType": "AudioVideo", + "isInternal": false + } + ], + "currentRoutes": [], + "sinkCurrentSources": [], + "multiviewLayouts": {} +} +``` + +**Device role flags**: `hasInputsAndOutputs` is the literal "is a midpoint" flag. Note that a multiview decoder reports `hasInputs` **and** `hasOutputs` while **not** being a midpoint, so "sink = has inputs and no outputs" is not a safe test β€” see [UI Components Reference](./ui-components.md#route-popover). + +**Multiview tiles**: A multiview decoder's tile child devices are not returned as top-level devices. Each tile is synthesized as an input port on the parent, keyed `tile{N}:{portKey}`, and any tie line or route targeting a tile is remapped onto the parent. + +--- + +### Start Routing Feedback Session + +**Purpose**: Start the routing feedback WebSocket server and obtain its URL + +```http +GET /routingFeedbackSession +``` + +**Response**: + +```json +{ + "url": "wss://192.168.1.164:65401/routing/join/", + "fallbackUrl": "wss://10.0.0.5:65401/routing/join/" +} +``` + +**Usage**: Connect to `url` to receive live route changes. Messages are a discriminated union on `type`: `snapshot`, `midpointRouteChanged`, `sinkInputChanged`, and `layoutChanged`. A `sinkInputChanged` with an empty `sourceDeviceKey` means the route feeding that input was cleared. + +--- + +### Execute Routing Command + +**Purpose**: Make or clear a route, addressed entirely by device and port keys + +```http +POST /routingCommand +``` + +Ports are addressed by **key**, never by selector: a routing port's selector is a driver-defined object that cannot be expressed in JSON, so the processor resolves key β†’ port β†’ selector itself. + +**Request Body** β€” one of four commands: + +```json +{ "command": "sinkRoute", "deviceKey": "display-1", "inputPortKey": "hdmiIn1", + "sourceDeviceKey": "laptop-1", "signalType": "AudioVideo" } + +{ "command": "midpointSwitch", "deviceKey": "dm-chassis-1", + "inputPortKey": "inputCard3", "outputPortKey": "outputCard5", "signalType": "Video" } + +{ "command": "clearSink", "deviceKey": "display-1", "inputPortKey": "hdmiIn1", + "clearSinkInput": true } + +{ "command": "clearMidpointOutput", "deviceKey": "dm-chassis-1", + "outputPortKey": "outputCard5", "signalType": "AudioVideo" } +``` + +**Request Fields**: + +- `command` (string): `sinkRoute`, `midpointSwitch`, `clearSink` or `clearMidpointOutput`. Case-insensitive +- `deviceKey` (string): Target device. For sink commands this is the destination β€” or the multiview parent when addressing a tile +- `inputPortKey` (string, optional): May be multiview-qualified (`tile2:tileInput`); the processor de-qualifies it to the child tile sink. Optional for `clearSink`, where omitting it clears whatever route the sink has +- `outputPortKey` (string): Required for the midpoint commands +- `sourceDeviceKey` (string): Required for `sinkRoute` +- `sourcePortKey` (string, optional): Omit to let the processor's path discovery choose +- `signalType` (string): `Audio`, `Video`, `AudioVideo` or `Usb`. Defaults to `AudioVideo`. Numeric values are rejected +- `releaseOnly` (bool): `clearSink` only β€” stop usage tracking but leave the signal flowing +- `clearSinkInput` (bool): `clearSink` only β€” also deselect the destination's own input. Off by default, because clearing a route otherwise never touches the destination +- `dryRun` (bool): Validate and compute the path, execute nothing + +**Response**: + +```json +{ + "status": "accepted", + "command": "sinkRoute", + "deviceKey": "nvx-decoder-1", + "resolvedDeviceKey": "nvx-decoder-1-tile2", + "resolvedInputPortKey": "tileInput", + "signalType": "AudioVideo", + "effectiveSignalType": "AudioVideo", + "partial": false, + "steps": [ + { + "signalType": "Video", + "switchingDeviceKey": "dm-chassis-1", + "inputPortKey": "inputCard3", + "outputPortKey": "outputCard5" + } + ] +} +``` + +**Response Fields**: + +- `status`: `executed` (done before the response was written), `accepted` (validated and queued), `validated` (dry run), or `error` +- `resolvedDeviceKey` / `resolvedInputPortKey`: The real device and port the command ran against. These differ from the requested values only when a `tile{N}:` port was de-qualified +- `effectiveSignalType`: What was actually handed to the devices. May be **wider** than `signalType` β€” a pre-mapped route descriptor takes its type from the port's declared type, so an Audio-only request across all-`AudioVideo` ports executes as `AudioVideo` rather than breaking away +- `steps`: The switch steps that will run, in order. `sinkRoute` only; an `AudioVideo` route is discovered as two independent paths, so each step names its own signal type +- `partial`: An `AudioVideo` request that found a path for only one half. The half that was found is still routed + +**Status Codes**: + +| Status | Meaning | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `200` | `midpointSwitch` / `clearMidpointOutput` executed inline, or a dry run validated | +| `202` | `sinkRoute` / `clearSink` validated and queued. Confirmation arrives over the feedback WebSocket, not here | +| `400` | Malformed request: `invalidJson`, `missingField`, `unknownCommand`, `invalidSignalType` | +| `404` | `deviceNotFound` β€” the key is wrong | +| `409` | `noRouteFound` β€” the keys are valid but no wiring path exists | +| `422` | `deviceNotRoutable`, `tileNotFound`, `portNotFound`, `signalTypeNotSupportedByPort` β€” the keys exist but the request is impossible on that device | +| `500` | `executionError` β€” the device threw while switching | + +**Error Response Body**: + +```json +{ + "status": "error", + "error": { + "code": "noRouteFound", + "message": "No Video path exists from 'laptop-1' to 'display-1' input 'hdmiIn1'.", + "field": "signalType" + } +} +``` + +**Why sink commands return 202**: making a route enqueues onto the processor's single-worker routing queue, and completion is conditional β€” a destination that is warming or cooling parks its request until the cooldown finishes. The 202 is still meaningful because all validation, including full path discovery, happens synchronously first: a request that provably cannot work returns `409` before anything is queued. + +**Capability detection**: this endpoint does not exist on older processors. Probe [Get API Paths](#get-api-paths) for a `routingCommand` route rather than gating on a version number. + +--- + +## Secrets Endpoints + +Credential storage. **No endpoint here ever returns a stored secret value** β€” the processor is +write-only for values by design. + +Mutations are POST rather than DELETE: the Crestron web server advertises only `POST, GET, OPTIONS`, +so a DELETE would fail CORS preflight. + +### Get Secret Providers + +```http +GET /secrets/providers +``` + +```json +{ + "providers": [ + { + "key": "default", + "description": "Default secret provider serving Essentials Application 1", + "scope": "local", + "enumerationSupported": true, + "maxKeyLength": 32, + "maxValueLength": 1600 + }, + { + "key": "CrestronGlobalSecrets", + "description": "Default secret provider serving all local applications", + "scope": "global", + "enumerationSupported": true, + "maxKeyLength": 32, + "maxValueLength": 1600 + } + ] +} +``` + +`scope` is `local` (this program slot) or `global` (shared across slots). The limits are Crestron +Data Store caps, not application choices. + +--- + +### List Secrets + +```http +GET /secrets?provider=default +``` + +Optional: `includeReserved=true` to show the API's own bookkeeping records, `includeSizes=true` to +include each value's character count. + +```json +{ + "provider": "default", + "scope": "local", + "indexStatus": "ok", + "enumerationComplete": true, + "counts": { "total": 3, "managed": 1, "unmanaged": 2, "stale": 0 }, + "secrets": [ + { + "key": "displayPassword", + "managed": true, + "description": "Display admin", + "createdUtc": "2026-09-10T14:00:00Z", + "updatedUtc": "2026-09-16T18:22:05Z", + "lastModifiedUtc": "2026-09-16T18:22:05Z", + "owner": "app01", + "type": "String" + } + ], + "staleIndexEntries": [], + "warnings": [] +} +``` + +**Response Fields**: + +- `managed` β€” whether the key was written through this API. Unmanaged records exist in the same flat + Data Store but were created elsewhere; Mobile Control's paired-client tokens are one +- `indexStatus` β€” `ok`, `missing`, or `corrupt:`. Anything but `ok` means classification is + unavailable and every key reports as unmanaged. **The secrets themselves are unaffected** +- `enumerationComplete` β€” `false` means the Data Store walk was cut short and the list is partial. + Index pruning is refused in that state +- `staleIndexEntries` β€” keys the index lists that no longer exist in the store + +Status: `200` Β· `400 missingField` Β· `404 providerNotFound` Β· `500` + +--- + +### Execute Secrets Command + +```http +POST /secrets/command +``` + +```json +{ + "action": "set", + "provider": "default", + "key": "displayPassword", + "value": "…", + "description": "Display admin", + "overwrite": false +} +``` + +| Action | Semantics | +| -------------- | ---------------------------------------------------------------------------------------------------------------- | +| `set` | Creates. Existing key + `overwrite:false` β†’ `409 alreadyExists` | +| `update` | Must already exist, else `404 notFound` | +| `delete` | Removes the secret and its index entry | +| `test` | Existence probe β†’ `{"exists": true}`. Never a value | +| `pruneIndex` | Drops index entries with no matching record. `409` if the walk was incomplete | +| `rebuildIndex` | Repairs a damaged index. `adoptKeys` marks existing keys as managed **without reading or changing their values** | + +```json +{ + "status": "ok", + "action": "set", + "provider": "default", + "key": "displayPassword", + "existedBefore": false, + "indexUpdated": true +} +``` + +`indexUpdated: false` with a `warning` means the secret was written but its metadata was not. That is +reported as **success**, because the secret is authoritative and the index is advisory β€” returning an +error would invite retrying a write that already happened. + +**Validation** (all before any store access): + +| Rule | Code | Status | +| -------------------------------------------------------- | --------------------------- | ------ | +| Key empty or whitespace | `emptyKey` | 400 | +| Key over 32 characters, or containing control characters | `keyTooLong` / `invalidKey` | 400 | +| Key starts with `__essSecretsIdx` | `reservedKey` | 400 | +| Value empty (empty means _delete_ in the store) | `emptyValue` | 400 | +| Value over 1600 characters | `valueTooLong` | 400 | + +The empty-key rule is not cosmetic: an empty key reaches a Data Store call that deletes **every +record belonging to the application**. + +Status: `200` Β· `400` Β· `403 accessDenied` Β· `404 notFound` Β· `409 alreadyExists` / +`enumerationIncomplete` Β· `507 storeFull` Β· `503 storeUnavailable` Β· `500` + +--- + +### Apply Secrets In Bulk + +```http +POST /secrets/bulk +``` + +```json +{ + "mode": "preview", + "provider": "default", + "overwrite": false, + "secrets": { "displayPassword": "…", "codecPassword": "…" } +} +``` + +`secrets` accepts either the flat map above β€” which is what the template endpoint emits β€” or an +array of `{key, value, provider?, description?}`. + +```json +{ + "mode": "preview", + "provider": "default", + "overwrite": false, + "summary": { + "total": 3, + "create": 1, + "overwrite": 1, + "skip": 1, + "invalid": 0, + "failed": 0 + }, + "entries": [ + { + "index": 0, + "key": "displayPassword", + "provider": "default", + "action": "create", + "applied": false + } + ], + "indexUpdated": false +} +``` + +Rules worth knowing: + +- **Preview writes nothing.** `applied` is always `false`, and the response is `200` even when + entries are invalid β€” a successful preview of a bad file is not a failed request +- **A commit with any invalid entry returns `422` and writes nothing** +- `overwrite` defaults to `false` +- An entry targeting an existing **unmanaged** key is skipped as `unmanagedTarget` unless + `allowUnmanagedOverwrite` is set. This is what stops a round-tripped template destroying another + subsystem's records +- **Bulk never deletes.** A key absent from the file is left alone +- Duplicate keys within one batch are `invalid`, not last-write-wins +- Over 200 entries β†’ `400 batchTooLarge` + +--- + +### Get Secrets Template + +```http +GET /secrets/template?provider=default +``` + +```json +{ + "provider": "default", + "generatedUtc": "2026-09-16T18:30:00Z", + "note": "Values are intentionally blank. Fill them in, then apply this file.", + "secrets": { "displayPassword": "", "codecPassword": "" }, + "metadata": [ + { + "key": "displayPassword", + "provider": "default", + "description": "Display admin", + "managed": true + } + ] +} +``` + +`secrets` is byte-for-byte what the bulk endpoint accepts, so the file round-trips: download, fill +in, apply elsewhere. `includeUnmanaged` defaults to `false`, so other subsystems' records are not +offered as blanks to fill in. + +**Capability detection**: these endpoints do not exist on older processors. Probe +[Get API Paths](#get-api-paths) for a `secrets` route rather than gating on a version number. + +--- + ## Error Response Format All endpoints return errors in consistent format: **HTTP Status Codes**: + - `400`: Bad Request - Invalid parameters or request format - `404`: Not Found - Requested resource does not exist - `500`: Internal Server Error - Server-side processing error **Error Response Body**: + ```json { "error": "Description of the error condition", @@ -555,11 +1035,13 @@ All endpoints return errors in consistent format: **Rate Limits**: No explicit rate limiting implemented **Performance Considerations**: + - `/config` endpoint may take several seconds for large configurations - `/debugSession` WebSocket can generate high message volumes - Multiple simultaneous debug sessions impact processor performance **Best Practices**: + - Cache version and type information (changes infrequently) - Close debug sessions when not actively monitoring - Use appropriate minimum log levels to reduce debug message volume @@ -575,6 +1057,7 @@ All endpoints return errors in consistent format: **CORS**: Configured to allow requests from the web application origin **Sensitive Data**: + - Configuration may contain IP addresses, usernames, and network topology - Debug messages may contain sensitive operational information - No automatic filtering of sensitive information in responses @@ -582,20 +1065,24 @@ All endpoints return errors in consistent format: ## WebSocket Debug Protocol ### Connection Establishment + 1. Call `GET /debugSession` to get WebSocket URL 2. Establish WebSocket connection using returned URL 3. Connection remains open until explicitly closed or system restart ### Message Flow + - **Server to Client**: Continuous stream of debug messages in JSON format - **Client to Server**: No messages required (read-only protocol) ### Connection Management + - **Keep-alive**: WebSocket handles connection keep-alive automatically - **Reconnection**: Client must handle reconnection logic if connection drops - **Cleanup**: Call `POST /debugSession` to cleanly stop session ### Message Volume Management + - **High Volume**: Systems may generate >100 messages per second - **Filtering**: Use minimum log level to reduce message volume - **Browser Limits**: Very high message rates may impact browser performance @@ -603,28 +1090,30 @@ All endpoints return errors in consistent format: ## Integration Examples ### Basic Configuration Retrieval + ```javascript fetch('https://192.168.1.100/cws/app01/api/config') - .then(response => response.json()) - .then(config => { + .then((response) => response.json()) + .then((config) => { console.log('System configuration:', config); }); ``` ### Starting Debug Session + ```javascript // Get WebSocket URL fetch('https://192.168.1.100/cws/app01/api/debugSession') - .then(response => response.json()) - .then(data => { + .then((response) => response.json()) + .then((data) => { // Connect to WebSocket const ws = new WebSocket(data.url); - + ws.onmessage = (event) => { const message = JSON.parse(event.data); console.log('Debug message:', message); }; - + ws.onclose = () => { console.log('Debug session closed'); }; @@ -632,15 +1121,16 @@ fetch('https://192.168.1.100/cws/app01/api/debugSession') ``` ### Setting Log Level + ```javascript fetch('https://192.168.1.100/cws/app01/api/appdebug', { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, body: JSON.stringify({ - minimumLevel: 'Warning' - }) + minimumLevel: 'Warning', + }), }); ``` diff --git a/docs/reference/configuration-schema.md b/docs/reference/configuration-schema.md index 801d45d..4a1f648 100644 --- a/docs/reference/configuration-schema.md +++ b/docs/reference/configuration-schema.md @@ -17,7 +17,7 @@ This document provides complete reference information about the configuration da "devices": { "deviceKey": { "key": "string", - "name": "string", + "name": "string", "type": "string", "group": "string", "properties": {}, @@ -27,7 +27,7 @@ This document provides complete reference information about the configuration da "tieLines": [ { "sourceDevice": "string", - "destinationDevice": "string", + "destinationDevice": "string", "type": "string" } ], @@ -50,20 +50,21 @@ This document provides complete reference information about the configuration da All devices share these common configuration properties: -| Property | Type | Required | Description | -|----------|------|----------|-------------| -| `key` | string | Yes | Unique identifier for the device | -| `name` | string | Yes | Human-readable display name | -| `type` | string | Yes | Device type identifier | -| `group` | string | No | Logical grouping for organization | -| `enabled` | boolean | No | Whether device is active (default: true) | -| `description` | string | No | Additional device description | +| Property | Type | Required | Description | +| ------------- | ------- | -------- | ---------------------------------------- | +| `key` | string | Yes | Unique identifier for the device | +| `name` | string | Yes | Human-readable display name | +| `type` | string | Yes | Device type identifier | +| `group` | string | No | Logical grouping for organization | +| `enabled` | boolean | No | Whether device is active (default: true) | +| `description` | string | No | Additional device description | ### Device-Specific Properties Device properties vary by type. Common property categories include: #### Connection Properties + ```json { "control": { @@ -79,6 +80,7 @@ Device properties vary by type. Common property categories include: ``` #### Communication Properties + ```json { "communicationMonitorProperties": { @@ -95,6 +97,7 @@ Device properties vary by type. Common property categories include: ### Display Devices #### Generic Display + ```json { "type": "genericDisplay", @@ -113,6 +116,7 @@ Device properties vary by type. Common property categories include: ``` #### Sony Display + ```json { "type": "sonyDisplay", @@ -131,7 +135,8 @@ Device properties vary by type. Common property categories include: ### Audio Devices -#### Generic Audio DSP +#### Generic Audio DSP + ```json { "type": "genericAudioDsp", @@ -157,6 +162,7 @@ Device properties vary by type. Common property categories include: ``` #### Cisco Codec + ```json { "type": "ciscoCodec", @@ -178,15 +184,16 @@ Device properties vary by type. Common property categories include: ### Control System Devices #### Crestron Processor + ```json { - "type": "crestron3Series", + "type": "crestron3Series", "properties": { "control": { "method": "crestronCom", "comParams": { "hardwareHandshake": "None", - "parity": "None", + "parity": "None", "baudRate": 38400, "dataBits": 8, "stopBits": 1 @@ -202,6 +209,7 @@ Device properties vary by type. Common property categories include: ## Room Configuration Schema ### Basic Room Structure + ```json { "roomKey": { @@ -226,18 +234,19 @@ Device properties vary by type. Common property categories include: ### Room Properties -| Property | Type | Required | Description | -|----------|------|----------|-------------| -| `key` | string | Yes | Unique room identifier | -| `name` | string | Yes | Display name for the room | -| `description` | string | No | Additional room information | -| `sourceListKey` | string | No | Reference to source list configuration | -| `defaultSourceItem` | string | No | Default active source | -| `devices` | array | No | List of device keys in this room | +| Property | Type | Required | Description | +| ------------------- | ------ | -------- | -------------------------------------- | +| `key` | string | Yes | Unique room identifier | +| `name` | string | Yes | Display name for the room | +| `description` | string | No | Additional room information | +| `sourceListKey` | string | No | Reference to source list configuration | +| `defaultSourceItem` | string | No | Default active source | +| `devices` | array | No | List of device keys in this room | ## Source List Configuration ### Source List Structure + ```json { "sourceListKey": { @@ -254,7 +263,7 @@ Device properties vary by type. Common property categories include: }, "routingOutputs": { "videoOutputs": ["output1"], - "audioOutputs": ["output1"] + "audioOutputs": ["output1"] } } } @@ -267,22 +276,24 @@ Device properties vary by type. Common property categories include: ### Tie Line Types #### Audio Tie Lines + ```json { "sourceDevice": "dsp1", - "destinationDevice": "codec1", + "destinationDevice": "codec1", "type": "audio", "sourceOutput": "programOut", "destinationInput": "micIn" } ``` -#### Video Tie Lines +#### Video Tie Lines + ```json { "sourceDevice": "switcher1", "destinationDevice": "display1", - "type": "video", + "type": "video", "sourceOutput": "output1", "destinationInput": "hdmi1" } @@ -293,13 +304,15 @@ Device properties vary by type. Common property categories include: ### Required Fields Validation The system validates that all required fields are present: + - Device `key` and `type` are mandatory -- Room `key` and `name` are mandatory +- Room `key` and `name` are mandatory - Tie line `sourceDevice` and `destinationDevice` must reference existing devices ### Type Validation Device types must match registered factory types: + - Unknown device types will generate warnings - Missing required properties for device types will cause errors - Invalid property values will be flagged during validation @@ -307,6 +320,7 @@ Device types must match registered factory types: ### Reference Validation Cross-references between configuration objects are validated: + - Device keys referenced in rooms must exist - Source list references must point to valid source lists - Tie line device references must point to existing devices @@ -316,8 +330,9 @@ Cross-references between configuration objects are validated: ### Merge Priority Order When multiple configuration files are present, they are merged in this order: + 1. Base system configuration -2. Template configurations +2. Template configurations 3. User configuration files 4. Environment-specific overrides @@ -329,6 +344,7 @@ When multiple configuration files are present, they are merged in this order: - **Null/Undefined**: Explicit null values remove properties ### Example Merge Behavior + ```json // Base config { @@ -343,11 +359,11 @@ When multiple configuration files are present, they are merged in this order: } } -// Override config +// Override config { "devices": { "display1": { - "name": "Main Display", + "name": "Main Display", "properties": { "volume": 75 } @@ -374,6 +390,7 @@ When multiple configuration files are present, they are merged in this order: ### Read-Only Access The web config app provides read-only access to merged configuration: + - Cannot modify configuration through the web interface - Configuration changes require file system access to the processor - Changes take effect after configuration reload @@ -381,6 +398,7 @@ The web config app provides read-only access to merged configuration: ### Configuration Refresh Configuration can be refreshed without restarting: + - Use the "Reload Configuration" function in the web app - Configuration is re-read and merged from files - Active devices maintain state where possible @@ -389,6 +407,7 @@ Configuration can be refreshed without restarting: ## Common Configuration Patterns ### Multi-Room Systems + ```json { "devices": { @@ -409,7 +428,7 @@ Configuration can be refreshed without restarting: "volumeControlKey": "room1Volume" }, "room2": { - "devices": ["mainDsp"], + "devices": ["mainDsp"], "volumeControlKey": "room2Volume" } } @@ -417,6 +436,7 @@ Configuration can be refreshed without restarting: ``` ### Video Switching Systems + ```json { "devices": { @@ -444,24 +464,29 @@ Configuration can be refreshed without restarting: ### Common Problems **Missing Device References**: + - Symptoms: Warnings in debug console about unknown devices - Solution: Verify device keys match exactly between references **Invalid Device Types**: + - Symptoms: Errors during system startup - Solution: Check device type names against factory registrations **Circular References**: + - Symptoms: System fails to start or infinite loops - Solution: Review tie line configurations for circular routing **Property Type Mismatches**: + - Symptoms: Device properties not working as expected - Solution: Verify property types match device expectations ### Configuration Debugging Use the debug console to identify configuration issues: + 1. Look for configuration validation messages during startup 2. Check device initialization messages for failures 3. Monitor property change messages to verify configuration application @@ -469,4 +494,4 @@ Use the debug console to identify configuration issues: --- -*This reference provides complete information about configuration structure and validation. Use it to understand how configuration files are structured and how to interpret configuration data displayed in the web app.* +_This reference provides complete information about configuration structure and validation. Use it to understand how configuration files are structured and how to interpret configuration data displayed in the web app._ diff --git a/docs/reference/device-types.md b/docs/reference/device-types.md index 2735bd6..9699843 100644 --- a/docs/reference/device-types.md +++ b/docs/reference/device-types.md @@ -7,9 +7,11 @@ This document provides comprehensive reference information about all supported d ### Display Devices #### Generic Display (`genericDisplay`) + **Purpose**: Universal display control for most display devices using standard protocols. **Key Properties**: + - `supportsDiscretePower`: boolean - Whether device supports separate on/off commands - `supportsVolumeControl`: boolean - Whether display has volume control - `warmupTimeMs`: number - Time in milliseconds for display warmup @@ -18,6 +20,7 @@ This document provides comprehensive reference information about all supported d **Control Methods**: TCP/IP, RS-232, IR **Example Configuration**: + ```json { "type": "genericDisplay", @@ -31,9 +34,11 @@ This document provides comprehensive reference information about all supported d ``` #### Sony Professional Display (`sonyDisplay`) + **Purpose**: Enhanced control for Sony professional displays with advanced features. **Key Properties**: + - `id`: string - Display ID for multi-display setups (01-99) - `supportsAdvancedPicture`: boolean - Advanced picture control support - `inputCount`: number - Number of available inputs @@ -41,9 +46,10 @@ This document provides comprehensive reference information about all supported d **Control Methods**: TCP/IP (port 20060), RS-232 **Example Configuration**: + ```json { - "type": "sonyDisplay", + "type": "sonyDisplay", "properties": { "id": "01", "supportsAdvancedPicture": true, @@ -53,9 +59,11 @@ This document provides comprehensive reference information about all supported d ``` #### Samsung Commercial Display (`samsungDisplay`) + **Purpose**: Control for Samsung commercial display series with MDC protocol. **Key Properties**: + - `displayId`: number - Display ID for daisy-chained displays (0-254) - `supportsNetworkStandby`: boolean - Network wake-on-LAN support - `maxVolumeLevel`: number - Maximum volume level (default: 100) @@ -65,14 +73,17 @@ This document provides comprehensive reference information about all supported d ### Audio DSP Devices #### Generic Audio DSP (`genericAudioDsp`) + **Purpose**: Universal audio processor control with configurable level and routing blocks. **Key Properties**: + - `levelControlBlocks`: object - Named level control configurations - `presetCount`: number - Number of available presets - `supportsAdvancedRouting`: boolean - Matrix routing capabilities **Level Control Block Properties**: + - `enabled`: boolean - Whether this control is active - `hasLevel`: boolean - Volume level control available - `hasMute`: boolean - Mute control available @@ -83,6 +94,7 @@ This document provides comprehensive reference information about all supported d **Control Methods**: TCP/IP, RS-232 **Example Configuration**: + ```json { "type": "genericAudioDsp", @@ -109,9 +121,11 @@ This document provides comprehensive reference information about all supported d ``` #### Biamp Tesira DSP (`biampTesiraDsp`) + **Purpose**: Advanced control for Biamp Tesira series audio processors. **Key Properties**: + - `instanceTag`: string - Tesira instance identifier - `controlBlocks`: object - Named control block configurations - `supportsSubscriptions`: boolean - Real-time property subscriptions @@ -119,9 +133,11 @@ This document provides comprehensive reference information about all supported d **Control Methods**: TCP/IP (port 23) #### QSC Q-SYS Core (`qscQSysCore`) + **Purpose**: Control for QSC Q-SYS platform processors and components. **Key Properties**: + - `coreId`: string - Q-SYS Core identifier - `namedControls`: object - Named control configurations - `supportsExternalControl`: boolean - External control script support @@ -131,9 +147,11 @@ This document provides comprehensive reference information about all supported d ### Video Switching Devices #### Extron Video Switcher (`extronVideoSwitcher`) + **Purpose**: Control for Extron matrix switchers and presentation systems. **Key Properties**: + - `inputCount`: number - Number of inputs available - `outputCount`: number - Number of outputs available - `supportsAudioSwitching`: boolean - Audio switching capabilities @@ -142,6 +160,7 @@ This document provides comprehensive reference information about all supported d **Control Methods**: TCP/IP (port 23), RS-232 **Example Configuration**: + ```json { "type": "extronVideoSwitcher", @@ -155,9 +174,11 @@ This document provides comprehensive reference information about all supported d ``` #### Crestron DmMd Series (`crestronDmMd`) + **Purpose**: Control for Crestron DigitalMedia switcher series. **Key Properties**: + - `dmChassisId`: number - DM chassis ID for larger systems - `inputSlots`: array - Available input slot configurations - `outputSlots`: array - Available output slot configurations @@ -167,9 +188,11 @@ This document provides comprehensive reference information about all supported d ### Codec and Communication Devices #### Cisco Video Codec (`ciscoCodec`) + **Purpose**: Control for Cisco video conferencing systems (SX, MX, Room series). **Key Properties**: + - `sipPhoneLineKeys`: array - Available SIP line identifiers - `supportsContacts`: boolean - Contact directory integration - `maxCallCount`: number - Maximum simultaneous calls @@ -178,6 +201,7 @@ This document provides comprehensive reference information about all supported d **Control Methods**: SSH (port 22), HTTP API **Example Configuration**: + ```json { "type": "ciscoCodec", @@ -191,9 +215,11 @@ This document provides comprehensive reference information about all supported d ``` #### Polycom Group Series (`polycomGroupSeries`) + **Purpose**: Control for Polycom Group series video systems. **Key Properties**: + - `apiVersion`: string - Polycom API version (v1, v2) - `supportsDirectoryServices`: boolean - Directory integration - `audioInputCount`: number - Number of audio inputs @@ -201,9 +227,11 @@ This document provides comprehensive reference information about all supported d **Control Methods**: HTTP API (port 80/443) #### Generic VoIP Phone (`genericVoipPhone`) + **Purpose**: Basic control for SIP-based VoIP phones. **Key Properties**: + - `lineCount`: number - Number of phone lines - `supportsCallControl`: boolean - Call control capabilities - `registrarAddress`: string - SIP registrar server @@ -213,9 +241,11 @@ This document provides comprehensive reference information about all supported d ### Lighting Control Devices #### Lutron Quantum (`lutronQuantum`) + **Purpose**: Integration with Lutron Quantum lighting systems. **Key Properties**: + - `integrationId`: number - Quantum integration ID (1-100) - `zoneCount`: number - Number of lighting zones - `supportsShades`: boolean - Motorized shade control @@ -223,9 +253,11 @@ This document provides comprehensive reference information about all supported d **Control Methods**: TCP/IP (port 23) #### Generic Lighting Controller (`genericLighting`) + **Purpose**: Universal lighting control for various protocols. **Key Properties**: + - `channelCount`: number - Number of lighting channels - `supportsScenes`: boolean - Lighting scene recall - `dimmingCurve`: string - Dimming curve type (linear, logarithmic) @@ -235,9 +267,11 @@ This document provides comprehensive reference information about all supported d ### Environmental Control Devices #### Generic Climate Control (`genericClimate`) + **Purpose**: HVAC and environmental control integration. **Key Properties**: + - `supportsHeating`: boolean - Heating control available - `supportsCooling`: boolean - Cooling control available - `tempRange`: object - Temperature control range @@ -246,9 +280,11 @@ This document provides comprehensive reference information about all supported d **Control Methods**: TCP/IP, RS-232, BACnet #### Generic Relay Controller (`genericRelay`) + **Purpose**: Control for relay-based switching systems. **Key Properties**: + - `relayCount`: number - Number of available relays - `relayType`: string - Relay type (NO, NC, SPDT) - `pulseDurationMs`: number - Default pulse duration @@ -258,9 +294,11 @@ This document provides comprehensive reference information about all supported d ### Control System Devices #### Crestron 3-Series Processor (`crestron3Series`) + **Purpose**: Integration with Crestron 3-Series control processors. **Key Properties**: + - `eiscp`: object - EISCP server configuration - `cip`: object - CIP communication settings - `roomId`: number - Room ID for multi-room systems @@ -268,6 +306,7 @@ This document provides comprehensive reference information about all supported d **Control Methods**: CresNet, Ethernet (CIP) **Example Configuration**: + ```json { "type": "crestron3Series", @@ -281,9 +320,11 @@ This document provides comprehensive reference information about all supported d ``` #### Generic IR Controller (`genericIr`) + **Purpose**: Infrared control device integration. **Key Properties**: + - `irPorts`: array - Available IR port configurations - `supportsLearning`: boolean - IR learning capability - `carrierFrequency`: number - IR carrier frequency (Hz) @@ -293,9 +334,11 @@ This document provides comprehensive reference information about all supported d ### Camera and PTZ Devices #### Generic PTZ Camera (`genericPtzCamera`) + **Purpose**: Pan-tilt-zoom camera control. **Key Properties**: + - `presetCount`: number - Number of camera presets - `supportsAutoFocus`: boolean - Auto-focus capability - `zoomRange`: object - Optical zoom range @@ -304,6 +347,7 @@ This document provides comprehensive reference information about all supported d **Control Methods**: TCP/IP, RS-232, VISCA **Example Configuration**: + ```json { "type": "genericPtzCamera", @@ -326,6 +370,7 @@ This document provides comprehensive reference information about all supported d All network-connected devices support these communication properties: #### TCP/IP Properties (`tcpSshProperties`) + ```json { "address": "192.168.1.100", @@ -338,6 +383,7 @@ All network-connected devices support these communication properties: ``` #### Serial Properties (`comParams`) + ```json { "baudRate": 9600, @@ -352,6 +398,7 @@ All network-connected devices support these communication properties: ### Monitoring Properties #### Communication Monitor (`communicationMonitorProperties`) + ```json { "pollString": "?", @@ -365,6 +412,7 @@ All network-connected devices support these communication properties: ### Device Status Properties All devices provide these status indicators: + - **CommunicationMonitor**: Connection health (OK, Warning, Error) - **PowerIsOn**: Device power state (when applicable) - **IsOnline**: Network connectivity status @@ -377,13 +425,14 @@ All devices provide these status indicators: Each device type must be registered with a factory class: ```csharp -DeviceFactory.RegisterDeviceFactory("genericDisplay", +DeviceFactory.RegisterDeviceFactory("genericDisplay", typeof(GenericDisplayFactory)); ``` ### Properties Schema Validation Device factories define property schemas for validation: + - Required properties are enforced during configuration load - Property types are validated against expected types - Unknown properties generate warnings but don't prevent loading @@ -391,6 +440,7 @@ Device factories define property schemas for validation: ### Device Capabilities Devices expose capabilities through interfaces: + - `IBasicVolumeControls`: Volume and mute control - `IPower`: Power control with discrete on/off - `IRoutingInputsOutputs`: Signal routing capabilities @@ -415,6 +465,7 @@ Devices expose capabilities through interfaces: ### Property Validation Custom device types can implement property validation: + - Override `ValidateProperties()` method - Return validation messages for invalid configurations - Support both warnings and errors @@ -422,6 +473,7 @@ Custom device types can implement property validation: ### Device Communication Implement communication patterns: + - Override communication methods for device-specific protocols - Implement heartbeat and monitoring patterns - Handle connection state changes appropriately @@ -431,16 +483,19 @@ Implement communication patterns: ### Common Issues **Device Type Not Found**: + - Verify device type name matches factory registration exactly - Check that required assemblies are loaded - Review plugin loading and factory registration logs **Property Validation Errors**: + - Compare device properties against schema requirements - Check property data types and value ranges - Verify required properties are present **Communication Failures**: + - Verify network connectivity and device addresses - Check protocol-specific settings (ports, credentials) - Review device-specific communication requirements @@ -448,6 +503,7 @@ Implement communication patterns: ### Debugging Device Behavior Use the debug console to troubleshoot device issues: + 1. Monitor device initialization messages 2. Check communication monitor status changes 3. Review property change notifications @@ -455,4 +511,4 @@ Use the debug console to troubleshoot device issues: --- -*This reference provides complete information about all supported device types and their configuration requirements. Use it to understand device capabilities and configure devices properly in your system.* +_This reference provides complete information about all supported device types and their configuration requirements. Use it to understand device capabilities and configure devices properly in your system._ diff --git a/docs/reference/log-levels.md b/docs/reference/log-levels.md index 758a1f7..fe14066 100644 --- a/docs/reference/log-levels.md +++ b/docs/reference/log-levels.md @@ -6,33 +6,37 @@ ### Severity Levels (Most to Least Critical) -| Level | Numeric Value | Purpose | Usage Guidelines | -|-------|---------------|---------|------------------| -| Fatal | 5 | System cannot continue | Application crashes, critical failures | -| Error | 4 | Error conditions | Device failures, communication errors | -| Warning | 3 | Warning conditions | Potential issues, deprecated usage | -| Information | 2 | General information | Normal operations, status changes | -| Debug | 1 | Detailed debug info | Technical details for developers | -| Verbose | 0 | Trace information | Extremely detailed execution traces | +| Level | Numeric Value | Purpose | Usage Guidelines | +| ----------- | ------------- | ---------------------- | -------------------------------------- | +| Fatal | 5 | System cannot continue | Application crashes, critical failures | +| Error | 4 | Error conditions | Device failures, communication errors | +| Warning | 3 | Warning conditions | Potential issues, deprecated usage | +| Information | 2 | General information | Normal operations, status changes | +| Debug | 1 | Detailed debug info | Technical details for developers | +| Verbose | 0 | Trace information | Extremely detailed execution traces | ### Level Selection Impact **When setting minimum log level to "Warning":** + - **Captured**: Fatal, Error, Warning messages - **Filtered out**: Information, Debug, Verbose messages - **Use case**: Problem identification, production monitoring **When setting minimum log level to "Information":** -- **Captured**: Fatal, Error, Warning, Information messages + +- **Captured**: Fatal, Error, Warning, Information messages - **Filtered out**: Debug, Verbose messages - **Use case**: Normal system monitoring, general troubleshooting **When setting minimum log level to "Debug":** + - **Captured**: All messages except Verbose - **Filtered out**: Only Verbose messages - **Use case**: Detailed troubleshooting, development **When setting minimum log level to "Verbose":** + - **Captured**: All messages - **Filtered out**: None - **Use case**: Deep debugging, code-level analysis @@ -49,14 +53,14 @@ Each device checked in the Debug Console Devices dropdown has its own minimum lo where `LOG_LEVEL_ORDER` is: -| Level | Order Value | -|-------|-------------| -| Verbose | 0 | -| Debug | 1 | -| Information | 2 | -| Warning | 3 | -| Error | 4 | -| Fatal | 5 | +| Level | Order Value | +| ----------- | ----------- | +| Verbose | 0 | +| Debug | 1 | +| Information | 2 | +| Warning | 3 | +| Error | 4 | +| Fatal | 5 | **Example**: Device set to `Warning` β€” shows Warning, Error, Fatal; hides Information, Debug, Verbose @@ -65,6 +69,7 @@ where `LOG_LEVEL_ORDER` is: ### Filter Combination Logic All client-side filters use AND logic: + - **Device filter**: Message key must match a checked device (or `Global`) - **Per-device minimum level**: Message severity must meet the device's threshold - **Text search**: All search terms must appear somewhere in the message fields @@ -76,12 +81,14 @@ The **Minimum Log Level** dropdown in the Debug Console session panel sets the s ### System-Level Messages **Global Messages** (Key: "global" or empty): + - System startup and shutdown events - Framework-level operations - Cross-device operations - Resource allocation and management **Example Global Messages**: + ``` Information: System startup complete Warning: High memory usage detected @@ -92,12 +99,14 @@ Information: Device initialization sequence started ### Device-Specific Messages **Device Messages** (Key: Device identifier): + - Individual device operations - Communication events - Status changes - Error conditions **Example Device Messages**: + ``` Information: Device [Display-Room1] connection established Warning: Device [Display-Room1] response timeout @@ -110,6 +119,7 @@ Debug: Device [Display-Room1] sending command: Power On ### Standard Message Format **Core Fields** (Present in all messages): + ```json { "Timestamp": "ISO 8601 formatted timestamp", @@ -123,29 +133,34 @@ Debug: Device [Display-Room1] sending command: Power On **Field Descriptions**: **Timestamp**: + - Format: ISO 8601 with milliseconds - Example: `"2024-01-15T10:30:45.123Z"` - Timezone: UTC - Precision: Millisecond accuracy **MessageTemplate**: + - Format: String with placeholder syntax - Example: `"Device {Key} power state changed to {State}"` - Placeholders: Use curly brace syntax `{PropertyName}` - Purpose: Allows structured logging and analysis **RenderedMessage**: + - Format: Final human-readable message - Example: `"Device Display-Room1 power state changed to On"` - Content: Template with placeholders replaced by actual values - Purpose: Direct display to users **Level**: + - Values: One of the defined log levels (Fatal, Error, Warning, Information, Debug, Verbose) - Case: Exact case as defined in hierarchy - Purpose: Message classification and filtering **Properties**: + - Format: JSON object with key-value pairs - Content: Structured data related to the message - Optional: May be null or empty for simple messages @@ -154,15 +169,17 @@ Debug: Device [Display-Room1] sending command: Power On ### Common Property Fields **Device-Related Properties**: + ```json { "Key": "Display-Room1", - "DeviceType": "samsungMDC", + "DeviceType": "samsungMDC", "DeviceName": "Conference Room Display" } ``` **Command-Related Properties**: + ```json { "CommandType": "PowerOn", @@ -172,6 +189,7 @@ Debug: Device [Display-Room1] sending command: Power On ``` **Error-Related Properties**: + ```json { "ErrorCode": "ConnectionTimeout", @@ -181,6 +199,7 @@ Debug: Device [Display-Room1] sending command: Power On ``` **System-Related Properties**: + ```json { "SourceContext": "PepperDash.Essentials.Core.DeviceManager", @@ -194,16 +213,19 @@ Debug: Device [Display-Room1] sending command: Power On ### Device Filtering **Global Filter**: + - **Matches**: Messages with no Key property or Key = null - **Content**: System-wide events, framework operations - **Usage**: System health monitoring, startup/shutdown events **Device-Specific Filters**: + - **Matches**: Messages where Properties.Key matches selected device key - **Content**: All messages from that specific device - **Usage**: Device-specific troubleshooting **Multiple Device Filters**: + - **Logic**: OR operation (matches any selected device) - **Behavior**: Shows messages from any of the selected devices - **Usage**: Comparing behavior between related devices @@ -211,10 +233,12 @@ Debug: Device [Display-Room1] sending command: Power On ### Log Level Filtering **Single Level Selection**: + - Shows only messages at the selected severity level - Example: Selecting only "Error" shows error messages only **Multiple Level Selection**: + - **Logic**: OR operation (matches any selected level) - **Common combinations**: - Error + Warning: Problem identification @@ -224,32 +248,37 @@ Debug: Device [Display-Room1] sending command: Power On ### Text Search Filtering **Search Behavior**: + - **Case insensitive**: "ERROR" matches "error" and "Error" - **Partial matching**: "conn" matches "connection", "connected", "disconnect" - **Multiple terms**: Space-separated terms use AND logic - **Target fields**: MessageTemplate and RenderedMessage **Search Targets**: + - **MessageTemplate**: Raw template text with placeholders - **RenderedMessage**: Final formatted message text - **Not searched**: Properties object, Timestamp, Level **Search Examples**: -| Search Term | Matches | Usage | -|-------------|---------|-------| -| `power` | Any message containing "power" | Power-related events | -| `timeout` | Any message containing "timeout" | Communication timeouts | -| `display power` | Messages containing both "display" AND "power" | Display power events | -| `button press` | Messages containing both "button" AND "press" | Button interactions | + +| Search Term | Matches | Usage | +| --------------- | ---------------------------------------------- | ---------------------- | +| `power` | Any message containing "power" | Power-related events | +| `timeout` | Any message containing "timeout" | Communication timeouts | +| `display power` | Messages containing both "display" AND "power" | Display power events | +| `button press` | Messages containing both "button" AND "press" | Button interactions | ### Filter Combination Logic **Multiple Filters Applied**: + - **Logic**: AND operation (all conditions must match) - **Example**: Device="Display-Room1" AND Level="Error" AND Search="power" - **Result**: Only error-level power-related messages from Display-Room1 **Filter Priority**: + 1. **Minimum log level**: Applied first (server-side) 2. **Device filter**: Applied to client-side message list 3. **Log level filter**: Applied to remaining messages @@ -260,6 +289,7 @@ Debug: Device [Display-Room1] sending command: Power On ### Normal Message Rates by Level **Production System** (per minute): + - **Fatal**: 0 (should never occur in normal operation) - **Error**: 0-2 (occasional errors acceptable) - **Warning**: 1-10 (depends on system configuration) @@ -268,6 +298,7 @@ Debug: Device [Display-Room1] sending command: Power On - **Verbose**: 100-1000+ (if enabled, very high volume) **Development/Testing System** (per minute): + - Higher rates acceptable across all levels - Debug and Verbose levels commonly used - Error rates may be higher during testing @@ -275,17 +306,20 @@ Debug: Device [Display-Room1] sending command: Power On ### Volume Impact on Performance **Browser Performance**: + - **<100 messages/minute**: No noticeable impact - **100-500 messages/minute**: Slight impact on scrolling - **500+ messages/minute**: May cause browser lag - **1000+ messages/minute**: Significant performance impact **Network Impact**: + - **Average message size**: 200-500 bytes - **High volume impact**: Can consume significant bandwidth - **Multiple sessions**: Multiplies network load on processor **Processor Impact**: + - **Debug sessions**: Consume processor resources - **Multiple sessions**: Significant impact on system performance - **High log levels**: Verbose/Debug logging impacts all operations @@ -295,6 +329,7 @@ Debug: Device [Display-Room1] sending command: Power On ### Common Filter Combinations **Problem Identification**: + ``` Log Levels: Error + Warning Device: All or problematic area @@ -302,6 +337,7 @@ Search: (none initially, add specific terms as needed) ``` **Device Troubleshooting**: + ``` Log Levels: Information + Warning + Error Device: Specific device only @@ -309,6 +345,7 @@ Search: Terms related to suspected issue ``` **System Health Monitoring**: + ``` Log Levels: Warning + Error + Fatal Device: Global + critical devices @@ -316,13 +353,15 @@ Search: "timeout", "failed", "error" ``` **User Interaction Tracing**: + ``` Log Levels: Information + Warning + Error Device: Control panels + target devices Search: "button", "press", "command" ``` -**Network Issue Investigation**: +**Network Issue Investigation**: + ``` Log Levels: Warning + Error Device: All network-connected devices @@ -332,6 +371,7 @@ Search: "connection", "timeout", "network" ### Performance Optimization Filters **High-Traffic Monitoring**: + ``` Minimum Log Level: Warning (server-side) Log Levels: Warning + Error (client-side) @@ -340,9 +380,10 @@ Search: Specific error terms ``` **Baseline Performance Monitoring**: + ``` Minimum Log Level: Information (server-side) -Log Levels: Information + Warning + Error (client-side) +Log Levels: Information + Warning + Error (client-side) Device: Global only Search: (none) ``` @@ -352,6 +393,7 @@ Search: (none) ### Healthy System Patterns **Startup Sequence**: + ``` Information: System initializing Information: Loading configuration @@ -362,14 +404,16 @@ Information: System startup complete ``` **Normal Operation**: + ``` Information: Button [ButtonName] pressed -Information: Command sent to [DeviceKey]: [Command] +Information: Command sent to [DeviceKey]: [Command] Information: Device [DeviceKey] acknowledged command Information: Device [DeviceKey] status updated ``` **Clean Shutdown**: + ``` Information: System shutdown initiated Information: Device [DeviceKey] disconnecting @@ -380,6 +424,7 @@ Information: System shutdown complete ### Problem Patterns **Connection Issues**: + ``` Warning: Device [DeviceKey] connection timeout Warning: Device [DeviceKey] attempting reconnection @@ -388,6 +433,7 @@ Warning: Device [DeviceKey] attempting reconnection ``` **Command Failures**: + ``` Information: Command sent to [DeviceKey]: [Command] Warning: Device [DeviceKey] no response to command @@ -396,6 +442,7 @@ Warning: Device [DeviceKey] retrying command ``` **System Resource Issues**: + ``` Warning: High memory usage detected Warning: Thread pool exhaustion @@ -408,12 +455,14 @@ Fatal: System cannot continue ### RTK Query Integration **Log Level Filter Hook**: + ```typescript const { data: logLevels } = useGetMinimumLogLevelQuery(); const [setLogLevel] = useSetMinimumLogLevelMutation(); ``` **Filter State Management**: + ```typescript const [searchParams, setSearchParams] = useSearchParams(); const deviceFilters = searchParams.getAll('device'); @@ -422,23 +471,26 @@ const searchTerms = searchParams.getAll('searchText'); ``` **Message Filtering Logic**: + ```typescript const filteredMessages = useMemo(() => { - return messages.filter(message => { + return messages.filter((message) => { // Device filter - const deviceMatch = !deviceFilters.length || + const deviceMatch = + !deviceFilters.length || deviceFilters.includes(message.Properties?.Key || 'global'); - - // Log level filter - const levelMatch = !logLevelFilters.length || - logLevelFilters.includes(message.Level); - + + // Log level filter + const levelMatch = + !logLevelFilters.length || logLevelFilters.includes(message.Level); + // Text search - const textMatch = !searchTerms.length || - searchTerms.every(term => + const textMatch = + !searchTerms.length || + searchTerms.every((term) => message.RenderedMessage.toLowerCase().includes(term.toLowerCase()) ); - + return deviceMatch && levelMatch && textMatch; }); }, [messages, deviceFilters, logLevelFilters, searchTerms]); diff --git a/docs/reference/ui-components.md b/docs/reference/ui-components.md index 3bd0bd4..425e2ff 100644 --- a/docs/reference/ui-components.md +++ b/docs/reference/ui-components.md @@ -13,25 +13,30 @@ This document provides detailed information about every UI element, its purpose, **Purpose**: Primary navigation between application sections **Elements**: + - **Brand**: "Essentials Debugger" - Always visible, non-clickable - **Navigation Links**: Six main sections accessible via top menu **Navigation Links**: -| Link | Route | Purpose | -|------|-------|---------| -| Home | `/home` | Welcome page and application starting point | -| Debug Console | `/console` | Real-time system monitoring and debugging | -| Versions | `/versions` | View loaded assemblies and version information | -| Config File | `/config` | View complete merged configuration | -| Devices | `/devices` | Browse and inspect configured devices | -| Types | `/types` | View available device types and descriptions | + +| Link | Route | Purpose | +| ------------- | ----------- | ----------------------------------------------------- | +| Home | `/home` | Welcome page and application starting point | +| Debug Console | `/console` | Real-time system monitoring and debugging | +| Versions | `/versions` | View loaded assemblies and version information | +| Secrets | `/secrets` | View and manage stored credentials (capability-gated) | +| Config File | `/config` | View complete merged configuration | +| Devices | `/devices` | Browse and inspect configured devices | +| Types | `/types` | View available device types and descriptions | **Visual States**: + - **Active**: Link text shows in secondary color when on that page - **Inactive**: Standard link appearance - **Hover**: Standard link hover effect **Responsive Behavior**: + - Collapses to hamburger menu on mobile devices - Maintains horizontal layout on desktop and tablet @@ -45,25 +50,30 @@ This document provides detailed information about every UI element, its purpose, **Purpose**: Control debug session state and system operations #### Start/Stop Session Buttons + - **Start Debug Session**: Initiates WebSocket connection for real-time messages - **Stop Debug Session**: Closes WebSocket connection and stops message flow - **Visual State**: Blue primary buttons, disabled when appropriate #### System Control Buttons + - **Load Config**: Manually reload configuration (only available when "Do Not Load Config" is checked) - **Restart Program**: Complete system restart with confirmation modal #### Configuration Controls + - **Do Not Load Config on Next Boot**: Checkbox to control configuration loading behavior - **State**: Checked = don't load config, Unchecked = load config normally #### Minimum Log Level Dropdown + - **Purpose**: Set minimum severity level for captured messages - **Options**: Verbose, Debug, Information, Warning, Error, Fatal - **Default**: Information - **Behavior**: Dropdown updates immediately, affects new messages only #### Message Counter + - **Display**: "Message Count: [number]" - **Purpose**: Shows total messages received in current session - **Updates**: Real-time during active debug session @@ -71,31 +81,37 @@ This document provides detailed information about every UI element, its purpose, ### Filtering Components #### Search Box + **Component**: `FilterSearchText` **Purpose**: Free-text search within debug messages **Behavior**: + - **Debounce**: 1-second delay before applying search - **Multiple terms**: Space-separated terms use AND logic - **Case insensitive**: Searches are not case-sensitive - **Partial matching**: Finds partial word matches **Search targets**: + - Message template text - Rendered message text - Device keys and names #### Device Filter Dropdown + **Component**: `DeviceFilterDropdown` **Purpose**: Filter messages by source device and set a per-device minimum log level **Options**: + - **Global**: System-wide messages not tied to specific devices - **Device entries**: All configured devices, sorted alphabetically by name (or key when no name is set) - **Multiple selection**: Checkbox interface allows multiple devices - **Badge indicator**: Shows count of checked devices **Per-Device Minimum Log Level**: + - When a device is checked, a level dropdown appears inline next to its name - Default level when first checked: `Information` - Available levels: Fatal, Error, Warning, Information, Debug, Verbose @@ -107,9 +123,11 @@ This document provides detailed information about every UI element, its purpose, **State**: Stored in Redux `debugConsole` slice (`checkedDevices` and `deviceLevels`); persists across route navigation **Filter Combined With**: + - Text search (AND logic: both conditions must be satisfied) #### Clear Filters Button + **Purpose**: Reset all debug console filters (checked devices, per-device levels, and search text) to their initial empty state **Trigger**: "Clear Filters" button in the debug filters toolbar @@ -120,19 +138,23 @@ This document provides detailed information about every UI element, its purpose, ## API Paths Components ### API Paths Table + **Location**: API Paths page (`/:appId/apiPaths`) **Purpose**: Display all available REST API routes exposed by the processor **Layout**: + - **Two columns**: Name and URL - **Sortable**: Alphabetically sorted by route name - **Striped rows**: Bootstrap `table-striped` styling **Interaction**: + - **Click row**: Selects the route and opens the detail drawer - **Selected row**: Highlighted with `table-primary` ### API Path Detail Drawer + **Component**: `ApiPathDetailDrawer` **Purpose**: Show complete detail for a selected API route @@ -140,11 +162,13 @@ This document provides detailed information about every UI element, its purpose, **Trigger**: Click on any route row **Content Sections**: + 1. **Name**: Route name 2. **URL**: Full URL as a clickable link (opens in new tab) 3. **Data Token Name**: Shown only when present **Behavior**: + - No backdrop (main content remains interactive) - Close button dismisses the drawer @@ -153,74 +177,179 @@ This document provides detailed information about every UI element, its purpose, ## Routing Components ### Routing Diagram + +**Component**: `Routing` **Location**: Routing page (`/:appId/routing`) -**Purpose**: Visual signal routing diagram showing devices, ports, and tie lines +**Purpose**: Interactive, auto-laid-out signal routing diagram showing devices, ports, tie lines, and (on supported systems) live current-source feedback + +**Technology**: React Flow (`@xyflow/react`) canvas with Dagre auto-layout (left-to-right column layout, recomputed whenever the device/filter set changes; manually dragged node positions are preserved across live feedback updates) -**Technology**: React Flow (@xyflow/react) with Dagre auto-layout +**Version requirements**: + +| PepperDashEssentials.dll version | Behavior | +| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| < 2.29 | Routing page shows "Routing feature is not available for this version." | +| 2.29 – 2.x | Static diagram only: devices, ports, and tie lines render, but there is no live current-source feedback, no internal route curves, no Live/Offline badge activity, and no multiview layout panels | +| 3.0+ | Full functionality: live WebSocket feedback, signal path tracing, and multiview layout panels (all described below) | +| 3.0+ with the `routingCommand` endpoint | Adds route editing (see **Route Popover** below). Detected by probing the processor's live CWS route table via `apiPaths` rather than by version number, so the affordances appear only where the endpoint actually exists | **Elements**: -- **Device Nodes**: Each routing device shown as a card with input and output ports -- **Tie Line Edges**: Connections between device ports, color-coded by signal type -- **MiniMap**: Overview map for orientation in large diagrams -- **Controls**: Zoom and pan controls + +- **Device Nodes**: Each routing device shown as a card with input ports listed on the left and output ports on the right (see **Device Node Card** below) +- **Tie Line Edges**: Connections between device ports, color-coded by signal type (see **Tie Line Edges** below) +- **MiniMap**: Overview map (bottom-right) for orientation in large diagrams +- **Controls**: Zoom and pan controls (bottom-left) +- **Multiview Layout Panels**: Optional floating windows showing a device's current tile layout (see below) **Signal Type Colors**: -| Signal Type | Color | -|-------------|-------| -| AudioVideo | Purple (#6f42c1) | -| Video | Blue (#0d6efd) | -| Audio | Red (#dc3545) | -| UsbOutput / UsbInput | Orange (#fd7e14) | -**Filtering**: -- A signal type dropdown allows filtering visible tie lines by signal type +| Signal Type | Color | +| ------------------------------------------ | ----------------------- | +| AudioVideo | Purple (#6f42c1) | +| Video | Blue (#0d6efd) | +| Audio / Audio, SecondaryAudio | Red (#dc3545) | +| UsbOutput / UsbInput / UsbOutput, UsbInput | Orange (#fd7e14) | +| Any other signal type | Gray (#adb5bd) fallback | + +### Toolbar + +A single toolbar strip above the diagram canvas holds all filtering and connection controls: + +- **Signal type toggle buttons**: One button per signal type present in the system, color-coded to match its edges. Click a button to hide every tie line of that type; click again to show it. This replaces the "signal type dropdown" of earlier versions. +- **Devices filter dropdown**: Shows "Devices (visible/total)". Opens a menu with: + - A search box to filter the device list by name or key + - **Select all** / **Deselect all** links + - A checkbox per device (checked = visible); unchecking hides that device and its edges from the canvas + - The dropdown button turns amber/warning-colored whenever at least one device is hidden +- **Hide unconnected devices** switch: When on, devices with no visible tie line endpoint (after other filters are applied) are removed from the canvas entirely +- **Hide unconnected ports** switch: When on, each remaining device card only shows the input/output ports that currently have a visible tie line attached, instead of every port the device exposes +- **Dark mode** switch: Toggles the canvas and device node cards between dark and light styling (on by default) +- **Live/Offline badge**: Green "Live" when the routing feedback WebSocket is connected, gray "Offline" otherwise. Only relevant on PepperDashEssentials.dll 3.0+; earlier versions always show "Offline" since there is no live feedback to connect to +- **Refresh button** (circular-arrow icon): Re-fetches the routing devices/tie-lines snapshot over HTTP and forces the feedback WebSocket to disconnect and reconnect. Use this if the "Live" badge appears stuck on "Offline" or after the routing feedback server has restarted + +**Certificate warning**: If the feedback WebSocket fails to connect because its host uses an untrusted/self-signed certificate, a warning banner appears above the toolbar with a link to open that host's URL directly (to accept the certificate) before reloading the page. + +### Device Node Card + +Each device is drawn as a card with: + +- **Header**: The device's display name (falls back to its key if unnamed), with the key shown as a smaller subtitle when a name is present. Hovering shows the key as a tooltip. +- **Layout toggle button** (small tile icon, header, only shown for devices with an active multiview canvas): Opens/closes a floating **Multiview Layout Panel** for that device (see below) +- **Hide button** (`Γ—`, header): Hides just this one device from the canvas (equivalent to unchecking it in the Devices filter dropdown) +- **Port rows**: Input ports listed on the left edge, output ports on the right edge, one row per port. Hovering a port label shows its signal type as a tooltip. Multiview tile ports (keyed `tile{N}:...` on the parent device) are labeled `Tile N`, with the full qualified key in the tooltip +- **Clickable port rows** (route editing only): Where route editing is available, input ports on route destinations and output ports on midpoints render as buttons that outline blue on hover and open the **Route Popover**. Clicking a port does not also trigger signal path tracing, drag the node, or pan the canvas. Source ports and midpoint _input_ ports are never clickable β€” routing is always driven from the destination or midpoint-output end +- **Port status indicators** (route editing only): A pulsing blue dot marks a port whose route command has been accepted by the processor but not yet confirmed over the feedback WebSocket. An amber `!` replaces it if no confirmation arrives within ~10 seconds, and clears itself a few seconds later +- **Internal route curves**: On PepperDashEssentials.dll 3.0+, an SVG overlay draws a curve from each currently-active input port to its routed output port inside the card, color-coded by signal type. When a signal path is selected elsewhere in the diagram (see **Signal Path Tracing**), curves that are part of the selected path stay highlighted while all others dim + +### Tie Line Edges + +- Edges are color-coded by signal type using the palette above +- **Dashed edges** represent "live-only" routes: connections reported by live feedback (e.g. a route made through a device-specific bulk API such as a dynamic multiview layout) that have no corresponding static tie line in the configuration. Solid edges are real configured tie lines +- **Hover** an edge to show a tooltip with its signal type, source device/port, and destination device/port +- **Click** an edge to select it (see **Signal Path Tracing**); click it again, or click empty canvas space, to clear the selection + +### Signal Path Tracing + +Clicking a tie line edge, a device node, or a tile inside a Multiview Layout Panel highlights the complete signal path and dims everything else, using the live `currentRoutes`/`sinkCurrentSources` feedback (3.0+ only β€” tracing has no effect on older versions since there is no live route data to trace): + +- **Click a tie line edge**: Traces upstream and downstream from that edge through every switching (midpoint) device in the path, highlighting every tie line and internal route curve that carries the same signal end-to-end +- **Click a device node**: Traces every signal path currently passing into or out of that device +- **Click a tile in a Multiview Layout Panel**: Traces the path feeding that specific tile +- Clicking the same selection again, or clicking empty canvas space, clears the highlight +- While a path is selected, non-path edges and internal route curves are dimmed and thinned so the active path stands out + +### Multiview Layout Panels + +For devices that implement a multiview/window layout (e.g. a multiview decoder), the layout toggle button on the device card opens a **floating, freely-draggable panel** showing a scaled mock-up of what that device is actually displaying: + +- The panel renders the canvas at its real aspect ratio, with one rectangle per tile positioned and sized to match the live layout, labeled with the tile number and the name of the source currently routed to it +- **Drag** the panel's title bar to reposition it anywhere on screen; its position is independent of the graph layout, so it doesn't move when the diagram re-lays-out +- **Click a tile** to highlight the full signal path feeding it (see **Signal Path Tracing**) β€” the tile itself is also highlighted while its path is selected +- **Tile edit badge** (small pencil icon, tile corner, route editing only): Appears on hover and opens the **Route Popover** for that tile. This is equivalent to clicking the tile's `Tile N` port row on the device node; both resolve to the same qualified port +- Multiple panels (one per device) can be open and positioned independently at the same time +- Click the panel's `Γ—` to close it; closed panels stay closed until the layout toggle button is clicked again + +### Route Popover + +**Component**: `RoutePopover` +**Purpose**: Make or clear a route from the diagram. Opened by clicking a clickable port row on a device node, or a tile's edit badge in a Multiview Layout Panel. + +Rendered through a portal into `document.body` and positioned against the clicked element's viewport rect, so it is never clipped by a device card and never scales with the canvas zoom. It closes on outside click, `Escape`, its own `Γ—`, a successful commit, and any canvas pan, zoom, or node drag. + +**Two flows**, determined by what was clicked: + +| Clicked | Step 1 | Step 2 | Effect | +| --------------------------------- | ----------- | ----------------------------- | ---------------------------------------------------------------------------------------------------- | +| Input port on a route destination | Signal type | Source device | Routes the source through every midpoint in the discovered path and switches the destination's input | +| Output port on a midpoint | Signal type | Input port on the same device | Switches that one device only | + +**Behavior**: + +- **Signal type step**: Offers the port's declared type plus each individual flag as a breakaway option (`AudioVideo` β†’ `AudioVideo`, `Audio`, `Video`). Collapses to a static label when the port carries a single type. Buttons use the same color coding as the toolbar's signal type toggles +- **Source list**: Computed client-side by walking the tie-line graph backwards from the clicked port, mirroring the processor's own path-finding rules β€” so only sources with a real physical path for that signal type are listed. Pure sources sort first, then by name. This uses the complete, unfiltered tie-line set: toolbar filters change the view, never what is routable +- **Partial-match badge**: A source with a path for only one half of an `AudioVideo` request is badged `video only` / `audio only`. It remains selectable, since the processor routes whichever half has a path +- **Current selection**: The source (or input port) currently routed to that port is marked with a check. For a destination this is derived by tracing back over the tie lines using each midpoint's live route feedback, not by reading the sink's own current-source bookkeeping β€” the processor only updates that bookkeeping on a graph-level route, so switching a midpoint directly would otherwise leave it stale. The bookkeeping is used as a fallback only where there are no tie lines to trace +- **None β€” clear route**: Always the first option. On a destination this tears down the path _and_ deselects the destination's own input; on a midpoint output it clears just that output +- **Filter box**: Appears once the option list exceeds 12 entries +- **Errors**: A failed command keeps the popover open with the processor's reason inline, so a different option can be chosen without reopening + +**Route roles**: which ports are clickable is derived from the device flags in the routing graph API β€” midpoints (`hasInputsAndOutputs`) expose clickable outputs; route destinations (`hasInputs` and _not_ `hasInputsAndOutputs`, which covers both pure sinks and multiview parents) expose clickable inputs. --- ### Message Display Components #### Message List Container + **Component**: `ConsoleWindow` **Purpose**: Display filtered debug messages in tabular format **Layout**: + - **Header row**: Fixed column headers (Timestamp, Key, Level, Message) - **Message rows**: Scrollable list of debug messages - **Responsive**: Columns adjust for different screen sizes **Column Details**: -| Column | Width | Content | Behavior | -|--------|-------|---------|----------| -| Timestamp | 6 units | Full timestamp with milliseconds | Fixed width, truncated if needed | -| Key | 3 units | Device key or "global" | Fixed width | -| Level | 2 units | Log severity level | Fixed width | -| Message | 13 units | Rendered message text | Truncated with ellipsis, expandable on click | + +| Column | Width | Content | Behavior | +| --------- | -------- | -------------------------------- | -------------------------------------------- | +| Timestamp | 6 units | Full timestamp with milliseconds | Fixed width, truncated if needed | +| Key | 3 units | Device key or "global" | Fixed width | +| Level | 2 units | Log severity level | Fixed width | +| Message | 13 units | Rendered message text | Truncated with ellipsis, expandable on click | #### Individual Message Rows + **Visual States**: + - **Default**: Standard row appearance - **Hover**: Subtle background highlight - **Selected**: Primary color background with white text - **Clickable**: Cursor changes to pointer **Interaction**: + - **Click**: Opens message detail drawer - **Selection**: Only one message can be selected at a time - **Keyboard**: Not currently supported #### Scroll Container + **Component**: `ScrollToBottom` **Purpose**: Auto-scroll to newest messages **Features**: + - **Auto-follow**: Automatically scrolls to bottom for new messages -- **Manual control**: User can scroll up to view history +- **Manual control**: User can scroll up to view history - **Follow button**: Appears when user scrolls up, click to resume auto-follow - **Performance**: Optimized for high message volumes ### Message Detail Components #### Message Detail Drawer + **Component**: `LogMessageDetailDrawer` **Purpose**: Show complete information for selected message @@ -228,42 +357,145 @@ This document provides detailed information about every UI element, its purpose, **Trigger**: Click on any message row **Content Sections**: + 1. **Timestamp**: Full timestamp with timezone information 2. **Rendered Message**: Complete formatted message text 3. **Message Template**: Raw message template with placeholders 4. **Properties**: JSON-formatted structured data **Behavior**: + - **Overlay**: Appears over main content, does not push content aside - **Backdrop**: No backdrop (can interact with main content) - **Close methods**: Close button (X) or programmatic close - **Responsive**: Adjusts width on mobile devices **Properties Display**: + - **Format**: JSON with syntax highlighting and indentation - **Content**: All structured data associated with the message - **Common fields**: Key, SourceContext, CommandType, etc. --- +## Secrets Components + +### Secrets Page + +**Component**: `Secrets` +**Location**: Secrets page (`/:appId/secrets`) +**Purpose**: View which credentials the processor has stored, add/replace/delete them, and apply or export a bulk file + +**Availability**: The page and its nav entry are gated on capability detection β€” `supportsSecretsApi` +matches a whole `secrets` path segment in the processor's live CWS route table (`apiPaths`), rather +than on a version number. On a processor without the API the nav entry is absent and a direct link +renders an explanation. The whole-segment match is deliberately stricter than the routing feature's +substring check, because `secrets` collides far more easily than `routingCommand`. + +**Values are never displayed.** No endpoint returns a stored value, so the page can show that a +secret exists and replace it, but never reveal it. A persistent notice states this. + +**Elements**: + +- **Provider selector**: `default` (this program slot) or `CrestronGlobalSecrets` (shared across + slots), populated from the providers endpoint +- **Filter**: matches key and description +- **Download template** / **Apply file…**: the bulk workflow +- **Managed secrets table**: Key Β· Description Β· Updated Β· actions, with `Add +` in the last header + cell (matching the Mobile Control convention). Per row: **Replace value** and **Delete** +- **Other data store records table**: records that exist but were not created here, each badged + _Not managed here_, shown with Owner and Modified. **No Replace button**, and deletion requires + typing the key +- **Index-health banner**: shown when `indexStatus` is not `ok`, stating that classification is + unavailable but the secrets themselves are unaffected +- **Incomplete-listing banner**: shown when `enumerationComplete` is false + +### Secret Edit Modal + +**Component**: `SecretEditModal` + +Add or replace one secret. Provider and key are locked when replacing. The value field reuses the +existing `EyeIcon` show/hide toggle from the login form, with `autoComplete="off"` throughout so the +browser never offers to save the credential. Key and value length limits (32 / 1600) are enforced +inline against the Crestron Data Store caps. + +Collision handling is driven by the already-loaded list: a key matching an existing **managed** entry +shows a warning and sends `overwrite`; one matching an **unmanaged** entry blocks the submit behind a +separate acknowledgement checkbox. + +### Secret Delete Modal + +**Component**: `SecretDeleteModal` + +Confirms deletion. For an **unmanaged** record it switches to a strict mode requiring the exact key +to be typed β€” Mobile Control stores its paired-client tokens in the same flat Data Store, and +deleting that record silently un-pairs every touchpanel. + +### Bulk Apply Modal + +**Component**: `BulkApplyModal` + +A four-stage flow: choose β†’ preview β†’ confirm β†’ done. + +- The drop zone wraps a real ``, so keyboard and screen-reader + users get the same affordance; dragging is layered on top +- While open, window-level `dragover`/`drop` handlers call `preventDefault()`. Without them a drop + that misses the zone makes the browser navigate to the file β€” destroying the page and putting a + credential file in the address bar and history +- Files are validated **client-side first** (extension, 256 KB size cap, JSON shape, per-entry rules) + before any request. `JSON.parse` error text is never surfaced, because V8 embeds a snippet of the + offending source in it β€” which for a secrets file is a live credential +- The processor is then asked for a **preview**, which writes nothing +- Parsed entries live in local component state and never enter Redux, keeping plaintext values out + of the store and out of Redux DevTools +- The backdrop becomes static once a file is loaded, so a stray click cannot discard a reviewed batch +- On commit the parsed values and the file input are cleared immediately + +### Bulk Preview Table + +**Component**: `BulkPreviewTable` + +Presentational only β€” no store access β€” which is what lets it be tested directly. One row per entry +with an action badge: + +| Action | Badge | Meaning | +| --------- | ----- | ----------------------------------------- | +| Create | green | Key does not exist yet | +| Overwrite | amber | Exists and will be replaced | +| Skip | grey | Exists and will be left alone | +| Invalid | red | Rejected; will not be written | +| Failed | red | The store refused the write (commit only) | + +An entry that would overwrite a record the tool does not manage gets an additional _not managed here_ +badge, and the Apply button then requires an explicit acknowledgement. + +The **Replace secrets that already exist** switch re-runs the preview, so what is shown always matches +the flag that would actually be sent. + +--- + ## Configuration Display Components ### Configuration Viewer + **Location**: Config File page **Purpose**: Display complete merged configuration **Display Format**: + - **JSON**: Pretty-printed with proper indentation - **Monospace font**: Fixed-width font for code readability - **Scrollable**: Full height scrolling for large configurations - **Selectable**: Text can be selected and copied **Loading States**: + - **Loading**: Shows "Loading..." text while fetching configuration - **Loaded**: Full configuration display - **Error**: Error message if configuration cannot be loaded **Performance**: + - **Large files**: Handles configurations with thousands of lines - **Memory efficient**: Uses browser's native text rendering - **Search**: Browser's built-in search (Ctrl+F) works within configuration @@ -273,30 +505,36 @@ This document provides detailed information about every UI element, its purpose, ## Device Management Components ### Device List + **Location**: Left side of Devices page **Purpose**: Display all configured devices for selection **Layout**: + - **Header row**: "Key" and "Name" column headers - **Device rows**: All configured devices with Key and Name - **Fixed width**: Consistent column widths **Interaction**: + - **Click**: Select device to show details - **Hover**: Visual feedback on hover - **Selection**: Single selection only **Content**: + - **Key**: Technical identifier used in configuration - **Name**: Human-readable device name - **Sorting**: Devices appear in configuration order ### Device Detail Panel + **Location**: Right side of Devices page **Purpose**: Display detailed information about selected device **Current State**: Placeholder implementation **Planned Content**: + - Device status and connection state - Real-time property values - Available methods and commands @@ -308,29 +546,35 @@ This document provides detailed information about every UI element, its purpose, ## Information Display Components ### Versions List + **Location**: Versions page **Purpose**: Display all loaded software assemblies and versions **Layout**: + - **Sticky header**: Column headers remain visible during scroll - **Sortable**: Alphabetically sorted by assembly name - **Two columns**: Name and Version **Content**: + - **Name**: Full assembly name including namespace - **Version**: Complete version string with build information - **Filtering**: No built-in filtering (use browser search) -### Types List +### Types List + **Location**: Types page **Purpose**: Display all available device types and descriptions **Layout**: + - **Three columns**: Type Name, Class Type, Description - **Sticky header**: Headers remain visible during scroll - **Sortable**: Alphabetically sorted by type name **Content**: + - **Type Name**: Configuration identifier for device type - **Class Type**: .NET class that implements the device - **Description**: Human-readable description of device purpose @@ -340,16 +584,19 @@ This document provides detailed information about every UI element, its purpose, ## Modal Components ### Restart Confirmation Modal + **Component**: `RestartConfirmModal` **Trigger**: Click "Restart Program" button **Purpose**: Confirm system restart action **Content**: + - **Title**: "Restart Program" - **Message**: "Are you sure you want to restart the program?" - **Actions**: Cancel (light button) and Restart (danger button) **Behavior**: + - **Modal backdrop**: Prevents interaction with background - **Keyboard**: ESC key closes modal - **Focus management**: Focus moves to modal when opened @@ -359,16 +606,19 @@ This document provides detailed information about every UI element, its purpose, ## Form Components ### Checkboxes + **Usage**: Configuration options like "Do Not Load Config on Next Boot" **Style**: Bootstrap form-check styling **States**: Checked, unchecked, disabled (when appropriate) ### Dropdowns + **Usage**: Filters, log level selection, minimum log level **Style**: Bootstrap dropdown with custom styling **Features**: Badge indicators for active selections, scrollable menus ### Search Inputs + **Usage**: Message filtering and search **Features**: Debounced input, placeholder text, clear functionality **Styling**: Form control with border styling @@ -378,10 +628,12 @@ This document provides detailed information about every UI element, its purpose, ## Layout Components ### Header-Scroller-Footer Pattern + **Component**: `HeaderScrollerFooter` **Purpose**: Consistent layout with fixed header/footer and scrollable content **Usage**: + - **Header**: Fixed elements like page titles and controls - **Scroller**: Main content area that scrolls when content overflows - **Footer**: Fixed elements like status bars or action buttons @@ -389,10 +641,12 @@ This document provides detailed information about every UI element, its purpose, **Responsive**: Adapts to different screen sizes and orientations ### Filter Headers + **Component**: `ListFiltersHeader` **Purpose**: Consistent layout for filter controls across different sections **Elements**: + - **Search box**: Text-based filtering - **Filter dropdowns**: Categorical filtering - **Clear button**: Reset all filters @@ -403,7 +657,9 @@ This document provides detailed information about every UI element, its purpose, ## Visual Design System ### Color Scheme + **Primary Colors**: + - Primary: #4466a2 (Blue) - Secondary: #7f7f7f (Gray) - Success: #00b5aa (Teal) @@ -411,22 +667,26 @@ This document provides detailed information about every UI element, its purpose, - Danger: #d90169 (Red) **Background Colors**: + - Body: #e9edf3 (Light Gray) - White: #ffffff - Very Light: #f5f5f5 ### Typography + **Base Font**: Roboto, 'Helvetica Neue', sans-serif **Base Size**: 0.875rem (14px) **Line Height**: Default browser settings **Monospace**: For code/configuration display ### Spacing + **Grid System**: 24-column Bootstrap grid **Standard Spacing**: Bootstrap spacing utilities (mb-2, p-2, etc.) **Component Gaps**: Consistent spacing between related elements ### Interactive States + **Buttons**: Hover, focus, active, and disabled states **Links**: Hover and active states with underline **Form Controls**: Focus states with border color changes @@ -437,16 +697,19 @@ This document provides detailed information about every UI element, its purpose, ## Accessibility Features ### Keyboard Navigation + - **Tab order**: Logical tab sequence through interactive elements - **Focus indicators**: Visible focus states on all interactive elements - **Modal management**: Focus traps and restoration ### Screen Reader Support + - **Semantic HTML**: Proper use of headings, lists, and form labels - **ARIA labels**: Where needed for complex interactions - **Status announcements**: For dynamic content updates ### Visual Accessibility + - **Color contrast**: Meets WCAG guidelines for text and background contrast - **Focus indicators**: High contrast focus outlines - **Text scaling**: Responsive to browser zoom and text size preferences @@ -456,16 +719,19 @@ This document provides detailed information about every UI element, its purpose, ## Performance Characteristics ### Rendering Performance + - **Virtual scrolling**: Not implemented (could benefit large message lists) - **Debounced interactions**: Search and filter inputs use appropriate delays - **Efficient updates**: React optimizations for frequent message updates ### Memory Management + - **Message accumulation**: Messages accumulate in browser memory during session - **Clear mechanism**: Page refresh clears accumulated messages - **Large datasets**: May require manual session management for very high message volumes ### Network Efficiency + - **WebSocket connection**: Single persistent connection for debug messages - **API calls**: Minimal API calls for configuration and device data - **Caching**: Browser caching for static resources @@ -475,18 +741,21 @@ This document provides detailed information about every UI element, its purpose, ## Browser Compatibility ### Supported Browsers + - **Chrome**: Version 70+ - **Firefox**: Version 65+ - **Safari**: Version 12+ - **Edge**: Version 79+ ### Required Features + - **WebSocket support**: For real-time debug messages - **ES6 support**: Modern JavaScript features - **CSS Grid/Flexbox**: For responsive layouts - **JSON support**: For configuration display ### Mobile Support + - **Responsive design**: Adapts to mobile screen sizes - **Touch interactions**: Touch-friendly button and link sizes - **Mobile browsers**: Same browser version requirements as desktop diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md index fe078fd..4aeb2d3 100644 --- a/docs/tutorials/README.md +++ b/docs/tutorials/README.md @@ -7,11 +7,13 @@ These tutorials are designed to take you from beginner to competent user through ## πŸš€ Getting Started ### [Getting Started Tutorial](./getting-started.md) + **Time**: 15-20 minutes | **Level**: Beginner Your first steps with the PepperDash Essentials Web Config App. Learn to access the application, navigate the interface, and perform basic operations. **You'll learn**: + - How to access the web application securely - Navigate through all main sections - Start your first debug session @@ -25,11 +27,13 @@ Your first steps with the PepperDash Essentials Web Config App. Learn to access ## πŸ” Core Features ### [Debug Console Tutorial](./debug-console-basics.md) + **Time**: 25-30 minutes | **Level**: Intermediate Master the Debug Console - the most powerful feature for system monitoring and troubleshooting. Learn advanced filtering, message interpretation, and systematic troubleshooting approaches. **You'll learn**: + - Advanced filtering and search techniques - How to interpret different log levels and message types - Managing debug sessions effectively @@ -39,11 +43,13 @@ Master the Debug Console - the most powerful feature for system monitoring and t **Prerequisites**: Completed Getting Started Tutorial ### [Device Management Tutorial](./device-management-basics.md) + **Time**: 20-25 minutes | **Level**: Intermediate Learn to effectively browse, understand, and work with devices in your PepperDash Essentials system. Understand device types, relationships, and how to use device information for troubleshooting. **You'll learn**: + - How to browse and identify devices - Understanding device properties and capabilities - Interpreting device types and their purposes @@ -62,7 +68,7 @@ We recommend following this sequence for the best learning experience: 1. Getting Started Tutorial (Required) ↓ 2. Debug Console Tutorial (Recommended) - ↓ + ↓ 3. Device Management Tutorial (Recommended) ``` @@ -104,12 +110,15 @@ Our tutorials follow these principles: Once you've completed the tutorials: ### For Specific Problems + Head to **[How-to Guides](../how-to/)** for step-by-step solutions to common issues ### For Technical Details + Consult the **[Reference](../reference/)** documentation for complete technical information ### For Deeper Understanding + Read **[Explanation](../explanation/)** articles to understand system design and concepts --- @@ -117,18 +126,21 @@ Read **[Explanation](../explanation/)** articles to understand system design and ## πŸ“ž Getting Help **Stuck on a tutorial?** + - Re-read the prerequisites to ensure you have everything needed - Check that your system is running and accessible - Try the troubleshooting section at the end of each tutorial **Want to suggest improvements?** + - These tutorials are designed to be practical and helpful - Your feedback helps us improve the learning experience **Need more advanced training?** + - The tutorials cover essential skills for most users - For specialized or advanced use cases, consult the reference documentation --- -*Remember: Tutorials are for learning, not quick problem-solving. Take your time to understand each concept before moving on. The investment in foundational knowledge will pay off in your daily work!* +_Remember: Tutorials are for learning, not quick problem-solving. Take your time to understand each concept before moving on. The investment in foundational knowledge will pay off in your daily work!_ diff --git a/docs/tutorials/debug-console-basics.md b/docs/tutorials/debug-console-basics.md index ea370df..6a0f8d3 100644 --- a/docs/tutorials/debug-console-basics.md +++ b/docs/tutorials/debug-console-basics.md @@ -4,7 +4,8 @@ **Time required**: 25-30 minutes -**Prerequisites**: +**Prerequisites**: + - Completed the [Getting Started Tutorial](./getting-started.md) - Access to a running PepperDash Essentials system - Basic familiarity with the web app interface @@ -26,8 +27,9 @@ 3. **Generate various activities** on your system to see different message types **Log levels you'll encounter** (from most to least critical): + - **Error**: System failures, connection problems, critical issues -- **Warning**: Potential issues, deprecated features, recoverable problems +- **Warning**: Potential issues, deprecated features, recoverable problems - **Information**: Normal operations, status changes, confirmations - **Debug**: Detailed technical information for developers - **Verbose**: Extremely detailed trace information @@ -53,7 +55,8 @@ 4. **Set a per-device minimum level**: Once a device is checked, an inline level dropdown appears next to its name. Change it to `Warning` to suppress that device's `Information` and `Debug` messages while keeping other devices at a lower threshold 5. **Combine multiple devices** each with their own levels -**Practical example**: +**Practical example**: + - If one display is flooding the console with `Information` messages, check it and set its level to `Warning` - Keep other devices at `Information` so you see their normal activity @@ -68,6 +71,7 @@ ### Step 5: Understand Filter State Persistence Filter selections are stored in Redux state: + - **Persists across navigation**: Switching to Versions and back preserves your filters - **Resets on page reload**: Refreshing the browser clears all filter state (along with the login session) - **Clear Filters button**: Resets device selections, per-device levels, and search text in one click @@ -79,6 +83,7 @@ Filter selections are stored in Redux state: Real systems generate patterns of messages. Learn to recognize them: 1. **Normal startup sequence**: + ``` Information: Device [DisplayRoom1] initializing Information: Device [DisplayRoom1] connection established @@ -86,6 +91,7 @@ Real systems generate patterns of messages. Learn to recognize them: ``` 2. **Error patterns**: + ``` Warning: Device [DisplayRoom1] connection timeout Error: Device [DisplayRoom1] failed to respond @@ -110,6 +116,7 @@ Real systems generate patterns of messages. Learn to recognize them: - Timing information **Common properties you'll see**: + - `Key`: The device that generated the message - `SourceContext`: Which part of the code generated the message - `CommandType`: What type of command was executed @@ -159,11 +166,12 @@ Real systems generate patterns of messages. Learn to recognize them: 4. **Check message timestamps** to see when problems started **Expected findings**: + - Connection timeout errors - Power command confirmations (or lack thereof) - Network connectivity issues -### Scenario 2: System Running Slowly +### Scenario 2: System Running Slowly **Goal**: Identify performance bottlenecks @@ -188,6 +196,7 @@ Real systems generate patterns of messages. Learn to recognize them: ## Best Practices Summary ### Do's: + - βœ… Start with broad filters, then narrow down - βœ… Use appropriate log levels for your task - βœ… Stop sessions when not actively debugging @@ -195,6 +204,7 @@ Real systems generate patterns of messages. Learn to recognize them: - βœ… Use search terms related to your specific problem ### Don'ts: + - ❌ Leave debug sessions running indefinitely - ❌ Use "Verbose" level unless absolutely necessary - ❌ Ignore the message count - it indicates system load @@ -227,16 +237,19 @@ You now have advanced skills in using the Debug Console: ## Troubleshooting This Tutorial **Not seeing expected message types?** + - Your system may be configured with a higher minimum log level - Try generating more system activity - Check if your devices are actually connected and functioning **Too many messages to follow?** + - Use more restrictive filtering - Increase the minimum log level temporarily - Focus on one device or message type at a time **Messages seem delayed?** + - This can indicate network latency or system load - Check your network connection - Consider if other users are also connected diff --git a/docs/tutorials/device-management-basics.md b/docs/tutorials/device-management-basics.md index 1767204..57fdef4 100644 --- a/docs/tutorials/device-management-basics.md +++ b/docs/tutorials/device-management-basics.md @@ -4,7 +4,8 @@ **Time required**: 20-25 minutes -**Prerequisites**: +**Prerequisites**: + - Completed the [Getting Started Tutorial](./getting-started.md) - Access to a system with configured devices - Basic understanding of the web app interface @@ -30,12 +31,14 @@ Each device has two important identifiers: -**Key**: +**Key**: + - Unique technical identifier used in configuration - Often follows naming conventions like "Display-01" or "Codec-Main" - Used internally by the system for routing commands **Name**: + - Human-friendly display name - What users typically see in interfaces - Often describes location or function like "Conference Room Display" @@ -81,9 +84,10 @@ Each device has two important identifiers: - Currently shows basic "Device Detail" header - This is where detailed device information would appear -*Note: The current implementation shows a placeholder. In a fully implemented system, you would see:* +_Note: The current implementation shows a placeholder. In a fully implemented system, you would see:_ **Expected device details**: + - Current status and connection state - Device properties (power state, input selection, volume, etc.) - Available methods (commands you can send) @@ -99,6 +103,7 @@ Even with the current interface, you can gather valuable information: 3. **Key format**: Indicates how the system organizes devices **Troubleshooting workflow**: + 1. Check if the problematic device appears in the device list 2. Note its Key and Name for reference in debug messages 3. Use the Key to filter debug console messages for that specific device @@ -144,15 +149,17 @@ Even with the current interface, you can gather valuable information: Real systems have devices that work together: **Common relationships**: + - **Display + Audio**: TV with sound system - **Touch Panel + Devices**: Control interface managing multiple devices - **Switcher + Endpoints**: Video routing with sources and destinations - **Codec + Peripherals**: Video conferencing system with cameras and microphones **Look for these patterns** in your device list: + - Similar names with different suffixes (-01, -02, etc.) - Hierarchical naming (Room1-Display, Room1-Audio) -- Functional grouping (Boardroom-*, Training-*) +- Functional grouping (`Boardroom-*`, `Training-*`) ### Step 8: Configuration Insights @@ -164,6 +171,7 @@ The device list reveals configuration decisions: 4. **Missing devices**: Are expected devices configured? **Use this information to**: + - Understand system complexity - Identify potential configuration issues - Plan troubleshooting approaches @@ -174,19 +182,22 @@ The device list reveals configuration decisions: ## Best Practices for Device Management ### Investigation Workflow: + 1. βœ… **Start broad**: Review the complete device list -2. βœ… **Identify patterns**: Look for naming conventions and groupings +2. βœ… **Identify patterns**: Look for naming conventions and groupings 3. βœ… **Focus specific**: Select devices related to your current task 4. βœ… **Cross-reference**: Use device Keys in debug console for detailed analysis 5. βœ… **Document findings**: Note device relationships and issues ### Troubleshooting Tips: + - βœ… Use device Keys (not Names) when filtering debug messages - βœ… Look for similar devices to compare expected vs. actual behavior - βœ… Check device types to understand what functionality should be available - βœ… Note device naming patterns to find related components ### Don'ts: + - ❌ Assume device Names match debug message identifiers (use Keys) - ❌ Ignore devices that seem unrelated to your current problem - ❌ Forget to check if expected devices are actually configured @@ -195,12 +206,14 @@ The device list reveals configuration decisions: ## Integration with Other Features ### Connecting Device Management to Debug Console: + 1. **Identify problem device** in device list 2. **Note the device Key** 3. **Filter debug console** to that specific device 4. **Analyze messages** for that device's behavior ### Using Configuration Data: + 1. **Check device list** for what's configured 2. **View config file** to see detailed device settings 3. **Compare types list** to understand available capabilities @@ -222,11 +235,13 @@ You now understand device management fundamentals: ## Current Limitations and Future Enhancements **Current interface limitations**: + - Device detail panel shows placeholder content - No real-time device status information - Limited interaction capabilities **In enhanced versions, you might see**: + - Live device status and properties - Command sending capabilities - Device method execution @@ -242,16 +257,19 @@ You now understand device management fundamentals: ## Troubleshooting This Tutorial **No devices showing?** + - Check that your system is properly configured - Verify you're connected to the right processor - Confirm the Essentials framework is running **Device names don't make sense?** + - This reflects how your system was configured - Contact your system administrator for naming conventions - Use Keys for technical references, Names for user communication **Can't find expected devices?** + - They may not be configured in the system - Check the configuration file for more details - Verify physical device connections and power diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md index 3575a3f..e37a4e5 100644 --- a/docs/tutorials/getting-started.md +++ b/docs/tutorials/getting-started.md @@ -4,7 +4,8 @@ **Time required**: 15-20 minutes -**Prerequisites**: +**Prerequisites**: + - Access to a PepperDash Essentials processor on your network - Valid credentials for the processor - Basic understanding of network connectivity @@ -57,38 +58,48 @@ The application has several main sections accessible from the top navigation: ### Versions + - Lists all loaded software assemblies and their versions - Useful for verifying what software is running ### API Paths + - Shows all REST API routes available on the processor - Click a route row to see its full URL and details ### Initialization Exceptions + - Only visible when PepperDashEssentials.dll β‰₯ 3.0 - Lists any exceptions from system startup ### Debug Console + - Real-time log messages from your system - The most powerful feature for troubleshooting ### Config File + - Shows the complete merged configuration - Displays the JSON structure of your system setup ### Devices + - Lists all configured devices in your system - Allows inspection of device properties and methods ### Types + - Shows all supported device types - Useful for understanding what devices can be configured ### Routing -- Visual diagram of signal routing between devices and tie lines -- Color-coded by signal type + +- Visual diagram of signal routing between devices and tie lines, color-coded by signal type +- Click a tie line, device, or multiview tile to trace and highlight its full signal path +- Shows live current-source feedback on systems running PepperDashEssentials.dll 3.0+ ### Mobile Control + - Management interface for connected mobile control clients **Try this**: Click through each navigation item to see the different sections. @@ -152,12 +163,12 @@ Let’s learn to filter messages: - Click **Clear Filters** to reset the device selections and search text - Filter selections are saved in memory β€” navigating to another page and returning preserves them -3. **Filter by log level** +4. **Filter by log level** - Click the "Log Level" dropdown - Try selecting only "Error" and "Warning" to see problems - This helps focus on issues that need attention -4. **Clear filters** +5. **Clear filters** - Click the "Clear" button to remove all filters - All messages will be visible again @@ -201,16 +212,19 @@ Now that you understand the basics, you can: ## Troubleshooting **Can't access the web app?** + - Verify the processor IP address - Check network connectivity - Ensure the processor is powered on and running **No debug messages appearing?** + - Verify the debug session started successfully - Check if your system is generating activity - Try reloading the page and starting a new session **Browser security warnings?** + - This is normal for internal devices with self-signed certificates - Safe to proceed on your internal network - Don't ignore these warnings on public networks diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..105085e --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,42 @@ +import js from '@eslint/js'; +import eslintConfigPrettier from 'eslint-config-prettier/flat'; +import { defineConfig, globalIgnores } from 'eslint/config'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default defineConfig([ + globalIgnores(['dist', 'coverage']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2022, + globals: { ...globals.browser, ...globals.node }, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + // Type-aware rules that catch real bugs: unhandled promises and async handlers + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + // React Compiler-era rule; fixes can be non-trivial, so warn while we clean up + 'react-hooks/set-state-in-effect': 'warn', + }, + }, + { + files: ['eslint.config.js'], + extends: [js.configs.recommended], + languageOptions: { globals: globals.node }, + }, + // Must be last: turns off rules that conflict with Prettier + eslintConfigPrettier, +]); diff --git a/index.html b/index.html index 8a4d1a6..4954474 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,4 @@ - + diff --git a/package-lock.json b/package-lock.json index e1267c2..4eb3e9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "essentials-web-config-app", - "version": "0.1.0", + "version": "1.0.0-local", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "essentials-web-config-app", - "version": "0.1.0", + "version": "1.0.0-local", "dependencies": { "@monaco-editor/react": "^4.7.0", "@reduxjs/toolkit": "^2.11.2", @@ -18,13 +18,16 @@ "react": "^19.2.4", "react-bootstrap": "^2.10.10", "react-dom": "^19.2.4", + "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.2", "react-scroll-to-bottom": "^4.2.0", + "remark-gfm": "^4.0.1", "sass": "^1.98.0", "web-vitals": "^5.2.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@types/dagre": "^0.7.54", @@ -34,11 +37,21 @@ "@types/react-dom": "^19.2.3", "@types/react-scroll-to-bottom": "^4.2.5", "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.11.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.12.0", "jsdom": "^29.0.1", + "prettier": "^3.9.9", "typescript": "^6.0.2", + "typescript-eslint": "^8.70.1", "vite": "^8.0.3", "vite-plugin-svgr": "^5.0.0", "vitest": "^4.1.2" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@adobe/css-tools": { @@ -48,19 +61,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", - "devOptional": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.1.tgz", @@ -144,12 +144,12 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -158,30 +158,32 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.2.tgz", - "integrity": "sha512-0S9TQMmDHlqAZ2ITT95irXKfxN9bncq8ZCoJhun3nHL/lLUxd2NKBJYoNGWH7S0hz6fRQwWlAWn/ILM0C70KZQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "devOptional": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", - "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "devOptional": true, + "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-module-transforms": "^7.23.0", - "@babel/helpers": "^7.23.2", - "@babel/parser": "^7.23.0", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.2", - "@babel/types": "^7.23.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -196,39 +198,32 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "devOptional": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", - "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", - "devOptional": true, + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.23.0", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", - "jsesc": "^2.5.1" + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", - "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "devOptional": true, + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.22.9", - "@babel/helper-validator-option": "^7.22.15", - "browserslist": "^4.21.9", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -236,71 +231,38 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "devOptional": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "devOptional": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "devOptional": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "devOptional": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.15" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.0.tgz", - "integrity": "sha512-WhDWw1tdrlT0gMgUJSlX0IQvoO1eN279zrAUbVB+KpV2c3Tylz8+GnKOLllCS6Z/iZQEyVYxhZVUdPTqs2YYPw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "devOptional": true, + "license": "MIT", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -309,79 +271,55 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", - "devOptional": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", - "devOptional": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", - "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "devOptional": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "devOptional": true, + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -412,49 +350,45 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "devOptional": true, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", - "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", - "devOptional": true, + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", - "debug": "^4.1.0", - "globals": "^11.1.0" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -494,6 +428,30 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -762,6 +720,134 @@ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", @@ -780,68 +866,142 @@ } } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "devOptional": true, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@humanfs/types": "^0.15.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=18.18.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", - "devOptional": true, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, "engines": { - "node": ">=6.0.0" + "node": ">=18.18.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "devOptional": true, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=6.0.0" + "node": ">=18.18.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", - "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "optional": true, - "peer": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "devOptional": true, + "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@monaco-editor/loader": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", @@ -2080,6 +2240,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2087,10 +2256,41 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, @@ -2101,6 +2301,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", @@ -2167,6 +2382,12 @@ "optional": true, "peer": true }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -2179,21 +2400,270 @@ "integrity": "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg==", "license": "MIT" }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", + "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/type-utils": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", + "@typescript-eslint/parser": "^8.70.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", + "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", + "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", + "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", + "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", + "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", + "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", + "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.1", + "@typescript-eslint/tsconfig-utils": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", + "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", + "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -2381,13 +2851,11 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2395,6 +2863,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2477,10 +2955,30 @@ "npm": ">=6" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/baseline-browser-mapping": { - "version": "2.10.13", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", - "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -2519,10 +3017,23 @@ "@popperjs/core": "^2.11.8" } }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", "devOptional": true, "funding": [ { @@ -2540,11 +3051,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -2553,13 +3064,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", "dev": true, - "optional": true, - "peer": true + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", @@ -2596,9 +3113,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001782", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", - "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "devOptional": true, "funding": [ { @@ -2616,6 +3133,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2626,6 +3153,46 @@ "node": ">=18" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/classcat": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", @@ -2648,6 +3215,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2689,6 +3266,21 @@ "node": ">= 6" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -2835,7 +3427,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2856,6 +3447,26 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2882,6 +3493,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -2935,9 +3559,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.329", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", - "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", + "version": "1.5.438", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.438.tgz", + "integrity": "sha512-AN9xMU1hJiT65LkCUPL1DZm5TCbOPb2Qsm5pYwFLEXp6/qj9SdT4yMiqs7Qqstwvkn1zF7l36SRGt+s6XcJ0FA==", "devOptional": true, "license": "ISC" }, @@ -3024,31 +3648,364 @@ "node": ">=6" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", "engines": { - "node": ">=12.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "node_modules/eslint": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { @@ -3134,13 +4091,30 @@ "node": ">= 0.4" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "devOptional": true, + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/gopd": { @@ -3191,6 +4165,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -3203,6 +4190,70 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -3216,14 +4267,34 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/immutable": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", - "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", - "license": "MIT" - }, - "node_modules/import-fresh": { - "version": "3.3.0", + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", "dependencies": { @@ -3245,6 +4316,16 @@ "node": ">=4" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -3255,6 +4336,12 @@ "node": ">=8" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -3263,6 +4350,30 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3279,11 +4390,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "optional": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -3292,7 +4413,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "optional": true, + "devOptional": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -3300,22 +4421,40 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, - "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "optional": true, - "peer": true, - "bin": { - "jiti": "bin/jiti.js" - } + "license": "ISC" }, "node_modules/js-tokens": { "version": "4.0.0", @@ -3433,15 +4572,15 @@ "license": "CC0-1.0" }, "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "devOptional": true, + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/json-parse-even-better-errors": { @@ -3449,6 +4588,13 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3461,6 +4607,30 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3727,79 +4897,949 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/math-random": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/math-random/-/math-random-2.0.1.tgz", + "integrity": "sha512-oIEbWiVDxDpl5tIF4S6zYS9JExhh3bun3uLb3YAinHPTlRtW4g1S66LtJrJ4Npq8dgIa8CLK5iPVah5n4n0s2w==" + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/lower-case": { + "node_modules/micromark-util-decode-numeric-character-reference": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dev": true, + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "tslib": "^2.0.3" + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "devOptional": true, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", "dependencies": { - "yallist": "^3.0.2" + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" + "dependencies": { + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "peer": true, - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" + "dependencies": { + "micromark-util-types": "^2.0.0" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/math-random": { + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/math-random/-/math-random-2.0.1.tgz", - "integrity": "sha512-oIEbWiVDxDpl5tIF4S6zYS9JExhh3bun3uLb3YAinHPTlRtW4g1S66LtJrJ4Npq8dgIa8CLK5iPVah5n4n0s2w==" + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" }, "node_modules/mime-db": { "version": "1.52.0", @@ -3830,6 +5870,22 @@ "node": ">=4" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/monaco-editor": { "version": "0.55.1", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", @@ -3845,7 +5901,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true, "license": "MIT" }, "node_modules/nanoid": { @@ -3867,6 +5922,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -3886,11 +5948,14 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "devOptional": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/object-assign": { "version": "4.1.1", @@ -3911,6 +5976,56 @@ ], "license": "MIT" }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3922,6 +6037,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -3965,6 +6105,26 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -4033,6 +6193,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -4093,6 +6279,16 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -4111,6 +6307,26 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -4175,6 +6391,33 @@ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -4342,6 +6585,72 @@ "redux": "^5.0.0" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4481,12 +6790,45 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -4519,27 +6861,14 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/stackback": { @@ -4562,6 +6891,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -4575,6 +6918,24 @@ "node": ">=8" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -4604,35 +6965,6 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, - "node_modules/terser": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", - "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4741,12 +7073,58 @@ "node": ">=20" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", @@ -4761,6 +7139,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", + "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/parser": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/uncontrollable": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", @@ -4792,10 +7194,97 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "devOptional": true, "funding": [ { @@ -4823,6 +7312,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -4832,6 +7331,34 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", @@ -5079,6 +7606,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -5096,6 +7639,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -5117,24 +7670,43 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "devOptional": true + "devOptional": true, + "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, + "license": "MIT", "engines": { - "node": ">= 14.6" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/eemeli" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" } }, "node_modules/zustand": { @@ -5164,6 +7736,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index ab8575c..54f465a 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "1.0.0-local", "type": "module", "private": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, "dependencies": { "@monaco-editor/react": "^4.7.0", "@reduxjs/toolkit": "^2.11.2", @@ -14,9 +17,11 @@ "react": "^19.2.4", "react-bootstrap": "^2.10.10", "react-dom": "^19.2.4", + "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.2", "react-scroll-to-bottom": "^4.2.0", + "remark-gfm": "^4.0.1", "sass": "^1.98.0", "web-vitals": "^5.2.0" }, @@ -24,9 +29,14 @@ "start": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "test": "vitest" + "test": "vitest", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "devDependencies": { + "@eslint/js": "^10.0.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@types/dagre": "^0.7.54", @@ -36,8 +46,15 @@ "@types/react-dom": "^19.2.3", "@types/react-scroll-to-bottom": "^4.2.5", "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.11.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.12.0", "jsdom": "^29.0.1", + "prettier": "^3.9.9", "typescript": "^6.0.2", + "typescript-eslint": "^8.70.1", "vite": "^8.0.3", "vite-plugin-svgr": "^5.0.0", "vitest": "^4.1.2" diff --git a/src/App.test.tsx b/src/App.test.tsx index 2a68616..f81a126 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,9 +1,167 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { + createMemoryRouter, + MemoryRouter, + RouterProvider, +} from 'react-router-dom'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import App from './App'; +import { authActions } from './store/auth/authSlice'; +import { store } from './store/store'; +import { connected } from './store/websocketSlice'; -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); +const { + mockStartSession, + mockStopSession, + mockSetDoNotLoadConfig, + mockRestart, + mockLoadConfig, +} = vi.hoisted(() => ({ + mockStartSession: vi.fn(), + mockStopSession: vi.fn(), + mockSetDoNotLoadConfig: vi.fn(), + mockRestart: vi.fn(), + mockLoadConfig: vi.fn(), +})); + +vi.mock('./store/apiSlice', async () => { + const actual = + await vi.importActual( + './store/apiSlice' + ); + + return { + ...actual, + useGetDebugSessionMutation: () => [mockStartSession], + useStopDebugSessionMutation: () => [mockStopSession], + useGetDoNotLoadConfigOnNextBootQuery: () => ({ + data: { doNotLoadConfigOnNextBoot: false }, + }), + useSetDoNotLoadConfigOnNextBootMutation: () => [mockSetDoNotLoadConfig], + useSetRestartMutation: () => [mockRestart], + useSetLoadConfigMutation: () => [mockLoadConfig], + }; +}); + +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + onmessage: (() => void) | null = null; + + constructor(public url: string) { + FakeWebSocket.instances.push(this); + } + + close() {} +} + +const renderAtRoute = (route: string) => { + const router = createMemoryRouter([{ path: '*', element: }], { + initialEntries: [route], + }); + + render( + + + + ); + + return router; +}; + +beforeEach(() => { + FakeWebSocket.instances = []; + vi.stubGlobal('WebSocket', FakeWebSocket); + mockStartSession.mockReset(); + mockStopSession.mockReset(); + mockSetDoNotLoadConfig.mockReset(); + mockRestart.mockReset(); + mockLoadConfig.mockReset(); + store.dispatch({ type: 'commonUi/resetState' }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + store.dispatch({ type: 'commonUi/resetState' }); +}); + +it('renders the help page inside the app shell', () => { + render( + + + + + + ); + expect( + screen.getByRole('heading', { + name: /PepperDash Essentials Web Config App Documentation/i, + }) + ).toBeInTheDocument(); +}); + +it('clears the debug-session connection when the app route changes', async () => { + store.dispatch(authActions.loginSuccess(['app01', 'app02'])); + store.dispatch(connected()); + + const router = renderAtRoute('/app01/console'); + + expect( + await screen.findByRole('button', { name: /Stop Debug Session/i }) + ).toBeInTheDocument(); + + await act(async () => { + await router.navigate('/app02/console'); + }); + + expect(store.getState().websocket.isConnected).toBe(false); + expect( + screen.getByRole('button', { name: /Start Debug Session/i }) + ).toBeInTheDocument(); +}); + +it('ignores stale debug-session responses after navigating to another app', async () => { + store.dispatch(authActions.loginSuccess(['app01', 'app02'])); + + let resolveStartSession: (value: { + url: string; + fallbackUrl?: string; + }) => void = () => {}; + + mockStartSession.mockImplementation(() => ({ + unwrap: () => + new Promise((resolve) => { + resolveStartSession = resolve; + }), + })); + + const router = renderAtRoute('/app01/console'); + + fireEvent.click( + await screen.findByRole('button', { name: /Start Debug Session/i }) + ); + + expect(mockStartSession).toHaveBeenCalledWith({ appId: 'app01' }); + expect(store.getState().websocket.isConnecting).toBe(true); + + await act(async () => { + await router.navigate('/app02/console'); + }); + + expect(store.getState().websocket.isConnecting).toBe(false); + + await act(async () => { + resolveStartSession({ url: 'ws://app01' }); + await Promise.resolve(); + }); + + expect(FakeWebSocket.instances).toHaveLength(0); + expect(store.getState().websocket.isConnected).toBe(false); + expect(store.getState().websocket.isConnecting).toBe(false); + expect( + screen.getByRole('button', { name: /Start Debug Session/i }) + ).toBeInTheDocument(); }); diff --git a/src/App.tsx b/src/App.tsx index 593846b..f6be838 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,48 +1,119 @@ -import { Suspense } from "react"; -import { useDispatch, useSelector } from "react-redux"; -import { Navigate, Route, Routes } from "react-router-dom"; +import { Suspense, useEffect, useRef } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; import { ApiPaths } from './features/ApiPaths'; -import ConfigFile from "./features/ConfigFile"; -import DebugConsole from "./features/DebugConsole/DebugConsole"; -import DeviceList from "./features/DeviceList"; -import ErrorBoundary from "./features/ErrorBoundary"; -import InitializationExceptions from "./features/InitializationExceptions"; -import LoginForm from "./features/LoginForm"; -import MainLayout from "./features/MainLayout"; +import ConfigFile from './features/ConfigFile'; +import DebugConsole from './features/DebugConsole/DebugConsole'; +import DeviceList from './features/DeviceList'; +import ErrorBoundary from './features/ErrorBoundary'; +import Help from './features/Help/Help'; +import InitializationExceptions from './features/InitializationExceptions'; +import LoginForm from './features/LoginForm'; +import MainLayout from './features/MainLayout'; import MobileControl from './features/MobileControl'; -import RequireAuth from "./features/RequireAuth"; +import RequireAuth from './features/RequireAuth'; import Routing from './features/Routing'; -import Types from "./features/Types"; -import Versions from "./features/Versions"; +import Secrets from './features/Secrets'; +import Types from './features/Types'; +import Versions from './features/Versions'; import { useGetDebugSessionMutation, useStopDebugSessionMutation, -} from "./store/apiSlice"; -import { AppDispatch, RootState } from "./store/store"; -import { messagesCleared, WS_CONNECT, WS_DISCONNECT } from "./store/websocketSlice"; +} from './store/apiSlice'; +import { AppDispatch, RootState } from './store/store'; +import { + connectionAttemptStarted, + disconnected, + messagesCleared, + WS_CONNECT, + WS_DISCONNECT, +} from './store/websocketSlice'; + +function getRouteAppId(pathname: string) { + const [firstSegment] = pathname.replace(/^\/+/, '').split('/'); + + if (!firstSegment || firstSegment === 'help' || firstSegment === 'login') { + return null; + } + + return firstSegment; +} function App() { const dispatch = useDispatch(); - const isConnected = useSelector((state: RootState) => state.websocket.isConnected); + const store = useStore(); + const location = useLocation(); + const isConnected = useSelector( + (state: RootState) => state.websocket.isConnected + ); + const isConnecting = useSelector( + (state: RootState) => state.websocket.isConnecting + ); const [startSession] = useGetDebugSessionMutation(); const [stopSession] = useStopDebugSessionMutation(); + const currentAppId = getRouteAppId(location.pathname); + const routeAppIdRef = useRef(currentAppId); + const previousAppIdRef = useRef(currentAppId); + const joinRequestIdRef = useRef(0); + + useEffect(() => { + routeAppIdRef.current = currentAppId; + }, [currentAppId]); + + useEffect(() => { + const previousAppId = previousAppIdRef.current; + previousAppIdRef.current = currentAppId; + + if (previousAppId === null || previousAppId === currentAppId) { + return; + } + + joinRequestIdRef.current += 1; + dispatch({ type: WS_DISCONNECT }); + }, [currentAppId, dispatch]); //* FUNCTIONS *******************************************************/ const join = async (appId: string) => { - if (!appId) return; - const res = await startSession({ appId }).unwrap(); - const primaryUrl = res.fallbackUrl || res.url; - const fallbackUrl = res.fallbackUrl ? res.url : undefined; - console.log("Joining debug session at " + primaryUrl + (fallbackUrl ? " (fallback: " + fallbackUrl + ")" : "")); - dispatch({ type: WS_CONNECT, payload: { url: primaryUrl, fallbackUrl } }); + // Ignore repeat clicks until the current attempt connects or fails. Read + // the store directly so a click before re-render still sees the flag + const { isConnecting, isConnected } = store.getState().websocket; + if (!appId || isConnecting || isConnected) return; + const requestId = ++joinRequestIdRef.current; + dispatch(connectionAttemptStarted()); + try { + const res = await startSession({ appId }).unwrap(); + if ( + joinRequestIdRef.current !== requestId || + routeAppIdRef.current !== appId + ) { + return; + } + // The server already picks the URL on the browser's side of the network + const { url, fallbackUrl } = res; + console.log( + 'Joining debug session at ' + + url + + (fallbackUrl ? ' (fallback: ' + fallbackUrl + ')' : '') + ); + dispatch({ type: WS_CONNECT, payload: { url, fallbackUrl } }); + } catch (err) { + if ( + joinRequestIdRef.current !== requestId || + routeAppIdRef.current !== appId + ) { + return; + } + console.error('Failed to start debug session', err); + dispatch(disconnected()); + } }; const stop = (appId: string) => { - console.log("Stopping debug session"); + console.log('Stopping debug session'); dispatch({ type: WS_DISCONNECT }); if (!appId) return; - stopSession({ appId }); + void stopSession({ appId }); }; const clear = () => { @@ -53,33 +124,42 @@ function App() { - } /> - } /> - - } /> - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } - /> + } /> + } /> + } /> + + } /> + } + > + }> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + + } + /> + - diff --git a/src/features/ApiPathDetailDrawer.tsx b/src/features/ApiPathDetailDrawer.tsx index 51fd591..8001029 100644 --- a/src/features/ApiPathDetailDrawer.tsx +++ b/src/features/ApiPathDetailDrawer.tsx @@ -1,5 +1,5 @@ -import { Offcanvas } from "react-bootstrap"; -import { Route } from "../store/apiSlice"; +import { Offcanvas } from 'react-bootstrap'; +import { Route } from '../store/apiSlice'; const ApiPathDetailDrawer = ({ show, @@ -28,7 +28,11 @@ const ApiPathDetailDrawer = ({ diff --git a/src/features/ApiPaths.tsx b/src/features/ApiPaths.tsx index 4fc88d1..99156d3 100644 --- a/src/features/ApiPaths.tsx +++ b/src/features/ApiPaths.tsx @@ -4,10 +4,11 @@ import useAppParams from '../shared/hooks/useAppParams'; import { Route, useGetPathsQuery } from '../store/apiSlice'; import ApiPathDetailDrawer from './ApiPathDetailDrawer'; - export const ApiPaths = () => { const { appId } = useAppParams(); - const { data: apiPathData, isLoading } = useGetPathsQuery(appId ? { appId } : skipToken); + const { data: apiPathData, isLoading } = useGetPathsQuery( + appId ? { appId } : skipToken + ); const [showDrawer, setShowDrawer] = useState(false); const [selectedRoute, setSelectedRoute] = useState(); @@ -15,7 +16,9 @@ export const ApiPaths = () => { if (!apiPathData?.routes) return
No paths available
; - const sorted = [...apiPathData.routes].sort((a, b) => a.Name.localeCompare(b.Name)); + const sorted = [...apiPathData.routes].sort((a, b) => + a.Name.localeCompare(b.Name) + ); function clickRow(route: Route) { setSelectedRoute(route); @@ -29,7 +32,7 @@ export const ApiPaths = () => { return (
-

Available API Paths

+

Available API Paths

@@ -42,7 +45,10 @@ export const ApiPaths = () => { clickRow(path)} - className={'cursor-pointer hover' + (selectedRoute === path ? ' table-primary' : '')} + className={ + 'cursor-pointer hover' + + (selectedRoute === path ? ' table-primary' : '') + } > @@ -59,4 +65,4 @@ export const ApiPaths = () => { /> ); -} \ No newline at end of file +}; diff --git a/src/features/ConfigFile.tsx b/src/features/ConfigFile.tsx index e2980bb..d26a231 100644 --- a/src/features/ConfigFile.tsx +++ b/src/features/ConfigFile.tsx @@ -7,9 +7,26 @@ import { useGetConfigQuery } from '../store/apiSlice'; type IConfigViewer = Parameters[0]; +// Newer monaco typings drop languages.json, but the runtime still provides it +type LegacyJsonLanguages = { + json?: { + jsonDefaults?: { + setDiagnosticsOptions(options: { + enableSchemaRequest?: boolean; + allowComments?: boolean; + validate?: boolean; + }): void; + }; + }; +}; + const ConfigFile = () => { const { appId } = useAppParams(); - const { data: config, refetch, isFetching } = useGetConfigQuery(appId ? { appId } : skipToken); + const { + data: config, + refetch, + isFetching, + } = useGetConfigQuery(appId ? { appId } : skipToken); if (!config) { return
Config Data Loading or Not Available
; @@ -18,7 +35,12 @@ const ConfigFile = () => { return (
-
@@ -31,15 +53,17 @@ const ConfigFile = () => { export default ConfigFile; -const ConfigFileRender = ({ config }: { config: any }) => { - console.log("ConfigFileRender == ", config); +const ConfigFileRender = ({ config }: { config: unknown }) => { + console.log('ConfigFileRender == ', config); const monaco = useMonaco(); const editorRef = useRef(null); useEffect(() => { if (!monaco) return; - (monaco.languages as any).json?.jsonDefaults?.setDiagnosticsOptions({ + ( + monaco.languages as unknown as LegacyJsonLanguages + ).json?.jsonDefaults?.setDiagnosticsOptions({ enableSchemaRequest: false, allowComments: false, validate: true, diff --git a/src/features/DebugConsole/ConsoleWindow.tsx b/src/features/DebugConsole/ConsoleWindow.tsx index 516e0f7..5bb2c73 100644 --- a/src/features/DebugConsole/ConsoleWindow.tsx +++ b/src/features/DebugConsole/ConsoleWindow.tsx @@ -1,8 +1,8 @@ -import { useState } from "react"; -import { Col, Container, Row } from "react-bootstrap"; -import ScrollToBottom from "react-scroll-to-bottom"; -import { LogMessage } from "../../shared/types/LogMessage"; -import LogMessageDetailDrawer from "./LogMessageDetailDrawer"; +import { useState } from 'react'; +import { Col, Container, Row } from 'react-bootstrap'; +import ScrollToBottom from 'react-scroll-to-bottom'; +import { LogMessage } from '../../shared/types/LogMessage'; +import LogMessageDetailDrawer from './LogMessageDetailDrawer'; const Content = ({ filteredItems }: ConsoleWindowProps) => { const [showDrawer, setShowDrawer] = useState(false); @@ -20,15 +20,22 @@ const Content = ({ filteredItems }: ConsoleWindowProps) => { return ( <> - + {filteredItems.map((message, index) => ( clickItem(message)} - className={"cursor-pointer hover " + (selectedItem === message ? "bg-primary text-white" : (index % 2 === 0 ? "bg-light" : "bg-white"))} + className={ + 'cursor-pointer hover ' + + (selectedItem === message + ? 'bg-primary text-white' + : index % 2 === 0 + ? 'bg-light' + : 'bg-white') + } >
{message.Timestamp} - {message.Properties?.Key || "global"} + {message.Properties?.Key || 'global'}{message.Level}{message.RenderedMessage} @@ -62,7 +69,7 @@ const ConsoleWindow = ({ filteredItems }: ConsoleWindowProps) => { className="overflow-auto flex-grow-1" followButtonClassName="btn btn-sm btn-outline-secondary" mode="bottom" - initialScrollBehavior='auto' + initialScrollBehavior="auto" > diff --git a/src/features/DebugConsole/DebugConsole.tsx b/src/features/DebugConsole/DebugConsole.tsx index 86ad70f..f1f89ae 100644 --- a/src/features/DebugConsole/DebugConsole.tsx +++ b/src/features/DebugConsole/DebugConsole.tsx @@ -7,28 +7,41 @@ import { useGetDoNotLoadConfigOnNextBootQuery, useSetDoNotLoadConfigOnNextBootMutation, useSetLoadConfigMutation, - useSetRestartMutation + useSetRestartMutation, } from '../../store/apiSlice'; import { selectSearchText } from '../../store/debugConsole/debugConsoleSelectors'; import { debugConsoleActions } from '../../store/debugConsole/debugConsoleSlice'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; import type { RootState } from '../../store/store'; +import { downloadText } from '../../shared/functions/downloadFile'; import ConsoleWindow from './ConsoleWindow'; import { DebugFilters } from './DebugFilters'; import MinimumLogLevelDropdown from './MinimumLogLevelDropdown'; import RestartConfirmModal from './RestartConfirmModal'; import { useFilteredMessages } from './hooks/useFilteredMessages'; -const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => { +const DebugConsole = ({ + isConnected, + isConnecting, + join, + stop, + clear, +}: DebugConsoleProps) => { //* HOOKS ***********************************************************/ const [showModal, setShowModal] = useState(false); const { appId } = useAppParams(); const dispatch = useAppDispatch(); - const messages = useAppSelector((state: RootState) => state.websocket.messages); - const failedUrls = useAppSelector((state: RootState) => state.websocket.failedUrls); + const messages = useAppSelector( + (state: RootState) => state.websocket.messages + ); + const failedUrls = useAppSelector( + (state: RootState) => state.websocket.failedUrls + ); const searchText = useAppSelector(selectSearchText); const certUrls = failedUrls - ? failedUrls.map((u: string) => new URL(u).origin.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:')) + ? failedUrls.map((u: string) => + new URL(u).origin.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:') + ) : null; const { data: doNotLoadConfigOnNextBoot } = @@ -43,19 +56,15 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => { const exportFilteredItems = () => { const content = filteredItems - .map((item) => `${item.Timestamp} [${item.Level}]${item.Properties?.Key ? ` [${item.Properties.Key}]` : ''} ${item.RenderedMessage}`) + .map( + (item) => + `${item.Timestamp} [${item.Level}]${item.Properties?.Key ? ` [${item.Properties.Key}]` : ''} ${item.RenderedMessage}` + ) .join('\n'); - const blob = new Blob([content], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `debug-log-${new Date().toISOString().replace(/[:.]/g, '-')}.log`; - document.body.appendChild(a); - a.click(); - setTimeout(() => { - document.body.removeChild(a); - URL.revokeObjectURL(url); - }, 0); + downloadText( + `debug-log-${new Date().toISOString().replace(/[:.]/g, '-')}.log`, + content + ); }; const clickRestart = () => { @@ -63,9 +72,9 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => { }; const clickLoadConfig = () => { - if(!appId) return; - console.log("Loading config"); - loadConfig({ appId }); + if (!appId) return; + console.log('Loading config'); + void loadConfig({ appId }); }; if (!doNotLoadConfigOnNextBoot || !appId) return null; @@ -79,14 +88,24 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => {
{!isConnected ? ( - - ) - : ( - + + ) : ( + )} { id="doNotLoadConfig" checked={doNotLoadConfigOnNextBoot?.doNotLoadConfigOnNextBoot} onChange={() => { - if(!appId) return; - setDoNotLoadConfig( - { appId, doNotLoadConfigOnNextBoot: !doNotLoadConfigOnNextBoot?.doNotLoadConfigOnNextBoot } - ) + if (!appId) return; + void setDoNotLoadConfig({ + appId, + doNotLoadConfigOnNextBoot: + !doNotLoadConfigOnNextBoot?.doNotLoadConfigOnNextBoot, + }); }} /> {doNotLoadConfigOnNextBoot?.doNotLoadConfigOnNextBoot && ( - )} - + )} +
{certUrls && certUrls.length > 0 && ( - - Connection failed. The debug server may have an untrusted certificate.{' '} + + Connection failed. The debug server may have an + untrusted certificate.{' '} {certUrls.map((certUrl: string, i: number) => ( {i > 0 && ' or '} @@ -157,18 +179,20 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => { dispatch(debugConsoleActions.setSearchText(val))} + onSearchChange={(val) => + dispatch(debugConsoleActions.setSearchText(val)) + } filters={} /> - + setShowModal(false)} handleConfirm={() => { - if(!appId) return; - restart({ appId }); + if (!appId) return; + void restart({ appId }); setShowModal(false); }} /> @@ -178,11 +202,10 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => { export default DebugConsole; - interface DebugConsoleProps { isConnected: boolean; - join: (appId: string) => void; + isConnecting: boolean; + join: (appId: string) => Promise; stop: (appId: string) => void; clear: () => void; } - diff --git a/src/features/DebugConsole/DebugFilters.tsx b/src/features/DebugConsole/DebugFilters.tsx index 90ede37..cb7eede 100644 --- a/src/features/DebugConsole/DebugFilters.tsx +++ b/src/features/DebugConsole/DebugFilters.tsx @@ -1,4 +1,3 @@ - import { skipToken } from '@reduxjs/toolkit/query'; import { useMemo } from 'react'; import { Button } from 'react-bootstrap'; @@ -10,7 +9,6 @@ import { useAppDispatch } from '../../store/hooks'; import { debugConsts } from './debugConsts'; import { DeviceFilterDropdown } from './DeviceFilterDropdown'; - export const DebugFilters = () => { const { appId } = useAppParams(); const { data: devices } = useGetDevicesQuery(appId ? { appId } : skipToken); diff --git a/src/features/DebugConsole/DeviceFilterDropdown.tsx b/src/features/DebugConsole/DeviceFilterDropdown.tsx index 99a325c..ca640fd 100644 --- a/src/features/DebugConsole/DeviceFilterDropdown.tsx +++ b/src/features/DebugConsole/DeviceFilterDropdown.tsx @@ -20,7 +20,9 @@ export const DeviceFilterDropdown = ({ items }: DeviceFilterDropdownProps) => { const dispatch = useAppDispatch(); const checkedDevices = useAppSelector(selectCheckedDevices); const deviceLevels = useAppSelector(selectDeviceLevels); - const [openLevelDropdowns, setOpenLevelDropdowns] = useState>({}); + const [openLevelDropdowns, setOpenLevelDropdowns] = useState< + Record + >({}); function handleCheckChange( event: ChangeEvent, @@ -40,7 +42,11 @@ export const DeviceFilterDropdown = ({ items }: DeviceFilterDropdownProps) => { return ( - + Devices {checkedDevices.length > 0 && ( @@ -75,7 +81,10 @@ export const DeviceFilterDropdown = ({ items }: DeviceFilterDropdownProps) => { - setOpenLevelDropdowns((prev) => ({ ...prev, [stringId]: isOpen })) + setOpenLevelDropdowns((prev) => ({ + ...prev, + [stringId]: isOpen, + })) } className="ms-auto" > @@ -99,7 +108,8 @@ export const DeviceFilterDropdown = ({ items }: DeviceFilterDropdownProps) => { > {opt.label} - ))} + ))}{' '} + )} diff --git a/src/features/DebugConsole/LogMessageDetailDrawer.tsx b/src/features/DebugConsole/LogMessageDetailDrawer.tsx index 5ac179b..f3c5ac4 100644 --- a/src/features/DebugConsole/LogMessageDetailDrawer.tsx +++ b/src/features/DebugConsole/LogMessageDetailDrawer.tsx @@ -1,5 +1,5 @@ -import { Offcanvas } from "react-bootstrap"; -import { LogMessage } from "../../shared/types/LogMessage"; +import { Offcanvas } from 'react-bootstrap'; +import { LogMessage } from '../../shared/types/LogMessage'; const LogMessageDetailDrawer = ({ show, diff --git a/src/features/DebugConsole/MinimumLogLevelDropdown.tsx b/src/features/DebugConsole/MinimumLogLevelDropdown.tsx index 333894b..8b8e491 100644 --- a/src/features/DebugConsole/MinimumLogLevelDropdown.tsx +++ b/src/features/DebugConsole/MinimumLogLevelDropdown.tsx @@ -1,15 +1,17 @@ import { skipToken } from '@reduxjs/toolkit/query'; -import { Dropdown } from "react-bootstrap"; +import { Dropdown } from 'react-bootstrap'; import useAppParams from '../../shared/hooks/useAppParams'; import { IconDarkChevronDown } from '../../shared/icons'; import { useGetMinimumLogLevelQuery, useSetMinimumLogLevelMutation, -} from "../../store/apiSlice"; +} from '../../store/apiSlice'; const MinimumLogLevelDropdown = () => { const { appId } = useAppParams(); - const { data: currentLogLevel } = useGetMinimumLogLevelQuery(appId ? { appId } : skipToken); + const { data: currentLogLevel } = useGetMinimumLogLevelQuery( + appId ? { appId } : skipToken + ); const [setLogLevel] = useSetMinimumLogLevelMutation(); @@ -28,42 +30,42 @@ const MinimumLogLevelDropdown = () => { { - setLogLevel({ appId, minimumLevel: "Information" }); + void setLogLevel({ appId, minimumLevel: 'Information' }); }} > Information { - setLogLevel({ appId, minimumLevel: "Warning" }); + void setLogLevel({ appId, minimumLevel: 'Warning' }); }} > Warning { - setLogLevel({ appId, minimumLevel: "Error" }); + void setLogLevel({ appId, minimumLevel: 'Error' }); }} > Error { - setLogLevel({ appId, minimumLevel: "Fatal" }); + void setLogLevel({ appId, minimumLevel: 'Fatal' }); }} > Fatal { - setLogLevel({ appId, minimumLevel: "Debug" }); + void setLogLevel({ appId, minimumLevel: 'Debug' }); }} > Debug { - setLogLevel({ appId, minimumLevel: "Verbose" }); + void setLogLevel({ appId, minimumLevel: 'Verbose' }); }} > Verbose diff --git a/src/features/DebugConsole/RestartConfirmModal.tsx b/src/features/DebugConsole/RestartConfirmModal.tsx index 25e2e18..050c33d 100644 --- a/src/features/DebugConsole/RestartConfirmModal.tsx +++ b/src/features/DebugConsole/RestartConfirmModal.tsx @@ -1,27 +1,27 @@ import { Button, Modal } from 'react-bootstrap'; -const RestartConfirmModal = ({show, handleClose, handleConfirm}: RestartConfirmModalProps) => { +const RestartConfirmModal = ({ + show, + handleClose, + handleConfirm, +}: RestartConfirmModalProps) => { return ( - - Restart Program - - Are you sure you want to restart the program? - - - - - - - ); -} + + Restart Program + + Are you sure you want to restart the program? + + + + + + ); +}; export default RestartConfirmModal; @@ -29,4 +29,4 @@ interface RestartConfirmModalProps { show: boolean; handleClose: () => void; handleConfirm: () => void; -} \ No newline at end of file +} diff --git a/src/features/DebugConsole/debugConsts.ts b/src/features/DebugConsole/debugConsts.ts index 4241db7..9ab43cb 100644 --- a/src/features/DebugConsole/debugConsts.ts +++ b/src/features/DebugConsole/debugConsts.ts @@ -1,19 +1,19 @@ -import { IdLabel } from "../../shared/types/IdLabel"; +import { IdLabel } from '../../shared/types/IdLabel'; -export const DEVICE = "device"; -export const SEARCH_TEXT = "searchText"; -export const AFTER = "after"; -export const BEFORE = "before"; -export const LOG_LEVEL = "logLevel"; +export const DEVICE = 'device'; +export const SEARCH_TEXT = 'searchText'; +export const AFTER = 'after'; +export const BEFORE = 'before'; +export const LOG_LEVEL = 'logLevel'; -export const GLOBAL = "global"; +export const GLOBAL = 'global'; -const ERROR = "Error"; -const INFORMATION = "Information"; -const WARNING = "Warning"; -const FATAL = "Fatal"; -const VERBOSE = "Verbose"; -const DEBUG = "Debug"; +const ERROR = 'Error'; +const INFORMATION = 'Information'; +const WARNING = 'Warning'; +const FATAL = 'Fatal'; +const VERBOSE = 'Verbose'; +const DEBUG = 'Debug'; const LOG_LEVELS = [ERROR, WARNING, INFORMATION, FATAL, VERBOSE, DEBUG]; @@ -27,12 +27,12 @@ export const LOG_LEVEL_ORDER: Record = { }; export const logLevelOpts: IdLabel[] = [ - { id: FATAL, label: "Fatal" }, - { id: ERROR, label: "Error" }, - { id: WARNING, label: "Warning" }, - { id: INFORMATION, label: "Information" }, - { id: DEBUG, label: "Debug" }, - { id: VERBOSE, label: "Verbose" }, + { id: FATAL, label: 'Fatal' }, + { id: ERROR, label: 'Error' }, + { id: WARNING, label: 'Warning' }, + { id: INFORMATION, label: 'Information' }, + { id: DEBUG, label: 'Debug' }, + { id: VERBOSE, label: 'Verbose' }, ]; export const debugSearchParams = { diff --git a/src/features/DeviceDetail.tsx b/src/features/DeviceDetail.tsx index 13ffd09..2d6cd6b 100644 --- a/src/features/DeviceDetail.tsx +++ b/src/features/DeviceDetail.tsx @@ -1,6 +1,6 @@ import { skipToken } from '@reduxjs/toolkit/query'; -import { useState } from "react"; -import { Button, Form, Modal } from "react-bootstrap"; +import { useState } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; import useAppParams from '../shared/hooks/useAppParams'; import { DeviceFeedbacks, @@ -11,16 +11,21 @@ import { useGetDeviceMethodsQuery, useGetDevicePropertiesQuery, useSetDeviceJsonCommandMutation, -} from "../store/apiSlice"; +} from '../store/apiSlice'; const DeviceDetail = ({ item }: DeviceDetailProps) => { const { appId } = useAppParams(); - const { data: properties } = useGetDevicePropertiesQuery(appId && item?.Key ? { appId, key: item.Key } : skipToken + const { data: properties } = useGetDevicePropertiesQuery( + appId && item?.Key ? { appId, key: item.Key } : skipToken + ); + const { data: methods } = useGetDeviceMethodsQuery( + appId && item?.Key ? { appId, key: item.Key } : skipToken + ); + const { data: feedbacks } = useGetDeviceFeedbacksQuery( + appId && item?.Key ? { appId, key: item.Key } : skipToken ); - const { data: methods } = useGetDeviceMethodsQuery(appId && item?.Key ? { appId, key: item.Key } : skipToken); - const { data: feedbacks } = useGetDeviceFeedbacksQuery(appId && item?.Key ? { appId, key: item.Key } : skipToken); - console.log("DeviceDetail == ", { item, properties, methods, feedbacks }); + console.log('DeviceDetail == ', { item, properties, methods, feedbacks }); if (!properties || !methods) { return
Loading...
; @@ -49,13 +54,16 @@ const DeviceDetailRender = ({ deviceKey, }: DeviceDetailRenderProps) => { const { appId } = useAppParams(); - const [selectedMethod, setSelectedMethod] = useState(null); + const [selectedMethod, setSelectedMethod] = useState( + null + ); const [paramValues, setParamValues] = useState>({}); - const [executeMethod, { isLoading: isExecuting }] = useSetDeviceJsonCommandMutation(); + const [executeMethod, { isLoading: isExecuting }] = + useSetDeviceJsonCommandMutation(); const handleOpen = (method: DeviceMethods) => { setSelectedMethod(method); - setParamValues(Object.fromEntries(method.Params.map((p) => [p.Name, ""]))); + setParamValues(Object.fromEntries(method.Params.map((p) => [p.Name, '']))); }; const handleClose = () => { @@ -65,7 +73,12 @@ const DeviceDetailRender = ({ const handleExecute = async () => { if (!selectedMethod || !appId) return; - await executeMethod({ appId, deviceKey, methodName: selectedMethod.Name, params: Object.values(paramValues) }); + await executeMethod({ + appId, + deviceKey, + methodName: selectedMethod.Name, + params: Object.values(paramValues), + }); handleClose(); }; @@ -90,8 +103,8 @@ const DeviceDetailRender = ({
- - + + ))} @@ -111,10 +124,17 @@ const DeviceDetailRender = ({ ))} @@ -133,14 +153,18 @@ const DeviceDetailRender = ({ {selectedMethod?.Params.map((param) => ( - {param.Name} ({param.Type}) + {param.Name}{' '} + ({param.Type}) - setParamValues((prev) => ({ ...prev, [param.Name]: e.target.value })) + setParamValues((prev) => ({ + ...prev, + [param.Name]: e.target.value, + })) } /> @@ -149,9 +173,15 @@ const DeviceDetailRender = ({ )} - - + @@ -176,7 +206,9 @@ const DeviceDetailRender = ({ )) ) : ( - + + + )}
{path.Name} {path.Url}
{p.Name} {p.Type} {p.Value}{p.CanRead ? "Yes" : "No"}{p.canWrite ? "Yes" : "No"}{p.CanRead ? 'Yes' : 'No'}{p.canWrite ? 'Yes' : 'No'}
{m.Name} - {m.Params.map((param) => `${param.Name}: ${param.Type}`).join(", ")} + {m.Params.map((param) => `${param.Name}: ${param.Type}`).join( + ', ' + )} - +
None
None
@@ -198,7 +230,9 @@ const DeviceDetailRender = ({ )) ) : ( - None + + None + )} @@ -220,7 +254,9 @@ const DeviceDetailRender = ({ )) ) : ( - None + + None + )} diff --git a/src/features/DeviceList.tsx b/src/features/DeviceList.tsx index ca75af8..896c27e 100644 --- a/src/features/DeviceList.tsx +++ b/src/features/DeviceList.tsx @@ -1,8 +1,8 @@ import { skipToken } from '@reduxjs/toolkit/query'; -import { useState } from "react"; +import { useState } from 'react'; import useAppParams from '../shared/hooks/useAppParams'; -import { IKeyed, useGetDevicesQuery } from "../store/apiSlice"; -import DeviceDetail from "./DeviceDetail"; +import { IKeyed, useGetDevicesQuery } from '../store/apiSlice'; +import DeviceDetail from './DeviceDetail'; const DeviceList = () => { const [selectedDevice, setSelectedDevice] = useState(); @@ -29,7 +29,7 @@ const DeviceList = () => { {devices.map((i) => ( setSelectedDevice(i)} > {i.Key} diff --git a/src/features/ErrorBoundary.tsx b/src/features/ErrorBoundary.tsx index 46087e6..e9b17b3 100644 --- a/src/features/ErrorBoundary.tsx +++ b/src/features/ErrorBoundary.tsx @@ -1,5 +1,5 @@ -import { Component, ErrorInfo, ReactNode } from "react"; -import ErrorBox from "./ErrorBox"; +import { Component, ErrorInfo, ReactNode } from 'react'; +import ErrorBox from './ErrorBox'; interface Props { children: ReactNode; @@ -17,7 +17,7 @@ class ErrorBoundary extends Component { } componentDidCatch(error: Error, info: ErrorInfo) { - console.error("ErrorBoundary caught an error:", error, info.componentStack); + console.error('ErrorBoundary caught an error:', error, info.componentStack); } render() { diff --git a/src/features/Help/Help.test.tsx b/src/features/Help/Help.test.tsx new file mode 100644 index 0000000..17cb615 --- /dev/null +++ b/src/features/Help/Help.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import Help from './Help'; + +const renderHelp = (initialPath: string) => + render( + + + } /> + + + ); + +const getSidebar = () => screen.getByRole('navigation'); + +describe('Help', () => { + it('renders the docs home page with sidebar navigation at /help', () => { + renderHelp('/help'); + expect( + screen.getByRole('heading', { + name: /PepperDash Essentials Web Config App Documentation/i, + }) + ).toBeInTheDocument(); + + const sidebar = getSidebar(); + expect( + within(sidebar).getByRole('link', { name: 'Tutorials' }) + ).toBeInTheDocument(); + expect( + within(sidebar).getByRole('link', { name: 'How-to Guides' }) + ).toBeInTheDocument(); + }); + + it('navigates client-side when following an internal doc link', async () => { + renderHelp('/help/tutorials/debug-console-basics'); + + const howToLink = within(getSidebar()).getByRole('link', { + name: 'How-to Guides', + }); + fireEvent.click(howToLink); + + expect( + await screen.findByRole('heading', { + name: /How-to Guides - Problem-Oriented Solutions/i, + }) + ).toBeInTheDocument(); + }); + + it('opens external links in a new tab instead of routing internally', () => { + renderHelp('/help'); + const externalLink = screen.getAllByRole('link', { + name: 'Diataxis framework', + })[0]; + expect(externalLink).toHaveAttribute('href', 'https://diataxis.fr/'); + expect(externalLink).toHaveAttribute('target', '_blank'); + }); + + it('renders GFM tables from reference docs as styled Bootstrap tables', () => { + renderHelp('/help/reference/log-levels'); + const tables = screen.getAllByRole('table'); + expect(tables.length).toBeGreaterThan(0); + tables.forEach((table) => + expect(table).toHaveClass('table', 'table-striped') + ); + expect(screen.getAllByText('Fatal').length).toBeGreaterThan(0); + }); + + it('shows a not-found message with a way back for an unknown slug', () => { + renderHelp('/help/does/not/exist'); + expect(screen.getByText(/page not found/i)).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /back to help/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/src/features/Help/Help.tsx b/src/features/Help/Help.tsx new file mode 100644 index 0000000..76f7d07 --- /dev/null +++ b/src/features/Help/Help.tsx @@ -0,0 +1,31 @@ +import { Link, useParams } from 'react-router-dom'; +import HelpArticle from './HelpArticle'; +import HelpSidebar from './HelpSidebar'; +import { getDocBySlug } from './docsContent'; + +const Help = () => { + const params = useParams(); + const slug = params['*'] ?? ''; + const doc = getDocBySlug(slug); + + return ( +
+

Help

+
+ +
+ {doc ? ( + + ) : ( +
+

Page not found.

+ Back to Help +
+ )} +
+
+
+ ); +}; + +export default Help; diff --git a/src/features/Help/HelpArticle.tsx b/src/features/Help/HelpArticle.tsx new file mode 100644 index 0000000..73301e0 --- /dev/null +++ b/src/features/Help/HelpArticle.tsx @@ -0,0 +1,53 @@ +import { Link } from 'react-router-dom'; +import ReactMarkdown from 'react-markdown'; +import type { Components } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { DocEntry, resolveRelativeLink } from './docsContent'; + +const DocLink = ({ + currentSlug, + href, + children, +}: { + currentSlug: string; + href?: string; + children?: React.ReactNode; +}) => { + if (!href) return <>{children}; + + if (/^(https?:|mailto:)/i.test(href)) { + return ( + + {children} + + ); + } + + const resolved = resolveRelativeLink(currentSlug, href); + if (resolved) { + return {children}; + } + + return {children}; +}; + +const HelpArticle = ({ doc }: { doc: DocEntry }) => { + const components: Components = { + a: ({ href, children }) => ( + + {children} + + ), + table: ({ children }) => ( + {children}
+ ), + }; + + return ( + + {doc.content} + + ); +}; + +export default HelpArticle; diff --git a/src/features/Help/HelpSidebar.tsx b/src/features/Help/HelpSidebar.tsx new file mode 100644 index 0000000..f8d3956 --- /dev/null +++ b/src/features/Help/HelpSidebar.tsx @@ -0,0 +1,47 @@ +import { NavLink } from 'react-router-dom'; +import { docsNavTree } from './docsContent'; + +const HelpSidebar = () => { + return ( + + ); +}; + +export default HelpSidebar; diff --git a/src/features/Help/docsContent.test.ts b/src/features/Help/docsContent.test.ts new file mode 100644 index 0000000..0bc2631 --- /dev/null +++ b/src/features/Help/docsContent.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { + docsMap, + docsNavTree, + getDocBySlug, + resolveRelativeLink, +} from './docsContent'; + +describe('docsContent', () => { + it('indexes the root docs README under the empty slug', () => { + const root = getDocBySlug(''); + expect(root).toBeDefined(); + expect(root?.isIndex).toBe(true); + expect(root?.title).toMatch(/Documentation/i); + }); + + it('indexes category READMEs and leaf docs', () => { + expect(getDocBySlug('tutorials')?.isIndex).toBe(true); + expect(getDocBySlug('tutorials/getting-started')?.isIndex).toBe(false); + expect(getDocBySlug('tutorials/getting-started')?.category).toBe( + 'tutorials' + ); + }); + + it('orders tutorial pages the way tutorials/README.md links them, not alphabetically', () => { + const tutorials = docsNavTree.find((c) => c.category === 'tutorials'); + expect(tutorials?.pages.map((p) => p.slug)).toEqual([ + 'tutorials/getting-started', + 'tutorials/debug-console-basics', + 'tutorials/device-management-basics', + ]); + }); + + it('resolves a same-directory relative link', () => { + expect( + resolveRelativeLink( + 'tutorials/debug-console-basics', + './getting-started.md' + ) + ).toBe('/help/tutorials/getting-started'); + }); + + it('resolves a parent-directory folder link to a category index', () => { + expect( + resolveRelativeLink('tutorials/debug-console-basics', '../how-to/') + ).toBe('/help/how-to'); + }); + + it('resolves a root-relative README link to the docs home', () => { + expect( + resolveRelativeLink('tutorials/getting-started', '../README.md') + ).toBe('/help'); + }); + + it('returns null for external and anchor links', () => { + expect(resolveRelativeLink('', 'https://diataxis.fr/')).toBeNull(); + expect(resolveRelativeLink('', 'mailto:test@example.com')).toBeNull(); + expect(resolveRelativeLink('tutorials', '#some-heading')).toBeNull(); + }); + + it('resolves a relative link with a trailing heading fragment, preserving the fragment', () => { + expect( + resolveRelativeLink( + 'how-to/trace-signal-routes', + '../reference/ui-components.md#routing-diagram' + ) + ).toBe('/help/reference/ui-components#routing-diagram'); + }); + + it('has every doc reachable from docsMap for links found across the docs', () => { + expect(docsMap.size).toBeGreaterThan(0); + }); + + it('includes the routing how-to guides in the how-to nav category', () => { + const howTo = docsNavTree.find((c) => c.category === 'how-to'); + const slugs = howTo?.pages.map((p) => p.slug); + expect(slugs).toContain('how-to/trace-signal-routes'); + expect(slugs).toContain('how-to/change-routes'); + }); + + // Nav order comes from the ](./slug.md) links in the category README, so a page that isn't + // linked there silently sorts to the end instead of next to its sibling. + it('includes the secrets how-to in the how-to nav category', () => { + const howTo = docsNavTree.find((c) => c.category === 'how-to'); + expect(howTo?.pages.map((p) => p.slug)).toContain('how-to/manage-secrets'); + }); + + it('orders the route-changing guide directly after the tracing guide', () => { + const howTo = docsNavTree.find((c) => c.category === 'how-to'); + const slugs = howTo?.pages.map((p) => p.slug) ?? []; + expect(slugs.indexOf('how-to/change-routes')).toBe( + slugs.indexOf('how-to/trace-signal-routes') + 1 + ); + }); +}); diff --git a/src/features/Help/docsContent.ts b/src/features/Help/docsContent.ts new file mode 100644 index 0000000..903780f --- /dev/null +++ b/src/features/Help/docsContent.ts @@ -0,0 +1,176 @@ +export type DocCategory = 'tutorials' | 'how-to' | 'reference' | 'explanation'; + +export interface DocEntry { + slug: string; + category: DocCategory | null; + title: string; + content: string; + isIndex: boolean; +} + +interface CategoryNav { + category: DocCategory; + label: string; + indexSlug: string; + pages: { slug: string; title: string }[]; +} + +const CATEGORY_LABELS: Record = { + tutorials: 'Tutorials', + 'how-to': 'How-to Guides', + reference: 'Reference', + explanation: 'Explanation', +}; + +const CATEGORY_ORDER: DocCategory[] = [ + 'tutorials', + 'how-to', + 'reference', + 'explanation', +]; + +function normalizePath(raw: string): { slug: string; isIndex: boolean } { + const trimmed = raw.replace(/^\/docs\//, '').replace(/\.md$/, ''); + if (trimmed === 'README') { + return { slug: '', isIndex: true }; + } + if (trimmed.endsWith('/README')) { + return { slug: trimmed.slice(0, -'/README'.length), isIndex: true }; + } + return { slug: trimmed, isIndex: false }; +} + +function deriveTitle(content: string, slug: string): string { + const match = content.match(/^#\s+(.+)$/m); + if (match) return match[1].trim(); + const last = slug.split('/').pop() || slug; + return last + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +// Joins a markdown-relative href against the directory of the doc that +// contains it, collapsing "." and ".." segments manually (no Node `path` +// module available in the browser bundle). +function joinRelative(baseDir: string, href: string): string { + const baseSegments = baseDir ? baseDir.split('/') : []; + const hrefSegments = href.split('/'); + const stack = [...baseSegments]; + + for (const segment of hrefSegments) { + if (segment === '' || segment === '.') continue; + if (segment === '..') { + stack.pop(); + } else { + stack.push(segment); + } + } + + return stack.join('/'); +} + +function buildDocsMap(): Map { + const rawModules = import.meta.glob('/docs/**/*.md', { + query: '?raw', + import: 'default', + eager: true, + }) as Record; + + const map = new Map(); + + for (const [path, content] of Object.entries(rawModules)) { + const { slug, isIndex } = normalizePath(path); + const category = slug + ? ((slug.split('/')[0] as DocCategory) ?? null) + : null; + + map.set(slug, { + slug, + category: CATEGORY_ORDER.includes(category as DocCategory) + ? (category as DocCategory) + : null, + title: deriveTitle(content, slug), + content, + isIndex, + }); + } + + return map; +} + +// Extracts the order pages are linked in a category's README (e.g. +// "[Getting Started](./getting-started.md)") so the in-app nav mirrors the +// deliberate, pedagogical ordering the docs authors already chose, rather +// than falling back to alphabetical order for everything. +function extractLinkOrder(readmeContent: string): string[] { + const order: string[] = []; + const linkPattern = /\]\(\.\/([a-z0-9-]+)\.md\)/g; + let match: RegExpExecArray | null; + while ((match = linkPattern.exec(readmeContent)) !== null) { + if (!order.includes(match[1])) order.push(match[1]); + } + return order; +} + +function buildNavTree(docsMap: Map): CategoryNav[] { + return CATEGORY_ORDER.map((category) => { + const indexEntry = docsMap.get(category); + const linkOrder = indexEntry ? extractLinkOrder(indexEntry.content) : []; + + const pages = Array.from(docsMap.values()) + .filter((doc) => doc.category === category && !doc.isIndex) + .sort((a, b) => { + const aName = a.slug.split('/').pop() ?? a.slug; + const bName = b.slug.split('/').pop() ?? b.slug; + const aIndex = linkOrder.indexOf(aName); + const bIndex = linkOrder.indexOf(bName); + if (aIndex === -1 && bIndex === -1) return aName.localeCompare(bName); + if (aIndex === -1) return 1; + if (bIndex === -1) return -1; + return aIndex - bIndex; + }) + .map((doc) => ({ slug: doc.slug, title: doc.title })); + + return { + category, + label: CATEGORY_LABELS[category], + indexSlug: category, + pages, + }; + }); +} + +export const docsMap = buildDocsMap(); +export const docsNavTree = buildNavTree(docsMap); + +export function getDocBySlug(slug: string): DocEntry | undefined { + return docsMap.get(slug); +} + +// Resolves a markdown-relative link found inside the doc at `currentSlug` +// (e.g. "./foo.md", "../how-to/", "../tutorials/getting-started.md") into +// an in-app "/help/" path. Returns null if `href` isn't a relative +// doc link (callers should render those as plain external anchors). +export function resolveRelativeLink( + currentSlug: string, + href: string +): string | null { + if (/^([a-z][a-z0-9+.-]*:|#)/i.test(href)) return null; + + const hashIndex = href.indexOf('#'); + const path = hashIndex === -1 ? href : href.slice(0, hashIndex); + const hash = hashIndex === -1 ? '' : href.slice(hashIndex); + + const currentDir = currentSlug.includes('/') + ? currentSlug.slice(0, currentSlug.lastIndexOf('/')) + : ''; + + const joined = joinRelative(currentDir, path) + .replace(/\.md$/, '') + .replace(/\/$/, ''); + const { slug } = normalizePath(`/docs/${joined}.md`); + + if (!docsMap.has(slug)) return null; + return (slug === '' ? '/help' : `/help/${slug}`) + hash; +} diff --git a/src/features/InitializationExceptions.tsx b/src/features/InitializationExceptions.tsx index b14e77a..aa87326 100644 --- a/src/features/InitializationExceptions.tsx +++ b/src/features/InitializationExceptions.tsx @@ -1,15 +1,15 @@ -import { skipToken } from "@reduxjs/toolkit/query"; -import { Fragment, useState } from "react"; -import useAppParams from "../shared/hooks/useAppParams"; +import { skipToken } from '@reduxjs/toolkit/query'; +import { Fragment, useState } from 'react'; +import useAppParams from '../shared/hooks/useAppParams'; import { EssentialsException, useGetInitializationExceptionsQuery, -} from "../store/apiSlice"; +} from '../store/apiSlice'; const InitializationExceptions = () => { const { appId } = useAppParams(); const { data, isLoading, isError } = useGetInitializationExceptionsQuery( - appId ? { appId } : skipToken, + appId ? { appId } : skipToken ); const [expandedIndex, setExpandedIndex] = useState(null); @@ -37,9 +37,9 @@ const InitializationExceptions = () => { - + - + @@ -62,7 +62,7 @@ const InitializationExceptions = () => { setExpandedIndex(isExpanded ? null : idx) } > - {isExpanded ? "Hide" : "Show"} + {isExpanded ? 'Hide' : 'Show'} )} @@ -72,7 +72,10 @@ const InitializationExceptions = () => { - + + + + + + + )); + + return ( +
+
+

Secrets

+

+ Credentials stored on the processor, referenced from device configs as{' '} + {'{"secret": {"provider": "…", "key": "…"}}'}. +

+
+ +
+ Stored values are never readable, here or anywhere else. Replacing a + secret means entering the new value in full; a forgotten credential + cannot be recovered. +
+ +
+ setProvider(e.target.value)} + aria-label="Secret provider" + > + {(providers.length > 0 + ? providers + : [{ key: DEFAULT_PROVIDER, scope: 'local' }] + ).map((item) => ( + + ))} + + + setFilter(e.target.value)} + aria-label="Filter secrets" + /> + +
+ + +
+
+ + {data.indexStatus && data.indexStatus !== 'ok' && ( +
+ The record of which secrets were created here is{' '} + {data.indexStatus === 'missing' ? 'missing' : 'damaged'}, so + everything below is listed as unmanaged. The secrets themselves are + unaffected and still work. +
+ )} + + {data.enumerationComplete === false && ( +
+ The processor could not list every record, so this page may be + incomplete. +
+ )} + + {commandError && ( +
+ {commandError} +
+ )} + +
+
Managed secrets
+
## MessageStack traceStack trace
                           {ex.StackTrace}
                         
diff --git a/src/features/LoginForm.tsx b/src/features/LoginForm.tsx index 922a71c..f21bea8 100644 --- a/src/features/LoginForm.tsx +++ b/src/features/LoginForm.tsx @@ -1,6 +1,7 @@ import { FormEvent, useState } from 'react'; -import { Alert, Button, Form, Spinner } from 'react-bootstrap'; -import { Navigate, useLocation, useNavigate } from 'react-router-dom'; +import { Alert, Button, Form, InputGroup, Spinner } from 'react-bootstrap'; +import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom'; +import EyeIcon from '../shared/components/EyeIcon'; import useAppParams from '../shared/hooks/useAppParams'; import { useSetLoginCredentialsMutation } from '../store/apiSlice'; import { @@ -11,16 +12,16 @@ import { authActions } from '../store/auth/authSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; const ALL_APP_IDS = [ - "app01", - "app02", - "app03", - "app04", - "app05", - "app06", - "app07", - "app08", - "app09", - "app10", + 'app01', + 'app02', + 'app03', + 'app04', + 'app05', + 'app06', + 'app07', + 'app08', + 'app09', + 'app10', ]; const LoginForm = () => { @@ -31,8 +32,9 @@ const LoginForm = () => { const navigate = useNavigate(); const location = useLocation(); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -56,39 +58,42 @@ const LoginForm = () => { // Send login to all program slots in parallel const results = await Promise.allSettled( ALL_APP_IDS.map((id) => - setLoginCredentials({ appId: id, username, password }).unwrap(), - ), + setLoginCredentials({ appId: id, username, password }).unwrap() + ) ); const availableApps = ALL_APP_IDS.filter( - (_, i) => results[i].status === "fulfilled", + (_, i) => results[i].status === 'fulfilled' ); setIsLoading(false); if (availableApps.length === 0) { - setError("Invalid credentials. Please try again."); + setError('Invalid credentials. Please try again.'); return; } dispatch(authActions.loginSuccess(availableApps)); const destination = from ?? `/${availableApps[0] ?? probeAppId}/versions`; - navigate(destination, { replace: true }); + void navigate(destination, { replace: true }); } return (
- - Version: {APP_VERSION} - +
+ + Help + + Version: {APP_VERSION} +

PepperDash Essentials Developer Tools

-
+

Sign In

{error && {error}} -
+ void handleSubmit(e)}> Username { Password - setPassword(e.target.value)} - required - disabled={isLoading} - /> + + setPassword(e.target.value)} + required + disabled={isLoading} + /> + +
diff --git a/src/features/MainLayout.tsx b/src/features/MainLayout.tsx index 7225aa6..e632d4b 100644 --- a/src/features/MainLayout.tsx +++ b/src/features/MainLayout.tsx @@ -1,16 +1,15 @@ -import { Outlet } from 'react-router-dom' -import TopNav from './TopNav' - +import { Outlet } from 'react-router-dom'; +import TopNav from './TopNav'; const MainLayout = ({ isConnected }: { isConnected: boolean }) => { return (
-
+
- ) -} + ); +}; -export default MainLayout \ No newline at end of file +export default MainLayout; diff --git a/src/features/MobileControl.tsx b/src/features/MobileControl.tsx index 2bfdb2b..a75071f 100644 --- a/src/features/MobileControl.tsx +++ b/src/features/MobileControl.tsx @@ -1,7 +1,7 @@ -import { skipToken } from "@reduxjs/toolkit/query"; -import { useState } from "react"; -import { Button, Modal } from "react-bootstrap"; -import useAppParams from "../shared/hooks/useAppParams"; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useState } from 'react'; +import { Button, Modal } from 'react-bootstrap'; +import useAppParams from '../shared/hooks/useAppParams'; import { ActionPath, ClientRequest, @@ -12,16 +12,16 @@ import { useDeleteMobileControlUiClientMutation, useGetMobileControlActionPathsQuery, useGetMobileControlInfoQuery, -} from "../store/apiSlice"; +} from '../store/apiSlice'; const MobileControl = () => { const { appId } = useAppParams(); const { data: info } = useGetMobileControlInfoQuery( - appId ? { appId, deviceKey: "appServer" } : skipToken, + appId ? { appId, deviceKey: 'appServer' } : skipToken ); const { data: actionPaths } = useGetMobileControlActionPathsQuery( - appId ? { appId, deviceKey: "appServer" } : skipToken, + appId ? { appId, deviceKey: 'appServer' } : skipToken ); const [deleteClient] = useDeleteMobileControlUiClientMutation(); @@ -31,19 +31,19 @@ const MobileControl = () => { useState(null); const [confirmDeleteAll, setConfirmDeleteAll] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false); - const [newRoomKey, setNewRoomKey] = useState(""); - const [newGrantCode, setNewGrantCode] = useState(""); + const [newRoomKey, setNewRoomKey] = useState(''); + const [newGrantCode, setNewGrantCode] = useState(''); const handleConfirmDelete = async () => { if (!appId || !pendingDelete) return; const clientPayload: ClientResponse = { - error: "", + error: '', token: pendingDelete.token, - path: "", + path: '', }; await deleteClient({ appId, - deviceKey: "appServer-directServer", + deviceKey: 'appServer-directServer', client: clientPayload, }); setPendingDelete(null); @@ -51,7 +51,7 @@ const MobileControl = () => { const handleConfirmDeleteAll = async () => { if (!appId) return; - await deleteAllClients({ appId, deviceKey: "appServer-directServer" }); + await deleteAllClients({ appId, deviceKey: 'appServer-directServer' }); setConfirmDeleteAll(false); }; @@ -60,12 +60,12 @@ const MobileControl = () => { const request: ClientRequest = { roomKey: newRoomKey.trim(), grantCode: newGrantCode.trim(), - token: "", + token: '', }; - await createClient({ appId, deviceKey: "appServer-directServer", request }); + await createClient({ appId, deviceKey: 'appServer-directServer', request }); setShowCreateModal(false); - setNewRoomKey(""); - setNewGrantCode(""); + setNewRoomKey(''); + setNewGrantCode(''); }; if (!info || !actionPaths) { @@ -115,9 +115,7 @@ const MobileControl = () => {
# Room Key Touchpanel Key - Token - Token URL
@@ -155,7 +153,7 @@ const MobileControl = () => { {client.url} -
+ - @@ -235,7 +233,10 @@ const MobileControl = () => { > Cancel - @@ -281,7 +282,7 @@ const MobileControl = () => { + )} + {sourceName} + + ); + })} + + + ); +}; + +export default MultiviewLayoutCanvas; diff --git a/src/features/MultiviewLayoutPanel.module.scss b/src/features/MultiviewLayoutPanel.module.scss new file mode 100644 index 0000000..71d64eb --- /dev/null +++ b/src/features/MultiviewLayoutPanel.module.scss @@ -0,0 +1,65 @@ +.panel { + position: absolute; + width: 280px; + z-index: 20; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + border-radius: 6px; + overflow: hidden; + background: #fff; + border: 1px solid #ccc; +} + +.panelDark { + background: #1e1e1e; + border-color: #444; + color: #e0e0e0; +} + +.titleBar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.35rem 0.5rem; + font-size: 0.78rem; + font-weight: 600; + cursor: move; + -webkit-user-select: none; + user-select: none; + background: #e9ecef; + border-bottom: 1px solid #ccc; +} + +.titleBarDark { + background: #2c2c2c; + border-bottom-color: #444; + color: #e0e0e0; +} + +.title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-grow: 1; + margin-right: 0.5rem; +} + +.closeBtn { + flex-shrink: 0; + background: none; + border: none; + padding: 0 2px; + line-height: 1; + font-size: 1.1rem; + color: inherit; + opacity: 0.7; + cursor: pointer; + + &:hover { + opacity: 1; + color: #dc3545; + } +} + +.body { + padding: 0.5rem; +} diff --git a/src/features/MultiviewLayoutPanel.tsx b/src/features/MultiviewLayoutPanel.tsx new file mode 100644 index 0000000..756bcee --- /dev/null +++ b/src/features/MultiviewLayoutPanel.tsx @@ -0,0 +1,123 @@ +import { useCallback, useRef } from 'react'; + +import { MultiviewLayoutState, MultiviewTileState } from '../store/apiSlice'; +import MultiviewLayoutCanvas from './MultiviewLayoutCanvas'; +import styles from './MultiviewLayoutPanel.module.scss'; + +export interface MultiviewLayoutPanelPosition { + x: number; + y: number; +} + +export interface MultiviewLayoutPanelProps { + title: string; + layout: MultiviewLayoutState; + position: MultiviewLayoutPanelPosition; + darkMode?: boolean; + selectedTileNumber?: number | null; + resolveSourceName: (deviceKey: string) => string; + onTileClick?: (tile: MultiviewTileState) => void; + /** Opens the route popover for a tile. Omitted when route editing is unavailable. */ + onTileEditClick?: (tile: MultiviewTileState, rect: DOMRect) => void; + onClose: () => void; + onMove: (position: MultiviewLayoutPanelPosition) => void; +} + +/** + * A floating, freely-draggable window showing a single device's multiview canvas/tile mock-up + * (via MultiviewLayoutCanvas). Rendered as a sibling of the React Flow canvas (not as part of a + * graph node), so its position is independent of node positions - which move whenever the dagre + * layout re-runs in response to routing/filter changes. Stays open until explicitly closed, and + * multiple panels (one per device) can be open and dragged around independently at once. + */ +const MultiviewLayoutPanel = ({ + title, + layout, + position, + darkMode, + selectedTileNumber, + resolveSourceName, + onTileClick, + onTileEditClick, + onClose, + onMove, +}: MultiviewLayoutPanelProps) => { + const dragStateRef = useRef<{ + startX: number; + startY: number; + originX: number; + originY: number; + } | null>(null); + + const handleTitleBarPointerDown = useCallback( + (e: React.PointerEvent) => { + // Only left-click / primary touch should start a drag. + if (e.button !== 0) return; + + dragStateRef.current = { + startX: e.clientX, + startY: e.clientY, + originX: position.x, + originY: position.y, + }; + + const handlePointerMove = (moveEvent: PointerEvent) => { + const dragState = dragStateRef.current; + if (!dragState) return; + onMove({ + x: dragState.originX + (moveEvent.clientX - dragState.startX), + y: dragState.originY + (moveEvent.clientY - dragState.startY), + }); + }; + + const handlePointerUp = () => { + dragStateRef.current = null; + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp, { once: true }); + window.addEventListener('pointercancel', handlePointerUp, { once: true }); + }, + [position.x, position.y, onMove] + ); + + return ( +
+
+ + {title} + + +
+
+ +
+
+ ); +}; + +export default MultiviewLayoutPanel; diff --git a/src/features/RequireAuth.tsx b/src/features/RequireAuth.tsx index e8a90ba..890f063 100644 --- a/src/features/RequireAuth.tsx +++ b/src/features/RequireAuth.tsx @@ -11,13 +11,7 @@ const RequireAuth = () => { if (!isAuthenticated) { const loginPath = appId && appId !== 'undefined' ? `/${appId}/login` : '/login'; - return ( - - ); + return ; } return ; diff --git a/src/features/Routing.tsx b/src/features/Routing.tsx index 17fd37b..4212ea2 100644 --- a/src/features/Routing.tsx +++ b/src/features/Routing.tsx @@ -1,6 +1,6 @@ -import "@xyflow/react/dist/style.css"; +import '@xyflow/react/dist/style.css'; -import { skipToken } from "@reduxjs/toolkit/query"; +import { skipToken } from '@reduxjs/toolkit/query'; import { Background, Controls, @@ -12,27 +12,64 @@ import { ReactFlow, useEdgesState, useNodesState, -} from "@xyflow/react"; -import dagre from "dagre"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Dropdown } from "react-bootstrap"; +} from '@xyflow/react'; +import dagre from 'dagre'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Alert, Dropdown } from 'react-bootstrap'; -import { meetsMinVersion } from "../shared/functions/meetsMinimumVersion"; -import useAppParams from "../shared/hooks/useAppParams"; +import { meetsMinVersion } from '../shared/functions/meetsMinimumVersion'; +import useAppParams from '../shared/hooks/useAppParams'; import { + MidpointRoute, RoutingDevice, RoutingDevicesAndTieLines, + RoutingPort, + SinkRoute, TieLine, + useGetPathsQuery, useGetRoutingDevicesAndTieLinesQuery, useGetVersionsQuery, -} from "../store/apiSlice"; -import styles from "./Routing.module.scss"; + useSendRoutingCommandMutation, +} from '../store/apiSlice'; +import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { + describeRoutingError, + RoutingCommand, + supportsRoutingCommand, +} from '../store/routingCommands'; +import { + ROUTING_WS_CONNECT, + ROUTING_WS_DISCONNECT, + routingSnapshotReceived, +} from '../store/routingFeedbackSlice'; +import MultiviewLayoutPanel, { + MultiviewLayoutPanelPosition, +} from './MultiviewLayoutPanel'; +import styles from './Routing.module.scss'; +import { resolveCurrentSource } from './routing/currentSource'; +import { + agePendingRoutes, + isExpectationMet, + pendingFromCommand, + pendingId, + PendingRoute, +} from './routing/pendingRoutes'; +import { + buildRouteIndex, + describeNoCandidates, + isMidpoint, + isRouteDestination, +} from './routing/routeGraph'; +import RoutePopover, { RouteEditTarget } from './routing/RoutePopover'; +import { FALLBACK_COLOR, signalColor } from './routing/signalColors'; +import useRouteCandidates from './routing/useRouteCandidates'; import RoutingDeviceNode, { HEADER_PX, PORT_ROW_PX, + PortStatus, RoutingDeviceNodeData, -} from "./RoutingDeviceNode"; -import TieLineEdge from "./TieLineEdge"; +} from './RoutingDeviceNode'; +import TieLineEdge from './TieLineEdge'; // ─── Constants ────────────────────────────────────────────────────────────── @@ -40,29 +77,13 @@ const NODE_WIDTH = 280; const NODE_SEP = 60; const RANK_SEP = 350; -const SIGNAL_COLORS: Record = { - AudioVideo: "#6f42c1", - Video: "#0d6efd", - Audio: "#dc3545", - "Audio, SecondaryAudio": "#dc3545", - "UsbOutput, UsbInput": "#fd7e14", - UsbOutput: "#fd7e14", - UsbInput: "#fd7e14", -}; - -const FALLBACK_COLOR = "#adb5bd"; - -function signalColor(signalType: string): string { - return SIGNAL_COLORS[signalType] ?? FALLBACK_COLOR; -} - // ─── Dagre layout ──────────────────────────────────────────────────────────── function nodeHeight(device: RoutingDevice): number { const rows = Math.max( (device.inputPorts ?? []).length, (device.outputPorts ?? []).length, - 1, + 1 ); return HEADER_PX + rows * PORT_ROW_PX; } @@ -70,7 +91,7 @@ function nodeHeight(device: RoutingDevice): number { function makeGraph() { const g = new dagre.graphlib.Graph(); g.setDefaultEdgeLabel(() => ({})); - g.setGraph({ rankdir: "LR", nodesep: NODE_SEP, ranksep: RANK_SEP }); + g.setGraph({ rankdir: 'LR', nodesep: NODE_SEP, ranksep: RANK_SEP }); return g; } @@ -80,27 +101,72 @@ function buildGraph( hideUnconnected: boolean, hiddenDevices: Set, hideUnconnectedPorts: boolean, + sinkRoutes: Record ): { nodes: Node[]; edges: Edge[] } { - // Devices that appear in at least one *visible* tie line endpoint + // Live, tile-aware current-source feedback (sinkRoutes) reflects routes made via + // device-specific bulk APIs (e.g. ApplyDynamicLayout) that never create a real TieLine. Turn + // these into synthetic tie-line-shaped edges so they're visualized just like static wiring - + // but only where no real tie line already targets that exact device+port (a real tie line's + // active route is already covered by midpointRoutes/tie-line tracing). + const realTieLineDestinations = new Set( + data.tieLines.map( + (tl) => `${tl.destinationDeviceKey}:${tl.destinationPortKey}` + ) + ); + const deviceOutputPortKey = new Map( + data.devices + .filter((d) => (d.outputPorts ?? []).length === 1) + .map((d) => [d.key, d.outputPorts![0].key]) + ); + const syntheticTieLines: TieLine[] = Object.entries(sinkRoutes).flatMap( + ([deviceKey, routes]) => + routes + .filter( + (r) => !realTieLineDestinations.has(`${deviceKey}:${r.inputPortKey}`) + ) + // A cleared route carries no source, so it can produce no edge. + .filter( + (r): r is SinkRoute & { sourceDeviceKey: string } => + Boolean(r.sourceDeviceKey) && + deviceOutputPortKey.has(r.sourceDeviceKey!) + ) + .map((r) => ({ + sourceDeviceKey: r.sourceDeviceKey, + sourcePortKey: deviceOutputPortKey.get(r.sourceDeviceKey)!, + destinationDeviceKey: deviceKey, + destinationPortKey: r.inputPortKey, + signalType: r.signalType, + isInternal: false, + })) + ); + const allTieLines = [...data.tieLines, ...syntheticTieLines]; + const syntheticTieLineKeys = new Set( + syntheticTieLines.map( + (tl) => + `${tl.sourceDeviceKey}|${tl.sourcePortKey}|${tl.destinationDeviceKey}|${tl.destinationPortKey}` + ) + ); + + // Devices that appear in at least one *visible* tie line endpoint (real or synthetic) const connectedKeys = new Set( - data.tieLines + allTieLines .filter((tl) => !hiddenTypes.has(tl.signalType)) - .flatMap((tl) => [tl.sourceDeviceKey, tl.destinationDeviceKey]), + .flatMap((tl) => [tl.sourceDeviceKey, tl.destinationDeviceKey]) ); - // Tie lines that pass all active filters (used for port-level filtering) - const visibleTieLines = data.tieLines.filter( + // Tie lines (real or synthetic) that pass all active filters (used for port-level filtering) + const visibleTieLines = allTieLines.filter( (tl) => !hiddenTypes.has(tl.signalType) && !hiddenDevices.has(tl.sourceDeviceKey) && - !hiddenDevices.has(tl.destinationDeviceKey), + !hiddenDevices.has(tl.destinationDeviceKey) ); const connectedPortKeys = hideUnconnectedPorts ? new Set( visibleTieLines.flatMap((tl) => [ `${tl.sourceDeviceKey}:${tl.sourcePortKey}`, `${tl.destinationDeviceKey}:${tl.destinationPortKey}`, - ]), + ]) ) : null; @@ -116,13 +182,13 @@ function buildGraph( ? { ...d, inputPorts: (d.inputPorts ?? []).filter((p) => - connectedPortKeys.has(`${d.key}:${p.key}`), + connectedPortKeys.has(`${d.key}:${p.key}`) ), outputPorts: (d.outputPorts ?? []).filter((p) => - connectedPortKeys.has(`${d.key}:${p.key}`), + connectedPortKeys.has(`${d.key}:${p.key}`) ), } - : d, + : d ); const effectiveDeviceKeys = new Set(effectiveDevices.map((d) => d.key)); @@ -136,9 +202,9 @@ function buildGraph( .filter( (tl) => effectiveDeviceKeys.has(tl.sourceDeviceKey) && - effectiveDeviceKeys.has(tl.destinationDeviceKey), + effectiveDeviceKeys.has(tl.destinationDeviceKey) ) - .map((tl) => `${tl.sourceDeviceKey}|${tl.destinationDeviceKey}`), + .map((tl) => `${tl.sourceDeviceKey}|${tl.destinationDeviceKey}`) ), ]; @@ -148,7 +214,7 @@ function buildGraph( g1.setNode(device.key, { width: NODE_WIDTH, height: nodeHeight(device) }); } for (const pair of uniquePairs) { - const [src, dst] = pair.split("|"); + const [src, dst] = pair.split('|'); g1.setEdge(src, dst); } dagre.layout(g1); @@ -158,11 +224,11 @@ function buildGraph( // a midpoint node into a later column. Exclude these from the second pass. const backwardPairs = new Set( uniquePairs.filter((pair) => { - const [src, dst] = pair.split("|"); + const [src, dst] = pair.split('|'); const sx = g1.node(src)?.x ?? 0; const dx = g1.node(dst)?.x ?? 0; return sx > dx + 10; - }), + }) ); // ── Pass 2: layout without backwards edges β†’ correct column alignment ────── @@ -172,7 +238,7 @@ function buildGraph( } for (const pair of uniquePairs) { if (!backwardPairs.has(pair)) { - const [src, dst] = pair.split("|"); + const [src, dst] = pair.split('|'); g.setEdge(src, dst); } } @@ -184,44 +250,54 @@ function buildGraph( const pos = g.node(device.key); return { id: device.key, - type: "routingDevice", + type: 'routingDevice', position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - nodeHeight(device) / 2, }, data: { device }, }; - }, + } ); - // Map tieLines β†’ React Flow edges, filtering by hidden signal types and hidden devices. - // All tie lines are rendered regardless of whether they were excluded from - // the layout pass β€” backwards edges still appear as connections on the canvas. - const edges: Edge[] = data.tieLines + // Map tieLines (real + synthetic sink-route edges) β†’ React Flow edges, filtering by hidden + // signal types and hidden devices. All tie lines are rendered regardless of whether they were + // excluded from the layout pass β€” backwards edges still appear as connections on the canvas. + const edges: Edge[] = allTieLines .filter( (tl) => !hiddenTypes.has(tl.signalType) && !hiddenDevices.has(tl.sourceDeviceKey) && - !hiddenDevices.has(tl.destinationDeviceKey), + !hiddenDevices.has(tl.destinationDeviceKey) ) - .map((tl, idx) => ({ - id: `tl-${idx}-${tl.sourceDeviceKey}-${tl.sourcePortKey}-${tl.destinationDeviceKey}-${tl.destinationPortKey}`, - source: tl.sourceDeviceKey, - sourceHandle: tl.sourcePortKey, - target: tl.destinationDeviceKey, - targetHandle: tl.destinationPortKey, - style: { stroke: signalColor(tl.signalType), strokeWidth: 1.5 }, - data: { - signalColor: signalColor(tl.signalType), - sourceDeviceKey: tl.sourceDeviceKey, - sourcePortKey: tl.sourcePortKey, - destinationDeviceKey: tl.destinationDeviceKey, - destinationPortKey: tl.destinationPortKey, - signalType: tl.signalType, - }, - type: "tieLine", - animated: false, - })); + .map((tl, idx) => { + const isLiveOnly = syntheticTieLineKeys.has( + `${tl.sourceDeviceKey}|${tl.sourcePortKey}|${tl.destinationDeviceKey}|${tl.destinationPortKey}` + ); + return { + id: `tl-${idx}-${tl.sourceDeviceKey}-${tl.sourcePortKey}-${tl.destinationDeviceKey}-${tl.destinationPortKey}`, + source: tl.sourceDeviceKey, + sourceHandle: tl.sourcePortKey, + target: tl.destinationDeviceKey, + targetHandle: tl.destinationPortKey, + style: { + stroke: signalColor(tl.signalType), + strokeWidth: 1.5, + ...(isLiveOnly ? { strokeDasharray: '6 4' } : {}), + }, + data: { + signalColor: signalColor(tl.signalType), + sourceDeviceKey: tl.sourceDeviceKey, + sourcePortKey: tl.sourcePortKey, + destinationDeviceKey: tl.destinationDeviceKey, + destinationPortKey: tl.destinationPortKey, + signalType: tl.signalType, + isLiveOnly, + }, + type: 'tieLine', + animated: false, + }; + }); return { nodes, edges }; } @@ -232,6 +308,129 @@ function uniqueSignalTypes(tieLines: TieLine[]): string[] { return [...new Set(tieLines.map((tl) => tl.signalType))].sort(); } +// ─── Signal path tracing ───────────────────────────────────────────────────── + +/** + * Given a clicked edge, traces the full signal path from source to sink through + * midpoint devices using midpointRoutes data. Returns a Set of edge IDs that + * form the continuous path. + */ +function traceSignalPath( + edges: Edge[], + clickedEdgeId: string, + midpointRoutes: Record +): Set { + interface EdgeData { + sourceDeviceKey?: string; + sourcePortKey?: string; + destinationDeviceKey?: string; + destinationPortKey?: string; + } + + const result = new Set(); + const clickedEdge = edges.find((e) => e.id === clickedEdgeId); + if (!clickedEdge) return result; + + result.add(clickedEdgeId); + + const d = (e: Edge) => (e.data ?? {}) as EdgeData; + + // Build lookup maps. A port can carry multiple tie lines (fan-out from an + // output, fan-in to an input), so each key maps to every matching edge. + // "deviceKey:portKey" β†’ edges that ARRIVE at that input port + const edgesByDestPort = new Map(); + // "deviceKey:portKey" β†’ edges that LEAVE from that output port + const edgesBySrcPort = new Map(); + + const addTo = (map: Map, key: string, e: Edge) => { + const list = map.get(key); + if (list) list.push(e); + else map.set(key, [e]); + }; + + for (const e of edges) { + const ed = d(e); + addTo(edgesBySrcPort, `${ed.sourceDeviceKey}:${ed.sourcePortKey}`, e); + addTo( + edgesByDestPort, + `${ed.destinationDeviceKey}:${ed.destinationPortKey}`, + e + ); + } + + // Trace upstream from the clicked edge's source + function traceUpstream( + deviceKey: string | undefined, + outputPortKey: string | undefined + ) { + if (!deviceKey || !outputPortKey) return; + const routes = midpointRoutes[deviceKey]; + if (!routes) return; + // Find ALL input ports that feed this output port + const matchingRoutes = routes.filter( + (r) => r.outputPortKey === outputPortKey + ); + for (const route of matchingRoutes) { + for (const incomingEdge of edgesByDestPort.get( + `${deviceKey}:${route.inputPortKey}` + ) ?? []) { + if (result.has(incomingEdge.id)) continue; + result.add(incomingEdge.id); + const ed = d(incomingEdge); + traceUpstream(ed.sourceDeviceKey, ed.sourcePortKey); + } + } + } + + // Trace downstream from the clicked edge's destination + function traceDownstream( + deviceKey: string | undefined, + inputPortKey: string | undefined + ) { + if (!deviceKey || !inputPortKey) return; + const routes = midpointRoutes[deviceKey]; + if (!routes) return; + // Find ALL output ports that this input port feeds + const matchingRoutes = routes.filter( + (r) => r.inputPortKey === inputPortKey + ); + for (const route of matchingRoutes) { + for (const outgoingEdge of edgesBySrcPort.get( + `${deviceKey}:${route.outputPortKey}` + ) ?? []) { + if (result.has(outgoingEdge.id)) continue; + result.add(outgoingEdge.id); + const ed = d(outgoingEdge); + traceDownstream(ed.destinationDeviceKey, ed.destinationPortKey); + } + } + } + + // Start tracing in both directions from the clicked edge + const clickedData = d(clickedEdge); + traceUpstream(clickedData.sourceDeviceKey, clickedData.sourcePortKey); + traceDownstream( + clickedData.destinationDeviceKey, + clickedData.destinationPortKey + ); + + return result; +} + +// ─── Route editing ─────────────────────────────────────────────────────────── + +const EMPTY_PORT_STATUS: Readonly> = Object.freeze( + {} +); + +/** Multiview tile ports arrive qualified as "tile{N}:{portKey}". */ +const TILE_PORT_RE = /^tile(\d+):/; + +function tileNumberOf(portKey: string): number | undefined { + const match = TILE_PORT_RE.exec(portKey); + return match ? Number(match[1]) : undefined; +} + // ─── Node types registry (stable reference outside component) ──────────────── const nodeTypes: NodeTypes = { @@ -246,44 +445,522 @@ const edgeTypes: EdgeTypes = { const Routing = () => { const { appId } = useAppParams(); - const { data: versions } = useGetVersionsQuery(appId ? { appId } : skipToken); - const { data, isLoading, isError } = useGetRoutingDevicesAndTieLinesQuery( - appId ? { appId } : skipToken, + const dispatch = useAppDispatch(); + const midpointRoutes = useAppSelector( + (s) => s.routingFeedback.midpointRoutes ); + const sinkRoutes = useAppSelector((s) => s.routingFeedback.sinkRoutes); + const layouts = useAppSelector((s) => s.routingFeedback.layouts); + const routingWsConnected = useAppSelector((s) => s.routingFeedback.connected); + const failedUrls = useAppSelector((s) => s.routingFeedback.failedUrls); + const { data: versions } = useGetVersionsQuery(appId ? { appId } : skipToken); + const { data, isLoading, isError, refetch } = + useGetRoutingDevicesAndTieLinesQuery(appId ? { appId } : skipToken); const [hiddenTypes, setHiddenTypes] = useState>(new Set()); const [hideUnconnected, setHideUnconnected] = useState(false); const [hiddenDevices, setHiddenDevices] = useState>(new Set()); - const [deviceSearch, setDeviceSearch] = useState(""); + const [deviceSearch, setDeviceSearch] = useState(''); const [hideUnconnectedPorts, setHideUnconnectedPorts] = useState(false); - const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [selectedEdgeIds, setSelectedEdgeIds] = useState>( + new Set() + ); const [darkMode, setDarkMode] = useState(true); + // ── Route editing ────────────────────────────────────────────────────────── + const [routeEdit, setRouteEdit] = useState<{ + target: RouteEditTarget; + anchorRect: DOMRect; + } | null>(null); + const [commandError, setCommandError] = useState(null); + // Commands sent but not yet confirmed over the feedback WebSocket, keyed by device+port. + const [pendingByPort, setPendingByPort] = useState< + Record + >({}); + const [sendRoutingCommand, { isLoading: isSendingCommand }] = + useSendRoutingCommandMutation(); + + // Floating, freely-draggable multiview layout panels - independent of graph node positions + // (which move whenever dagre re-runs in response to routing/filter changes). Keyed by device + // key; presence in the record means the panel is open. Persists until explicitly closed. + const [layoutPanels, setLayoutPanels] = useState< + Record + >({}); + const toggleLayoutPanel = useCallback((deviceKey: string) => { + setLayoutPanels((prev) => { + if (prev[deviceKey]) { + const next = { ...prev }; + delete next[deviceKey]; + return next; + } + // Cascade new panels so they don't stack exactly on top of one another. + const count = Object.keys(prev).length; + return { + ...prev, + [deviceKey]: { x: 40 + count * 24, y: 40 + count * 24 }, + }; + }); + }, []); + + const closeLayoutPanel = useCallback((deviceKey: string) => { + setLayoutPanels((prev) => { + if (!(deviceKey in prev)) return prev; + const next = { ...prev }; + delete next[deviceKey]; + return next; + }); + }, []); + + const moveLayoutPanel = useCallback( + (deviceKey: string, position: MultiviewLayoutPanelPosition) => { + setLayoutPanels((prev) => + prev[deviceKey] ? { ...prev, [deviceKey]: position } : prev + ); + }, + [] + ); + + const isV3 = useMemo(() => { + const essentialsVersion = versions?.find( + (v) => v.Name === 'PepperDashEssentials.dll' + )?.Version; + return essentialsVersion + ? meetsMinVersion(essentialsVersion, '3.0.0') + : false; + }, [versions]); + + // Route editing needs a routing-command endpoint that older processors do not have. Detect it by + // capability rather than version: apiPaths reports the processor's live CWS route table, which is + // exact and does not need updating when the shipping version changes. Editing stays off until + // the probe confirms the route, so an older processor simply renders the read-only page. + const { data: apiPaths } = useGetPathsQuery(appId ? { appId } : skipToken); + const canEditRoutes = useMemo( + () => Boolean(appId && isV3 && supportsRoutingCommand(apiPaths?.routes)), + [appId, isV3, apiPaths] + ); + + // Seed routing feedback state from the HTTP response before the WebSocket connects + useEffect(() => { + if (!data || !isV3) return; + const midpoints: Record< + string, + { inputPortKey: string; outputPortKey: string; signalType: string }[] + > = {}; + + for (const group of data.currentRoutes ?? []) { + for (const route of group.routes) { + // Each step is a switching device in the route path + for (const step of route.steps) { + if (!midpoints[step.switchingDeviceKey]) { + midpoints[step.switchingDeviceKey] = []; + } + midpoints[step.switchingDeviceKey].push({ + inputPortKey: step.inputPortKey, + outputPortKey: step.outputPortKey, + signalType: group.signalType, + }); + } + } + } + + // Sink current sources come from sinkCurrentSources, read directly from each sink's own + // current-source bookkeeping on the backend - unlike currentRoutes, this also reflects routes + // made via device-specific bulk APIs (e.g. dynamic multiview layouts) that never create a + // RouteDescriptor/TieLine at all. A device implementing IRoutingSinkWithLayouts (e.g. a + // multiview decoder) can have multiple simultaneous tile routes under its one device key, so + // this is a list per device. + const sinks: Record< + string, + { inputPortKey: string; sourceDeviceKey: string; signalType: string }[] + > = {}; + for (const source of data.sinkCurrentSources ?? []) { + if (!sinks[source.deviceKey]) { + sinks[source.deviceKey] = []; + } + sinks[source.deviceKey].push({ + inputPortKey: source.inputPortKey, + sourceDeviceKey: source.sourceDeviceKey, + signalType: source.signalType, + }); + } + + dispatch( + routingSnapshotReceived({ + type: 'snapshot', + midpointRoutes: midpoints, + sinkRoutes: sinks, + layouts: data.multiviewLayouts ?? {}, + }) + ); + }, [data, isV3, dispatch]); + + // Fetches the routing feedback session URL from the API, then connects the WebSocket. Shared + // by the mount effect below and the manual refresh button. + const connectRoutingWebSocket = useCallback( + (onCancelledRef?: { current: boolean }) => { + if (!appId || !isV3) return; + const baseUrl = `/cws/${appId}/api/routingFeedbackSession`; + + fetch(baseUrl) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((session: { url: string; fallbackUrl?: string }) => { + if (onCancelledRef?.current) return; + dispatch({ + type: ROUTING_WS_CONNECT, + payload: { url: session.url, fallbackUrl: session.fallbackUrl }, + }); + }) + .catch((err) => { + console.warn('[routing-ws] Failed to start feedback session:', err); + }); + }, + [appId, isV3, dispatch] + ); + + // Connect to routing feedback WebSocket when data is available (v3+ only) + useEffect(() => { + if (!appId || !isV3) return; + const cancelledRef = { current: false }; + connectRoutingWebSocket(cancelledRef); + + return () => { + cancelledRef.current = true; + dispatch({ type: ROUTING_WS_DISCONNECT }); + }; + }, [appId, isV3, dispatch, connectRoutingWebSocket]); + + // Manual refresh: reloads the routing devices/tie-lines snapshot over HTTP and forces the + // WebSocket to disconnect and reconnect (e.g. after the routing feedback server was restarted, + // or its connection got stuck). + const handleRefreshClick = useCallback(() => { + void refetch(); + dispatch({ type: ROUTING_WS_DISCONNECT }); + connectRoutingWebSocket(); + }, [refetch, dispatch, connectRoutingWebSocket]); + const sortedDevices = useMemo( () => [...(data?.devices ?? [])].sort((a, b) => - (a.name || a.key).localeCompare(b.name || b.key), + (a.name || a.key).localeCompare(b.name || b.key) ), - [data], + [data] ); const filteredDropdownDevices = useMemo(() => { const q = deviceSearch.toLowerCase(); if (!q) return sortedDevices; return sortedDevices.filter( - (d) => - d.key.toLowerCase().includes(q) || d.name.toLowerCase().includes(q), + (d) => d.key.toLowerCase().includes(q) || d.name.toLowerCase().includes(q) ); }, [sortedDevices, deviceSearch]); const signalTypes = useMemo( () => (data ? uniqueSignalTypes(data.tieLines) : []), - [data], + [data] ); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges] = useEdgesState([]); + // Tile number to highlight in each device's open layout panel, derived from the edges in the + // current graph edge/path selection. + const selectedTileNumberByDevice = useMemo(() => { + const result: Record = {}; + if (selectedEdgeIds.size === 0) return result; + for (const e of edges) { + if (!selectedEdgeIds.has(e.id)) continue; + const ed = e.data as + | { destinationDeviceKey?: string; destinationPortKey?: string } + | undefined; + if (!ed?.destinationDeviceKey || !ed.destinationPortKey) continue; + const tileNumber = tileNumberOf(ed.destinationPortKey); + if (tileNumber !== undefined) + result[ed.destinationDeviceKey] = tileNumber; + } + return result; + }, [edges, selectedEdgeIds]); + + // Keep a ref to current edges for path tracing without triggering re-renders + const edgesRef = useRef([]); + useEffect(() => { + edgesRef.current = edges; + }, [edges]); + + // Keep refs to the latest feedback data so the layout effect can read current + // values without depending on them (and thus without re-running dagre on every + // WebSocket update). + const midpointRoutesRef = useRef(midpointRoutes); + useEffect(() => { + midpointRoutesRef.current = midpointRoutes; + }, [midpointRoutes]); + const layoutsRef = useRef(layouts); + useEffect(() => { + layoutsRef.current = layouts; + }, [layouts]); + + // Resolves a device key (e.g. a multiview tile's routed source) to a display name. + const deviceNameByKey = useMemo(() => { + const map = new Map(); + for (const d of data?.devices ?? []) { + map.set(d.key, d.name || d.key); + } + return map; + }, [data]); + const resolveSourceName = useCallback( + (key: string) => deviceNameByKey.get(key) ?? key, + [deviceNameByKey] + ); + + // Tile click (from a device node's layout popover): find the tie-line/synthetic edge feeding + // that tile (its destination port is qualified as "tile{N}:...", per RoutingGraphHelpers on the + // backend) and trace/highlight its signal path the same way clicking a graph edge/node does. + const handleTileClick = useCallback( + (deviceKey: string, tile: { tileNumber: number }) => { + const currentEdges = edgesRef.current; + const targetEdge = currentEdges.find((e) => { + const ed = e.data as + | { destinationDeviceKey?: string; destinationPortKey?: string } + | undefined; + return ( + ed?.destinationDeviceKey === deviceKey && + ed?.destinationPortKey?.startsWith(`tile${tile.tileNumber}:`) + ); + }); + + if (!targetEdge) { + setSelectedEdgeIds(new Set()); + return; + } + + const pathEdges = traceSignalPath( + currentEdges, + targetEdge.id, + midpointRoutes + ); + setSelectedEdgeIds((prev) => { + const allMatch = + prev.size === pathEdges.size && + [...prev].every((id) => pathEdges.has(id)); + return allMatch ? new Set() : pathEdges; + }); + }, + [midpointRoutes] + ); + + // ── Route editing ────────────────────────────────────────────────────────── + + // Candidate-source lookups run against the full, unfiltered tie-line graph: hiding a device or + // a signal type in the toolbar changes the view, never what is physically routable. + const routeIndex = useMemo( + () => (data ? buildRouteIndex(data) : null), + [data] + ); + const getCandidates = useRouteCandidates(routeIndex); + + // Read the latest data from a ref inside the click handlers, so the handlers stay referentially + // stable and the dagre effect (which injects them into node data) does not re-run. + const dataRef = useRef(data); + useEffect(() => { + dataRef.current = data; + }, [data]); + + const closeRoutePopover = useCallback(() => { + setRouteEdit(null); + setCommandError(null); + }, []); + + const handlePortClick = useCallback( + ( + deviceKey: string, + port: RoutingPort, + kind: 'input' | 'output', + rect: DOMRect + ) => { + // Resolve against the UNFILTERED device, so "Hide unconnected ports" cannot hide an input + // from a midpoint's route-from list. + const device = dataRef.current?.devices.find((d) => d.key === deviceKey); + if (!device) return; + + const deviceName = device.name || device.key; + const target: RouteEditTarget = + kind === 'input' + ? { + kind: 'sinkInput', + deviceKey, + deviceName, + port, + tileNumber: tileNumberOf(port.key), + } + : { + kind: 'midpointOutput', + deviceKey, + deviceName, + port, + inputPorts: device.inputPorts ?? [], + }; + + setCommandError(null); + setRouteEdit({ target, anchorRect: rect }); + }, + [] + ); + + // Edit badge on a tile in a floating multiview layout panel. Tiles are exposed on the parent + // node as "tile{N}:" qualified input ports, so this resolves to the same port the node's own row + // would open and reuses the identical flow. + const handleTileEditClick = useCallback( + (deviceKey: string, tile: { tileNumber: number }, rect: DOMRect) => { + const device = dataRef.current?.devices.find((d) => d.key === deviceKey); + const port = device?.inputPorts?.find((p) => + p.key.startsWith(`tile${tile.tileNumber}:`) + ); + if (!port) return; + handlePortClick(deviceKey, port, 'input', rect); + }, + [handlePortClick] + ); + + const handleSubmitRouteCommand = useCallback( + async (command: RoutingCommand) => { + if (!appId) return; + try { + await sendRoutingCommand({ appId, command }).unwrap(); + } catch (error) { + // Keep the popover open so the user can pick something else. + setCommandError(describeRoutingError(error)); + return; + } + + // A sinkRoute/clearSink is only *accepted* by the processor; the authoritative result lands + // over the feedback WebSocket. Mark the port pending until the expected feedback shows up. + const pending = pendingFromCommand(command, Date.now()); + if (pending) { + setPendingByPort((prev) => ({ + ...prev, + [pendingId(pending.deviceKey, pending.portKey)]: pending, + })); + } + + setRouteEdit(null); + setCommandError(null); + }, + [appId, sendRoutingCommand] + ); + + // Clear pending markers once the feedback confirms what was asked for. + // + // pendingByPort is a dependency as well as the thing being set, so a newly-added marker is + // checked against feedback immediately rather than waiting for the next WebSocket tick. That + // matters for two cases that would otherwise sit pending until they timed out and showed a + // spurious warning: feedback that raced ahead of the mutation resolving, and re-selecting the + // source that is already routed, which changes nothing and so produces no feedback at all. + // The updater returns `prev` unchanged when nothing resolved, so React bails out rather than + // looping. + useEffect(() => { + // Deliberately an effect: markers have to be re-checked each time feedback arrives, and the + // updater bails out with `prev` when nothing resolved, so there is no render cascade. + // eslint-disable-next-line react-hooks/set-state-in-effect + setPendingByPort((prev) => { + const entries = Object.entries(prev); + if (entries.length === 0) return prev; + + const next: Record = {}; + let changed = false; + for (const [id, pending] of entries) { + if (isExpectationMet(sinkRoutes, midpointRoutes, pending)) + changed = true; + else next[id] = pending; + } + return changed ? next : prev; + }); + }, [sinkRoutes, midpointRoutes, pendingByPort]); + + // Age out anything the processor never confirmed: warn at PENDING_TIMEOUT_MS, then drop it. The + // interval only runs while something is actually pending. + const hasPending = Object.keys(pendingByPort).length > 0; + useEffect(() => { + if (!hasPending) return; + const interval = setInterval(() => { + setPendingByPort((prev) => agePendingRoutes(prev, Date.now())); + }, 1000); + return () => clearInterval(interval); + }, [hasPending]); + + // Port status grouped by device, so the node-data effect below can hand each node a stable + // object and skip re-rendering every other node on every pending change. + const portStatusByDevice = useMemo(() => { + const map = new Map>(); + for (const pending of Object.values(pendingByPort)) { + const forDevice = map.get(pending.deviceKey) ?? {}; + forDevice[pending.portKey] = pending.timedOut ? 'timedOut' : 'pending'; + map.set(pending.deviceKey, forDevice); + } + return map; + }, [pendingByPort]); + + // What is routed to the port the popover is open on, for its check mark. + const routeEditCurrent = useMemo(() => { + if (!routeEdit) return null; + const { target } = routeEdit; + + if (target.kind === 'midpointOutput') { + const route = midpointRoutes[target.deviceKey]?.find( + (r: MidpointRoute) => r.outputPortKey === target.port.key + ); + return { inputPortKey: route?.inputPortKey ?? null }; + } + + // Trace the live midpoint state rather than trusting the sink's own current-source + // bookkeeping, which the processor only updates on a graph-level route - switching a midpoint + // directly leaves it stale. Fall back to that bookkeeping only when the trace cannot answer, + // which is the dynamically-routed case where it is the sole source of truth. + const traced = routeIndex + ? resolveCurrentSource( + routeIndex, + midpointRoutes, + target.deviceKey, + target.port.key + ) + : ({ status: 'unknown' } as const); + + if (traced.status === 'resolved') + return { sourceDeviceKey: traced.sourceDeviceKey }; + if (traced.status === 'cleared') return { sourceDeviceKey: null }; + + const route = sinkRoutes[target.deviceKey]?.find( + (r: SinkRoute) => r.inputPortKey === target.port.key + ); + return { sourceDeviceKey: route?.sourceDeviceKey ?? null }; + }, [routeEdit, sinkRoutes, midpointRoutes, routeIndex]); + + const getCandidatesForOpenPopover = useCallback( + (signalType: string) => + routeEdit + ? getCandidates( + routeEdit.target.deviceKey, + routeEdit.target.port.key, + signalType + ) + : [], + [routeEdit, getCandidates] + ); + + const describeEmptySources = useCallback( + (signalType: string) => + routeIndex && routeEdit + ? describeNoCandidates( + routeIndex, + routeEdit.target.deviceKey, + routeEdit.target.port.key, + signalType + ) + : '', + [routeIndex, routeEdit] + ); + // Re-run dagre layout only when the source data or filters change. // Using useEffect (not useMemo) means React Flow owns the node array // between renders, so drag positions are preserved. @@ -295,24 +972,44 @@ const Routing = () => { hideUnconnected, hiddenDevices, hideUnconnectedPorts, + sinkRoutes ); setNodes( - layoutNodes.map((n) => ({ - ...n, - data: { - ...n.data, - darkMode, - onHide: () => - setHiddenDevices((prev) => { - const next = new Set(prev); - next.add(n.id); - return next; - }), - }, - })), + layoutNodes.map((n) => { + const device = (n.data as RoutingDeviceNodeData).device; + return { + ...n, + data: { + ...n.data, + darkMode, + currentRoutes: midpointRoutesRef.current[n.id], + hasLayout: Boolean(layoutsRef.current[n.id]), + onToggleLayoutPanel: () => toggleLayoutPanel(n.id), + // Only the stable edit fields go in here; pending/editing state is injected by the + // cheap effect below so feedback ticks never re-run dagre. + onPortClick: canEditRoutes + ? (port: RoutingPort, kind: 'input' | 'output', rect: DOMRect) => + handlePortClick(n.id, port, kind, rect) + : undefined, + // Inputs are routable on route destinations - pure sinks AND multiview parents, which + // report hasInputs && hasOutputs without being midpoints. + canEditInputs: canEditRoutes && isRouteDestination(device), + canEditOutputs: canEditRoutes && isMidpoint(device), + onHide: () => + setHiddenDevices((prev) => { + const next = new Set(prev); + next.add(n.id); + return next; + }), + }, + }; + }) ); setEdges(layoutEdges); - setSelectedEdgeId(null); + // A rebuilt graph has new edges, so any selection refers to edges that may no longer exist. + // Cleared here, alongside the rebuild it belongs to, rather than tracked separately. + // eslint-disable-next-line react-hooks/set-state-in-effect + setSelectedEdgeIds(new Set()); }, [ data, hiddenTypes, @@ -320,65 +1017,221 @@ const Routing = () => { hiddenDevices, hideUnconnectedPorts, darkMode, + sinkRoutes, + resolveSourceName, + handleTileClick, + canEditRoutes, + handlePortClick, + toggleLayoutPanel, setNodes, setEdges, ]); - // Re-style edges when selection changes without triggering a layout rebuild. + // Keep node data's currentRoutes/hasLayout/port status fresh as feedback arrives, without + // re-running dagre or resetting the current selection. + useEffect(() => { + const editingDeviceKey = routeEdit?.target.deviceKey ?? null; + const editingPortKey = routeEdit?.target.port.key ?? null; + + setNodes((nds) => + nds.map((n) => { + const portStatus = portStatusByDevice.get(n.id) ?? EMPTY_PORT_STATUS; + const nodeEditingPortKey = + n.id === editingDeviceKey ? editingPortKey : null; + const prev = n.data as RoutingDeviceNodeData; + + // Most nodes are unaffected by any given pending change; returning the same object keeps + // React Flow from re-rendering them. + if ( + prev.portStatus === portStatus && + (prev.editingPortKey ?? null) === nodeEditingPortKey && + prev.currentRoutes === midpointRoutes[n.id] && + prev.hasLayout === Boolean(layouts[n.id]) + ) { + return n; + } + + return { + ...n, + data: { + ...n.data, + currentRoutes: midpointRoutes[n.id], + hasLayout: Boolean(layouts[n.id]), + portStatus, + editingPortKey: nodeEditingPortKey, + }, + }; + }) + ); + }, [midpointRoutes, layouts, portStatusByDevice, routeEdit, setNodes]); + + // Re-style edges and update node highlights when selection changes. useEffect(() => { setEdges((eds) => eds.map((e) => { const baseColor = (e.data as { signalColor?: string } | undefined)?.signalColor ?? FALLBACK_COLOR; - const isSelected = selectedEdgeId !== null && e.id === selectedEdgeId; - if (selectedEdgeId === null) { + const isLiveOnly = Boolean( + (e.data as { isLiveOnly?: boolean } | undefined)?.isLiveOnly + ); + const dash = isLiveOnly ? { strokeDasharray: '6 4' } : {}; + const isSelected = + selectedEdgeIds.size > 0 && selectedEdgeIds.has(e.id); + if (selectedEdgeIds.size === 0) { return { ...e, data: { ...e.data, selected: false }, - style: { stroke: baseColor, strokeWidth: 1.5 }, + style: { stroke: baseColor, strokeWidth: 1.5, ...dash }, }; } return { ...e, data: { ...e.data, selected: isSelected }, style: { - stroke: isSelected ? baseColor : "#ccc", + stroke: isSelected ? baseColor : '#ccc', strokeWidth: isSelected ? 3.5 : 1.5, opacity: isSelected ? 1 : 0.35, + ...dash, }, }; - }), + }) ); - }, [selectedEdgeId, setEdges]); + // Compute which internal routes on each midpoint node are part of the path + if (selectedEdgeIds.size === 0) { + setNodes((nds) => + nds.map((n) => ({ + ...n, + data: { ...n.data, highlightedRouteKeys: null }, + })) + ); + } else { + const selectedDestPorts = new Set(); + const selectedSrcPorts = new Set(); + for (const e of edgesRef.current) { + if (selectedEdgeIds.has(e.id)) { + const ed = e.data as + | { + sourceDeviceKey?: string; + sourcePortKey?: string; + destinationDeviceKey?: string; + destinationPortKey?: string; + } + | undefined; + if (ed?.destinationDeviceKey && ed?.destinationPortKey) { + selectedDestPorts.add( + `${ed.destinationDeviceKey}:${ed.destinationPortKey}` + ); + } + if (ed?.sourceDeviceKey && ed?.sourcePortKey) { + selectedSrcPorts.add(`${ed.sourceDeviceKey}:${ed.sourcePortKey}`); + } + } + } + + setNodes((nds) => + nds.map((n) => { + const routes = midpointRoutes[n.id]; + if (!routes || routes.length === 0) { + return { + ...n, + data: { ...n.data, highlightedRouteKeys: new Set() }, + }; + } + const highlighted = new Set(); + for (const r of routes) { + const inputInPath = selectedDestPorts.has( + `${n.id}:${r.inputPortKey}` + ); + const outputInPath = selectedSrcPorts.has( + `${n.id}:${r.outputPortKey}` + ); + if (inputInPath && outputInPath) { + highlighted.add(`${r.inputPortKey}:${r.outputPortKey}`); + } + } + return { + ...n, + data: { ...n.data, highlightedRouteKeys: highlighted }, + }; + }) + ); + } + }, [selectedEdgeIds, setEdges, setNodes, midpointRoutes]); + + // Edge click: highlight only the single clicked edge const onEdgeClick = useCallback( (_: React.MouseEvent, edge: Edge) => - setSelectedEdgeId((prev) => (prev === edge.id ? null : edge.id)), - [], + setSelectedEdgeIds((prev) => { + if (prev.has(edge.id)) return new Set(); + return new Set([edge.id]); + }), + [] ); - const onPaneClick = useCallback(() => setSelectedEdgeId(null), []); + // Node click: trace all signal paths through/from/to the clicked device + const onNodeClick = useCallback( + (_: React.MouseEvent, node: Node) => { + const currentEdges = edgesRef.current; + const allPathEdges = new Set(); + + // Find all edges connected to this device and trace each one + for (const e of currentEdges) { + const ed = e.data as + | { sourceDeviceKey?: string; destinationDeviceKey?: string } + | undefined; + if ( + ed?.sourceDeviceKey === node.id || + ed?.destinationDeviceKey === node.id + ) { + const pathFromEdge = traceSignalPath( + currentEdges, + e.id, + midpointRoutes + ); + for (const id of pathFromEdge) { + allPathEdges.add(id); + } + } + } + + setSelectedEdgeIds((prev) => { + // Toggle off if clicking the same node again + if (prev.size > 0 && allPathEdges.size > 0) { + const prevArr = [...prev]; + const allMatch = + prevArr.every((id) => allPathEdges.has(id)) && + prevArr.length === allPathEdges.size; + if (allMatch) return new Set(); + } + return allPathEdges; + }); + }, + [midpointRoutes] + ); + + const onPaneClick = useCallback(() => setSelectedEdgeIds(new Set()), []); const onEdgeMouseEnter = useCallback( (_: React.MouseEvent, edge: Edge) => setEdges((eds) => { if (eds.some((e) => e.data?.selected)) return eds; return eds.map((e) => - e.id === edge.id ? { ...e, data: { ...e.data, hovered: true } } : e, + e.id === edge.id ? { ...e, data: { ...e.data, hovered: true } } : e ); }), - [setEdges], + [setEdges] ); const onEdgeMouseLeave = useCallback( (_: React.MouseEvent, edge: Edge) => setEdges((eds) => eds.map((e) => - e.id === edge.id ? { ...e, data: { ...e.data, hovered: false } } : e, - ), + e.id === edge.id ? { ...e, data: { ...e.data, hovered: false } } : e + ) ), - [setEdges], + [setEdges] ); function toggleSignalType(type: string) { @@ -423,8 +1276,8 @@ const Routing = () => { versions && !versions.some( (v) => - v.Name === "PepperDashEssentials.dll" && - meetsMinVersion(v.Version, "2.29"), + v.Name === 'PepperDashEssentials.dll' && + meetsMinVersion(v.Version, '2.29') ) ) { return ( @@ -434,8 +1287,40 @@ const Routing = () => { ); } + const certUrls = + failedUrls.length > 0 + ? [ + ...new Set( + failedUrls.map((u: string) => + new URL(u).origin + .replace(/^wss:/, 'https:') + .replace(/^ws:/, 'http:') + ) + ), + ] + : null; + return (
+ {certUrls && certUrls.length > 0 && ( + + Live feedback connection failed. The routing feedback + server may have an untrusted certificate.{' '} + {certUrls.map((certUrl, i) => ( + + {i > 0 && ' or '} + + Open {certUrl} + + + ))} + {' in a new tab, accept the certificate, then reload this page.'} + + )} {/* Signal type filter bar */}
@@ -450,8 +1335,8 @@ const Routing = () => { return (
+ + {routingWsConnected ? 'Live' : 'Offline'} + +
{/* React Flow canvas */}
{ + + {/* Floating multiview layout panels - siblings of the React Flow canvas (not graph + nodes), so their position is independent of node positions/dagre re-layout. */} + {Object.entries(layoutPanels).map(([deviceKey, position]) => { + const layout = layouts[deviceKey]; + if (!layout) return null; + return ( + handleTileClick(deviceKey, tile)} + onTileEditClick={ + canEditRoutes + ? (tile, rect) => handleTileEditClick(deviceKey, tile, rect) + : undefined + } + onClose={() => closeLayoutPanel(deviceKey)} + onMove={(nextPosition) => + moveLayoutPanel(deviceKey, nextPosition) + } + /> + ); + })} + + {/* Rendered into document.body by the component itself, so it is never clipped by the + canvas or scaled by its zoom transform. */} + {routeEdit && ( + void handleSubmitRouteCommand(command)} + onClose={closeRoutePopover} + /> + )}
); diff --git a/src/features/RoutingDeviceNode.module.scss b/src/features/RoutingDeviceNode.module.scss index b46bb98..ea80f4a 100644 --- a/src/features/RoutingDeviceNode.module.scss +++ b/src/features/RoutingDeviceNode.module.scss @@ -35,6 +35,22 @@ } } +.layoutToggleBtn { + flex-shrink: 0; + background: none; + border: none; + padding: 2px; + margin-top: 2px; + margin-left: 2px; + line-height: 1; + color: #555; + cursor: pointer; + + &:hover { + color: #0d6efd; + } +} + .nodeKeyLabel { font-size: 0.65rem; } @@ -59,6 +75,7 @@ .portLabelWrap { position: relative; + z-index: 2; max-width: 46%; min-width: 0; @@ -72,6 +89,65 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + background-color: #fff; + padding: 0 3px; + border-radius: 2px; + + .portRowDark & { + background-color: #1e1e1e; + } +} + +// Clickable port row, shown when route editing is available for that side of the device. +.portButton { + display: flex; + align-items: center; + gap: 3px; + width: 100%; + max-width: 100%; + border: 1px solid transparent; + cursor: pointer; + color: #6c757d; + text-align: left; + + &.portButtonEnd { + justify-content: flex-end; + text-align: right; + } + + &:hover { + border-color: #0d6efd; + color: #0d6efd; + } +} + +.portButtonActive { + border-color: #0d6efd !important; + color: #0d6efd !important; +} + +// Route command sent, awaiting confirmation over the feedback WebSocket. +.portPending { + flex-shrink: 0; + color: #0d6efd; + font-size: 0.6rem; + animation: portPendingPulse 1s ease-in-out infinite; +} + +@keyframes portPendingPulse { + 0%, + 100% { + opacity: 0.3; + } + 50% { + opacity: 1; + } +} + +.portTimedOut { + flex-shrink: 0; + color: #fd7e14; + font-weight: 700; } .portTooltip { @@ -90,3 +166,11 @@ opacity: 0; transition: opacity 0.12s; } + +.internalRouteSvg { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + z-index: 1; +} diff --git a/src/features/RoutingDeviceNode.tsx b/src/features/RoutingDeviceNode.tsx index 78fc558..29331a3 100644 --- a/src/features/RoutingDeviceNode.tsx +++ b/src/features/RoutingDeviceNode.tsx @@ -1,18 +1,140 @@ -import { Handle, NodeProps, Position } from "@xyflow/react"; -import { RoutingDevice } from "../store/apiSlice"; -import styles from "./RoutingDeviceNode.module.scss"; +import { Handle, NodeProps, Position } from '@xyflow/react'; + +import { MidpointRoute, RoutingDevice, RoutingPort } from '../store/apiSlice'; +import { signalColor } from './routing/signalColors'; +import styles from './RoutingDeviceNode.module.scss'; + +/** + * A command was sent for this port but the processor has not confirmed it over the feedback + * WebSocket yet; "timedOut" means it never did. + */ +export type PortStatus = 'pending' | 'timedOut'; export type RoutingDeviceNodeData = { device: RoutingDevice; onHide?: () => void; darkMode?: boolean; + currentRoutes?: MidpointRoute[]; + /** When edges are selected, contains the set of "inputPortKey:outputPortKey" pairs on this node that are part of the path. null = no selection active. */ + highlightedRouteKeys?: Set | null; + /** Whether this device has an active multiview canvas/tile layout (IRoutingSinkWithLayoutState). */ + hasLayout?: boolean; + /** Called when the layout toggle button is clicked - shows/hides this device's floating layout panel (see Routing.tsx). */ + onToggleLayoutPanel?: () => void; + /** Opens the route popover for a port. Absent when route editing is unavailable. */ + onPortClick?: ( + port: RoutingPort, + kind: 'input' | 'output', + rect: DOMRect + ) => void; + /** Input ports are clickable on route destinations (pure sinks and multiview parents). */ + canEditInputs?: boolean; + /** Output ports are clickable on midpoints. */ + canEditOutputs?: boolean; + /** In-flight/timed-out route commands on this device, keyed by port key. */ + portStatus?: Readonly>; + /** Port key whose popover is currently open, for the active outline. */ + editingPortKey?: string | null; +}; + +/** Multiview tile ports arrive qualified as "tile{N}:{portKey}" - see RoutingGraphHelpers. */ +const TILE_PORT_RE = /^tile(\d+):/; + +/** "tile2:tileInput" truncates badly in a 280px card; "Tile 2" does not. */ +function portDisplayLabel(portKey: string): string { + const match = TILE_PORT_RE.exec(portKey); + return match ? `Tile ${match[1]}` : portKey; +} + +interface PortCellProps { + port?: RoutingPort; + kind: 'input' | 'output'; + editable: boolean; + status?: PortStatus; + isEditing: boolean; + onPortClick?: RoutingDeviceNodeData['onPortClick']; +} + +const PortCell = ({ + port, + kind, + editable, + status, + isEditing, + onPortClick, +}: PortCellProps) => { + const align = kind === 'output' ? 'text-end ' : ''; + if (!port) return
; + + const label = portDisplayLabel(port.key); + const indicator = status && ( + + {status === 'pending' ? '●' : '!'} + + ); + + return ( +
+ {editable && onPortClick ? ( + + ) : ( + + {indicator} + {label} + + )} + {port.signalType} +
+ ); }; const PORT_ROW_PX = 28; const HEADER_PX = 38; const RoutingDeviceNode = ({ data }: NodeProps) => { - const { device, onHide, darkMode } = data as RoutingDeviceNodeData; + const { + device, + onHide, + darkMode, + currentRoutes, + highlightedRouteKeys, + hasLayout, + onToggleLayoutPanel, + onPortClick, + canEditInputs, + canEditOutputs, + portStatus, + editingPortKey, + } = data as RoutingDeviceNodeData; const inputPorts = device.inputPorts ?? []; const outputPorts = device.outputPorts ?? []; const portRows = Math.max(inputPorts.length, outputPorts.length, 1); @@ -20,11 +142,11 @@ const RoutingDeviceNode = ({ data }: NodeProps) => { return (
@@ -36,6 +158,62 @@ const RoutingDeviceNode = ({ data }: NodeProps) => {
)}
+ {hasLayout && ( + + )} {onHide && (
-
+
{/* Input port handles (left side) */} {inputPorts.map((port, i) => { const topPct = ((i + 0.5) / portRows) * 100; @@ -71,29 +252,85 @@ const RoutingDeviceNode = ({ data }: NodeProps) => { return (
-
- - {inPort?.key ?? ""} - - {inPort && ( - {inPort.signalType} - )} -
-
- - {outPort?.key ?? ""} - - {outPort && ( - {outPort.signalType} - )} -
+ {/* Handlers hang off each side's cell, not the row: a midpoint row carries an input + on the left and an output on the right, so a row-level click is ambiguous. */} + +
); })} + {/* Internal route SVG overlay */} + {currentRoutes && currentRoutes.length > 0 && ( + + {currentRoutes.map((route, idx) => { + const inIdx = inputPorts.findIndex( + (p) => p.key === route.inputPortKey + ); + const outIdx = outputPorts.findIndex( + (p) => p.key === route.outputPortKey + ); + if (inIdx === -1 || outIdx === -1) return null; + + const inY = ((inIdx + 0.5) / portRows) * bodyHeight; + const outY = ((outIdx + 0.5) / portRows) * bodyHeight; + const color = signalColor(route.signalType); + + // Determine if this route is highlighted or dimmed + const routeKey = `${route.inputPortKey}:${route.outputPortKey}`; + const isHighlighted = + highlightedRouteKeys == null || + highlightedRouteKeys.has(routeKey); + + // Bezier control points for a smooth S-curve + const x1 = 24; + const x2 = 256; + const cx1 = x1 + (x2 - x1) * 0.4; + const cx2 = x2 - (x2 - x1) * 0.4; + + return ( + + ); + })} + + )} + {/* Output port handles (right side) */} {outputPorts.map((port, i) => { const topPct = ((i + 0.5) / portRows) * 100; diff --git a/src/features/Secrets.tsx b/src/features/Secrets.tsx new file mode 100644 index 0000000..d1cb104 --- /dev/null +++ b/src/features/Secrets.tsx @@ -0,0 +1,470 @@ +import { skipToken } from '@reduxjs/toolkit/query'; +import { useMemo, useState } from 'react'; +import { Button, Form } from 'react-bootstrap'; + +import useAppParams from '../shared/hooks/useAppParams'; +import { + downloadJson, + timestampedFilename, +} from '../shared/functions/downloadFile'; +import { + useApplyBulkSecretsMutation, + useGetPathsQuery, + useGetSecretProvidersQuery, + useGetSecretsQuery, + useLazyGetSecretsTemplateQuery, + useSendSecretCommandMutation, +} from '../store/apiSlice'; +import { + BulkSecretEntry, + BulkSecretsResponse, + bulkSecretsRequest, + deleteSecretCommand, + describeSecretsError, + SecretEntry, + setSecretCommand, + supportsSecretsApi, +} from '../store/secretsContract'; +import BulkApplyModal from './secrets/BulkApplyModal'; +import SecretDeleteModal from './secrets/SecretDeleteModal'; +import SecretEditModal, { + SecretEditSubmission, + SecretEditTarget, +} from './secrets/SecretEditModal'; + +const DEFAULT_PROVIDER = 'default'; + +/** + * Manage the credentials stored on the processor. + * + * Values are write-only throughout: the API never returns one, so this page can show which secrets + * exist and replace them, but never reveal them. + */ +const Secrets = () => { + const { appId } = useAppParams(); + + const [provider, setProvider] = useState(DEFAULT_PROVIDER); + const [filter, setFilter] = useState(''); + const [editing, setEditing] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + const [showBulk, setShowBulk] = useState(false); + const [commandError, setCommandError] = useState(null); + + const { + data: apiPaths, + isLoading: isProbing, + isError: probeFailed, + refetch: retryProbe, + } = useGetPathsQuery(appId ? { appId } : skipToken); + const canManageSecrets = useMemo( + () => supportsSecretsApi(apiPaths?.routes), + [apiPaths] + ); + + const { data: providerData } = useGetSecretProvidersQuery( + appId && canManageSecrets ? { appId } : skipToken + ); + const { data, isLoading, isError, refetch } = useGetSecretsQuery( + appId && canManageSecrets ? { appId, provider } : skipToken + ); + + const [sendCommand, { isLoading: isSending, reset: resetCommand }] = + useSendSecretCommandMutation(); + const [applyBulk, { reset: resetBulk }] = useApplyBulkSecretsMutation(); + const [fetchTemplate, { isFetching: isFetchingTemplate }] = + useLazyGetSecretsTemplateQuery(); + + const providers = providerData?.providers ?? []; + const secrets = useMemo(() => data?.secrets ?? [], [data]); + + const { managed, unmanaged } = useMemo(() => { + const needle = filter.trim().toLowerCase(); + const matches = (entry: SecretEntry) => + !needle || + entry.key.toLowerCase().includes(needle) || + (entry.description ?? '').toLowerCase().includes(needle); + const shown = secrets.filter(matches); + return { + managed: shown.filter((s) => s.managed), + unmanaged: shown.filter((s) => !s.managed), + }; + }, [secrets, filter]); + + // ── Actions ─────────────────────────────────────────────────────────────── + + const handleEditSubmit = async (submission: SecretEditSubmission) => { + if (!appId) return; + setCommandError(null); + try { + await sendCommand({ + appId, + request: setSecretCommand( + submission.provider, + submission.key, + submission.value, + { + description: submission.description, + overwrite: submission.overwrite, + } + ), + }).unwrap(); + setEditing(null); + } catch (error) { + setCommandError(describeSecretsError(error)); + } finally { + // Purges the mutation cache entry, and with it the plaintext value RTK Query keeps in + // originalArgs where Redux DevTools can read it. + resetCommand(); + } + }; + + const handleDeleteConfirm = async () => { + if (!appId || !pendingDelete) return; + setCommandError(null); + try { + await sendCommand({ + appId, + request: deleteSecretCommand(provider, pendingDelete.key), + }).unwrap(); + setPendingDelete(null); + } catch (error) { + setCommandError(describeSecretsError(error)); + } finally { + resetCommand(); + } + }; + + const handleBulkRun = async ( + entries: BulkSecretEntry[], + options: { + mode: 'preview' | 'commit'; + overwrite: boolean; + allowUnmanagedOverwrite: boolean; + } + ): Promise => { + if (!appId) throw new Error('No application selected.'); + try { + return await applyBulk({ + appId, + request: bulkSecretsRequest(provider, entries, options), + }).unwrap(); + } catch (error) { + throw new Error(describeSecretsError(error), { cause: error }); + } finally { + resetBulk(); + } + }; + + const handleDownloadTemplate = async () => { + if (!appId) return; + setCommandError(null); + try { + const template = await fetchTemplate({ appId, provider }).unwrap(); + downloadJson( + timestampedFilename(`secrets-template-${appId}-${provider}`, 'json'), + template + ); + } catch (error) { + setCommandError(describeSecretsError(error)); + } + }; + + // ── Render gates ────────────────────────────────────────────────────────── + + if (isProbing || (canManageSecrets && isLoading)) { + return
Loading secrets…
; + } + + // "Could not ask" and "asked, and it is not supported" are different problems with different + // fixes. Collapsing them would tell someone to upgrade Essentials over a dropped request. + if (probeFailed) { + return ( +
+
+ Could not check whether this processor supports secrets management. +
+ +
+ ); + } + + if (!canManageSecrets) { + return ( +
+ Secrets management is not available on this processor. It requires a + newer Essentials version. +
+ ); + } + + if (isError || !data) { + return ( +
+
Failed to load secrets.
+ +
+ ); + } + + const renderRows = (entries: SecretEntry[], showActions: boolean) => + entries.map((entry) => ( +
{entry.key}{entry.description} + {entry.updatedUtc ?? entry.lastModifiedUtc ?? ''} + + {showActions && ( + + )} + +
+ + + + + + + + + + {managed.length > 0 ? ( + renderRows(managed, true) + ) : ( + + + + )} + +
KeyDescriptionUpdated + +
+ {filter + ? 'No secrets match the filter.' + : 'No secrets stored here yet. Add one, or apply a file.'} +
+ + {unmanaged.length > 0 && ( + <> +
Other data store records
+

+ These exist on the processor but were not created here. Some + belong to other parts of the system β€” Mobile Control keeps its + paired-client tokens this way β€” so deleting one can break + something unrelated. +

+ + + + + + + + + + + {unmanaged.map((entry) => ( + + + + + + + ))} + +
KeyOwnerModified 
+ {entry.key}{' '} + + Not managed here + + {entry.owner} + {entry.lastModifiedUtc} + + +
+ + )} +
+ + {editing && ( + void handleEditSubmit(submission)} + onClose={() => { + setEditing(null); + setCommandError(null); + }} + /> + )} + + {pendingDelete && ( + void handleDeleteConfirm()} + onClose={() => { + setPendingDelete(null); + setCommandError(null); + }} + /> + )} + + {showBulk && ( + setShowBulk(false)} + /> + )} + + ); +}; + +export default Secrets; diff --git a/src/features/TieLineEdge.tsx b/src/features/TieLineEdge.tsx index 64e93a9..bb9a5ec 100644 --- a/src/features/TieLineEdge.tsx +++ b/src/features/TieLineEdge.tsx @@ -3,8 +3,8 @@ import { EdgeLabelRenderer, EdgeProps, getBezierPath, -} from "@xyflow/react"; -import styles from "./Routing.module.scss"; +} from '@xyflow/react'; +import styles from './Routing.module.scss'; export interface TieLineEdgeData { signalColor: string; diff --git a/src/features/TopNav.tsx b/src/features/TopNav.tsx index 412f815..2f75171 100644 --- a/src/features/TopNav.tsx +++ b/src/features/TopNav.tsx @@ -1,12 +1,18 @@ -import { useMemo } from "react"; -import { Dropdown, Nav, Navbar } from "react-bootstrap"; -import { NavLink, useLocation } from "react-router-dom"; -import { meetsMinVersion } from "../shared/functions/meetsMinimumVersion"; -import useAppParams from "../shared/hooks/useAppParams"; -import { IconDarkChevronDown, IconDarkEllipse } from "../shared/icons"; -import { useGetVersionsQuery } from "../store/apiSlice"; -import { selectAvailableApps } from "../store/auth/authSelectors"; -import { useAppSelector } from "../store/hooks"; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useMemo } from 'react'; +import { Dropdown, Nav, Navbar } from 'react-bootstrap'; +import { NavLink, useLocation } from 'react-router-dom'; +import { meetsMinVersion } from '../shared/functions/meetsMinimumVersion'; +import useAppParams from '../shared/hooks/useAppParams'; +import { + IconDarkChevronDown, + IconDarkEllipse, + IconDarkHelp, +} from '../shared/icons'; +import { useGetPathsQuery, useGetVersionsQuery } from '../store/apiSlice'; +import { selectAvailableApps } from '../store/auth/authSelectors'; +import { useAppSelector } from '../store/hooks'; +import { supportsSecretsApi } from '../store/secretsContract'; const AppNavLink = ({ appId, @@ -31,7 +37,7 @@ const AppNavLink = ({ } return ( (isActive ? "me-3 text-secondary" : "me-3")} + className={({ isActive }) => (isActive ? 'me-3 text-secondary' : 'me-3')} to={`/${appId}/${path}`} > {children} @@ -48,8 +54,8 @@ const TopNav = ({ isConnected }: { isConnected: boolean }) => { // Single version query for the currently active app only (used for feature flagging) const { data: currentVersions } = useGetVersionsQuery( - params.appId ? { appId: params.appId } : { appId: "" }, - { skip: !params.appId }, + params.appId ? { appId: params.appId } : { appId: '' }, + { skip: !params.appId } ); const appIdOptions = availableApps; @@ -59,15 +65,26 @@ const TopNav = ({ isConnected }: { isConnected: boolean }) => { // app-scoped route yet. const currentSubRoute = useMemo(() => { const match = location.pathname.match(/^\/app\d+\/(.+)/); - return match ? match[1] : "console"; + return match ? match[1] : 'console'; }, [location.pathname]); + // Detected from the processor's live route table rather than a version number, so the link + // appears exactly where the endpoint exists. RTK Query dedupes this with the Routing page's + // identical probe, so it costs no extra request. + const { data: apiPaths } = useGetPathsQuery( + params.appId ? { appId: params.appId } : skipToken + ); + const showSecrets = useMemo( + () => supportsSecretsApi(apiPaths?.routes), + [apiPaths] + ); + const showInitializationExceptions = useMemo(() => { const essentialsVersion = currentVersions?.find( - (v) => v.Name === "PepperDashEssentials.dll", + (v) => v.Name === 'PepperDashEssentials.dll' )?.Version; if (!essentialsVersion) return false; - return meetsMinVersion(essentialsVersion, "3.0.0"); + return meetsMinVersion(essentialsVersion, '3.0.0'); }, [currentVersions]); return ( @@ -87,7 +104,7 @@ const TopNav = ({ isConnected }: { isConnected: boolean }) => { {/* display the currently selected appId or "Select Application" if no appId is selected */} {/* if no appIdOptions are available, display "No Loaded Applications" */} - {params.appId || "Select Application"} + {params.appId || 'Select Application'} @@ -133,16 +150,30 @@ const TopNav = ({ isConnected }: { isConnected: boolean }) => { Mobile Control + {showSecrets && ( + + Secrets + + )} + + isActive ? 'me-3 text-secondary' : 'me-3' + } + to="/help" + > + + Help +
Version: {reactAppVersion}
- Debug Console {isConnected ? "Connected" : "Disconnected"} + Debug Console {isConnected ? 'Connected' : 'Disconnected'}
diff --git a/src/features/Types.tsx b/src/features/Types.tsx index b9af3db..52980cd 100644 --- a/src/features/Types.tsx +++ b/src/features/Types.tsx @@ -1,6 +1,6 @@ import { skipToken } from '@reduxjs/toolkit/query'; import useAppParams from '../shared/hooks/useAppParams'; -import { Type, useGetTypesQuery } from "../store/apiSlice"; +import { Type, useGetTypesQuery } from '../store/apiSlice'; const Types = () => { const { appId } = useAppParams(); @@ -28,7 +28,7 @@ const Types = () => {

The Type Names Supported by the Currently Loaded Plugins

-
+
diff --git a/src/features/Versions.tsx b/src/features/Versions.tsx index 89ca64f..c6fd368 100644 --- a/src/features/Versions.tsx +++ b/src/features/Versions.tsx @@ -1,6 +1,6 @@ import { skipToken } from '@reduxjs/toolkit/query'; import useAppParams from '../shared/hooks/useAppParams'; -import { Version, useGetVersionsQuery } from "../store/apiSlice"; +import { Version, useGetVersionsQuery } from '../store/apiSlice'; const Versions = () => { const { appId } = useAppParams(); @@ -11,7 +11,7 @@ const Versions = () => { } const unsorted: Version[] = []; - Object.assign(unsorted, versions) + Object.assign(unsorted, versions); const sorted = unsorted.sort((a, b) => { if (a.Name < b.Name) { @@ -24,24 +24,26 @@ const Versions = () => { }); return ( -
-

Loaded Assemblies and Versions

-
- - - - - - - - {sorted?.map((i) => ( - - - +
+

Loaded Assemblies and Versions

+
+
NameVersion
{i.Name}{i.Version}
+ + + + - ))} - -
NameVersion
+ + + {sorted?.map((i) => ( + + {i.Name} + {i.Version} + + ))} + + +
); }; diff --git a/src/features/routing/RoutePopover.module.scss b/src/features/routing/RoutePopover.module.scss new file mode 100644 index 0000000..6b38682 --- /dev/null +++ b/src/features/routing/RoutePopover.module.scss @@ -0,0 +1,178 @@ +// Rendered into document.body via a portal, so it is never clipped by a React Flow node's +// stacking context and never scales with the canvas zoom. +// +// Styled here in full rather than borrowing Bootstrap's .card - see the note in RoutePopover.tsx. +// Bootstrap is emitted after the CSS modules, so any property .card also sets would win on source +// order and silently override this panel (position and background both did). +.panel { + position: fixed; + display: flex; + flex-direction: column; + width: 280px; + min-width: 0; + font-size: 0.78rem; + color: #212529; + background-color: #fff; + border: 1px solid rgba(0, 0, 0, 0.175); + border-radius: 6px; + overflow: hidden; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); +} + +.panelDark { + background-color: #1e1e1e; + border-color: #444; + color: #e0e0e0; + + .header { + background-color: #2c2c2c; + border-bottom-color: #444; + } + + .option { + color: #e0e0e0; + + &:hover:not(:disabled) { + background-color: #2c2c2c; + } + } + + .optionCurrent { + background-color: #263238; + } + + .section + .section { + border-top-color: #333; + } +} + +.header { + line-height: 1.3; + background-color: rgba(0, 0, 0, 0.03); + border-bottom: 1px solid rgba(0, 0, 0, 0.175); +} + +.portLabel { + font-size: 0.7rem; +} + +.closeBtn { + flex-shrink: 0; + background: none; + border: none; + padding: 0 2px; + line-height: 1; + font-size: 1.1rem; + color: #888; + cursor: pointer; + + &:hover { + color: #dc3545; + } +} + +.section + .section { + border-top: 1px solid #f0f0f0; +} + +.sectionLabel { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +// --signal-color is set inline per button, matching the toolbar's signal-type toggles. +.signalTypeBtn { + font-size: 0.72rem; + padding: 1px 8px; + border-color: var(--signal-color) !important; + background-color: var(--signal-color) !important; + color: #fff !important; + + &.signalTypeBtnInactive { + background-color: transparent !important; + color: var(--signal-color) !important; + } +} + +.signalTypeStatic { + font-size: 0.72rem; + font-weight: 500; + color: #fff; +} + +.optionScroller { + overflow-y: auto; + max-height: 320px; +} + +.option { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + background: none; + border: none; + text-align: left; + padding: 5px 10px; + color: inherit; + cursor: pointer; + + &:hover:not(:disabled) { + background-color: #f1f3f5; + } + + &:disabled { + opacity: 0.55; + cursor: default; + } +} + +.optionClear { + color: #dc3545; + font-style: italic; + border-bottom: 1px solid #f0f0f0; + + .panelDark & { + color: #ff6b6b; + border-bottom-color: #333; + } +} + +.optionCurrent { + background-color: #e7f1ff; + font-weight: 600; +} + +.optionCheck { + flex-shrink: 0; + width: 12px; + color: #0d6efd; +} + +.optionLabel { + font-size: 0.78rem; +} + +.optionSublabel { + font-size: 0.68rem; +} + +.optionBadge { + flex-shrink: 0; + font-size: 0.6rem; +} + +.emptyMessage { + font-size: 0.74rem; +} + +.status { + font-size: 0.74rem; +} + +// Bootstrap 5 ships no min-width utility, but a flex child needs min-width:0 to allow its +// text-truncate children to actually shrink. +.minWidth0 { + min-width: 0; +} diff --git a/src/features/routing/RoutePopover.test.tsx b/src/features/routing/RoutePopover.test.tsx new file mode 100644 index 0000000..0b16789 --- /dev/null +++ b/src/features/routing/RoutePopover.test.tsx @@ -0,0 +1,371 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { RoutingPort } from '../../store/apiSlice'; +import { CandidateSource } from './routeGraph'; +import RoutePopover, { + RouteEditTarget, + RoutePopoverProps, +} from './RoutePopover'; + +const port = (key: string, signalType = 'AudioVideo'): RoutingPort => ({ + key, + signalType, + connectionType: 'Hdmi', + isInternal: false, +}); + +const candidate = ( + deviceKey: string, + matchedFlags = ['Audio', 'Video'] +): CandidateSource => ({ + deviceKey, + name: deviceKey, + isPureSource: true, + matchedFlags, +}); + +const anchorRect = { + top: 100, + left: 100, + right: 380, + bottom: 128, + width: 280, + height: 28, + x: 100, + y: 100, + toJSON: () => ({}), +} as DOMRect; + +const sinkTarget: RouteEditTarget = { + kind: 'sinkInput', + deviceKey: 'display-1', + deviceName: 'Main Display', + port: port('hdmiIn1'), +}; + +function renderPopover(overrides: Partial = {}) { + const onSubmit = vi.fn(); + const onClose = vi.fn(); + const props: RoutePopoverProps = { + target: sinkTarget, + anchorRect, + getCandidateSources: () => [candidate('laptop-1'), candidate('bluray-1')], + onSubmit, + onClose, + ...overrides, + }; + render(); + return { onSubmit, onClose }; +} + +const optionButton = (name: RegExp | string) => + screen.getByRole('button', { name }); + +describe('signal-type step', () => { + it('offers the full type plus each breakaway option for an AudioVideo port', () => { + renderPopover(); + expect(optionButton('AudioVideo')).toHaveAttribute('aria-pressed', 'true'); + expect(optionButton('Audio')).toHaveAttribute('aria-pressed', 'false'); + expect(optionButton('Video')).toHaveAttribute('aria-pressed', 'false'); + }); + + it('collapses to a static label for a single-type port', () => { + renderPopover({ + target: { ...sinkTarget, port: port('hdmiIn1', 'Video') }, + }); + expect( + screen.queryByRole('button', { name: 'Video' }) + ).not.toBeInTheDocument(); + expect(screen.getByText('Video')).toBeInTheDocument(); + }); + + it('re-queries candidates when the signal type changes', () => { + const getCandidateSources = vi.fn((signalType: string) => + signalType === 'Audio' + ? [candidate('dsp-1', ['Audio'])] + : [candidate('laptop-1')] + ); + renderPopover({ getCandidateSources }); + + expect(screen.getByText('laptop-1')).toBeInTheDocument(); + fireEvent.click(optionButton('Audio')); + + expect(getCandidateSources).toHaveBeenCalledWith('Audio'); + expect(screen.getByText('dsp-1')).toBeInTheDocument(); + expect(screen.queryByText('laptop-1')).not.toBeInTheDocument(); + }); +}); + +describe('sink routing', () => { + it('submits a sinkRoute command with the selected source and signal type', () => { + const { onSubmit } = renderPopover(); + fireEvent.click(optionButton(/laptop-1/)); + + expect(onSubmit).toHaveBeenCalledWith({ + command: 'sinkRoute', + deviceKey: 'display-1', + inputPortKey: 'hdmiIn1', + sourceDeviceKey: 'laptop-1', + signalType: 'AudioVideo', + }); + }); + + it('submits the narrowed signal type after a breakaway selection', () => { + const { onSubmit } = renderPopover(); + fireEvent.click(optionButton('Video')); + fireEvent.click(optionButton(/laptop-1/)); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + signalType: 'Video', + sourceDeviceKey: 'laptop-1', + }) + ); + }); + + it('submits a clearSink command from the None option', () => { + const { onSubmit } = renderPopover(); + fireEvent.click(optionButton(/clear route/i)); + + expect(onSubmit).toHaveBeenCalledWith({ + command: 'clearSink', + deviceKey: 'display-1', + inputPortKey: 'hdmiIn1', + clearSinkInput: true, + }); + }); + + it('marks the currently routed source', () => { + renderPopover({ current: { sourceDeviceKey: 'bluray-1' } }); + expect(within(optionButton(/bluray-1/)).getByText('βœ“')).toBeInTheDocument(); + }); + + it('badges a candidate that only has a path for one half of AudioVideo', () => { + renderPopover({ + getCandidateSources: () => [ + candidate('cam-1', ['Video']), + candidate('av-1'), + ], + }); + expect( + within(optionButton(/cam-1/)).getByText('video only') + ).toBeInTheDocument(); + expect( + within(optionButton(/av-1/)).queryByText(/only/) + ).not.toBeInTheDocument(); + }); + + // A partial match is offerable, not blocked: the request goes out as the selected AudioVideo and + // the processor routes whichever half has a path. Narrowing it to "Video" here would silently + // change what the user asked for. + it('submits a partial-match candidate with the selected signal type, unnarrowed', () => { + const { onSubmit } = renderPopover({ + getCandidateSources: () => [candidate('cam-1', ['Video'])], + }); + fireEvent.click(optionButton(/cam-1/)); + + expect(onSubmit).toHaveBeenCalledWith({ + command: 'sinkRoute', + deviceKey: 'display-1', + inputPortKey: 'hdmiIn1', + sourceDeviceKey: 'cam-1', + signalType: 'AudioVideo', + }); + }); + + it('does not disable a partial-match candidate', () => { + renderPopover({ + getCandidateSources: () => [candidate('cam-1', ['Video'])], + }); + expect(optionButton(/cam-1/)).not.toBeDisabled(); + }); + + it('does not badge anything once the request is narrowed to one atom', () => { + renderPopover({ + getCandidateSources: () => [candidate('cam-1', ['Video'])], + }); + fireEvent.click(optionButton('Video')); + expect(screen.queryByText(/only/)).not.toBeInTheDocument(); + }); + + it('explains an empty candidate list', () => { + renderPopover({ getCandidateSources: () => [] }); + expect( + screen.getByText('No source has a path to this input for AudioVideo.') + ).toBeInTheDocument(); + }); + + it('labels a multiview tile port readably', () => { + renderPopover({ + target: { + kind: 'sinkInput', + deviceKey: 'nvx-1', + deviceName: 'Decoder', + port: port('tile2:tileInput'), + tileNumber: 2, + }, + }); + expect(screen.getByText(/Tile 2/)).toBeInTheDocument(); + }); +}); + +describe('midpoint routing', () => { + const midpointTarget: RouteEditTarget = { + kind: 'midpointOutput', + deviceKey: 'dm-chassis-1', + deviceName: 'DM Chassis', + port: port('outputCard5'), + inputPorts: [ + port('inputCard1'), + port('inputCard2', 'Video'), + port('inputCard3', 'Audio'), + ], + }; + + it("lists the device's own input ports, without consulting the graph", () => { + const getCandidateSources = vi.fn(() => []); + renderPopover({ target: midpointTarget, getCandidateSources }); + + expect(getCandidateSources).not.toHaveBeenCalled(); + expect(optionButton(/inputCard1/)).toBeInTheDocument(); + }); + + it('hides inputs that cannot carry the requested signal type', () => { + renderPopover({ target: midpointTarget }); + // An AudioVideo request needs both flags; the Video-only and Audio-only cards cannot serve it. + expect(optionButton(/inputCard1/)).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /inputCard2/ }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /inputCard3/ }) + ).not.toBeInTheDocument(); + }); + + it('reveals a breakaway-only input once the type is narrowed', () => { + renderPopover({ target: midpointTarget }); + fireEvent.click(optionButton('Video')); + expect(optionButton(/inputCard2/)).toBeInTheDocument(); + }); + + it('submits a midpointSwitch command', () => { + const { onSubmit } = renderPopover({ target: midpointTarget }); + fireEvent.click(optionButton(/inputCard1/)); + + expect(onSubmit).toHaveBeenCalledWith({ + command: 'midpointSwitch', + deviceKey: 'dm-chassis-1', + inputPortKey: 'inputCard1', + outputPortKey: 'outputCard5', + signalType: 'AudioVideo', + }); + }); + + it('submits a clearMidpointOutput command from the None option', () => { + const { onSubmit } = renderPopover({ target: midpointTarget }); + fireEvent.click(optionButton(/clear route/i)); + + expect(onSubmit).toHaveBeenCalledWith({ + command: 'clearMidpointOutput', + deviceKey: 'dm-chassis-1', + outputPortKey: 'outputCard5', + signalType: 'AudioVideo', + }); + }); +}); + +describe('panel styling', () => { + // Regression guard. The panel is positioned fixed by its own CSS module, but Bootstrap is + // emitted after the modules in the bundle, so any Bootstrap single-class selector setting the + // same property wins on source order. Adding `card` back here silently reverts the panel to + // position:relative, which parks the portal at the end of where it cannot be seen - + // the popover mounts and behaves correctly, it is just invisible, so no behavioral test + // catches it. jsdom does not apply the module CSS, so the class list is what we can assert. + it('does not borrow Bootstrap classes that would override its own positioning', () => { + renderPopover(); + const panel = screen.getByRole('dialog'); + for (const cls of [ + 'card', + 'card-body', + 'card-header', + 'position-relative', + 'position-absolute', + ]) { + expect(panel.classList.contains(cls)).toBe(false); + } + expect(panel.querySelector('.card, .card-body, .card-header')).toBeNull(); + }); +}); + +describe('submission state and dismissal', () => { + it('disables the options and shows progress while submitting', () => { + renderPopover({ isSubmitting: true }); + expect(screen.getByText('Sending…')).toBeInTheDocument(); + expect(optionButton(/laptop-1/)).toBeDisabled(); + expect(optionButton(/clear route/i)).toBeDisabled(); + }); + + it('shows an error without closing', () => { + renderPopover({ + errorMessage: 'No path from laptop-1 to display-1 for Video.', + }); + expect(screen.getByRole('alert')).toHaveTextContent( + 'No path from laptop-1' + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('closes on Escape', () => { + const { onClose } = renderPopover(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('closes on an outside click but not on an inside one', () => { + const { onClose } = renderPopover(); + + fireEvent.pointerDown(screen.getByText('Main Display')); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.pointerDown(document.body); + expect(onClose).toHaveBeenCalled(); + }); + + it('closes from the header close button', () => { + const { onClose } = renderPopover(); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(onClose).toHaveBeenCalled(); + }); +}); + +describe('filtering', () => { + const many = Array.from({ length: 20 }, (_, i) => candidate(`src-${i}`)); + + it('offers a filter box only for long lists', () => { + renderPopover({ getCandidateSources: () => [candidate('laptop-1')] }); + expect(screen.queryByLabelText('Filter options')).not.toBeInTheDocument(); + }); + + it('narrows a long list', () => { + renderPopover({ getCandidateSources: () => many }); + fireEvent.change(screen.getByLabelText('Filter options'), { + target: { value: 'src-19' }, + }); + + expect(optionButton(/src-19/)).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /src-18/ }) + ).not.toBeInTheDocument(); + }); + + it('keeps the clear option available while filtering', () => { + renderPopover({ getCandidateSources: () => many }); + fireEvent.change(screen.getByLabelText('Filter options'), { + target: { value: 'nothingmatches' }, + }); + + expect(screen.getByText('No matches.')).toBeInTheDocument(); + expect(optionButton(/clear route/i)).toBeInTheDocument(); + }); +}); diff --git a/src/features/routing/RoutePopover.tsx b/src/features/routing/RoutePopover.tsx new file mode 100644 index 0000000..34355af --- /dev/null +++ b/src/features/routing/RoutePopover.tsx @@ -0,0 +1,407 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { RoutingPort } from '../../store/apiSlice'; +import { + clearMidpointOutputCommand, + clearSinkCommand, + midpointSwitchCommand, + RoutingCommand, + sinkRouteCommand, +} from '../../store/routingCommands'; +import { CandidateSource } from './routeGraph'; +import { signalColor } from './signalColors'; +import { + atomsOf, + portSupportsSignalType, + signalTypeOptionsForPort, +} from './signalTypes'; +import styles from './RoutePopover.module.scss'; + +/** What the user clicked, and therefore which of the two routing flows this popover drives. */ +export type RouteEditTarget = + | { + kind: 'sinkInput'; + deviceKey: string; + deviceName: string; + port: RoutingPort; + /** Set when the port is a multiview tile ("tile{N}:..."), for a friendlier label. */ + tileNumber?: number; + } + | { + kind: 'midpointOutput'; + deviceKey: string; + deviceName: string; + port: RoutingPort; + /** All input ports on the same device - the midpoint flow never leaves the device. */ + inputPorts: RoutingPort[]; + }; + +export interface RoutePopoverCurrent { + sourceDeviceKey?: string | null; + inputPortKey?: string | null; +} + +export interface RoutePopoverProps { + target: RouteEditTarget; + /** Viewport rect of the clicked port row; the popover anchors beside it. */ + anchorRect: DOMRect; + darkMode?: boolean; + /** What is routed to this port right now, for the check mark and the Clear affordance. */ + current?: RoutePopoverCurrent | null; + /** Memoized candidate lookup. Only called for the sinkInput flow. */ + getCandidateSources: (signalType: string) => CandidateSource[]; + /** Explains an empty source list for the sinkInput flow - the two causes need different fixes. */ + describeEmptySources?: (signalType: string) => string; + isSubmitting?: boolean; + errorMessage?: string | null; + onSubmit: (command: RoutingCommand) => void; + onClose: () => void; +} + +const GAP_PX = 8; +const VIEWPORT_MARGIN_PX = 8; +/** Above the filter bar and the floating multiview layout panels. */ +const Z_INDEX = 1070; +/** Beyond this many options, offer a filter box. */ +const FILTER_THRESHOLD = 12; + +function tileLabel(target: RouteEditTarget): string { + if (target.kind === 'sinkInput' && target.tileNumber !== undefined) { + return `Tile ${target.tileNumber}`; + } + return target.port.key; +} + +const RoutePopover = ({ + target, + anchorRect, + darkMode, + current, + getCandidateSources, + describeEmptySources, + isSubmitting = false, + errorMessage, + onSubmit, + onClose, +}: RoutePopoverProps) => { + const panelRef = useRef(null); + + const signalTypeOptions = useMemo( + () => signalTypeOptionsForPort(target.port.signalType), + [target.port.signalType] + ); + // A single-atom port has nothing to choose, so step 1 collapses to a label. + const [signalType, setSignalType] = useState(signalTypeOptions[0] ?? ''); + const [filter, setFilter] = useState(''); + + // ── Positioning ────────────────────────────────────────────────────────── + // anchorRect is already in viewport coordinates (the node is real DOM inside React Flow's + // transformed container), so a fixed-position portal lands correctly at any zoom without + // needing the flow transform. Input ports sit on a node's left edge and outputs on its right, + // so preferring the right side and flipping on overflow reads naturally for both. + const [position, setPosition] = useState<{ left: number; top: number }>({ + left: anchorRect.right + GAP_PX, + top: anchorRect.top, + }); + + useLayoutEffect(() => { + const panel = panelRef.current; + if (!panel) return; + const { width, height } = panel.getBoundingClientRect(); + + let left = anchorRect.right + GAP_PX; + if (left + width > window.innerWidth - VIEWPORT_MARGIN_PX) { + left = anchorRect.left - width - GAP_PX; + } + left = Math.max(VIEWPORT_MARGIN_PX, left); + + const top = Math.max( + VIEWPORT_MARGIN_PX, + Math.min(anchorRect.top, window.innerHeight - height - VIEWPORT_MARGIN_PX) + ); + + setPosition({ left, top }); + }, [anchorRect, signalType, target.deviceKey, target.port.key]); + + // ── Dismissal ──────────────────────────────────────────────────────────── + useEffect(() => { + const onPointerDown = (e: PointerEvent) => { + if (!panelRef.current?.contains(e.target as globalThis.Node)) onClose(); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + // Capture phase, so a click on another port row closes this one before opening that one. + document.addEventListener('pointerdown', onPointerDown, true); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onPointerDown, true); + document.removeEventListener('keydown', onKeyDown); + }; + }, [onClose]); + + // ── Options ────────────────────────────────────────────────────────────── + const candidates = useMemo( + () => + target.kind === 'sinkInput' && signalType + ? getCandidateSources(signalType) + : [], + [target.kind, signalType, getCandidateSources] + ); + + const inputPortOptions = useMemo( + () => + target.kind === 'midpointOutput' && signalType + ? target.inputPorts.filter((p) => + portSupportsSignalType(p.signalType, signalType) + ) + : [], + [target, signalType] + ); + + // How many atoms the *selected* type asks for - not how many the port offers, which would + // wrongly badge every candidate once the user narrows to a single breakaway type. + const requestedAtomCount = useMemo( + () => atomsOf(signalType).length, + [signalType] + ); + + const rows: { + key: string; + label: string; + sublabel?: string; + badge?: string; + }[] = + target.kind === 'sinkInput' + ? candidates.map((c) => ({ + key: c.deviceKey, + label: c.name, + sublabel: c.name === c.deviceKey ? undefined : c.deviceKey, + // A partial match on an AudioVideo request - the processor still routes the half that + // has a path, so offer it rather than hiding it, but say so. + badge: + c.matchedFlags.length < requestedAtomCount + ? `${c.matchedFlags.join(' + ').toLowerCase()} only` + : undefined, + })) + : inputPortOptions.map((p) => ({ + key: p.key, + label: p.key, + sublabel: p.signalType, + })); + + const filtered = + rows.length > FILTER_THRESHOLD && filter + ? rows.filter((r) => + `${r.label} ${r.sublabel ?? ''}` + .toLowerCase() + .includes(filter.toLowerCase()) + ) + : rows; + + const currentKey = + target.kind === 'sinkInput' + ? current?.sourceDeviceKey + : current?.inputPortKey; + + function handlePick(key: string | null): void { + if (key === null) { + onSubmit( + target.kind === 'sinkInput' + ? clearSinkCommand(target.deviceKey, target.port.key) + : clearMidpointOutputCommand( + target.deviceKey, + target.port.key, + signalType + ) + ); + return; + } + onSubmit( + target.kind === 'sinkInput' + ? sinkRouteCommand(target.deviceKey, target.port.key, key, signalType) + : midpointSwitchCommand( + target.deviceKey, + key, + target.port.key, + signalType + ) + ); + } + + const emptyMessage = + target.kind === 'sinkInput' + ? (describeEmptySources?.(signalType) ?? + `No source has a path to this input for ${signalType}.`) + : `No input on this device carries ${signalType}.`; + + // The panel styles itself rather than reusing Bootstrap's `card`. Both are single-class + // selectors, and Bootstrap is emitted after the CSS modules, so `.card { position: relative }` + // would beat this panel's `position: fixed` and park the portal at the end of , out of + // view - and `.card`'s background would likewise override the dark-mode one. + // MultiviewLayoutPanel avoids `card` for the same reason. + return createPortal( +
+
+
+
+
{target.deviceName}
+
+ {target.kind === 'sinkInput' ? 'Input' : 'Output'} Β·{' '} + {tileLabel(target)} +
+
+ +
+
+ +
+ {/* Step 1 - signal type */} +
+
+ Signal type +
+ {signalTypeOptions.length <= 1 ? ( + + {signalType || 'Unknown'} + + ) : ( +
+ {signalTypeOptions.map((option) => ( + + ))} +
+ )} +
+ + {/* Step 2 - source device, or input port on the same midpoint */} +
+
+ {target.kind === 'sinkInput' ? 'Source' : 'Route from input'} +
+ {rows.length > FILTER_THRESHOLD && ( + setFilter(e.target.value)} + aria-label="Filter options" + /> + )} +
+ +
+ + + {filtered.map((row) => { + const isCurrent = row.key === currentKey; + return ( + + ); + })} + + {filtered.length === 0 && ( +
+ {filter ? 'No matches.' : emptyMessage} +
+ )} +
+ + {isSubmitting && ( +
+
+ )} + + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+
, + document.body + ); +}; + +export default RoutePopover; diff --git a/src/features/routing/currentSource.test.ts b/src/features/routing/currentSource.test.ts new file mode 100644 index 0000000..73e2ce5 --- /dev/null +++ b/src/features/routing/currentSource.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; + +import { + MidpointRoute, + RoutingDevice, + RoutingDevicesAndTieLines, + TieLine, +} from '../../store/apiSlice'; +import { resolveCurrentSource } from './currentSource'; +import { buildRouteIndex } from './routeGraph'; + +type Role = 'source' | 'sink' | 'midpoint' | 'passthrough'; + +function device(key: string, role: Role): RoutingDevice { + const flags = { + source: { hasInputs: false, hasOutputs: true, hasInputsAndOutputs: false }, + sink: { hasInputs: true, hasOutputs: false, hasInputsAndOutputs: false }, + midpoint: { hasInputs: true, hasOutputs: true, hasInputsAndOutputs: true }, + passthrough: { + hasInputs: true, + hasOutputs: true, + hasInputsAndOutputs: false, + }, + }[role]; + return { key, name: key, ...flags }; +} + +function tie( + sourceDeviceKey: string, + sourcePortKey: string, + destinationDeviceKey: string, + destinationPortKey: string +): TieLine { + return { + sourceDeviceKey, + sourcePortKey, + destinationDeviceKey, + destinationPortKey, + signalType: 'AudioVideo', + isInternal: false, + }; +} + +function system( + devices: RoutingDevice[], + tieLines: TieLine[] +): RoutingDevicesAndTieLines { + return { devices, tieLines, currentRoutes: [], sinkCurrentSources: [] }; +} + +const route = (inputPortKey: string, outputPortKey: string): MidpointRoute => ({ + inputPortKey, + outputPortKey, + signalType: 'AudioVideo', +}); + +// laptop -> mtx.in1 ; bluray -> mtx.in2 ; mtx.out1 -> display.hdmi1 +const SIMPLE = buildRouteIndex( + system( + [ + device('laptop', 'source'), + device('bluray', 'source'), + device('mtx', 'midpoint'), + device('display', 'sink'), + ], + [ + tie('laptop', 'out1', 'mtx', 'in1'), + tie('bluray', 'out1', 'mtx', 'in2'), + tie('mtx', 'out1', 'display', 'hdmi1'), + ] + ) +); + +describe('resolveCurrentSource', () => { + it("follows the midpoint's active route to the real origin", () => { + expect( + resolveCurrentSource( + SIMPLE, + { mtx: [route('in1', 'out1')] }, + 'display', + 'hdmi1' + ) + ).toEqual({ status: 'resolved', sourceDeviceKey: 'laptop' }); + }); + + // The reported bug: switching the matrix directly must move the answer, even though the sink's + // own current-source bookkeeping on the processor never gets updated for a midpoint switch. + it('follows a midpoint switch without any sink-side feedback', () => { + expect( + resolveCurrentSource( + SIMPLE, + { mtx: [route('in2', 'out1')] }, + 'display', + 'hdmi1' + ) + ).toEqual({ status: 'resolved', sourceDeviceKey: 'bluray' }); + }); + + it('reports cleared when the feeding output has no active route', () => { + // The device publishes feedback, and that feedback says nothing is on out1. + expect( + resolveCurrentSource( + SIMPLE, + { mtx: [route('in1', 'out9')] }, + 'display', + 'hdmi1' + ) + ).toEqual({ status: 'cleared' }); + expect( + resolveCurrentSource(SIMPLE, { mtx: [] }, 'display', 'hdmi1') + ).toEqual({ + status: 'cleared', + }); + }); + + it('reports unknown when the midpoint publishes no feedback at all', () => { + // Distinct from "cleared" - the caller must fall back rather than claim nothing is routed. + expect(resolveCurrentSource(SIMPLE, {}, 'display', 'hdmi1')).toEqual({ + status: 'unknown', + }); + }); + + it('reports unknown for a port with no tie line to follow', () => { + expect( + resolveCurrentSource( + SIMPLE, + { mtx: [route('in1', 'out1')] }, + 'display', + 'hdmi9' + ) + ).toEqual({ status: 'unknown' }); + }); + + it('traces through several chained matrices', () => { + const index = buildRouteIndex( + system( + [ + device('cam', 'source'), + device('mtx-a', 'midpoint'), + device('mtx-b', 'midpoint'), + device('display', 'sink'), + ], + [ + tie('cam', 'out1', 'mtx-a', 'in4'), + tie('mtx-a', 'out2', 'mtx-b', 'in7'), + tie('mtx-b', 'out3', 'display', 'hdmi1'), + ] + ) + ); + + expect( + resolveCurrentSource( + index, + { 'mtx-a': [route('in4', 'out2')], 'mtx-b': [route('in7', 'out3')] }, + 'display', + 'hdmi1' + ) + ).toEqual({ status: 'resolved', sourceDeviceKey: 'cam' }); + + // Break the chain at the upstream matrix and the whole path goes dark. + expect( + resolveCurrentSource( + index, + { 'mtx-a': [], 'mtx-b': [route('in7', 'out3')] }, + 'display', + 'hdmi1' + ) + ).toEqual({ status: 'cleared' }); + }); + + it('stops at a non-midpoint passthrough, which is the origin as far as routing is concerned', () => { + const index = buildRouteIndex( + system( + [ + device('cam', 'source'), + device('scaler', 'passthrough'), + device('display', 'sink'), + ], + [ + tie('cam', 'out1', 'scaler', 'in1'), + tie('scaler', 'out1', 'display', 'hdmi1'), + ] + ) + ); + expect(resolveCurrentSource(index, {}, 'display', 'hdmi1')).toEqual({ + status: 'resolved', + sourceDeviceKey: 'scaler', + }); + }); + + it('terminates on a loop instead of hanging', () => { + const index = buildRouteIndex( + system( + [ + device('mtx-a', 'midpoint'), + device('mtx-b', 'midpoint'), + device('display', 'sink'), + ], + [ + tie('mtx-a', 'out1', 'mtx-b', 'in1'), + tie('mtx-b', 'out1', 'mtx-a', 'in1'), + tie('mtx-b', 'out2', 'display', 'hdmi1'), + ] + ) + ); + expect( + resolveCurrentSource( + index, + { + 'mtx-a': [route('in1', 'out1')], + 'mtx-b': [route('in1', 'out1'), route('in1', 'out2')], + }, + 'display', + 'hdmi1' + ) + ).toEqual({ status: 'unknown' }); + }); + + it('resolves a multiview tile by its qualified port key', () => { + const index = buildRouteIndex( + system( + [ + device('cam-1', 'source'), + device('mtx', 'midpoint'), + device('nvx', 'sink'), + ], + [ + tie('cam-1', 'out1', 'mtx', 'in1'), + tie('mtx', 'out5', 'nvx', 'tile2:tileInput'), + ] + ) + ); + expect( + resolveCurrentSource( + index, + { mtx: [route('in1', 'out5')] }, + 'nvx', + 'tile2:tileInput' + ) + ).toEqual({ status: 'resolved', sourceDeviceKey: 'cam-1' }); + }); +}); diff --git a/src/features/routing/currentSource.ts b/src/features/routing/currentSource.ts new file mode 100644 index 0000000..99b2bb2 --- /dev/null +++ b/src/features/routing/currentSource.ts @@ -0,0 +1,78 @@ +/** + * Resolving what is *actually* feeding a destination port right now. + * + * The obvious answer - the sink's own current-source bookkeeping in `sinkRoutes` - is only + * written by the processor's graph-level route path (`RunRouteRequest` calls `SetCurrentSource` + * after executing a route). Switching a midpoint directly, via + * `IRoutingMidpointWithFeedback.ExecuteSwitch`, bypasses that entirely: the matrix reports its new + * internal route over `RouteChanged`, but nothing tells the downstream display that what it is + * watching just changed. `sinkRoutes` then keeps the stale value from the last full route. + * + * So instead of trusting that bookkeeping, walk backwards from the port over the physical tie + * lines, using each midpoint's live `midpointRoutes` as its crossbar state, until reaching a + * device that is not a midpoint. That is the real origin, and it updates the instant any midpoint + * in the chain switches. + * + * The walk cannot always answer, so the result is explicit about which. A "cleared" verdict (a + * midpoint reporting no route on the feeding output) is real information and must not be confused + * with "unknown" (no tie lines to follow, or a midpoint that publishes no feedback at all) - only + * the latter should fall back to `sinkRoutes`. + */ + +import { MidpointRoute } from '../../store/apiSlice'; +import { portId, RouteIndex } from './routeGraph'; + +export type CurrentSourceResolution = + /** Traced all the way to an originating device. */ + | { status: 'resolved'; sourceDeviceKey: string } + /** Traced to a midpoint that has no active route on the feeding output - nothing is getting through. */ + | { status: 'cleared' } + /** The walk ran out of information; the caller should fall back to the sink's own bookkeeping. */ + | { status: 'unknown' }; + +/** + * Traces backwards from a destination port to whatever is feeding it, following live midpoint + * routes. + */ +export function resolveCurrentSource( + index: RouteIndex, + midpointRoutes: Record, + destDeviceKey: string, + destPortKey: string +): CurrentSourceResolution { + const visited = new Set(); + let deviceKey = destDeviceKey; + let portKey = destPortKey; + + for (;;) { + const id = portId(deviceKey, portKey); + // A miswired loop must not hang the popover. + if (visited.has(id)) return { status: 'unknown' }; + visited.add(id); + + const incoming = index.byDestPort.get(id); + // No tie line to follow - e.g. a dynamically routed system with no static wiring. + if (!incoming || incoming.length === 0) return { status: 'unknown' }; + + // A physical input port is fed by one wire; if a config models more, the first is as good a + // guess as any and the alternative is inventing a tie-break the hardware does not have. + const { sourceDeviceKey, sourcePortKey } = incoming[0].tieLine; + + // Anything that is not a midpoint is the origin: the backend refuses to route through + // non-midpoints too, so the chain genuinely ends here. + if (!index.midpointKeys.has(sourceDeviceKey)) { + return { status: 'resolved', sourceDeviceKey }; + } + + const routes = midpointRoutes[sourceDeviceKey]; + // The device publishes no route feedback at all, so its crossbar state is unknowable. + if (!routes) return { status: 'unknown' }; + + const active = routes.find((r) => r.outputPortKey === sourcePortKey); + // It does publish feedback, and reports nothing on this output - a real "nothing is routed". + if (!active) return { status: 'cleared' }; + + deviceKey = sourceDeviceKey; + portKey = active.inputPortKey; + } +} diff --git a/src/features/routing/pendingRoutes.test.ts b/src/features/routing/pendingRoutes.test.ts new file mode 100644 index 0000000..7aa6bb6 --- /dev/null +++ b/src/features/routing/pendingRoutes.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, it } from 'vitest'; + +import { MidpointRoute, SinkRoute } from '../../store/apiSlice'; +import { + clearMidpointOutputCommand, + clearSinkCommand, + midpointSwitchCommand, + sinkRouteCommand, +} from '../../store/routingCommands'; +import { + agePendingRoutes, + isExpectationMet, + PENDING_CLEANUP_MS, + PENDING_TIMEOUT_MS, + PendingRoute, + pendingFromCommand, + pendingId, +} from './pendingRoutes'; + +const NOW = 1_700_000_000_000; + +const sinks = (routes: Record) => routes; +const midpoints = (routes: Record) => routes; + +describe('pendingFromCommand', () => { + it('watches the destination input and the requested source for a sinkRoute', () => { + expect( + pendingFromCommand( + sinkRouteCommand('display-1', 'hdmiIn1', 'laptop-1', 'AudioVideo'), + NOW + ) + ).toEqual({ + deviceKey: 'display-1', + portKey: 'hdmiIn1', + kind: 'sinkInput', + expectedSourceDeviceKey: 'laptop-1', + expectedInputPortKey: null, + startedAt: NOW, + }); + }); + + it('watches the midpoint OUTPUT port, and the requested input, for a midpointSwitch', () => { + expect( + pendingFromCommand( + midpointSwitchCommand('dm-1', 'inputCard3', 'outputCard5', 'Video'), + NOW + ) + ).toEqual({ + deviceKey: 'dm-1', + portKey: 'outputCard5', + kind: 'midpointOutput', + expectedSourceDeviceKey: null, + expectedInputPortKey: 'inputCard3', + startedAt: NOW, + }); + }); + + it('expects the absence of a route for both clear commands', () => { + const sinkClear = pendingFromCommand( + clearSinkCommand('display-1', 'hdmiIn1'), + NOW + ); + expect(sinkClear).toMatchObject({ + kind: 'sinkInput', + portKey: 'hdmiIn1', + expectedSourceDeviceKey: null, + expectedInputPortKey: null, + }); + + const midpointClear = pendingFromCommand( + clearMidpointOutputCommand('dm-1', 'outputCard5', 'AudioVideo'), + NOW + ); + expect(midpointClear).toMatchObject({ + kind: 'midpointOutput', + portKey: 'outputCard5', + expectedSourceDeviceKey: null, + expectedInputPortKey: null, + }); + }); + + it('returns null when there is no port to mark', () => { + // clearSink may omit inputPortKey, meaning "clear whatever this sink has". + expect( + pendingFromCommand({ command: 'clearSink', deviceKey: 'display-1' }, NOW) + ).toBeNull(); + }); + + it('keys markers uniquely per device and port', () => { + expect(pendingId('a', 'b')).not.toBe(pendingId('b', 'a')); + expect(pendingId('a', 'b')).toBe(pendingId('a', 'b')); + }); +}); + +describe('isExpectationMet - sink routes', () => { + const routePending = pendingFromCommand( + sinkRouteCommand('display-1', 'hdmiIn1', 'laptop-1', 'AudioVideo'), + NOW + )!; + + it('is unmet while nothing is reported', () => { + expect(isExpectationMet(sinks({}), midpoints({}), routePending)).toBe( + false + ); + }); + + it('is met once the requested source is reported on that port', () => { + const state = sinks({ + 'display-1': [ + { + inputPortKey: 'hdmiIn1', + sourceDeviceKey: 'laptop-1', + signalType: 'AudioVideo', + }, + ], + }); + expect(isExpectationMet(state, midpoints({}), routePending)).toBe(true); + }); + + it('stays unmet when a different source lands on that port', () => { + const state = sinks({ + 'display-1': [ + { + inputPortKey: 'hdmiIn1', + sourceDeviceKey: 'bluray-1', + signalType: 'AudioVideo', + }, + ], + }); + expect(isExpectationMet(state, midpoints({}), routePending)).toBe(false); + }); + + it('stays unmet when the right source lands on a different port', () => { + const state = sinks({ + 'display-1': [ + { + inputPortKey: 'hdmiIn2', + sourceDeviceKey: 'laptop-1', + signalType: 'AudioVideo', + }, + ], + }); + expect(isExpectationMet(state, midpoints({}), routePending)).toBe(false); + }); + + it('resolves a clear only once the entry is gone', () => { + const clearPending = pendingFromCommand( + clearSinkCommand('display-1', 'hdmiIn1'), + NOW + )!; + const stillRouted = sinks({ + 'display-1': [ + { + inputPortKey: 'hdmiIn1', + sourceDeviceKey: 'laptop-1', + signalType: 'AudioVideo', + }, + ], + }); + + expect(isExpectationMet(stillRouted, midpoints({}), clearPending)).toBe( + false + ); + expect(isExpectationMet(sinks({}), midpoints({}), clearPending)).toBe(true); + // Another tile on the same decoder staying routed must not block the cleared one. + expect( + isExpectationMet( + sinks({ + 'display-1': [ + { + inputPortKey: 'hdmiIn2', + sourceDeviceKey: 'bluray-1', + signalType: 'AudioVideo', + }, + ], + }), + midpoints({}), + clearPending + ) + ).toBe(true); + }); + + it('tracks a multiview tile by its qualified port key', () => { + const tilePending = pendingFromCommand( + sinkRouteCommand('nvx-1', 'tile2:tileInput', 'cam-2', 'Video'), + NOW + )!; + const state = sinks({ + 'nvx-1': [ + { + inputPortKey: 'tile1:tileInput', + sourceDeviceKey: 'cam-1', + signalType: 'Video', + }, + { + inputPortKey: 'tile2:tileInput', + sourceDeviceKey: 'cam-2', + signalType: 'Video', + }, + ], + }); + expect(isExpectationMet(state, midpoints({}), tilePending)).toBe(true); + }); +}); + +describe('isExpectationMet - midpoint routes', () => { + const switchPending = pendingFromCommand( + midpointSwitchCommand('dm-1', 'inputCard3', 'outputCard5', 'Video'), + NOW + )!; + + it('is met once the output reports the requested input', () => { + const state = midpoints({ + 'dm-1': [ + { + inputPortKey: 'inputCard3', + outputPortKey: 'outputCard5', + signalType: 'Video', + }, + ], + }); + expect(isExpectationMet(sinks({}), state, switchPending)).toBe(true); + }); + + it('stays unmet when another output was switched instead', () => { + const state = midpoints({ + 'dm-1': [ + { + inputPortKey: 'inputCard3', + outputPortKey: 'outputCard6', + signalType: 'Video', + }, + ], + }); + expect(isExpectationMet(sinks({}), state, switchPending)).toBe(false); + }); + + it('resolves a midpoint clear only once that output has no route', () => { + const clearPending = pendingFromCommand( + clearMidpointOutputCommand('dm-1', 'outputCard5', 'AudioVideo'), + NOW + )!; + const stillRouted = midpoints({ + 'dm-1': [ + { + inputPortKey: 'inputCard3', + outputPortKey: 'outputCard5', + signalType: 'Video', + }, + ], + }); + const otherOutputOnly = midpoints({ + 'dm-1': [ + { + inputPortKey: 'inputCard3', + outputPortKey: 'outputCard6', + signalType: 'Video', + }, + ], + }); + + expect(isExpectationMet(sinks({}), stillRouted, clearPending)).toBe(false); + expect(isExpectationMet(sinks({}), otherOutputOnly, clearPending)).toBe( + true + ); + }); +}); + +describe('agePendingRoutes', () => { + const entry = (startedAt: number, timedOut?: boolean): PendingRoute => ({ + deviceKey: 'display-1', + portKey: 'hdmiIn1', + kind: 'sinkInput', + expectedSourceDeviceKey: 'laptop-1', + expectedInputPortKey: null, + startedAt, + timedOut, + }); + + it('returns the same object when nothing aged, so a state update can bail out', () => { + const pending = { a: entry(NOW) }; + expect(agePendingRoutes(pending, NOW + 1_000)).toBe(pending); + }); + + it('flags an entry past the timeout', () => { + const result = agePendingRoutes( + { a: entry(NOW) }, + NOW + PENDING_TIMEOUT_MS + 1 + ); + expect(result.a.timedOut).toBe(true); + }); + + it('does not re-flag an already-flagged entry', () => { + const pending = { a: entry(NOW, true) }; + expect(agePendingRoutes(pending, NOW + PENDING_TIMEOUT_MS + 1)).toBe( + pending + ); + }); + + it('drops an entry once the warning period is over', () => { + const result = agePendingRoutes( + { a: entry(NOW, true) }, + NOW + PENDING_TIMEOUT_MS + PENDING_CLEANUP_MS + 1 + ); + expect(result).toEqual({}); + }); + + it('ages each entry independently', () => { + const result = agePendingRoutes( + { fresh: entry(NOW), stale: entry(NOW - PENDING_TIMEOUT_MS - 1) }, + NOW + ); + expect(result.fresh.timedOut).toBeUndefined(); + expect(result.stale.timedOut).toBe(true); + }); +}); diff --git a/src/features/routing/pendingRoutes.ts b/src/features/routing/pendingRoutes.ts new file mode 100644 index 0000000..b979c09 --- /dev/null +++ b/src/features/routing/pendingRoutes.ts @@ -0,0 +1,126 @@ +/** + * Tracking for routing commands the processor has accepted but not yet confirmed. + * + * `sinkRoute` and `clearSink` return 202: the processor validates them synchronously, then runs + * them through a serialized routing queue, and a destination that is warming or cooling can hold + * its request until the cooldown finishes. So the authoritative result arrives over the feedback + * WebSocket rather than in the HTTP response, and the affected port shows a pending marker until + * the expected feedback lands - or until it plainly never will. + */ + +import { MidpointRoute, SinkRoute } from '../../store/apiSlice'; +import { RoutingCommand } from '../../store/routingCommands'; + +export interface PendingRoute { + deviceKey: string; + portKey: string; + kind: 'sinkInput' | 'midpointOutput'; + /** Null for a clear, where the expectation is the ABSENCE of a route. */ + expectedSourceDeviceKey: string | null; + expectedInputPortKey: string | null; + startedAt: number; + timedOut?: boolean; +} + +/** How long to wait for feedback before warning that a route may not have been made. */ +export const PENDING_TIMEOUT_MS = 10_000; +/** How long the warning stays on the port before clearing itself. */ +export const PENDING_CLEANUP_MS = 5_000; + +/** NUL cannot appear in a device or port key, so it cannot collide with key content. */ +export function pendingId(deviceKey: string, portKey: string): string { + return `${deviceKey}\u0000${portKey}`; +} + +/** + * Derives what to watch for from the command that was sent, or null when the command names no + * port to attach a marker to (a `clearSink` with no input port clears whatever the sink has). + */ +export function pendingFromCommand( + command: RoutingCommand, + now: number +): PendingRoute | null { + const isMidpoint = + command.command === 'midpointSwitch' || + command.command === 'clearMidpointOutput'; + const portKey = isMidpoint ? command.outputPortKey : command.inputPortKey; + if (!portKey) return null; + + return { + deviceKey: command.deviceKey, + portKey, + kind: isMidpoint ? 'midpointOutput' : 'sinkInput', + expectedSourceDeviceKey: + command.command === 'sinkRoute' ? command.sourceDeviceKey : null, + expectedInputPortKey: + command.command === 'midpointSwitch' ? command.inputPortKey : null, + startedAt: now, + }; +} + +/** + * Has sink feedback caught up with what was asked for? A route expects the port to report the + * requested source; a clear expects no route at all, since the feedback slice removes cleared + * entries rather than storing a sourceless one. + */ +export function isSinkExpectationMet( + sinkRoutes: Record, + pending: PendingRoute +): boolean { + const route = (sinkRoutes[pending.deviceKey] ?? []).find( + (r) => r.inputPortKey === pending.portKey + ); + if (pending.expectedSourceDeviceKey === null) return route === undefined; + return route?.sourceDeviceKey === pending.expectedSourceDeviceKey; +} + +/** The same for a midpoint output: the requested input, or no route on that output for a clear. */ +export function isMidpointExpectationMet( + midpointRoutes: Record, + pending: PendingRoute +): boolean { + const route = (midpointRoutes[pending.deviceKey] ?? []).find( + (r) => r.outputPortKey === pending.portKey + ); + if (pending.expectedInputPortKey === null) return route === undefined; + return route?.inputPortKey === pending.expectedInputPortKey; +} + +/** True when live feedback shows the command landed. */ +export function isExpectationMet( + sinkRoutes: Record, + midpointRoutes: Record, + pending: PendingRoute +): boolean { + return pending.kind === 'sinkInput' + ? isSinkExpectationMet(sinkRoutes, pending) + : isMidpointExpectationMet(midpointRoutes, pending); +} + +/** + * Ages out markers the processor never confirmed: flags them past the timeout, then drops them. + * Returns the same object when nothing changed, so callers can bail out of a state update. + */ +export function agePendingRoutes( + pending: Record, + now: number +): Record { + const next: Record = {}; + let changed = false; + + for (const [id, entry] of Object.entries(pending)) { + const age = now - entry.startedAt; + if (age > PENDING_TIMEOUT_MS + PENDING_CLEANUP_MS) { + changed = true; + continue; + } + if (age > PENDING_TIMEOUT_MS && !entry.timedOut) { + next[id] = { ...entry, timedOut: true }; + changed = true; + } else { + next[id] = entry; + } + } + + return changed ? next : pending; +} diff --git a/src/features/routing/routeGraph.test.ts b/src/features/routing/routeGraph.test.ts new file mode 100644 index 0000000..f028b79 --- /dev/null +++ b/src/features/routing/routeGraph.test.ts @@ -0,0 +1,545 @@ +import { describe, expect, it } from 'vitest'; + +import { + RoutingDevice, + RoutingDevicesAndTieLines, + TieLine, +} from '../../store/apiSlice'; +import { + buildRouteIndex, + describeNoCandidates, + findCandidateSources, + findReachableUpstreamDevices, + isMidpoint, + isPureSource, + isRouteDestination, +} from './routeGraph'; + +// ─── Fixture helpers ───────────────────────────────────────────────────────── + +type Role = 'source' | 'sink' | 'midpoint' | 'multiview' | 'passthrough'; + +/** + * Builds a RoutingDevice with the flag combination the backend actually emits for each role. + * + * "multiview" is the important one: GetRoutingDevicesAndTieLinesHandler forces HasInputs = true on + * an IRoutingSinkWithLayouts (which is itself an IRoutingSource), while NOT setting + * HasInputsAndOutputs, since it is not an IRoutingMidpoint. + * + * "passthrough" has both inputs and outputs but is not an IRoutingMidpoint - a device the backend + * refuses to recurse through. + */ +function device( + key: string, + role: Role, + ports: { in?: string[]; out?: string[] } = {} +): RoutingDevice { + const flags = { + source: { hasInputs: false, hasOutputs: true, hasInputsAndOutputs: false }, + sink: { hasInputs: true, hasOutputs: false, hasInputsAndOutputs: false }, + midpoint: { hasInputs: true, hasOutputs: true, hasInputsAndOutputs: true }, + multiview: { + hasInputs: true, + hasOutputs: true, + hasInputsAndOutputs: false, + }, + passthrough: { + hasInputs: true, + hasOutputs: true, + hasInputsAndOutputs: false, + }, + }[role]; + + return { + key, + name: key, + ...flags, + inputPorts: (ports.in ?? []).map((k) => ({ + key: k, + signalType: 'AudioVideo', + connectionType: 'Hdmi', + isInternal: false, + })), + outputPorts: (ports.out ?? []).map((k) => ({ + key: k, + signalType: 'AudioVideo', + connectionType: 'Hdmi', + isInternal: false, + })), + }; +} + +function tie( + sourceDeviceKey: string, + sourcePortKey: string, + destinationDeviceKey: string, + destinationPortKey: string, + signalType = 'AudioVideo' +): TieLine { + return { + sourceDeviceKey, + sourcePortKey, + destinationDeviceKey, + destinationPortKey, + signalType, + isInternal: false, + }; +} + +function system( + devices: RoutingDevice[], + tieLines: TieLine[] +): RoutingDevicesAndTieLines { + return { devices, tieLines, currentRoutes: [], sinkCurrentSources: [] }; +} + +const keysOf = (candidates: { deviceKey: string }[]) => + candidates.map((c) => c.deviceKey).sort(); + +// ─── Role inference ────────────────────────────────────────────────────────── + +describe('device roles', () => { + it('treats a multiview parent as a route destination, not a midpoint', () => { + const mv = device('nvx-decoder', 'multiview'); + expect(isMidpoint(mv)).toBe(false); + expect(isRouteDestination(mv)).toBe(true); + expect(isPureSource(mv)).toBe(false); + }); + + it('classifies pure sources, sinks and midpoints', () => { + expect(isPureSource(device('laptop', 'source'))).toBe(true); + expect(isRouteDestination(device('display', 'sink'))).toBe(true); + expect(isMidpoint(device('matrix', 'midpoint'))).toBe(true); + expect(isRouteDestination(device('matrix', 'midpoint'))).toBe(false); + }); +}); + +// ─── Traversal ─────────────────────────────────────────────────────────────── + +describe('findCandidateSources', () => { + it('finds a directly tied source', () => { + const index = buildRouteIndex( + system( + [ + device('laptop', 'source', { out: ['out1'] }), + device('display', 'sink', { in: ['hdmi1'] }), + ], + [tie('laptop', 'out1', 'display', 'hdmi1')] + ) + ); + expect( + keysOf(findCandidateSources(index, 'display', 'hdmi1', 'AudioVideo')) + ).toEqual(['laptop']); + }); + + it('walks back through a midpoint to the source behind it', () => { + const index = buildRouteIndex( + system( + [ + device('laptop', 'source', { out: ['out1'] }), + device('matrix', 'midpoint', { in: ['in1'], out: ['out1'] }), + device('display', 'sink', { in: ['hdmi1'] }), + ], + [ + tie('laptop', 'out1', 'matrix', 'in1'), + tie('matrix', 'out1', 'display', 'hdmi1'), + ] + ) + ); + // Both the matrix (direct tie) and the laptop (through the matrix) are offerable. + expect( + keysOf(findCandidateSources(index, 'display', 'hdmi1', 'AudioVideo')) + ).toEqual(['laptop', 'matrix']); + }); + + it('stops at a non-midpoint passthrough, hiding what feeds it', () => { + // Mirrors Extensions.cs:653 - the backend only recurses through IRoutingMidpoint. + const index = buildRouteIndex( + system( + [ + device('laptop', 'source', { out: ['out1'] }), + device('scaler', 'passthrough', { in: ['in1'], out: ['out1'] }), + device('display', 'sink', { in: ['hdmi1'] }), + ], + [ + tie('laptop', 'out1', 'scaler', 'in1'), + tie('scaler', 'out1', 'display', 'hdmi1'), + ] + ) + ); + expect( + keysOf(findCandidateSources(index, 'display', 'hdmi1', 'AudioVideo')) + ).toEqual(['scaler']); + }); + + it('gates on signal type, with AudioVideo satisfying both halves', () => { + const index = buildRouteIndex( + system( + [ + device('cam', 'source', { out: ['out1'] }), + device('mic', 'source', { out: ['out1'] }), + device('av-src', 'source', { out: ['out1'] }), + device('display', 'sink', { in: ['hdmi1'] }), + ], + [ + tie('cam', 'out1', 'display', 'hdmi1', 'Video'), + tie('mic', 'out1', 'display', 'hdmi1', 'Audio'), + tie('av-src', 'out1', 'display', 'hdmi1', 'AudioVideo'), + ] + ) + ); + + expect( + keysOf(findCandidateSources(index, 'display', 'hdmi1', 'Video')) + ).toEqual(['av-src', 'cam']); + expect( + keysOf(findCandidateSources(index, 'display', 'hdmi1', 'Audio')) + ).toEqual(['av-src', 'mic']); + // An AudioVideo request matches anything carrying either half. + expect( + keysOf(findCandidateSources(index, 'display', 'hdmi1', 'AudioVideo')) + ).toEqual(['av-src', 'cam', 'mic']); + }); + + it('reports partial matches so the UI can badge them', () => { + const index = buildRouteIndex( + system( + [ + device('cam', 'source', { out: ['out1'] }), + device('av-src', 'source', { out: ['out1'] }), + device('display', 'sink', { in: ['hdmi1'] }), + ], + [ + tie('cam', 'out1', 'display', 'hdmi1', 'Video'), + tie('av-src', 'out1', 'display', 'hdmi1', 'AudioVideo'), + ] + ) + ); + + const byKey = new Map( + findCandidateSources(index, 'display', 'hdmi1', 'AudioVideo').map((c) => [ + c.deviceKey, + c.matchedFlags, + ]) + ); + expect(byKey.get('cam')).toEqual(['Video']); + expect(byKey.get('av-src')).toEqual(['Audio', 'Video']); + }); + + it('constrains on the destination port', () => { + const index = buildRouteIndex( + system( + [ + device('laptop', 'source', { out: ['out1'] }), + device('bluray', 'source', { out: ['out1'] }), + device('display', 'sink', { in: ['hdmi1', 'hdmi2'] }), + ], + [ + tie('laptop', 'out1', 'display', 'hdmi1'), + tie('bluray', 'out1', 'display', 'hdmi2'), + ] + ) + ); + expect( + keysOf(findCandidateSources(index, 'display', 'hdmi1', 'AudioVideo')) + ).toEqual(['laptop']); + expect( + keysOf(findCandidateSources(index, 'display', 'hdmi2', 'AudioVideo')) + ).toEqual(['bluray']); + }); + + it('is not port-constrained past the first hop', () => { + // Extensions.cs:677-678 recurses with destinationPort = null, so a source on ANY input of an + // upstream matrix reaches a destination tied to one specific matrix output. + const index = buildRouteIndex( + system( + [ + device('laptop', 'source', { out: ['out1'] }), + device('bluray', 'source', { out: ['out1'] }), + device('matrix', 'midpoint', { in: ['in1', 'in2'], out: ['out1'] }), + device('display', 'sink', { in: ['hdmi1'] }), + ], + [ + tie('laptop', 'out1', 'matrix', 'in1'), + tie('bluray', 'out1', 'matrix', 'in2'), + tie('matrix', 'out1', 'display', 'hdmi1'), + ] + ) + ); + expect( + keysOf(findCandidateSources(index, 'display', 'hdmi1', 'AudioVideo')) + ).toEqual(['bluray', 'laptop', 'matrix']); + }); + + it('terminates on a cycle between two matrices', () => { + const index = buildRouteIndex( + system( + [ + device('laptop', 'source', { out: ['out1'] }), + device('mtx-a', 'midpoint', { + in: ['in1', 'in2'], + out: ['out1', 'out2'], + }), + device('mtx-b', 'midpoint', { in: ['in1'], out: ['out1', 'out2'] }), + device('display', 'sink', { in: ['hdmi1'] }), + ], + [ + tie('laptop', 'out1', 'mtx-a', 'in1'), + tie('mtx-a', 'out1', 'mtx-b', 'in1'), + tie('mtx-b', 'out1', 'mtx-a', 'in2'), // back edge + tie('mtx-b', 'out2', 'display', 'hdmi1'), + ] + ) + ); + expect( + keysOf(findCandidateSources(index, 'display', 'hdmi1', 'AudioVideo')) + ).toEqual(['laptop', 'mtx-a', 'mtx-b']); + }); + + it('never offers the destination device as its own source', () => { + const index = buildRouteIndex( + system( + [ + device('mtx', 'midpoint', { in: ['in1'], out: ['out1'] }), + device('display', 'sink', { in: ['hdmi1'] }), + ], + // A loop back onto the destination itself. + [ + tie('display', 'hdmi1', 'mtx', 'in1'), + tie('mtx', 'out1', 'display', 'hdmi1'), + ] + ) + ); + expect( + keysOf(findCandidateSources(index, 'display', 'hdmi1', 'AudioVideo')) + ).toEqual(['mtx']); + }); + + it('sorts pure sources first, then by name', () => { + const index = buildRouteIndex( + system( + [ + device('zebra', 'source', { out: ['out1'] }), + device('apple', 'source', { out: ['out1'] }), + device('matrix', 'midpoint', { in: ['in1', 'in2'], out: ['out1'] }), + device('display', 'sink', { in: ['hdmi1'] }), + ], + [ + tie('zebra', 'out1', 'matrix', 'in1'), + tie('apple', 'out1', 'matrix', 'in2'), + tie('matrix', 'out1', 'display', 'hdmi1'), + ] + ) + ); + expect( + findCandidateSources(index, 'display', 'hdmi1', 'AudioVideo').map( + (c) => c.deviceKey + ) + ).toEqual(['apple', 'zebra', 'matrix']); + }); + + it('ignores tie lines naming a device the read API filtered out', () => { + const index = buildRouteIndex( + system( + [device('display', 'sink', { in: ['hdmi1'] })], + [tie('ghost', 'out1', 'display', 'hdmi1')] + ) + ); + expect( + findCandidateSources(index, 'display', 'hdmi1', 'AudioVideo') + ).toEqual([]); + }); + + it('returns nothing for an empty signal type', () => { + const index = buildRouteIndex( + system( + [ + device('laptop', 'source', { out: ['out1'] }), + device('display', 'sink', { in: ['hdmi1'] }), + ], + [tie('laptop', 'out1', 'display', 'hdmi1')] + ) + ); + expect(findCandidateSources(index, 'display', 'hdmi1', '')).toEqual([]); + }); +}); + +// ─── Multiview ─────────────────────────────────────────────────────────────── + +describe('multiview tiles', () => { + // Tie lines targeting a tile child are remapped onto the parent with a "tile{N}:" qualified port + // key by RoutingGraphHelpers.QualifyTilePortKey, so tiles need no special-casing here - the + // port-specific BFS seed does the work. + const index = buildRouteIndex( + system( + [ + device('cam-1', 'source', { out: ['out1'] }), + device('cam-2', 'source', { out: ['out1'] }), + device('nvx-decoder', 'multiview', { + in: ['tile1:tileInput', 'tile2:tileInput'], + }), + ], + [ + tie('cam-1', 'out1', 'nvx-decoder', 'tile1:tileInput'), + tie('cam-2', 'out1', 'nvx-decoder', 'tile2:tileInput'), + ] + ) + ); + + it('resolves different candidates per tile', () => { + expect( + keysOf( + findCandidateSources( + index, + 'nvx-decoder', + 'tile1:tileInput', + 'AudioVideo' + ) + ) + ).toEqual(['cam-1']); + expect( + keysOf( + findCandidateSources( + index, + 'nvx-decoder', + 'tile2:tileInput', + 'AudioVideo' + ) + ) + ).toEqual(['cam-2']); + }); + + it("returns every tile's sources when no port is specified", () => { + expect( + keysOf(findCandidateSources(index, 'nvx-decoder', null, 'AudioVideo')) + ).toEqual(['cam-1', 'cam-2']); + }); +}); + +// ─── Performance ───────────────────────────────────────────────────────────── + +describe('performance', () => { + it('sweeps a 400-device / ~2000-tie-line system quickly', () => { + const devices: RoutingDevice[] = []; + const tieLines: TieLine[] = []; + + // 4 chained matrices, 300 sources fanning into the first, 96 sinks off the last. + for (let m = 0; m < 4; m++) { + devices.push( + device(`mtx-${m}`, 'midpoint', { + in: Array.from({ length: 300 }, (_, i) => `in${i}`), + out: Array.from({ length: 300 }, (_, i) => `out${i}`), + }) + ); + if (m > 0) { + for (let i = 0; i < 300; i++) { + tieLines.push(tie(`mtx-${m - 1}`, `out${i}`, `mtx-${m}`, `in${i}`)); + } + } + } + for (let s = 0; s < 300; s++) { + devices.push(device(`src-${s}`, 'source', { out: ['out1'] })); + tieLines.push(tie(`src-${s}`, 'out1', 'mtx-0', `in${s}`)); + } + for (let d = 0; d < 96; d++) { + devices.push(device(`sink-${d}`, 'sink', { in: ['hdmi1'] })); + tieLines.push(tie('mtx-3', `out${d}`, `sink-${d}`, 'hdmi1')); + } + + const start = performance.now(); + const index = buildRouteIndex(system(devices, tieLines)); + const candidates = findCandidateSources( + index, + 'sink-0', + 'hdmi1', + 'AudioVideo' + ); + const elapsed = performance.now() - start; + + expect(candidates).toHaveLength(300 + 4); // every source, plus all four matrices + // Generous bound - this is a smoke test against accidental O(n^2), not a benchmark. + expect(elapsed).toBeLessThan(1000); + }); +}); + +// ─── Raw traversal ─────────────────────────────────────────────────────────── + +describe('findReachableUpstreamDevices', () => { + it('returns a set containing only upstream devices for the requested atom', () => { + const index = buildRouteIndex( + system( + [ + device('cam', 'source', { out: ['out1'] }), + device('mic', 'source', { out: ['out1'] }), + device('display', 'sink', { in: ['hdmi1'] }), + ], + [ + tie('cam', 'out1', 'display', 'hdmi1', 'Video'), + tie('mic', 'out1', 'display', 'hdmi1', 'Audio'), + ] + ) + ); + expect( + Array.from( + findReachableUpstreamDevices(index, 'display', 'hdmi1', 'Video') + ) + ).toEqual(['cam']); + expect( + Array.from( + findReachableUpstreamDevices(index, 'display', 'hdmi1', 'Audio') + ) + ).toEqual(['mic']); + expect( + findReachableUpstreamDevices(index, 'display', 'hdmi1', 'Usb').size + ).toBe(0); + }); +}); + +// ─── Empty-state diagnosis ─────────────────────────────────────────────────── + +describe('describeNoCandidates', () => { + const index = buildRouteIndex( + system( + [ + device('cam', 'source', { out: ['out1'] }), + device('display', 'sink', { in: ['hdmi1', 'hdmi2'] }), + ], + [tie('cam', 'out1', 'display', 'hdmi1', 'Video')] + ) + ); + + it('reports an unwired port as a configuration gap', () => { + expect( + describeNoCandidates(index, 'display', 'hdmi2', 'AudioVideo') + ).toMatch(/Nothing is wired to this input/); + }); + + it('reports a wired port carrying a disjoint type, naming what it does carry', () => { + const message = describeNoCandidates(index, 'display', 'hdmi1', 'Audio'); + expect(message).toMatch(/wired for Video/); + expect(message).toMatch(/carries no part of Audio/); + }); + + // A candidate qualifies on ANY matching atom, so a Video tie line still answers an AudioVideo + // request - badged "video only". Only a genuinely disjoint request empties the list, which is + // what the wording above has to reflect. + it('does not claim a narrower overlap is empty, because it is not', () => { + expect( + findCandidateSources(index, 'display', 'hdmi1', 'AudioVideo') + ).toHaveLength(1); + expect( + findCandidateSources(index, 'display', 'hdmi1', 'Video') + ).toHaveLength(1); + }); + + // The two cases are exhaustive: a tie line sharing any atom with the request always contributes + // at least its own source, so the list could not have been empty. + it('only ever explains a genuinely empty list', () => { + expect( + findCandidateSources(index, 'display', 'hdmi1', 'Audio') + ).toHaveLength(0); + expect( + findCandidateSources(index, 'display', 'hdmi2', 'Video') + ).toHaveLength(0); + }); +}); diff --git a/src/features/routing/routeGraph.ts b/src/features/routing/routeGraph.ts new file mode 100644 index 0000000..478ef77 --- /dev/null +++ b/src/features/routing/routeGraph.ts @@ -0,0 +1,295 @@ +/** + * Client-side routing-graph traversal: "which source devices have a path to this destination + * port for this signal type?" + * + * The backend answers the inverse question - `Extensions.GetRouteToSource` walks backwards from + * one destination to one named source and returns a RouteDescriptor or nothing. Asking it once + * per source to populate a dropdown would be O(sources x graph). Instead we run a single reverse + * reachability sweep per signal atom, which is O(devices + tieLines) and answers for every source + * at once. + * + * The traversal deliberately mirrors the backend's rules (PepperDash.Essentials.Core/Routing/ + * Extensions.cs:588-717) so the UI does not offer routes the processor would refuse: + * + * - The *initial* destination is port-constrained, but every upstream hop is not. The backend + * recurses with `destinationPort = null` (Extensions.cs:677-678), so once we step off the + * destination we consider all of a device's inputs. + * - A tie line must carry the requested signal: `t.Type.HasFlag(signalType)` (Extensions.cs:619). + * - Any device on the far end of a matching tie line is a candidate, whatever its role - the + * backend's direct-tie check (Extensions.cs:625-641) does not filter by device type. + * - But we only *recurse* through midpoints: `t.SourcePort.ParentDevice is IRoutingMidpoint` + * (Extensions.cs:653). This is why a non-midpoint passthrough terminates the walk and hides + * whatever is upstream of it. + * + * KNOWN DIVERGENCE: the backend's `alreadyCheckedDevices` list (Extensions.cs:656-658) is shared + * across its whole depth-first search and never unwound, and it breaks on first success. In + * diamond topologies it can therefore FAIL to find a route that this complete sweep finds. The + * consequence is that a source we list may occasionally fail to route - which surfaces to the user + * as a pending-feedback timeout rather than a silent lie. Erring toward offering the route is the + * right side to be wrong on; the alternative would hide legitimately routable sources. + */ + +import { + RoutingDevice, + RoutingDevicesAndTieLines, + TieLine, +} from '../../store/apiSlice'; +import { atomsOf, parseSignalFlags } from './signalTypes'; + +/** A tie line with its signal type pre-parsed, so traversal never re-parses strings. */ +interface IndexedTieLine { + tieLine: TieLine; + flags: ReadonlySet; +} + +interface RouteIndex { + deviceByKey: Map; + /** All tie lines arriving at a device, keyed by device key. */ + byDestDevice: Map; + /** All tie lines arriving at a specific port, keyed by `portId(deviceKey, portKey)`. */ + byDestPort: Map; + /** Devices we may recurse through - the literal `is IRoutingMidpoint` flag. */ + midpointKeys: Set; +} + +interface CandidateSource { + deviceKey: string; + name: string; + /** True for a device with outputs and no inputs - listed first, since these are the usual pick. */ + isPureSource: boolean; + /** + * Which of the requested signal atoms actually have a path. Shorter than the request means a + * partial match (e.g. video reaches the destination but audio does not), which the backend will + * still route - `ReleaseAndMakeRoute` succeeds if either half of an AudioVideo request resolves. + */ + matchedFlags: string[]; +} + +/** Resolves reachable upstream devices for one destination port and one signal atom. */ +type ReachabilityResolver = ( + destDeviceKey: string, + destPortKey: string | null, + atom: string +) => ReadonlySet; + +/** NUL is not legal in a device or port key, so it cannot collide with key content. */ +const PORT_SEP = '\u0000'; + +function portId(deviceKey: string, portKey: string): string { + return `${deviceKey}${PORT_SEP}${portKey}`; +} + +// ─── Device roles ──────────────────────────────────────────────────────────── +// +// `hasInputsAndOutputs` is the literal `is IRoutingMidpoint` flag and is the only reliable +// discriminator. Note a multiview decoder (IRoutingSinkWithLayouts) reports hasInputs AND +// hasOutputs while NOT being a midpoint - the read handler forces HasInputs = true so its tiles +// can be rendered as input ports. So "sink = hasInputs && !hasOutputs" would wrongly exclude +// exactly the devices whose tiles we want to route. + +function isMidpoint(device: RoutingDevice): boolean { + return device.hasInputsAndOutputs; +} + +/** Devices that can be the target of a source-to-sink route: pure sinks and multiview parents. */ +function isRouteDestination(device: RoutingDevice): boolean { + return device.hasInputs && !device.hasInputsAndOutputs; +} + +function isPureSource(device: RoutingDevice): boolean { + return device.hasOutputs && !device.hasInputs; +} + +/** + * Builds the traversal index. Signal-type strings are parsed exactly once here rather than on + * every traversal, which is the single biggest performance lever on a large system. + * + * Always built from `data.tieLines` - never from the filtered React Flow edges. Hiding a device or + * a signal type in the toolbar must not change what is physically routable. + */ +function buildRouteIndex(data: RoutingDevicesAndTieLines): RouteIndex { + const deviceByKey = new Map(); + const midpointKeys = new Set(); + for (const device of data.devices) { + deviceByKey.set(device.key, device); + if (isMidpoint(device)) midpointKeys.add(device.key); + } + + const byDestDevice = new Map(); + const byDestPort = new Map(); + for (const tieLine of data.tieLines) { + const indexed: IndexedTieLine = { + tieLine, + flags: parseSignalFlags(tieLine.signalType), + }; + push(byDestDevice, tieLine.destinationDeviceKey, indexed); + push( + byDestPort, + portId(tieLine.destinationDeviceKey, tieLine.destinationPortKey), + indexed + ); + } + + return { deviceByKey, byDestDevice, byDestPort, midpointKeys }; +} + +function push( + map: Map, + key: string, + value: IndexedTieLine +): void { + const existing = map.get(key); + if (existing) existing.push(value); + else map.set(key, [value]); +} + +/** + * Every device that can reach `destDeviceKey` (optionally constrained to `destPortKey`) carrying + * the given signal atom. Breadth-first backwards over tie lines. + * + * Each device is enqueued at most once, so each tie line is inspected at most twice: + * O(devices + tieLines) time and memory. Cycles terminate on the visited check. + */ +function findReachableUpstreamDevices( + index: RouteIndex, + destDeviceKey: string, + destPortKey: string | null, + atom: string +): ReadonlySet { + const found = new Set(); + const visited = new Set([destDeviceKey]); + const queue: Array<[string, string | null]> = [[destDeviceKey, destPortKey]]; + + // Cursor rather than Array.shift(), which is O(n) per call on a large frontier. + let head = 0; + while (head < queue.length) { + const [deviceKey, portKey] = queue[head++]; + + // Port-constrained at the seed only; every upstream hop passes null and considers all inputs. + const incoming = + portKey !== null + ? index.byDestPort.get(portId(deviceKey, portKey)) + : index.byDestDevice.get(deviceKey); + if (!incoming) continue; + + for (const { tieLine, flags } of incoming) { + if (!flags.has(atom)) continue; + + const sourceKey = tieLine.sourceDeviceKey; + // Any device across a matching tie line is a candidate, regardless of role... + found.add(sourceKey); + // ...but only a midpoint can be traversed *through* to whatever feeds it. + if (index.midpointKeys.has(sourceKey) && !visited.has(sourceKey)) { + visited.add(sourceKey); + queue.push([sourceKey, null]); + } + } + } + + return found; +} + +/** + * The source devices to offer for a destination port and signal type, sorted with pure sources + * first and then by name. + * + * A device qualifies if ANY requested atom has a path, mirroring `ReleaseAndMakeRoute`'s + * success-if-either behavior for AudioVideo (Extensions.cs:268-273); `matchedFlags` records which, + * so the UI can badge a video-only match on an AudioVideo request. + * + * Pass `resolve` to supply a memoized traversal (see useRouteCandidates). + */ +function findCandidateSources( + index: RouteIndex, + destDeviceKey: string, + destPortKey: string | null, + signalType: string, + resolve?: ReachabilityResolver +): CandidateSource[] { + const atoms = atomsOf(signalType); + if (atoms.length === 0) return []; + + const resolveReachable: ReachabilityResolver = + resolve ?? ((d, p, a) => findReachableUpstreamDevices(index, d, p, a)); + + const matchedByDevice = new Map(); + for (const atom of atoms) { + for (const deviceKey of resolveReachable( + destDeviceKey, + destPortKey, + atom + )) { + // A device is never a source for itself, even if the graph loops back to it. + if (deviceKey === destDeviceKey) continue; + const matched = matchedByDevice.get(deviceKey); + if (matched) matched.push(atom); + else matchedByDevice.set(deviceKey, [atom]); + } + } + + const candidates: CandidateSource[] = []; + for (const [deviceKey, matchedFlags] of matchedByDevice) { + const device = index.deviceByKey.get(deviceKey); + // A tie line can name a device the read API filtered out; it isn't selectable. + if (!device) continue; + candidates.push({ + deviceKey, + name: device.name || device.key, + isPureSource: isPureSource(device), + matchedFlags, + }); + } + + candidates.sort( + (a, b) => + Number(b.isPureSource) - Number(a.isPureSource) || + a.name.localeCompare(b.name) + ); + return candidates; +} + +/** + * Explains an empty candidate list. + * + * Routing is driven by the tie lines in the configuration, so "no sources" has exactly two causes + * and they need completely different fixes: either nothing is wired to the port at all (a config + * problem), or what is wired shares no part of the requested signal type (pick another type). + * + * The second case needs a DISJOINT type, not merely a narrower one: a candidate qualifies on any + * single matching atom, so a Video tie line still answers an AudioVideo request - badged "video + * only". Emptiness therefore means no incoming tie line carries any atom of the request at all. + * Since a matching tie line always contributes at least its own source device, these two cases are + * exhaustive. + */ +function describeNoCandidates( + index: RouteIndex, + destDeviceKey: string, + destPortKey: string | null, + signalType: string +): string { + const incoming = + (destPortKey !== null + ? index.byDestPort.get(portId(destDeviceKey, destPortKey)) + : index.byDestDevice.get(destDeviceKey)) ?? []; + + if (incoming.length === 0) { + return 'Nothing is wired to this input. Routing follows the tie lines in the configuration, and this port has none.'; + } + + const wiredFor = [...new Set(incoming.map((t) => t.tieLine.signalType))].join( + ', ' + ); + return `This input is wired for ${wiredFor}, which carries no part of ${signalType}.`; +} + +export { + buildRouteIndex, + describeNoCandidates, + findCandidateSources, + findReachableUpstreamDevices, + isMidpoint, + isPureSource, + isRouteDestination, + portId, +}; +export type { CandidateSource, ReachabilityResolver, RouteIndex }; diff --git a/src/features/routing/signalColors.ts b/src/features/routing/signalColors.ts new file mode 100644 index 0000000..8f56452 --- /dev/null +++ b/src/features/routing/signalColors.ts @@ -0,0 +1,27 @@ +/** + * Colors used to distinguish routing signal types across the Routing diagram - tie-line edges, + * the signal-type filter buttons, internal route curves inside device nodes, and the route + * popover's signal-type picker. + * + * Keys are the raw `signalType` strings that come off the wire, which are C# `ToString()` of a + * [Flags] enum - so composite values like "Audio, SecondaryAudio" appear verbatim and get their + * own entry rather than being decomposed. + */ +const SIGNAL_COLORS: Record = { + AudioVideo: '#6f42c1', + Video: '#0d6efd', + Audio: '#dc3545', + 'Audio, SecondaryAudio': '#dc3545', + 'UsbOutput, UsbInput': '#fd7e14', + UsbOutput: '#fd7e14', + UsbInput: '#fd7e14', +}; + +const FALLBACK_COLOR = '#adb5bd'; + +/** Returns the display color for a signal type, falling back to grey for unrecognized values. */ +function signalColor(signalType: string): string { + return SIGNAL_COLORS[signalType] ?? FALLBACK_COLOR; +} + +export { FALLBACK_COLOR, SIGNAL_COLORS, signalColor }; diff --git a/src/features/routing/signalTypes.test.ts b/src/features/routing/signalTypes.test.ts new file mode 100644 index 0000000..41991c8 --- /dev/null +++ b/src/features/routing/signalTypes.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; + +import { + atomsOf, + flagsContainAll, + flagsIntersect, + formatSignalFlags, + parseSignalFlags, + portSupportsSignalType, + signalTypeOptionsForPort, +} from './signalTypes'; + +/** Every signal-type string observed coming off a real processor, plus an unknown one. */ +const REAL_WORLD = [ + 'AudioVideo', + 'Video', + 'Audio', + 'Audio, SecondaryAudio', + 'UsbOutput, UsbInput', + 'UsbOutput', + 'UsbInput', + 'Usb', + 'Foo', +]; + +describe('parseSignalFlags', () => { + it('expands the AudioVideo composite', () => { + expect(Array.from(parseSignalFlags('AudioVideo'))).toEqual([ + 'Audio', + 'Video', + ]); + }); + + it('splits comma-separated flag strings, preserving source order', () => { + expect(Array.from(parseSignalFlags('UsbOutput, UsbInput'))).toEqual([ + 'UsbOutput', + 'UsbInput', + ]); + }); + + it('tolerates whitespace variations', () => { + expect(Array.from(parseSignalFlags('Audio,SecondaryAudio'))).toEqual([ + 'Audio', + 'SecondaryAudio', + ]); + expect(Array.from(parseSignalFlags(' Audio , Video '))).toEqual([ + 'Audio', + 'Video', + ]); + }); + + it('passes unknown tokens through as their own atom', () => { + expect(Array.from(parseSignalFlags('Foo'))).toEqual(['Foo']); + }); + + it('returns an empty set for empty, null and undefined input', () => { + expect(parseSignalFlags('').size).toBe(0); + expect(parseSignalFlags(null).size).toBe(0); + expect(parseSignalFlags(undefined).size).toBe(0); + }); +}); + +describe('formatSignalFlags', () => { + it('collapses {Audio, Video} back to the AudioVideo composite', () => { + expect(formatSignalFlags(parseSignalFlags('AudioVideo'))).toBe( + 'AudioVideo' + ); + }); + + it.each(REAL_WORLD)('round-trips %s', (signalType) => { + expect(formatSignalFlags(parseSignalFlags(signalType))).toBe(signalType); + }); + + it('does not collapse a partial composite', () => { + expect(formatSignalFlags(parseSignalFlags('Audio'))).toBe('Audio'); + }); +}); + +describe('flagsContainAll', () => { + // The asymmetry this whole module exists for. + it('lets an AudioVideo port satisfy a Video request', () => { + expect( + flagsContainAll(parseSignalFlags('AudioVideo'), parseSignalFlags('Video')) + ).toBe(true); + }); + + it('does not let a Video port satisfy an AudioVideo request', () => { + expect( + flagsContainAll(parseSignalFlags('Video'), parseSignalFlags('AudioVideo')) + ).toBe(false); + }); + + it('is reflexive for every real-world value', () => { + for (const s of REAL_WORLD) { + expect(flagsContainAll(parseSignalFlags(s), parseSignalFlags(s))).toBe( + true + ); + } + }); + + it('keeps Usb isolated from audio and video', () => { + expect( + flagsContainAll(parseSignalFlags('AudioVideo'), parseSignalFlags('Usb')) + ).toBe(false); + expect( + flagsContainAll(parseSignalFlags('Usb'), parseSignalFlags('Audio')) + ).toBe(false); + }); + + it('treats an empty request as vacuously satisfied, matching (have & 0) == 0', () => { + expect( + flagsContainAll(parseSignalFlags('Video'), parseSignalFlags('')) + ).toBe(true); + }); +}); + +describe('flagsIntersect', () => { + it('is true for overlapping sets and symmetric', () => { + const av = parseSignalFlags('AudioVideo'); + const a = parseSignalFlags('Audio, SecondaryAudio'); + expect(flagsIntersect(av, a)).toBe(true); + expect(flagsIntersect(a, av)).toBe(true); + }); + + it('is false for disjoint sets', () => { + expect( + flagsIntersect(parseSignalFlags('Video'), parseSignalFlags('Audio')) + ).toBe(false); + expect( + flagsIntersect(parseSignalFlags('Video'), parseSignalFlags('')) + ).toBe(false); + }); +}); + +describe('signalTypeOptionsForPort', () => { + it('offers the full type plus each breakaway atom', () => { + expect(signalTypeOptionsForPort('AudioVideo')).toEqual([ + 'AudioVideo', + 'Audio', + 'Video', + ]); + }); + + it('offers a single option for a single-atom port, so the picker can be skipped', () => { + expect(signalTypeOptionsForPort('Video')).toEqual(['Video']); + expect(signalTypeOptionsForPort('Foo')).toEqual(['Foo']); + }); + + it('decomposes plugin flag strings without reordering them', () => { + expect(signalTypeOptionsForPort('UsbOutput, UsbInput')).toEqual([ + 'UsbOutput, UsbInput', + 'UsbOutput', + 'UsbInput', + ]); + }); + + it('returns nothing for a port with no declared type', () => { + expect(signalTypeOptionsForPort('')).toEqual([]); + expect(signalTypeOptionsForPort(undefined)).toEqual([]); + }); + + it('only ever offers options the port actually supports', () => { + for (const portType of REAL_WORLD) { + for (const option of signalTypeOptionsForPort(portType)) { + expect(portSupportsSignalType(portType, option)).toBe(true); + } + } + }); +}); + +describe('atomsOf', () => { + it('returns atoms in source order', () => { + expect(atomsOf('AudioVideo')).toEqual(['Audio', 'Video']); + expect(atomsOf('UsbOutput, UsbInput')).toEqual(['UsbOutput', 'UsbInput']); + }); +}); diff --git a/src/features/routing/signalTypes.ts b/src/features/routing/signalTypes.ts new file mode 100644 index 0000000..cfd29d2 --- /dev/null +++ b/src/features/routing/signalTypes.ts @@ -0,0 +1,135 @@ +/** + * Signal-type flag algebra for routing. + * + * On the backend `eRoutingSignalType` is a [Flags] enum (Audio=1, Video=2, AudioVideo=3, Usb=8) + * and the wire carries its `ToString()`. But plugin and legacy devices contribute their own + * enum names too - "Audio, SecondaryAudio", "UsbOutput, UsbInput", "UsbInput" are all observed in + * the field - so we cannot assume a fixed numeric bitmask. Instead we model a signal type as a + * set of atom names, which degrades gracefully: an unrecognized token is simply an atom that only + * ever matches itself. + * + * The one asymmetry that matters, and the reason this module exists: matching is "contains all", + * mirroring C#'s `port.Type.HasFlag(requested)`. An AudioVideo port satisfies a Video request; a + * Video port does NOT satisfy an AudioVideo request. + */ + +/** + * Names that expand into more than one atom. Only AudioVideo is a real composite in + * `eRoutingSignalType`; comma-separated flag strings are decomposed structurally instead. + */ +const COMPOSITES: Record = { + AudioVideo: ['Audio', 'Video'], +}; + +/** + * Parses a signal-type string into its set of atoms, expanding known composite names. + * Insertion order follows the source string, so `formatSignalFlags` round-trips it. + * + * "AudioVideo" -> {Audio, Video}; "Audio, SecondaryAudio" -> {Audio, SecondaryAudio} + */ +function parseSignalFlags( + signalType: string | null | undefined +): ReadonlySet { + const atoms = new Set(); + if (!signalType) return atoms; + + for (const token of signalType.split(',')) { + const name = token.trim(); + if (!name) continue; + const expanded = COMPOSITES[name]; + if (expanded) { + for (const atom of expanded) atoms.add(atom); + } else { + atoms.add(name); + } + } + return atoms; +} + +/** + * Renders a set of atoms back to a wire-compatible signal-type string, collapsing to a composite + * name where one matches exactly so the result round-trips through C# `Enum.TryParse`. + * + * {Audio, Video} -> "AudioVideo"; {Video} -> "Video" + */ +function formatSignalFlags(flags: ReadonlySet): string { + for (const [name, atoms] of Object.entries(COMPOSITES)) { + if (atoms.length === flags.size && atoms.every((a) => flags.has(a))) + return name; + } + return Array.from(flags).join(', '); +} + +/** + * True when `have` includes every atom of `want` - the equivalent of C# `have.HasFlag(want)`. + * Deliberately asymmetric: containsAll(AudioVideo, Video) is true, containsAll(Video, AudioVideo) + * is false. An empty `want` is vacuously satisfied, matching `(have & 0) == 0`. + */ +function flagsContainAll( + have: ReadonlySet, + want: ReadonlySet +): boolean { + for (const atom of want) { + if (!have.has(atom)) return false; + } + return true; +} + +/** True when the two flag sets share at least one atom. */ +function flagsIntersect( + a: ReadonlySet, + b: ReadonlySet +): boolean { + // Iterate the smaller set. + const [small, large] = a.size <= b.size ? [a, b] : [b, a]; + for (const atom of small) { + if (large.has(atom)) return true; + } + return false; +} + +/** The atoms of a signal-type string, in source order. */ +function atomsOf(signalType: string | null | undefined): string[] { + return Array.from(parseSignalFlags(signalType)); +} + +/** + * The signal types a user may select for a given port: the port's own full type first, then each + * individual atom as a breakaway option. A port carrying a single atom yields one option, which + * lets the popover skip its signal-type step entirely. + * + * "AudioVideo" -> ["AudioVideo", "Audio", "Video"]; "Video" -> ["Video"] + */ +function signalTypeOptionsForPort( + portSignalType: string | null | undefined +): string[] { + if (!portSignalType) return []; + const atoms = atomsOf(portSignalType); + if (atoms.length <= 1) return [portSignalType]; + return [portSignalType, ...atoms]; +} + +/** + * True when a port can carry the requested signal type - the client-side mirror of the backend's + * `(port.Type & requested) == requested` check, so the UI never offers a combination the endpoint + * would reject with `signalTypeNotSupportedByPort`. + */ +function portSupportsSignalType( + portSignalType: string | null | undefined, + requested: string +): boolean { + return flagsContainAll( + parseSignalFlags(portSignalType), + parseSignalFlags(requested) + ); +} + +export { + atomsOf, + flagsContainAll, + flagsIntersect, + formatSignalFlags, + parseSignalFlags, + portSupportsSignalType, + signalTypeOptionsForPort, +}; diff --git a/src/features/routing/useRouteCandidates.test.ts b/src/features/routing/useRouteCandidates.test.ts new file mode 100644 index 0000000..2d15717 --- /dev/null +++ b/src/features/routing/useRouteCandidates.test.ts @@ -0,0 +1,103 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { RoutingDevicesAndTieLines } from '../../store/apiSlice'; +import * as routeGraph from './routeGraph'; +import { buildRouteIndex } from './routeGraph'; +import useRouteCandidates from './useRouteCandidates'; + +function system(): RoutingDevicesAndTieLines { + const port = { + signalType: 'AudioVideo', + connectionType: 'Hdmi', + isInternal: false, + }; + return { + devices: [ + { + key: 'laptop', + name: 'Laptop', + hasInputs: false, + hasOutputs: true, + hasInputsAndOutputs: false, + outputPorts: [{ key: 'out1', ...port }], + }, + { + key: 'display', + name: 'Display', + hasInputs: true, + hasOutputs: false, + hasInputsAndOutputs: false, + inputPorts: [{ key: 'hdmi1', ...port }], + }, + ], + tieLines: [ + { + sourceDeviceKey: 'laptop', + sourcePortKey: 'out1', + destinationDeviceKey: 'display', + destinationPortKey: 'hdmi1', + signalType: 'AudioVideo', + isInternal: false, + }, + ], + currentRoutes: [], + sinkCurrentSources: [], + }; +} + +describe('useRouteCandidates', () => { + it('returns candidates for a destination port', () => { + const { result } = renderHook(() => + useRouteCandidates(buildRouteIndex(system())) + ); + expect( + result.current('display', 'hdmi1', 'AudioVideo').map((c) => c.deviceKey) + ).toEqual(['laptop']); + }); + + it('returns nothing when there is no index yet', () => { + const { result } = renderHook(() => useRouteCandidates(null)); + expect(result.current('display', 'hdmi1', 'AudioVideo')).toEqual([]); + }); + + it('traverses once per (port, atom) and reuses the result on repeat queries', () => { + const spy = vi.spyOn(routeGraph, 'findReachableUpstreamDevices'); + const index = buildRouteIndex(system()); + const { result } = renderHook(() => useRouteCandidates(index)); + + // AudioVideo decomposes into two atoms, so the first call is two traversals. + result.current('display', 'hdmi1', 'AudioVideo'); + expect(spy).toHaveBeenCalledTimes(2); + + result.current('display', 'hdmi1', 'AudioVideo'); + expect(spy).toHaveBeenCalledTimes(2); + + // Video reuses the cached Video atom rather than traversing again. + result.current('display', 'hdmi1', 'Video'); + expect(spy).toHaveBeenCalledTimes(2); + + // A different port is a different cache key. + result.current('display', 'hdmi2', 'Video'); + expect(spy).toHaveBeenCalledTimes(3); + + spy.mockRestore(); + }); + + it('clears the cache when the index identity changes', () => { + const spy = vi.spyOn(routeGraph, 'findReachableUpstreamDevices'); + const { result, rerender } = renderHook( + ({ index }) => useRouteCandidates(index), + { initialProps: { index: buildRouteIndex(system()) } } + ); + + result.current('display', 'hdmi1', 'Video'); + expect(spy).toHaveBeenCalledTimes(1); + + rerender({ index: buildRouteIndex(system()) }); + result.current('display', 'hdmi1', 'Video'); + expect(spy).toHaveBeenCalledTimes(2); + + spy.mockRestore(); + }); +}); diff --git a/src/features/routing/useRouteCandidates.ts b/src/features/routing/useRouteCandidates.ts new file mode 100644 index 0000000..1f616b4 --- /dev/null +++ b/src/features/routing/useRouteCandidates.ts @@ -0,0 +1,58 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { + CandidateSource, + findCandidateSources, + findReachableUpstreamDevices, + RouteIndex, +} from './routeGraph'; + +/** + * Memoized candidate-source lookup for the route popover. + * + * The key property being exploited: a candidate set depends only on the static tie-line graph and + * device roles - never on live route feedback. So the cache survives every WebSocket tick and is + * invalidated only when the underlying query data changes (i.e. on refetch), which is what makes + * it safe to hold results indefinitely. + * + * Traversals are computed lazily, once per (destination port, signal atom), the first time a + * popover asks for them. + */ +function useRouteCandidates( + index: RouteIndex | null +): ( + destDeviceKey: string, + destPortKey: string | null, + signalType: string +) => CandidateSource[] { + const cache = useRef(new Map>()); + + // Index identity changes only when the query data does. + useEffect(() => { + cache.current.clear(); + }, [index]); + + return useCallback( + (destDeviceKey: string, destPortKey: string | null, signalType: string) => { + if (!index) return []; + return findCandidateSources( + index, + destDeviceKey, + destPortKey, + signalType, + (d, p, atom) => { + const cacheKey = `${d}\u0000${p ?? '*'}\u0000${atom}`; + let reachable = cache.current.get(cacheKey); + if (!reachable) { + reachable = findReachableUpstreamDevices(index, d, p, atom); + cache.current.set(cacheKey, reachable); + } + return reachable; + } + ); + }, + [index] + ); +} + +export default useRouteCandidates; diff --git a/src/features/secrets/BulkApplyModal.module.scss b/src/features/secrets/BulkApplyModal.module.scss new file mode 100644 index 0000000..c261716 --- /dev/null +++ b/src/features/secrets/BulkApplyModal.module.scss @@ -0,0 +1,16 @@ +// The file input stays a real inside the zone, so keyboard and screen-reader +// users get the same affordance; dragging is an enhancement layered on top. +.dropZone { + border: 2px dashed var(--bs-border-color); + border-radius: 8px; + padding: 1.25rem; + text-align: center; + transition: + border-color 0.12s, + background-color 0.12s; +} + +.dropZoneActive { + border-color: var(--bs-primary); + background-color: var(--bs-primary-bg-subtle); +} diff --git a/src/features/secrets/BulkApplyModal.test.tsx b/src/features/secrets/BulkApplyModal.test.tsx new file mode 100644 index 0000000..cb29bf5 --- /dev/null +++ b/src/features/secrets/BulkApplyModal.test.tsx @@ -0,0 +1,127 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { + BulkEntryResult, + BulkSecretEntry, + BulkSecretsResponse, +} from '../../store/secretsContract'; +import BulkApplyModal, { BulkApplyModalProps } from './BulkApplyModal'; + +type RunOptions = Parameters[1]; + +const respond = (results: BulkEntryResult[]): BulkSecretsResponse => ({ + mode: 'preview', + provider: 'default', + overwrite: true, + summary: { + total: results.length, + create: results.filter((r) => r.action === 'create').length, + overwrite: results.filter((r) => r.action === 'overwrite').length, + skip: results.filter((r) => r.action === 'skip').length, + invalid: 0, + failed: 0, + }, + entries: results, +}); + +// Stands in for the processor's classification: an unmanaged target is skipped unless the request +// explicitly allows overwriting it. +const fakeProcessor = + (unmanaged: string[]) => (entries: BulkSecretEntry[], options: RunOptions) => + Promise.resolve( + respond( + entries.map((entry, index) => ({ + index, + key: entry.key, + provider: entry.provider ?? 'default', + applied: options.mode === 'commit', + ...(unmanaged.includes(entry.key) && !options.allowUnmanagedOverwrite + ? { action: 'skip' as const, reason: 'unmanagedTarget' } + : { action: 'overwrite' as const }), + })) + ) + ); + +function renderModal(onRun: BulkApplyModalProps['onRun']) { + render(); +} + +async function chooseFile(contents: object) { + const text = JSON.stringify(contents); + const file = new File([text], 'secrets.json', { type: 'application/json' }); + // jsdom's File has no text() in every version + Object.defineProperty(file, 'text', { value: () => Promise.resolve(text) }); + const input = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + fireEvent.change(input, { target: { files: [file] } }); +} + +describe('provider overrides', () => { + it('refuses a file aimed at another provider without sending it', async () => { + const onRun = vi.fn(fakeProcessor([])); + renderModal(onRun); + + await chooseFile({ provider: 'other', secrets: { a: '1' } }); + + expect( + await screen.findByText(/This file is for "other", not "default"/) + ).toBeInTheDocument(); + expect(onRun).not.toHaveBeenCalled(); + }); + + it('accepts a file naming the selected provider', async () => { + const onRun = vi.fn(fakeProcessor([])); + renderModal(onRun); + + await chooseFile({ provider: 'Default', secrets: { a: '1' } }); + + await waitFor(() => expect(onRun).toHaveBeenCalled()); + }); +}); + +describe('unmanaged targets', () => { + it('flags the entries the processor held back and only sends the override once acknowledged', async () => { + const onRun = vi.fn(fakeProcessor(['mcTokens'])); + renderModal(onRun); + + await chooseFile({ secrets: { mcTokens: 'x', mine: 'y' } }); + + const ack = await screen.findByLabelText( + /Also overwrite 1 record this tool does not manage/ + ); + expect(screen.getByText('not managed here')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Apply 1 secret' }) + ).toBeEnabled(); + + fireEvent.click(ack); + + // Re-previewed with the override, so the count matches what Apply will write + expect( + await screen.findByRole('button', { name: 'Apply 2 secrets' }) + ).toBeEnabled(); + expect(onRun).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + mode: 'preview', + allowUnmanagedOverwrite: true, + }) + ); + // Still marked after the processor starts reporting it as a plain overwrite + expect(screen.getByText('not managed here')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Apply 2 secrets' })); + + await waitFor(() => + expect(onRun).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + mode: 'commit', + allowUnmanagedOverwrite: true, + }) + ) + ); + }); +}); diff --git a/src/features/secrets/BulkApplyModal.tsx b/src/features/secrets/BulkApplyModal.tsx new file mode 100644 index 0000000..060dac1 --- /dev/null +++ b/src/features/secrets/BulkApplyModal.tsx @@ -0,0 +1,429 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button, Form, Modal, Spinner } from 'react-bootstrap'; + +import { + BulkSecretEntry, + BulkSecretsResponse, +} from '../../store/secretsContract'; +import BulkPreviewTable from './BulkPreviewTable'; +import { + MAX_FILE_BYTES, + parseSecretsFile, + SecretsFileIssue, +} from './secretsFile'; +import styles from './BulkApplyModal.module.scss'; + +export interface BulkApplyModalProps { + provider: string; + /** Runs a preview or a commit and resolves with the processor's verdict. */ + onRun: ( + entries: BulkSecretEntry[], + options: { + mode: 'preview' | 'commit'; + overwrite: boolean; + allowUnmanagedOverwrite: boolean; + } + ) => Promise; + onClose: () => void; +} + +type Stage = 'choose' | 'review' | 'done'; + +/** + * Applies a file of secrets, with a mandatory preview. + * + * Three things here are load-bearing for safety rather than for looks: + * + * 1. The parsed entries live in local state and never enter Redux, so plaintext values stay out + * of the store and out of Redux DevTools. + * 2. Window-level drag handlers are installed while the modal is open. Without them, a drop that + * misses the drop zone makes the browser navigate to the file - destroying the page and + * putting a credential file in the address bar and history. + * 3. Nothing is written until the user presses Apply. The only request before that is a preview, + * which the processor answers without touching the store. + */ +const BulkApplyModal = ({ provider, onRun, onClose }: BulkApplyModalProps) => { + const inputRef = useRef(null); + + const [stage, setStage] = useState('choose'); + const [fileName, setFileName] = useState(null); + const [entries, setEntries] = useState([]); + const [warnings, setWarnings] = useState([]); + const [issues, setIssues] = useState([]); + const [response, setResponse] = useState(null); + const [overwrite, setOverwrite] = useState(false); + const [acknowledgeUnmanaged, setAcknowledgeUnmanaged] = useState(false); + // Entries the processor held back because they would overwrite a record this tool does not + // manage. Taken from the processor's own verdict, which is authoritative for every provider. + const [unmanagedIndices, setUnmanagedIndices] = useState>( + new Set() + ); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [isDragging, setIsDragging] = useState(false); + + // A drop landing anywhere but the zone would otherwise navigate away from the app, taking the + // unsaved state with it and exposing the file path. + useEffect(() => { + const swallow = (event: DragEvent) => event.preventDefault(); + window.addEventListener('dragover', swallow); + window.addEventListener('drop', swallow); + return () => { + window.removeEventListener('dragover', swallow); + window.removeEventListener('drop', swallow); + }; + }, []); + + const resetFile = () => { + setEntries([]); + setWarnings([]); + setIssues([]); + setResponse(null); + setFileName(null); + setAcknowledgeUnmanaged(false); + setUnmanagedIndices(new Set()); + if (inputRef.current) inputRef.current.value = ''; + }; + + const handleFile = async (file: File | undefined) => { + if (!file) return; + + setError(null); + setIssues([]); + + // Checked before reading so a large file is never pulled into memory. + if (file.size > MAX_FILE_BYTES) { + setIssues([ + { + code: 'tooLarge', + message: `"${file.name}" is too large. The limit is ${Math.round(MAX_FILE_BYTES / 1024)} KB.`, + }, + ]); + return; + } + + const text = await file.text(); + const parsed = parseSecretsFile({ + text, + fileName: file.name, + fileSize: file.size, + }); + + if (!parsed.ok) { + setIssues(parsed.issues); + setFileName(file.name); + return; + } + + // The processor writes every entry to the request's provider and only echoes a per-entry one + // back, so a file aimed at another provider would land somewhere other than the preview says. + const otherProviders = [ + ...new Set( + parsed.entries + .map((entry) => entry.provider) + .filter( + (p): p is string => + !!p && p.toLowerCase() !== provider.toLowerCase() + ) + ), + ]; + if (otherProviders.length > 0) { + setIssues([ + { + code: 'providerMismatch', + message: `This file is for ${otherProviders + .map((p) => `"${p}"`) + .join( + ', ' + )}, not "${provider}". Switch to that provider to apply it, or remove the provider from the file.`, + }, + ]); + setFileName(file.name); + return; + } + + setFileName(file.name); + setEntries(parsed.entries); + setWarnings(parsed.warnings); + await run(parsed.entries, 'preview', overwrite, false); + }; + + const run = async ( + toRun: BulkSecretEntry[], + mode: 'preview' | 'commit', + overwriteFlag: boolean, + allowUnmanaged: boolean + ) => { + setBusy(true); + setError(null); + try { + const result = await onRun(toRun, { + mode, + overwrite: overwriteFlag, + allowUnmanagedOverwrite: allowUnmanaged, + }); + setResponse(result); + setStage(mode === 'commit' ? 'done' : 'review'); + + // Only a run that did NOT allow unmanaged overwrites reports them, as skips; once allowed + // they come back as ordinary overwrites, so keep the set from the last run that could see it. + if (!allowUnmanaged) { + setUnmanagedIndices( + new Set( + (result.entries ?? []) + .filter( + (entry) => + entry.action === 'skip' && entry.reason === 'unmanagedTarget' + ) + .map((entry) => entry.index) + ) + ); + } + + if (mode === 'commit') { + // The values have served their purpose; drop them as soon as the write lands. The results + // kept for display carry only keys and outcomes. + setEntries([]); + if (inputRef.current) inputRef.current.value = ''; + } + } catch (e) { + setError(e instanceof Error ? e.message : 'The request failed.'); + } finally { + setBusy(false); + } + }; + + const handleOverwriteChange = (next: boolean) => { + setOverwrite(next); + setAcknowledgeUnmanaged(false); + // Re-preview so what is shown always matches the flag that would actually be sent. + void run(entries, 'preview', next, false); + }; + + const handleAcknowledgeChange = (next: boolean) => { + setAcknowledgeUnmanaged(next); + // Re-preview so the unmanaged rows show as the overwrites Apply would now perform. + void run(entries, 'preview', overwrite, next); + }; + + const unmanagedCount = unmanagedIndices.size; + + const applicable = + (response?.summary.create ?? 0) + (response?.summary.overwrite ?? 0); + const blocked = + busy || applicable === 0 || (response?.summary.invalid ?? 0) > 0; + + return ( + + + + {stage === 'done' + ? 'Secrets applied' + : `Apply a secrets file to ${provider}`} + + + + + {stage === 'choose' && ( + <> +
+ This file contains credentials in plain text. Delete it when you + are finished, and do not commit it to source control. +
+ +
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragOver={(e) => e.preventDefault()} + onDragLeave={() => setIsDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + if (e.dataTransfer.files.length > 1) { + setIssues([ + { code: 'notJson', message: 'Drop one file at a time.' }, + ]); + return; + } + void handleFile(e.dataTransfer.files[0]); + }} + > +

+ Drop a .json file here, or choose one: +

+ { + const input = e.target as HTMLInputElement; + void handleFile(input.files?.[0]); + }} + /> +
+ + {busy && ( +
+ + Checking the file… +
+ )} + + {issues.length > 0 && ( +
+
+ {fileName + ? `"${fileName}" could not be read:` + : 'The file could not be read:'} +
+
    + {issues.map((issue, index) => ( +
  • {issue.message}
  • + ))} +
+
+ )} + +
+ Expected file format +
+                {`{
+  "provider": "${provider}",
+  "secrets": {
+    "displayPassword": "…",
+    "codecPassword": "…"
+  }
+}`}
+              
+

+ Download a template to get this file pre-filled with the keys + already stored here. +

+
+ + )} + + {stage !== 'choose' && response && ( + <> + {fileName && ( +
+ From {fileName} +
+ )} + + + + {stage === 'review' && (response.summary.invalid ?? 0) > 0 && ( +
+ Fix the invalid entries before applying. Nothing will be written + while any entry is invalid. +
+ )} + + {stage === 'review' && unmanagedCount > 0 && ( +
+ handleAcknowledgeChange(e.target.checked)} + label={`Also overwrite ${unmanagedCount} record${ + unmanagedCount === 1 ? '' : 's' + } this tool does not manage. They may belong to another part of the system.`} + /> +
+ )} + + {stage === 'done' && ( +
+ {response.summary.create + response.summary.overwrite} secret + {response.summary.create + response.summary.overwrite === 1 + ? '' + : 's'}{' '} + written. + {response.indexUpdated === false && + ' Their details could not be recorded.'} +
+ )} + + )} + + {error && ( +
+ {error} +
+ )} +
+ + + {stage === 'review' && ( + + )} + + + + {stage === 'review' && ( + + )} + +
+ ); +}; + +export default BulkApplyModal; diff --git a/src/features/secrets/BulkPreviewTable.test.tsx b/src/features/secrets/BulkPreviewTable.test.tsx new file mode 100644 index 0000000..478f921 --- /dev/null +++ b/src/features/secrets/BulkPreviewTable.test.tsx @@ -0,0 +1,242 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { + BulkEntryResult, + BulkSecretAction, + BulkSummary, +} from '../../store/secretsContract'; +import BulkPreviewTable, { BulkPreviewTableProps } from './BulkPreviewTable'; +import { SecretsFileIssue } from './secretsFile'; + +const entry = ( + index: number, + key: string, + action: BulkSecretAction, + extra: Partial = {} +): BulkEntryResult => ({ + index, + key, + provider: 'default', + action, + applied: false, + ...extra, +}); + +const summaryFor = (results: BulkEntryResult[]): BulkSummary => ({ + total: results.length, + create: results.filter((r) => r.action === 'create').length, + overwrite: results.filter((r) => r.action === 'overwrite').length, + skip: results.filter((r) => r.action === 'skip').length, + invalid: results.filter((r) => r.action === 'invalid').length, + failed: results.filter((r) => r.action === 'failed').length, +}); + +function renderTable(overrides: Partial = {}) { + const results = overrides.results ?? [ + entry(0, 'newKey', 'create'), + entry(1, 'existingKey', 'overwrite'), + ]; + const onOverwriteChange = vi.fn(); + + const props: BulkPreviewTableProps = { + results, + summary: overrides.summary ?? summaryFor(results), + warnings: [], + unmanagedIndices: new Set(), + overwrite: false, + onOverwriteChange, + ...overrides, + }; + + render(); + return { onOverwriteChange }; +} + +const row = (key: string) => screen.getByText(key).closest('tr') as HTMLElement; + +describe('rows and badges', () => { + it('renders one row per entry with the right action badge', () => { + const results = [ + entry(0, 'a', 'create'), + entry(1, 'b', 'overwrite'), + entry(2, 'c', 'skip'), + entry(3, 'd', 'invalid'), + entry(4, 'e', 'failed'), + ]; + renderTable({ results, summary: summaryFor(results) }); + + expect(within(row('a')).getByText('Create')).toBeInTheDocument(); + expect(within(row('b')).getByText('Overwrite')).toBeInTheDocument(); + expect(within(row('c')).getByText('Skip')).toBeInTheDocument(); + expect(within(row('d')).getByText('Invalid')).toBeInTheDocument(); + expect(within(row('e')).getByText('Failed')).toBeInTheDocument(); + }); + + it("shows each entry's note", () => { + const results = [ + entry(0, 'a', 'skip', { + message: 'Already exists; enable overwrite to replace it.', + }), + ]; + renderTable({ results, summary: summaryFor(results) }); + + expect(screen.getByText(/Already exists/)).toBeInTheDocument(); + }); + + it('marks entries that were actually written', () => { + const results = [entry(0, 'a', 'create', { applied: true })]; + renderTable({ results, summary: summaryFor(results) }); + + expect(within(row('a')).getByText('applied')).toBeInTheDocument(); + }); + + it('renders a placeholder for a blank key rather than an empty cell', () => { + const results = [ + entry(0, '', 'invalid', { message: 'A secret key is required.' }), + ]; + renderTable({ results, summary: summaryFor(results) }); + + expect(screen.getByText('(blank)')).toBeInTheDocument(); + }); + + it('handles an empty batch', () => { + renderTable({ results: [], summary: summaryFor([]) }); + expect(screen.getByText('Nothing to apply.')).toBeInTheDocument(); + }); +}); + +describe('summary', () => { + it('counts each outcome', () => { + const results = [ + entry(0, 'a', 'create'), + entry(1, 'b', 'overwrite'), + entry(2, 'c', 'skip'), + entry(3, 'd', 'invalid'), + ]; + renderTable({ results, summary: summaryFor(results) }); + + expect(screen.getByText(/1 new/)).toBeInTheDocument(); + expect(screen.getByText(/1 overwrite/)).toBeInTheDocument(); + expect(screen.getByText(/1 skipped/)).toBeInTheDocument(); + expect(screen.getByText(/1 invalid/)).toBeInTheDocument(); + }); + + it('states plainly that nothing has been written yet', () => { + renderTable(); + expect( + screen.getByText(/Nothing has been changed yet/) + ).toBeInTheDocument(); + }); + + it('says so when a file would write nothing', () => { + const results = [entry(0, 'a', 'skip')]; + renderTable({ results, summary: summaryFor(results) }); + + expect( + screen.getByText('Nothing in this file would be written.') + ).toBeInTheDocument(); + }); +}); + +describe('overwrite control', () => { + it('reports a change so the caller can re-run the preview', () => { + const { onOverwriteChange } = renderTable(); + + fireEvent.click( + screen.getByLabelText('Replace secrets that already exist') + ); + expect(onOverwriteChange).toHaveBeenCalledWith(true); + }); + + it('is disabled while a preview is in flight', () => { + renderTable({ isRefreshing: true }); + expect( + screen.getByLabelText('Replace secrets that already exist') + ).toBeDisabled(); + }); + + it('is hidden once the batch has been applied', () => { + renderTable({ readOnly: true }); + expect( + screen.queryByLabelText('Replace secrets that already exist') + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/Nothing has been changed yet/) + ).not.toBeInTheDocument(); + }); +}); + +// The guard that stops a round-tripped template destroying another subsystem's records - Mobile +// Control's pairing tokens live in the same flat data store. +describe('unmanaged targets', () => { + it('flags the entries the processor reported as unmanaged', () => { + const results = [ + entry(0, 'mcTokens', 'overwrite'), + entry(1, 'mine', 'overwrite'), + ]; + renderTable({ + results, + summary: summaryFor(results), + unmanagedIndices: new Set([0]), + }); + + expect( + within(row('mcTokens')).getByText('not managed here') + ).toBeInTheDocument(); + expect( + within(row('mine')).queryByText('not managed here') + ).not.toBeInTheDocument(); + }); + + it('flags an unmanaged entry that is being held back, too', () => { + const results = [ + entry(0, 'mcTokens', 'skip', { reason: 'unmanagedTarget' }), + ]; + renderTable({ + results, + summary: summaryFor(results), + unmanagedIndices: new Set([0]), + }); + + expect( + within(row('mcTokens')).getByText('not managed here') + ).toBeInTheDocument(); + }); + + // Matched by result index, not provider + key: the processor reports each entry under the + // provider named in the file, which need not be the one the caller looked up. + it('matches by result index regardless of the provider shown', () => { + const results = [ + entry(0, 'shared', 'overwrite', { provider: 'CrestronGlobalSecrets' }), + ]; + renderTable({ + results, + summary: summaryFor(results), + unmanagedIndices: new Set([0]), + }); + + expect(screen.getByText('not managed here')).toBeInTheDocument(); + }); +}); + +describe('client-side warnings', () => { + it('lists problems found while parsing the file', () => { + const warnings: SecretsFileIssue[] = [ + { code: 'emptyValue', message: '"blank" has a blank value.', index: 2 }, + { code: 'duplicate', message: '"dup" appears more than once.', index: 4 }, + ]; + renderTable({ warnings }); + + expect(screen.getByText('2 problems in the file:')).toBeInTheDocument(); + expect(screen.getByText('"blank" has a blank value.')).toBeInTheDocument(); + expect( + screen.getByText('"dup" appears more than once.') + ).toBeInTheDocument(); + }); + + it('omits the warning block entirely when the file was clean', () => { + renderTable({ warnings: [] }); + expect(screen.queryByText(/problem/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/secrets/BulkPreviewTable.tsx b/src/features/secrets/BulkPreviewTable.tsx new file mode 100644 index 0000000..1afd664 --- /dev/null +++ b/src/features/secrets/BulkPreviewTable.tsx @@ -0,0 +1,170 @@ +import { Form } from 'react-bootstrap'; + +import { + BulkEntryResult, + BulkSecretAction, + BulkSummary, +} from '../../store/secretsContract'; +import { SecretsFileIssue } from './secretsFile'; + +export interface BulkPreviewTableProps { + results: BulkEntryResult[]; + summary: BulkSummary; + /** Client-side problems found while parsing the file, shown alongside the server's verdict. */ + warnings: SecretsFileIssue[]; + /** Result indices the processor reported as records this tool does not manage. */ + unmanagedIndices: ReadonlySet; + overwrite: boolean; + onOverwriteChange?: (next: boolean) => void; + /** True while a dry run is in flight, so the table reads as provisional. */ + isRefreshing?: boolean; + /** Hides the overwrite control and the caption once the batch has been applied. */ + readOnly?: boolean; +} + +const ACTION_BADGE: Record = { + create: 'text-bg-success', + overwrite: 'text-bg-warning', + skip: 'text-bg-secondary', + invalid: 'text-bg-danger', + failed: 'text-bg-danger', +}; + +const ACTION_LABEL: Record = { + create: 'Create', + overwrite: 'Overwrite', + skip: 'Skip', + invalid: 'Invalid', + failed: 'Failed', +}; + +/** + * Shows what a bulk apply will do, before it does it. + * + * Presentational only - no store, no mutations - which is what lets it be tested directly. + */ +const BulkPreviewTable = ({ + results, + summary, + warnings, + unmanagedIndices, + overwrite, + onOverwriteChange, + isRefreshing = false, + readOnly = false, +}: BulkPreviewTableProps) => { + const applicable = summary.create + summary.overwrite; + + return ( +
+
+
+ {summary.total} entr + {summary.total === 1 ? 'y' : 'ies'} + {summary.create > 0 && <> Β· {summary.create} new} + {summary.overwrite > 0 && <> Β· {summary.overwrite} overwrite} + {summary.skip > 0 && <> Β· {summary.skip} skipped} + {summary.invalid > 0 && ( + <> + {' '} + Β· {summary.invalid} invalid + + )} + {summary.failed > 0 && ( + <> + {' '} + Β· {summary.failed} failed + + )} +
+ + {!readOnly && onOverwriteChange && ( + onOverwriteChange(e.target.checked)} + label="Replace secrets that already exist" + /> + )} +
+ + {warnings.length > 0 && ( +
+
+ {warnings.length} problem{warnings.length === 1 ? '' : 's'} in the + file: +
+
    + {warnings.map((issue, index) => ( +
  • + {issue.message} +
  • + ))} +
+
+ )} + +
+ + + + + + + + + + + {results.map((result) => { + const unmanaged = unmanagedIndices.has(result.index); + + return ( + + + + + + + ); + })} + + {results.length === 0 && ( + + + + )} + +
KeyProviderActionNote
+ {result.key || (blank)} + {result.provider} + + {ACTION_LABEL[result.action]} + + {result.applied && ( + applied + )} + {unmanaged && ( + + not managed here + + )} + {result.message}
+ Nothing to apply. +
+
+ + {!readOnly && ( +
+ {applicable === 0 + ? 'Nothing in this file would be written.' + : `${applicable} secret${applicable === 1 ? '' : 's'} will be written. Nothing has been changed yet.`} +
+ )} +
+ ); +}; + +export default BulkPreviewTable; diff --git a/src/features/secrets/SecretDeleteModal.tsx b/src/features/secrets/SecretDeleteModal.tsx new file mode 100644 index 0000000..96acb1f --- /dev/null +++ b/src/features/secrets/SecretDeleteModal.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; + +import { SecretEntry } from '../../store/secretsContract'; + +export interface SecretDeleteModalProps { + entry: SecretEntry; + provider: string; + isDeleting?: boolean; + errorMessage?: string | null; + onConfirm: () => void; + onClose: () => void; +} + +/** + * Confirms deleting a secret. + * + * An unmanaged record - one this tool did not create - requires typing the key to confirm. Those + * records belong to other parts of the system: Mobile Control keeps its pairing tokens in the same + * flat data store, and deleting that one silently un-pairs every touchpanel. A second click is too + * cheap a gate for that. + */ +const SecretDeleteModal = ({ + entry, + provider, + isDeleting = false, + errorMessage, + onConfirm, + onClose, +}: SecretDeleteModalProps) => { + const [typedKey, setTypedKey] = useState(''); + + const strict = !entry.managed; + const confirmed = !strict || typedKey.trim() === entry.key; + + return ( + + + Delete secret + + + +

+ Delete {entry.key} from {provider}? +

+

+ Any device configured to use this secret will fail to authenticate + until it is replaced. The value cannot be recovered. +

+ + {strict && ( +
+
+ This record was not created by this tool and may belong to another + part of the system, such as Mobile Control's paired clients. +
+ + + Type {entry.key} to confirm + + setTypedKey(e.target.value)} + disabled={isDeleting} + autoComplete="off" + spellCheck={false} + /> + +
+ )} + + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+ + + + + +
+ ); +}; + +export default SecretDeleteModal; diff --git a/src/features/secrets/SecretEditModal.tsx b/src/features/secrets/SecretEditModal.tsx new file mode 100644 index 0000000..174b97c --- /dev/null +++ b/src/features/secrets/SecretEditModal.tsx @@ -0,0 +1,247 @@ +import { useState } from 'react'; +import { Button, Form, InputGroup, Modal } from 'react-bootstrap'; + +import EyeIcon from '../../shared/components/EyeIcon'; +import { + DOCUMENTED_MAX_SECRET_KEY_LENGTH, + MAX_SECRET_VALUE_LENGTH, + SecretEntry, + SecretProviderInfo, +} from '../../store/secretsContract'; + +/** What the modal is doing: creating a new secret, or replacing an existing one's value. */ +export type SecretEditTarget = + { mode: 'add' } | { mode: 'update'; entry: SecretEntry }; + +export interface SecretEditSubmission { + provider: string; + key: string; + value: string; + description?: string; + /** True when the key already exists, so the processor is told to replace rather than refuse. */ + overwrite: boolean; +} + +export interface SecretEditModalProps { + target: SecretEditTarget; + provider: string; + providers: SecretProviderInfo[]; + /** Existing keys in the selected provider, for collision detection. */ + existingKeys: SecretEntry[]; + isSaving?: boolean; + errorMessage?: string | null; + onSubmit: (submission: SecretEditSubmission) => void; + onClose: () => void; +} + +/** + * Add or replace a single secret. + * + * The value field is write-only in both directions: the processor never returns a stored value, so + * an update always starts blank rather than pre-filling something we cannot know. + */ +const SecretEditModal = ({ + target, + provider, + providers, + existingKeys, + isSaving = false, + errorMessage, + onSubmit, + onClose, +}: SecretEditModalProps) => { + const isUpdate = target.mode === 'update'; + + const [selectedProvider, setSelectedProvider] = useState(provider); + const [key, setKey] = useState(isUpdate ? target.entry.key : ''); + const [description, setDescription] = useState( + isUpdate ? (target.entry.description ?? '') : '' + ); + const [value, setValue] = useState(''); + const [showValue, setShowValue] = useState(false); + const [acknowledgeUnmanaged, setAcknowledgeUnmanaged] = useState(false); + + const trimmedKey = key.trim(); + const collision = existingKeys.find( + (entry) => entry.key.toLowerCase() === trimmedKey.toLowerCase() + ); + // An update is always a collision with itself; only a *new* key colliding is worth warning about. + const collidesUnexpectedly = !isUpdate && collision !== undefined; + const collidesWithUnmanaged = collision !== undefined && !collision.managed; + + // A warning, not a block: the processor accepts longer keys than the SDK documents, and it is + // the authority on what it will take. See DOCUMENTED_MAX_SECRET_KEY_LENGTH. + const keyIsLong = trimmedKey.length > DOCUMENTED_MAX_SECRET_KEY_LENGTH; + const valueTooLong = value.length > MAX_SECRET_VALUE_LENGTH; + + const blocked = + isSaving || + trimmedKey === '' || + value === '' || + valueTooLong || + (collidesWithUnmanaged && !acknowledgeUnmanaged); + + const handleSubmit = () => { + if (blocked) return; + onSubmit({ + provider: selectedProvider, + key: trimmedKey, + value, + description: description.trim() || undefined, + overwrite: collision !== undefined, + }); + }; + + return ( + + + + {isUpdate ? 'Replace secret value' : 'Add secret'} + + + + + {/* autoComplete off throughout so the browser never offers to save the credential. */} +
{ + e.preventDefault(); + handleSubmit(); + }} + > + + Provider + setSelectedProvider(e.target.value)} + disabled={isUpdate || isSaving} + > + {providers.map((item) => ( + + ))} + + + + + + Key * + + setKey(e.target.value)} + disabled={isUpdate || isSaving} + spellCheck={false} + autoComplete="off" + /> + {keyIsLong && ( + + This key is {trimmedKey.length} characters, longer than the + documented limit of {DOCUMENTED_MAX_SECRET_KEY_LENGTH}. The + processor may reject it. + + )} + {!isUpdate && ( + + This is the key a device config refers to, e.g.{' '} + {`{"secret": {"provider": "${selectedProvider}", "key": "${trimmedKey || 'yourKey'}"}}`} + + )} + + + + Description + setDescription(e.target.value)} + disabled={isSaving} + placeholder="What this credential is for" + autoComplete="off" + /> + + + + + Value * + + + setValue(e.target.value)} + disabled={isSaving} + isInvalid={valueTooLong} + autoComplete="off" + spellCheck={false} + /> + + + Values are limited to {MAX_SECRET_VALUE_LENGTH} characters. + + + {isUpdate && ( + + The stored value is never readable, so enter the new value in + full. + + )} + + + {collidesUnexpectedly && !collidesWithUnmanaged && ( +
+ {trimmedKey} already exists. Saving will replace + its value. +
+ )} + + {collidesWithUnmanaged && ( +
+
+ {trimmedKey} exists in the data store but was + not created here. It may belong to another part of the system. +
+ setAcknowledgeUnmanaged(e.target.checked)} + disabled={isSaving} + label="I understand this will overwrite a record this tool does not manage" + /> +
+ )} + + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+
+ + + + + +
+ ); +}; + +export default SecretEditModal; diff --git a/src/features/secrets/secretsFile.test.ts b/src/features/secrets/secretsFile.test.ts new file mode 100644 index 0000000..e9282d1 --- /dev/null +++ b/src/features/secrets/secretsFile.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it } from 'vitest'; + +import { + MAX_ENTRIES, + MAX_FILE_BYTES, + parseSecretsFile, + summarizeSecretsFile, +} from './secretsFile'; + +/** Parses `text` as a well-formed .json file of the right size, so only content is under test. */ +function parse(text: string, fileName = 'secrets.json') { + return parseSecretsFile({ text, fileName, fileSize: text.length }); +} + +const json = (value: unknown) => JSON.stringify(value, null, 2); + +const codes = (result: ReturnType) => + result.ok + ? result.warnings.map((w) => w.code) + : result.issues.map((i) => i.code); + +// ─── Structural rejection ──────────────────────────────────────────────────── + +describe('file-level rejection', () => { + it('rejects anything that is not .json', () => { + expect(codes(parse('[]', 'secrets.txt'))).toEqual(['notJson']); + expect(codes(parse('[]', 'secrets.json.exe'))).toEqual(['notJson']); + expect(codes(parse('[]', 'secrets'))).toEqual(['notJson']); + }); + + it('accepts an uppercase extension', () => { + expect(parse('[]', 'SECRETS.JSON').ok).toBe(true); + }); + + it('rejects an oversized file from its size, without parsing it', () => { + // Deliberately passes text that would parse fine: size alone must reject it, so a huge file is + // never read into JSON.parse. + const result = parseSecretsFile({ + text: '[]', + fileName: 'secrets.json', + fileSize: MAX_FILE_BYTES + 1, + }); + expect(codes(result)).toEqual(['tooLarge']); + }); + + it('rejects empty and whitespace-only files', () => { + expect(codes(parse(''))).toEqual(['empty']); + expect(codes(parse(' \n\t '))).toEqual(['empty']); + }); + + it('rejects a root that is neither a list nor a secrets wrapper', () => { + expect(codes(parse('"just a string"'))).toEqual(['badRootShape']); + expect(codes(parse('42'))).toEqual(['badRootShape']); + expect(codes(parse('null'))).toEqual(['badRootShape']); + expect(codes(parse(json({ notSecrets: [] })))).toEqual(['badRootShape']); + }); + + it('rejects a file with too many entries', () => { + const many = Array.from({ length: MAX_ENTRIES + 1 }, (_, i) => ({ + key: `k${i}`, + value: 'v', + })); + expect(codes(parse(json(many)))).toEqual(['tooManyEntries']); + }); +}); + +// ─── The security-critical case ────────────────────────────────────────────── + +describe('malformed JSON', () => { + it("reports a location without using the parser's own message", () => { + const result = parse('{\n "secrets": {\n "a": "b",\n }\n}'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.issues[0].code).toBe('invalidJson'); + expect(result.issues[0].message).toMatch( + /not valid JSON \(line \d+, column \d+\)/ + ); + }); + + // THE regression test. V8's SyntaxError.message embeds a snippet of the offending source, so + // surfacing it would render a live credential into the DOM. See describeJsonSyntaxError. + it("never leaks a secret value from the parser's error text", () => { + const text = '{\n "secrets": {\n "password": "hunter2",\n }\n}'; + const result = parse(text); + expect(result.ok).toBe(false); + if (result.ok) return; + + for (const item of result.issues) { + expect(item.message).not.toContain('hunter2'); + } + // Sanity-check the premise: the raw parser message really does contain the value, so this test + // would fail if anyone "improved" the code by passing err.message through. + let raw = ''; + try { + JSON.parse(text); + } catch (e) { + raw = (e as SyntaxError).message; + } + expect(raw.length).toBeGreaterThan(0); + }); + + it('degrades gracefully when there is no position to extract', () => { + const result = parseSecretsFile({ + text: '{', + fileName: 's.json', + fileSize: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.issues[0].message).toMatch(/not valid JSON/); + }); +}); + +// ─── Accepted shapes ───────────────────────────────────────────────────────── + +describe('accepted root shapes', () => { + const expected = [ + { key: 'displayPassword', value: 'hunter2' }, + { key: 'codecPassword', value: 's3cret' }, + ]; + + it('accepts a bare array', () => { + const result = parse(json(expected)); + expect(result.ok && result.entries).toEqual(expected); + }); + + it('accepts an array under a secrets wrapper', () => { + const result = parse(json({ secrets: expected })); + expect(result.ok && result.entries).toEqual(expected); + }); + + // This is the shape the template endpoint returns, so it is the round-trip path. + it('accepts the flat template map and picks up its provider', () => { + const result = parse( + json({ + provider: 'default', + secrets: { displayPassword: 'hunter2', codecPassword: 's3cret' }, + }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.provider).toBe('default'); + expect(result.entries).toEqual([ + { key: 'displayPassword', value: 'hunter2', provider: 'default' }, + { key: 'codecPassword', value: 's3cret', provider: 'default' }, + ]); + }); + + it('lets a per-entry provider override the file-level one', () => { + const result = parse( + json({ + provider: 'default', + secrets: [ + { key: 'a', value: '1' }, + { key: 'b', value: '2', provider: 'CrestronGlobalSecrets' }, + ], + }) + ); + expect(result.ok && result.entries).toEqual([ + { key: 'a', value: '1', provider: 'default' }, + { key: 'b', value: '2', provider: 'CrestronGlobalSecrets' }, + ]); + }); +}); + +// ─── Per-entry validation ──────────────────────────────────────────────────── + +describe('entry validation', () => { + it('rejects a missing or blank key', () => { + expect(codes(parse(json([{ value: 'v' }])))).toEqual(['missingKey']); + expect(codes(parse(json([{ key: ' ', value: 'v' }])))).toEqual([ + 'missingKey', + ]); + expect(codes(parse(json([{ key: 42, value: 'v' }])))).toEqual([ + 'missingKey', + ]); + }); + + // The documented 32-character cap is not what the processor enforces: Mobile Control's + // "7:mobileControl-directServer-tokens" is 35 characters and lives in the same store. Warn, but + // let the processor be the authority on what it accepts. + it('warns about a long key without dropping the entry', () => { + const result = parse(json([{ key: 'k'.repeat(35), value: 'v' }])); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.entries).toHaveLength(1); + expect(result.warnings.map((w) => w.code)).toEqual(['keyTooLong']); + expect(result.warnings[0].message).toContain('may reject'); + }); + + it('says nothing about a key within the documented limit', () => { + const result = parse(json([{ key: 'k'.repeat(32), value: 'v' }])); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.warnings).toEqual([]); + }); + + it('rejects control characters in a key', () => { + expect(codes(parse(json([{ key: 'bad\nkey', value: 'v' }])))).toEqual([ + 'badKeyChars', + ]); + }); + + it("rejects the API's own reserved records", () => { + expect( + codes(parse(json([{ key: '__essSecretsIdx', value: 'v' }]))) + ).toEqual(['reservedKey']); + expect( + codes(parse(json([{ key: '__essSecretsIdx03', value: 'v' }]))) + ).toEqual(['reservedKey']); + }); + + it('rejects a non-string value', () => { + expect(codes(parse(json([{ key: 'k' }])))).toEqual(['missingValue']); + expect(codes(parse(json([{ key: 'k', value: 42 }])))).toEqual([ + 'missingValue', + ]); + expect(codes(parse(json([{ key: 'k', value: null }])))).toEqual([ + 'missingValue', + ]); + }); + + // An empty value is not a no-op: CrestronLocalSecretsProvider.SetSecret(key, "") calls + // clearLocal(key), so a blank value in a file would silently delete a working credential. + it('rejects a blank value, because blank means DELETE on the processor', () => { + const result = parse( + json([ + { key: 'keep', value: 'v' }, + { key: 'k', value: '' }, + ]) + ); + expect(codes(result)).toEqual(['emptyValue']); + expect(result.ok && result.entries).toEqual([{ key: 'keep', value: 'v' }]); + }); + + it('rejects a value over the 1600-character limit, reporting only its length', () => { + const result = parse(json([{ key: 'k', value: 'x'.repeat(1601) }])); + expect(codes(result)).toEqual(['valueTooLong']); + if (!result.ok) return; + expect(result.warnings[0].message).toContain('1601'); + expect(result.warnings[0].message).not.toContain('xxxx'); + }); + + it('rejects an invalid provider or description', () => { + expect( + codes(parse(json([{ key: 'k', value: 'v', provider: '' }]))) + ).toEqual(['badProvider']); + expect( + codes(parse(json([{ key: 'k', value: 'v', description: 5 }]))) + ).toEqual(['badDescription']); + }); + + it('rejects an entry that is not an object', () => { + expect(codes(parse(json(['nope'])))).toEqual(['notAnObject']); + }); + + it('trims key and provider but never the value', () => { + const result = parse( + json([{ key: ' k ', value: ' v ', provider: ' default ' }]) + ); + expect(result.ok && result.entries).toEqual([ + { key: 'k', value: ' v ', provider: 'default' }, + ]); + }); + + it('ignores unrecognized properties with a warning rather than failing', () => { + const result = parse( + json([{ key: 'k', value: 'v', note: 'hi', extra: 1 }]) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.entries).toEqual([{ key: 'k', value: 'v' }]); + expect(result.warnings.map((w) => w.code)).toEqual(['unknownProperty']); + expect(result.warnings[0].message).toContain('note, extra'); + }); + + it('drops a duplicate rather than letting the last one win', () => { + const result = parse( + json([ + { key: 'k', value: 'first' }, + { key: 'k', value: 'second' }, + ]) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.entries).toEqual([{ key: 'k', value: 'first' }]); + expect(result.warnings.map((w) => w.code)).toEqual(['duplicate']); + }); + + it('treats the same key under different providers as distinct', () => { + const result = parse( + json([ + { key: 'k', value: '1', provider: 'default' }, + { key: 'k', value: '2', provider: 'CrestronGlobalSecrets' }, + ]) + ); + expect(result.ok && result.entries).toHaveLength(2); + }); +}); + +// ─── Partial success ───────────────────────────────────────────────────────── + +describe('partial success', () => { + it('keeps the valid entries and reports the failures alongside', () => { + const result = parse( + json([ + { key: 'good1', value: 'v' }, + { key: '', value: 'v' }, + { key: 'good2', value: 'v' }, + { key: 'blank', value: '' }, + ]) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.entries.map((e) => e.key)).toEqual(['good1', 'good2']); + expect(result.warnings.map((w) => w.code)).toEqual([ + 'missingKey', + 'emptyValue', + ]); + }); + + it('reports the row index so the UI can point at the offending line', () => { + const result = parse( + json([ + { key: 'ok', value: 'v' }, + { key: '', value: 'v' }, + ]) + ); + expect(result.ok && result.warnings[0].index).toBe(1); + }); + + it("does not guess 'template' from a single blank entry", () => { + const result = parse(json([{ key: 'k', value: '' }])); + expect(codes(result)).toEqual(['emptyValue']); + expect(result.ok && result.entries).toEqual([]); + }); + + it('flags an unfilled template as its own distinct mistake', () => { + const result = parse( + json({ provider: 'default', secrets: { a: '', b: '' } }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.entries).toEqual([]); + expect(result.warnings[0].code).toBe('templateNotFilled'); + }); + + it('does not flag a partially filled template as unfilled', () => { + const result = parse( + json({ provider: 'default', secrets: { a: 'filled', b: '' } }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.warnings.map((w) => w.code)).not.toContain( + 'templateNotFilled' + ); + }); +}); + +// ─── No value ever appears in an issue message ─────────────────────────────── + +describe('issue messages never contain values', () => { + it('holds across every failure mode', () => { + const value = 'sup3rS3cretValue'; + const files = [ + json([{ key: '', value }]), + json([{ key: 'badkey', value }]), + json([{ key: '__essSecretsIdx', value }]), + json([{ key: 'k', value: `${value}${'x'.repeat(1601)}` }]), + json([{ key: 'k', value, provider: '' }]), + json([{ key: 'k', value, description: 5 }]), + json([ + { key: 'dup', value }, + { key: 'dup', value }, + ]), + json([{ key: 'k', value, junk: value }]), + ]; + + for (const text of files) { + const result = parse(text); + const messages = result.ok + ? result.warnings.map((w) => w.message) + : result.issues.map((i) => i.message); + for (const message of messages) { + expect(message).not.toContain(value); + } + } + }); +}); + +// ─── Summary ───────────────────────────────────────────────────────────────── + +describe('summarizeSecretsFile', () => { + it('counts by provider and exposes no values', () => { + const summary = summarizeSecretsFile([ + { key: 'a', value: '1', provider: 'default' }, + { key: 'b', value: '2', provider: 'default' }, + { key: 'c', value: '3', provider: 'CrestronGlobalSecrets' }, + ]); + expect(summary).toEqual({ + total: 3, + byProvider: { default: 2, CrestronGlobalSecrets: 1 }, + }); + const serialized = JSON.stringify(summary); + for (const value of ['1', '2', '3'].map((v) => `"${v}"`)) { + expect(serialized).not.toContain(value); + } + }); + + it('groups entries with no provider under the empty key', () => { + expect(summarizeSecretsFile([{ key: 'a', value: 'v' }])).toEqual({ + total: 1, + byProvider: { '': 1 }, + }); + }); +}); diff --git a/src/features/secrets/secretsFile.ts b/src/features/secrets/secretsFile.ts new file mode 100644 index 0000000..74544de --- /dev/null +++ b/src/features/secrets/secretsFile.ts @@ -0,0 +1,468 @@ +/** + * Parsing and validating a bulk secrets file. + * + * Pure by design - no React, no DOM, no `File`. It takes text that has already been read, which is + * what makes the risky logic here trivially testable. + * + * SECURITY: a file handled by this module is full of plaintext credentials. Two rules follow, and + * both are enforced by tests: + * + * 1. **No issue message ever contains a value.** In particular, `JSON.parse`'s own error text is + * never surfaced: V8 embeds a snippet of the offending source in `SyntaxError.message`, so for + * a secrets file that snippet can literally be `"password": "hunter2"` - which would then be + * rendered straight into the DOM. Only the numeric offset is extracted, and the message is + * written here. + * 2. **Nothing is logged.** There are no `console.*` calls in this file or anywhere under + * `features/secrets/`, deliberately. + */ + +import { + BulkSecretEntry, + DOCUMENTED_MAX_SECRET_KEY_LENGTH, + MAX_SECRET_VALUE_LENGTH, + RESERVED_KEY_PREFIX, +} from '../../store/secretsContract'; + +/** Generous for a credential list, small enough that a stray binary never reaches JSON.parse. */ +export const MAX_FILE_BYTES = 256 * 1024; +/** Matches the processor's own per-batch cap. */ +export const MAX_ENTRIES = 500; + +export type SecretsFileIssueCode = + // Structural - these abort the whole file. + | 'notJson' + | 'tooLarge' + | 'empty' + | 'invalidJson' + | 'badRootShape' + | 'tooManyEntries' + // Raised by BulkApplyModal rather than the parser: the file names a provider other than the one + // being applied to. + | 'providerMismatch' + // Per-entry - these drop one row and are reported alongside the rows that survived. + | 'notAnObject' + | 'missingKey' + | 'keyTooLong' + | 'badKeyChars' + | 'reservedKey' + | 'missingValue' + | 'emptyValue' + | 'valueTooLong' + | 'badProvider' + | 'badDescription' + | 'duplicate' + // Advisory. + | 'templateNotFilled' + | 'unknownProperty'; + +export interface SecretsFileIssue { + code: SecretsFileIssueCode; + /** Safe to render. Never contains a secret value. */ + message: string; + /** 0-based index into the file's entries, for row-level issues. */ + index?: number; + key?: string; +} + +export interface SecretsFileSummary { + total: number; + byProvider: Record; +} + +export type ParseSecretsFileResult = + | { + ok: true; + entries: BulkSecretEntry[]; + /** Provider named in the file itself. The downloaded template includes one. */ + provider?: string; + warnings: SecretsFileIssue[]; + summary: SecretsFileSummary; + } + | { ok: false; issues: SecretsFileIssue[] }; + +interface ParseInput { + text: string; + fileName: string; + fileSize: number; +} + +// Matching control characters is the point, so no-control-regex doesn't apply +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS = /[\u0000-\u001f\u007f]/; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} bytes`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Builds a location hint from a SyntaxError without using any of its text. + * + * Modern V8 messages look like `Expected ',' or '}' after property value in JSON at position 214 + * (line 9 column 3)` - useful, but they also quote the surrounding source. Take the offset only. + */ +function describeJsonSyntaxError(error: unknown, text: string): string { + const raw = error instanceof SyntaxError ? error.message : ''; + const match = /position (\d+)/.exec(raw); + if (!match) return 'The file is not valid JSON.'; + + const position = Math.min(Number(match[1]), text.length); + const before = text.slice(0, position); + const line = before.split('\n').length; + const column = position - before.lastIndexOf('\n'); + return `The file is not valid JSON (line ${line}, column ${column}).`; +} + +function issue( + code: SecretsFileIssueCode, + message: string, + extra: { index?: number; key?: string } = {} +): SecretsFileIssue { + return { code, message, ...extra }; +} + +/** Raw entry as it appears in the file, before validation. */ +interface RawEntry { + index: number; + key: unknown; + value: unknown; + provider?: unknown; + description?: unknown; + extraProperties?: string[]; +} + +const KNOWN_ENTRY_PROPERTIES = new Set([ + 'key', + 'value', + 'provider', + 'description', +]); + +/** + * Normalizes the three accepted shapes into a flat list. + * + * Accepted, in order of how likely they are to arrive: + * `{ "provider": "default", "secrets": { "key": "value" } }` the downloaded template + * `{ "secrets": [ { "key": …, "value": … } ] }` array under a wrapper + * `[ { "key": …, "value": … } ]` bare array, hand-written + */ +function normalizeRoot( + root: unknown +): { entries: RawEntry[]; provider?: string } | null { + if (Array.isArray(root)) { + return { entries: root.map(toRawEntry) }; + } + + if (typeof root !== 'object' || root === null) return null; + + const wrapper = root as Record; + const provider = + typeof wrapper.provider === 'string' ? wrapper.provider.trim() : undefined; + const secrets = wrapper.secrets; + + if (Array.isArray(secrets)) { + return { entries: secrets.map(toRawEntry), provider }; + } + + if (typeof secrets === 'object' && secrets !== null) { + const entries = Object.entries(secrets as Record).map( + ([key, value], index): RawEntry => ({ index, key, value }) + ); + return { entries, provider }; + } + + return null; +} + +function toRawEntry(raw: unknown, index: number): RawEntry { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return { index, key: undefined, value: undefined }; + } + const object = raw as Record; + return { + index, + key: object.key, + value: object.value, + provider: object.provider, + description: object.description, + extraProperties: Object.keys(object).filter( + (name) => !KNOWN_ENTRY_PROPERTIES.has(name) + ), + }; +} + +/** + * Validates one raw entry, returning either the accepted entry or the reason it was dropped. + * + * Mirrors the processor's own validation so a bad file is rejected before any round trip. The rules + * that are not obvious: + * - an empty value is INVALID, not a no-op: `SetSecret(key, "")` deletes the record, so a blank + * value in a file would silently destroy a credential; + * - `key` and `provider` are trimmed, `value` never is - trailing whitespace can be significant + * in a password. + */ +function validateEntry( + raw: RawEntry, + fallbackProvider: string | undefined +): + | { entry: BulkSecretEntry; warnings: SecretsFileIssue[] } + | { issue: SecretsFileIssue } { + const at = { index: raw.index }; + + if (raw.key === undefined && raw.value === undefined) { + return { + issue: issue( + 'notAnObject', + `Entry ${raw.index + 1} is not an object.`, + at + ), + }; + } + + if (typeof raw.key !== 'string' || raw.key.trim() === '') { + return { + issue: issue('missingKey', `Entry ${raw.index + 1} has no key.`, at), + }; + } + const key = raw.key.trim(); + const named = { ...at, key }; + + if (CONTROL_CHARS.test(key)) { + return { + issue: issue( + 'badKeyChars', + `Key "${key}" contains control characters.`, + named + ), + }; + } + if (key.startsWith(RESERVED_KEY_PREFIX)) { + return { + issue: issue( + 'reservedKey', + `Key "${key}" is reserved for internal use.`, + named + ), + }; + } + + if (typeof raw.value !== 'string') { + return { + issue: issue( + 'missingValue', + `"${key}" has no value, or its value is not text.`, + named + ), + }; + } + if (raw.value === '') { + return { + issue: issue( + 'emptyValue', + `"${key}" has a blank value. Blank would delete the secret, so fill it in or remove the entry.`, + named + ), + }; + } + if (raw.value.length > MAX_SECRET_VALUE_LENGTH) { + // Deliberately reports only the length, never any part of the value. + return { + issue: issue( + 'valueTooLong', + `The value for "${key}" is ${raw.value.length} characters; the limit is ${MAX_SECRET_VALUE_LENGTH}.`, + named + ), + }; + } + + let provider = fallbackProvider; + if (raw.provider !== undefined) { + if (typeof raw.provider !== 'string' || raw.provider.trim() === '') { + return { + issue: issue('badProvider', `"${key}" has an invalid provider.`, named), + }; + } + provider = raw.provider.trim(); + } + + if (raw.description !== undefined && typeof raw.description !== 'string') { + return { + issue: issue( + 'badDescription', + `"${key}" has an invalid description.`, + named + ), + }; + } + + const entry: BulkSecretEntry = { key, value: raw.value }; + if (provider) entry.provider = provider; + if (typeof raw.description === 'string' && raw.description.trim() !== '') { + entry.description = raw.description.trim(); + } + + const warnings: SecretsFileIssue[] = []; + + // Advisory, not a rejection: the processor accepts keys longer than the documented limit, and it + // is the authority on what it will take. See DOCUMENTED_MAX_SECRET_KEY_LENGTH. + if (key.length > DOCUMENTED_MAX_SECRET_KEY_LENGTH) { + warnings.push( + issue( + 'keyTooLong', + `Key "${key}" is ${key.length} characters, longer than the documented limit of ${DOCUMENTED_MAX_SECRET_KEY_LENGTH}. The processor may reject it.`, + named + ) + ); + } + + if (raw.extraProperties && raw.extraProperties.length > 0) { + warnings.push( + issue( + 'unknownProperty', + `"${key}" has unrecognized properties that were ignored: ${raw.extraProperties.join(', ')}.`, + named + ) + ); + } + + return { entry, warnings }; +} + +/** + * Parses and validates a secrets file. + * + * Structural problems (1-6 in the order below) abort with `ok: false`. A per-entry problem drops + * only that row: the valid entries still come back, with the failures reported as warnings, so a + * user with one bad line out of thirty is not forced to start over. + */ +export function parseSecretsFile(input: ParseInput): ParseSecretsFileResult { + const { text, fileName, fileSize } = input; + + // Ordered cheapest-first so a large binary is rejected without ever being parsed. Extension is + // the assertion rather than MIME type, which is unreliable for a dropped file. + if (!/\.json$/i.test(fileName.trim())) { + return { + ok: false, + issues: [issue('notJson', `"${fileName}" is not a .json file.`)], + }; + } + + if (fileSize > MAX_FILE_BYTES) { + return { + ok: false, + issues: [ + issue( + 'tooLarge', + `"${fileName}" is ${formatBytes(fileSize)}; the limit is ${formatBytes(MAX_FILE_BYTES)}.` + ), + ], + }; + } + + if (text.trim() === '') { + return { ok: false, issues: [issue('empty', 'The file is empty.')] }; + } + + let root: unknown; + try { + root = JSON.parse(text); + } catch (error) { + return { + ok: false, + issues: [issue('invalidJson', describeJsonSyntaxError(error, text))], + }; + } + + const normalized = normalizeRoot(root); + if (!normalized) { + return { + ok: false, + issues: [ + issue( + 'badRootShape', + 'Expected a list of secrets, or an object with a "secrets" property.' + ), + ], + }; + } + + if (normalized.entries.length > MAX_ENTRIES) { + return { + ok: false, + issues: [ + issue( + 'tooManyEntries', + `The file has ${normalized.entries.length} entries; the limit is ${MAX_ENTRIES}.` + ), + ], + }; + } + + const entries: BulkSecretEntry[] = []; + const warnings: SecretsFileIssue[] = []; + const seen = new Set(); + let blankValues = 0; + + for (const raw of normalized.entries) { + const result = validateEntry(raw, normalized.provider); + + if ('issue' in result) { + if (result.issue.code === 'emptyValue') blankValues += 1; + warnings.push(result.issue); + continue; + } + + const identity = `${result.entry.provider ?? ''}\u0000${result.entry.key}`; + if (seen.has(identity)) { + // Last-write-wins is too surprising when the payload is a credential. + warnings.push( + issue('duplicate', `"${result.entry.key}" appears more than once.`, { + index: raw.index, + key: result.entry.key, + }) + ); + continue; + } + seen.add(identity); + + entries.push(result.entry); + warnings.push(...result.warnings); + } + + // The overwhelmingly common mistake: downloading the template and uploading it unchanged. + // Requires more than one entry - a single blank value is far more likely a typo than a template, + // and the per-entry message already tells the user to fill it in. + if ( + entries.length === 0 && + normalized.entries.length > 1 && + blankValues === normalized.entries.length + ) { + warnings.unshift( + issue( + 'templateNotFilled', + 'Every value in this file is blank. This looks like a downloaded template - fill in the values before applying it.' + ) + ); + } + + return { + ok: true, + entries, + provider: normalized.provider, + warnings, + summary: summarizeSecretsFile(entries), + }; +} + +/** Counts only - never values. */ +export function summarizeSecretsFile( + entries: BulkSecretEntry[] +): SecretsFileSummary { + const byProvider: Record = {}; + for (const entry of entries) { + const provider = entry.provider ?? ''; + byProvider[provider] = (byProvider[provider] ?? 0) + 1; + } + return { total: entries.length, byProvider }; +} diff --git a/src/index.tsx b/src/index.tsx index df83cdf..e4bbf92 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,11 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import { Provider } from "react-redux"; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; -import App from "./App"; -import reportWebVitals from "./reportWebVitals"; -import { store } from "./store/store"; -import "./styles.scss"; +import App from './App'; +import reportWebVitals from './reportWebVitals'; +import { store } from './store/store'; +import './styles.scss'; // const router = createBrowserRouter( // [ @@ -18,12 +18,12 @@ import "./styles.scss"; // ); const root = ReactDOM.createRoot( - document.getElementById("root") as HTMLElement + document.getElementById('root') as HTMLElement ); root.render( - + diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 92359d1..d551fd5 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -2,7 +2,9 @@ declare module '*.svg' { import type React from 'react'; - export const ReactComponent: React.FunctionComponent>; + export const ReactComponent: React.FunctionComponent< + React.SVGProps + >; const src: string; export default src; } diff --git a/src/shared/FilterDropdownSearchParams.tsx b/src/shared/FilterDropdownSearchParams.tsx index c3e4fae..06d44fc 100644 --- a/src/shared/FilterDropdownSearchParams.tsx +++ b/src/shared/FilterDropdownSearchParams.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, useEffect, useState } from 'react'; +import { ChangeEvent } from 'react'; import { Badge } from 'react-bootstrap'; import Dropdown from 'react-bootstrap/Dropdown'; import Form from 'react-bootstrap/Form'; @@ -12,16 +12,12 @@ export const FilterDropdownSearchParams = ( props: FilterDropdownSearchParamsProps ) => { const [searchParams, setSearchParams] = useSearchParams(); - const [values, setValues] = useState([]); - - // React to search params and get the selected values, if any - useEffect(() => { - setValues(searchParams.getAll(props.paramName)); - }, [searchParams, props.paramName]); + // The selected values, if any, straight from the search params + const values = searchParams.getAll(props.paramName); // Defined inside here for access to props const FilterCheckItem = (checkProps: { - item: IdLabel ; + item: IdLabel; htmlName: string; htmlId: string; }) => { @@ -46,7 +42,7 @@ export const FilterDropdownSearchParams = ( { /* HOOKS ***********************************************************/ const timerRef = useRef | null>(null); - const PARAM = "searchText"; + const PARAM = 'searchText'; const [searchParams, setSearchParams] = useSearchParams(); - const [searchText, setSearchText] = useState(controlledValue ?? ""); + // The value from outside: the prop in controlled mode, otherwise the URL params + const externalText = onChangeValue + ? (controlledValue ?? '') + : searchParams.getAll(PARAM).join(' '); + const [searchText, setSearchText] = useState(externalText); + const [syncedText, setSyncedText] = useState(externalText); /* FUNCTIONS *******************************************************/ /** Handles search text change, after 1s debounce */ @@ -45,15 +50,12 @@ export const FilterSearchText = ({ }; }, []); - /** In URL-params mode, sync local state from params. In controlled mode, sync from prop. **/ - useEffect(() => { - if (onChangeValue) { - setSearchText(controlledValue ?? ""); - } else { - setSearchText(searchParams.getAll(PARAM).join(" ")); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [controlledValue, searchParams]); + /** Replace the draft whenever the outside value changes. Done during render rather than in an + * effect, so the input never paints the stale value first. **/ + if (externalText !== syncedText) { + setSyncedText(externalText); + setSearchText(externalText); + } /* RENDER **********************************************************/ return ( @@ -85,5 +87,4 @@ type FilterSearchTextUncontrolledProps = FilterSearchTextBaseProps & { }; type FilterSearchTextProps = - | FilterSearchTextControlledProps - | FilterSearchTextUncontrolledProps; + FilterSearchTextControlledProps | FilterSearchTextUncontrolledProps; diff --git a/src/shared/ListFiltersHeader.tsx b/src/shared/ListFiltersHeader.tsx index 86ca4af..234d0ff 100644 --- a/src/shared/ListFiltersHeader.tsx +++ b/src/shared/ListFiltersHeader.tsx @@ -16,9 +16,14 @@ const ListFiltersHeader = ({
{showSearch && (
- {onSearchChange !== undefined - ? - : } + {onSearchChange !== undefined ? ( + + ) : ( + + )}
)}
{filters}
diff --git a/src/shared/components/EyeIcon.tsx b/src/shared/components/EyeIcon.tsx new file mode 100644 index 0000000..fb617ea --- /dev/null +++ b/src/shared/components/EyeIcon.tsx @@ -0,0 +1,43 @@ +type EyeIconProps = { + slashed?: boolean; + size?: number; +}; + +/** + * Simple inline eye / eye-slash icon (Bootstrap Icons glyphs), used for password-visibility + * toggles. Avoids pulling in a whole icon font/library for a single icon pair. + */ +const EyeIcon = ({ slashed = false, size = 16 }: EyeIconProps) => { + if (slashed) { + return ( + + ); + } + + return ( + + ); +}; + +export default EyeIcon; diff --git a/src/shared/functions/downloadFile.test.ts b/src/shared/functions/downloadFile.test.ts new file mode 100644 index 0000000..8bf5d63 --- /dev/null +++ b/src/shared/functions/downloadFile.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + downloadJson, + downloadText, + timestampedFilename, +} from './downloadFile'; + +// jsdom implements neither of these, so they have to be installed before the helper runs. +// Typed with its signature so `mock.calls[0][0]` is the Blob rather than an empty tuple. +const createObjectURL = vi.fn<(blob: Blob | MediaSource) => string>( + () => 'blob:mock-url' +); +const revokeObjectURL = vi.fn<(url: string) => void>(() => undefined); + +let clicked: HTMLAnchorElement[] = []; + +beforeEach(() => { + vi.useFakeTimers(); + clicked = []; + createObjectURL.mockClear(); + revokeObjectURL.mockClear(); + + Object.defineProperty(URL, 'createObjectURL', { + value: createObjectURL, + configurable: true, + }); + Object.defineProperty(URL, 'revokeObjectURL', { + value: revokeObjectURL, + configurable: true, + }); + + // jsdom's HTMLAnchorElement.click() would try to navigate; record the call instead. + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function ( + this: HTMLAnchorElement + ) { + clicked.push(this); + }); +}); + +afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('downloadText', () => { + it('clicks an anchor carrying the filename', () => { + downloadText('notes.txt', 'hello'); + + expect(clicked).toHaveLength(1); + expect(clicked[0].download).toBe('notes.txt'); + expect(clicked[0].href).toContain('blob:mock-url'); + }); + + it('puts the anchor in the document before clicking, so Firefox honours it', () => { + downloadText('notes.txt', 'hello'); + // The recorded anchor was attached at click time; cleanup happens on the timer below. + expect(clicked[0].isConnected).toBe(true); + }); + + it('builds the blob with the requested media type', () => { + downloadText('notes.txt', 'hello', 'text/csv'); + + const blob = createObjectURL.mock.calls[0][0] as Blob; + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe('text/csv'); + }); + + it('defaults to text/plain', () => { + downloadText('notes.txt', 'hello'); + const blob = createObjectURL.mock.calls[0][0] as Blob; + expect(blob.type).toBe('text/plain'); + }); + + // Revoking inline cancels the download in some browsers, so cleanup is deferred a tick. + it('defers cleanup rather than revoking before the click is dispatched', () => { + downloadText('notes.txt', 'hello'); + + expect(revokeObjectURL).not.toHaveBeenCalled(); + expect(clicked[0].isConnected).toBe(true); + + vi.runAllTimers(); + + expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + expect(clicked[0].isConnected).toBe(false); + }); + + it('leaves no anchors behind after several downloads', () => { + downloadText('a.txt', 'a'); + downloadText('b.txt', 'b'); + vi.runAllTimers(); + + expect(document.querySelectorAll('a')).toHaveLength(0); + expect(revokeObjectURL).toHaveBeenCalledTimes(2); + }); +}); + +describe('downloadJson', () => { + it('pretty-prints the payload as application/json', async () => { + downloadJson('data.json', { b: 2, a: 1 }); + + expect(clicked[0].download).toBe('data.json'); + const blob = createObjectURL.mock.calls[0][0] as Blob; + expect(blob.type).toBe('application/json'); + await expect(blob.text()).resolves.toBe('{\n "b": 2,\n "a": 1\n}'); + }); +}); + +describe('timestampedFilename', () => { + it('stamps the current date and the given extension', () => { + vi.setSystemTime(new Date('2026-09-16T18:30:00Z')); + expect(timestampedFilename('secrets-template-app01', 'json')).toBe( + 'secrets-template-app01-2026-09-16.json' + ); + }); +}); diff --git a/src/shared/functions/downloadFile.ts b/src/shared/functions/downloadFile.ts new file mode 100644 index 0000000..72f33ca --- /dev/null +++ b/src/shared/functions/downloadFile.ts @@ -0,0 +1,47 @@ +/** + * Saving generated content to the user's machine. + * + * Extracted from DebugConsole's inline log export so the secrets template download shares one + * implementation rather than a second copy of the same eight lines. + */ + +/** + * Prompts the browser to save `text` as `filename`. + * + * The anchor has to be in the document for the synthetic click to work in Firefox, and the object + * URL can only be revoked once the click has been dispatched - hence the deferred cleanup rather + * than revoking inline, which cancels the download in some browsers. + */ +export function downloadText( + filename: string, + text: string, + mime = 'text/plain' +): void { + const blob = new Blob([text], { type: mime }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + + setTimeout(() => { + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + }, 0); +} + +/** Pretty-prints `data` and saves it as a .json file. */ +export function downloadJson(filename: string, data: unknown): void { + downloadText(filename, JSON.stringify(data, null, 2), 'application/json'); +} + +/** + * Builds a filename stamped with the current date, e.g. `secrets-template-app01-2026-09-16.json`. + * Matching the timestamped convention the debug log export already uses. + */ +export function timestampedFilename(prefix: string, extension: string): string { + const date = new Date().toISOString().slice(0, 10); + return `${prefix}-${date}.${extension}`; +} diff --git a/src/shared/functions/meetsMinimumVersion.ts b/src/shared/functions/meetsMinimumVersion.ts index 9dc48b8..5693be0 100644 --- a/src/shared/functions/meetsMinimumVersion.ts +++ b/src/shared/functions/meetsMinimumVersion.ts @@ -1,12 +1,18 @@ // Compares dot-separated version strings numerically (e.g. "2.29" > "2.9"). // Strips semver pre-release suffixes (e.g. "2.29.0-alpha.1" β†’ "2.29.0"). export function meetsMinVersion(version: string, minimum: string): boolean { - const vParts = version.split("-")[0].split(".").map((s) => parseInt(s, 10)); - const mParts = minimum.split("-")[0].split(".").map((s) => parseInt(s, 10)); + const vParts = version + .split('-')[0] + .split('.') + .map((s) => parseInt(s, 10)); + const mParts = minimum + .split('-')[0] + .split('.') + .map((s) => parseInt(s, 10)); for (let i = 0; i < Math.max(vParts.length, mParts.length); i++) { const a = vParts[i] ?? 0; const b = mParts[i] ?? 0; if (a !== b) return a > b; } return true; -} \ No newline at end of file +} diff --git a/src/shared/hooks/useAppParams.ts b/src/shared/hooks/useAppParams.ts index f43c1b1..beba0a5 100644 --- a/src/shared/hooks/useAppParams.ts +++ b/src/shared/hooks/useAppParams.ts @@ -7,4 +7,4 @@ export default function useAppParams() { type AppParams = { appId: string; -}; \ No newline at end of file +}; diff --git a/src/shared/icons/index.tsx b/src/shared/icons/index.ts similarity index 98% rename from src/shared/icons/index.tsx rename to src/shared/icons/index.ts index 620f624..601d413 100644 --- a/src/shared/icons/index.tsx +++ b/src/shared/icons/index.ts @@ -1,4 +1,3 @@ // export * from './Icon'; export * from './ObjectIcons'; export * from './OtherIcons'; - diff --git a/src/shared/types/LogMessage.ts b/src/shared/types/LogMessage.ts index e8c1c7e..d2858b1 100644 --- a/src/shared/types/LogMessage.ts +++ b/src/shared/types/LogMessage.ts @@ -1,9 +1,9 @@ export interface LogMessage { Timestamp: string; MessageTemplate: string; - RenderedMessage: String; + RenderedMessage: string; Level: string; Properties?: { Key: string; }; -} \ No newline at end of file +} diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index 3f6bbf8..b29f322 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -1,12 +1,25 @@ -import { createApi } from "@reduxjs/toolkit/query/react"; - -import { axiosBaseQuery } from "../services/httpService"; - -function getAppIdFromPath(): string { - const path = window.location.pathname; - const pathParts = path.split("/"); - return pathParts[2]; -} +import { createApi } from '@reduxjs/toolkit/query/react'; + +import { axiosBaseQuery } from '../services/httpService'; +import { + ROUTING_COMMAND_PATH, + RoutingCommand, + RoutingCommandResponse, +} from './routingCommands'; +import { + BulkSecretsRequest, + BulkSecretsResponse, + SECRETS_BULK_PATH, + SECRETS_COMMAND_PATH, + SECRETS_PATH, + SECRETS_PROVIDERS_PATH, + SECRETS_TEMPLATE_PATH, + SecretCommandRequest, + SecretCommandResponse, + SecretsListResponse, + SecretsProvidersResponse, + SecretsTemplateResponse, +} from './secretsContract'; function getBaseApiPath(): string { return `/cws`; @@ -15,33 +28,33 @@ function getBaseApiPath(): string { const apiSlice = createApi({ baseQuery: axiosBaseQuery({ baseUrl: getBaseApiPath() }), tagTypes: [ - "Version", - "Device", - "Type", - "DeviceProperty", - "DeviceMethod", - "DeviceFeedback", - "Config", - "DebugSession", - "DoNotLoadConfigOnNextBoot", - "MinimumLogLevel", - "MobileControlInfo", + 'Version', + 'Device', + 'Type', + 'DeviceProperty', + 'DeviceMethod', + 'DeviceFeedback', + 'Config', + 'DebugSession', + 'DoNotLoadConfigOnNextBoot', + 'MinimumLogLevel', + 'MobileControlInfo', + 'Secrets', ], endpoints: (builder) => ({ getPaths: builder.query({ query: ({ appId }) => ({ url: `/${appId}/api/apiPaths`, - method: "GET", + method: 'GET', }), }), - getVersions: builder.query({ query: ({ appId }) => ({ url: `/${appId}/api/versions`, - method: "GET", + method: 'GET', }), - providesTags: ["Version"], + providesTags: ['Version'], }), getInitializationExceptions: builder.query< @@ -50,24 +63,24 @@ const apiSlice = createApi({ >({ query: ({ appId }) => ({ url: `/${appId}/api/initializationExceptions`, - method: "GET", + method: 'GET', }), }), getDevices: builder.query({ query: ({ appId }) => ({ url: `/${appId}/api/devices`, - method: "GET", + method: 'GET', }), - providesTags: ["Device"], + providesTags: ['Device'], }), getTypes: builder.query({ query: ({ appId }) => ({ url: `/${appId}/api/types`, - method: "GET", + method: 'GET', }), - providesTags: ["Type"], + providesTags: ['Type'], }), getDeviceProperties: builder.query< @@ -76,9 +89,9 @@ const apiSlice = createApi({ >({ query: ({ appId, key }) => ({ url: `/${appId}/api/deviceProperties/${key}`, - method: "GET", + method: 'GET', }), - providesTags: ["DeviceProperty"], + providesTags: ['DeviceProperty'], }), getDeviceMethods: builder.query< @@ -87,7 +100,7 @@ const apiSlice = createApi({ >({ query: ({ appId, key }) => ({ url: `/${appId}/api/deviceMethods/${key}`, - method: "GET", + method: 'GET', }), }), @@ -97,9 +110,9 @@ const apiSlice = createApi({ >({ query: ({ appId, key }) => ({ url: `/${appId}/api/deviceFeedbacks/${key}`, - method: "GET", + method: 'GET', }), - providesTags: ["DeviceFeedback"], + providesTags: ['DeviceFeedback'], }), setDeviceJsonCommand: builder.mutation< @@ -113,7 +126,7 @@ const apiSlice = createApi({ >({ query: ({ appId, deviceKey, methodName, params }) => ({ url: `/${appId}/api/deviceCommands/${deviceKey}`, - method: "POST", + method: 'POST', data: { deviceKey, methodName, params }, }), }), @@ -124,16 +137,30 @@ const apiSlice = createApi({ >({ query: ({ appId }) => ({ url: `/${appId}/api/routingDevicesAndTieLines`, - method: "GET", + method: 'GET', }), }), - getConfig: builder.query({ + // No invalidatesTags: getRoutingDevicesAndTieLines provides no tags, and the authoritative + // result of a routing command arrives over the routing feedback WebSocket rather than by + // refetching. See src/store/routingCommands.ts for the wire contract. + sendRoutingCommand: builder.mutation< + RoutingCommandResponse, + { appId: string; command: RoutingCommand } + >({ + query: ({ appId, command }) => ({ + url: `/${appId}/api/${ROUTING_COMMAND_PATH}`, + method: 'POST', + data: command, + }), + }), + + getConfig: builder.query({ query: ({ appId }) => ({ url: `/${appId}/api/config`, - method: "GET", + method: 'GET', }), - providesTags: ["Config"], + providesTags: ['Config'], }), getMobileControlInfo: builder.query< @@ -142,9 +169,9 @@ const apiSlice = createApi({ >({ query: ({ appId, deviceKey }) => ({ url: `/${appId}/api/device/${deviceKey}/info`, - method: "GET", + method: 'GET', }), - providesTags: ["MobileControlInfo"], + providesTags: ['MobileControlInfo'], }), getMobileControlActionPaths: builder.query< @@ -153,14 +180,14 @@ const apiSlice = createApi({ >({ query: ({ appId, deviceKey }) => ({ url: `/${appId}/api/device/${deviceKey}/actionPaths`, - method: "GET", + method: 'GET', }), }), getDebugSession: builder.mutation({ query: ({ appId }) => ({ url: `/${appId}/api/debugSession`, - method: "GET", + method: 'GET', }), }), @@ -170,9 +197,9 @@ const apiSlice = createApi({ >({ query: ({ appId }) => ({ url: `/${appId}/api/appdebug`, - method: "GET", + method: 'GET', }), - providesTags: ["MinimumLogLevel"], + providesTags: ['MinimumLogLevel'], }), setLoginCredentials: builder.mutation< @@ -181,7 +208,7 @@ const apiSlice = createApi({ >({ query: ({ appId, username, password }) => ({ url: `/${appId}/api/login`, - method: "POST", + method: 'POST', data: { username, password }, }), }), @@ -192,16 +219,16 @@ const apiSlice = createApi({ >({ query: ({ appId, minimumLevel }) => ({ url: `/${appId}/api/appdebug`, - method: "POST", + method: 'POST', data: { minimumLevel }, }), - invalidatesTags: ["MinimumLogLevel"], + invalidatesTags: ['MinimumLogLevel'], }), stopDebugSession: builder.mutation({ query: ({ appId }) => ({ url: `/${appId}/api/debugSession`, - method: "POST", + method: 'POST', }), }), @@ -211,9 +238,9 @@ const apiSlice = createApi({ >({ query: ({ appId }) => ({ url: `/${appId}/api/doNotLoadConfigOnNextBoot`, - method: "GET", + method: 'GET', }), - providesTags: ["DoNotLoadConfigOnNextBoot"], + providesTags: ['DoNotLoadConfigOnNextBoot'], }), setDoNotLoadConfigOnNextBoot: builder.mutation< @@ -222,36 +249,36 @@ const apiSlice = createApi({ >({ query: ({ appId, doNotLoadConfigOnNextBoot }) => ({ url: `/${appId}/api/doNotLoadConfigOnNextBoot`, - method: "POST", + method: 'POST', data: { doNotLoadConfigOnNextBoot }, }), - invalidatesTags: ["DoNotLoadConfigOnNextBoot"], + invalidatesTags: ['DoNotLoadConfigOnNextBoot'], }), setRestart: builder.mutation({ query: ({ appId }) => ({ url: `/${appId}/api/restartProgram`, - method: "POST", + method: 'POST', }), }), setLoadConfig: builder.mutation({ query: ({ appId }) => ({ url: `/${appId}/api/loadConfig`, - method: "POST", + method: 'POST', }), }), createMobileControlUiClient: builder.mutation< ClientResponse, - { appId: string; deviceKey: string, request: ClientRequest } + { appId: string; deviceKey: string; request: ClientRequest } >({ query: ({ appId, deviceKey, request }) => ({ url: `/${appId}/api/device/${deviceKey}/client`, - method: "POST", + method: 'POST', data: request, }), - invalidatesTags: ["MobileControlInfo"], + invalidatesTags: ['MobileControlInfo'], }), deleteMobileControlUiClient: builder.mutation< @@ -260,10 +287,10 @@ const apiSlice = createApi({ >({ query: ({ appId, deviceKey, client }) => ({ url: `/${appId}/api/device/${deviceKey}/client`, - method: "DELETE", + method: 'DELETE', data: client, }), - invalidatesTags: ["MobileControlInfo"], + invalidatesTags: ['MobileControlInfo'], }), deleteAllMobileControlUiClients: builder.mutation< @@ -272,9 +299,79 @@ const apiSlice = createApi({ >({ query: ({ appId, deviceKey }) => ({ url: `/${appId}/api/device/${deviceKey}/deleteAllUiClients`, - method: "DELETE", + method: 'DELETE', + }), + invalidatesTags: ['MobileControlInfo'], + }), + + // ─── Secrets ───────────────────────────────────────────────────────────── + // No endpoint here ever returns a secret value; see src/store/secretsContract.ts. + // + // SECURITY: RTK Query retains a mutation's arguments under + // state.api.mutations[requestId].originalArgs, which is visible in Redux DevTools. For + // sendSecretCommand and applyBulkSecrets those arguments contain plaintext values, so every + // call site must call the mutation's reset() once the request settles. + + getSecretProviders: builder.query< + SecretsProvidersResponse, + { appId: string } + >({ + query: ({ appId }) => ({ + url: `/${appId}/api/${SECRETS_PROVIDERS_PATH}`, + method: 'GET', + }), + }), + + getSecrets: builder.query< + SecretsListResponse, + { appId: string; provider: string } + >({ + query: ({ appId, provider }) => ({ + url: `/${appId}/api/${SECRETS_PATH}`, + method: 'GET', + params: { provider }, }), - invalidatesTags: ["MobileControlInfo"], + providesTags: ['Secrets'], + }), + + sendSecretCommand: builder.mutation< + SecretCommandResponse, + { appId: string; request: SecretCommandRequest } + >({ + query: ({ appId, request }) => ({ + url: `/${appId}/api/${SECRETS_COMMAND_PATH}`, + method: 'POST', + data: request, + }), + invalidatesTags: ['Secrets'], + }), + + applyBulkSecrets: builder.mutation< + BulkSecretsResponse, + { appId: string; request: BulkSecretsRequest } + >({ + query: ({ appId, request }) => ({ + url: `/${appId}/api/${SECRETS_BULK_PATH}`, + method: 'POST', + data: request, + }), + // A preview writes nothing, so re-fetching the list after one would be a wasted round trip + // and would churn the table while the user is reading the preview. + invalidatesTags: (_result, _error, arg) => + arg.request.mode === 'commit' ? ['Secrets'] : [], + }), + + getSecretsTemplate: builder.query< + SecretsTemplateResponse, + { appId: string; provider: string } + >({ + query: ({ appId, provider }) => ({ + url: `/${appId}/api/${SECRETS_TEMPLATE_PATH}`, + method: 'GET', + params: { provider }, + }), + // No providesTags: the template is derived on demand for a download, so re-deriving it after + // an unrelated mutation would be work nobody asked for. }), }), }); @@ -301,10 +398,16 @@ export const { useGetMinimumLogLevelQuery, useSetMinimumLogLevelMutation, useGetRoutingDevicesAndTieLinesQuery, + useSendRoutingCommandMutation, useCreateMobileControlUiClientMutation, useDeleteMobileControlUiClientMutation, useDeleteAllMobileControlUiClientsMutation, useSetLoginCredentialsMutation, + useGetSecretProvidersQuery, + useGetSecretsQuery, + useSendSecretCommandMutation, + useApplyBulkSecretsMutation, + useLazyGetSecretsTemplateQuery, } = apiSlice; export const oneSliceToRuleThemAll = { @@ -459,15 +562,130 @@ export interface TieLine { isInternal: boolean; } +export interface RouteSwitchStepInfo { + switchingDeviceKey: string; + inputPortKey: string; + outputPortKey: string; +} + +export interface ActiveRouteInfo { + sourceDeviceKey: string; + destinationDeviceKey: string; + destinationInputPortKey: string; + steps: RouteSwitchStepInfo[]; +} + +export interface CurrentRouteGroupInfo { + signalType: string; + routes: ActiveRouteInfo[]; +} + +export interface SinkCurrentSourceInfo { + deviceKey: string; + inputPortKey: string; + sourceDeviceKey: string; + signalType: string; +} + export interface RoutingDevicesAndTieLines { devices: RoutingDevice[]; tieLines: TieLine[]; + currentRoutes: CurrentRouteGroupInfo[]; + // Current source per sink device, read directly from each sink's own current-source + // bookkeeping. Covers routes made via device-specific bulk APIs (e.g. dynamic multiview + // layouts) that currentRoutes does not, since those never create a RouteDescriptor/TieLine. + sinkCurrentSources: SinkCurrentSourceInfo[]; + // Current multiview canvas/tile layout for every device implementing + // IRoutingSinkWithLayoutState, keyed by device key. Devices with no currently active layout + // are omitted. Lets the UI render an initial layout mock-up without waiting on the routing + // feedback WebSocket. + multiviewLayouts?: Record; +} + +// ─── Multiview layout/tile mock-up state ───────────────────────────────────── + +/** + * Describes a single tile/window within a MultiviewLayoutState. Geometry (x/y/width/height) is + * expressed in pixels within the same coordinate space as the parent's canvasWidth/canvasHeight. + */ +export interface MultiviewTileState { + tileNumber: number; + tileSinkKey: string; + x: number; + y: number; + width: number; + height: number; + zOrder: number; + sourceDeviceKey: string | null; +} + +/** + * Describes the current shape of a multiview canvas and every visible tile within it - a + * product-agnostic, JSON-serializable snapshot of what is actually displayed on the monitor fed + * by a multiview-capable decoder. + */ +export interface MultiviewLayoutState { + canvasWidth: number; + canvasHeight: number; + tiles: MultiviewTileState[]; } +// ─── Live routing feedback (WebSocket) types ───────────────────────────────── + +export interface MidpointRoute { + inputPortKey: string; + outputPortKey: string; + signalType: string; +} + +export interface SinkRoute { + inputPortKey: string; + // Null/empty when the route feeding this input has been cleared. The feedback slice removes such + // entries rather than storing them, so a SinkRoute held in state always has a real source. + sourceDeviceKey: string | null; + signalType: string; +} + +export interface RoutingSnapshotMessage { + type: 'snapshot'; + midpointRoutes: Record; + // A device implementing IRoutingSinkWithLayouts (e.g. a multiview decoder) can have multiple + // simultaneous tile routes reported under its one device key, so this is a list per device + // rather than a single route. + sinkRoutes: Record; + // Current multiview canvas/tile layout for every device implementing + // IRoutingSinkWithLayoutState, keyed by device key. + layouts: Record; +} + +export interface MidpointRouteChangedMessage { + type: 'midpointRouteChanged'; + deviceKey: string; + routes: MidpointRoute[]; +} + +export interface SinkInputChangedMessage { + type: 'sinkInputChanged'; + deviceKey: string; + inputPortKey: string; + // Null/empty means the route feeding this input was cleared - the processor raises this from + // ICurrentSources.CurrentSourcesChanged, since clearing a route never calls ExecuteSwitch on the + // sink itself and so fires no InputChanged. + sourceDeviceKey: string | null; + signalType: string; +} + +export interface LayoutChangedMessage { + type: 'layoutChanged'; + deviceKey: string; + layout: MultiviewLayoutState; +} + +export type RoutingFeedbackMessage = + | RoutingSnapshotMessage + | MidpointRouteChangedMessage + | SinkInputChangedMessage + | LayoutChangedMessage; + export type LogEventLevel = - | "Verbose" - | "Debug" - | "Information" - | "Warning" - | "Error" - | "Fatal"; + 'Verbose' | 'Debug' | 'Information' | 'Warning' | 'Error' | 'Fatal'; diff --git a/src/store/commonUi/commonUiHooks.ts b/src/store/commonUi/commonUiHooks.ts index 8285771..1ebed75 100644 --- a/src/store/commonUi/commonUiHooks.ts +++ b/src/store/commonUi/commonUiHooks.ts @@ -3,4 +3,4 @@ import { selectRoomId } from './commonUiSelectors'; export const useRoomId = () => { return useSelector(selectRoomId); -}; \ No newline at end of file +}; diff --git a/src/store/commonUi/commonUiSelectors.ts b/src/store/commonUi/commonUiSelectors.ts index 9c4ccc1..46bf8af 100644 --- a/src/store/commonUi/commonUiSelectors.ts +++ b/src/store/commonUi/commonUiSelectors.ts @@ -1,8 +1,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { RootState } from '../store'; - export const selectRoomId = createSelector( (state: RootState) => state.commonUI, (commonUI) => commonUI.roomId -); \ No newline at end of file +); diff --git a/src/store/commonUi/commonUiSlice.ts b/src/store/commonUi/commonUiSlice.ts index a1004ee..7bf5ebc 100644 --- a/src/store/commonUi/commonUiSlice.ts +++ b/src/store/commonUi/commonUiSlice.ts @@ -9,7 +9,6 @@ const commonUiSlice = createSlice({ state.roomId = action.payload; }, - // resetState: () => { // // This is here in order to provide an action name to trigger store reset // // in root store reducer @@ -18,4 +17,4 @@ const commonUiSlice = createSlice({ }); export const commonUiActions = commonUiSlice.actions; -export const commonUiReducer = commonUiSlice.reducer; \ No newline at end of file +export const commonUiReducer = commonUiSlice.reducer; diff --git a/src/store/commonUi/commonUiState.ts b/src/store/commonUi/commonUiState.ts index 260efe8..add9f8d 100644 --- a/src/store/commonUi/commonUiState.ts +++ b/src/store/commonUi/commonUiState.ts @@ -6,6 +6,5 @@ export interface CommonUiState { } export const initialCommonUiState: CommonUiState = { - roomId: '' + roomId: '', }; - diff --git a/src/store/hooks.ts b/src/store/hooks.ts index ac676f7..92a85ef 100644 --- a/src/store/hooks.ts +++ b/src/store/hooks.ts @@ -1,7 +1,7 @@ -import { useDispatch, useSelector } from 'react-redux' -import type { TypedUseSelectorHook } from 'react-redux' -import type { RootState, AppDispatch } from './store' +import { useDispatch, useSelector } from 'react-redux'; +import type { TypedUseSelectorHook } from 'react-redux'; +import type { RootState, AppDispatch } from './store'; // Use throughout your app instead of plain `useDispatch` and `useSelector` -export const useAppDispatch: () => AppDispatch = useDispatch -export const useAppSelector: TypedUseSelectorHook = useSelector \ No newline at end of file +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/store/routingCommands.test.ts b/src/store/routingCommands.test.ts new file mode 100644 index 0000000..e2bc84d --- /dev/null +++ b/src/store/routingCommands.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; + +import { + clearMidpointOutputCommand, + clearSinkCommand, + describeRoutingError, + midpointSwitchCommand, + ROUTING_COMMAND_PATH, + sinkRouteCommand, + supportsRoutingCommand, +} from './routingCommands'; + +// These assertions are deliberately literal. They are the only thing standing between a rename on +// the C# side (RoutingCommandRequest in PepperDash.Essentials.Core/Web) and a silently broken +// endpoint, since the request is never type-checked across the wire. +describe('command builders emit the exact wire shape', () => { + it('sinkRoute', () => { + expect( + sinkRouteCommand('display-1', 'hdmiIn1', 'laptop-1', 'AudioVideo') + ).toEqual({ + command: 'sinkRoute', + deviceKey: 'display-1', + inputPortKey: 'hdmiIn1', + sourceDeviceKey: 'laptop-1', + signalType: 'AudioVideo', + }); + }); + + it('sinkRoute omits sourcePortKey, letting the processor discover the path', () => { + expect( + sinkRouteCommand('display-1', 'hdmiIn1', 'laptop-1', 'Video') + ).not.toHaveProperty('sourcePortKey'); + }); + + it('sinkRoute carries a multiview-qualified port key verbatim', () => { + expect( + sinkRouteCommand('nvx-decoder-1', 'tile2:tileInput', 'cam-1', 'Video') + .inputPortKey + ).toBe('tile2:tileInput'); + }); + + it('midpointSwitch', () => { + expect( + midpointSwitchCommand( + 'dm-chassis-1', + 'inputCard3', + 'outputCard5', + 'Video' + ) + ).toEqual({ + command: 'midpointSwitch', + deviceKey: 'dm-chassis-1', + inputPortKey: 'inputCard3', + outputPortKey: 'outputCard5', + signalType: 'Video', + }); + }); + + it("clearSink also deselects the sink's own input", () => { + expect(clearSinkCommand('display-1', 'hdmiIn1')).toEqual({ + command: 'clearSink', + deviceKey: 'display-1', + inputPortKey: 'hdmiIn1', + clearSinkInput: true, + }); + }); + + it('clearMidpointOutput', () => { + expect( + clearMidpointOutputCommand('dm-chassis-1', 'outputCard5', 'AudioVideo') + ).toEqual({ + command: 'clearMidpointOutput', + deviceKey: 'dm-chassis-1', + outputPortKey: 'outputCard5', + signalType: 'AudioVideo', + }); + }); + + it('uses the agreed endpoint path', () => { + expect(ROUTING_COMMAND_PATH).toBe('routingCommand'); + }); +}); + +describe('describeRoutingError', () => { + it("prefers the endpoint's structured message", () => { + const error = { + status: 409, + data: { + status: 'error', + error: { + code: 'noRouteFound', + message: 'No path from laptop-1 to display-1 for Video.', + }, + }, + }; + expect(describeRoutingError(error)).toBe( + 'No path from laptop-1 to display-1 for Video.' + ); + }); + + it('falls back to the status code when there is no body', () => { + expect(describeRoutingError({ status: 500 })).toBe( + 'Routing command failed (500).' + ); + }); + + it('reports a transport failure plainly', () => { + expect(describeRoutingError({ status: 'FETCH_ERROR' })).toBe( + 'Could not reach the processor.' + ); + expect(describeRoutingError(undefined)).toBe( + 'Could not reach the processor.' + ); + }); +}); + +describe('supportsRoutingCommand', () => { + const routes = (...urls: string[]) => urls.map((Url) => ({ Url })); + + it('detects the routing command route', () => { + expect( + supportsRoutingCommand(routes('/cws/app01/api/routingCommand')) + ).toBe(true); + expect(supportsRoutingCommand(routes('/api/routingCommand/'))).toBe(true); + expect(supportsRoutingCommand(routes('routingCommand'))).toBe(true); + }); + + it('is case-insensitive', () => { + expect(supportsRoutingCommand(routes('/api/routingcommand'))).toBe(true); + }); + + it('is not fooled by an unrelated route containing the substring', () => { + expect(supportsRoutingCommand(routes('/api/notRoutingCommand'))).toBe( + false + ); + expect(supportsRoutingCommand(routes('/api/routingCommands'))).toBe(false); + expect(supportsRoutingCommand(routes('/api/routingCommandLog'))).toBe( + false + ); + }); + + it('is false with no route table', () => { + expect(supportsRoutingCommand(undefined)).toBe(false); + expect(supportsRoutingCommand([])).toBe(false); + expect(supportsRoutingCommand([{}])).toBe(false); + }); +}); diff --git a/src/store/routingCommands.ts b/src/store/routingCommands.ts new file mode 100644 index 0000000..b750d5d --- /dev/null +++ b/src/store/routingCommands.ts @@ -0,0 +1,230 @@ +/** + * The wire contract for `POST /cws/{appId}/api/routingCommand`. + * + * Everything the backend contract touches lives in this one file, so a shape change on the C# + * side (RoutingCommandRequestHandler / RoutingCommandExecutor in PepperDash.Essentials.Core/Web) + * is a single-file edit here rather than a hunt through components. + * + * Ports are addressed by KEY, never by selector: `RoutingPort.Selector` is a driver-defined + * `object` that cannot cross JSON, so the server resolves key -> RoutingPort -> Selector itself. + */ + +/** Path segment appended after `/{appId}/api/`. Also probed to detect endpoint support. */ +export const ROUTING_COMMAND_PATH = 'routingCommand'; + +/** + * Route a source device to a sink input, switching every midpoint along the discovered path and + * the sink's own input. `inputPortKey` may be multiview-qualified ("tile2:tileInput"); the server + * de-qualifies it back to the child tile sink. + * + * `sourcePortKey` is intentionally unset by this app: with no source port the backend's + * `GetRouteToSource` picks one, and the UI has no basis for choosing among a source's outputs. + */ +export interface SinkRouteCommand { + command: 'sinkRoute'; + deviceKey: string; + inputPortKey?: string; + sourceDeviceKey: string; + sourcePortKey?: string; + signalType: string; + dryRun?: boolean; +} + +/** Switch a single midpoint, input to output. No upstream or downstream routing. */ +export interface MidpointSwitchCommand { + command: 'midpointSwitch'; + deviceKey: string; + inputPortKey: string; + outputPortKey: string; + signalType: string; + dryRun?: boolean; +} + +/** + * Tear down the route feeding a sink input. Omit `inputPortKey` to clear whatever the sink + * currently has. + * + * `clearSinkInput` additionally deselects the sink's own input: a clear tears down the midpoints + * in the path but never touches the destination, so without it a cleared display stays showing its + * last input. + */ +export interface ClearSinkCommand { + command: 'clearSink'; + deviceKey: string; + inputPortKey?: string; + /** Stop usage tracking but leave the signal flowing, rather than tearing the path down. */ + releaseOnly?: boolean; + clearSinkInput?: boolean; + dryRun?: boolean; +} + +/** Clear one output port on a midpoint. */ +export interface ClearMidpointOutputCommand { + command: 'clearMidpointOutput'; + deviceKey: string; + outputPortKey: string; + signalType: string; + dryRun?: boolean; +} + +export type RoutingCommand = + | SinkRouteCommand + | MidpointSwitchCommand + | ClearSinkCommand + | ClearMidpointOutputCommand; + +/** One switch in a discovered route path. Mirrors the read API's `RouteSwitchStepInfo`. */ +export interface RoutingCommandStep { + /** Audio or Video - an AudioVideo route is discovered as two independent paths. */ + signalType: string; + switchingDeviceKey?: string; + inputPortKey?: string; + /** Absent on the final step onto a sink, which has no output port. */ + outputPortKey?: string; +} + +export interface RoutingCommandError { + code: RoutingCommandErrorCode; + message: string; + /** The offending request field, where one applies. */ + field?: string; +} + +/** + * Stable error codes. The status code says how to react: 404 means the key is wrong, 422 means the + * keys exist but the request is impossible on that device, 409 means the keys are fine but the + * system's wiring cannot satisfy it. + */ +export type RoutingCommandErrorCode = + | 'invalidJson' + | 'missingField' + | 'unknownCommand' + | 'invalidSignalType' + | 'deviceNotFound' + | 'deviceNotRoutable' + | 'tileNotFound' + | 'portNotFound' + | 'signalTypeNotSupportedByPort' + | 'noRouteFound' + | 'executionError'; + +export interface RoutingCommandResponse { + /** + * "executed" - done by the time the response was written (midpoint commands run inline). + * "accepted" - validated and enqueued on the processor's serialized routing queue; confirmation + * arrives over the routing feedback WebSocket, not here. + * "validated" - dry run. + */ + status: 'executed' | 'accepted' | 'validated' | 'error'; + command?: RoutingCommand['command']; + deviceKey?: string; + /** Differs from `deviceKey` only when a "tile{N}:" port was de-qualified to its child sink. */ + resolvedDeviceKey?: string; + resolvedInputPortKey?: string; + resolvedOutputPortKey?: string; + signalType?: string; + /** + * What was actually handed to the devices. Can be WIDER than the request: a pre-mapped route + * descriptor is built from the port's declared type, so an Audio-only request across + * all-AudioVideo ports executes as AudioVideo rather than breaking away. + */ + effectiveSignalType?: string; + steps?: RoutingCommandStep[]; + /** An AudioVideo request that found a path for only one half. The found half is still routed. */ + partial?: boolean; + error?: RoutingCommandError; +} + +// ─── Builders ──────────────────────────────────────────────────────────────── + +export function sinkRouteCommand( + deviceKey: string, + inputPortKey: string, + sourceDeviceKey: string, + signalType: string +): SinkRouteCommand { + return { + command: 'sinkRoute', + deviceKey, + inputPortKey, + sourceDeviceKey, + signalType, + }; +} + +export function midpointSwitchCommand( + deviceKey: string, + inputPortKey: string, + outputPortKey: string, + signalType: string +): MidpointSwitchCommand { + return { + command: 'midpointSwitch', + deviceKey, + inputPortKey, + outputPortKey, + signalType, + }; +} + +export function clearSinkCommand( + deviceKey: string, + inputPortKey: string +): ClearSinkCommand { + // Always deselect the destination's own input too - a user clearing a route expects the display + // to stop showing the old source, not just for the path behind it to be released. + return { + command: 'clearSink', + deviceKey, + inputPortKey, + clearSinkInput: true, + }; +} + +export function clearMidpointOutputCommand( + deviceKey: string, + outputPortKey: string, + signalType: string +): ClearMidpointOutputCommand { + return { + command: 'clearMidpointOutput', + deviceKey, + outputPortKey, + signalType, + }; +} + +// ─── Capability detection ──────────────────────────────────────────────────── + +const ROUTING_COMMAND_SEGMENT = new RegExp( + `(^|/)${ROUTING_COMMAND_PATH}(/|$)`, + 'i' +); + +/** + * True when the processor exposes the routing command endpoint, detected from its live CWS route + * table. Matches a whole path segment, so `/api/notRoutingCommand` doesn't read as support. + */ +export function supportsRoutingCommand(routes?: { Url?: string }[]): boolean { + if (!routes) return false; + return routes.some((route) => ROUTING_COMMAND_SEGMENT.test(route.Url ?? '')); +} + +// ─── Error rendering ───────────────────────────────────────────────────────── + +/** + * Turns whatever RTK Query surfaced into a sentence worth showing in the popover. + * + * `axiosBaseQuery` maps a non-2xx response to `{ status, data }`, so the endpoint's structured + * error body is at `error.data.error`. + */ +export function describeRoutingError(error: unknown): string { + const data = (error as { data?: RoutingCommandResponse })?.data; + if (data?.error?.message) return data.error.message; + + const status = (error as { status?: number | string })?.status; + if (status === 'FETCH_ERROR' || status === undefined) { + return 'Could not reach the processor.'; + } + return `Routing command failed (${status}).`; +} diff --git a/src/store/routingFeedbackMiddleware.test.ts b/src/store/routingFeedbackMiddleware.test.ts new file mode 100644 index 0000000..bec748e --- /dev/null +++ b/src/store/routingFeedbackMiddleware.test.ts @@ -0,0 +1,115 @@ +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { routingFeedbackMiddleware } from './routingFeedbackMiddleware'; +import routingFeedbackReducer, { + ROUTING_WS_CONNECT, +} from './routingFeedbackSlice'; + +// Minimal stand-in for the browser WebSocket; tests drive its events directly +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onerror: ((e: unknown) => void) | null = null; + onmessage: ((e: { data: string }) => void) | null = null; + closed = false; + + constructor(public url: string) { + FakeWebSocket.instances.push(this); + } + + close() { + this.closed = true; + } +} + +const makeStore = () => + configureStore({ + reducer: combineReducers({ routingFeedback: routingFeedbackReducer }), + middleware: (getDefault) => getDefault().concat(routingFeedbackMiddleware), + }); + +const connect = ( + store: ReturnType, + url: string, + fallbackUrl?: string +) => + store.dispatch({ type: ROUTING_WS_CONNECT, payload: { url, fallbackUrl } }); + +describe('routingFeedbackMiddleware', () => { + beforeEach(() => { + FakeWebSocket.instances = []; + vi.useFakeTimers(); + vi.stubGlobal('WebSocket', FakeWebSocket); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('ignores callbacks queued for a replaced socket', () => { + const store = makeStore(); + connect(store, 'ws://old'); + // Capture the first socket's handlers before the reconnect detaches them, + // as if their events were already queued + const stale = FakeWebSocket.instances[0]; + const { onclose, onerror, onmessage } = stale; + + connect(store, 'ws://new'); + const live = FakeWebSocket.instances[1]; + live.onopen?.(); + + onmessage?.call(stale, { + data: JSON.stringify({ type: 'midpointRouteChanged', deviceKey: 'x' }), + }); + onerror?.call(stale, new Event('error')); + onclose?.call(stale); + vi.runAllTimers(); + + expect(store.getState().routingFeedback.connected).toBe(true); + expect(store.getState().routingFeedback.midpointRoutes).toEqual({}); + // No reconnect was scheduled, so no third socket + expect(FakeWebSocket.instances).toHaveLength(2); + expect(live.closed).toBe(false); + }); + + it('does not let the failed primary tear down its fallback', () => { + const store = makeStore(); + connect(store, 'ws://primary', 'ws://fallback'); + const primary = FakeWebSocket.instances[0]; + const { onclose } = primary; + + primary.onerror?.(new Event('error')); + const fallback = FakeWebSocket.instances[1]; + expect(fallback.url).toBe('ws://fallback'); + fallback.onopen?.(); + + // A real socket fires close after error; this one is already queued + onclose?.call(primary); + vi.runAllTimers(); + + expect(store.getState().routingFeedback.connected).toBe(true); + expect(FakeWebSocket.instances).toHaveLength(2); + }); + + it('reports the disconnect when an open socket errors over to the fallback', () => { + const store = makeStore(); + connect(store, 'ws://primary', 'ws://fallback'); + const primary = FakeWebSocket.instances[0]; + primary.onopen?.(); + expect(store.getState().routingFeedback.connected).toBe(true); + + primary.onerror?.(new Event('error')); + + // Fallback is still connecting, so the badge must not read Live + expect(FakeWebSocket.instances[1].url).toBe('ws://fallback'); + expect(store.getState().routingFeedback.connected).toBe(false); + + FakeWebSocket.instances[1].onopen?.(); + expect(store.getState().routingFeedback.connected).toBe(true); + }); +}); diff --git a/src/store/routingFeedbackMiddleware.ts b/src/store/routingFeedbackMiddleware.ts new file mode 100644 index 0000000..9a429c5 --- /dev/null +++ b/src/store/routingFeedbackMiddleware.ts @@ -0,0 +1,155 @@ +import { Middleware } from '@reduxjs/toolkit'; +import { RoutingFeedbackMessage } from './apiSlice'; +import { + layoutChanged, + midpointRouteChanged, + ROUTING_WS_CONNECT, + ROUTING_WS_DISCONNECT, + routingFeedbackReset, + routingSnapshotReceived, + RoutingWsConnectAction, + routingWsConnected, + routingWsConnectionFailed, + RoutingWsDisconnectAction, + routingWsDisconnected, + sinkInputChanged, +} from './routingFeedbackSlice'; + +const RECONNECT_DELAY_MS = 3000; +const MAX_RECONNECT_ATTEMPTS = 5; + +export const routingFeedbackMiddleware: Middleware = (store) => { + let socket: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let reconnectAttempts = 0; + let currentUrl: string | null = null; + let fallbackUrl: string | null = null; + let attemptedUrls: string[] = []; + + function cleanup() { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (socket) { + socket.onopen = null; + socket.onclose = null; + socket.onerror = null; + socket.onmessage = null; + socket.close(); + socket = null; + } + reconnectAttempts = 0; + currentUrl = null; + fallbackUrl = null; + attemptedUrls = []; + } + + function connectToUrl(url: string, fallback?: string) { + if (socket) { + socket.onopen = null; + socket.onclose = null; + socket.onerror = null; + socket.onmessage = null; + socket.close(); + socket = null; + } + + currentUrl = url; + if (!attemptedUrls.includes(url)) attemptedUrls.push(url); + const ws = new WebSocket(url); + socket = ws; + + // Every handler checks it still owns `socket`, so a callback already queued + // for a replaced connection can't clobber the new one + ws.onopen = () => { + if (socket !== ws) return; + reconnectAttempts = 0; + attemptedUrls = []; + store.dispatch(routingWsConnected()); + }; + + ws.onclose = () => { + if (socket !== ws) return; + store.dispatch(routingWsDisconnected()); + socket = null; + attemptReconnect(); + }; + + ws.onerror = (err) => { + if (socket !== ws) return; + console.error('[routing-ws] WebSocket error', err); + if (fallback) { + console.log( + '[routing-ws] Primary connection failed, falling back to', + fallback + ); + // connectToUrl detaches this socket's onclose, which is otherwise the only place the + // disconnect is reported - so a socket that was already open would leave the badge Live + store.dispatch(routingWsDisconnected()); + connectToUrl(fallback); + } + }; + + ws.onmessage = (event: MessageEvent) => { + if (socket !== ws) return; + try { + const msg = JSON.parse(event.data) as RoutingFeedbackMessage; + switch (msg.type) { + case 'snapshot': + store.dispatch(routingSnapshotReceived(msg)); + break; + case 'midpointRouteChanged': + store.dispatch(midpointRouteChanged(msg)); + break; + case 'sinkInputChanged': + store.dispatch(sinkInputChanged(msg)); + break; + case 'layoutChanged': + store.dispatch(layoutChanged(msg)); + break; + } + } catch (e) { + console.error('[routing-ws] Failed to parse message', e); + } + }; + } + + function attemptReconnect() { + if (!currentUrl) return; + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + store.dispatch(routingWsConnectionFailed([...attemptedUrls])); + return; + } + reconnectAttempts++; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + reconnectTimer = setTimeout(() => { + if (currentUrl) connectToUrl(currentUrl); + }, RECONNECT_DELAY_MS); + } + + return (next) => (action) => { + const { type } = action as + RoutingWsConnectAction | RoutingWsDisconnectAction; + + if (type === ROUTING_WS_CONNECT) { + const { url, fallbackUrl: fb } = (action as RoutingWsConnectAction) + .payload; + cleanup(); + fallbackUrl = fb ?? null; + connectToUrl(url, fallbackUrl ?? undefined); + return; + } + + if (type === ROUTING_WS_DISCONNECT) { + cleanup(); + store.dispatch(routingWsDisconnected()); + store.dispatch(routingFeedbackReset()); + return; + } + + return next(action); + }; +}; diff --git a/src/store/routingFeedbackSlice.test.ts b/src/store/routingFeedbackSlice.test.ts new file mode 100644 index 0000000..4245b7e --- /dev/null +++ b/src/store/routingFeedbackSlice.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import reducer, { sinkInputChanged } from './routingFeedbackSlice'; + +const initial = { + midpointRoutes: {}, + sinkRoutes: {}, + layouts: {}, + connected: true, + failedUrls: [], +}; + +const changed = ( + deviceKey: string, + inputPortKey: string, + sourceDeviceKey: string | null, + signalType = 'AudioVideo' +) => + sinkInputChanged({ + type: 'sinkInputChanged', + deviceKey, + inputPortKey, + sourceDeviceKey, + signalType, + }); + +describe('sinkInputChanged', () => { + it('adds a route for a newly routed input', () => { + const state = reducer(initial, changed('display-1', 'hdmiIn1', 'laptop-1')); + expect(state.sinkRoutes['display-1']).toEqual([ + { + inputPortKey: 'hdmiIn1', + sourceDeviceKey: 'laptop-1', + signalType: 'AudioVideo', + }, + ]); + }); + + it('replaces the route on the same input rather than appending', () => { + let state = reducer(initial, changed('display-1', 'hdmiIn1', 'laptop-1')); + state = reducer(state, changed('display-1', 'hdmiIn1', 'bluray-1')); + expect(state.sinkRoutes['display-1']).toEqual([ + { + inputPortKey: 'hdmiIn1', + sourceDeviceKey: 'bluray-1', + signalType: 'AudioVideo', + }, + ]); + }); + + it('keeps multiple tile routes under one multiview device key', () => { + let state = reducer(initial, changed('nvx-1', 'tile1:tileInput', 'cam-1')); + state = reducer(state, changed('nvx-1', 'tile2:tileInput', 'cam-2')); + expect(state.sinkRoutes['nvx-1']).toHaveLength(2); + }); + + // The clear path: the processor reports a cleared route via CurrentSourcesChanged with no + // source, and we must remove the entry rather than store a sourceless route. + it('removes the entry when the source is cleared', () => { + let state = reducer(initial, changed('nvx-1', 'tile1:tileInput', 'cam-1')); + state = reducer(state, changed('nvx-1', 'tile2:tileInput', 'cam-2')); + state = reducer(state, changed('nvx-1', 'tile1:tileInput', null)); + + expect(state.sinkRoutes['nvx-1']).toEqual([ + { + inputPortKey: 'tile2:tileInput', + sourceDeviceKey: 'cam-2', + signalType: 'AudioVideo', + }, + ]); + }); + + it('treats an empty-string source as cleared too', () => { + let state = reducer(initial, changed('display-1', 'hdmiIn1', 'laptop-1')); + state = reducer(state, changed('display-1', 'hdmiIn1', '')); + expect(state.sinkRoutes['display-1']).toBeUndefined(); + }); + + it('drops the device key entirely once its last route is cleared', () => { + let state = reducer(initial, changed('display-1', 'hdmiIn1', 'laptop-1')); + state = reducer(state, changed('display-1', 'hdmiIn1', null)); + expect(state.sinkRoutes).toEqual({}); + }); + + it('ignores a clear for an input that was never routed', () => { + const state = reducer(initial, changed('display-1', 'hdmiIn1', null)); + expect(state.sinkRoutes).toEqual({}); + }); +}); diff --git a/src/store/routingFeedbackSlice.ts b/src/store/routingFeedbackSlice.ts new file mode 100644 index 0000000..46fcdcb --- /dev/null +++ b/src/store/routingFeedbackSlice.ts @@ -0,0 +1,118 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { + LayoutChangedMessage, + MidpointRoute, + MidpointRouteChangedMessage, + MultiviewLayoutState, + RoutingSnapshotMessage, + SinkInputChangedMessage, + SinkRoute, +} from './apiSlice'; + +export interface RoutingFeedbackState { + midpointRoutes: Record; + // A device implementing IRoutingSinkWithLayouts (e.g. a multiview decoder) can have multiple + // simultaneous tile routes under its one device key, so this is a list per device. + sinkRoutes: Record; + // Current multiview canvas/tile layout for every device implementing + // IRoutingSinkWithLayoutState, keyed by device key. + layouts: Record; + connected: boolean; + failedUrls: string[]; +} + +const initialState: RoutingFeedbackState = { + midpointRoutes: {}, + sinkRoutes: {}, + layouts: {}, + connected: false, + failedUrls: [], +}; + +const routingFeedbackSlice = createSlice({ + name: 'routingFeedback', + initialState, + reducers: { + routingWsConnected(state) { + state.connected = true; + state.failedUrls = []; + }, + routingWsDisconnected(state) { + state.connected = false; + }, + routingWsConnectionFailed(state, action: PayloadAction) { + state.failedUrls = action.payload; + }, + routingSnapshotReceived( + state, + action: PayloadAction + ) { + state.midpointRoutes = action.payload.midpointRoutes; + state.sinkRoutes = action.payload.sinkRoutes; + state.layouts = action.payload.layouts ?? {}; + }, + midpointRouteChanged( + state, + action: PayloadAction + ) { + state.midpointRoutes[action.payload.deviceKey] = action.payload.routes; + }, + sinkInputChanged(state, action: PayloadAction) { + const { deviceKey, inputPortKey, sourceDeviceKey, signalType } = + action.payload; + const existing = state.sinkRoutes[deviceKey] ?? []; + const idx = existing.findIndex((r) => r.inputPortKey === inputPortKey); + + // An empty source means the route was cleared. Drop the entry rather than storing a + // sourceless route, so consumers can treat "present in sinkRoutes" as "actually routed" - + // the synthetic tie-line edges on the routing diagram depend on this. + if (!sourceDeviceKey) { + if (idx < 0) return; + existing.splice(idx, 1); + if (existing.length === 0) delete state.sinkRoutes[deviceKey]; + else state.sinkRoutes[deviceKey] = existing; + return; + } + + const updated: SinkRoute = { inputPortKey, sourceDeviceKey, signalType }; + if (idx >= 0) { + existing[idx] = updated; + } else { + existing.push(updated); + } + state.sinkRoutes[deviceKey] = existing; + }, + layoutChanged(state, action: PayloadAction) { + state.layouts[action.payload.deviceKey] = action.payload.layout; + }, + routingFeedbackReset() { + return initialState; + }, + }, +}); + +export const { + routingWsConnected, + routingWsDisconnected, + routingWsConnectionFailed, + routingSnapshotReceived, + midpointRouteChanged, + sinkInputChanged, + layoutChanged, + routingFeedbackReset, +} = routingFeedbackSlice.actions; + +export default routingFeedbackSlice.reducer; + +// ── Action type constants used by the middleware ───────────────────────────── +export const ROUTING_WS_CONNECT = 'routingFeedback/wsConnect'; +export const ROUTING_WS_DISCONNECT = 'routingFeedback/wsDisconnect'; + +export interface RoutingWsConnectAction { + type: typeof ROUTING_WS_CONNECT; + payload: { url: string; fallbackUrl?: string }; +} + +export interface RoutingWsDisconnectAction { + type: typeof ROUTING_WS_DISCONNECT; +} diff --git a/src/store/secretsContract.test.ts b/src/store/secretsContract.test.ts new file mode 100644 index 0000000..135f09b --- /dev/null +++ b/src/store/secretsContract.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest'; + +import { + bulkSecretsRequest, + deleteSecretCommand, + describeSecretsError, + DOCUMENTED_MAX_SECRET_KEY_LENGTH, + MAX_SECRET_VALUE_LENGTH, + pruneIndexCommand, + rebuildIndexCommand, + RESERVED_KEY_PREFIX, + SECRETS_BULK_PATH, + SECRETS_COMMAND_PATH, + SECRETS_PATH, + SECRETS_TEMPLATE_PATH, + setSecretCommand, + supportsSecretsApi, + testSecretCommand, + updateSecretCommand, +} from './secretsContract'; + +// These assertions are deliberately literal. They are the only thing standing between a rename on +// the C# side (SecretsApiContracts.cs) and a silently broken endpoint, since request bodies are +// never type-checked across the wire. +describe('command builders emit the exact wire shape', () => { + it('set', () => { + expect(setSecretCommand('default', 'displayPassword', 'hunter2')).toEqual({ + action: 'set', + provider: 'default', + key: 'displayPassword', + value: 'hunter2', + }); + }); + + it('set with description and overwrite', () => { + expect( + setSecretCommand('default', 'displayPassword', 'hunter2', { + description: 'Display admin', + overwrite: true, + }) + ).toEqual({ + action: 'set', + provider: 'default', + key: 'displayPassword', + value: 'hunter2', + description: 'Display admin', + overwrite: true, + }); + }); + + it('omits optional fields rather than sending null or false', () => { + const request = setSecretCommand('default', 'k', 'v', { overwrite: false }); + expect(request).not.toHaveProperty('overwrite'); + expect(request).not.toHaveProperty('description'); + }); + + it('update', () => { + expect(updateSecretCommand('default', 'k', 'v', 'note')).toEqual({ + action: 'update', + provider: 'default', + key: 'k', + value: 'v', + description: 'note', + }); + }); + + it('delete carries no value', () => { + const request = deleteSecretCommand('default', 'k'); + expect(request).toEqual({ + action: 'delete', + provider: 'default', + key: 'k', + }); + expect(request).not.toHaveProperty('value'); + }); + + it('test carries no value', () => { + const request = testSecretCommand('CrestronGlobalSecrets', 'k'); + expect(request).toEqual({ + action: 'test', + provider: 'CrestronGlobalSecrets', + key: 'k', + }); + expect(request).not.toHaveProperty('value'); + }); + + it('pruneIndex', () => { + expect(pruneIndexCommand('default')).toEqual({ + action: 'pruneIndex', + provider: 'default', + }); + }); + + it('rebuildIndex, with and without adoptKeys', () => { + expect(rebuildIndexCommand('default')).toEqual({ + action: 'rebuildIndex', + provider: 'default', + }); + expect(rebuildIndexCommand('default', ['a', 'b'])).toEqual({ + action: 'rebuildIndex', + provider: 'default', + adoptKeys: ['a', 'b'], + }); + // An empty array would read as "adopt nothing" but is indistinguishable from a bug; omit it. + expect(rebuildIndexCommand('default', [])).not.toHaveProperty('adoptKeys'); + }); +}); + +describe('bulk request builder', () => { + const entries = [ + { key: 'a', value: '1' }, + { key: 'b', value: '2' }, + ]; + + it('builds a preview request', () => { + expect( + bulkSecretsRequest('default', entries, { + mode: 'preview', + overwrite: false, + }) + ).toEqual({ + mode: 'preview', + provider: 'default', + overwrite: false, + secrets: entries, + }); + }); + + it('carries overwrite and the unmanaged acknowledgement on commit', () => { + expect( + bulkSecretsRequest('default', entries, { + mode: 'commit', + overwrite: true, + allowUnmanagedOverwrite: true, + }) + ).toEqual({ + mode: 'commit', + provider: 'default', + overwrite: true, + allowUnmanagedOverwrite: true, + secrets: entries, + }); + }); + + it('sends overwrite:false explicitly rather than omitting it', () => { + // Omitting it would rely on the server's default; being explicit means a server-side default + // change can never silently start overwriting credentials. + const request = bulkSecretsRequest('default', entries, { + mode: 'commit', + overwrite: false, + }); + expect(request.overwrite).toBe(false); + expect(request).not.toHaveProperty('allowUnmanagedOverwrite'); + }); +}); + +describe('constants match the Crestron Data Store limits', () => { + it('uses the documented caps', () => { + expect(DOCUMENTED_MAX_SECRET_KEY_LENGTH).toBe(32); + expect(MAX_SECRET_VALUE_LENGTH).toBe(1600); + }); + + it('uses the agreed endpoint paths and reserved prefix', () => { + expect(SECRETS_PATH).toBe('secrets'); + expect(SECRETS_COMMAND_PATH).toBe('secrets/command'); + expect(SECRETS_BULK_PATH).toBe('secrets/bulk'); + expect(SECRETS_TEMPLATE_PATH).toBe('secrets/template'); + expect(RESERVED_KEY_PREFIX).toBe('__essSecretsIdx'); + }); +}); + +describe('supportsSecretsApi', () => { + const routes = (...urls: string[]) => urls.map((Url) => ({ Url })); + + it('detects the secrets routes', () => { + expect(supportsSecretsApi(routes('/cws/app01/api/secrets'))).toBe(true); + expect(supportsSecretsApi(routes('/api/secrets/bulk'))).toBe(true); + expect(supportsSecretsApi(routes('secrets'))).toBe(true); + }); + + it('is case-insensitive', () => { + expect(supportsSecretsApi(routes('/api/Secrets/Command'))).toBe(true); + }); + + // The reason this matches a whole path segment instead of using .includes() + it('is not fooled by an unrelated route containing the substring', () => { + expect(supportsSecretsApi(routes('/api/mysecretsthing'))).toBe(false); + expect(supportsSecretsApi(routes('/api/secretsmanager'))).toBe(false); + expect(supportsSecretsApi(routes('/api/devicesecrets'))).toBe(false); + }); + + it('returns false for an older processor and for an unresolved probe', () => { + expect(supportsSecretsApi(routes('/api/devices', '/api/versions'))).toBe( + false + ); + expect(supportsSecretsApi([])).toBe(false); + expect(supportsSecretsApi(undefined)).toBe(false); + }); + + it('tolerates a route with no Url', () => { + expect(supportsSecretsApi([{}])).toBe(false); + }); +}); + +describe('describeSecretsError', () => { + it("prefers the endpoint's structured message", () => { + expect( + describeSecretsError({ + status: 409, + data: { + status: 'error', + error: { code: 'alreadyExists', message: 'Key already exists.' }, + }, + }) + ).toBe('Key already exists.'); + }); + + it('falls back to the status code when there is no body', () => { + expect(describeSecretsError({ status: 500 })).toBe( + 'Secrets request failed (500).' + ); + }); + + it('reports a transport failure plainly', () => { + expect(describeSecretsError({ status: 'FETCH_ERROR' })).toBe( + 'Could not reach the processor.' + ); + expect(describeSecretsError(undefined)).toBe( + 'Could not reach the processor.' + ); + }); + + // SECURITY: the describer reads only the response body. A submitted value living on the error + // object elsewhere must never reach the rendered string. + it('never echoes a submitted value back into the message', () => { + const message = describeSecretsError({ + status: 400, + data: { + status: 'error', + error: { code: 'valueTooLong', message: 'Value is too long.' }, + }, + config: { data: JSON.stringify({ value: 'hunter2' }) }, + }); + expect(message).not.toContain('hunter2'); + expect(message).toBe('Value is too long.'); + }); +}); diff --git a/src/store/secretsContract.ts b/src/store/secretsContract.ts new file mode 100644 index 0000000..0efdc32 --- /dev/null +++ b/src/store/secretsContract.ts @@ -0,0 +1,371 @@ +/** + * The wire contract for the Essentials secrets API. + * + * Owned on the C# side by PepperDash.Essentials.Core/Web/SecretsApiContracts.cs and the five + * Secrets*RequestHandler classes. Everything the backend shape touches lives in this one file, so a + * rename over there is a single-file edit here rather than a hunt through components. + * + * Two invariants hold across every endpoint: + * + * 1. **No response ever carries a secret value.** The processor is write-only for values - a + * forgotten credential must be re-entered, never recovered. None of the response types below + * have a value field, and that is deliberate rather than incidental. + * 2. **Mutations are POST, never DELETE.** WebApiBaseRequestHandler advertises only + * `POST, GET, OPTIONS` and its HandleOptions returns 501, so a cross-origin DELETE fails + * preflight. Deletion is `secrets/command` with `action: "delete"`. + */ + +// ─── Endpoint paths ────────────────────────────────────────────────────────── +// Appended after `/{appId}/api/`. SECRETS_PATH is also what the capability probe looks for. + +export const SECRETS_PATH = 'secrets'; +export const SECRETS_PROVIDERS_PATH = 'secrets/providers'; +export const SECRETS_COMMAND_PATH = 'secrets/command'; +export const SECRETS_BULK_PATH = 'secrets/bulk'; +export const SECRETS_TEMPLATE_PATH = 'secrets/template'; + +// ─── Crestron Data Store limits ────────────────────────────────────────────── + +/** + * The key length the Crestron SDK documents for CDS_NAME_TOO_BIG. + * + * ADVISORY, NOT ENFORCED. Records with longer names demonstrably exist - Mobile Control's + * `7:mobileControl-directServer-tokens` is 35 characters and was written through the same store + * API. Treating 32 as a hard limit would refuse keys the processor accepts, and would make an + * existing record impossible to delete. The processor is the authority: it reports `keyTooLong` if + * it actually objects. Use this to warn, never to block. + */ +export const DOCUMENTED_MAX_SECRET_KEY_LENGTH = 32; + +/** Value length cap from CDS_STRING_TOO_BIG. No counter-evidence, so still enforced client-side. */ +export const MAX_SECRET_VALUE_LENGTH = 1600; + +/** Records the API uses for its own bookkeeping. Never writable, never listed by default. */ +export const RESERVED_KEY_PREFIX = '__essSecretsIdx'; + +// ─── Providers ─────────────────────────────────────────────────────────────── + +/** Which Crestron Data Store space a provider writes to. */ +export type SecretStoreScope = 'local' | 'global'; + +export interface SecretProviderInfo { + key: string; + description?: string; + scope: SecretStoreScope; + /** False for a provider that cannot enumerate; its secrets can be written but not listed. */ + enumerationSupported: boolean; + maxKeyLength: number; + maxValueLength: number; +} + +export interface SecretsProvidersResponse { + providers: SecretProviderInfo[]; +} + +// ─── Listing ───────────────────────────────────────────────────────────────── + +/** + * Health of the sidecar index that records which keys this API manages. + * + * The index is advisory: it classifies records, it does not store them. "missing" or any + * "corrupt:*" value means every key is reported unmanaged, which is a degraded display - never a + * broken system, and never a reason to block writes. + */ +export type SecretsIndexStatus = 'ok' | 'missing' | `corrupt:${string}`; + +export interface SecretEntry { + key: string; + /** True when the sidecar index knows about this key - i.e. it was written through this API. */ + managed: boolean; + description?: string; + createdUtc?: string; + updatedUtc?: string; + /** Last-modified straight from the Data Store record, independent of the index. */ + lastModifiedUtc?: string; + /** Creating application. Relevant in the global scope, which is shared across program slots. */ + owner?: string; + /** True for a key the index lists that no longer exists in the store. */ + stale?: boolean; + /** True for the API's own index records; only present when explicitly requested. */ + reserved?: boolean; +} + +export interface StaleIndexEntry { + key: string; + description?: string; + createdUtc?: string; +} + +export interface SecretsCounts { + total: number; + managed: number; + unmanaged: number; + stale: number; +} + +export interface SecretsListResponse { + provider: string; + scope: SecretStoreScope; + indexStatus: SecretsIndexStatus; + /** + * False when the Data Store walk was cut short. The list is then incomplete, and the processor + * refuses to prune the index - pruning on a partial view would delete live entries. + */ + enumerationComplete: boolean; + counts: SecretsCounts; + secrets: SecretEntry[]; + staleIndexEntries?: StaleIndexEntry[]; + warnings?: string[]; + /** Present when the provider cannot enumerate, explaining why the list is empty. */ + notice?: string; +} + +// ─── Single-secret commands ────────────────────────────────────────────────── + +export type SecretCommandAction = + 'set' | 'update' | 'delete' | 'test' | 'pruneIndex' | 'rebuildIndex'; + +export interface SecretCommandRequest { + action: SecretCommandAction; + provider: string; + key?: string; + value?: string; + description?: string; + /** `set` only: replace an existing key instead of failing with `alreadyExists`. */ + overwrite?: boolean; + /** `rebuildIndex` only: adopt these existing keys as managed WITHOUT touching their values. */ + adoptKeys?: string[]; +} + +export interface SecretCommandResponse { + status: 'ok' | 'error'; + action?: SecretCommandAction; + provider?: string; + key?: string; + existedBefore?: boolean; + /** `test` only. */ + exists?: boolean; + /** False when the secret was written but the index could not be updated - not a failure. */ + indexUpdated?: boolean; + /** `rebuildIndex` only. */ + entriesRetained?: number; + warning?: string; + error?: SecretsApiError; +} + +// ─── Bulk apply ────────────────────────────────────────────────────────────── + +export interface BulkSecretEntry { + key: string; + value: string; + /** Falls back to the request's top-level provider when omitted. */ + provider?: string; + description?: string; +} + +export interface BulkSecretsRequest { + /** "preview" validates and reports without writing anything. */ + mode: 'preview' | 'commit'; + provider: string; + overwrite: boolean; + /** Permit writing over a key the index does not manage (e.g. another subsystem's record). */ + allowUnmanagedOverwrite?: boolean; + secrets: BulkSecretEntry[]; +} + +export type BulkSecretAction = + 'create' | 'overwrite' | 'skip' | 'invalid' | 'failed'; + +export interface BulkEntryResult { + index: number; + key: string; + provider: string; + action: BulkSecretAction; + /** Machine-readable cause for `skip`, `invalid` and `failed`. */ + reason?: string; + message?: string; + /** Always false in preview mode. */ + applied: boolean; +} + +export interface BulkSummary { + total: number; + create: number; + overwrite: number; + skip: number; + invalid: number; + failed: number; +} + +export interface BulkSecretsResponse { + mode: 'preview' | 'commit'; + provider: string; + overwrite: boolean; + summary: BulkSummary; + entries: BulkEntryResult[]; + indexUpdated?: boolean; + warnings?: string[]; + error?: SecretsApiError; +} + +// ─── Template ──────────────────────────────────────────────────────────────── + +export interface SecretsTemplateMetadata { + key: string; + provider: string; + description?: string; + managed: boolean; +} + +export interface SecretsTemplateResponse { + provider: string; + generatedUtc: string; + note?: string; + /** Flat key β†’ "" map. Fill in the values and send it back to the bulk endpoint. */ + secrets: Record; + metadata?: SecretsTemplateMetadata[]; +} + +// ─── Errors ────────────────────────────────────────────────────────────────── + +/** + * Stable codes. The status code says how to react: 400 means the request is malformed, 404 that a + * key or provider is wrong, 409 that the store's current state forbids it, 403/507/503 that the + * Data Store itself refused. + */ +export type SecretsApiErrorCode = + | 'invalidJson' + | 'missingField' + | 'unknownAction' + | 'providerNotFound' + | 'emptyKey' + | 'invalidKey' + | 'keyTooLong' + | 'reservedKey' + | 'emptyValue' + | 'valueTooLong' + | 'alreadyExists' + | 'notFound' + | 'accessDenied' + | 'storeFull' + | 'storeUnavailable' + | 'batchTooLarge' + | 'enumerationIncomplete' + | 'indexFull' + | 'executionError'; + +export interface SecretsApiError { + code: SecretsApiErrorCode; + message: string; + field?: string; +} + +// ─── Builders ──────────────────────────────────────────────────────────────── + +export function setSecretCommand( + provider: string, + key: string, + value: string, + options: { description?: string; overwrite?: boolean } = {} +): SecretCommandRequest { + const request: SecretCommandRequest = { action: 'set', provider, key, value }; + if (options.description) request.description = options.description; + if (options.overwrite) request.overwrite = true; + return request; +} + +export function updateSecretCommand( + provider: string, + key: string, + value: string, + description?: string +): SecretCommandRequest { + const request: SecretCommandRequest = { + action: 'update', + provider, + key, + value, + }; + if (description) request.description = description; + return request; +} + +export function deleteSecretCommand( + provider: string, + key: string +): SecretCommandRequest { + return { action: 'delete', provider, key }; +} + +export function testSecretCommand( + provider: string, + key: string +): SecretCommandRequest { + return { action: 'test', provider, key }; +} + +export function pruneIndexCommand(provider: string): SecretCommandRequest { + return { action: 'pruneIndex', provider }; +} + +export function rebuildIndexCommand( + provider: string, + adoptKeys?: string[] +): SecretCommandRequest { + const request: SecretCommandRequest = { action: 'rebuildIndex', provider }; + if (adoptKeys && adoptKeys.length > 0) request.adoptKeys = adoptKeys; + return request; +} + +export function bulkSecretsRequest( + provider: string, + secrets: BulkSecretEntry[], + options: { + mode: 'preview' | 'commit'; + overwrite: boolean; + allowUnmanagedOverwrite?: boolean; + } +): BulkSecretsRequest { + const request: BulkSecretsRequest = { + mode: options.mode, + provider, + overwrite: options.overwrite, + secrets, + }; + if (options.allowUnmanagedOverwrite) request.allowUnmanagedOverwrite = true; + return request; +} + +// ─── Capability detection ──────────────────────────────────────────────────── + +/** + * True when the processor exposes the secrets API, detected from its live CWS route table rather + * than from a version number. + * + * Matches a whole path segment: "secrets" is a common enough token that `/api/mysecretsthing` + * would otherwise read as support. + */ +export function supportsSecretsApi(routes?: { Url?: string }[]): boolean { + if (!routes) return false; + return routes.some((route) => /(^|\/)secrets(\/|$)/i.test(route.Url ?? '')); +} + +// ─── Error rendering ───────────────────────────────────────────────────────── + +/** + * Turns whatever RTK Query surfaced into a sentence safe to render. + * + * SECURITY: this must only ever read from the RESPONSE body. `axiosBaseQuery` surfaces + * `error.response?.data` and never `error.config.data`, so there is no path by which a submitted + * secret value could be echoed back into the DOM - do not add one. + */ +export function describeSecretsError(error: unknown): string { + const data = (error as { data?: { error?: SecretsApiError } })?.data; + if (data?.error?.message) return data.error.message; + + const status = (error as { status?: number | string })?.status; + if (status === 'FETCH_ERROR' || status === undefined) { + return 'Could not reach the processor.'; + } + return `Secrets request failed (${status}).`; +} diff --git a/src/store/store.ts b/src/store/store.ts index efabe87..f6b7bdc 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,32 +1,42 @@ -import { AnyAction, Reducer, combineReducers, configureStore } from '@reduxjs/toolkit'; +import { + AnyAction, + Reducer, + combineReducers, + configureStore, +} from '@reduxjs/toolkit'; import { oneSliceToRuleThemAll } from './apiSlice'; import { authReducer } from './auth/authSlice'; import { commonUiReducer } from './commonUi/commonUiSlice'; import { debugConsoleReducer } from './debugConsole/debugConsoleSlice'; +import { routingFeedbackMiddleware } from './routingFeedbackMiddleware'; +import routingFeedbackReducer from './routingFeedbackSlice'; import { websocketMiddleware } from './websocketMiddleware'; import websocketReducer from './websocketSlice'; const allReducers = combineReducers({ - [oneSliceToRuleThemAll.apiSlice.reducerPath]: + [oneSliceToRuleThemAll.apiSlice.reducerPath]: oneSliceToRuleThemAll.apiSlice.reducer, - auth: authReducer, - commonUi: commonUiReducer, - debugConsole: debugConsoleReducer, - websocket: websocketReducer, -}) + auth: authReducer, + commonUi: commonUiReducer, + debugConsole: debugConsoleReducer, + websocket: websocketReducer, + routingFeedback: routingFeedbackReducer, +}); const rootReducer: Reducer = (state: RootState, action: AnyAction) => { - if (action.type === 'commonUi/resetState') { - state = {} as RootState; - } - return allReducers(state, action); - }; + if (action.type === 'commonUi/resetState') { + state = {} as RootState; + } + return allReducers(state, action); +}; export const store = configureStore({ - reducer: rootReducer, - middleware: (getDefaultMiddleware) => getDefaultMiddleware() - .concat(oneSliceToRuleThemAll.apiSlice.middleware) - .concat(websocketMiddleware) + reducer: rootReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware() + .concat(oneSliceToRuleThemAll.apiSlice.middleware) + .concat(websocketMiddleware) + .concat(routingFeedbackMiddleware), }); export type RootState = ReturnType; diff --git a/src/store/websocketMiddleware.test.ts b/src/store/websocketMiddleware.test.ts new file mode 100644 index 0000000..a8ea8d2 --- /dev/null +++ b/src/store/websocketMiddleware.test.ts @@ -0,0 +1,157 @@ +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { websocketMiddleware } from './websocketMiddleware'; +import websocketReducer, { WS_CONNECT, WS_DISCONNECT } from './websocketSlice'; + +// Minimal stand-in for the browser WebSocket; tests drive its events directly +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onerror: ((e: unknown) => void) | null = null; + onmessage: ((e: { data: string }) => void) | null = null; + closed = false; + + constructor(public url: string) { + FakeWebSocket.instances.push(this); + } + + // Real sockets fire error (if still connecting) and close events after close() + close() { + this.closed = true; + this.onerror?.(new Event('error')); + this.onclose?.(); + } + + receive(msg: object) { + this.onmessage?.({ data: JSON.stringify(msg) }); + } +} + +const makeStore = () => + configureStore({ + reducer: combineReducers({ websocket: websocketReducer }), + middleware: (getDefault) => getDefault().concat(websocketMiddleware), + }); + +const connect = (store: ReturnType) => + store.dispatch({ + type: WS_CONNECT, + payload: { url: 'ws://primary', fallbackUrl: 'ws://fallback' }, + }); + +const openSockets = () => FakeWebSocket.instances.filter((s) => !s.closed); + +describe('websocketMiddleware', () => { + beforeEach(() => { + FakeWebSocket.instances = []; + vi.stubGlobal('WebSocket', FakeWebSocket); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('keeps a single live socket when connect is dispatched twice', () => { + const store = makeStore(); + connect(store); + // Capture the first socket's handlers before the reconnect detaches them + const stale = FakeWebSocket.instances[0]; + const { onopen, onmessage } = stale; + connect(store); + + expect(openSockets()).toHaveLength(1); + + onopen?.call(stale); + onmessage?.call(stale, { + data: JSON.stringify({ RenderedMessage: 'stale' }), + }); + + expect(store.getState().websocket.isConnected).toBe(false); + expect(store.getState().websocket.messages).toHaveLength(0); + + openSockets()[0].onopen?.(); + openSockets()[0].receive({ RenderedMessage: 'hello' }); + + expect(store.getState().websocket.isConnected).toBe(true); + expect(store.getState().websocket.messages).toHaveLength(1); + }); + + it('closes the live socket on disconnect', () => { + const store = makeStore(); + connect(store); + connect(store); + openSockets()[0].onopen?.(); + + store.dispatch({ type: WS_DISCONNECT }); + + expect(openSockets()).toHaveLength(0); + expect(store.getState().websocket.isConnected).toBe(false); + }); + + it('falls back to the secondary URL when the primary errors', () => { + const store = makeStore(); + connect(store); + + FakeWebSocket.instances[0].onerror?.(new Event('error')); + + expect(openSockets().map((s) => s.url)).toEqual(['ws://fallback']); + }); + + it('clears isConnected when a replacement connection fails', () => { + const store = makeStore(); + connect(store); + FakeWebSocket.instances[0].onopen?.(); + expect(store.getState().websocket.isConnected).toBe(true); + + connect(store); + FakeWebSocket.instances[1].onerror?.(new Event('error')); + FakeWebSocket.instances[2].onerror?.(new Event('error')); + + const state = store.getState().websocket; + expect(state.isConnected).toBe(false); + expect(state.isConnecting).toBe(false); + expect(state.failedUrls).toEqual(['ws://primary', 'ws://fallback']); + }); + + it('clears isConnected when a fallback connection fails after a live socket errors', () => { + const store = makeStore(); + connect(store); + FakeWebSocket.instances[0].onopen?.(); + expect(store.getState().websocket.isConnected).toBe(true); + + FakeWebSocket.instances[0].onerror?.(new Event('error')); + FakeWebSocket.instances[1].onerror?.(new Event('error')); + + const state = store.getState().websocket; + expect(state.isConnected).toBe(false); + expect(state.isConnecting).toBe(false); + expect(state.failedUrls).toEqual(['ws://primary', 'ws://fallback']); + }); + + it('tracks isConnecting from connect until the socket opens', () => { + const store = makeStore(); + connect(store); + expect(store.getState().websocket.isConnecting).toBe(true); + + // Still connecting while the fallback is being tried + FakeWebSocket.instances[0].onerror?.(new Event('error')); + expect(store.getState().websocket.isConnecting).toBe(true); + + FakeWebSocket.instances[1].onopen?.(); + expect(store.getState().websocket.isConnecting).toBe(false); + expect(store.getState().websocket.isConnected).toBe(true); + }); + + it('clears isConnecting on disconnect during the handshake', () => { + const store = makeStore(); + connect(store); + + store.dispatch({ type: WS_DISCONNECT }); + + expect(store.getState().websocket.isConnecting).toBe(false); + }); +}); diff --git a/src/store/websocketMiddleware.ts b/src/store/websocketMiddleware.ts index 67dc18a..4954bbe 100644 --- a/src/store/websocketMiddleware.ts +++ b/src/store/websocketMiddleware.ts @@ -1,4 +1,4 @@ -import { Middleware } from "@reduxjs/toolkit"; +import { Middleware } from '@reduxjs/toolkit'; import { connected, connectionAttemptStarted, @@ -9,51 +9,93 @@ import { WS_DISCONNECT, WsConnectAction, WsDisconnectAction, -} from "./websocketSlice"; +} from './websocketSlice'; export const websocketMiddleware: Middleware = (store) => { let socket: WebSocket | null = null; + // Detach handlers before closing so a stale socket can't clobber the + // current one, trigger a fallback connection, or keep dispatching messages + const closeSocket = () => { + if (socket) { + socket.onopen = null; + socket.onclose = null; + socket.onerror = null; + socket.onmessage = null; + socket.close(); + socket = null; + } + }; + return (next) => (action) => { const { type } = action as WsConnectAction | WsDisconnectAction; if (type === WS_CONNECT) { const { url, fallbackUrl } = (action as WsConnectAction).payload; - console.log("[ws] Connecting to", url); + console.log('[ws] Connecting to', url); + // Close any existing connection before opening a new one. Its handlers + // are detached, so report the disconnect here before the new attempt + closeSocket(); + store.dispatch(disconnected()); store.dispatch(connectionAttemptStarted()); - // Close any existing connection before opening a new one - if (socket) { - socket.close(); - } - const connectToUrl = (targetUrl: string, fallback?: string) => { - socket = new WebSocket(targetUrl); - socket.onopen = () => store.dispatch(connected()); - socket.onclose = () => { + let ws: WebSocket; + try { + ws = new WebSocket(targetUrl); + } catch (err) { + // An unparseable URL throws synchronously instead of firing onerror + console.error('[ws] Invalid WebSocket URL', targetUrl, err); + if (fallback) { + connectToUrl(fallback); + } else { + store.dispatch( + connectionFailed( + fallbackUrl && targetUrl === fallbackUrl + ? [url, fallbackUrl] + : [targetUrl] + ) + ); + } + return; + } + socket = ws; + ws.onopen = () => { + if (socket !== ws) return; + store.dispatch(connected()); + }; + ws.onclose = () => { + if (socket !== ws) return; store.dispatch(disconnected()); socket = null; }; - socket.onerror = (err) => { - console.error("WebSocket error", err); + ws.onerror = (err) => { + if (socket !== ws) return; + console.error('WebSocket error', err); if (fallback) { - console.log("[ws] Primary connection failed, falling back to", fallback); + console.log( + '[ws] Primary connection failed, falling back to', + fallback + ); + closeSocket(); connectToUrl(fallback); } else { // Report all attempted URLs (primary + fallback that was tried) - const attemptedUrls = fallbackUrl && targetUrl === fallbackUrl - ? [url, fallbackUrl] - : [targetUrl]; + const attemptedUrls = + fallbackUrl && targetUrl === fallbackUrl + ? [url, fallbackUrl] + : [targetUrl]; store.dispatch(connectionFailed(attemptedUrls)); } }; - socket.onmessage = (event: MessageEvent) => { + ws.onmessage = (event: MessageEvent) => { + if (socket !== ws) return; try { store.dispatch(messageReceived(JSON.parse(event.data))); } catch (e) { - console.error("Failed to parse WebSocket message", e); + console.error('Failed to parse WebSocket message', e); } }; }; @@ -64,10 +106,8 @@ export const websocketMiddleware: Middleware = (store) => { } if (type === WS_DISCONNECT) { - if (socket) { - socket.close(); - socket = null; - } + closeSocket(); + store.dispatch(disconnected()); return; } diff --git a/src/store/websocketSlice.ts b/src/store/websocketSlice.ts index 00c75c4..71f4831 100644 --- a/src/store/websocketSlice.ts +++ b/src/store/websocketSlice.ts @@ -1,29 +1,34 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { LogMessage } from "../shared/types/LogMessage"; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { LogMessage } from '../shared/types/LogMessage'; interface WebsocketState { messages: LogMessage[]; isConnected: boolean; + /** True from the start of a join until the socket opens, fails, or closes */ + isConnecting: boolean; failedUrls: string[] | null; } const initialState: WebsocketState = { messages: [], isConnected: false, + isConnecting: false, failedUrls: null, }; const websocketSlice = createSlice({ - name: "websocket", + name: 'websocket', initialState, reducers: { /** Dispatched by the middleware when the socket opens */ connected(state) { state.isConnected = true; + state.isConnecting = false; }, /** Dispatched by the middleware when the socket closes */ disconnected(state) { state.isConnected = false; + state.isConnecting = false; }, /** Dispatched by the middleware for each incoming message */ messageReceived(state, action: PayloadAction) { @@ -36,24 +41,33 @@ const websocketSlice = createSlice({ /** Dispatched by the middleware when a connection attempt fails */ connectionFailed(state, action: PayloadAction) { state.failedUrls = action.payload; + state.isConnected = false; + state.isConnecting = false; }, - /** Dispatched by the middleware when a new connection attempt starts */ + /** Dispatched when a join begins and again when the socket is opened */ connectionAttemptStarted(state) { state.failedUrls = null; + state.isConnecting = true; }, }, }); -export const { connected, disconnected, messageReceived, messagesCleared, connectionFailed, connectionAttemptStarted } = - websocketSlice.actions; +export const { + connected, + disconnected, + messageReceived, + messagesCleared, + connectionFailed, + connectionAttemptStarted, +} = websocketSlice.actions; export default websocketSlice.reducer; // ── Action type constants used by the middleware ───────────────────────────── /** Dispatch this to open a WebSocket connection */ -export const WS_CONNECT = "websocket/connect"; +export const WS_CONNECT = 'websocket/connect'; /** Dispatch this to close the current connection */ -export const WS_DISCONNECT = "websocket/disconnect"; +export const WS_DISCONNECT = 'websocket/disconnect'; export interface WsConnectAction { type: typeof WS_CONNECT; diff --git a/src/styles.scss b/src/styles.scss index 78d5600..64a87aa 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -214,34 +214,28 @@ $theme-colors: map-merge($theme-colors, $custom-colors); $utilities: map-merge( $utilities, ( - 'background-color': - map-merge( + 'background-color': map-merge( map-get($utilities, 'background-color'), ( - values: - map-merge( + values: map-merge( map-get(map-get($utilities, 'background-color'), 'values'), ($theme-colors) ), ) ), - 'border-color': - map-merge( + 'border-color': map-merge( map-get($utilities, 'border-color'), ( - values: - map-merge( + values: map-merge( map-get(map-get($utilities, 'border-color'), 'values'), ($theme-colors) ), ) ), - 'color': - map-merge( + 'color': map-merge( map-get($utilities, 'color'), ( - values: - map-merge( + values: map-merge( map-get(map-get($utilities, 'color'), 'values'), ($theme-colors) ), diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 28929cc..46458c6 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,2 +1,2 @@ /// -declare const APP_VERSION: string; \ No newline at end of file +declare const APP_VERSION: string; diff --git a/tsconfig.json b/tsconfig.json index ab8cd43..963cd71 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2020", - "lib": [ - "ES2020", - "dom", - "dom.iterable" - ], + "lib": ["ES2020", "ES2022.Error", "dom", "dom.iterable"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, @@ -21,8 +17,5 @@ "jsx": "react-jsx", "types": ["vite/client", "vitest/globals"] }, - "include": [ - "src", - "vite.config.ts" - ] + "include": ["src", "vite.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 79cafa9..bfce74b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,22 +1,22 @@ -import react from "@vitejs/plugin-react"; -import { defineConfig, loadEnv } from "vite"; -import svgr from "vite-plugin-svgr"; +import react from '@vitejs/plugin-react'; +import { defineConfig, loadEnv } from 'vite'; +import svgr from 'vite-plugin-svgr'; export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), ""); + const env = loadEnv(mode, process.cwd(), ''); const programHost = env.PROGRAM_HOST; - if (!programHost && mode !== "test") { + if (!programHost && mode !== 'test') { console.warn( - "Warning: PROGRAM_HOST is not set β€” proxy will not be configured.", + 'Warning: PROGRAM_HOST is not set β€” proxy will not be configured.' ); } return { - base: "/cws/debug/", + base: '/cws/debug/', plugins: [ react(), - svgr({ include: "**/*.svg", svgrOptions: { exportType: "named" } }), + svgr({ include: '**/*.svg', svgrOptions: { exportType: 'named' } }), ], define: { APP_VERSION: JSON.stringify(process.env.npm_package_version), @@ -24,21 +24,20 @@ export default defineConfig(({ mode }) => { server: { proxy: programHost ? { - "^/cws/.*/api/.*": { + '^/cws/.*/api/.*': { target: programHost, changeOrigin: true, secure: false, configure: (proxy) => { - proxy.on("proxyReq", (proxyReq) => { - const referer = proxyReq.getHeader("referer") as - | string - | undefined; + proxy.on('proxyReq', (proxyReq) => { + const referer = proxyReq.getHeader('referer') as + string | undefined; if (referer) { const newReferer = referer.replace( /https:\/\/localhost:.*/, - programHost, + programHost ); - proxyReq.setHeader("referer", newReferer); + proxyReq.setHeader('referer', newReferer); } }); }, @@ -48,8 +47,8 @@ export default defineConfig(({ mode }) => { }, test: { globals: true, - environment: "jsdom", - setupFiles: "./src/setupTests.ts", + environment: 'jsdom', + setupFiles: './src/setupTests.ts', }, }; });