diff --git a/src/components/CustomIcon.tsx b/src/components/CustomIcon.tsx index 36c9cab29..922c13689 100644 --- a/src/components/CustomIcon.tsx +++ b/src/components/CustomIcon.tsx @@ -4,6 +4,8 @@ export type CustomIconName = | 'arrowRight' | 'arrowUp' | 'checkmark' + | 'clipboardPlus' + | 'clipboardCheckmark' | 'close' | 'equal' | 'extrude' @@ -13,8 +15,11 @@ export type CustomIconName = | 'folderPlus' | 'gear' | 'horizontal' + | 'horizontalDash' | 'line' | 'move' + | 'network' + | 'networkCrossedOut' | 'parallel' | 'search' | 'sketch' @@ -107,6 +112,38 @@ export const CustomIcon = ({ /> ) + case 'clipboardCheckmark': + return ( + + + + ) + case 'clipboardPlus': + return ( + + + + ) case 'close': return ( ) + case 'horizontalDash': + return ( + + + + ) case 'line': return ( ) + case 'network': + return ( + + + + ) + case 'networkCrossedOut': + return ( + + + + ) case 'parallel': return ( { fireEvent.click(screen.getByTestId('network-toggle')) - expect(screen.getByTestId('network-good')).toHaveTextContent( - NETWORK_CONTENT.good + expect(screen.getByTestId('network')).toHaveTextContent( + NETWORK_HEALTH_TEXT[NetworkHealthState.Ok] ) }) @@ -43,8 +44,8 @@ describe('NetworkHealthIndicator tests', () => { fireEvent.offline(window) fireEvent.click(screen.getByTestId('network-toggle')) - expect(screen.getByTestId('network-bad')).toHaveTextContent( - NETWORK_CONTENT.bad + expect(screen.getByTestId('network')).toHaveTextContent( + NETWORK_HEALTH_TEXT[NetworkHealthState.Disconnected] ) }) }) diff --git a/src/components/NetworkHealthIndicator.tsx b/src/components/NetworkHealthIndicator.tsx index 1357ef461..a5218a5f5 100644 --- a/src/components/NetworkHealthIndicator.tsx +++ b/src/components/NetworkHealthIndicator.tsx @@ -1,41 +1,186 @@ -import { faExclamation, faWifi } from '@fortawesome/free-solid-svg-icons' import { Popover } from '@headlessui/react' import { useEffect, useState } from 'react' -import { ActionIcon } from './ActionIcon' +import { ActionIcon, ActionIconProps } from './ActionIcon' +import { + ConnectingType, + ConnectingTypeGroup, + DisconnectingType, + engineCommandManager, + EngineConnectionState, + EngineConnectionStateType, + ErrorType, + initialConnectingTypeGroupState, +} from '../lang/std/engineConnection' +import Tooltip from './Tooltip' -export const NETWORK_CONTENT = { - good: 'Network health is good', - bad: 'Network issue', +export enum NetworkHealthState { + Ok, + Issue, + Disconnected, } -const NETWORK_MESSAGES = { - offline: 'You are offline', +export const NETWORK_HEALTH_TEXT: Record = { + [NetworkHealthState.Ok]: 'Connected', + [NetworkHealthState.Issue]: 'Problem', + [NetworkHealthState.Disconnected]: 'Offline', +} + +type IconColorConfig = { + icon: string + bg: string +} + +const hasIssueToIcon: Record< + string | number | symbol, + ActionIconProps['icon'] +> = { + true: 'close', + undefined: 'horizontalDash', + false: 'checkmark', +} + +const hasIssueToIconColors: Record = + { + true: { + icon: 'text-destroy-80 dark:text-destroy-10', + bg: 'bg-destroy-10 dark:bg-destroy-80', + }, + undefined: { + icon: 'text-chalkboard-70 dark:text-chalkboard-30', + bg: 'bg-chalkboard-30 dark:bg-chalkboard-70', + }, + false: { + icon: 'text-chalkboard-110 dark:!text-chalkboard-10', + bg: 'bg-transparent dark:bg-transparent', + }, + } + +const overallConnectionStateColor: Record = + { + [NetworkHealthState.Ok]: { + icon: 'text-energy-80 dark:text-energy-10', + bg: 'bg-energy-10/30 dark:bg-energy-80/50', + }, + [NetworkHealthState.Issue]: { + icon: 'text-destroy-80 dark:text-destroy-10', + bg: 'bg-destroy-10 dark:bg-destroy-80/80', + }, + [NetworkHealthState.Disconnected]: { + icon: 'text-destroy-80 dark:text-destroy-10', + bg: 'bg-destroy-10 dark:bg-destroy-80', + }, + } + +const overallConnectionStateIcon: Record< + NetworkHealthState, + ActionIconProps['icon'] +> = { + [NetworkHealthState.Ok]: 'network', + [NetworkHealthState.Issue]: 'networkCrossedOut', + [NetworkHealthState.Disconnected]: 'networkCrossedOut', } export const NetworkHealthIndicator = () => { - const [networkIssues, setNetworkIssues] = useState([]) - const hasIssues = [...networkIssues.values()].length > 0 + const [steps, setSteps] = useState(initialConnectingTypeGroupState) + const [internetConnected, setInternetConnected] = useState(true) + const [overallState, setOverallState] = useState( + NetworkHealthState.Ok + ) + const [hasCopied, setHasCopied] = useState(false) + + const [error, setError] = useState(undefined) + + const issues: Record = { + [ConnectingTypeGroup.WebSocket]: steps[ConnectingTypeGroup.WebSocket].some( + (a: [ConnectingType, boolean | undefined]) => a[1] === false + ), + [ConnectingTypeGroup.ICE]: steps[ConnectingTypeGroup.ICE].some( + (a: [ConnectingType, boolean | undefined]) => a[1] === false + ), + [ConnectingTypeGroup.WebRTC]: steps[ConnectingTypeGroup.WebRTC].some( + (a: [ConnectingType, boolean | undefined]) => a[1] === false + ), + } + + const hasIssues: boolean = + issues[ConnectingTypeGroup.WebSocket] || + issues[ConnectingTypeGroup.ICE] || + issues[ConnectingTypeGroup.WebRTC] useEffect(() => { - const offlineListener = () => - setNetworkIssues((issues) => { - return [ - ...issues.filter((issue) => issue !== NETWORK_MESSAGES.offline), - NETWORK_MESSAGES.offline, - ] - }) - window.addEventListener('offline', offlineListener) + setOverallState( + !internetConnected + ? NetworkHealthState.Disconnected + : hasIssues + ? NetworkHealthState.Issue + : NetworkHealthState.Ok + ) + }, [hasIssues, internetConnected]) - const onlineListener = () => - setNetworkIssues((issues) => { - return [...issues.filter((issue) => issue !== NETWORK_MESSAGES.offline)] - }) - window.addEventListener('online', onlineListener) - - return () => { - window.removeEventListener('offline', offlineListener) - window.removeEventListener('online', onlineListener) + useEffect(() => { + const cb1 = () => { + setSteps(initialConnectingTypeGroupState) + setInternetConnected(true) } + const cb2 = () => { + setInternetConnected(false) + } + window.addEventListener('online', cb1) + window.addEventListener('offline', cb2) + return () => { + window.removeEventListener('online', cb1) + window.removeEventListener('offline', cb2) + } + }, []) + + useEffect(() => { + engineCommandManager.onConnectionStateChange( + (engineConnectionState: EngineConnectionState) => { + let hasSetAStep = false + + if ( + engineConnectionState.type === EngineConnectionStateType.Connecting + ) { + const groups = Object.values(steps) + for (let group of groups) { + for (let step of group) { + if (step[0] !== engineConnectionState.value.type) continue + step[1] = true + hasSetAStep = true + } + } + } + + if ( + engineConnectionState.type === EngineConnectionStateType.Disconnecting + ) { + const groups = Object.values(steps) + for (let group of groups) { + for (let step of group) { + if ( + engineConnectionState.value.type === DisconnectingType.Error + ) { + if ( + engineConnectionState.value.value.lastConnectingValue + ?.type === step[0] + ) { + step[1] = false + hasSetAStep = true + } + } + } + + if (engineConnectionState.value.type === DisconnectingType.Error) { + setError(engineConnectionState.value.value) + } + } + } + + if (hasSetAStep) { + setSteps(steps) + } + } + ) }, []) return ( @@ -45,65 +190,94 @@ export const NetworkHealthIndicator = () => { 'p-0 border-none bg-transparent dark:bg-transparent relative ' + (hasIssues ? 'focus-visible:outline-destroy-80' - : 'focus-visible:outline-succeed-80') + : 'focus-visible:outline-energy-80') } data-testid="network-toggle" > Network Health + + Network Health ({NETWORK_HEALTH_TEXT[overallState]}) + - - {!hasIssues ? ( - +
+

Network health

+

- - {NETWORK_CONTENT.good} - - ) : ( -

    - +
+
    + {Object.keys(steps).map((name) => ( +
  • - {NETWORK_CONTENT.bad} - {networkIssues.length > 1 ? 's' : ''} - - {networkIssues.map((issue) => ( -
  • - -

    {issue}

    -
  • - ))} -
- )} +
+

{name}

+ {internetConnected ? ( + + ) : ( + + )} +
+ {issues[name as ConnectingTypeGroup] && ( + + )} + + ))} +
) diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index 18bc574a6..92b39cdad 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -58,26 +58,36 @@ type Value = U extends undefined type State = Value -enum EngineConnectionStateType { +export enum EngineConnectionStateType { Fresh = 'fresh', Connecting = 'connecting', ConnectionEstablished = 'connection-established', + Disconnecting = 'disconnecting', Disconnected = 'disconnected', } -enum DisconnectedType { +export enum DisconnectingType { Error = 'error', Timeout = 'timeout', Quit = 'quit', } -type DisconnectedValue = - | State - | State - | State +export interface ErrorType { + // We may not necessary have an error to assign. + error?: Error + + // We assign this in the state setter because we may have not failed at + // a Connecting state, which we check for there. + lastConnectingValue?: ConnectingValue +} + +export type DisconnectingValue = + | State + | State + | State // These are ordered by the expected sequence. -enum ConnectingType { +export enum ConnectingType { WebSocketConnecting = 'websocket-connecting', WebSocketEstablished = 'websocket-established', PeerConnectionCreated = 'peer-connection-created', @@ -94,7 +104,39 @@ enum ConnectingType { DataChannelEstablished = 'data-channel-established', } -type ConnectingValue = +export enum ConnectingTypeGroup { + WebSocket = 'WebSocket', + ICE = 'ICE', + WebRTC = 'WebRTC', +} + +export const initialConnectingTypeGroupState: Record< + ConnectingTypeGroup, + [ConnectingType, boolean | undefined][] +> = { + [ConnectingTypeGroup.WebSocket]: [ + [ConnectingType.WebSocketConnecting, undefined], + [ConnectingType.WebSocketEstablished, undefined], + ], + [ConnectingTypeGroup.ICE]: [ + [ConnectingType.PeerConnectionCreated, undefined], + [ConnectingType.ICEServersSet, undefined], + [ConnectingType.SetLocalDescription, undefined], + [ConnectingType.OfferedSdp, undefined], + [ConnectingType.ReceivedSdp, undefined], + [ConnectingType.SetRemoteDescription, undefined], + [ConnectingType.WebRTCConnecting, undefined], + [ConnectingType.ICECandidateReceived, undefined], + ], + [ConnectingTypeGroup.WebRTC]: [ + [ConnectingType.TrackReceived, undefined], + [ConnectingType.DataChannelRequested, undefined], + [ConnectingType.DataChannelConnecting, undefined], + [ConnectingType.DataChannelEstablished, undefined], + ], +} + +export type ConnectingValue = | State | State | State @@ -110,11 +152,12 @@ type ConnectingValue = | State | State -type EngineConnectionState = +export type EngineConnectionState = | State | State | State - | State + | State + | State // EngineConnection encapsulates the connection(s) to the Engine // for the EngineCommandManager; namely, the underlying WebSocket @@ -135,22 +178,38 @@ class EngineConnection { set state(next: EngineConnectionState) { console.log(`${JSON.stringify(this.state)} → ${JSON.stringify(next)}`) - if (next.type === EngineConnectionStateType.Disconnected) { + + if (next.type === EngineConnectionStateType.Disconnecting) { console.trace() const sub = next.value - if (sub.type === DisconnectedType.Error) { + if (sub.type === DisconnectingType.Error) { + // Record the last step we failed at. + // (Check the current state that we're about to override that + // it was a Connecting state.) + console.log(sub) + if (this._state.type === EngineConnectionStateType.Connecting) { + if (!sub.value) sub.value = {} + sub.value.lastConnectingValue = this._state.value + } + console.error(sub.value) } } this._state = next + this.onConnectionStateChange(this._state) } private failedConnTimeout: Timeout | null readonly url: string private readonly token?: string - private onWebsocketOpen: (engineConnection: EngineConnection) => void - private onDataChannelOpen: (engineConnection: EngineConnection) => void + + // For now, this is only used by the NetworkHealthIndicator. + // We can eventually use it for more, but one step at a time. + private onConnectionStateChange: (state: EngineConnectionState) => void + + // These are used for the EngineCommandManager and were created + // before onConnectionStateChange existed. private onEngineConnectionOpen: (engineConnection: EngineConnection) => void private onConnectionStarted: (engineConnection: EngineConnection) => void private onClose: (engineConnection: EngineConnection) => void @@ -162,17 +221,15 @@ class EngineConnection { constructor({ url, token, - onWebsocketOpen = () => {}, + onConnectionStateChange = () => {}, onNewTrack = () => {}, onEngineConnectionOpen = () => {}, onConnectionStarted = () => {}, onClose = () => {}, - onDataChannelOpen = () => {}, }: { url: string token?: string - onWebsocketOpen?: (engineConnection: EngineConnection) => void - onDataChannelOpen?: (engineConnection: EngineConnection) => void + onConnectionStateChange?: (state: EngineConnectionState) => void onEngineConnectionOpen?: (engineConnection: EngineConnection) => void onConnectionStarted?: (engineConnection: EngineConnection) => void onClose?: (engineConnection: EngineConnection) => void @@ -181,8 +238,7 @@ class EngineConnection { this.url = url this.token = token this.failedConnTimeout = null - this.onWebsocketOpen = onWebsocketOpen - this.onDataChannelOpen = onDataChannelOpen + this.onConnectionStateChange = onConnectionStateChange this.onEngineConnectionOpen = onEngineConnectionOpen this.onConnectionStarted = onConnectionStarted @@ -198,6 +254,7 @@ class EngineConnection { case EngineConnectionStateType.ConnectionEstablished: this.send({ type: 'ping' }) break + case EngineConnectionStateType.Disconnecting: case EngineConnectionStateType.Disconnected: clearInterval(pingInterval) break @@ -209,17 +266,11 @@ class EngineConnection { const connectionTimeoutMs = VITE_KC_CONNECTION_TIMEOUT_MS let connectRetryInterval = setInterval(() => { if (this.state.type !== EngineConnectionStateType.Disconnected) return - switch (this.state.value.type) { - case DisconnectedType.Error: - clearInterval(connectRetryInterval) - break - case DisconnectedType.Timeout: - console.log('Trying to reconnect') - this.connect() - break - default: - break - } + + // Only try reconnecting when completely disconnected. + clearInterval(connectRetryInterval) + console.log('Trying to reconnect') + this.connect() }, connectionTimeoutMs) } @@ -234,8 +285,8 @@ class EngineConnection { tearDown() { this.disconnectAll() this.state = { - type: EngineConnectionStateType.Disconnected, - value: { type: DisconnectedType.Quit }, + type: EngineConnectionStateType.Disconnecting, + value: { type: DisconnectingType.Quit }, } } @@ -352,12 +403,14 @@ class EngineConnection { case 'failed': this.disconnectAll() this.state = { - type: EngineConnectionStateType.Disconnected, + type: EngineConnectionStateType.Disconnecting, value: { - type: DisconnectedType.Error, - value: new Error( - 'failed to negotiate ice connection; restarting' - ), + type: DisconnectingType.Error, + value: { + error: new Error( + 'failed to negotiate ice connection; restarting' + ), + }, }, } break @@ -473,8 +526,6 @@ class EngineConnection { dataChannelSpan.resolve?.() } - this.onDataChannelOpen(this) - // Everything is now connected. this.state = { type: EngineConnectionStateType.ConnectionEstablished } @@ -482,27 +533,20 @@ class EngineConnection { }) this.unreliableDataChannel.addEventListener('close', (event) => { - console.log(event) - console.log('unreliable data channel closed') this.disconnectAll() - this.unreliableDataChannel = undefined - - if (this.areAllConnectionsClosed()) { - this.state = { - type: EngineConnectionStateType.Disconnected, - value: { type: DisconnectedType.Quit }, - } - } + this.finalizeIfAllConnectionsClosed() }) this.unreliableDataChannel.addEventListener('error', (event) => { this.disconnectAll() this.state = { - type: EngineConnectionStateType.Disconnected, + type: EngineConnectionStateType.Disconnecting, value: { - type: DisconnectedType.Error, - value: new Error(event.toString()), + type: DisconnectingType.Error, + value: { + error: new Error(event.toString()), + }, }, } }) @@ -527,8 +571,6 @@ class EngineConnection { }, } - this.onWebsocketOpen(this) - // This is required for when KCMA is running stand-alone / within Tauri. // Otherwise when run in a browser, the token is sent implicitly via // the Cookie header. @@ -560,24 +602,19 @@ class EngineConnection { this.websocket.addEventListener('close', (event) => { this.disconnectAll() - this.websocket = undefined - - if (this.areAllConnectionsClosed()) { - this.state = { - type: EngineConnectionStateType.Disconnected, - value: { type: DisconnectedType.Quit }, - } - } + this.finalizeIfAllConnectionsClosed() }) this.websocket.addEventListener('error', (event) => { this.disconnectAll() this.state = { - type: EngineConnectionStateType.Disconnected, + type: EngineConnectionStateType.Disconnecting, value: { - type: DisconnectedType.Error, - value: new Error(event.toString()), + type: DisconnectingType.Error, + value: { + error: new Error(event.toString()), + }, }, } }) @@ -706,10 +743,12 @@ failed cmd type was ${artifactThatFailed?.commandType}` // The local description is invalid, so there's no point continuing. this.disconnectAll() this.state = { - type: EngineConnectionStateType.Disconnected, + type: EngineConnectionStateType.Disconnecting, value: { - type: DisconnectedType.Error, - value: error, + type: DisconnectingType.Error, + value: { + error, + }, }, } }) @@ -787,13 +826,14 @@ failed cmd type was ${artifactThatFailed?.commandType}` return } this.failedConnTimeout = null - this.disconnectAll() this.state = { - type: EngineConnectionStateType.Disconnected, + type: EngineConnectionStateType.Disconnecting, value: { - type: DisconnectedType.Timeout, + type: DisconnectingType.Timeout, }, } + this.disconnectAll() + this.finalizeIfAllConnectionsClosed() }, connectionTimeoutMs) this.onConnectionStarted(this) @@ -816,11 +856,18 @@ failed cmd type was ${artifactThatFailed?.commandType}` this.websocket?.close() this.unreliableDataChannel?.close() this.pc?.close() + this.webrtcStatsCollector = undefined } - areAllConnectionsClosed() { + finalizeIfAllConnectionsClosed() { console.log(this.websocket, this.pc, this.unreliableDataChannel) - return !this.websocket && !this.pc && !this.unreliableDataChannel + const allClosed = + this.websocket?.readyState === 3 && + this.pc?.connectionState === 'closed' && + this.unreliableDataChannel?.readyState === 'closed' + if (allClosed) { + this.state = { type: EngineConnectionStateType.Disconnected } + } } } @@ -893,6 +940,9 @@ export class EngineCommandManager { } } = {} as any + callbacksEngineStateConnection: ((state: EngineConnectionState) => void)[] = + [] + constructor() { this.engineConnection = undefined ;(async () => { @@ -934,6 +984,11 @@ export class EngineCommandManager { this.engineConnection = new EngineConnection({ url, token, + onConnectionStateChange: (state: EngineConnectionState) => { + for (let cb of this.callbacksEngineStateConnection) { + cb(state) + } + }, onEngineConnectionOpen: () => { this.resolveReady() setIsStreamReady(true) @@ -1189,6 +1244,9 @@ export class EngineCommandManager { ) { delete this.unreliableSubscriptions[event][id] } + onConnectionStateChange(callback: (state: EngineConnectionState) => void) { + this.callbacksEngineStateConnection.push(callback) + } endSession() { // TODO: instead of sending a single command with `object_ids: Object.keys(this.artifactMap)` // we need to loop over them each individually because if the engine doesn't recognise a single