mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-06-02 19:32:58 +02:00
Release 202412292127
This commit is contained in:
306
ui/src/components/popovers/MountPopover.tsx
Normal file
306
ui/src/components/popovers/MountPopover.tsx
Normal 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;
|
||||
164
ui/src/components/popovers/PasteModal.tsx
Normal file
164
ui/src/components/popovers/PasteModal.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
104
ui/src/components/popovers/WakeOnLan/AddDeviceForm.tsx
Normal file
104
ui/src/components/popovers/WakeOnLan/AddDeviceForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
ui/src/components/popovers/WakeOnLan/DeviceList.tsx
Normal file
85
ui/src/components/popovers/WakeOnLan/DeviceList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
ui/src/components/popovers/WakeOnLan/EmptyStateCard.tsx
Normal file
54
ui/src/components/popovers/WakeOnLan/EmptyStateCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
ui/src/components/popovers/WakeOnLan/Index.tsx
Normal file
137
ui/src/components/popovers/WakeOnLan/Index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user