mirror of https://github.com/jetkvm/kvm.git
389 lines
14 KiB
TypeScript
389 lines
14 KiB
TypeScript
import { MdOutlineContentPasteGo } from "react-icons/md";
|
|
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
|
|
import { FaKeyboard } from "react-icons/fa6";
|
|
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
|
import { Fragment, useCallback, useRef, useEffect } from "react";
|
|
import { CommandLineIcon, UserGroupIcon } from "@heroicons/react/20/solid";
|
|
|
|
import { Button } from "@components/Button";
|
|
import {
|
|
useHidStore,
|
|
useMountMediaStore,
|
|
useSettingsStore,
|
|
useUiStore,
|
|
useRTCStore } from "@/hooks/stores";
|
|
import Container from "@components/Container";
|
|
import { cx } from "@/cva.config";
|
|
import PasteModal from "@/components/popovers/PasteModal";
|
|
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
|
import MountPopopover from "@/components/popovers/MountPopover";
|
|
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
|
|
import SessionPopover from "@/components/popovers/SessionPopover";
|
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
|
import { useSessionStore } from "@/stores/sessionStore";
|
|
import { usePermissions } from "@/hooks/usePermissions";
|
|
import { Permission } from "@/types/permissions";
|
|
|
|
export default function Actionbar({
|
|
requestFullscreen,
|
|
}: {
|
|
requestFullscreen: () => Promise<void>;
|
|
}) {
|
|
const { navigateTo } = useDeviceUiNavigation();
|
|
const { isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore();
|
|
const { setDisableVideoFocusTrap, terminalType, setTerminalType, toggleSidebarView } = useUiStore();
|
|
|
|
const remoteVirtualMediaState = useMountMediaStore(
|
|
state => state.remoteVirtualMediaState,
|
|
);
|
|
const { developerMode } = useSettingsStore();
|
|
const { currentMode, sessions, setSessions } = useSessionStore();
|
|
const { rpcDataChannel } = useRTCStore();
|
|
const { hasPermission } = usePermissions();
|
|
|
|
// Fetch sessions on mount if we have an RPC channel
|
|
useEffect(() => {
|
|
if (rpcDataChannel?.readyState === "open" && sessions.length === 0) {
|
|
const id = Math.random().toString(36).substring(2);
|
|
const message = JSON.stringify({ jsonrpc: "2.0", method: "getSessions", params: {}, id });
|
|
|
|
const handler = (event: MessageEvent) => {
|
|
try {
|
|
const response = JSON.parse(event.data);
|
|
if (response.id === id && response.result) {
|
|
setSessions(response.result);
|
|
}
|
|
} catch {
|
|
// Ignore parse errors for non-JSON messages
|
|
}
|
|
};
|
|
|
|
rpcDataChannel.addEventListener("message", handler);
|
|
rpcDataChannel.send(message);
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
rpcDataChannel.removeEventListener("message", handler);
|
|
}, 5000);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
rpcDataChannel.removeEventListener("message", handler);
|
|
};
|
|
}
|
|
}, [rpcDataChannel, sessions.length, setSessions]);
|
|
|
|
// This is the only way to get a reliable state change for the popover
|
|
// at time of writing this there is no mount, or unmount event for the popover
|
|
const isOpen = useRef<boolean>(false);
|
|
const checkIfStateChanged = useCallback(
|
|
(open: boolean) => {
|
|
if (open !== isOpen.current) {
|
|
isOpen.current = open;
|
|
if (!open) {
|
|
setTimeout(() => {
|
|
setDisableVideoFocusTrap(false);
|
|
}, 0);
|
|
}
|
|
}
|
|
},
|
|
[setDisableVideoFocusTrap],
|
|
);
|
|
|
|
return (
|
|
<Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
|
|
<div
|
|
onKeyUp={e => e.stopPropagation()}
|
|
onKeyDown={e => e.stopPropagation()}
|
|
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-1.5"
|
|
>
|
|
<div className="relative flex flex-wrap items-center gap-x-2 gap-y-2">
|
|
{developerMode && hasPermission(Permission.TERMINAL_ACCESS) && (
|
|
<Button
|
|
size="XS"
|
|
theme="light"
|
|
text="Web Terminal"
|
|
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
|
|
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
|
|
/>
|
|
)}
|
|
{hasPermission(Permission.PASTE) && (
|
|
<Popover>
|
|
<PopoverButton as={Fragment}>
|
|
<Button
|
|
size="XS"
|
|
theme="light"
|
|
text="Paste text"
|
|
LeadingIcon={MdOutlineContentPasteGo}
|
|
onClick={() => {
|
|
setDisableVideoFocusTrap(true);
|
|
}}
|
|
/>
|
|
</PopoverButton>
|
|
<PopoverPanel
|
|
anchor="bottom start"
|
|
transition
|
|
className={cx(
|
|
"z-10 flex w-[420px] origin-top flex-col overflow-visible!",
|
|
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
|
|
)}
|
|
>
|
|
{({ open }) => {
|
|
checkIfStateChanged(open);
|
|
return (
|
|
<div className="mx-auto w-full max-w-xl">
|
|
<PasteModal />
|
|
</div>
|
|
);
|
|
}}
|
|
</PopoverPanel>
|
|
</Popover>
|
|
)}
|
|
{hasPermission(Permission.MOUNT_MEDIA) && (
|
|
<div className="relative">
|
|
<Popover>
|
|
<PopoverButton as={Fragment}>
|
|
<Button
|
|
size="XS"
|
|
theme="light"
|
|
text="Virtual Media"
|
|
LeadingIcon={({ className }) => {
|
|
return (
|
|
<>
|
|
<LuHardDrive className={className} />
|
|
<div
|
|
className={cx(className, "h-2 w-2 rounded-full bg-blue-700", {
|
|
hidden: !remoteVirtualMediaState,
|
|
})}
|
|
/>
|
|
</>
|
|
);
|
|
}}
|
|
onClick={() => {
|
|
setDisableVideoFocusTrap(true);
|
|
}}
|
|
/>
|
|
</PopoverButton>
|
|
<PopoverPanel
|
|
anchor="bottom start"
|
|
transition
|
|
className={cx(
|
|
"z-10 flex w-[420px] origin-top flex-col overflow-visible!",
|
|
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
|
|
)}
|
|
>
|
|
{({ open }) => {
|
|
checkIfStateChanged(open);
|
|
return (
|
|
<div className="mx-auto w-full max-w-xl">
|
|
<MountPopopover />
|
|
</div>
|
|
);
|
|
}}
|
|
</PopoverPanel>
|
|
</Popover>
|
|
</div>
|
|
)}
|
|
{hasPermission(Permission.EXTENSION_WOL) && (
|
|
<div>
|
|
<Popover>
|
|
<PopoverButton as={Fragment}>
|
|
<Button
|
|
size="XS"
|
|
theme="light"
|
|
text="Wake on LAN"
|
|
onClick={() => {
|
|
setDisableVideoFocusTrap(true);
|
|
}}
|
|
LeadingIcon={({ className }) => (
|
|
<svg
|
|
className={className}
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3z" />
|
|
<path d="M6 8v1" />
|
|
<path d="M10 8v1" />
|
|
<path d="M14 8v1" />
|
|
<path d="M18 8v1" />
|
|
</svg>
|
|
)}
|
|
/>
|
|
</PopoverButton>
|
|
<PopoverPanel
|
|
anchor="bottom start"
|
|
transition
|
|
style={{
|
|
transitionProperty: "opacity",
|
|
}}
|
|
className={cx(
|
|
"z-10 flex w-[420px] origin-top flex-col overflow-visible!",
|
|
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
|
|
)}
|
|
>
|
|
{({ open }) => {
|
|
checkIfStateChanged(open);
|
|
return (
|
|
<div className="mx-auto w-full max-w-xl">
|
|
<WakeOnLanModal />
|
|
</div>
|
|
);
|
|
}}
|
|
</PopoverPanel>
|
|
</Popover>
|
|
</div>
|
|
)}
|
|
{hasPermission(Permission.KEYBOARD_INPUT) && (
|
|
<div className="hidden lg:block">
|
|
<Button
|
|
size="XS"
|
|
theme="light"
|
|
text="Virtual Keyboard"
|
|
LeadingIcon={FaKeyboard}
|
|
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
|
|
{/* Session Control */}
|
|
<div className="relative">
|
|
<Popover>
|
|
<PopoverButton as={Fragment}>
|
|
<Button
|
|
size="XS"
|
|
theme="light"
|
|
text={sessions.length > 0 ? `Sessions (${sessions.length})` : "Sessions"}
|
|
LeadingIcon={({ className }) => {
|
|
const modeColor = currentMode === "primary" ? "text-green-500" :
|
|
currentMode === "observer" ? "text-blue-500" :
|
|
currentMode === "queued" ? "text-yellow-500" :
|
|
"text-slate-500";
|
|
return <UserGroupIcon className={cx(className, modeColor)} />;
|
|
}}
|
|
onClick={() => {
|
|
setDisableVideoFocusTrap(true);
|
|
}}
|
|
/>
|
|
</PopoverButton>
|
|
|
|
{/* Mode indicator dot */}
|
|
{currentMode && (
|
|
<div className="absolute -top-1 -right-1 pointer-events-none">
|
|
<div className={cx(
|
|
"h-2 w-2 rounded-full",
|
|
currentMode === "primary" && "bg-green-500",
|
|
currentMode === "observer" && "bg-blue-500",
|
|
currentMode === "queued" && "bg-yellow-500 animate-pulse"
|
|
)} />
|
|
</div>
|
|
)}
|
|
<PopoverPanel
|
|
anchor="bottom end"
|
|
transition
|
|
className={cx(
|
|
"z-10 flex w-[380px] origin-top flex-col overflow-visible!",
|
|
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
|
|
)}
|
|
>
|
|
{({ open }) => {
|
|
checkIfStateChanged(open);
|
|
return <SessionPopover />;
|
|
}}
|
|
</PopoverPanel>
|
|
</Popover>
|
|
</div>
|
|
|
|
{hasPermission(Permission.EXTENSION_MANAGE) && (
|
|
<Popover>
|
|
<PopoverButton as={Fragment}>
|
|
<Button
|
|
size="XS"
|
|
theme="light"
|
|
text="Extension"
|
|
LeadingIcon={LuCable}
|
|
onClick={() => {
|
|
setDisableVideoFocusTrap(true);
|
|
}}
|
|
/>
|
|
</PopoverButton>
|
|
<PopoverPanel
|
|
anchor="bottom start"
|
|
transition
|
|
className={cx(
|
|
"z-10 flex w-[420px] flex-col overflow-visible!",
|
|
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
|
|
)}
|
|
>
|
|
{({ open }) => {
|
|
checkIfStateChanged(open);
|
|
return <ExtensionPopover />;
|
|
}}
|
|
</PopoverPanel>
|
|
</Popover>
|
|
)}
|
|
|
|
{hasPermission(Permission.KEYBOARD_INPUT) && (
|
|
<div className="block lg:hidden">
|
|
<Button
|
|
size="XS"
|
|
theme="light"
|
|
text="Virtual Keyboard"
|
|
LeadingIcon={FaKeyboard}
|
|
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="hidden md:block">
|
|
<Button
|
|
size="XS"
|
|
theme="light"
|
|
text="Connection Stats"
|
|
LeadingIcon={({ className }) => (
|
|
<LuSignal
|
|
className={cx(className, "mb-0.5 text-green-500")}
|
|
strokeWidth={4}
|
|
/>
|
|
)}
|
|
onClick={() => {
|
|
toggleSidebarView("connection-stats");
|
|
}}
|
|
/>
|
|
</div>
|
|
{/* Only show Settings for sessions with settings access */}
|
|
{hasPermission(Permission.SETTINGS_ACCESS) && (
|
|
<div>
|
|
<Button
|
|
size="XS"
|
|
theme="light"
|
|
text="Settings"
|
|
LeadingIcon={LuSettings}
|
|
onClick={() => {
|
|
setDisableVideoFocusTrap(true);
|
|
navigateTo("/settings")
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="hidden items-center gap-x-2 lg:flex">
|
|
<div className="h-4 w-px bg-slate-300 dark:bg-slate-600" />
|
|
<Button
|
|
size="XS"
|
|
theme="light"
|
|
text="Fullscreen"
|
|
LeadingIcon={LuMaximize}
|
|
onClick={() => requestFullscreen()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Container>
|
|
);
|
|
}
|