Skip to content

Commit 3330485

Browse files
committed
feat: add ISO direct download from URL to JetKVM storage
Add feature to download ISO/IMG files directly to JetKVM storage from a URL. This addresses the use case where remote deployments have slow upload speeds, making direct URL downloads significantly faster. Backend changes: - Add DownloadState struct for tracking download progress - Add rpcDownloadFromUrl, rpcGetDownloadState, rpcCancelDownload RPCs - Implement streaming download with 32KB buffer and progress tracking - Broadcast download state events to connected clients Frontend changes: - Add DownloadFileView component with URL input and progress display - Add "Download from URL" button in Device Storage view - Auto-extract filename from URL for .iso/.img files - Poll-based progress updates with speed calculation - Support for download cancellation Fixes #727
1 parent d24ce1c commit 3330485

File tree

6 files changed

+632
-3
lines changed

6 files changed

+632
-3
lines changed

jsonrpc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1181,6 +1181,9 @@ var rpcHandlers = map[string]RPCHandler{
11811181
"listStorageFiles": {Func: rpcListStorageFiles},
11821182
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
11831183
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
1184+
"downloadFromUrl": {Func: rpcDownloadFromUrl, Params: []string{"url", "filename"}},
1185+
"getDownloadState": {Func: rpcGetDownloadState},
1186+
"cancelDownload": {Func: rpcCancelDownload},
11841187
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
11851188
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
11861189
"resetConfig": {Func: rpcResetConfig},

ui/localization/messages/en.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,19 @@
568568
"mount_uploaded_has_been_uploaded": "{name} has been uploaded",
569569
"mount_uploading": "Uploading…",
570570
"mount_uploading_with_name": "Uploading {name}",
571+
"mount_download_title": "Download from URL",
572+
"mount_download_description": "Download an image file directly to JetKVM storage from a URL",
573+
"mount_download_url_label": "Image URL",
574+
"mount_download_filename_label": "Save as filename",
575+
"mount_downloading": "Downloading...",
576+
"mount_downloading_with_name": "Downloading {name}",
577+
"mount_download_successful": "Download successful",
578+
"mount_download_has_been_downloaded": "{name} has been downloaded",
579+
"mount_download_error": "Download error: {error}",
580+
"mount_download_cancelled": "Download cancelled",
581+
"mount_button_start_download": "Start Download",
582+
"mount_button_cancel_download": "Cancel Download",
583+
"mount_button_download_from_url": "Download from URL",
571584
"mount_url_description": "Mount files from any public web address",
572585
"mount_url_input_label": "Image URL",
573586
"mount_url_mount": "URL Mount",

ui/src/hooks/stores.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ export interface MountMediaState {
443443
remoteVirtualMediaState: RemoteVirtualMediaState | null;
444444
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void;
445445

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

449449
isMountMediaDialogOpen: boolean;

ui/src/routes/devices.$id.mount.tsx

Lines changed: 289 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
LuRadioReceiver,
66
LuCheck,
77
LuUpload,
8+
LuDownload,
89
} from "react-icons/lu";
910
import { PlusCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
1011
import { TrashIcon } from "@heroicons/react/16/solid";
@@ -186,6 +187,9 @@ export function Dialog({ onClose }: Readonly<{ onClose: () => void }>) {
186187
setIncompleteFileName(incompleteFile || null);
187188
setModalView("upload");
188189
}}
190+
onDownloadClick={() => {
191+
setModalView("download");
192+
}}
189193
/>
190194
)}
191195

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

207+
{modalView === "download" && (
208+
<DownloadFileView
209+
onBack={() => setModalView("device")}
210+
onDownloadComplete={() => {
211+
setModalView("device");
212+
}}
213+
/>
214+
)}
215+
203216
{modalView === "error" && (
204217
<ErrorView
205218
errorMessage={errorMessage}
@@ -508,11 +521,13 @@ function DeviceFileView({
508521
mountInProgress,
509522
onBack,
510523
onNewImageClick,
524+
onDownloadClick,
511525
}: {
512526
onMountStorageFile: (name: string, mode: RemoteVirtualMediaState["mode"]) => void;
513527
mountInProgress: boolean;
514528
onBack: () => void;
515529
onNewImageClick: (incompleteFileName?: string) => void;
530+
onDownloadClick: () => void;
516531
}) {
517532
const [onStorageFiles, setOnStorageFiles] = useState<
518533
{
@@ -799,7 +814,7 @@ function DeviceFileView({
799814

800815
{onStorageFiles.length > 0 && (
801816
<div
802-
className="w-full animate-fadeIn opacity-0"
817+
className="w-full animate-fadeIn space-y-2 opacity-0"
803818
style={{
804819
animationDuration: "0.7s",
805820
animationDelay: "0.25s",
@@ -812,6 +827,13 @@ function DeviceFileView({
812827
text={m.mount_button_upload_new_image()}
813828
onClick={() => onNewImageClick()}
814829
/>
830+
<Button
831+
size="MD"
832+
theme="light"
833+
fullWidth
834+
text={m.mount_button_download_from_url()}
835+
onClick={() => onDownloadClick()}
836+
/>
815837
</div>
816838
)}
817839
</div>
@@ -1247,6 +1269,272 @@ function UploadFileView({
12471269
);
12481270
}
12491271

1272+
function DownloadFileView({
1273+
onBack,
1274+
onDownloadComplete,
1275+
}: {
1276+
onBack: () => void;
1277+
onDownloadComplete: () => void;
1278+
}) {
1279+
const [downloadViewState, setDownloadViewState] = useState<"idle" | "downloading" | "success" | "error">("idle");
1280+
const [url, setUrl] = useState<string>("");
1281+
const [filename, setFilename] = useState<string>("");
1282+
const [progress, setProgress] = useState(0);
1283+
const [downloadSpeed, setDownloadSpeed] = useState<number | null>(null);
1284+
const [downloadError, setDownloadError] = useState<string | null>(null);
1285+
const [totalBytes, setTotalBytes] = useState<number>(0);
1286+
1287+
const { send } = useJsonRpc();
1288+
1289+
// Track download speed
1290+
const lastBytesRef = useRef(0);
1291+
const lastTimeRef = useRef(0);
1292+
const speedHistoryRef = useRef<number[]>([]);
1293+
1294+
// Compute URL validity
1295+
const isUrlValid = useMemo(() => {
1296+
try {
1297+
const urlObj = new URL(url);
1298+
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
1299+
} catch {
1300+
return false;
1301+
}
1302+
}, [url]);
1303+
1304+
// Extract filename from URL
1305+
const suggestedFilename = useMemo(() => {
1306+
if (!url) return '';
1307+
try {
1308+
const urlObj = new URL(url);
1309+
const pathParts = urlObj.pathname.split('/');
1310+
const lastPart = pathParts[pathParts.length - 1];
1311+
if (lastPart && (lastPart.endsWith('.iso') || lastPart.endsWith('.img'))) {
1312+
return lastPart;
1313+
}
1314+
} catch {
1315+
// Invalid URL, ignore
1316+
}
1317+
return '';
1318+
}, [url]);
1319+
1320+
// Update filename when URL changes and user hasn't manually edited it
1321+
const [userEditedFilename, setUserEditedFilename] = useState(false);
1322+
const effectiveFilename = userEditedFilename ? filename : (suggestedFilename || filename);
1323+
1324+
// Listen for download state events via polling
1325+
useEffect(() => {
1326+
if (downloadViewState !== "downloading") return;
1327+
1328+
const pollInterval = setInterval(() => {
1329+
send("getDownloadState", {}, (resp: JsonRpcResponse) => {
1330+
if ("error" in resp) return;
1331+
1332+
const state = resp.result as {
1333+
downloading: boolean;
1334+
filename: string;
1335+
totalBytes: number;
1336+
doneBytes: number;
1337+
progress: number;
1338+
error?: string;
1339+
};
1340+
1341+
if (state.error) {
1342+
setDownloadError(state.error);
1343+
setDownloadViewState("error");
1344+
return;
1345+
}
1346+
1347+
setTotalBytes(state.totalBytes);
1348+
setProgress(state.progress * 100);
1349+
1350+
// Calculate speed
1351+
const now = Date.now();
1352+
const timeDiff = (now - lastTimeRef.current) / 1000;
1353+
const bytesDiff = state.doneBytes - lastBytesRef.current;
1354+
1355+
if (timeDiff > 0 && bytesDiff > 0) {
1356+
const instantSpeed = bytesDiff / timeDiff;
1357+
speedHistoryRef.current.push(instantSpeed);
1358+
if (speedHistoryRef.current.length > 5) {
1359+
speedHistoryRef.current.shift();
1360+
}
1361+
const avgSpeed = speedHistoryRef.current.reduce((a, b) => a + b, 0) / speedHistoryRef.current.length;
1362+
setDownloadSpeed(avgSpeed);
1363+
}
1364+
1365+
lastBytesRef.current = state.doneBytes;
1366+
lastTimeRef.current = now;
1367+
1368+
if (!state.downloading && state.progress >= 1) {
1369+
setDownloadViewState("success");
1370+
}
1371+
});
1372+
}, 500);
1373+
1374+
return () => clearInterval(pollInterval);
1375+
}, [downloadViewState, send]);
1376+
1377+
function handleStartDownload() {
1378+
if (!url || !effectiveFilename) return;
1379+
1380+
setDownloadViewState("downloading");
1381+
setDownloadError(null);
1382+
setProgress(0);
1383+
setDownloadSpeed(null);
1384+
lastBytesRef.current = 0;
1385+
lastTimeRef.current = Date.now();
1386+
speedHistoryRef.current = [];
1387+
1388+
send("downloadFromUrl", { url, filename: effectiveFilename }, (resp: JsonRpcResponse) => {
1389+
if ("error" in resp) {
1390+
setDownloadError(resp.error.message);
1391+
setDownloadViewState("error");
1392+
}
1393+
});
1394+
}
1395+
1396+
function handleCancelDownload() {
1397+
send("cancelDownload", {}, (resp: JsonRpcResponse) => {
1398+
if ("error" in resp) {
1399+
console.error("Failed to cancel download:", resp.error);
1400+
}
1401+
setDownloadViewState("idle");
1402+
});
1403+
}
1404+
1405+
return (
1406+
<div className="w-full space-y-4">
1407+
<ViewHeader
1408+
title={m.mount_download_title()}
1409+
description={m.mount_download_description()}
1410+
/>
1411+
1412+
{downloadViewState === "idle" && (
1413+
<>
1414+
<div className="animate-fadeIn space-y-4 opacity-0" style={{ animationDuration: "0.7s" }}>
1415+
<InputFieldWithLabel
1416+
placeholder="https://example.com/image.iso"
1417+
type="url"
1418+
label={m.mount_download_url_label()}
1419+
value={url}
1420+
onChange={e => setUrl(e.target.value)}
1421+
/>
1422+
<InputFieldWithLabel
1423+
placeholder="image.iso"
1424+
type="text"
1425+
label={m.mount_download_filename_label()}
1426+
value={effectiveFilename}
1427+
onChange={e => {
1428+
setFilename(e.target.value);
1429+
setUserEditedFilename(true);
1430+
}}
1431+
/>
1432+
</div>
1433+
<div className="flex w-full justify-end space-x-2">
1434+
<Button size="MD" theme="blank" text={m.back()} onClick={onBack} />
1435+
<Button
1436+
size="MD"
1437+
theme="primary"
1438+
text={m.mount_button_start_download()}
1439+
onClick={handleStartDownload}
1440+
disabled={!isUrlValid || !effectiveFilename}
1441+
/>
1442+
</div>
1443+
</>
1444+
)}
1445+
1446+
{downloadViewState === "downloading" && (
1447+
<div className="animate-fadeIn space-y-4 opacity-0" style={{ animationDuration: "0.7s" }}>
1448+
<Card>
1449+
<div className="p-4 space-y-3">
1450+
<div className="flex items-center gap-2">
1451+
<LuDownload className="h-5 w-5 text-blue-500 animate-pulse" />
1452+
<h3 className="text-lg font-semibold dark:text-white">
1453+
{m.mount_downloading_with_name({ name: formatters.truncateMiddle(effectiveFilename, 30) })}
1454+
</h3>
1455+
</div>
1456+
<p className="text-sm text-slate-600 dark:text-slate-400">
1457+
{formatters.bytes(totalBytes)}
1458+
</p>
1459+
<div className="h-3.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-700">
1460+
<div
1461+
className="h-3.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
1462+
style={{ width: `${progress}%` }}
1463+
/>
1464+
</div>
1465+
<div className="flex justify-between text-xs text-slate-600 dark:text-slate-400">
1466+
<span>{m.mount_downloading()}</span>
1467+
<span>
1468+
{downloadSpeed !== null
1469+
? `${formatters.bytes(downloadSpeed)}/s`
1470+
: m.mount_calculating()}
1471+
</span>
1472+
</div>
1473+
</div>
1474+
</Card>
1475+
<div className="flex w-full justify-end">
1476+
<Button
1477+
size="MD"
1478+
theme="light"
1479+
text={m.mount_button_cancel_download()}
1480+
onClick={handleCancelDownload}
1481+
/>
1482+
</div>
1483+
</div>
1484+
)}
1485+
1486+
{downloadViewState === "success" && (
1487+
<div className="animate-fadeIn space-y-4 opacity-0" style={{ animationDuration: "0.7s" }}>
1488+
<Card>
1489+
<div className="p-4 text-center space-y-2">
1490+
<LuCheck className="h-8 w-8 text-green-500 mx-auto" />
1491+
<h3 className="text-lg font-semibold dark:text-white">
1492+
{m.mount_download_successful()}
1493+
</h3>
1494+
<p className="text-sm text-slate-600 dark:text-slate-400">
1495+
{m.mount_download_has_been_downloaded({ name: effectiveFilename })}
1496+
</p>
1497+
</div>
1498+
</Card>
1499+
<div className="flex w-full justify-end">
1500+
<Button
1501+
size="MD"
1502+
theme="primary"
1503+
text={m.mount_button_back_to_overview()}
1504+
onClick={onDownloadComplete}
1505+
/>
1506+
</div>
1507+
</div>
1508+
)}
1509+
1510+
{downloadViewState === "error" && (
1511+
<div className="animate-fadeIn space-y-4 opacity-0" style={{ animationDuration: "0.7s" }}>
1512+
<Card className="border border-red-200 bg-red-50 dark:bg-red-900/20">
1513+
<div className="p-4 text-center space-y-2">
1514+
<ExclamationTriangleIcon className="h-8 w-8 text-red-500 mx-auto" />
1515+
<h3 className="text-lg font-semibold text-red-800 dark:text-red-400">
1516+
{m.mount_error_title()}
1517+
</h3>
1518+
<p className="text-sm text-red-600 dark:text-red-400">
1519+
{downloadError}
1520+
</p>
1521+
</div>
1522+
</Card>
1523+
<div className="flex w-full justify-end space-x-2">
1524+
<Button size="MD" theme="light" text={m.back()} onClick={onBack} />
1525+
<Button
1526+
size="MD"
1527+
theme="primary"
1528+
text={m.mount_button_back_to_overview()}
1529+
onClick={() => setDownloadViewState("idle")}
1530+
/>
1531+
</div>
1532+
</div>
1533+
)}
1534+
</div>
1535+
);
1536+
}
1537+
12501538
function ErrorView({
12511539
errorMessage,
12521540
onClose,

0 commit comments

Comments
 (0)