diff --git a/.gitignore b/.gitignore index e149012..41fa25d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ yarn-debug.log* yarn-error.log* dist/assets/index-C_SGWbYA.js tsconfig.tsbuildinfo +tsconfig.tsbuildinfo diff --git a/docs/README.md b/docs/README.md index 5ff98c5..e64aada 100644 --- a/docs/README.md +++ b/docs/README.md @@ -61,12 +61,20 @@ To understand the underlying concepts, read the **[Explanation](./explanation/)* The PepperDash Essentials Web Config App provides several key features: -- **πŸ” Debug Console**: Real-time log monitoring with filtering and search -- **βš™οΈ Device Management**: Inspect and interact with connected devices +- **οΏ½ Authentication**: Secure login required before accessing any app data +- **πŸ” Debug Console**: Real-time log monitoring with per-device minimum log level filtering +- **βš™οΈ 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 +- **πŸ“± 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 +## Multi-Application Support + +The app supports up to 10 simultaneous PepperDash Essentials program slots (`app01` through `app10`). After logging in, available program slots are automatically discovered and populated in the application selector dropdown in the top navigation bar. Navigating between app slots does not require re-authentication. + ## System Requirements - Modern web browser (Chrome, Firefox, Safari, Edge) diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index b9d12e1..077db71 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -78,8 +78,13 @@ The web application serves several specific roles within this ecosystem: **State Management Strategy**: - **RTK Query**: Server state management and caching -- **URL-based filters**: Filter state persisted in URL parameters -- **Local component state**: UI-specific state like modals and selections +- **Redux Toolkit**: Client state management for authentication, debug console filters, and UI state + - `auth` slice: global authentication state and available app slot list + - `debugConsole` slice: per-device minimum log level, checked device filters, and search text + - `websocket` slice: WebSocket connection state and received log messages + - `commonUi` slice: shared UI state such as the active room ID +- **Local component state**: Transient UI state such as modals, drawer open/close, and form inputs +- Filter state lives in Redux (not URL parameters) so selections are preserved when navigating between routes ### API Layer (RESTful Services) @@ -194,10 +199,20 @@ Framework Logging ──► Message Buffer ──► WebSocket ──► Client ### Authentication Model -**Processor-Based Security**: -The web app leverages the processor's built-in security system: +**Application-Level Authentication**: +The web app implements its own credential-based authentication flow on top of the processor's built-in security: -- **No Independent Authentication**: Web app doesn't implement its own user system +- **Login Form**: Users provide a username and password before accessing any app data +- **Credential Validation**: Credentials are submitted to the processor via `POST /:appId/api/loginCredentials` +- **Server-Side Single Auth**: The backend has one shared authentication mechanism regardless of which `appId` is used in the request +- **Global Session**: A successful login with any app slot authenticates the session for all running app slots +- **Redux Auth State**: `isAuthenticated` boolean and `availableApps` list are stored in Redux in-memory (resets on page reload) +- **Route Protection**: A `RequireAuth` layout route wraps all `/:appId/*` sub-routes; unauthenticated requests are redirected to `/:appId/login` + +**App Slot Discovery**: +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 diff --git a/docs/explanation/debug-console-design.md b/docs/explanation/debug-console-design.md index 039ddbd..1dad5a3 100644 --- a/docs/explanation/debug-console-design.md +++ b/docs/explanation/debug-console-design.md @@ -25,9 +25,9 @@ The console presents information in layers, allowing users to start broad and na ``` All System Messages ↓ (Filter by Device) -Device-Specific Messages - ↓ (Filter by Level) -Problem-Level Messages +Device-Specific Messages + ↓ (Set Per-Device Minimum Level) +Severity-Filtered Device Messages ↓ (Text Search) Specific Event Messages ↓ (Click for Details) @@ -65,9 +65,10 @@ Complete Message Information **Why Client-Side**: - **Responsive Filtering**: Filter changes apply instantly to existing messages -- **Rich Interaction**: Complex filter combinations without server round-trips +- **Rich Interaction**: Complex filter combinations (including per-device levels) without server round-trips - **Historical Analysis**: Can re-filter previously received messages - **Reduced Server Load**: Filtering computation happens in user's browser +- **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 @@ -101,18 +102,17 @@ Messages use structured logging with both human-readable text and machine-readab ### Filter Interface Design **Multiple Filter Types**: -The console provides three distinct filtering mechanisms: -1. **Device Selection**: Who generated the message? -2. **Log Level Selection**: How important is the message? -3. **Text Search**: What specific content are you looking for? - -**Why Multiple Types**: -- **Different Mental Models**: Users think about problems in different ways -- **Complementary Filtering**: Different filters answer different questions -- **Flexible Workflow**: Users can apply filters in any order that makes sense - -**Filter Combination Logic**: -All filters use AND logic (all conditions must match) +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? + +**Per-Device Minimum Log Level**: +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" diff --git a/docs/explanation/security.md b/docs/explanation/security.md index b27801b..0b4575a 100644 --- a/docs/explanation/security.md +++ b/docs/explanation/security.md @@ -90,34 +90,21 @@ Web interface could be overwhelmed by requests: ### Authentication Model -**Certificate-Based Authentication**: -The system relies on the processor's built-in authentication: -- Client certificates validate user identity -- Self-signed certificates common in internal deployments -- Certificate trust requires explicit user acceptance -- Certificate revocation through processor management +**Credential-Based Authentication**: +The web app implements a credential-based login flow that gates access to all application features: + +- **Login Form**: Before any processor data is accessible, users must provide a username and password +- **API Validation**: Credentials are submitted to `POST /cws/:appId/api/loginCredentials` on the processor +- **Shared Authentication**: The processor backend uses a single authentication mechanism for all program slots β€” a successful login with any `appId` authenticates the entire session +- **Global Session State**: Authentication state (`isAuthenticated: boolean`) is stored in Redux. A `RequireAuth` layout route protects all `/:appId/*` sub-routes and redirects unauthenticated users to the login page +- **In-Memory Only**: The authentication state is not persisted to `localStorage` or cookies. Reloading the page requires re-authentication, providing natural session expiry +- **App Discovery at Login**: After validating credentials, all 10 possible program slots are probed in parallel. Only slots that respond successfully are shown in the application selector **Session Management**: -Web sessions are managed securely: -- Session tokens generated using cryptographically secure random numbers -- Session data stored server-side, not in cookies -- Automatic session expiration after inactivity -- Session invalidation on logout or timeout - -### Authorization Framework - -**Role-Based Access Control**: -Access is controlled through processor-level permissions: -- **System Administrator**: Full access to all features and data -- **Operator**: Read access to operational data and controls -- **Viewer**: Read-only access to status and configuration information -- **Guest**: Minimal access to basic system information - -**Permission Inheritance**: -Permissions are inherited from the processor's user management: -- Web app does not maintain separate user database -- Authorization decisions delegated to Essentials framework -- Consistent access control across all system interfaces +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 ## Data Protection diff --git a/docs/how-to/filter-debug-messages.md b/docs/how-to/filter-debug-messages.md index ca34f1b..c4f123e 100644 --- a/docs/how-to/filter-debug-messages.md +++ b/docs/how-to/filter-debug-messages.md @@ -8,9 +8,33 @@ **For immediate results:** 1. **Device filter**: Click "Devices" dropdown β†’ Select specific devices -2. **Log level filter**: Click "Log Level" dropdown β†’ Select "Warning" and "Error" only +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 -4. **Clear all**: Click "Clear" button to reset +4. **Clear all**: Click "Clear Filters" button to reset all filters + +## Understanding the Filter Controls + +### Device Filter and Per-Device Minimum Level + +The Devices dropdown combines two capabilities: + +- **Checkbox per device**: Check a device to include its messages in the view +- **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) + +### Text Search + +Type keywords in the search box to find messages whose rendered text, template, timestamp, or device key contains the term(s). ## Effective Search Strategies diff --git a/docs/reference/api-endpoints.md b/docs/reference/api-endpoints.md index 458f3f8..c0a711d 100644 --- a/docs/reference/api-endpoints.md +++ b/docs/reference/api-endpoints.md @@ -2,16 +2,42 @@ **Complete technical reference for all REST API endpoints used by the PepperDash Essentials Web Config App.** -All API endpoints are accessed through the base path `/cws/app01/api` on the processor's HTTPS port (443). +All API endpoints are accessed through the base path `/cws/:appId/api` where `:appId` is the program slot identifier (e.g. `app01` through `app10`). ## Base Configuration -**Base URL**: `https://[processor-ip]/cws/app01/api` +**Base URL**: `https://[processor-ip]/cws/:appId/api` **Protocol**: HTTPS only -**Authentication**: None (uses processor's built-in web authentication) +**Authentication**: Credential-based via `POST /loginCredentials` (see below) **Content-Type**: `application/json` for POST requests **Response Format**: JSON +## Authentication Endpoints + +### Set Login Credentials +**Purpose**: Authenticate with the processor. The backend uses a single shared authentication mechanism for all program slots. + +```http +POST /loginCredentials +``` + +**Request Body**: +```json +{ + "username": "admin", + "password": "yourpassword" +} +``` + +**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 + +--- + ## System Information Endpoints ### Get Versions @@ -46,6 +72,42 @@ GET /versions --- +### Get API Paths +**Purpose**: Retrieve all available REST API routes registered on the processor + +```http +GET /apiPaths +``` + +**Response**: +```json +{ + "url": "https://192.168.1.100/cws/app01", + "routes": [ + { + "Name": "getDevices", + "Url": "app01/api/devices", + "DataTokens": { "Name": "getDevices" }, + "RouteHandler": null + } + ] +} +``` + +**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 + - `Url` (string): Route URL relative to the base + - `DataTokens.Name` (string): Data token name when present + +**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 diff --git a/docs/reference/log-levels.md b/docs/reference/log-levels.md index 5631117..758a1f7 100644 --- a/docs/reference/log-levels.md +++ b/docs/reference/log-levels.md @@ -37,7 +37,41 @@ - **Filtered out**: None - **Use case**: Deep debugging, code-level analysis -## Message Classification +## Client-Side Filtering + +### Per-Device Minimum Log Level Filter + +Each device checked in the Debug Console Devices dropdown has its own minimum log level threshold. Messages from that device are only shown if they meet or exceed that device's threshold. + +**Default**: `Information` when a device is first checked + +**Severity comparison**: `LOG_LEVEL_ORDER[message.Level] >= LOG_LEVEL_ORDER[deviceMinLevel]` + +where `LOG_LEVEL_ORDER` is: + +| 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 + +**State**: Stored in Redux `debugConsole.deviceLevels` as `Record`; persists across navigation within the session + +### 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 + +### Server-Side Minimum Log Level + +The **Minimum Log Level** dropdown in the Debug Console session panel sets the server-side floor for all messages the processor sends to the client. Messages below this level are never transmitted, regardless of the client-side per-device settings. This controls what the processor captures and streams, while per-device levels control what the client displays after receipt. ### System-Level Messages diff --git a/docs/reference/ui-components.md b/docs/reference/ui-components.md index 43e5c08..3bd0bd4 100644 --- a/docs/reference/ui-components.md +++ b/docs/reference/ui-components.md @@ -86,40 +86,96 @@ This document provides detailed information about every UI element, its purpose, - Device keys and names #### Device Filter Dropdown -**Component**: `FilterDropdownSearchParams` -**Purpose**: Filter messages by source device +**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 with Key and Name +- **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 selected filters +- **Badge indicator**: Shows count of checked devices -**Behavior**: -- **Scroll support**: Long device lists are scrollable -- **Search within dropdown**: Future enhancement capability -- **Persistence**: Selections persist until manually cleared +**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 +- Only messages at or above the selected threshold for that device are shown +- Each device can have a different level independently +- Unchecking a device removes both the device filter and its level setting +- The level dropdown closes after a selection; the Devices dropdown stays open + +**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 +**Effect**: Dispatches `clearAllFilters()` to the Redux `debugConsole` slice + +--- + +## 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 -#### Log Level Filter Dropdown -**Purpose**: Filter messages by severity level +**Location**: Slides in from right side of screen +**Trigger**: Click on any route row -**Available levels**: -- Error -- Warning -- Information -- Log -- Verbose -- Debug +**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**: -- **Multiple selection**: Can select multiple levels simultaneously -- **Badge indicator**: Shows count of selected levels -- **Immediate application**: Filters apply to current message list +- No backdrop (main content remains interactive) +- Close button dismisses the drawer -#### Clear Filters Button -**Purpose**: Reset all filters to default state -**Behavior**: Clears device filters, log level filters, and search terms -**Visual**: Outline button style, positioned with other filter controls +--- + +## Routing Components + +### Routing Diagram +**Location**: Routing page (`/:appId/routing`) +**Purpose**: Visual signal routing diagram showing devices, ports, and tie lines + +**Technology**: React Flow (@xyflow/react) with Dagre auto-layout + +**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 + +**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 + +--- ### Message Display Components diff --git a/docs/tutorials/debug-console-basics.md b/docs/tutorials/debug-console-basics.md index 0874a89..ea370df 100644 --- a/docs/tutorials/debug-console-basics.md +++ b/docs/tutorials/debug-console-basics.md @@ -45,42 +45,32 @@ ## Part 2: Advanced Filtering Techniques -### Step 3: Master Device Filtering +### Step 3: Master Device Filtering with Per-Device Levels 1. **Open the Devices filter dropdown** 2. **Notice the "Global" option** - this captures system-wide messages -3. **Select specific devices** to focus on particular components -4. **Try combining multiple devices** to monitor a subset of your system +3. **Check specific devices** to focus on particular components +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**: -- If troubleshooting display issues, filter to just display devices -- For audio problems, focus on audio-related devices +- 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 -### Step 4: Combine Multiple Filters +### Step 4: Combine Device and Text Filters 1. **Apply a device filter** (select 1-2 devices) -2. **Add a log level filter** (try "Warning" and "Error" only) -3. **Add search terms** in the search box -4. **Observe how filters work together** (AND logic - all conditions must match) +2. **Add search terms** in the search box +3. **Observe how filters work together** (AND logic β€” all conditions must match) -**Exercise**: Create a filter to show only error messages from display devices containing the word "power". +**Exercise**: Create a filter to show only messages from display devices containing the word "power". -### Step 5: Use Search Effectively +### Step 5: Understand Filter State Persistence -The search function is powerful but has specific behavior: - -1. **Single word searches**: Type "button" to find all button-related messages -2. **Multiple word searches**: Type "display power" to find messages containing both words -3. **Case insensitive**: "ERROR" and "error" work the same way -4. **Partial matches**: "conn" will match "connection", "connected", "disconnect" - -**Try these search examples**: -- `error` - Find all error-related messages -- `display power` - Find display power-related messages -- `button press` - Find button press events -- `connection timeout` - Find connection timeout issues - -βœ… **Success indicator**: You can create complex filter combinations to find exactly what you need. +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 ## Part 3: Interpreting Messages and Troubleshooting diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md index f599bf3..3575a3f 100644 --- a/docs/tutorials/getting-started.md +++ b/docs/tutorials/getting-started.md @@ -1,17 +1,18 @@ # Getting Started with the Essentials Web Config App -**Learning objective**: By the end of this tutorial, you'll understand how to access the web app, navigate its interface, and perform basic debugging tasks. +**Learning objective**: By the end of this tutorial, you'll understand how to access the web app, sign in, navigate its interface, and perform basic debugging tasks. **Time required**: 15-20 minutes **Prerequisites**: - Access to a PepperDash Essentials processor on your network +- Valid credentials for the processor - Basic understanding of network connectivity ## What You'll Learn -- How to access the web application -- How to navigate the main interface +- How to access the web application and sign in +- How to navigate the main interface and switch between app slots - How to start a debug session - How to view basic system information @@ -31,24 +32,46 @@ - Click "Advanced" and "Proceed to [IP] (unsafe)" to continue - This is normal for internal network devices -βœ… **Success indicator**: You should see the "Essentials Debugger" interface with a navigation bar. +βœ… **Success indicator**: You should see a Sign In page. -## Step 2: Exploring the Interface +## Step 2: Signing In -The application has six main sections accessible from the top navigation: +1. **Enter your credentials** + - Type your username and password + - Click **Sign In** -### Home -- Basic welcome screen -- Starting point for navigation +2. **What happens during sign-in** + - Your credentials are sent to the processor to validate them + - If valid, the app automatically discovers which program slots are running (up to 10) + - You are redirected to the first available app slot -### Debug Console -- Real-time log messages from your system -- The most powerful feature for troubleshooting +3. **Application selector** + - The top navigation bar will show a dropdown with available program slots (e.g. `app01`, `app02`) + - Use this to switch between independently running Essentials instances + - Switching slots does not require re-authentication + +βœ… **Success indicator**: You are viewing a page like `/:appId/versions`. + +## Step 3: Exploring the Interface + +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 @@ -61,20 +84,26 @@ The application has six main sections accessible from the top navigation: - 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 + +### Mobile Control +- Management interface for connected mobile control clients + **Try this**: Click through each navigation item to see the different sections. -## Step 3: Viewing System Information +## Step 4: Viewing System Information -Let's start by checking what's running on your system: +Let’s start by checking what’s running on your system: 1. **Click "Versions"** - - You'll see a list of all loaded software components - - Look for "PepperDash-Essentials" to confirm the framework version + - You’ll see a list of all loaded software components, sorted alphabetically + - Look for "PepperDashEssentials.dll" to confirm the framework version 2. **Click "Types"** - Browse the available device types - - Notice the three columns: Type Name, Class Type, and Description - - Common types include "basicTriList", "panasonicDisplay", "samsungMDC" + - Common types include `basicTriList`, `panasonicDisplay`, `samsungMDC` 3. **Click "Devices"** - See all configured devices in your system @@ -82,7 +111,7 @@ Let's start by checking what's running on your system: βœ… **Success indicator**: You can see version information, available types, and configured devices. -## Step 4: Starting Your First Debug Session +## Step 5: Starting Your First Debug Session The Debug Console is the heart of the application for troubleshooting: @@ -90,7 +119,7 @@ The Debug Console is the heart of the application for troubleshooting: - Click "Debug Console" in the top navigation 2. **Start a debug session** - - Click the blue "Start Debug Session" button + - Click the blue **Start Debug Session** button - You should see "Message Count: 0" initially 3. **Generate some activity** @@ -99,24 +128,29 @@ The Debug Console is the heart of the application for troubleshooting: 4. **Observe the message structure** - **Timestamp**: When the message occurred - - **Key**: Which device generated the message ("global" for system messages) + - **Key**: Which device generated the message (`global` for system messages) - **Level**: Importance level (Information, Warning, Error, etc.) - **Message**: The actual log message βœ… **Success indicator**: You can see real-time log messages appearing in the console. -## Step 5: Basic Message Filtering +## Step 6: Basic Message Filtering + +Let’s learn to filter messages: -Let's learn to filter messages to find what you need: +1. **Filter by device with a minimum level** + - Click the **Devices** dropdown + - Check one or more devices + - Each checked device shows an inline level dropdown (defaults to `Information`) + - Change a device’s level to `Warning` to hide its lower-severity messages -1. **Use the search box** - - Type a keyword (like "button" or "display") in the search field - - Wait 1 second for results to filter automatically +2. **Use the search box** + - Type a keyword (like `button` or `display`) in the search field + - Results filter immediately -2. **Filter by device** - - Click the "Devices" dropdown - - Select one or more devices to filter messages - - Notice the blue badge showing how many filters are active +3. **Clear filters** + - 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** - Click the "Log Level" dropdown diff --git a/package-lock.json b/package-lock.json index b842ef7..e1267c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@reduxjs/toolkit": "^2.11.2", + "@xyflow/react": "^12.10.2", "axios": "^1.14.0", "bootstrap": "^5.3.8", + "dagre": "^0.8.5", "lodash": "^4.17.21", "react": "^19.2.4", "react-bootstrap": "^2.10.10", @@ -25,6 +27,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", + "@types/dagre": "^0.7.54", "@types/lodash": "^4.17.24", "@types/node": "^25.5.0", "@types/react": "^19.2.14", @@ -2021,6 +2024,62 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2289,6 +2348,38 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2535,6 +2626,12 @@ "node": ">=18" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/classnames": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", @@ -2605,6 +2702,121 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2943,6 +3155,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4915,6 +5136,34 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index ab1619f..dd62db1 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,10 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@reduxjs/toolkit": "^2.11.2", + "@xyflow/react": "^12.10.2", "axios": "^1.14.0", "bootstrap": "^5.3.8", + "dagre": "^0.8.5", "lodash": "^4.17.21", "react": "^19.2.4", "react-bootstrap": "^2.10.10", @@ -27,6 +29,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", + "@types/dagre": "^0.7.54", "@types/lodash": "^4.17.24", "@types/node": "^25.5.0", "@types/react": "^19.2.14", diff --git a/src/App.tsx b/src/App.tsx index d471750..f150db5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,17 @@ import { Suspense } from "react"; import { useDispatch, useSelector } from "react-redux"; import { Navigate, Route, Routes } 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 MobileControl from './features/MobileControl'; +import RequireAuth from "./features/RequireAuth"; +import Routing from './features/Routing'; import Types from "./features/Types"; import Versions from "./features/Versions"; import { @@ -42,29 +48,41 @@ function App() { }; return ( - - - } /> + + + + } /> + }> + } /> + + }> - } /> - } /> - } /> - } /> - } /> - - } - /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } + /> + - - + + + ); } diff --git a/src/features/ApiPathDetailDrawer.tsx b/src/features/ApiPathDetailDrawer.tsx new file mode 100644 index 0000000..51fd591 --- /dev/null +++ b/src/features/ApiPathDetailDrawer.tsx @@ -0,0 +1,54 @@ +import { Offcanvas } from "react-bootstrap"; +import { Route } from "../store/apiSlice"; + +const ApiPathDetailDrawer = ({ + show, + route, + handleClose, + url, +}: ApiPathDetailDrawerProps) => { + if (!route) return null; + + return ( + + + API Path Detail + + +
+
+
Name
+ {route.Name} +
+
+
URL
+ + {`${url}/${route.Url}`} + +
+ {route.DataTokens?.Name && ( +
+
Data Token Name
+ {route.DataTokens.Name} +
+ )} +
+
+
+ ); +}; + +export default ApiPathDetailDrawer; + +interface ApiPathDetailDrawerProps { + show: boolean; + route: Route | undefined; + handleClose: () => void; + url: string; +} diff --git a/src/features/ApiPaths.tsx b/src/features/ApiPaths.tsx new file mode 100644 index 0000000..4fc88d1 --- /dev/null +++ b/src/features/ApiPaths.tsx @@ -0,0 +1,62 @@ +import { skipToken } from '@reduxjs/toolkit/query'; +import { useState } from 'react'; +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 [showDrawer, setShowDrawer] = useState(false); + const [selectedRoute, setSelectedRoute] = useState(); + + if (isLoading) return
Loading...
; + + if (!apiPathData?.routes) return
No paths available
; + + const sorted = [...apiPathData.routes].sort((a, b) => a.Name.localeCompare(b.Name)); + + function clickRow(route: Route) { + setSelectedRoute(route); + setShowDrawer(true); + } + + function handleClose() { + setShowDrawer(false); + setSelectedRoute(undefined); + } + + return ( +
+

Available API Paths

+ + + + + + + + + {sorted.map((path) => ( + clickRow(path)} + className={'cursor-pointer hover' + (selectedRoute === path ? ' table-primary' : '')} + > + + + + ))} + +
NameURL
{path.Name}{path.Url}
+ + +
+ ); +} \ No newline at end of file diff --git a/src/features/DebugConsole/DebugConsole.tsx b/src/features/DebugConsole/DebugConsole.tsx index c1fd09c..24e7f67 100644 --- a/src/features/DebugConsole/DebugConsole.tsx +++ b/src/features/DebugConsole/DebugConsole.tsx @@ -1,6 +1,6 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { useState } from "react"; -import { Button, Form } from "react-bootstrap"; +import { Alert, Button, Form } from "react-bootstrap"; import { useSelector } from 'react-redux'; import ListFiltersHeader from "../../shared/ListFiltersHeader"; import useAppParams from '../../shared/hooks/useAppParams'; @@ -22,6 +22,10 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => { const [showModal, setShowModal] = useState(false); const { appId } = useAppParams(); const messages = useSelector((state: RootState) => state.websocket.messages); + const failedUrl = useSelector((state: RootState) => state.websocket.failedUrl); + const certUrl = failedUrl + ? new URL(failedUrl).origin.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:') + : null; const { data: doNotLoadConfigOnNextBoot } = useGetDoNotLoadConfigOnNextBootQuery(appId ? { appId } : skipToken); @@ -54,12 +58,12 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => {
{!isConnected ? ( - ) : ( - )} @@ -106,6 +110,15 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => { Message Count: {messages.length}
+ {certUrl && ( + + Connection failed. The debug server may have an untrusted certificate.{' '} + + Open {certUrl} in a new tab + + {', accept the certificate, then try "Start Debug Session" again.'} + + )} } /> diff --git a/src/features/DebugConsole/DebugFilters.tsx b/src/features/DebugConsole/DebugFilters.tsx index 0eb2e7b..36ae562 100644 --- a/src/features/DebugConsole/DebugFilters.tsx +++ b/src/features/DebugConsole/DebugFilters.tsx @@ -1,48 +1,44 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { useMemo } from 'react'; -import { FilterClearButton } from '../../shared/FilterClearButton'; -import { FilterDropdownSearchParams } from '../../shared/FilterDropdownSearchParams'; +import { Button } from 'react-bootstrap'; import useAppParams from '../../shared/hooks/useAppParams'; import { IdLabel } from '../../shared/types/IdLabel'; import { useGetDevicesQuery } from '../../store/apiSlice'; -import { debugConsts, debugSearchParams, logLevelOpts } from "./debugConsts"; +import { debugConsoleActions } from '../../store/debugConsole/debugConsoleSlice'; +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); + const dispatch = useAppDispatch(); const items = useMemo(() => { - if (!devices) return [{ id: debugConsts.GLOBAL, label: "Global"}]; + if (!devices) return [{ id: debugConsts.GLOBAL, label: 'Global' }]; - let fullList: IdLabel[] = [ - { id: debugConsts.GLOBAL, label: "Global"} - ]; + const deviceItems: IdLabel[] = devices + .map((d) => ({ id: d.Key, label: d.Name || d.Key })) + .sort((a, b) => a.label.localeCompare(b.label)); - devices.forEach((d) => { - fullList.push({ id: d.Key, label: d.Name}); - }); - - return fullList; + return [{ id: debugConsts.GLOBAL, label: 'Global' }, ...deviceItems]; }, [devices]); - if (!devices) return null; + if (!devices) return null; return (
- - - + +
); diff --git a/src/features/DebugConsole/DeviceFilterDropdown.tsx b/src/features/DebugConsole/DeviceFilterDropdown.tsx new file mode 100644 index 0000000..c14e72a --- /dev/null +++ b/src/features/DebugConsole/DeviceFilterDropdown.tsx @@ -0,0 +1,111 @@ +import { ChangeEvent, useState } from 'react'; +import { Badge } from 'react-bootstrap'; +import Dropdown from 'react-bootstrap/Dropdown'; +import Form from 'react-bootstrap/Form'; +import { IconDarkChevronDown } from '../../shared/icons'; +import { IdLabel } from '../../shared/types/IdLabel'; +import { + selectCheckedDevices, + selectDeviceLevels, +} from '../../store/debugConsole/debugConsoleSelectors'; +import { debugConsoleActions } from '../../store/debugConsole/debugConsoleSlice'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { logLevelOpts } from './debugConsts'; + +interface DeviceFilterDropdownProps { + items: IdLabel[]; +} + +export const DeviceFilterDropdown = ({ items }: DeviceFilterDropdownProps) => { + const dispatch = useAppDispatch(); + const checkedDevices = useAppSelector(selectCheckedDevices); + const deviceLevels = useAppSelector(selectDeviceLevels); + const [openLevelDropdowns, setOpenLevelDropdowns] = useState>({}); + + function handleCheckChange( + event: ChangeEvent, + deviceId: string + ) { + if (event.target.checked) { + dispatch(debugConsoleActions.checkDevice(deviceId)); + } else { + dispatch(debugConsoleActions.uncheckDevice(deviceId)); + } + } + + function handleLevelChange(deviceId: string, level: string) { + dispatch(debugConsoleActions.setDeviceLevel({ deviceId, level })); + setOpenLevelDropdowns((prev) => ({ ...prev, [deviceId]: false })); + } + + return ( + + + Devices + {checkedDevices.length > 0 && ( + + {checkedDevices.length} + + )} + + + + + {items.map((item) => { + const stringId = item.id.toString(); + const isChecked = checkedDevices.includes(stringId); + const currentLevel = deviceLevels[stringId] ?? 'Information'; + + return ( + e.stopPropagation()} + > + handleCheckChange(e, stringId)} + className="flex-grow-1 m-0" + /> + {isChecked && ( + + setOpenLevelDropdowns((prev) => ({ ...prev, [stringId]: isOpen })) + } + className="ms-auto" + > + + {currentLevel} + + + {logLevelOpts.map((opt) => ( + { + e.stopPropagation(); + handleLevelChange(stringId, opt.id.toString()); + }} + > + {opt.label} + + ))} + + )} + + ); + })} + + + ); +}; diff --git a/src/features/DebugConsole/debugConsts.ts b/src/features/DebugConsole/debugConsts.ts index c305d5e..4241db7 100644 --- a/src/features/DebugConsole/debugConsts.ts +++ b/src/features/DebugConsole/debugConsts.ts @@ -17,6 +17,15 @@ const DEBUG = "Debug"; const LOG_LEVELS = [ERROR, WARNING, INFORMATION, FATAL, VERBOSE, DEBUG]; +export const LOG_LEVEL_ORDER: Record = { + [VERBOSE]: 0, + [DEBUG]: 1, + [INFORMATION]: 2, + [WARNING]: 3, + [ERROR]: 4, + [FATAL]: 5, +}; + export const logLevelOpts: IdLabel[] = [ { id: FATAL, label: "Fatal" }, { id: ERROR, label: "Error" }, diff --git a/src/features/DebugConsole/hooks/useFilteredMessages.ts b/src/features/DebugConsole/hooks/useFilteredMessages.ts index 605c4dc..21c298e 100644 --- a/src/features/DebugConsole/hooks/useFilteredMessages.ts +++ b/src/features/DebugConsole/hooks/useFilteredMessages.ts @@ -1,51 +1,43 @@ import filter from 'lodash/filter'; import { useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; import { LogMessage } from '../../../shared/types/LogMessage'; -import { debugConsts } from '../debugConsts'; +import { + selectCheckedDevices, + selectDeviceLevels, + selectSearchText, +} from '../../../store/debugConsole/debugConsoleSelectors'; +import { useAppSelector } from '../../../store/hooks'; +import { debugConsts, LOG_LEVEL_ORDER } from '../debugConsts'; export function useFilteredMessages(listItems: LogMessage[]) { - const [searchParams] = useSearchParams(); + const checkedDevices = useAppSelector(selectCheckedDevices); + const deviceLevels = useAppSelector(selectDeviceLevels); + const searchText = useAppSelector(selectSearchText); return useMemo(() => { if (!listItems?.length) return []; - - const deviceValues = searchParams.getAll( - debugConsts.DEVICE - ); - const logLevelValues = searchParams.getAll( - debugConsts.LOG_LEVEL - ); - const searchText = searchParams.getAll(debugConsts.SEARCH_TEXT); - - // filter for other criteria const filtered = filter(listItems, (item) => { - - + // Device filter let deviceMatch = true; - // if (deviceValues.length) { - // deviceValues.forEach((val) => { - // // TODO: handle global - // // set deviceMatch to false if global and no key - // if((val === debugConsts.GLOBAL && item.Properties?.Key) || item.Properties?.Key !== val) - // deviceMatch = false; - - // }); - // } - - if (deviceValues.length) - deviceMatch = deviceValues.some((val) => { - if(!item.Properties?.Key) return val === debugConsts.GLOBAL; - return item.Properties?.Key === val; - }) + if (checkedDevices.length) { + deviceMatch = checkedDevices.some((val: string) => { + if (!item.Properties?.Key) return val === debugConsts.GLOBAL; + return item.Properties?.Key === val; + }); + } - let levelMatch = true; - if (logLevelValues.length) { - levelMatch = logLevelValues.includes(item.Level); + // Per-device minimum log level filter + let deviceLevelMatch = true; + const messageKey = item.Properties?.Key ?? debugConsts.GLOBAL; + const minLevel = deviceLevels[messageKey]; + if (minLevel !== undefined) { + const msgOrder = LOG_LEVEL_ORDER[item.Level] ?? -1; + const minOrder = LOG_LEVEL_ORDER[minLevel] ?? 0; + deviceLevelMatch = msgOrder >= minOrder; } - // Match search string on visible things + // Text search filter let textMatch = true; if (searchText.length) { const textMatchFields = [ @@ -53,19 +45,19 @@ export function useFilteredMessages(listItems: LogMessage[]) { item.Timestamp, item.Properties?.Key, ]; - // true if for every search text word, some field contains it - textMatch = searchText.every((st) => - textMatchFields.some((f) => f?.toLowerCase().includes(st)) - ); + textMatch = searchText + .split(' ') + .filter(Boolean) + .every((st: string) => + textMatchFields.some((f) => + f?.toLowerCase().includes(st.toLowerCase()) + ) + ); } - return ( - deviceMatch && - levelMatch && - textMatch - ); // && otherMatches + + return deviceMatch && deviceLevelMatch && textMatch; }); - // DONE return filtered; - }, [searchParams, listItems]); + }, [listItems, checkedDevices, deviceLevels, searchText]); } diff --git a/src/features/ErrorBoundary.tsx b/src/features/ErrorBoundary.tsx new file mode 100644 index 0000000..46087e6 --- /dev/null +++ b/src/features/ErrorBoundary.tsx @@ -0,0 +1,31 @@ +import { Component, ErrorInfo, ReactNode } from "react"; +import ErrorBox from "./ErrorBox"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; +} + +class ErrorBoundary extends Component { + state: State = { hasError: false }; + + static getDerivedStateFromError(): State { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("ErrorBoundary caught an error:", error, info.componentStack); + } + + render() { + if (this.state.hasError) { + return ; + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/features/InitializationExceptions.tsx b/src/features/InitializationExceptions.tsx new file mode 100644 index 0000000..6c372a3 --- /dev/null +++ b/src/features/InitializationExceptions.tsx @@ -0,0 +1,94 @@ +import { skipToken } from "@reduxjs/toolkit/query"; +import { useState } from "react"; +import useAppParams from "../shared/hooks/useAppParams"; +import { + EssentialsException, + useGetInitializationExceptionsQuery, +} from "../store/apiSlice"; + +const InitializationExceptions = () => { + const { appId } = useAppParams(); + const { data, isLoading, isError } = useGetInitializationExceptionsQuery( + appId ? { appId } : skipToken, + ); + + console.log("Initialization exceptions:", data?.Exceptions); + + const [expandedIndex, setExpandedIndex] = useState(null); + + if (isLoading) return
Loading…
; + if (isError) + return ( +
+ Failed to load initialization exceptions. +
+ ); + + if (!data || data.Exceptions.length === 0) { + return ( +
+ No initialization exceptions reported. +
+ ); + } + + return ( +
+

Initialization Exceptions

+
+ + + + + + + + + + {data.Exceptions.map((ex: EssentialsException, idx: number) => { + const isExpanded = expandedIndex === idx; + return ( + <> + + + + + + {isExpanded && ex.StackTrace && ( + + + + )} + + ); + })} + +
#MessageStack trace
{idx + 1} + + {ex.Message} + + + {ex.StackTrace && ( + + )} +
+
+                          {ex.StackTrace}
+                        
+
+
+
+ ); +}; + +export default InitializationExceptions; diff --git a/src/features/LoginForm.tsx b/src/features/LoginForm.tsx new file mode 100644 index 0000000..8e0d44f --- /dev/null +++ b/src/features/LoginForm.tsx @@ -0,0 +1,114 @@ +import { FormEvent, useState } from 'react'; +import { Alert, Button, Form, Spinner } from 'react-bootstrap'; +import { Navigate, useLocation, useNavigate } from 'react-router-dom'; +import useAppParams from '../shared/hooks/useAppParams'; +import { useSetLoginCredentialsMutation } from '../store/apiSlice'; +import { selectIsAuthenticated } from '../store/auth/authSelectors'; +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', +]; + +const LoginForm = () => { + const { appId } = useAppParams(); + const isAuthenticated = useAppSelector(selectIsAuthenticated); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const location = useLocation(); + + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const [setLoginCredentials] = useSetLoginCredentialsMutation(); + + const from = (location.state as { from?: Location })?.from?.pathname; + + const isValidAppId = appId && ALL_APP_IDS.includes(appId); + const probeAppId = isValidAppId ? appId : ALL_APP_IDS[0]; + + if (isAuthenticated) { + return ; + } + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setIsLoading(true); + + try { + // First authenticate using the current (or first) appId to confirm credentials are valid + await setLoginCredentials({ appId: probeAppId, username, password }).unwrap(); + } catch { + setIsLoading(false); + setError('Invalid credentials. Please try again.'); + return; + } + + // Credentials are valid β€” now probe all slots in parallel to discover which are running + const results = await Promise.allSettled( + ALL_APP_IDS.map((id) => + setLoginCredentials({ appId: id, username, password }).unwrap() + ) + ); + + const availableApps = ALL_APP_IDS.filter( + (_, i) => results[i].status === 'fulfilled' + ); + + setIsLoading(false); + dispatch(authActions.loginSuccess(availableApps)); + + const destination = from ?? `/${availableApps[0] ?? probeAppId}/versions`; + navigate(destination, { replace: true }); + } + + return ( +
+
+

Sign In

+ {error && {error}} +
+ + Username + setUsername(e.target.value)} + required + disabled={isLoading} + /> + + + Password + setPassword(e.target.value)} + required + disabled={isLoading} + /> + + +
+
+
+ ); +}; + +export default LoginForm; diff --git a/src/features/MobileControl.tsx b/src/features/MobileControl.tsx index 87dfa39..c8750dd 100644 --- a/src/features/MobileControl.tsx +++ b/src/features/MobileControl.tsx @@ -1,80 +1,296 @@ -import { skipToken } from '@reduxjs/toolkit/query'; -import useAppParams from '../shared/hooks/useAppParams'; -import { useGetMobileControlInfoQuery } from '../store/apiSlice'; - +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, + ClientResponse, + MobileControlClient, + useCreateMobileControlUiClientMutation, + useDeleteAllMobileControlUiClientsMutation, + useDeleteMobileControlUiClientMutation, + useGetMobileControlActionPathsQuery, + useGetMobileControlInfoQuery, +} from "../store/apiSlice"; const MobileControl = () => { const { appId } = useAppParams(); console.log("AppId in MobileControl", appId); - const { data: info } = useGetMobileControlInfoQuery(appId ? { appId, deviceKey: "appServer" } : skipToken); - console.log("Mobile Control Info", info); + const { data: info } = useGetMobileControlInfoQuery( + appId ? { appId, deviceKey: "appServer" } : skipToken, + ); + const { data: actionPaths } = useGetMobileControlActionPathsQuery( + appId ? { appId, deviceKey: "appServer" } : skipToken, + ); + + const [deleteClient] = useDeleteMobileControlUiClientMutation(); + const [deleteAllClients] = useDeleteAllMobileControlUiClientsMutation(); + const [createClient] = useCreateMobileControlUiClientMutation(); + const [pendingDelete, setPendingDelete] = + useState(null); + const [confirmDeleteAll, setConfirmDeleteAll] = useState(false); + const [showCreateModal, setShowCreateModal] = useState(false); + const [newRoomKey, setNewRoomKey] = useState(""); + const [newGrantCode, setNewGrantCode] = useState(""); + + const handleConfirmDelete = async () => { + if (!appId || !pendingDelete) return; + const clientPayload: ClientResponse = { + error: "", + token: pendingDelete.token, + path: "", + }; + await deleteClient({ + appId, + deviceKey: "appServer-directServer", + client: clientPayload, + }); + setPendingDelete(null); + }; + + const handleConfirmDeleteAll = async () => { + if (!appId) return; + await deleteAllClients({ appId, deviceKey: "appServer-directServer" }); + setConfirmDeleteAll(false); + }; - if (!info) { + const handleCreateClient = async () => { + if (!appId || !newRoomKey.trim()) return; + const request: ClientRequest = { + roomKey: newRoomKey.trim(), + grantCode: newGrantCode.trim(), + token: "", + }; + await createClient({ appId, deviceKey: "appServer-directServer", request }); + setShowCreateModal(false); + setNewRoomKey(""); + setNewGrantCode(""); + }; + + if (!info || !actionPaths) { return
Mobile Control Not Available
; } const { directServer: ds } = info; return ( -
+

Mobile Control

-
-
Direct Server
- - - - - - - - - - - - - - - - - - - -
User App URL{ds.userAppUrl}
Server Port{ds.serverPort}
Tokens Defined{ds.tokensDefined}
Clients Connected{ds.clientsConnected}
-
- -
-
Clients
- - - - - - - - - - - - {ds.clients.map((client) => ( - - - - - + {ds && ( +
+
Direct Server
+
#Room KeyTouchpanel KeyTokenURL
{client.clientNumber}{client.roomKey}{client.touchpanelKey}{client.token}
+ + + - ))} - -
User App URL - - {client.url} - + {ds.userAppUrl}
+ + Server Port + {ds.serverPort} + + + Tokens Defined + {ds.tokensDefined} + + + Clients Connected + {ds.clientsConnected} + + + +
+ )} + +
+
Clients
+
+ + + + + + + + + + + + + {ds.clients.map((client) => ( + + + + + + + + + ))} + +
#Room KeyTouchpanel Key + Token + URL +
+ + {ds.clients.length > 0 && ( + + )} +
+
{client.clientNumber}{client.roomKey}{client.touchpanelKey} + {client.token} + + + {client.url} + + + +
+
+ +
+
Action Paths
+
+ + + + + + + + + {actionPaths.actionPaths.map((ap: ActionPath) => ( + + + + + ))} + +
Messenger KeyPath
{ap.messengerKey} + {ap.path} +
+
+
+ + setPendingDelete(null)} + centered + > + + Delete Client + + + Are you sure you want to delete client{" "} + {pendingDelete?.clientNumber}? + + + + + + + + setConfirmDeleteAll(false)} + centered + > + + Delete All Clients + + + Are you sure you want to delete all clients? + + + + + + + + setShowCreateModal(false)} + centered + > + + New Client + + +
+ + setNewRoomKey(e.target.value)} + placeholder="e.g. room1" + /> +
+
+ + setNewGrantCode(e.target.value)} + placeholder="e.g. abc123" + /> +
+
+ + + + +
); -} +}; -export default MobileControl; \ No newline at end of file +export default MobileControl; diff --git a/src/features/RequireAuth.tsx b/src/features/RequireAuth.tsx new file mode 100644 index 0000000..e8a90ba --- /dev/null +++ b/src/features/RequireAuth.tsx @@ -0,0 +1,26 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import useAppParams from '../shared/hooks/useAppParams'; +import { selectIsAuthenticated } from '../store/auth/authSelectors'; +import { useAppSelector } from '../store/hooks'; + +const RequireAuth = () => { + const { appId } = useAppParams(); + const isAuthenticated = useAppSelector(selectIsAuthenticated); + const location = useLocation(); + + if (!isAuthenticated) { + const loginPath = + appId && appId !== 'undefined' ? `/${appId}/login` : '/login'; + return ( + + ); + } + + return ; +}; + +export default RequireAuth; diff --git a/src/features/Routing.module.scss b/src/features/Routing.module.scss new file mode 100644 index 0000000..eb8b295 --- /dev/null +++ b/src/features/Routing.module.scss @@ -0,0 +1,79 @@ +.signalTypesLabel { + font-size: 0.8rem; +} + +.dropdownToggle { + font-size: 0.75rem; +} + +.dropdownMenu { + width: 280px; + padding: 0; +} + +.dropdownAction { + font-size: 0.75rem; +} + +.deviceListScroller { + overflow-y: auto; + max-height: 320px; +} + +.deviceItem { + font-size: 0.78rem; + padding-left: 2rem !important; +} + +.deviceKeyLabel { + font-size: 0.7rem; +} + +.noDevicesMessage { + font-size: 0.78rem; +} + +// Signal type toggle buttons. +// --signal-color is set as an inline CSS variable per button. +.signalTypeBtn { + font-size: 0.75rem; + padding: 2px 10px; + border-color: var(--signal-color) !important; + background-color: var(--signal-color) !important; + color: #fff !important; + + &.signalTypeBtnHidden { + background-color: transparent !important; + color: var(--signal-color) !important; + } +} + +.hideUnconnectedSwitch { + font-size: 0.8rem; +} + +.tieLineTooltip { + position: absolute; + background: rgba(30, 30, 30, 0.92); + color: #f0f0f0; + border-radius: 5px; + padding: 6px 10px; + font-size: 0.72rem; + pointer-events: none; + white-space: nowrap; + z-index: 10; + line-height: 1.6; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); +} + +.tieLineTooltipRow { + display: flex; + gap: 8px; +} + +.tieLineTooltipLabel { + color: #aaa; + min-width: 52px; + text-align: right; + flex-shrink: 0; +} diff --git a/src/features/Routing.tsx b/src/features/Routing.tsx new file mode 100644 index 0000000..4672ebb --- /dev/null +++ b/src/features/Routing.tsx @@ -0,0 +1,604 @@ +import "@xyflow/react/dist/style.css"; + +import { skipToken } from "@reduxjs/toolkit/query"; +import { + Background, + Controls, + Edge, + EdgeTypes, + MiniMap, + Node, + NodeTypes, + ReactFlow, + useEdgesState, + useNodesState, +} from "@xyflow/react"; +import dagre from "dagre"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Dropdown } from "react-bootstrap"; + +import { meetsMinVersion } from '../shared/functions/meetsMinimumVersion'; +import useAppParams from "../shared/hooks/useAppParams"; +import { + RoutingDevice, + RoutingDevicesAndTieLines, + TieLine, + useGetRoutingDevicesAndTieLinesQuery, + useGetVersionsQuery, +} from "../store/apiSlice"; +import styles from "./Routing.module.scss"; +import RoutingDeviceNode, { + HEADER_PX, + PORT_ROW_PX, + RoutingDeviceNodeData, +} from "./RoutingDeviceNode"; +import TieLineEdge from "./TieLineEdge"; + +// ─── Constants ────────────────────────────────────────────────────────────── + +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, + ); + return HEADER_PX + rows * PORT_ROW_PX; +} + +function makeGraph() { + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + g.setGraph({ rankdir: "LR", nodesep: NODE_SEP, ranksep: RANK_SEP }); + return g; +} + +function buildGraph( + data: RoutingDevicesAndTieLines, + hiddenTypes: Set, + hideUnconnected: boolean, + hiddenDevices: Set, + hideUnconnectedPorts: boolean, +): { nodes: Node[]; edges: Edge[] } { + // Devices that appear in at least one *visible* tie line endpoint + const connectedKeys = new Set( + data.tieLines + .filter((tl) => !hiddenTypes.has(tl.signalType)) + .flatMap((tl) => [tl.sourceDeviceKey, tl.destinationDeviceKey]), + ); + + // Tie lines that pass all active filters (used for port-level filtering) + const visibleTieLines = data.tieLines.filter( + (tl) => + !hiddenTypes.has(tl.signalType) && + !hiddenDevices.has(tl.sourceDeviceKey) && + !hiddenDevices.has(tl.destinationDeviceKey), + ); + const connectedPortKeys = hideUnconnectedPorts + ? new Set( + visibleTieLines.flatMap((tl) => [ + `${tl.sourceDeviceKey}:${tl.sourcePortKey}`, + `${tl.destinationDeviceKey}:${tl.destinationPortKey}`, + ]), + ) + : null; + + const devices = data.devices.filter((d) => { + if (hiddenDevices.has(d.key)) return false; + if (hideUnconnected && !connectedKeys.has(d.key)) return false; + return true; + }); + + // Filter each device's ports down to only those with active tie lines + const effectiveDevices = devices.map((d) => + connectedPortKeys + ? { + ...d, + inputPorts: (d.inputPorts ?? []).filter((p) => + connectedPortKeys.has(`${d.key}:${p.key}`), + ), + outputPorts: (d.outputPorts ?? []).filter((p) => + connectedPortKeys.has(`${d.key}:${p.key}`), + ), + } + : d, + ); + + // Collect one unique device-pair edge per sourceβ†’destination (dagre only + // needs connectivity, not multiplicity, for rank assignment). + const uniquePairs = [ + ...new Set( + data.tieLines.map( + (tl) => `${tl.sourceDeviceKey}|${tl.destinationDeviceKey}`, + ), + ), + ]; + + // ── Pass 1: layout with all edges to detect cross-level (backwards) edges ── + const g1 = makeGraph(); + for (const device of effectiveDevices) { + g1.setNode(device.key, { width: NODE_WIDTH, height: nodeHeight(device) }); + } + for (const pair of uniquePairs) { + const [src, dst] = pair.split("|"); + g1.setEdge(src, dst); + } + dagre.layout(g1); + + // Any edge where the source's computed x is greater than the destination's x + // (with a small tolerance) is a "backwards" cross-level edge that would pull + // 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 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 ────── + const g = makeGraph(); + for (const device of effectiveDevices) { + g.setNode(device.key, { width: NODE_WIDTH, height: nodeHeight(device) }); + } + for (const pair of uniquePairs) { + if (!backwardPairs.has(pair)) { + const [src, dst] = pair.split("|"); + g.setEdge(src, dst); + } + } + dagre.layout(g); + + // Map dagre positions β†’ React Flow nodes + const nodes: Node[] = effectiveDevices.map((device) => { + const pos = g.node(device.key); + return { + id: device.key, + 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 + .filter( + (tl) => + !hiddenTypes.has(tl.signalType) && + !hiddenDevices.has(tl.sourceDeviceKey) && + !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, + })); + + return { nodes, edges }; +} + +// ─── Signal type toggle button list ───────────────────────────────────────── + +function uniqueSignalTypes(tieLines: TieLine[]): string[] { + return [...new Set(tieLines.map((tl) => tl.signalType))].sort(); +} + + +// ─── Node types registry (stable reference outside component) ──────────────── + +const nodeTypes: NodeTypes = { + routingDevice: RoutingDeviceNode, +}; + +const edgeTypes: EdgeTypes = { + tieLine: TieLineEdge, +}; + +// ─── Component ─────────────────────────────────────────────────────────────── + +const Routing = () => { + const { appId } = useAppParams(); + const { data: versions } = useGetVersionsQuery(appId ? { appId } : skipToken); + const { data, isLoading, isError } = 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 [hideUnconnectedPorts, setHideUnconnectedPorts] = useState(false); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [darkMode, setDarkMode] = useState(true); + + const sortedDevices = useMemo( + () => + [...(data?.devices ?? [])].sort((a, b) => + (a.name || a.key).localeCompare(b.name || b.key), + ), + [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), + ); + }, [sortedDevices, deviceSearch]); + + const signalTypes = useMemo( + () => (data ? uniqueSignalTypes(data.tieLines) : []), + [data], + ); + + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges] = useEdgesState([]); + + // 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. + useEffect(() => { + if (!data) return; + const { nodes: layoutNodes, edges: layoutEdges } = buildGraph( + data, + hiddenTypes, + hideUnconnected, + hiddenDevices, + hideUnconnectedPorts, + ); + setNodes( + layoutNodes.map((n) => ({ + ...n, + data: { + ...n.data, + darkMode, + onHide: () => + setHiddenDevices((prev) => { + const next = new Set(prev); + next.add(n.id); + return next; + }), + }, + })), + ); + setEdges(layoutEdges); + setSelectedEdgeId(null); + }, [data, hiddenTypes, hideUnconnected, hiddenDevices, hideUnconnectedPorts, darkMode, setNodes, setEdges]); + + // Re-style edges when selection changes without triggering a layout rebuild. + 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) { + return { + ...e, + data: { ...e.data, selected: false }, + style: { stroke: baseColor, strokeWidth: 1.5 }, + }; + } + return { + ...e, + data: { ...e.data, selected: isSelected }, + style: { + stroke: isSelected ? baseColor : "#ccc", + strokeWidth: isSelected ? 3.5 : 1.5, + opacity: isSelected ? 1 : 0.35, + }, + }; + }), + ); + }, [selectedEdgeId, setEdges]); + + const onEdgeClick = useCallback( + (_: React.MouseEvent, edge: Edge) => + setSelectedEdgeId((prev) => (prev === edge.id ? null : edge.id)), + [], + ); + + const onPaneClick = useCallback(() => setSelectedEdgeId(null), []); + + 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, + ); + }), + [setEdges], + ); + + const onEdgeMouseLeave = useCallback( + (_: React.MouseEvent, edge: Edge) => + setEdges((eds) => + eds.map((e) => + e.id === edge.id ? { ...e, data: { ...e.data, hovered: false } } : e, + ), + ), + [setEdges], + ); + + function toggleSignalType(type: string) { + setHiddenTypes((prev) => { + const next = new Set(prev); + if (next.has(type)) { + next.delete(type); + } else { + next.add(type); + } + return next; + }); + } + + function toggleDevice(key: string) { + setHiddenDevices((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + } + + function selectAllDevices() { + setHiddenDevices(new Set()); + } + + function deselectAllDevices() { + setHiddenDevices(new Set((data?.devices ?? []).map((d) => d.key))); + } + + const visibleDeviceCount = (data?.devices.length ?? 0) - hiddenDevices.size; + + if (isLoading) return
Loading routing data…
; + if (isError || !data) + return
Failed to load routing data.
; + + + if ( + versions && + !versions.some( + (v) => + v.Name === "PepperDashEssentials.dll" && + meetsMinVersion(v.Version, "2.29"), + ) + ) { + return ( +
+ Routing feature is not available for this version. +
+ ); + } + + return ( +
+ {/* Signal type filter bar */} +
+
+ + Signal types: + + {/* Device filter dropdown */} + + {signalTypes.map((type) => { + const hidden = hiddenTypes.has(type); + const color = signalColor(type); + return ( + + ); + })} +
+
+ + Filters: + + + 0 ? "warning" : "outline-secondary"} + className={styles.dropdownToggle} + > + Devices ({visibleDeviceCount}/{data.devices.length}) + + +
+ setDeviceSearch(e.target.value)} + /> +
+ + +
+
+
+ {filteredDropdownDevices.map((device, idx) => ( +
+ toggleDevice(device.key)} + /> + +
+ ))} + {filteredDropdownDevices.length === 0 && ( +
+ No devices match. +
+ )} +
+
+
+
+
+
+ setHideUnconnected(e.target.checked)} + /> + +
+
+ setHideUnconnectedPorts(e.target.checked)} + /> + +
+
+ setDarkMode(e.target.checked)} + /> + +
+
+
+ + {/* React Flow canvas */} +
+ + + + + +
+
+ ); +}; + +export default Routing; diff --git a/src/features/RoutingDeviceNode.module.scss b/src/features/RoutingDeviceNode.module.scss new file mode 100644 index 0000000..b46bb98 --- /dev/null +++ b/src/features/RoutingDeviceNode.module.scss @@ -0,0 +1,92 @@ +.nodeCard { + width: 280px; + font-size: 0.72rem; +} + +.nodeCardDark { + background-color: #1e1e1e !important; + border-color: #444 !important; + color: #e0e0e0; +} + +.nodeHeader { + line-height: 1.4; + overflow: hidden; +} + +.nodeHeaderDark { + background-color: #2c2c2c; + color: #e0e0e0; +} + +.hideBtn { + flex-shrink: 0; + background: none; + border: none; + padding: 0 2px; + margin-top: 1px; + line-height: 1; + font-size: 1.1rem; + color: #555; + cursor: pointer; + + &:hover { + color: #dc3545; + } +} + +.nodeKeyLabel { + font-size: 0.65rem; +} + +.handle { + background: #555; +} + +// Border added between port rows via adjacent-sibling selector, +// removing the need for a conditional inline style. +.portRow { + & + & { + border-top: 1px solid #f0f0f0; + } +} + +.portRowDark { + & + & { + border-top: 1px solid #333; + } +} + +.portLabelWrap { + position: relative; + max-width: 46%; + min-width: 0; + + &:hover .portTooltip { + opacity: 1; + } +} + +.portLabelText { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.portTooltip { + position: absolute; + bottom: calc(100% + 4px); + left: 50%; + transform: translateX(-50%); + background: rgba(30, 30, 30, 0.92); + color: #f0f0f0; + border-radius: 4px; + padding: 3px 8px; + font-size: 0.68rem; + white-space: nowrap; + pointer-events: none; + z-index: 100; + opacity: 0; + transition: opacity 0.12s; +} diff --git a/src/features/RoutingDeviceNode.tsx b/src/features/RoutingDeviceNode.tsx new file mode 100644 index 0000000..78fc558 --- /dev/null +++ b/src/features/RoutingDeviceNode.tsx @@ -0,0 +1,118 @@ +import { Handle, NodeProps, Position } from "@xyflow/react"; +import { RoutingDevice } from "../store/apiSlice"; +import styles from "./RoutingDeviceNode.module.scss"; + +export type RoutingDeviceNodeData = { + device: RoutingDevice; + onHide?: () => void; + darkMode?: boolean; +}; + +const PORT_ROW_PX = 28; +const HEADER_PX = 38; + +const RoutingDeviceNode = ({ data }: NodeProps) => { + const { device, onHide, darkMode } = data as RoutingDeviceNodeData; + const inputPorts = device.inputPorts ?? []; + const outputPorts = device.outputPorts ?? []; + const portRows = Math.max(inputPorts.length, outputPorts.length, 1); + const bodyHeight = portRows * PORT_ROW_PX; + + return ( +
+
+
+
{device.name || device.key}
+ {device.name && ( +
+ {device.key} +
+ )} +
+ {onHide && ( + + )} +
+ +
+ {/* Input port handles (left side) */} + {inputPorts.map((port, i) => { + const topPct = ((i + 0.5) / portRows) * 100; + return ( + + ); + })} + + {/* Port label rows */} + {Array.from({ length: portRows }).map((_, i) => { + const inPort = inputPorts[i]; + const outPort = outputPorts[i]; + return ( +
+
+ + {inPort?.key ?? ""} + + {inPort && ( + {inPort.signalType} + )} +
+
+ + {outPort?.key ?? ""} + + {outPort && ( + {outPort.signalType} + )} +
+
+ ); + })} + + {/* Output port handles (right side) */} + {outputPorts.map((port, i) => { + const topPct = ((i + 0.5) / portRows) * 100; + return ( + + ); + })} +
+
+ ); +}; + +export { HEADER_PX, PORT_ROW_PX }; +export default RoutingDeviceNode; diff --git a/src/features/TieLineEdge.tsx b/src/features/TieLineEdge.tsx new file mode 100644 index 0000000..64e93a9 --- /dev/null +++ b/src/features/TieLineEdge.tsx @@ -0,0 +1,83 @@ +import { + BaseEdge, + EdgeLabelRenderer, + EdgeProps, + getBezierPath, +} from "@xyflow/react"; +import styles from "./Routing.module.scss"; + +export interface TieLineEdgeData { + signalColor: string; + sourceDeviceKey: string; + sourcePortKey: string; + destinationDeviceKey: string; + destinationPortKey: string; + signalType: string; + hovered?: boolean; + selected?: boolean; + [key: string]: unknown; +} + +const TieLineEdge = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style, + data, + markerEnd, +}: EdgeProps) => { + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + const d = data as TieLineEdgeData | undefined; + const showTooltip = (d?.hovered || d?.selected) ?? false; + + return ( + <> + + {showTooltip && d && ( + +
+
+ Type + {d.signalType} +
+
+ Source + {d.sourceDeviceKey} +
+
+ Src Port + {d.sourcePortKey} +
+
+ Dest + {d.destinationDeviceKey} +
+
+ Dst Port + {d.destinationPortKey} +
+
+
+ )} + + ); +}; + +export default TieLineEdge; diff --git a/src/features/TopNav.tsx b/src/features/TopNav.tsx index ef68d8d..956aa26 100644 --- a/src/features/TopNav.tsx +++ b/src/features/TopNav.tsx @@ -1,52 +1,41 @@ 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"; const TopNav = ({ isConnected }: { isConnected: boolean }) => { const location = useLocation(); const params = useAppParams(); + const availableApps = useAppSelector(selectAvailableApps); - // make a call to get the version for each appId and for every valid response add the appId to the dropdown options - // this will ensure that only appIds with a running instance will be shown in the dropdown - const { data: app01versions } = useGetVersionsQuery({ appId: "app01" }); - const { data: app02versions } = useGetVersionsQuery({ appId: "app02" }); - const { data: app03versions } = useGetVersionsQuery({ appId: "app03" }); - const { data: app04versions } = useGetVersionsQuery({ appId: "app04" }); - const { data: app05versions } = useGetVersionsQuery({ appId: "app05" }); - const { data: app06versions } = useGetVersionsQuery({ appId: "app06" }); - const { data: app07versions } = useGetVersionsQuery({ appId: "app07" }); - const { data: app08versions } = useGetVersionsQuery({ appId: "app08" }); - const { data: app09versions } = useGetVersionsQuery({ appId: "app09" }); - const { data: app10versions } = useGetVersionsQuery({ appId: "app10" }); + // 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 } + ); + + const appIdOptions = availableApps; + + // Extract the current sub-route (e.g. "routing", "console") so we can + // preserve it when switching apps. Falls back to "console" if not on an + // app-scoped route yet. + const currentSubRoute = useMemo(() => { + const match = location.pathname.match(/^\/app\d+\/(.+)/); + return match ? match[1] : "console"; + }, [location.pathname]); - const appIdOptions = useMemo(() => { - const options: appIds[] = []; - if (app01versions) options.push("app01"); - if (app02versions) options.push("app02"); - if (app03versions) options.push("app03"); - if (app04versions) options.push("app04"); - if (app05versions) options.push("app05"); - if (app06versions) options.push("app06"); - if (app07versions) options.push("app07"); - if (app08versions) options.push("app08"); - if (app09versions) options.push("app09"); - if (app10versions) options.push("app10"); - return options; - }, [ - app01versions, - app02versions, - app03versions, - app04versions, - app05versions, - app06versions, - app07versions, - app08versions, - app09versions, - app10versions, - ]); + const showInitializationExceptions = useMemo(() => { + const essentialsVersion = currentVersions?.find( + (v) => v.Name === "PepperDashEssentials.dll", + )?.Version; + if (!essentialsVersion) return false; + return meetsMinVersion(essentialsVersion, "3.0.0"); + }, [currentVersions]); return ( { - {appIdOptions.map((id) => ( - + {appIdOptions.map((id: string) => ( + {id} ))} @@ -88,6 +81,30 @@ const TopNav = ({ isConnected }: { isConnected: boolean }) => { > Versions + + API Paths + + {showInitializationExceptions && ( + + Initialization Exceptions + + )} { > Types + + Routing + { className={isConnected ? "text-success" : "text-danger"} /> - {isConnected ? "Connected" : "Disconnected"} + Debug Console {isConnected ? "Connected" : "Disconnected"}
@@ -153,15 +180,3 @@ const TopNav = ({ isConnected }: { isConnected: boolean }) => { }; export default TopNav; - -type appIds = - | "app01" - | "app02" - | "app03" - | "app04" - | "app05" - | "app06" - | "app07" - | "app08" - | "app09" - | "app10"; diff --git a/src/shared/functions/meetsMinimumVersion.ts b/src/shared/functions/meetsMinimumVersion.ts new file mode 100644 index 0000000..9dc48b8 --- /dev/null +++ b/src/shared/functions/meetsMinimumVersion.ts @@ -0,0 +1,12 @@ +// 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)); + 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/store/apiSlice.ts b/src/store/apiSlice.ts index 9cbee29..3210514 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -2,7 +2,6 @@ import { createApi } from "@reduxjs/toolkit/query/react"; import { axiosBaseQuery } from "../services/httpService"; - function getAppIdFromPath(): string { const path = window.location.pathname; const pathParts = path.split("/"); @@ -26,9 +25,18 @@ const apiSlice = createApi({ "DebugSession", "DoNotLoadConfigOnNextBoot", "MinimumLogLevel", + "MobileControlInfo", ], endpoints: (builder) => ({ - getVersions: builder.query({ + getPaths: builder.query({ + query: ({ appId }) => ({ + url: `/${appId}/api/apiPaths`, + method: "GET", + }), + }), + + + getVersions: builder.query({ query: ({ appId }) => ({ url: `/${appId}/api/versions`, method: "GET", @@ -36,7 +44,17 @@ const apiSlice = createApi({ providesTags: ["Version"], }), - getDevices: builder.query({ + getInitializationExceptions: builder.query< + EssentialsExceptionReturn, + { appId: string } + >({ + query: ({ appId }) => ({ + url: `/${appId}/api/initializationExceptions`, + method: "GET", + }), + }), + + getDevices: builder.query({ query: ({ appId }) => ({ url: `/${appId}/api/devices`, method: "GET", @@ -44,7 +62,7 @@ const apiSlice = createApi({ providesTags: ["Device"], }), - getTypes: builder.query({ + getTypes: builder.query({ query: ({ appId }) => ({ url: `/${appId}/api/types`, method: "GET", @@ -52,7 +70,10 @@ const apiSlice = createApi({ providesTags: ["Type"], }), - getDeviceProperties: builder.query({ + getDeviceProperties: builder.query< + DeviceProperties[], + { appId: string; key: string } + >({ query: ({ appId, key }) => ({ url: `/${appId}/api/deviceProperties/${key}`, method: "GET", @@ -60,14 +81,20 @@ const apiSlice = createApi({ providesTags: ["DeviceProperty"], }), - getDeviceMethods: builder.query({ + getDeviceMethods: builder.query< + DeviceMethods[], + { appId: string; key: string } + >({ query: ({ appId, key }) => ({ url: `/${appId}/api/deviceMethods/${key}`, method: "GET", }), }), - getDeviceFeedbacks: builder.query({ + getDeviceFeedbacks: builder.query< + DeviceFeedbacks, + { appId: string; key: string } + >({ query: ({ appId, key }) => ({ url: `/${appId}/api/deviceFeedbacks/${key}`, method: "GET", @@ -75,15 +102,33 @@ const apiSlice = createApi({ providesTags: ["DeviceFeedback"], }), - setDeviceJsonCommand: builder.mutation({ + setDeviceJsonCommand: builder.mutation< + void, + { + appId: string; + deviceKey: string; + methodName: string; + params?: unknown[]; + } + >({ query: ({ appId, deviceKey, methodName, params }) => ({ url: `/${appId}/api/deviceCommands/${deviceKey}`, method: "POST", - data: {deviceKey, methodName, params}, + data: { deviceKey, methodName, params }, + }), + }), + + getRoutingDevicesAndTieLines: builder.query< + RoutingDevicesAndTieLines, + { appId: string } + >({ + query: ({ appId }) => ({ + url: `/${appId}/api/routingDevicesAndTieLines`, + method: "GET", }), }), - getConfig: builder.query({ + getConfig: builder.query({ query: ({ appId }) => ({ url: `/${appId}/api/config`, method: "GET", @@ -91,28 +136,38 @@ const apiSlice = createApi({ providesTags: ["Config"], }), - getMobileControlInfo: builder.query({ + getMobileControlInfo: builder.query< + MobileControlInfo, + { appId: string; deviceKey: string } + >({ query: ({ appId, deviceKey }) => ({ url: `/${appId}/api/device/${deviceKey}/info`, method: "GET", }), + providesTags: ["MobileControlInfo"], }), - getMobileControlActionPaths: builder.query({ + getMobileControlActionPaths: builder.query< + MobileControlActionPaths, + { appId: string; deviceKey: string } + >({ query: ({ appId, deviceKey }) => ({ url: `/${appId}/api/device/${deviceKey}/actionPaths`, method: "GET", }), }), - getDebugSession: builder.mutation({ + getDebugSession: builder.mutation({ query: ({ appId }) => ({ url: `/${appId}/api/debugSession`, method: "GET", }), }), - getMinimumLogLevel: builder.query<{ minimumLevel: LogEventLevel }, {appId: string}>({ + getMinimumLogLevel: builder.query< + { minimumLevel: LogEventLevel }, + { appId: string } + >({ query: ({ appId }) => ({ url: `/${appId}/api/appdebug`, method: "GET", @@ -120,7 +175,21 @@ const apiSlice = createApi({ providesTags: ["MinimumLogLevel"], }), - setMinimumLogLevel: builder.mutation({ + setLoginCredentials: builder.mutation< + void, + { appId: string; username: string; password: string } + >({ + query: ({ appId, username, password }) => ({ + url: `/${appId}/api/login`, + method: "POST", + data: { username, password }, + }), + }), + + setMinimumLogLevel: builder.mutation< + void, + { appId: string; minimumLevel: LogEventLevel } + >({ query: ({ appId, minimumLevel }) => ({ url: `/${appId}/api/appdebug`, method: "POST", @@ -129,7 +198,7 @@ const apiSlice = createApi({ invalidatesTags: ["MinimumLogLevel"], }), - stopDebugSession: builder.mutation({ + stopDebugSession: builder.mutation({ query: ({ appId }) => ({ url: `/${appId}/api/debugSession`, method: "POST", @@ -138,7 +207,7 @@ const apiSlice = createApi({ getDoNotLoadConfigOnNextBoot: builder.query< { doNotLoadConfigOnNextBoot: boolean }, - {appId: string} + { appId: string } >({ query: ({ appId }) => ({ url: `/${appId}/api/doNotLoadConfigOnNextBoot`, @@ -147,7 +216,10 @@ const apiSlice = createApi({ providesTags: ["DoNotLoadConfigOnNextBoot"], }), - setDoNotLoadConfigOnNextBoot: builder.mutation({ + setDoNotLoadConfigOnNextBoot: builder.mutation< + void, + { appId: string; doNotLoadConfigOnNextBoot: boolean } + >({ query: ({ appId, doNotLoadConfigOnNextBoot }) => ({ url: `/${appId}/api/doNotLoadConfigOnNextBoot`, method: "POST", @@ -156,24 +228,61 @@ const apiSlice = createApi({ invalidatesTags: ["DoNotLoadConfigOnNextBoot"], }), - setRestart: builder.mutation({ + setRestart: builder.mutation({ query: ({ appId }) => ({ url: `/${appId}/api/restartProgram`, method: "POST", }), }), - setLoadConfig: builder.mutation({ + setLoadConfig: builder.mutation({ query: ({ appId }) => ({ url: `/${appId}/api/loadConfig`, method: "POST", }), }), + + createMobileControlUiClient: builder.mutation< + ClientResponse, + { appId: string; deviceKey: string, request: ClientRequest } + >({ + query: ({ appId, deviceKey, request }) => ({ + url: `/${appId}/api/device/${deviceKey}/client`, + method: "POST", + data: request, + }), + invalidatesTags: ["MobileControlInfo"], + }), + + deleteMobileControlUiClient: builder.mutation< + void, + { appId: string; deviceKey: string; client: ClientResponse } + >({ + query: ({ appId, deviceKey, client }) => ({ + url: `/${appId}/api/device/${deviceKey}/client`, + method: "DELETE", + data: client, + }), + invalidatesTags: ["MobileControlInfo"], + }), + + deleteAllMobileControlUiClients: builder.mutation< + void, + { appId: string; deviceKey: string } + >({ + query: ({ appId, deviceKey }) => ({ + url: `/${appId}/api/device/${deviceKey}/deleteAllUiClients`, + method: "DELETE", + }), + invalidatesTags: ["MobileControlInfo"], + }), }), }); export const { + useGetPathsQuery, useGetVersionsQuery, + useGetInitializationExceptionsQuery, useGetDevicesQuery, useGetTypesQuery, useGetDevicePropertiesQuery, @@ -191,6 +300,11 @@ export const { useSetLoadConfigMutation, useGetMinimumLogLevelQuery, useSetMinimumLogLevelMutation, + useGetRoutingDevicesAndTieLinesQuery, + useCreateMobileControlUiClientMutation, + useDeleteMobileControlUiClientMutation, + useDeleteAllMobileControlUiClientsMutation, + useSetLoginCredentialsMutation, } = apiSlice; export const oneSliceToRuleThemAll = { @@ -199,6 +313,34 @@ export const oneSliceToRuleThemAll = { getBaseApiPath, }; +export interface PathsReturn { + url: string; + routes: Route[]; +} + +export interface Route { + DataTokens: { + Name: string; + }; + Url: string; + Name: string; + RouteHandler: unknown; +} + +export interface EssentialsExceptionReturn { + Exceptions: EssentialsException[]; +} + +export interface EssentialsException extends EssentialsExceptionBase { + InnerException?: EssentialsExceptionBase; +} + +export interface EssentialsExceptionBase { + Message: string; + StackTrace: string; + Type: string; +} + export interface Type { Type: string; Description: string; @@ -269,6 +411,58 @@ export interface MobileControlInfo { directServer: MobileControlDirectServer; } +export interface MobileControlActionPaths { + actionPaths: ActionPath[]; +} + +export interface ActionPath { + messengerKey: string; + path: string; +} + +export interface ClientRequest { + roomKey: string; + grantCode: string; + token: string; +} + +export interface ClientResponse { + error: string; + token: string; + path: string; +} + +export interface RoutingPort { + key: string; + signalType: string; + connectionType: string; + isInternal: boolean; +} + +export interface RoutingDevice { + key: string; + name: string; + hasInputs: boolean; + hasOutputs: boolean; + hasInputsAndOutputs: boolean; + inputPorts?: RoutingPort[]; + outputPorts?: RoutingPort[]; +} + +export interface TieLine { + sourceDeviceKey: string; + sourcePortKey: string; + destinationDeviceKey: string; + destinationPortKey: string; + signalType: string; + isInternal: boolean; +} + +export interface RoutingDevicesAndTieLines { + devices: RoutingDevice[]; + tieLines: TieLine[]; +} + export type LogEventLevel = | "Verbose" | "Debug" diff --git a/src/store/auth/authSelectors.ts b/src/store/auth/authSelectors.ts new file mode 100644 index 0000000..cf54998 --- /dev/null +++ b/src/store/auth/authSelectors.ts @@ -0,0 +1,14 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { RootState } from '../store'; + +const selectAuth = (state: RootState) => state.auth; + +export const selectIsAuthenticated = createSelector( + selectAuth, + (auth) => auth.isAuthenticated +); + +export const selectAvailableApps = createSelector( + selectAuth, + (auth) => auth.availableApps +); diff --git a/src/store/auth/authSlice.ts b/src/store/auth/authSlice.ts new file mode 100644 index 0000000..192db32 --- /dev/null +++ b/src/store/auth/authSlice.ts @@ -0,0 +1,26 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface AuthState { + isAuthenticated: boolean; + availableApps: string[]; +} + +const initialState: AuthState = { + isAuthenticated: false, + availableApps: [], +}; + +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + loginSuccess: (state, action: PayloadAction) => { + state.isAuthenticated = true; + state.availableApps = action.payload; + }, + logout: () => initialState, + }, +}); + +export const authActions = authSlice.actions; +export const authReducer = authSlice.reducer; diff --git a/src/store/debugConsole/debugConsoleSelectors.ts b/src/store/debugConsole/debugConsoleSelectors.ts new file mode 100644 index 0000000..8f5c3b1 --- /dev/null +++ b/src/store/debugConsole/debugConsoleSelectors.ts @@ -0,0 +1,19 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { RootState } from '../store'; + +const selectDebugConsole = (state: RootState) => state.debugConsole; + +export const selectCheckedDevices = createSelector( + selectDebugConsole, + (s) => s.checkedDevices +); + +export const selectDeviceLevels = createSelector( + selectDebugConsole, + (s) => s.deviceLevels +); + +export const selectSearchText = createSelector( + selectDebugConsole, + (s) => s.searchText +); diff --git a/src/store/debugConsole/debugConsoleSlice.ts b/src/store/debugConsole/debugConsoleSlice.ts new file mode 100644 index 0000000..e9ec9ea --- /dev/null +++ b/src/store/debugConsole/debugConsoleSlice.ts @@ -0,0 +1,46 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface DebugConsoleState { + checkedDevices: string[]; + deviceLevels: Record; + searchText: string; +} + +const initialState: DebugConsoleState = { + checkedDevices: [], + deviceLevels: {}, + searchText: '', +}; + +const debugConsoleSlice = createSlice({ + name: 'debugConsole', + initialState, + reducers: { + checkDevice: (state, action: PayloadAction) => { + const id = action.payload; + if (!state.checkedDevices.includes(id)) { + state.checkedDevices.push(id); + state.deviceLevels[id] = 'Information'; + } + }, + uncheckDevice: (state, action: PayloadAction) => { + const id = action.payload; + state.checkedDevices = state.checkedDevices.filter((d) => d !== id); + delete state.deviceLevels[id]; + }, + setDeviceLevel: ( + state, + action: PayloadAction<{ deviceId: string; level: string }> + ) => { + const { deviceId, level } = action.payload; + state.deviceLevels[deviceId] = level; + }, + setSearchText: (state, action: PayloadAction) => { + state.searchText = action.payload; + }, + clearAllFilters: () => initialState, + }, +}); + +export const debugConsoleActions = debugConsoleSlice.actions; +export const debugConsoleReducer = debugConsoleSlice.reducer; diff --git a/src/store/store.ts b/src/store/store.ts index daf90c8..efabe87 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,13 +1,17 @@ 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 { websocketMiddleware } from './websocketMiddleware'; import websocketReducer from './websocketSlice'; const allReducers = combineReducers({ [oneSliceToRuleThemAll.apiSlice.reducerPath]: oneSliceToRuleThemAll.apiSlice.reducer, - commonUi: commonUiReducer, + auth: authReducer, + commonUi: commonUiReducer, + debugConsole: debugConsoleReducer, websocket: websocketReducer, }) diff --git a/src/store/websocketMiddleware.ts b/src/store/websocketMiddleware.ts index eb797ea..6b60a7f 100644 --- a/src/store/websocketMiddleware.ts +++ b/src/store/websocketMiddleware.ts @@ -1,6 +1,8 @@ import { Middleware } from "@reduxjs/toolkit"; import { connected, + connectionAttemptStarted, + connectionFailed, disconnected, messageReceived, WS_CONNECT, @@ -20,6 +22,8 @@ export const websocketMiddleware: Middleware = (store) => { console.log("[ws] Connecting to", url); + store.dispatch(connectionAttemptStarted()); + // Close any existing connection before opening a new one if (socket) { socket.close(); @@ -31,7 +35,10 @@ export const websocketMiddleware: Middleware = (store) => { store.dispatch(disconnected()); socket = null; }; - socket.onerror = (err) => console.error("WebSocket error", err); + socket.onerror = (err) => { + console.error("WebSocket error", err); + store.dispatch(connectionFailed(url)); + }; socket.onmessage = (event: MessageEvent) => { try { store.dispatch(messageReceived(JSON.parse(event.data))); diff --git a/src/store/websocketSlice.ts b/src/store/websocketSlice.ts index 4c19968..a46b7b0 100644 --- a/src/store/websocketSlice.ts +++ b/src/store/websocketSlice.ts @@ -4,11 +4,13 @@ import { LogMessage } from "../shared/types/LogMessage"; interface WebsocketState { messages: LogMessage[]; isConnected: boolean; + failedUrl: string | null; } const initialState: WebsocketState = { messages: [], isConnected: false, + failedUrl: null, }; const websocketSlice = createSlice({ @@ -31,10 +33,18 @@ const websocketSlice = createSlice({ messagesCleared(state) { state.messages = []; }, + /** Dispatched by the middleware when a connection attempt fails */ + connectionFailed(state, action: PayloadAction) { + state.failedUrl = action.payload; + }, + /** Dispatched by the middleware when a new connection attempt starts */ + connectionAttemptStarted(state) { + state.failedUrl = null; + }, }, }); -export const { connected, disconnected, messageReceived, messagesCleared } = +export const { connected, disconnected, messageReceived, messagesCleared, connectionFailed, connectionAttemptStarted } = websocketSlice.actions; export default websocketSlice.reducer; diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index db16e85..1b6a8f0 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1,51 +1 @@ -{ - "root": [ - "./src/app.test.tsx", - "./src/app.tsx", - "./src/index.tsx", - "./src/react-app-env.d.ts", - "./src/reportwebvitals.ts", - "./src/setuptests.ts", - "./src/features/configfile.tsx", - "./src/features/devicedetail.tsx", - "./src/features/devicelist.tsx", - "./src/features/errorbox.tsx", - "./src/features/mainlayout.tsx", - "./src/features/mobilecontrol.tsx", - "./src/features/topnav.tsx", - "./src/features/types.tsx", - "./src/features/versions.tsx", - "./src/features/debugconsole/consolewindow.tsx", - "./src/features/debugconsole/debugconsole.tsx", - "./src/features/debugconsole/debugfilters.tsx", - "./src/features/debugconsole/logmessagedetaildrawer.tsx", - "./src/features/debugconsole/minimumlogleveldropdown.tsx", - "./src/features/debugconsole/restartconfirmmodal.tsx", - "./src/features/debugconsole/debugconsts.ts", - "./src/features/debugconsole/hooks/usefilteredmessages.ts", - "./src/services/httpservice.ts", - "./src/shared/filterclearbutton.tsx", - "./src/shared/filterdropdownsearchparams.tsx", - "./src/shared/filtersearchtext.tsx", - "./src/shared/headerscrollerfooter.tsx", - "./src/shared/listfiltersheader.tsx", - "./src/shared/tablecellspacer.tsx", - "./src/shared/hooks/useappparams.ts", - "./src/shared/icons/objecticons.ts", - "./src/shared/icons/othericons.ts", - "./src/shared/icons/index.tsx", - "./src/shared/types/idlabel.ts", - "./src/shared/types/logmessage.ts", - "./src/store/apislice.ts", - "./src/store/hooks.ts", - "./src/store/store.ts", - "./src/store/websocketmiddleware.ts", - "./src/store/websocketslice.ts", - "./src/store/commonui/commonuihooks.ts", - "./src/store/commonui/commonuiselectors.ts", - "./src/store/commonui/commonuislice.ts", - "./src/store/commonui/commonuistate.ts", - "./vite.config.ts" - ], - "version": "6.0.2" -} +{"root":["./src/app.test.tsx","./src/app.tsx","./src/index.tsx","./src/react-app-env.d.ts","./src/reportwebvitals.ts","./src/setuptests.ts","./src/features/apipathdetaildrawer.tsx","./src/features/apipaths.tsx","./src/features/configfile.tsx","./src/features/devicedetail.tsx","./src/features/devicelist.tsx","./src/features/errorboundary.tsx","./src/features/errorbox.tsx","./src/features/initializationexceptions.tsx","./src/features/loginform.tsx","./src/features/mainlayout.tsx","./src/features/mobilecontrol.tsx","./src/features/requireauth.tsx","./src/features/routing.tsx","./src/features/routingdevicenode.tsx","./src/features/tielineedge.tsx","./src/features/topnav.tsx","./src/features/types.tsx","./src/features/versions.tsx","./src/features/debugconsole/consolewindow.tsx","./src/features/debugconsole/debugconsole.tsx","./src/features/debugconsole/debugfilters.tsx","./src/features/debugconsole/devicefilterdropdown.tsx","./src/features/debugconsole/logmessagedetaildrawer.tsx","./src/features/debugconsole/minimumlogleveldropdown.tsx","./src/features/debugconsole/restartconfirmmodal.tsx","./src/features/debugconsole/debugconsts.ts","./src/features/debugconsole/hooks/usefilteredmessages.ts","./src/services/httpservice.ts","./src/shared/filterclearbutton.tsx","./src/shared/filterdropdownsearchparams.tsx","./src/shared/filtersearchtext.tsx","./src/shared/headerscrollerfooter.tsx","./src/shared/listfiltersheader.tsx","./src/shared/tablecellspacer.tsx","./src/shared/functions/meetsminimumversion.ts","./src/shared/hooks/useappparams.ts","./src/shared/icons/objecticons.ts","./src/shared/icons/othericons.ts","./src/shared/icons/index.tsx","./src/shared/types/idlabel.ts","./src/shared/types/logmessage.ts","./src/store/apislice.ts","./src/store/hooks.ts","./src/store/store.ts","./src/store/websocketmiddleware.ts","./src/store/websocketslice.ts","./src/store/auth/authselectors.ts","./src/store/auth/authslice.ts","./src/store/commonui/commonuihooks.ts","./src/store/commonui/commonuiselectors.ts","./src/store/commonui/commonuislice.ts","./src/store/commonui/commonuistate.ts","./src/store/debugconsole/debugconsoleselectors.ts","./src/store/debugconsole/debugconsoleslice.ts","./vite.config.ts"],"version":"6.0.2"} \ No newline at end of file