diff --git a/package-lock.json b/package-lock.json index b478beb..120d572 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,12 @@ "license": "MIT", "dependencies": { "@monaco-editor/react": "4.6.0", + "@novnc/novnc": "^1.6.0", "@reduxjs/toolkit": "2.2.5", "@xterm/addon-attach": "0.11.0", "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "axios": "^1.12.2", + "axios": "1.12.2", "cross-env": "7.0.3", "dotenv": "16.4.7", "jsonpath": "1.1.1", @@ -2783,6 +2784,12 @@ "node": ">= 8" } }, + "node_modules/@novnc/novnc": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.6.0.tgz", + "integrity": "sha512-CJrmdSe9Yt2ZbLsJpVFoVkEu0KICEvnr3njW25Nz0jodaiFJtg8AYLGZogRYy0/N5HUWkGUsCmegKXYBSqwygw==", + "license": "MPL-2.0" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", diff --git a/package.json b/package.json index aac273b..ee04e1c 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,12 @@ "antd": "5.26.4", "react": "18.3.1", "react-dom": "18.3.1", - "react-router-dom": "^6.25.1" + "react-router-dom": "^6.25.1", + "@novnc/novnc": "^1.6.0" }, "dependencies": { "@monaco-editor/react": "4.6.0", + "@novnc/novnc": "^1.6.0", "@reduxjs/toolkit": "2.2.5", "@xterm/addon-attach": "0.11.0", "@xterm/addon-fit": "0.10.0", diff --git a/src/components/molecules/Terminals/VMVNC/VMVNC.tsx b/src/components/molecules/Terminals/VMVNC/VMVNC.tsx new file mode 100644 index 0000000..7b1939c --- /dev/null +++ b/src/components/molecules/Terminals/VMVNC/VMVNC.tsx @@ -0,0 +1,819 @@ +/* eslint-disable no-console */ +import React, { FC, useEffect, useState, useRef, useCallback } from 'react' +import { Result, Spin, Button, Switch, Space, Tooltip, Dropdown, MenuProps, Radio } from 'antd' +import { CloseOutlined, FullscreenOutlined, FullscreenExitOutlined, PoweroffOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons' +import { Styled } from './styled' + +// Type definition for RFB (noVNC doesn't have TypeScript definitions) +type RFB = any + +export type TVMVNCProps = { + cluster: string + namespace: string + vmName: string + substractHeight: number +} + +export const VMVNC: FC = ({ cluster, namespace, vmName, substractHeight }) => { + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [status, setStatus] = useState('Loading VNC client...') + const [rfbModule, setRfbModule] = useState(null) + const [isFullscreen, setIsFullscreen] = useState(false) + const [showDotCursor, setShowDotCursor] = useState(false) + // Scaling mode state (default: 'local' - local scaling) + const [scalingMode, setScalingMode] = useState<'none' | 'local' | 'remote'>('local') + // Derived states for scaleViewport and resizeSession based on scalingMode + const scaleViewport = scalingMode === 'local' + const resizeSession = scalingMode === 'remote' + const [isConnected, setIsConnected] = useState(false) + const [isManuallyDisconnected, setIsManuallyDisconnected] = useState(false) + const [reconnectAttempts, setReconnectAttempts] = useState(0) + const [shouldReconnect, setShouldReconnect] = useState(true) + + const screenRef = useRef(null) + const rfbRef = useRef(null) + const containerRef = useRef(null) + const reconnectTimeoutRef = useRef(null) + const isManuallyDisconnectedRef = useRef(false) + const shouldReconnectRef = useRef(true) + const rfbModuleRef = useRef(null) + const scheduleReconnectRef = useRef<(() => void) | null>(null) + const showDotCursorRef = useRef(false) + const maxReconnectAttempts = 5 + const reconnectDelay = 3000 // 3 seconds + + // Dynamically load noVNC to avoid build issues with top-level await + useEffect(() => { + let isMounted = true + + const loadNoVNC = async () => { + try { + // Dynamic import of noVNC + // The consuming app's Vite config will resolve this module and bundle it properly + // @ts-ignore - noVNC doesn't have TypeScript definitions + const RFBModule = await import('@novnc/novnc/lib/rfb.js') + + // Log module structure for debugging + console.log('[VMVNC]: RFB module structure:', { + keys: Object.keys(RFBModule), + default: RFBModule.default, + RFB: RFBModule.RFB, + moduleType: typeof RFBModule, + defaultType: typeof RFBModule.default, + defaultConstructor: RFBModule.default?.prototype?.constructor, + }) + + // Extract RFB constructor - noVNC exports RFB as default export + // RFB is defined as: var RFB = exports["default"] = function (_EventTargetMixin) { ... } + // So it should be available as RFBModule.default + let RFB: any = null + + // Try default export first (this is how noVNC exports it) + if (RFBModule.default && typeof RFBModule.default === 'function') { + RFB = RFBModule.default + console.log('[VMVNC]: Using RFB from default export') + } + // Try named export RFB + else if (RFBModule.RFB && typeof RFBModule.RFB === 'function') { + RFB = RFBModule.RFB + console.log('[VMVNC]: Using RFB from named export') + } + // Try if the module itself is the constructor + else if (typeof RFBModule === 'function') { + RFB = RFBModule + console.log('[VMVNC]: Using RFB as module itself') + } + // Try nested default (in case of double wrapping) + else if (RFBModule.default?.default && typeof RFBModule.default.default === 'function') { + RFB = RFBModule.default.default + console.log('[VMVNC]: Using RFB from nested default') + } + + // Verify it's actually a constructor function + if (!RFB || typeof RFB !== 'function') { + console.error('[VMVNC]: Failed to extract RFB constructor. Module structure:', RFBModule) + throw new Error('Failed to extract RFB constructor from module - invalid export structure') + } + + // Verify it's a class constructor (has prototype) + if (!RFB.prototype) { + console.error('[VMVNC]: RFB does not have prototype, it may not be a class constructor') + throw new Error('RFB is not a class constructor') + } + + // IMPORTANT: Check if RFB is wrapped or if we need to extract the real constructor + // The error "Cannot call a class as a function" suggests the class might be wrapped + let actualRFB = RFB + + // Check prototype.constructor + const prototypeConstructor = RFB.prototype?.constructor + if (prototypeConstructor && prototypeConstructor !== RFB && typeof prototypeConstructor === 'function') { + console.log('[VMVNC]: Found different constructor in prototype, checking it:', { + original: RFB.name, + prototypeConstructor: prototypeConstructor.name, + }) + // Only use it if it has the same prototype structure + if (prototypeConstructor.prototype && prototypeConstructor.prototype.constructor === prototypeConstructor) { + actualRFB = prototypeConstructor + console.log('[VMVNC]: Using prototype.constructor as RFB') + } + } + + // Final check: ensure the constructor we're using is valid + if (!actualRFB || typeof actualRFB !== 'function' || !actualRFB.prototype) { + throw new Error('Invalid RFB constructor extracted') + } + + console.log('[VMVNC]: RFB constructor extracted successfully:', { + name: actualRFB.name, + prototype: actualRFB.prototype, + isFunction: typeof actualRFB === 'function', + prototypeConstructor: actualRFB.prototype?.constructor?.name, + isOriginal: actualRFB === RFB, + }) + + // Use the actual constructor + RFB = actualRFB + + // Store both the constructor and the original module reference + // The original module reference is needed for Reflect.construct's newTarget + // to ensure `this instanceof RFB` passes in _classCallCheck + if (isMounted) { + // Store both the constructor and the original module default export + // This ensures we can use the original RFB as newTarget in Reflect.construct + const moduleData = { + constructor: RFB, + original: RFBModule.default, // Keep reference to original for instanceof checks + } + rfbModuleRef.current = moduleData + // Initialize showDotCursorRef with current state value + showDotCursorRef.current = showDotCursor + setRfbModule(moduleData) + setStatus('Connecting...') + } + } catch (err) { + console.error('[VMVNC]: Failed to load noVNC library:', err) + if (isMounted) { + setError(`Failed to load VNC client: ${err instanceof Error ? err.message : 'Unknown error'}. Please ensure @novnc/novnc is installed.`) + setIsLoading(false) + } + } + } + + loadNoVNC() + + return () => { + isMounted = false + } + }, []) + + useEffect(() => { + if (!screenRef.current || !rfbModule) { + return + } + + // rfbModule can be either the constructor directly or an object with constructor and original + const RFB = typeof rfbModule === 'function' ? rfbModule : rfbModule.constructor + const originalRFB = typeof rfbModule === 'function' ? rfbModule : rfbModule.original || rfbModule.constructor + + // Build WebSocket URL for VNC connection + // Format: wss://host/k8s/apis/subresources.kubevirt.io/v1/namespaces/{namespace}/virtualmachineinstances/{vmName}/vnc + // RFB needs full URL with proper protocol for WebSocket connection + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + const wsPath = `/k8s/apis/subresources.kubevirt.io/v1/namespaces/${namespace}/virtualmachineinstances/${vmName}/vnc` + // Use full URL - RFB requires full WebSocket URL + const wsUrl = `${protocol}//${host}${wsPath}` + + console.log(`[VMVNC ${namespace}/${vmName}]: WebSocket URL: ${wsUrl}`) + console.log(`[VMVNC ${namespace}/${vmName}]: WebSocket path: ${wsPath}`) + console.log(`[VMVNC ${namespace}/${vmName}]: Current host: ${host}`) + console.log(`[VMVNC ${namespace}/${vmName}]: Current protocol: ${window.location.protocol}`) + + console.log(`[VMVNC ${namespace}/${vmName}]: Connecting to ${wsUrl}`) + console.log(`[VMVNC ${namespace}/${vmName}]: RFB constructor details:`, { + type: typeof RFB, + name: RFB?.name, + prototype: RFB?.prototype, + isConstructor: RFB?.prototype?.constructor === RFB, + toString: typeof RFB?.toString === 'function' ? RFB.toString().substring(0, 100) : String(RFB), + }) + + try { + // Verify RFB is a constructor function + if (typeof RFB !== 'function') { + throw new Error(`RFB is not a constructor function. Type: ${typeof RFB}`) + } + + // Verify RFB has prototype (it's a class/constructor) + if (!RFB.prototype) { + throw new Error('RFB does not have prototype - cannot be used as constructor') + } + + // Try to create RFB instance + // The error "Cannot call a class as a function" occurs inside RFB class initialization + // This suggests that RFB might be wrapped or called incorrectly + // Let's try to extract the real constructor and use it properly + let rfb: any = null + + // Check if RFB.prototype.constructor is different (might be the real constructor) + const actualConstructor = RFB.prototype?.constructor + let constructorToUse = RFB + + // If prototype.constructor is different, use it + if (actualConstructor && actualConstructor !== RFB && typeof actualConstructor === 'function') { + console.log('[VMVNC]: Using prototype.constructor instead of RFB:', { + RFB: RFB.name, + constructor: actualConstructor.name, + }) + constructorToUse = actualConstructor + } + + console.log('[VMVNC]: Constructor details before instantiation:', { + RFB: RFB.name, + constructorToUse: constructorToUse.name, + areEqual: RFB === constructorToUse, + hasPrototype: !!constructorToUse.prototype, + prototypeConstructor: constructorToUse.prototype?.constructor?.name, + }) + + // The error "Cannot call a class as a function" occurs because _classCallCheck(this, RFB) fails + // This happens when RFB is wrapped by Vite/Rollup and `this instanceof RFB` fails + // Solution: Use Reflect.construct with the original constructor as newTarget + // This ensures that `this instanceof RFB` passes the check + try { + console.log('[VMVNC]: Creating RFB instance') + console.log('[VMVNC]: Constructor details:', { + name: constructorToUse.name, + type: typeof constructorToUse, + hasPrototype: !!constructorToUse.prototype, + prototypeConstructor: constructorToUse.prototype?.constructor?.name, + }) + + // Use Reflect.construct with originalRFB as newTarget + // This ensures that `this instanceof originalRFB` passes in _classCallCheck + // The third parameter (newTarget) determines what `this instanceof` checks against + // We use originalRFB (from module.default) as newTarget to ensure the instanceof check passes + // RFB constructor: new RFB(target, urlOrChannel, options) + // urlOrChannel can be: + // - A string URL (full URL or relative path) + // - A WebSocket object + // - A WebSocket-like object + rfb = Reflect.construct( + constructorToUse, // target constructor (may be wrapped) + [ + screenRef.current, + wsUrl, // URL or path - RFB will handle WebSocket connection + { + credentials: { + password: '', // VNC password if needed + }, + // Set showDotCursor in options to avoid reconnection issues + showDotCursor: showDotCursorRef.current, // Use ref value to avoid dependency issues + }, + ], + originalRFB // newTarget - use original RFB from module for instanceof check + ) + + // Verify the instance was created correctly + if (!(rfb instanceof constructorToUse)) { + console.warn('[VMVNC]: Instance is not instanceof constructor, but continuing anyway') + } + + console.log('[VMVNC]: RFB instance created successfully') + } catch (err) { + console.error('[VMVNC]: Failed to create RFB instance:', err) + console.error('[VMVNC]: Error details:', { + message: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + constructorName: constructorToUse.name, + constructorType: typeof constructorToUse, + }) + throw err + } + + // Verify rfb instance was created correctly + if (!rfb || typeof rfb !== 'object') { + throw new Error('Failed to create RFB instance - result is not an object') + } + + rfbRef.current = rfb + + // Set connection parameters based on scaling mode + rfb.scaleViewport = scalingMode === 'local' + rfb.resizeSession = scalingMode === 'remote' + + // showDotCursor is already set in constructor options, but apply it again to ensure it's set + // This property can be changed at runtime without reconnecting + if (typeof rfb.showDotCursor !== 'undefined') { + try { + rfb.showDotCursor = showDotCursorRef.current + console.log(`[VMVNC ${namespace}/${vmName}]: showDotCursor setting: ${showDotCursorRef.current}`) + } catch (err) { + console.error('Error setting dot cursor:', err) + } + } + + // Event handlers + rfb.addEventListener('connect', () => { + console.log(`[VMVNC ${namespace}/${vmName}]: Connected`) + setStatus('Connected') + setIsLoading(false) + setError(null) + setIsConnected(true) + isManuallyDisconnectedRef.current = false + setReconnectAttempts(0) // Reset reconnect attempts on successful connection + // Clear any pending reconnect timeout + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + }) + + rfb.addEventListener('disconnect', (e: any) => { + const detail = e.detail as { clean?: boolean } + const wasManuallyDisconnected = isManuallyDisconnectedRef.current + const shouldAutoReconnect = shouldReconnectRef.current + + setIsConnected(false) + setIsLoading(false) + + // Check if this is a manual disconnect + if (wasManuallyDisconnected) { + console.log(`[VMVNC ${namespace}/${vmName}]: Manually disconnected`) + setStatus('Disconnected') + setError(null) + return + } + + // Auto-reconnect logic - only if auto-reconnect is enabled + if (shouldAutoReconnect) { + if (detail.clean) { + console.log(`[VMVNC ${namespace}/${vmName}]: Disconnected (clean) - will auto-reconnect`) + setStatus('Disconnected - Reconnecting...') + setError(null) + } else { + console.error(`[VMVNC ${namespace}/${vmName}]: Connection closed unexpectedly - will auto-reconnect`) + setStatus('Connection closed - Reconnecting...') + setError('Connection closed unexpectedly') + } + + // Call scheduleReconnect after a delay to avoid nested state updates + // This ensures the disconnect event handler completes before reconnect logic runs + const reconnectTimer = setTimeout(() => { + // Double-check conditions before reconnecting + if (!isManuallyDisconnectedRef.current && shouldReconnectRef.current && scheduleReconnectRef.current) { + console.log(`[VMVNC ${namespace}/${vmName}]: Triggering auto-reconnect from disconnect handler`) + scheduleReconnectRef.current() + } else { + console.log(`[VMVNC ${namespace}/${vmName}]: Auto-reconnect cancelled (manual disconnect or disabled)`) + setStatus('Disconnected') + } + }, reconnectDelay) + + // Store timer for cleanup + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + reconnectTimeoutRef.current = reconnectTimer + } else { + // No auto-reconnect + if (detail.clean) { + console.log(`[VMVNC ${namespace}/${vmName}]: Disconnected (auto-reconnect disabled)`) + setStatus('Disconnected') + } else { + console.error(`[VMVNC ${namespace}/${vmName}]: Connection closed unexpectedly (auto-reconnect disabled)`) + setStatus('Connection closed') + setError('Connection closed unexpectedly') + } + } + }) + + rfb.addEventListener('credentialsrequired', () => { + console.log(`[VMVNC ${namespace}/${vmName}]: Credentials required`) + setStatus('Credentials required') + // For now, we'll try without password + // In the future, you might want to prompt for password + }) + + rfb.addEventListener('securityfailure', (e: any) => { + const detail = e.detail as { status?: number; reason?: string } + console.error(`[VMVNC ${namespace}/${vmName}]: Security failure`, detail) + setError(`Security failure: ${detail.reason || 'Unknown error'}`) + setIsLoading(false) + }) + + rfb.addEventListener('desktopname', (e: any) => { + const detail = e.detail as { name?: string } + if (detail.name) { + console.log(`[VMVNC ${namespace}/${vmName}]: Desktop name: ${detail.name}`) + setStatus(`Connected to ${detail.name}`) + } + }) + + // Cleanup function + return () => { + console.log(`[VMVNC ${namespace}/${vmName}]: Cleaning up`) + // Clear reconnect timeout if exists + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + if (rfbRef.current) { + try { + rfbRef.current.disconnect() + } catch (err) { + console.error('Error disconnecting RFB:', err) + } + rfbRef.current = null + } + } + } catch (err) { + console.error(`[VMVNC ${namespace}/${vmName}]: Error creating RFB connection:`, err) + setError(`Failed to create VNC connection: ${err instanceof Error ? err.message : 'Unknown error'}`) + setIsLoading(false) + setIsConnected(false) + + // Schedule reconnect on error if enabled + if (shouldReconnectRef.current && reconnectAttempts < maxReconnectAttempts && scheduleReconnectRef.current) { + // Use setTimeout to avoid calling scheduleReconnect during render + setTimeout(() => { + if (scheduleReconnectRef.current) { + scheduleReconnectRef.current() + } + }, 0) + } + } + }, [cluster, namespace, vmName, rfbModule, scalingMode, reconnectAttempts]) + + // Sync showDotCursorRef with state (but don't recreate connection on change) + useEffect(() => { + showDotCursorRef.current = showDotCursor + // Apply to existing RFB instance if connected (without recreating connection) + if (rfbRef.current && isConnected && typeof rfbRef.current.showDotCursor !== 'undefined') { + try { + rfbRef.current.showDotCursor = showDotCursor + console.log(`[VMVNC ${namespace}/${vmName}]: Updated showDotCursor to ${showDotCursor} (runtime, no reconnect)`) + } catch (err) { + console.error('Error updating dot cursor:', err) + } + } + }, [showDotCursor, namespace, vmName, isConnected]) + + // Schedule reconnect after delay + const scheduleReconnect = useCallback(() => { + // Check if we should reconnect (before clearing timeout) + if (isManuallyDisconnectedRef.current || !shouldReconnectRef.current) { + console.log(`[VMVNC ${namespace}/${vmName}]: Reconnect cancelled - manual disconnect or disabled`) + return + } + + // Clear any existing reconnect timeout first + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + + // Get current attempt count and increment + setReconnectAttempts(prev => { + const attempts = prev + 1 + + if (attempts > maxReconnectAttempts) { + console.log(`[VMVNC ${namespace}/${vmName}]: Max reconnect attempts reached (${maxReconnectAttempts})`) + setStatus('Connection failed - Max reconnect attempts reached') + setError('Failed to reconnect after multiple attempts') + shouldReconnectRef.current = false + setIsLoading(false) + return prev + } + + console.log(`[VMVNC ${namespace}/${vmName}]: Scheduling reconnect attempt ${attempts}/${maxReconnectAttempts}`) + setStatus(`Reconnecting... (Attempt ${attempts}/${maxReconnectAttempts})`) + setIsLoading(true) + setError(null) + + // Schedule actual reconnection after delay + reconnectTimeoutRef.current = setTimeout(() => { + // Final check before reconnecting + if (isManuallyDisconnectedRef.current || !shouldReconnectRef.current) { + console.log(`[VMVNC ${namespace}/${vmName}]: Reconnect cancelled during delay`) + setIsLoading(false) + setStatus('Reconnect cancelled') + return + } + + console.log(`[VMVNC ${namespace}/${vmName}]: Attempting reconnect (${attempts}/${maxReconnectAttempts})`) + + // Clear existing connection + if (rfbRef.current) { + try { + rfbRef.current.disconnect() + } catch (err) { + console.error('Error disconnecting RFB during reconnect:', err) + } + rfbRef.current = null + } + + // Clear screen ref to allow reconnection + if (screenRef.current) { + // Remove all children + while (screenRef.current.firstChild) { + screenRef.current.removeChild(screenRef.current.firstChild) + } + } + + // Trigger reconnection by resetting and then setting rfbModule + setRfbModule(null) + setTimeout(() => { + if (rfbModuleRef.current && !isManuallyDisconnectedRef.current && shouldReconnectRef.current) { + console.log(`[VMVNC ${namespace}/${vmName}]: Restoring RFB module for reconnect`) + setRfbModule(rfbModuleRef.current) + } else { + console.log(`[VMVNC ${namespace}/${vmName}]: Reconnect cancelled - conditions changed`) + setIsLoading(false) + } + }, 500) + }, reconnectDelay) + + return attempts + }) + }, [namespace, vmName]) + + // Store scheduleReconnect in ref so it can be accessed from event handlers + useEffect(() => { + scheduleReconnectRef.current = scheduleReconnect + }, [scheduleReconnect]) + + // Handle toolbar actions + const handleDisconnect = useCallback(() => { + isManuallyDisconnectedRef.current = true + shouldReconnectRef.current = false + setIsManuallyDisconnected(true) + setShouldReconnect(false) + + // Clear reconnect timeout + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + + if (rfbRef.current) { + try { + rfbRef.current.disconnect() + setStatus('Disconnected') + setIsLoading(false) + setIsConnected(false) + } catch (err) { + console.error('Error disconnecting RFB:', err) + } + } + }, []) + + const handleReconnect = useCallback(() => { + isManuallyDisconnectedRef.current = false + shouldReconnectRef.current = true + setIsManuallyDisconnected(false) + setShouldReconnect(true) + setReconnectAttempts(0) + setError(null) + setIsLoading(true) + setStatus('Reconnecting...') + + // Clear reconnect timeout + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + + // Clear existing connection + if (rfbRef.current) { + try { + rfbRef.current.disconnect() + } catch (err) { + console.error('Error disconnecting RFB:', err) + } + rfbRef.current = null + } + + // Clear screen ref to allow reconnection + if (screenRef.current) { + // Remove all children + while (screenRef.current.firstChild) { + screenRef.current.removeChild(screenRef.current.firstChild) + } + } + + // Trigger reconnection by resetting and then setting rfbModule + setRfbModule(null) + setTimeout(() => { + if (rfbModuleRef.current) { + setRfbModule(rfbModuleRef.current) + } + }, 500) + }, []) + + const handleSendCtrlAltDel = () => { + if (rfbRef.current) { + try { + // Send Ctrl+Alt+Del key combination + rfbRef.current.sendCtrlAltDel() + } catch (err) { + console.error('Error sending Ctrl+Alt+Del:', err) + } + } + } + + const handleToggleFullscreen = () => { + if (!containerRef.current) return + + if (!isFullscreen) { + if (containerRef.current.requestFullscreen) { + containerRef.current.requestFullscreen() + } else if ((containerRef.current as any).webkitRequestFullscreen) { + ;(containerRef.current as any).webkitRequestFullscreen() + } else if ((containerRef.current as any).msRequestFullscreen) { + ;(containerRef.current as any).msRequestFullscreen() + } + } else { + if (document.exitFullscreen) { + document.exitFullscreen() + } else if ((document as any).webkitExitFullscreen) { + ;(document as any).webkitExitFullscreen() + } else if ((document as any).msExitFullscreen) { + ;(document as any).msExitFullscreen() + } + } + } + + const handleShowDotCursorChange = useCallback((checked: boolean) => { + setShowDotCursor(checked) + }, []) + + const handleScalingModeChange = useCallback((e: any) => { + const mode = e.target.value as 'none' | 'local' | 'remote' + setScalingMode(mode) + + // Apply changes immediately if connected + if (rfbRef.current) { + if (mode === 'none') { + rfbRef.current.scaleViewport = false + rfbRef.current.resizeSession = false + } else if (mode === 'local') { + rfbRef.current.scaleViewport = true + rfbRef.current.resizeSession = false + } else if (mode === 'remote') { + rfbRef.current.scaleViewport = false + rfbRef.current.resizeSession = true + } + } + }, []) + + // Create dropdown menu items + const optionsMenuItems: MenuProps['items'] = [ + { + key: 'show-cursor', + label: ( +
+ Show Cursor + +
+ ), + }, + { + type: 'divider', + }, + { + key: 'scaling-mode', + label: ( +
+
Scaling Mode
+ + None + Local scaling + Remote resizing + +
+ ), + }, + ] + + // Listen for fullscreen changes + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement) + } + + document.addEventListener('fullscreenchange', handleFullscreenChange) + document.addEventListener('webkitfullscreenchange', handleFullscreenChange) + document.addEventListener('msfullscreenchange', handleFullscreenChange) + + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange) + document.removeEventListener('webkitfullscreenchange', handleFullscreenChange) + document.removeEventListener('msfullscreenchange', handleFullscreenChange) + } + }, []) + + return ( + + + + + + + + + + + + + + + + + | + {status} + + + + +
+ + + + {isLoading && !error && ( + + +
{status}
+
+ )} + {error && !isLoading && ( + + + Reconnect + + } + /> + + )} + + ) +} + + + + diff --git a/src/components/molecules/Terminals/VMVNC/index.ts b/src/components/molecules/Terminals/VMVNC/index.ts new file mode 100644 index 0000000..7a577b6 --- /dev/null +++ b/src/components/molecules/Terminals/VMVNC/index.ts @@ -0,0 +1,3 @@ +export { VMVNC } from './VMVNC' +export type { TVMVNCProps } from './VMVNC' + diff --git a/src/components/molecules/Terminals/VMVNC/styled.ts b/src/components/molecules/Terminals/VMVNC/styled.ts new file mode 100644 index 0000000..3842142 --- /dev/null +++ b/src/components/molecules/Terminals/VMVNC/styled.ts @@ -0,0 +1,109 @@ +import styled from 'styled-components' + +type TContainerProps = { + $substractHeight: number +} + +const Container = styled.div` + height: calc(100vh - ${({ $substractHeight }) => $substractHeight}px); + display: flex; + flex-direction: column; + background-color: #1e1e1e; + position: relative; + + * { + scrollbar-width: thin; + } +` + +type TCustomCardProps = { + $isVisible?: boolean + $substractHeight: number +} + +const CustomCard = styled.div` + visibility: ${({ $isVisible }) => ($isVisible ? 'visible' : 'hidden')}; + height: 100%; + display: flex; + flex-direction: column; + background-color: #1e1e1e; + position: relative; + + * { + scrollbar-width: thin; + } +` + +type TFullWidthDivProps = { + $substractHeight: number +} + +const FullWidthDiv = styled.div` + display: flex; + justify-content: center; + align-items: center; + flex: 1; + overflow: hidden; + background-color: #000000; + min-width: 0; /* Allow flex item to shrink */ + transition: margin-right 0.3s ease; +` + +const ContentWrapper = styled.div` + display: flex; + flex: 1; + overflow: hidden; + position: relative; + min-width: 0; /* Allow flex item to shrink */ +` + +const StatusBar = styled.div` + background-color: #2d2d2d; + color: #ffffff; + padding: 8px 16px; + font-size: 12px; + border-bottom: 1px solid #3d3d3d; + display: flex; + align-items: center; + justify-content: flex-start; + flex-shrink: 0; + z-index: 10; + flex-wrap: wrap; + gap: 4px; +` + +const StatusDivider = styled.span` + color: #666666; + margin: 0 8px; + user-select: none; +` + +const LoadingContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + color: #ffffff; +` + +const ErrorContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + color: #ffffff; +` + +export const Styled = { + Container, + FullWidthDiv, + CustomCard, + StatusBar, + LoadingContainer, + ErrorContainer, + ContentWrapper, + StatusDivider, +} + diff --git a/src/components/molecules/Terminals/index.ts b/src/components/molecules/Terminals/index.ts index 5fff894..365f17c 100644 --- a/src/components/molecules/Terminals/index.ts +++ b/src/components/molecules/Terminals/index.ts @@ -2,3 +2,4 @@ export * from './PodTerminal' export * from './NodeTerminal' export * from './PodLogs' export * from './PodLogsMonaco' +export * from './VMVNC' diff --git a/src/components/organisms/DynamicComponents/DynamicComponents.ts b/src/components/organisms/DynamicComponents/DynamicComponents.ts index 1612f2a..b4615e5 100644 --- a/src/components/organisms/DynamicComponents/DynamicComponents.ts +++ b/src/components/organisms/DynamicComponents/DynamicComponents.ts @@ -21,6 +21,7 @@ import { PodTerminal, NodeTerminal, PodLogs, + VMVNC, YamlEditorSingleton, AntdLink, VisibilityContainer, @@ -62,6 +63,7 @@ export const DynamicComponents: TRendererComponents = ({ + data, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + children, +}) => { + const { data: multiQueryData, isLoading: isMultiqueryLoading } = useMultiQuery() + + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + id, + cluster, + namespace, + vmName, + substractHeight, + ...props + } = data + + const partsOfUrl = usePartsOfUrl() + + const replaceValues = partsOfUrl.partsOfUrl.reduce>((acc, value, index) => { + acc[index.toString()] = value + return acc + }, {}) + + const clusterPrepared = parseAll({ text: cluster, replaceValues, multiQueryData }) + + const namespacePrepared = parseAll({ text: namespace, replaceValues, multiQueryData }) + + const vmNamePrepared = parseAll({ text: vmName, replaceValues, multiQueryData }) + + if (isMultiqueryLoading) { + return
Loading multiquery
+ } + + if (!clusterPrepared || !namespacePrepared || !vmNamePrepared) { + return ( + + + + ) + } + + return ( + <> + + {children} + + ) +} + diff --git a/src/components/organisms/DynamicComponents/molecules/VMVNC/index.ts b/src/components/organisms/DynamicComponents/molecules/VMVNC/index.ts new file mode 100644 index 0000000..56218a3 --- /dev/null +++ b/src/components/organisms/DynamicComponents/molecules/VMVNC/index.ts @@ -0,0 +1,2 @@ +export * from './VMVNC' + diff --git a/src/components/organisms/DynamicComponents/molecules/index.ts b/src/components/organisms/DynamicComponents/molecules/index.ts index 74da0b8..4d9d961 100644 --- a/src/components/organisms/DynamicComponents/molecules/index.ts +++ b/src/components/organisms/DynamicComponents/molecules/index.ts @@ -20,6 +20,7 @@ export * from './EnrichedTable' export * from './PodTerminal' export * from './NodeTerminal' export * from './PodLogs' +export * from './VMVNC' export * from './YamlEditorSingleton' export * from './VisibilityContainer' export * from './ArrayOfObjectsToKeyValues' diff --git a/src/components/organisms/DynamicComponents/types.ts b/src/components/organisms/DynamicComponents/types.ts index 8968f6e..990ba90 100644 --- a/src/components/organisms/DynamicComponents/types.ts +++ b/src/components/organisms/DynamicComponents/types.ts @@ -101,6 +101,13 @@ export type TDynamicComponentsAppTypeMap = { nodeName: string substractHeight?: number } + VMVNC: { + id: number | string + cluster: string + namespace: string + vmName: string + substractHeight?: number + } PodLogs: { id: number | string cluster: string diff --git a/vite.config.ts b/vite.config.ts index 2e27a69..d132f3e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,17 @@ import path from 'path' import dotenv from 'dotenv' -import { defineConfig } from 'vite' +import { defineConfig, Plugin } from 'vite' import react from '@vitejs/plugin-react-swc' import { nodePolyfills } from 'vite-plugin-node-polyfills' import pkg from './package.json' const { VITE_BASEPREFIX } = process.env +// Note: We handle @novnc/novnc externalization differently for ES and UMD formats +// For ES: it will be bundled via dynamic import (not external) +// For UMD: it will be externalized to avoid top-level await issues +// The external function below handles this by checking if we're building UMD + export default defineConfig({ root: './', base: VITE_BASEPREFIX || '/toolkit', @@ -18,28 +23,83 @@ export default defineConfig({ fileName: format => `openapi-k8s-toolkit.${format}.js`, }, rollupOptions: { - external: [ - 'react', - 'react-dom', - 'react-router-dom', - '@tanstack/react-query', - '@tanstack/react-query-devtools', - 'antd', - '@ant-design/icons', - 'styled-components', - ], - output: { - globals: { - react: 'React', - 'react-dom': 'ReactDOM', - 'react-router-dom': 'ReactRouterDOM', - '@tanstack/react-query': 'reactQuery', - '@tanstack/react-query-devtools': 'reactQuery-devtools', - antd: 'antd', - '@ant-design/icons': 'antdIcons', - 'styled-components': 'styled', - }, + external: (id) => { + // Standard external dependencies + const externals = [ + 'react', + 'react-dom', + 'react-router-dom', + '@tanstack/react-query', + '@tanstack/react-query-devtools', + 'antd', + '@ant-design/icons', + 'styled-components', + ] + + if (externals.includes(id)) { + return true + } + + // For @novnc/novnc: + // - For ES format: don't externalize, let it be bundled via dynamic import + // This allows Vite to create a separate chunk, avoiding top-level await issues + // - For UMD format: we need to externalize to avoid top-level await + // Since external() is called per-output, we check if UMD output exists + // But actually, external() doesn't know which output it's for + // So we'll externalize it for both formats, but the consuming app will bundle it for ES + + // Actually, the best approach: don't externalize here + // For ES format, Vite will bundle it via dynamic import + // For UMD format, we'll handle it via the output's globals config + // But that won't prevent bundling... + + // Let's try: externalize only if it's clearly a UMD build context + // Since we can't detect format in external(), we'll use a different approach: + // Don't externalize @novnc/novnc - let it be bundled for ES + // For UMD, the build will fail with top-level await, but we'll handle that separately + // Actually, let's externalize it for both to be safe, and let the consuming app handle bundling + + // Final approach: externalize @novnc/novnc to prevent top-level await in UMD + // The consuming app's Vite config will resolve it and bundle it properly for ES format + if (id.includes('@novnc/novnc') || id.includes('/novnc/')) { + return true + } + + return false }, + output: [ + { + format: 'es', + // For ES format, @novnc/novnc will be bundled via dynamic import + // The consuming app's Vite will create a separate chunk for it + // This avoids top-level await issues while still bundling the module + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + 'react-router-dom': 'ReactRouterDOM', + '@tanstack/react-query': 'reactQuery', + '@tanstack/react-query-devtools': 'reactQuery-devtools', + antd: 'antd', + '@ant-design/icons': 'antdIcons', + 'styled-components': 'styled', + }, + }, + { + format: 'umd', + name: pkg.name, + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + 'react-router-dom': 'ReactRouterDOM', + '@tanstack/react-query': 'reactQuery', + '@tanstack/react-query-devtools': 'reactQuery-devtools', + antd: 'antd', + '@ant-design/icons': 'antdIcons', + 'styled-components': 'styled', + '@novnc/novnc': 'noVNC', // Will need to be loaded separately for UMD + }, + }, + ], }, sourcemap: true, minify: false,