Skip to content

Commit dc5700e

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 136966a commit dc5700e

File tree

6 files changed

+631
-3
lines changed

6 files changed

+631
-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}
@@ -514,11 +527,13 @@ function DeviceFileView({
514527
mountInProgress,
515528
onBack,
516529
onNewImageClick,
530+
onDownloadClick,
517531
}: {
518532
onMountStorageFile: (name: string, mode: RemoteVirtualMediaState["mode"]) => void;
519533
mountInProgress: boolean;
520534
onBack: () => void;
521535
onNewImageClick: (incompleteFileName?: string) => void;
536+
onDownloadClick: () => void;
522537
}) {
523538
const [onStorageFiles, setOnStorageFiles] = useState<StorageFile[]>([]);
524539

@@ -795,7 +810,7 @@ function DeviceFileView({
795810

796811
{onStorageFiles.length > 0 && (
797812
<div
798-
className="w-full animate-fadeIn opacity-0"
813+
className="w-full animate-fadeIn space-y-2 opacity-0"
799814
style={{
800815
animationDuration: "0.7s",
801816
animationDelay: "0.25s",
@@ -808,6 +823,13 @@ function DeviceFileView({
808823
text={m.mount_button_upload_new_image()}
809824
onClick={() => onNewImageClick()}
810825
/>
826+
<Button
827+
size="MD"
828+
theme="light"
829+
fullWidth
830+
text={m.mount_button_download_from_url()}
831+
onClick={() => onDownloadClick()}
832+
/>
811833
</div>
812834
)}
813835
</div>
@@ -1243,6 +1265,272 @@ function UploadFileView({
12431265
);
12441266
}
12451267

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

0 commit comments

Comments
 (0)