import Card, { GridCard } from "@/components/Card"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@components/Button"; import LogoBlueIcon from "@/assets/logo-blue.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg"; import Modal from "@components/Modal"; import { MountMediaState, RemoteVirtualMediaState, useMountMediaStore, useRTCStore, } from "../hooks/stores"; import { cx } from "../cva.config"; import { LuGlobe, LuLink, LuRadioReceiver, LuHardDrive, LuCheck, LuUpload, } from "react-icons/lu"; import { formatters } from "@/utils"; import { PlusCircleIcon } from "@heroicons/react/20/solid"; import AutoHeight from "./AutoHeight"; import { InputFieldWithLabel } from "./InputField"; import DebianIcon from "@/assets/debian-icon.png"; import UbuntuIcon from "@/assets/ubuntu-icon.png"; import FedoraIcon from "@/assets/fedora-icon.png"; import OpenSUSEIcon from "@/assets/opensuse-icon.png"; import ArchIcon from "@/assets/arch-icon.png"; import NetBootIcon from "@/assets/netboot-icon.svg"; import { TrashIcon } from "@heroicons/react/16/solid"; import { useJsonRpc } from "../hooks/useJsonRpc"; import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import notifications from "../notifications"; import Fieldset from "./Fieldset"; import { isOnDevice } from "../main"; import { SIGNAL_API } from "@/ui.config"; export default function MountMediaModal({ open, setOpen, }: { open: boolean; setOpen: (open: boolean) => void; }) { return ( setOpen(false)}> ); } export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { const { modalView, setModalView, setLocalFile, setIsMountMediaDialogOpen, setRemoteVirtualMediaState, errorMessage, setErrorMessage, } = useMountMediaStore(); const [incompleteFileName, setIncompleteFileName] = useState(null); const [mountInProgress, setMountInProgress] = useState(false); function clearMountMediaState() { setLocalFile(null); setRemoteVirtualMediaState(null); } const [send] = useJsonRpc(); async function syncRemoteVirtualMediaState() { return new Promise((resolve, reject) => { send("getVirtualMediaState", {}, resp => { if ("error" in resp) { reject(new Error(resp.error.message)); } else { setRemoteVirtualMediaState( resp as unknown as MountMediaState["remoteVirtualMediaState"], ); resolve(null); } }); }); } function triggerError(message: string) { setErrorMessage(message); setModalView("error"); } function handleUrlMount(url: string, mode: RemoteVirtualMediaState["mode"]) { console.log(`Mounting ${url} as ${mode}`); setMountInProgress(true); send("mountWithHTTP", { url, mode }, async resp => { if ("error" in resp) triggerError(resp.error.message); clearMountMediaState(); syncRemoteVirtualMediaState() .then(() => { setIsMountMediaDialogOpen(false); }) .catch(err => { triggerError(err instanceof Error ? err.message : String(err)); }) .finally(() => { setMountInProgress(false); }); setIsMountMediaDialogOpen(false); }); } function handleStorageMount(fileName: string, mode: RemoteVirtualMediaState["mode"]) { console.log(`Mounting ${fileName} as ${mode}`); setMountInProgress(true); send("mountWithStorage", { filename: fileName, mode }, async resp => { if ("error" in resp) triggerError(resp.error.message); clearMountMediaState(); syncRemoteVirtualMediaState() .then(() => { setIsMountMediaDialogOpen(false); }) .catch(err => { triggerError(err instanceof Error ? err.message : String(err)); }) .finally(() => { // We do this beacues the mounting is too fast and the UI gets choppy // and the modal exit animation for like 500ms setTimeout(() => { setMountInProgress(false); }, 500); }); }); clearMountMediaState(); } function handleBrowserMount(file: File, mode: RemoteVirtualMediaState["mode"]) { console.log(`Mounting ${file.name} as ${mode}`); setMountInProgress(true); send( "mountWithWebRTC", { filename: file.name, size: file.size, mode }, async resp => { if ("error" in resp) triggerError(resp.error.message); clearMountMediaState(); syncRemoteVirtualMediaState() .then(() => { // We need to keep the local file in the store so that the browser can // continue to stream the file to the device setLocalFile(file); setIsMountMediaDialogOpen(false); }) .catch(err => { triggerError(err instanceof Error ? err.message : String(err)); }) .finally(() => { setMountInProgress(false); }); }, ); } const [selectedMode, setSelectedMode] = useState<"browser" | "url" | "device">("url"); return (
JetKVM Logo JetKVM Logo {modalView === "mode" && ( setOpen(false)} selectedMode={selectedMode} setSelectedMode={setSelectedMode} /> )} {modalView === "browser" && ( { handleBrowserMount(file, mode); }} onBack={() => { setMountInProgress(false); setModalView("mode"); }} /> )} {modalView === "url" && ( { setMountInProgress(false); setModalView("mode"); }} onMount={(url, mode) => { handleUrlMount(url, mode); }} /> )} {modalView === "device" && ( { setMountInProgress(false); setModalView("mode"); }} mountInProgress={mountInProgress} onMountStorageFile={(fileName, mode) => { handleStorageMount(fileName, mode); }} onNewImageClick={incompleteFile => { setIncompleteFileName(incompleteFile || null); setModalView("upload"); }} /> )} {modalView === "upload" && ( setModalView("device")} onCancelUpload={() => { setModalView("device"); // Implement cancel upload logic here }} incompleteFileName={incompleteFileName || undefined} /> )} {modalView === "error" && ( { setOpen(false); setErrorMessage(null); }} onRetry={() => { setModalView("mode"); setErrorMessage(null); }} /> )}
); } function ModeSelectionView({ onClose, selectedMode, setSelectedMode, }: { onClose: () => void; selectedMode: "browser" | "url" | "device"; setSelectedMode: (mode: "browser" | "url" | "device") => void; }) { const { setModalView } = useMountMediaStore(); return (

Virtual Media Source

Choose how you want to mount your virtual media
{[ { label: "Browser Mount", value: "browser", description: "Stream files directly from your browser", icon: LuGlobe, tag: "Coming Soon", disabled: true, }, { label: "URL Mount", value: "url", description: "Mount files from any public web address", icon: LuLink, tag: "Experimental", disabled: false, }, { label: "JetKVM Storage Mount", value: "device", description: "Mount previously uploaded files from the JetKVM storage", icon: LuRadioReceiver, tag: null, disabled: false, }, ].map(({ label, description, value: mode, icon: Icon, tag, disabled }, index) => (
disabled ? null : setSelectedMode(mode as "browser" | "url" | "device") } >

{tag ? tag : <> }

{label}

{description}

))}
); } function BrowserFileView({ onMountFile, onBack, mountInProgress, }: { onBack: () => void; onMountFile: (file: File, mode: RemoteVirtualMediaState["mode"]) => void; mountInProgress: boolean; }) { const [selectedFile, setSelectedFile] = useState(null); const [usbMode, setUsbMode] = useState("CDROM"); const handleFileChange = (event: React.ChangeEvent) => { const file = event.target.files?.[0] || null; setSelectedFile(file); if (file?.name.endsWith(".iso")) { setUsbMode("CDROM"); } else if (file?.name.endsWith(".img")) { setUsbMode("CDROM"); } }; const handleMount = () => { if (selectedFile) { console.log(`Mounting ${selectedFile.name} as ${setUsbMode}`); onMountFile(selectedFile, usbMode); } }; return (
document.getElementById("file-upload")?.click()} className="block cursor-pointer select-none" >
{selectedFile ? ( <>

{formatters.truncateMiddle(selectedFile.name, 40)}

{formatters.bytes(selectedFile.size)}

) : (

Click to select a file

Supported formats: ISO, IMG

)}
); } function UrlView({ onBack, onMount, mountInProgress, }: { onBack: () => void; onMount: (url: string, usbMode: RemoteVirtualMediaState["mode"]) => void; mountInProgress: boolean; }) { const [usbMode, setUsbMode] = useState("CDROM"); const [url, setUrl] = useState(""); const popularImages = [ { name: "Ubuntu 24.04 LTS", url: "https://releases.ubuntu.com/noble/ubuntu-24.04.1-desktop-amd64.iso", icon: UbuntuIcon, }, { name: "Debian 12", url: "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-12.9.0-amd64-netinst.iso", icon: DebianIcon, }, { name: "Fedora 41", url: "https://download.fedoraproject.org/pub/fedora/linux/releases/41/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-41-1.4.iso", icon: FedoraIcon, }, { name: "openSUSE Leap 15.6", url: "https://download.opensuse.org/distribution/leap/15.6/iso/openSUSE-Leap-15.6-NET-x86_64-Media.iso", icon: OpenSUSEIcon, }, { name: "openSUSE Tumbleweed", url: "https://download.opensuse.org/tumbleweed/iso/openSUSE-Tumbleweed-NET-x86_64-Current.iso", icon: OpenSUSEIcon, }, { name: "Arch Linux", url: "https://archlinux.doridian.net/iso/2025.02.01/archlinux-2025.02.01-x86_64.iso", icon: ArchIcon, }, { name: "netboot.xyz", url: "https://boot.netboot.xyz/ipxe/netboot.xyz.iso", icon: NetBootIcon, description: "Boot and install various operating systems over network", }, ]; const urlRef = useRef(null); function handleUrlChange(url: string) { setUrl(url); if (url.endsWith(".iso")) { setUsbMode("CDROM"); } else if (url.endsWith(".img")) { setUsbMode("CDROM"); } } return (
handleUrlChange(e.target.value)} />

Popular images

{popularImages.map((image, index) => (
{`${image.name}

{formatters.truncateMiddle(image.name, 40)}

{image.description && (

{image.description}

)}

{formatters.truncateMiddle(image.url, 50)}

))}
); } function DeviceFileView({ onMountStorageFile, mountInProgress, onBack, onNewImageClick, }: { onMountStorageFile: (name: string, mode: RemoteVirtualMediaState["mode"]) => void; mountInProgress: boolean; onBack: () => void; onNewImageClick: (incompleteFileName?: string) => void; }) { const [onStorageFiles, setOnStorageFiles] = useState< { name: string; size: string; createdAt: string; }[] >([]); const [selected, setSelected] = useState(null); const [usbMode, setUsbMode] = useState("CDROM"); const [currentPage, setCurrentPage] = useState(1); const filesPerPage = 5; const [send] = useJsonRpc(); interface StorageSpace { bytesUsed: number; bytesFree: number; } const [storageSpace, setStorageSpace] = useState(null); const percentageUsed = useMemo(() => { if (!storageSpace) return 0; return Number( ( (storageSpace.bytesUsed / (storageSpace.bytesUsed + storageSpace.bytesFree)) * 100 ).toFixed(1), ); }, [storageSpace]); const bytesUsed = useMemo(() => { if (!storageSpace) return 0; return storageSpace.bytesUsed; }, [storageSpace]); const bytesFree = useMemo(() => { if (!storageSpace) return 0; return storageSpace.bytesFree; }, [storageSpace]); const syncStorage = useCallback(() => { send("listStorageFiles", {}, res => { if ("error" in res) { notifications.error(`Error listing storage files: ${res.error}`); return; } const { files } = res.result as StorageFiles; const formattedFiles = files.map(file => ({ name: file.filename, size: formatters.bytes(file.size), createdAt: formatters.date(new Date(file?.createdAt)), })); setOnStorageFiles(formattedFiles); }); send("getStorageSpace", {}, res => { if ("error" in res) { notifications.error(`Error getting storage space: ${res.error}`); return; } const space = res.result as StorageSpace; setStorageSpace(space); }); }, [send, setOnStorageFiles, setStorageSpace]); useEffect(() => { syncStorage(); }, [syncStorage]); interface StorageFiles { files: { filename: string; size: number; createdAt: string; }[]; } useEffect(() => { syncStorage(); }, [syncStorage]); function handleDeleteFile(file: { name: string; size: string; createdAt: string }) { console.log("Deleting file:", file); send("deleteStorageFile", { filename: file.name }, res => { if ("error" in res) { notifications.error(`Error deleting file: ${res.error}`); return; } syncStorage(); }); } function handleOnSelectFile(file: { name: string; size: string; createdAt: string }) { setSelected(file.name); if (file.name.endsWith(".iso")) { setUsbMode("CDROM"); } else if (file.name.endsWith(".img")) { setUsbMode("CDROM"); } } const indexOfLastFile = currentPage * filesPerPage; const indexOfFirstFile = indexOfLastFile - filesPerPage; const currentFiles = onStorageFiles.slice(indexOfFirstFile, indexOfLastFile); const totalPages = Math.ceil(onStorageFiles.length / filesPerPage); const handlePreviousPage = () => { setCurrentPage(prev => Math.max(prev - 1, 1)); }; const handleNextPage = () => { setCurrentPage(prev => Math.min(prev + 1, totalPages)); }; return (
{onStorageFiles.length === 0 ? (

No images available

Upload an image to start virtual media mounting.

) : (
{currentFiles.map((file, index) => ( { const selectedFile = onStorageFiles.find(f => f.name === file.name); if (!selectedFile) return; handleDeleteFile(selectedFile); }} onSelect={() => handleOnSelectFile(file)} onContinueUpload={() => onNewImageClick(file.name)} /> ))} {onStorageFiles.length > filesPerPage && (

Showing {indexOfFirstFile + 1} to{" "} {Math.min(indexOfLastFile, onStorageFiles.length)} {" "} of {onStorageFiles.length} results

)}
)}
{onStorageFiles.length > 0 ? (
) : (
)}
Available Storage {percentageUsed}% used
{formatters.bytes(bytesUsed)} used {formatters.bytes(bytesFree)} free
{onStorageFiles.length > 0 && (
)}
); } function UploadFileView({ onBack, onCancelUpload, incompleteFileName, }: { onBack: () => void; onCancelUpload: () => void; incompleteFileName?: string; }) { const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">( "idle", ); const [uploadProgress, setUploadProgress] = useState(0); const [uploadedFileName, setUploadedFileName] = useState(null); const [uploadedFileSize, setUploadedFileSize] = useState(null); const [uploadSpeed, setUploadSpeed] = useState(null); const [fileError, setFileError] = useState(null); const [uploadError, setUploadError] = useState(null); const [send] = useJsonRpc(); const rtcDataChannelRef = useRef(null); useEffect(() => { const ref = rtcDataChannelRef.current; return () => { console.log("unmounting"); if (ref) { ref.onopen = null; ref.onerror = null; ref.onmessage = null; ref.onclose = null; ref.close(); } }; }, []); function handleWebRTCUpload( file: File, alreadyUploadedBytes: number, dataChannel: string, ) { const rtcDataChannel = useRTCStore .getState() .peerConnection?.createDataChannel(dataChannel); if (!rtcDataChannel) { console.error("Failed to create data channel for file upload"); notifications.error("Failed to create data channel for file upload"); setUploadState("idle"); console.log("Upload state set to 'idle'"); return; } rtcDataChannelRef.current = rtcDataChannel; const lowWaterMark = 256 * 1024; const highWaterMark = 1 * 1024 * 1024; rtcDataChannel.bufferedAmountLowThreshold = lowWaterMark; let lastUploadedBytes = alreadyUploadedBytes; let lastUpdateTime = Date.now(); const speedHistory: number[] = []; rtcDataChannel.onmessage = e => { try { const { AlreadyUploadedBytes, Size } = JSON.parse(e.data) as { AlreadyUploadedBytes: number; Size: number; }; const now = Date.now(); const timeDiff = (now - lastUpdateTime) / 1000; // in seconds const bytesDiff = AlreadyUploadedBytes - lastUploadedBytes; if (timeDiff > 0) { const instantSpeed = bytesDiff / timeDiff; // bytes per second // Add to speed history, keeping last 5 readings speedHistory.push(instantSpeed); if (speedHistory.length > 5) { speedHistory.shift(); } // Calculate average speed const averageSpeed = speedHistory.reduce((a, b) => a + b, 0) / speedHistory.length; setUploadSpeed(averageSpeed); setUploadProgress((AlreadyUploadedBytes / Size) * 100); } lastUploadedBytes = AlreadyUploadedBytes; lastUpdateTime = now; } catch (e) { console.error("Error processing RTC Data channel message:", e); } }; rtcDataChannel.onopen = () => { let pauseSending = false; // Pause sending when the buffered amount is high const chunkSize = 4 * 1024; // 4KB chunks let offset = alreadyUploadedBytes; const sendNextChunk = () => { if (offset >= file.size) { rtcDataChannel.close(); setUploadState("success"); return; } if (pauseSending) return; const chunk = file.slice(offset, offset + chunkSize); chunk.arrayBuffer().then(buffer => { rtcDataChannel.send(buffer); if (rtcDataChannel.bufferedAmount >= highWaterMark) { pauseSending = true; } offset += buffer.byteLength; console.log(`Chunk sent: ${offset} / ${file.size} bytes`); sendNextChunk(); }); }; sendNextChunk(); rtcDataChannel.onbufferedamountlow = () => { console.log("RTC Data channel buffered amount low"); pauseSending = false; // Now the data channel is ready to send more data sendNextChunk(); }; }; rtcDataChannel.onerror = error => { console.error("RTC Data channel error:", error); notifications.error(`Upload failed: ${error}`); setUploadState("idle"); console.log("Upload state set to 'idle'"); }; } async function handleHttpUpload( file: File, alreadyUploadedBytes: number, dataChannel: string, ) { const uploadUrl = `${SIGNAL_API}/storage/upload?uploadId=${dataChannel}`; const xhr = new XMLHttpRequest(); xhr.open("POST", uploadUrl, true); let lastUploadedBytes = alreadyUploadedBytes; let lastUpdateTime = Date.now(); const speedHistory: number[] = []; xhr.upload.onprogress = event => { if (event.lengthComputable) { const totalUploaded = alreadyUploadedBytes + event.loaded; const totalSize = file.size; const now = Date.now(); const timeDiff = (now - lastUpdateTime) / 1000; // in seconds const bytesDiff = totalUploaded - lastUploadedBytes; if (timeDiff > 0) { const instantSpeed = bytesDiff / timeDiff; // bytes per second // Add to speed history, keeping last 5 readings speedHistory.push(instantSpeed); if (speedHistory.length > 5) { speedHistory.shift(); } // Calculate average speed const averageSpeed = speedHistory.reduce((a, b) => a + b, 0) / speedHistory.length; setUploadSpeed(averageSpeed); setUploadProgress((totalUploaded / totalSize) * 100); } lastUploadedBytes = totalUploaded; lastUpdateTime = now; } }; xhr.onload = () => { if (xhr.status === 200) { setUploadState("success"); } else { console.error("Upload error:", xhr.statusText); setUploadError(xhr.statusText); setUploadState("idle"); } }; xhr.onerror = () => { console.error("XHR error:", xhr.statusText); setUploadError(xhr.statusText); setUploadState("idle"); }; // Prepare the data to send const blob = file.slice(alreadyUploadedBytes); // Send the file data xhr.send(blob); } const handleFileChange = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { // Reset the upload error when a new file is selected setUploadError(null); if ( incompleteFileName && file.name !== incompleteFileName.replace(".incomplete", "") ) { setFileError( `Please select the file "${incompleteFileName.replace(".incomplete", "")}" to continue the upload.`, ); return; } setFileError(null); console.log(`File selected: ${file.name}, size: ${file.size} bytes`); setUploadedFileName(file.name); setUploadedFileSize(file.size); setUploadState("uploading"); console.log("Upload state set to 'uploading'"); send("startStorageFileUpload", { filename: file.name, size: file.size }, resp => { console.log("startStorageFileUpload response:", resp); if ("error" in resp) { console.error("Upload error:", resp.error.message); setUploadError(resp.error.data || resp.error.message); setUploadState("idle"); console.log("Upload state set to 'idle'"); return; } const { alreadyUploadedBytes, dataChannel } = resp.result as { alreadyUploadedBytes: number; dataChannel: string; }; console.log( `Already uploaded bytes: ${alreadyUploadedBytes}, Data channel: ${dataChannel}`, ); if (isOnDevice) { handleHttpUpload(file, alreadyUploadedBytes, dataChannel); } else { handleWebRTCUpload(file, alreadyUploadedBytes, dataChannel); } }); } }; return (
{ if (uploadState === "idle") { document.getElementById("file-upload")?.click(); } }} className="block select-none" >
{uploadState === "idle" && (

{incompleteFileName ? `Click to select "${incompleteFileName.replace(".incomplete", "")}"` : "Click to select a file"}

Supported formats: ISO, IMG

)} {uploadState === "uploading" && (

Uploading {formatters.truncateMiddle(uploadedFileName, 30)}

{formatters.bytes(uploadedFileSize || 0)}

Uploading... {uploadSpeed !== null ? `${formatters.bytes(uploadSpeed)}/s` : "Calculating..."}
)} {uploadState === "success" && (

Upload successful

{formatters.truncateMiddle(uploadedFileName, 40)} has been uploaded

)}
{fileError &&

{fileError}

}
{/* Display upload error if present */} {uploadError && (
Error: {uploadError}
)}
{uploadState === "uploading" ? (
); } function ErrorView({ errorMessage, onClose, onRetry, }: { errorMessage: string | null; onClose: () => void; onRetry: () => void; }) { return (

Mount Error

An error occurred while attempting to mount the media. Please try again.

{errorMessage && (

{errorMessage}

)}
); } function PreUploadedImageItem({ name, size, uploadedAt, isSelected, isIncomplete, onSelect, onDelete, onContinueUpload, }: { name: string; size: string; uploadedAt: string; isSelected: boolean; isIncomplete: boolean; onSelect: () => void; onDelete: () => void; onContinueUpload: () => void; }) { const [isHovering, setIsHovering] = useState(false); return ( ); } function ViewHeader({ title, description }: { title: string; description: string }) { return (

{title}

{description}
); } function UsbModeSelector({ usbMode, setUsbMode, }: { usbMode: RemoteVirtualMediaState["mode"]; setUsbMode: (mode: RemoteVirtualMediaState["mode"]) => void; }) { return (
); }