import { ce, MorphComponent, MorphFeature } from 'morphux'; import { Main } from './main'; import { createProgress, formatUptime, ServiceState, setProgressState, setStatusState, StatusType, } from './utils'; export class DashboardUnity { private _Main: Main; container: HTMLDivElement = document.querySelector('.ntsh_dashboard-unity'); processStatus: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-processstatus' ); processInfo: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-processinfo' ); restartButton: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-restart' ); uptimeInfo: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-uptime' ); webSocketStatus: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-websocketstatus' ); webSocketInfo: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-websocketinfo' ); outOfServiceStatus: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-outofservicestatus' ); outOfServiceInfo: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-outofserviceinfo' ); enableOutOfServiceButton: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-enableoutofservice' ); disableOutOfServiceButton: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-disableoutofservice' ); zedStreamStatus: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-zedstreamstatus' ); zedStreamInfo: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-zedstreaminfo' ); zedStreamFps: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-zedstreamfps' ); zedStreamPath: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-zedstreampath' ); timelineWatching: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-timeline-watching' ); timelineStanding: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-timeline-standing' ); timelineProgress: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-timeline-progress' ); parametersTable: HTMLTableElement = document.querySelector( '.ntsh_dashboard-unity-parameters' ); sensorsTable: HTMLTableElement = document.querySelector( '.ntsh_dashboard-unity-sensors' ); errorContainer: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-error' ); errorText: HTMLDivElement = document.querySelector( '.ntsh_dashboard-unity-errortext' ); constructor(Main: Main) { this._Main = Main; this.registerListeners(); } private runnerError: string; updateRunnerState(state: UnityRunnerStatus) { // ----------- Process ----------- if (state.state != 'RUNNING') { state.startTime = -1; this.restartButton.style.display = 'none'; } else { this.restartButton.style.display = 'flex'; } setStatusState( this.processStatus, { RUNNING: 'green', STOPPED: 'gray', STARTING: 'yellow', PROBLEM: 'red', }[state.state] as StatusType ); this.processInfo.innerText = state.message ?? ''; // ----------- Uptime ----------- const uptimeSeconds = state.startTime == -1 ? -1 : (Date.now() - state.startTime) / 1000; this.uptimeInfo.innerText = formatUptime(uptimeSeconds); // ----------- Error ----------- if ((state?.error ?? '').trim().length > 0) this.runnerError = state.error; else this.runnerError = null; this.updateError(); this._Main.Logs.setUnityLogs(state.output.current); } private webSocketError: string; updateWebSocketState(state: UnityWebSocketStatus) { // ----------- WebSocket ----------- setStatusState( this.webSocketStatus, { CONNECTING: 'yellow', CONNECTED: 'green', DISCONNECTED: 'gray', FAILED: 'red', }[state.state] as StatusType ); this.webSocketInfo.innerText = state.message ?? ''; // ----------- Out of Service ----------- setStatusState( this.outOfServiceStatus, state.parameters.outOfService == null ? 'gray' : state.parameters.outOfService ? 'red' : 'green' ); this.outOfServiceInfo.innerText = state.parameters.outOfService == null ? '' : state.parameters.outOfService ? 'Out of Service' : 'Operational'; this.enableOutOfServiceButton.style.display = state.parameters.outOfService == null || state.parameters.outOfService ? 'none' : 'flex'; this.disableOutOfServiceButton.style.display = state.parameters.outOfService && state.parameters.outOfService != null ? 'flex' : 'none'; // ----------- ZED Stream ----------- setStatusState( this.zedStreamStatus, state.parameters.zedReady ? 'green' : 'red' ); this.zedStreamInfo.innerText = state.parameters.zedReady ? `Connected to ${state.parameters.zedPath}` : 'Not ready'; this.zedStreamFps.innerText = state.parameters.zedFPS == '-' ? '' : state.parameters.zedFPS; // ----------- Timeline ----------- this.timelineWatching.innerText = state.parameters.timelineWatching ? 'Yes' : 'No'; this.timelineStanding.innerText = state.parameters.timelineStanding ? 'Yes' : 'No'; setProgressState( this.timelineProgress, Math.round(state.parameters.timelineProgress * 100), 0, 100, '%' ); // ----------- Parameters ----------- this.renderParameterSliders( state.state == 'CONNECTED' ? state.parameters.sliders : [] ); this.renderParameterSensors( state.state == 'CONNECTED' ? state.parameters.sensors : [] ); // ----------- Error ----------- if ((state?.error ?? '').trim().length > 0) this.webSocketError = state.error; else this.webSocketError = null; this.updateError(); } private renderParameterSliders(sliders: UnityParameters['sliders']) { const existingSliders = this.parametersTable.querySelectorAll( '.ntsh_dashboard-unity-parameter-row' ); if (existingSliders.length !== sliders.length) { this.parametersTable.innerHTML = ''; if (sliders.length === 0) { const row = ce('tr'); const cell = ce('td'); cell.appendChild( ce( 'div', ['mux_text', 'ntsh_dashboard-unity-parameters-loading'], null, 'Waiting for Unity...' ) ); row.appendChild(cell); this.parametersTable.appendChild(row); } else sliders.forEach((slider) => { const multiplierFactor = slider.visualMultiplier ?? 1; const decimalPlacesFactor = 10 ** (slider.decimalPlaces ?? 0); const value = Math.round( slider.outputValue * multiplierFactor * decimalPlacesFactor ) / decimalPlacesFactor; const row = ce('tr', 'ntsh_dashboard-unity-parameter-row'); const nameCell = ce('td'); nameCell.appendChild( ce('div', 'mux_text', null, slider.sliderName) ); row.appendChild(nameCell); const progressCell = ce('td', 'no-service'); progressCell.appendChild( createProgress( value, slider.min * multiplierFactor, slider.max * multiplierFactor, slider.unit ) ); row.appendChild(progressCell); const sliderCell = ce('td', 'only-service'); const sliderProgress = createProgress( value, slider.min * multiplierFactor, slider.max * multiplierFactor, slider.unit ); const sliderValue: HTMLDivElement = sliderProgress.querySelector('.ntsh_progress-value'); sliderValue.classList.add('mux_resizer'); sliderCell.appendChild(sliderProgress); const resizer = new MorphComponent.Resizer({ existingContainer: sliderValue, direction: 'right', relative: true, min: 0, max: () => sliderProgress.clientWidth, }); let lastValue: number = -1; resizer.on('resized', (size) => { const percentage = Math.round( (size / sliderProgress.clientWidth) * 100 ) / 100; var actualValue = slider.min + percentage * (slider.max - slider.min); if (actualValue === lastValue) return; lastValue = actualValue; this._Main.socket.emit( 'unityWebSocket', 'parameterValue', slider.sliderIndex, actualValue ); setProgressState( sliderProgress, Math.round( actualValue * multiplierFactor * decimalPlacesFactor ) / decimalPlacesFactor, slider.min * multiplierFactor, slider.max * multiplierFactor, slider.unit ); }); row.appendChild(sliderCell); this.parametersTable.appendChild(row); }); } else { existingSliders.forEach((row, index) => { const slider = sliders[index]; const multiplierFactor = slider.visualMultiplier ?? 1; const decimalPlacesFactor = 10 ** (slider.decimalPlaces ?? 0); const value = Math.round( slider.outputValue * multiplierFactor * decimalPlacesFactor ) / decimalPlacesFactor; const progressElement: HTMLDivElement = row.querySelector( '.no-service .ntsh_progress' ); setProgressState( progressElement, value, slider.min * multiplierFactor, slider.max * multiplierFactor, slider.unit ); const sliderElement: HTMLDivElement = row.querySelector( '.only-service .ntsh_progress' ); if (sliderElement.querySelector('.mux_resizer-moving') == null) setProgressState( sliderElement, value, slider.min * multiplierFactor, slider.max * multiplierFactor, slider.unit ); }); } } private renderParameterSensors(sensors: UnityParameters['sensors']) { const existingSensors = this.sensorsTable.querySelectorAll( '.ntsh_dashboard-unity-sensor-row' ); if (existingSensors.length !== sensors.length) { this.sensorsTable.innerHTML = ''; if (sensors.length === 0) { const row = ce('tr'); const cell = ce('td'); cell.appendChild( ce( 'div', ['mux_text', 'ntsh_dashboard-unity-sensors-loading'], null, 'Waiting for Unity...' ) ); row.appendChild(cell); this.sensorsTable.appendChild(row); } else sensors.forEach((sensor) => { const row = ce('tr', 'ntsh_dashboard-unity-sensor-row'); const nameCell = ce('td'); nameCell.appendChild( ce('div', 'mux_text', null, sensor.deviceName) ); row.appendChild(nameCell); const progressCell = ce('td'); progressCell.appendChild( createProgress( Math.round(sensor.outputValue * 100), 0, 100, '%' ) ); row.appendChild(progressCell); this.sensorsTable.appendChild(row); }); } else { existingSensors.forEach((row, index) => { const value = sensors[index].outputValue; const progressElement: HTMLDivElement = row.querySelector('.ntsh_progress'); setProgressState( progressElement, Math.round(value * 100), 0, 100, '%' ); }); } } private updateError() { const errors: string[] = []; if (this.runnerError != null) errors.push(this.runnerError); if (this.webSocketError != null) errors.push(this.webSocketError); if (errors.length > 0) { this.errorText.innerText = errors.join('\n'); this.errorContainer.style.display = 'block'; } else { this.errorContainer.style.display = 'none'; this.errorText.innerText = ''; } } registerListeners() { this._Main.socket.on('unityRunnerState', (state: UnityRunnerStatus) => this.updateRunnerState(state) ); this._Main.socket.on( 'unityWebSocketState', (state: UnityWebSocketStatus) => this.updateWebSocketState(state) ); this.restartButton.addEventListener('click', async () => { this.executeCommand( 'restart', 'Are you sure you want to restart the Unity Runner process?' ); }); this.enableOutOfServiceButton.addEventListener('click', async () => { this.executeCommand( 'enableOutOfService', 'Are you sure you want to set the installation to "Out of Service"?', 'unityWebSocket' ); }); this.disableOutOfServiceButton.addEventListener('click', async () => { this.executeCommand( 'disableOutOfService', 'Are you sure you want to set the installation to "Operational"?', 'unityWebSocket' ); }); } private async executeCommand( command: string, message: string, type: 'unityRunner' | 'unityWebSocket' = 'unityRunner' ) { const confirmed = await MorphFeature.Confirm({ title: 'Are you sure?', message, }); if (!confirmed) return; MorphFeature.Loader({ active: true, message: `Dispatching command...`, }); this._Main.socket.emit( type, command, (response: { succeed: boolean; message?: string }) => { MorphFeature.Loader({ active: false }); if (!response.succeed) return MorphFeature.Alert({ title: 'Error', message: response.message, }); MorphFeature.Notification({ level: 'success', message: `Dispatched command`, }); } ); } } interface UnityRunnerStatus { state: 'RUNNING' | 'STOPPED' | 'STARTING' | 'PROBLEM'; message?: string; error?: string; startTime: number; output: { current: string[]; last: string[] }; } interface UnityWebSocketStatus { state: ServiceState; message?: string; error?: string; parameters: UnityParameters; } interface UnityParameters { timelineWatching: boolean; timelineStanding: boolean; timelineProgress: number; zedPath: string; zedReady: boolean; zedFPS: string; outOfService: boolean; sliders: UnityParameterSlider[]; sensors: UnitySocketMessageHeartbeat['heartbeat']['dataSensors']; } type UnityHeartbeatSlider = UnitySocketMessageHeartbeat['heartbeat']['dataSliders'][number]; interface UnityParameterSlider extends UnityHeartbeatSlider { visualMultiplier: number; decimalPlaces: number; } interface UnitySocketMessageBase { type: string; timestamp: number; } interface UnitySocketMessageHeartbeat extends UnitySocketMessageBase { type: 'heartbeat_data'; heartbeat: { dataSensors: { sensorIndex: number; deviceName: string; outputValue: number; }[]; dataSliders: { sliderIndex: number; sliderName: string; outputValue: number; min: number; max: number; unit: string; }[]; dataTimeline: { isStanding: boolean; isWatching: boolean; timelineProgress: number; }; zedCamera: { cameraFPS: string; isZedReady: boolean; streamInputIP: string; streamInputPort: number; zedGrabError: number; }; showOutOfService?: boolean; }; }