Release 202412292127

This commit is contained in:
Adam Shiervani
2024-10-20 17:24:15 +02:00
commit 20780b65db
171 changed files with 24344 additions and 0 deletions

View File

@@ -0,0 +1,306 @@
import { Button } from "@components/Button";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import Card, { GridCard } from "@components/Card";
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SectionHeader } from "@components/SectionHeader";
import {
LuArrowUpFromLine,
LuCheckCheck,
LuLink,
LuPlus,
LuRadioReceiver,
} from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../../notifications";
import MountMediaModal from "../MountMediaDialog";
import { useClose } from "@headlessui/react";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
const [send] = useJsonRpc();
const {
remoteVirtualMediaState,
isMountMediaDialogOpen,
setModalView,
setIsMountMediaDialogOpen,
setRemoteVirtualMediaState,
} = useMountMediaStore();
const bytesSentPerSecond = useMemo(() => {
if (diskDataChannelStats.size < 2) return null;
const secondLastItem =
Array.from(diskDataChannelStats)[diskDataChannelStats.size - 2];
const lastItem = Array.from(diskDataChannelStats)[diskDataChannelStats.size - 1];
if (!secondLastItem || !lastItem) return 0;
const lastTime = lastItem[0];
const secondLastTime = secondLastItem[0];
const timeDelta = lastTime - secondLastTime;
const lastBytesSent = lastItem[1].bytesSent;
const secondLastBytesSent = secondLastItem[1].bytesSent;
const bytesDelta = lastBytesSent - secondLastBytesSent;
return bytesDelta / timeDelta;
}, [diskDataChannelStats]);
const syncRemoteVirtualMediaState = useCallback(() => {
send("getVirtualMediaState", {}, response => {
if ("error" in response) {
notifications.error(
`Failed to get virtual media state: ${response.error.message}`,
);
} else {
setRemoteVirtualMediaState(response.result as unknown as RemoteVirtualMediaState);
}
});
}, [send, setRemoteVirtualMediaState]);
const handleUnmount = () => {
send("unmountImage", {}, response => {
if ("error" in response) {
notifications.error(`Failed to unmount image: ${response.error.message}`);
} else {
syncRemoteVirtualMediaState();
}
});
};
const renderGridCardContent = () => {
if (!remoteVirtualMediaState) {
return (
<div className="space-y-1">
<div className="inline-block">
<Card>
<div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" />
</div>
</Card>
</div>
<div className="space-y-1">
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No mounted media
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a file to get started
</p>
</div>
</div>
);
}
const { source, filename, size, url, path } = remoteVirtualMediaState;
switch (source) {
case "WebRTC":
return (
<>
<div className="space-y-1">
<div className="flex items-center gap-x-2">
<LuCheckCheck className="h-5 text-green-500" />
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from Browser</h3>
</div>
<Card className="w-auto px-2 py-1">
<div className="w-full text-sm text-black truncate dark:text-white">
{formatters.truncateMiddle(filename, 50)}
</div>
</Card>
</div>
<div className="flex flex-col items-center my-2 gap-y-2">
<div className="w-full text-sm text-slate-900 dark:text-slate-100">
<div className="flex items-center justify-between">
<span>{formatters.bytes(size ?? 0)}</span>
<div className="flex items-center gap-x-1">
<LuArrowUpFromLine className="h-4 text-blue-700 dark:text-blue-500" strokeWidth={2} />
<span>
{bytesSentPerSecond !== null
? `${formatters.bytes(bytesSentPerSecond)}/s`
: "N/A"}
</span>
</div>
</div>
</div>
</div>
</>
);
case "HTTP":
return (
<div className="">
<div className="inline-block mb-0">
<Card>
<div className="p-1">
<LuLink className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
</div>
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from URL</h3>
<p className="text-sm truncate text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(url, 55)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
</div>
);
case "Storage":
return (
<div className="">
<div className="inline-block mb-0">
<Card>
<div className="p-1">
<LuRadioReceiver className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
</div>
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">Mounted from JetKVM Storage</h3>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(path, 50)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
</div>
);
default:
return null;
}
};
const close = useClose();
useEffect(() => {
syncRemoteVirtualMediaState();
}, [syncRemoteVirtualMediaState, isMountMediaDialogOpen]);
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div ref={ref} className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4 ">
<div className="space-y-4">
<SectionHeader
title="Virtual Media"
description="Mount an image to boot from or install an operating system."
/>
{remoteVirtualMediaState?.source === "WebRTC" ? (
<Card>
<div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm">
<ExclamationTriangleIcon className="h-4 text-yellow-500" />
<div className="flex items-center w-full text-black">
<div>Closing this tab will unmount the image</div>
</div>
</div>
</Card>
) : null}
<div
className="space-y-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div className="block select-none">
<div className="group">
<Card>
<div className="w-full px-4 py-8">
<div className="flex flex-col items-center justify-center h-full text-center">
{renderGridCardContent()}
</div>
</div>
</Card>
</div>
</div>
{remoteVirtualMediaState ? (
<div className="flex items-center justify-between text-xs select-none">
<div className="text-white select-none dark:text-slate-300">
<span>Mounted as</span>{" "}
<span className="font-semibold">
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
</span>
</div>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="light"
text="Unmount"
LeadingIcon={({ className }) => (
<svg
className={`${className} h-2.5 w-2.5 shrink-0`}
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_3137_1186)">
<path
d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z"
fill="currentColor"
/>
<path d="M10 7.49976H0V9.22453H10V7.49976Z" fill="currentColor" />
</g>
<defs>
<clipPath id="clip0_3137_1186">
<rect width="10" height="10" fill="white" />
</clipPath>
</defs>
</svg>
)}
onClick={handleUnmount}
/>
</div>
</div>
) : null}
</div>
</div>
</div>
<MountMediaModal
open={isMountMediaDialogOpen}
setOpen={setIsMountMediaDialogOpen}
/>
</div>
{!remoteVirtualMediaState && (
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="primary"
text="Add New Media"
onClick={() => {
setModalView("mode");
setIsMountMediaDialogOpen(true);
}}
LeadingIcon={LuPlus}
/>
</div>
)}
</div>
</GridCard>
);
});
MountPopopover.displayName = "MountSidebarRoute";
export default MountPopopover;

View File

@@ -0,0 +1,164 @@
import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
import { TextAreaWithLabel } from "@components/TextArea";
import { SectionHeader } from "@components/SectionHeader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "../../notifications";
import { useCallback, useEffect, useRef, useState } from "react";
import { LuCornerDownLeft } from "react-icons/lu";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react";
import { chars, keys, modifiers } from "@/keyboardMappings";
const hidKeyboardPayload = (keys: number[], modifier: number) => {
return { keys, modifier };
};
export default function PasteModal() {
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const [send] = useJsonRpc();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [invalidChars, setInvalidChars] = useState<string[]>([]);
const close = useClose();
const onCancelPasteMode = useCallback(() => {
setPasteMode(false);
setDisableVideoFocusTrap(false);
setInvalidChars([]);
}, [setDisableVideoFocusTrap, setPasteMode]);
const onConfirmPaste = useCallback(async () => {
setPasteMode(false);
setDisableVideoFocusTrap(false);
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
const text = TextAreaRef.current.value;
try {
for (const char of text) {
const { key, shift } = chars[char] ?? {};
if (!key) continue;
await new Promise<void>((resolve, reject) => {
send(
"keyboardReport",
hidKeyboardPayload([keys[key]], shift ? modifiers["ShiftLeft"] : 0),
params => {
if ("error" in params) return reject(params.error);
send("keyboardReport", hidKeyboardPayload([], 0), params => {
if ("error" in params) return reject(params.error);
resolve();
});
},
);
});
}
} catch (error) {
notifications.error("Failed to paste text");
}
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]);
useEffect(() => {
if (TextAreaRef.current) {
TextAreaRef.current.focus();
}
}, []);
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4">
<div className="space-y-4">
<SectionHeader
title="Paste text"
description="Paste text from your client to the remote host"
/>
<div
className="space-y-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div>
<div className="w-full" onKeyUp={e => e.stopPropagation()}>
<TextAreaWithLabel
ref={TextAreaRef}
label="Paste from host"
rows={4}
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => {
e.stopPropagation();
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onConfirmPaste();
} else if (e.key === "Escape") {
e.preventDefault();
onCancelPasteMode();
}
}}
onChange={e => {
const value = e.target.value;
const invalidChars = [
...new Set(
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
[...new Intl.Segmenter().segment(value)]
.map(x => x.segment)
.filter(char => !chars[char]),
),
];
setInvalidChars(invalidChars);
}}
/>
{invalidChars.length > 0 && (
<div className="flex items-center mt-2 gap-x-2">
<ExclamationCircleIcon className="w-4 h-4 text-red-500 dark:text-red-400" />
<span className="text-xs text-red-500 dark:text-red-400">
The following characters won&apos;t be pasted:{" "}
{invalidChars.join(", ")}
</span>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
<div
className="flex items-center justify-end opacity-0 animate-fadeIn gap-x-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Cancel"
onClick={() => {
onCancelPasteMode();
close();
}}
/>
<Button
size="SM"
theme="primary"
text="Confirm Paste"
onClick={onConfirmPaste}
LeadingIcon={LuCornerDownLeft}
/>
</div>
</div>
</GridCard>
);
}

View File

@@ -0,0 +1,104 @@
import { InputFieldWithLabel } from "@components/InputField";
import { useState, useRef } from "react";
import { LuPlus } from "react-icons/lu";
import { Button } from "../../Button";
import { LuArrowLeft } from "react-icons/lu";
interface AddDeviceFormProps {
onAddDevice: (name: string, macAddress: string) => void;
setShowAddForm: (show: boolean) => void;
errorMessage: string | null;
setErrorMessage: (errorMessage: string | null) => void;
}
export default function AddDeviceForm({
setShowAddForm,
onAddDevice,
errorMessage,
setErrorMessage,
}: AddDeviceFormProps) {
const [isDeviceNameValid, setIsDeviceNameValid] = useState<boolean>(false);
const [isMacAddressValid, setIsMacAddressValid] = useState<boolean>(false);
const nameInputRef = useRef<HTMLInputElement>(null);
const macInputRef = useRef<HTMLInputElement>(null);
return (
<div className="space-y-4">
<div
className="space-y-4 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.5s",
animationFillMode: "forwards",
}}
>
<InputFieldWithLabel
ref={nameInputRef}
placeholder="Plex Media Server"
label="Device Name"
required
onChange={e => {
setIsDeviceNameValid(e.target.validity.valid);
setErrorMessage(null);
}}
maxLength={30}
/>
<InputFieldWithLabel
ref={macInputRef}
placeholder="00:b0:d0:63:c2:26"
label="MAC Address"
onKeyUp={e => e.stopPropagation()}
required
pattern="^([0-9a-fA-F][0-9a-fA-F]:){5}([0-9a-fA-F][0-9a-fA-F])$"
error={errorMessage}
onChange={e => {
setIsMacAddressValid(e.target.validity.valid);
setErrorMessage(null);
}}
minLength={17}
maxLength={17}
onKeyDown={e => {
if (isMacAddressValid || isDeviceNameValid) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
const deviceName = nameInputRef.current?.value || "";
const macAddress = macInputRef.current?.value || "";
onAddDevice(deviceName, macAddress);
} else if (e.key === "Escape") {
e.preventDefault();
setShowAddForm(false);
}
}
}}
/>
</div>
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="light"
text="Back"
LeadingIcon={LuArrowLeft}
onClick={() => setShowAddForm(false)}
/>
<Button
size="SM"
theme="primary"
text="Save Device"
disabled={!isDeviceNameValid || !isMacAddressValid}
onClick={() => {
const deviceName = nameInputRef.current?.value || "";
const macAddress = macInputRef.current?.value || "";
onAddDevice(deviceName, macAddress);
}}
LeadingIcon={LuPlus}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { Button } from "@components/Button";
import Card from "@components/Card";
import { FieldError } from "@components/InputField";
import { LuPlus, LuSend, LuTrash2 } from "react-icons/lu";
export interface StoredDevice {
name: string;
macAddress: string;
}
interface DeviceListProps {
storedDevices: StoredDevice[];
errorMessage: string | null;
onSendMagicPacket: (macAddress: string) => void;
onDeleteDevice: (index: number) => void;
onCancelWakeOnLanModal: () => void;
setShowAddForm: (show: boolean) => void;
}
export default function DeviceList({
storedDevices,
errorMessage,
onSendMagicPacket,
onDeleteDevice,
onCancelWakeOnLanModal,
setShowAddForm,
}: DeviceListProps) {
return (
<div className="space-y-4">
<Card className="opacity-0 animate-fadeIn">
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{storedDevices.map((device, index) => (
<div key={index} className="flex items-center justify-between p-3 gap-x-2">
<div className="space-y-0.5">
<p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">{device?.name}</p>
<p className="text-sm text-slate-600 dark:text-slate-400">
{device.macAddress?.toLowerCase()}
</p>
</div>
{errorMessage && <FieldError error={errorMessage} />}
<div className="flex items-center space-x-2">
<Button
size="XS"
theme="light"
text="Wake"
LeadingIcon={LuSend}
onClick={() => onSendMagicPacket(device.macAddress)}
/>
<Button
size="XS"
theme="danger"
LeadingIcon={LuTrash2}
onClick={() => onDeleteDevice(index)}
aria-label="Delete device"
/>
</div>
</div>
))}
</div>
</Card>
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Close"
onClick={onCancelWakeOnLanModal}
/>
<Button
size="SM"
theme="primary"
text="Add New Device"
onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import Card from "@components/Card";
import { PlusCircleIcon } from "@heroicons/react/16/solid";
import { LuPlus } from "react-icons/lu";
import { Button } from "../../Button";
export default function EmptyStateCard({
onCancelWakeOnLanModal,
setShowAddForm,
}: {
onCancelWakeOnLanModal: () => void;
setShowAddForm: (show: boolean) => void;
}) {
return (
<div className="space-y-4 select-none">
<Card className="opacity-0 animate-fadeIn">
<div className="flex items-center justify-center py-8 text-center">
<div className="space-y-3">
<div className="space-y-1">
<div className="inline-block">
<Card>
<div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" />
</div>
</Card>
</div>
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No devices added
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a device to start using Wake-on-LAN
</p>
</div>
</div>
</div>
</Card>
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} />
<Button
size="SM"
theme="primary"
text="Add New Device"
onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { GridCard } from "@components/Card";
import { SectionHeader } from "@components/SectionHeader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { useCallback, useEffect, useState } from "react";
import { useClose } from "@headlessui/react";
import EmptyStateCard from "./EmptyStateCard";
import DeviceList, { StoredDevice } from "./DeviceList";
import AddDeviceForm from "./AddDeviceForm";
export default function WakeOnLanModal() {
const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [send] = useJsonRpc();
const close = useClose();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null);
const onCancelWakeOnLanModal = useCallback(() => {
close();
setDisableFocusTrap(false);
}, [close, setDisableFocusTrap]);
const onSendMagicPacket = useCallback(
(macAddress: string) => {
setErrorMessage(null);
if (rpcDataChannel?.readyState !== "open") return;
send("sendWOLMagicPacket", { macAddress }, resp => {
if ("error" in resp) {
const isInvalid = resp.error.data?.includes("invalid MAC address");
if (isInvalid) {
setErrorMessage("Invalid MAC address");
} else {
setErrorMessage("Failed to send Magic Packet");
}
} else {
notifications.success("Magic Packet sent successfully");
setDisableFocusTrap(false);
close();
}
});
},
[close, rpcDataChannel?.readyState, send, setDisableFocusTrap],
);
const syncStoredDevices = useCallback(() => {
send("getWakeOnLanDevices", {}, resp => {
if ("result" in resp) {
setStoredDevices(resp.result as StoredDevice[]);
} else {
console.error("Failed to load Wake-on-LAN devices:", resp.error);
}
});
}, [send, setStoredDevices]);
// Load stored devices from the backend
useEffect(() => {
syncStoredDevices();
}, [syncStoredDevices]);
const onDeleteDevice = useCallback(
(index: number) => {
const updatedDevices = storedDevices.filter((_, i) => i !== index);
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
if ("error" in resp) {
console.error("Failed to update Wake-on-LAN devices:", resp.error);
} else {
syncStoredDevices();
}
});
},
[storedDevices, send, syncStoredDevices],
);
const onAddDevice = useCallback(
(name: string, macAddress: string) => {
if (!name || !macAddress) return;
const updatedDevices = [...storedDevices, { name, macAddress }];
console.log("updatedDevices", updatedDevices);
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
if ("error" in resp) {
console.error("Failed to add Wake-on-LAN device:", resp.error);
setAddDeviceErrorMessage("Failed to add device");
} else {
setShowAddForm(false);
syncStoredDevices();
}
});
},
[send, storedDevices, syncStoredDevices],
);
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div className="grid h-full grid-rows-headerBody">
<div className="space-y-4">
<SectionHeader
title="Wake On LAN"
description="Send a Magic Packet to wake up a remote device."
/>
{showAddForm ? (
<AddDeviceForm
setShowAddForm={setShowAddForm}
errorMessage={addDeviceErrorMessage}
setErrorMessage={setAddDeviceErrorMessage}
onAddDevice={onAddDevice}
/>
) : storedDevices.length === 0 ? (
<EmptyStateCard
onCancelWakeOnLanModal={onCancelWakeOnLanModal}
setShowAddForm={setShowAddForm}
/>
) : (
<DeviceList
storedDevices={storedDevices}
errorMessage={errorMessage}
onSendMagicPacket={onSendMagicPacket}
onDeleteDevice={onDeleteDevice}
onCancelWakeOnLanModal={onCancelWakeOnLanModal}
setShowAddForm={setShowAddForm}
/>
)}
</div>
</div>
</div>
</GridCard>
);
}