Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions jsonrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,9 @@ var rpcHandlers = map[string]RPCHandler{
"listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
"downloadFromUrl": {Func: rpcDownloadFromUrl, Params: []string{"url", "filename"}},
"getDownloadState": {Func: rpcGetDownloadState},
"cancelDownload": {Func: rpcCancelDownload},
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
Expand Down
13 changes: 13 additions & 0 deletions ui/localization/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,19 @@
"mount_uploaded_has_been_uploaded": "{name} has been uploaded",
"mount_uploading": "Uploading…",
"mount_uploading_with_name": "Uploading {name}",
"mount_download_title": "Download from URL",
"mount_download_description": "Download an image file directly to JetKVM storage from a URL",
"mount_download_url_label": "Image URL",
"mount_download_filename_label": "Save as filename",
"mount_downloading": "Downloading...",
"mount_downloading_with_name": "Downloading {name}",
"mount_download_successful": "Download successful",
"mount_download_has_been_downloaded": "{name} has been downloaded",
"mount_download_error": "Download error: {error}",
"mount_download_cancelled": "Download cancelled",
"mount_button_start_download": "Start Download",
"mount_button_cancel_download": "Cancel Download",
"mount_button_download_from_url": "Download from URL",
"mount_url_description": "Mount files from any public web address",
"mount_url_input_label": "Image URL",
"mount_url_mount": "URL Mount",
Expand Down
2 changes: 1 addition & 1 deletion ui/src/hooks/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ export interface MountMediaState {
remoteVirtualMediaState: RemoteVirtualMediaState | null;
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void;

modalView: "mode" | "url" | "device" | "upload" | "error" | null;
modalView: "mode" | "url" | "device" | "upload" | "download" | "error" | null;
setModalView: (view: MountMediaState["modalView"]) => void;

isMountMediaDialogOpen: boolean;
Expand Down
290 changes: 289 additions & 1 deletion ui/src/routes/devices.$id.mount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
LuRadioReceiver,
LuCheck,
LuUpload,
LuDownload,
} from "react-icons/lu";
import { PlusCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { TrashIcon } from "@heroicons/react/16/solid";
Expand Down Expand Up @@ -186,6 +187,9 @@ export function Dialog({ onClose }: Readonly<{ onClose: () => void }>) {
setIncompleteFileName(incompleteFile || null);
setModalView("upload");
}}
onDownloadClick={() => {
setModalView("download");
}}
/>
)}

Expand All @@ -200,6 +204,15 @@ export function Dialog({ onClose }: Readonly<{ onClose: () => void }>) {
/>
)}

{modalView === "download" && (
<DownloadFileView
onBack={() => setModalView("device")}
onDownloadComplete={() => {
setModalView("device");
}}
/>
)}

{modalView === "error" && (
<ErrorView
errorMessage={errorMessage}
Expand Down Expand Up @@ -514,11 +527,13 @@ function DeviceFileView({
mountInProgress,
onBack,
onNewImageClick,
onDownloadClick,
}: {
onMountStorageFile: (name: string, mode: RemoteVirtualMediaState["mode"]) => void;
mountInProgress: boolean;
onBack: () => void;
onNewImageClick: (incompleteFileName?: string) => void;
onDownloadClick: () => void;
}) {
const [onStorageFiles, setOnStorageFiles] = useState<StorageFile[]>([]);

Expand Down Expand Up @@ -795,7 +810,7 @@ function DeviceFileView({

{onStorageFiles.length > 0 && (
<div
className="w-full animate-fadeIn opacity-0"
className="w-full animate-fadeIn space-y-2 opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.25s",
Expand All @@ -808,6 +823,13 @@ function DeviceFileView({
text={m.mount_button_upload_new_image()}
onClick={() => onNewImageClick()}
/>
<Button
size="MD"
theme="light"
fullWidth
text={m.mount_button_download_from_url()}
onClick={() => onDownloadClick()}
/>
</div>
)}
</div>
Expand Down Expand Up @@ -1243,6 +1265,272 @@ function UploadFileView({
);
}

function DownloadFileView({
onBack,
onDownloadComplete,
}: {
onBack: () => void;
onDownloadComplete: () => void;
}) {
const [downloadViewState, setDownloadViewState] = useState<"idle" | "downloading" | "success" | "error">("idle");
const [url, setUrl] = useState<string>("");
const [filename, setFilename] = useState<string>("");
const [progress, setProgress] = useState(0);
const [downloadSpeed, setDownloadSpeed] = useState<number | null>(null);
const [downloadError, setDownloadError] = useState<string | null>(null);
const [totalBytes, setTotalBytes] = useState<number>(0);

const { send } = useJsonRpc();

// Track download speed
const lastBytesRef = useRef(0);
const lastTimeRef = useRef(0);
const speedHistoryRef = useRef<number[]>([]);

// Compute URL validity
const isUrlValid = useMemo(() => {
try {
const urlObj = new URL(url);
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
} catch {
return false;
}
}, [url]);

// Extract filename from URL
const suggestedFilename = useMemo(() => {
if (!url) return '';
try {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/');
const lastPart = pathParts[pathParts.length - 1];
if (lastPart && (lastPart.endsWith('.iso') || lastPart.endsWith('.img'))) {
return lastPart;
}
} catch {
// Invalid URL, ignore
}
return '';
}, [url]);

// Update filename when URL changes and user hasn't manually edited it
const [userEditedFilename, setUserEditedFilename] = useState(false);
const effectiveFilename = userEditedFilename ? filename : (suggestedFilename || filename);

// Listen for download state events via polling
useEffect(() => {
if (downloadViewState !== "downloading") return;

const pollInterval = setInterval(() => {
send("getDownloadState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;

const state = resp.result as {
downloading: boolean;
filename: string;
totalBytes: number;
doneBytes: number;
progress: number;
error?: string;
};

if (state.error) {
setDownloadError(state.error);
setDownloadViewState("error");
return;
}

setTotalBytes(state.totalBytes);
setProgress(state.progress * 100);

// Calculate speed
const now = Date.now();
const timeDiff = (now - lastTimeRef.current) / 1000;
const bytesDiff = state.doneBytes - lastBytesRef.current;

if (timeDiff > 0 && bytesDiff > 0) {
const instantSpeed = bytesDiff / timeDiff;
speedHistoryRef.current.push(instantSpeed);
if (speedHistoryRef.current.length > 5) {
speedHistoryRef.current.shift();
}
const avgSpeed = speedHistoryRef.current.reduce((a, b) => a + b, 0) / speedHistoryRef.current.length;
setDownloadSpeed(avgSpeed);
}

lastBytesRef.current = state.doneBytes;
lastTimeRef.current = now;

if (!state.downloading && state.progress >= 1) {
setDownloadViewState("success");
}
});
}, 500);

return () => clearInterval(pollInterval);
}, [downloadViewState, send]);

function handleStartDownload() {
if (!url || !effectiveFilename) return;

setDownloadViewState("downloading");
setDownloadError(null);
setProgress(0);
setDownloadSpeed(null);
lastBytesRef.current = 0;
lastTimeRef.current = Date.now();
speedHistoryRef.current = [];

send("downloadFromUrl", { url, filename: effectiveFilename }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
setDownloadError(resp.error.message);
setDownloadViewState("error");
}
});
}

function handleCancelDownload() {
send("cancelDownload", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to cancel download:", resp.error);
}
setDownloadViewState("idle");
});
}

return (
<div className="w-full space-y-4">
<ViewHeader
title={m.mount_download_title()}
description={m.mount_download_description()}
/>

{downloadViewState === "idle" && (
<>
<div className="animate-fadeIn space-y-4 opacity-0" style={{ animationDuration: "0.7s" }}>
<InputFieldWithLabel
placeholder="https://example.com/image.iso"
type="url"
label={m.mount_download_url_label()}
value={url}
onChange={e => setUrl(e.target.value)}
/>
<InputFieldWithLabel
placeholder="image.iso"
type="text"
label={m.mount_download_filename_label()}
value={effectiveFilename}
onChange={e => {
setFilename(e.target.value);
setUserEditedFilename(true);
}}
/>
</div>
<div className="flex w-full justify-end space-x-2">
<Button size="MD" theme="blank" text={m.back()} onClick={onBack} />
<Button
size="MD"
theme="primary"
text={m.mount_button_start_download()}
onClick={handleStartDownload}
disabled={!isUrlValid || !effectiveFilename}
/>
</div>
</>
)}

{downloadViewState === "downloading" && (
<div className="animate-fadeIn space-y-4 opacity-0" style={{ animationDuration: "0.7s" }}>
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<LuDownload className="h-5 w-5 text-blue-500 animate-pulse" />
<h3 className="text-lg font-semibold dark:text-white">
{m.mount_downloading_with_name({ name: formatters.truncateMiddle(effectiveFilename, 30) })}
</h3>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">
{formatters.bytes(totalBytes)}
</p>
<div className="h-3.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-700">
<div
className="h-3.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex justify-between text-xs text-slate-600 dark:text-slate-400">
<span>{m.mount_downloading()}</span>
<span>
{downloadSpeed !== null
? `${formatters.bytes(downloadSpeed)}/s`
: m.mount_calculating()}
</span>
</div>
</div>
</Card>
<div className="flex w-full justify-end">
<Button
size="MD"
theme="light"
text={m.mount_button_cancel_download()}
onClick={handleCancelDownload}
/>
</div>
</div>
)}

{downloadViewState === "success" && (
<div className="animate-fadeIn space-y-4 opacity-0" style={{ animationDuration: "0.7s" }}>
<Card>
<div className="p-4 text-center space-y-2">
<LuCheck className="h-8 w-8 text-green-500 mx-auto" />
<h3 className="text-lg font-semibold dark:text-white">
{m.mount_download_successful()}
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">
{m.mount_download_has_been_downloaded({ name: effectiveFilename })}
</p>
</div>
</Card>
<div className="flex w-full justify-end">
<Button
size="MD"
theme="primary"
text={m.mount_button_back_to_overview()}
onClick={onDownloadComplete}
/>
</div>
</div>
)}

{downloadViewState === "error" && (
<div className="animate-fadeIn space-y-4 opacity-0" style={{ animationDuration: "0.7s" }}>
<Card className="border border-red-200 bg-red-50 dark:bg-red-900/20">
<div className="p-4 text-center space-y-2">
<ExclamationTriangleIcon className="h-8 w-8 text-red-500 mx-auto" />
<h3 className="text-lg font-semibold text-red-800 dark:text-red-400">
{m.mount_error_title()}
</h3>
<p className="text-sm text-red-600 dark:text-red-400">
{downloadError}
</p>
</div>
</Card>
<div className="flex w-full justify-end space-x-2">
<Button size="MD" theme="light" text={m.back()} onClick={onBack} />
<Button
size="MD"
theme="primary"
text={m.mount_button_back_to_overview()}
onClick={() => setDownloadViewState("idle")}
/>
</div>
</div>
)}
</div>
);
}

function ErrorView({
errorMessage,
onClose,
Expand Down
Loading
Loading