feat(extension): ATX/DC/Serial extension support

This commit is contained in:
Aveline
2025-02-17 18:37:47 +01:00
committed by GitHub
parent 1973a65635
commit cd333c4ebc
20 changed files with 1535 additions and 480 deletions

View File

@@ -2,13 +2,12 @@ import { Button } from "@components/Button";
import {
useHidStore,
useMountMediaStore,
useUiStore,
useSettingsStore,
useVideoStore,
useUiStore,
} from "@/hooks/stores";
import { MdOutlineContentPasteGo } from "react-icons/md";
import Container from "@components/Container";
import { LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { cx } from "@/cva.config";
import PasteModal from "@/components/popovers/PasteModal";
import { FaKeyboard } from "react-icons/fa6";
@@ -17,6 +16,7 @@ import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import MountPopopover from "./popovers/MountPopover";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import ExtensionPopover from "./popovers/ExtensionPopover";
export default function Actionbar({
requestFullscreen,
@@ -28,13 +28,12 @@ export default function Actionbar({
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const enableTerminal = useUiStore(state => state.enableTerminal);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const terminalType = useUiStore(state => state.terminalType);
const setTerminalType = useUiStore(state => state.setTerminalType);
const remoteVirtualMediaState = useMountMediaStore(
state => state.remoteVirtualMediaState,
);
const developerMode = useSettingsStore(state => state.developerMode);
const hdmiState = useVideoStore(state => state.hdmiState);
// 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
@@ -55,7 +54,7 @@ export default function Actionbar({
);
return (
<Container className="bg-white border-b border-b-slate-800/20 dark:bg-slate-900 dark:border-b-slate-300/20">
<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()}
@@ -68,7 +67,7 @@ export default function Actionbar({
theme="light"
text="Web Terminal"
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
onClick={() => setEnableTerminal(!enableTerminal)}
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
/>
)}
<Popover>
@@ -94,7 +93,7 @@ export default function Actionbar({
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<div className="mx-auto w-full max-w-xl">
<PasteModal />
</div>
);
@@ -136,7 +135,7 @@ export default function Actionbar({
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<div className="mx-auto w-full max-w-xl">
<MountPopopover />
</div>
);
@@ -188,7 +187,7 @@ export default function Actionbar({
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<div className="mx-auto w-full max-w-xl">
<WakeOnLanModal />
</div>
);
@@ -208,6 +207,33 @@ export default function Actionbar({
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Extension"
LeadingIcon={LuCable}
onClick={() => {
setDisableFocusTrap(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>
<div className="block lg:hidden">
<Button
size="XS"
@@ -243,13 +269,12 @@ export default function Actionbar({
onClick={() => toggleSidebarView("system")}
/>
</div>
<div className="items-center hidden gap-x-2 lg:flex">
<div className="hidden items-center gap-x-2 lg:flex">
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" />
<Button
size="XS"
theme="light"
text="Fullscreen"
disabled={hdmiState !== 'ready'}
LeadingIcon={LuMaximize}
onClick={() => requestFullscreen()}
/>

View File

@@ -156,7 +156,16 @@ function ButtonContent(props: ButtonContentPropsType) {
type ButtonPropsType = Pick<
JSX.IntrinsicElements["button"],
"type" | "disabled" | "onClick" | "name" | "value" | "formNoValidate" | "onMouseLeave"
| "type"
| "disabled"
| "onClick"
| "name"
| "value"
| "formNoValidate"
| "onMouseLeave"
| "onMouseDown"
| "onMouseUp"
| "onMouseLeave"
> &
React.ComponentProps<typeof ButtonContent> & {
fetcher?: FetcherWithComponents<unknown>;
@@ -179,6 +188,9 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
type={type}
disabled={disabled}
onClick={onClick}
onMouseDown={props?.onMouseDown}
onMouseUp={props?.onMouseUp}
onMouseLeave={props?.onMouseLeave}
name={props.name}
value={props.value}
>

View File

@@ -1,32 +1,172 @@
import "react-simple-keyboard/build/css/index.css";
import { useUiStore, useRTCStore } from "@/hooks/stores";
import { XTerm } from "./Xterm";
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
import { Button } from "./Button";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { cx } from "../cva.config";
import { Transition } from "@headlessui/react";
import { cx } from "@/cva.config";
import { useEffect } from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { ClipboardAddon } from "@xterm/addon-clipboard";
function TerminalWrapper() {
const enableTerminal = useUiStore(state => state.enableTerminal);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const terminalChannel = useRTCStore(state => state.terminalChannel);
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
// Terminal theme configuration
const SOLARIZED_THEME = {
background: "#0f172a", // Solarized base03
foreground: "#839496", // Solarized base0
cursor: "#93a1a1", // Solarized base1
cursorAccent: "#002b36", // Solarized base03
black: "#073642", // Solarized base02
red: "#dc322f", // Solarized red
green: "#859900", // Solarized green
yellow: "#b58900", // Solarized yellow
blue: "#268bd2", // Solarized blue
magenta: "#d33682", // Solarized magenta
cyan: "#2aa198", // Solarized cyan
white: "#eee8d5", // Solarized base2
brightBlack: "#002b36", // Solarized base03
brightRed: "#cb4b16", // Solarized orange
brightGreen: "#586e75", // Solarized base01
brightYellow: "#657b83", // Solarized base00
brightBlue: "#839496", // Solarized base0
brightMagenta: "#6c71c4", // Solarized violet
brightCyan: "#93a1a1", // Solarized base1
brightWhite: "#fdf6e3", // Solarized base3
} as const;
const TERMINAL_CONFIG = {
theme: SOLARIZED_THEME,
fontFamily: "'Fira Code', Menlo, Monaco, 'Courier New', monospace",
fontSize: 13,
allowProposedApi: true,
scrollback: 1000,
cursorBlink: true,
smoothScrollDuration: 100,
macOptionIsMeta: true,
macOptionClickForcesSelection: true,
convertEol: true,
linuxMode: false,
// Add these configurations:
cursorStyle: "block",
rendererType: "canvas", // Ensure we're using the canvas renderer
} as const;
function Terminal({
title,
dataChannel,
type,
}: {
title: string;
dataChannel: RTCDataChannel;
type: AvailableTerminalTypes;
}) {
const enableTerminal = useUiStore(state => state.terminalType == type);
const setTerminalType = useUiStore(state => state.setTerminalType);
const setDisableKeyboardFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG });
useEffect(() => {
setTimeout(() => {
setDisableKeyboardFocusTrap(enableTerminal);
}, 500);
return () => {
setDisableKeyboardFocusTrap(false);
};
}, [enableTerminal, instance, ref, setDisableKeyboardFocusTrap, type]);
const readyState = dataChannel.readyState;
useEffect(() => {
if (readyState !== "open") return;
const abortController = new AbortController();
dataChannel.addEventListener(
"message",
e => {
instance?.write(new Uint8Array(e.data));
},
{ signal: abortController.signal },
);
const onDataHandler = instance?.onData(data => {
dataChannel.send(data);
});
// Setup escape key handler
const onKeyHandler = instance?.onKey(e => {
const { domEvent } = e;
if (domEvent.key === "Escape") {
setTerminalType("none");
setDisableKeyboardFocusTrap(false);
domEvent.preventDefault();
}
});
return () => {
abortController.abort();
onDataHandler?.dispose();
onKeyHandler?.dispose();
};
}, [dataChannel, instance, readyState, setDisableKeyboardFocusTrap, setTerminalType]);
useEffect(() => {
if (!instance) return;
// Load the fit addon
const fitAddon = new FitAddon();
instance?.loadAddon(fitAddon);
instance?.loadAddon(new ClipboardAddon());
instance?.loadAddon(new Unicode11Addon());
instance?.loadAddon(new WebLinksAddon());
instance.unicode.activeVersion = "11";
if (isWebGl2Supported) {
const webGl2Addon = new WebglAddon();
webGl2Addon.onContextLoss(() => webGl2Addon.dispose());
instance?.loadAddon(webGl2Addon);
}
const handleResize = () => fitAddon.fit();
// Handle resize event
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [ref, instance, dataChannel]);
return (
<div onKeyDown={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()}>
<Transition show={enableTerminal} appear>
<div
onKeyDown={e => {
e.stopPropagation();
}}
onKeyUp={e => e.stopPropagation()}
>
<div>
<div
className={cx([
// Base styles
"fixed bottom-0 w-full transform transition duration-500 ease-in-out",
"translate-y-[0px]",
"data-[enter]:translate-y-[500px]",
"data-[closed]:translate-y-[500px]",
])}
className={cx(
[
// Base styles
"fixed bottom-0 w-full transform transition duration-500 ease-in-out",
"translate-y-[0px]",
],
{
"pointer-events-none translate-y-[500px] opacity-100 transition duration-300":
!enableTerminal,
"pointer-events-auto translate-y-[0px] opacity-100 transition duration-300":
enableTerminal,
},
)}
>
<div className="h-[500px] w-full bg-[#0f172a]">
<div className="flex items-center justify-center px-2 py-1 bg-white dark:bg-slate-800 border-y border-y-slate-800/30 dark:border-y-slate-300/20">
<div className="flex items-center justify-center border-y border-y-slate-800/30 bg-white px-2 py-1 dark:border-y-slate-300/20 dark:bg-slate-800">
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Web Terminal
{title}
</h2>
<div className="absolute right-2">
<Button
@@ -34,18 +174,19 @@ function TerminalWrapper() {
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setEnableTerminal(false)}
onClick={() => setTerminalType("none")}
/>
</div>
</div>
<div className="h-[calc(100%-36px)] p-3">
<XTerm terminalChannel={terminalChannel} />
<div ref={ref} style={{ height: "100%", width: "100%" }} />
</div>
</div>
</div>
</Transition>
</div>
</div>
);
}
export default TerminalWrapper;
export default Terminal;

View File

@@ -1,201 +0,0 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import { Terminal } from "xterm";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebglAddon } from "@xterm/addon-webgl";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import "xterm/css/xterm.css";
import { useRTCStore, useUiStore } from "../hooks/stores";
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
// Add this debounce function at the top of the file
function debounce(func: (...args: any[]) => void, wait: number) {
let timeout: number | null = null;
return (...args: any[]) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// Terminal theme configuration
const SOLARIZED_THEME = {
background: "#0f172a", // Solarized base03
foreground: "#839496", // Solarized base0
cursor: "#93a1a1", // Solarized base1
cursorAccent: "#002b36", // Solarized base03
black: "#073642", // Solarized base02
red: "#dc322f", // Solarized red
green: "#859900", // Solarized green
yellow: "#b58900", // Solarized yellow
blue: "#268bd2", // Solarized blue
magenta: "#d33682", // Solarized magenta
cyan: "#2aa198", // Solarized cyan
white: "#eee8d5", // Solarized base2
brightBlack: "#002b36", // Solarized base03
brightRed: "#cb4b16", // Solarized orange
brightGreen: "#586e75", // Solarized base01
brightYellow: "#657b83", // Solarized base00
brightBlue: "#839496", // Solarized base0
brightMagenta: "#6c71c4", // Solarized violet
brightCyan: "#93a1a1", // Solarized base1
brightWhite: "#fdf6e3", // Solarized base3
} as const;
const TERMINAL_CONFIG = {
theme: SOLARIZED_THEME,
fontFamily: "'Fira Code', Menlo, Monaco, 'Courier New', monospace",
fontSize: 13,
allowProposedApi: true,
scrollback: 1000,
cursorBlink: true,
smoothScrollDuration: 100,
macOptionIsMeta: true,
macOptionClickForcesSelection: true,
// Add these configurations:
convertEol: true,
linuxMode: false, // Disable Linux mode which might affect line endings
} as const;
interface XTermProps {
terminalChannel: RTCDataChannel | null;
}
export function XTerm({ terminalChannel }: XTermProps) {
const xtermRef = useRef<Terminal | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const terminalElmRef = useRef<HTMLDivElement | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const setDisableKeyboardFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const peerConnection = useRTCStore(state => state.peerConnection);
useEffect(() => {
setDisableKeyboardFocusTrap(true);
return () => {
setDisableKeyboardFocusTrap(false);
};
}, [setDisableKeyboardFocusTrap]);
const initializeTerminalAddons = (term: Terminal) => {
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new ClipboardAddon());
term.loadAddon(new Unicode11Addon());
term.loadAddon(new WebLinksAddon());
term.unicode.activeVersion = "11";
if (isWebGl2Supported) {
const webGl2Addon = new WebglAddon();
webGl2Addon.onContextLoss(() => webGl2Addon.dispose());
term.loadAddon(webGl2Addon);
}
return fitAddon;
};
const setupTerminalChannel = (
term: Terminal,
channel: RTCDataChannel,
abortController: AbortController,
) => {
channel.onopen = () => {
// Handle terminal input
term.onData(data => {
if (channel.readyState === "open") {
channel.send(data);
}
});
// Handle terminal output
channel.addEventListener(
"message",
(event: MessageEvent) => {
term.write(new Uint8Array(event.data));
},
{ signal: abortController.signal },
);
// Send initial terminal size
if (channel.readyState === "open") {
channel.send(JSON.stringify({ rows: term.rows, cols: term.cols }));
}
};
};
useLayoutEffect(() => {
if (!terminalElmRef.current) return;
// Ensure the container has dimensions before initializing
if (!terminalElmRef.current.offsetHeight || !terminalElmRef.current.offsetWidth) {
return;
}
const term = new Terminal(TERMINAL_CONFIG);
const fitAddon = initializeTerminalAddons(term);
const abortController = new AbortController();
// Setup escape key handler
term.onKey(e => {
const { domEvent } = e;
if (domEvent.key === "Escape") {
setEnableTerminal(false);
setDisableKeyboardFocusTrap(false);
domEvent.preventDefault();
}
});
let elm: HTMLDivElement | null = terminalElmRef.current;
// Initialize terminal
setTimeout(() => {
if (elm) {
console.log("opening terminal");
term.open(elm);
fitAddon.fit();
}
}, 800);
xtermRef.current = term;
fitAddonRef.current = fitAddon;
// Setup resize handling
const debouncedResizeHandler = debounce(() => fitAddon.fit(), 100);
const resizeObserver = new ResizeObserver(debouncedResizeHandler);
resizeObserver.observe(terminalElmRef.current);
// Focus terminal after a short delay
setTimeout(() => {
term.focus();
terminalElmRef.current?.focus();
}, 500);
// Setup terminal channel if available
const channel = peerConnection?.createDataChannel("terminal");
if (channel) {
setupTerminalChannel(term, channel, abortController);
}
// Cleanup
return () => {
resizeObserver.disconnect();
abortController.abort();
term.dispose();
elm = null;
xtermRef.current = null;
fitAddonRef.current = null;
};
}, [peerConnection, setDisableKeyboardFocusTrap, setEnableTerminal, terminalChannel]);
return (
<div className="w-full h-full" ref={containerRef}>
<div
className="w-full h-full terminal-container"
ref={terminalElmRef}
style={{ display: "flex", minHeight: "100%" }}
></div>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import { Button } from "@components/Button";
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
import Card from "@components/Card";
import { SectionHeader } from "@components/SectionHeader";
import { useEffect, useState } from "react";
import notifications from "@/notifications";
import { useJsonRpc } from "../../hooks/useJsonRpc";
import LoadingSpinner from "../LoadingSpinner";
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
interface ATXState {
power: boolean;
hdd: boolean;
}
export function ATXPowerControl() {
const [isPowerPressed, setIsPowerPressed] = useState(false);
const [powerPressTimer, setPowerPressTimer] = useState<ReturnType<
typeof setTimeout
> | null>(null);
const [atxState, setAtxState] = useState<ATXState | null>(null);
const [send] = useJsonRpc(function onRequest(resp) {
if (resp.method === "atxState") {
setAtxState(resp.params as ATXState);
}
});
// Request initial state
useEffect(() => {
send("getATXState", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get ATX state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setAtxState(resp.result as ATXState);
});
}, [send]);
const handlePowerPress = (pressed: boolean) => {
// Prevent phantom releases
if (!pressed && !isPowerPressed) return;
setIsPowerPressed(pressed);
// Handle button press
if (pressed) {
// Start long press timer
const timer = setTimeout(() => {
// Send long press action
console.log("Sending long press ATX power action");
send("setATXPowerAction", { action: "power-long" }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
}
setIsPowerPressed(false);
});
}, LONG_PRESS_DURATION);
setPowerPressTimer(timer);
}
// Handle button release
else {
// If timer exists, was a short press
if (powerPressTimer) {
clearTimeout(powerPressTimer);
setPowerPressTimer(null);
// Send short press action
console.log("Sending short press ATX power action");
send("setATXPowerAction", { action: "power-short" }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
}
});
}
}
};
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (powerPressTimer) {
clearTimeout(powerPressTimer);
}
};
}, [powerPressTimer]);
return (
<div className="space-y-4">
<SectionHeader
title="ATX Power Control"
description="Control your ATX power settings"
/>
{atxState === null ? (
<Card className="flex h-[120px] items-center justify-center p-3">
<LoadingSpinner className="w-6 h-6 text-blue-500 dark:text-blue-400" />
</Card>
) : (
<Card className="h-[120px] animate-fadeIn opacity-0">
<div className="p-3 space-y-4">
{/* Control Buttons */}
<div className="flex items-center space-x-2">
<Button
size="SM"
theme="light"
LeadingIcon={LuPower}
text="Power"
onMouseDown={() => handlePowerPress(true)}
onMouseUp={() => handlePowerPress(false)}
onMouseLeave={() => handlePowerPress(false)}
className={isPowerPressed ? "opacity-75" : ""}
/>
<Button
size="SM"
theme="light"
LeadingIcon={LuRotateCcw}
text="Reset"
onClick={() => {
send("setATXPowerAction", { action: "reset" }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
return;
}
});
}}
/>
</div>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
{/* Status Indicators */}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
<LuPower
strokeWidth={3}
className={`mr-1 inline ${
atxState?.power ? "text-green-600" : "text-slate-300"
}`}
/>
Power LED
</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
<LuHardDrive
strokeWidth={3}
className={`mr-1 inline ${
atxState?.hdd ? "text-blue-400" : "text-slate-300"
}`}
/>
HDD LED
</span>
</div>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { Button } from "@components/Button";
import { LuPower } from "react-icons/lu";
import Card from "@components/Card";
import { SectionHeader } from "@components/SectionHeader";
import FieldLabel from "../FieldLabel";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useCallback, useEffect, useState } from "react";
import notifications from "@/notifications";
import LoadingSpinner from "../LoadingSpinner";
interface DCPowerState {
isOn: boolean;
voltage: number;
current: number;
power: number;
}
export function DCPowerControl() {
const [send] = useJsonRpc();
const [powerState, setPowerState] = useState<DCPowerState | null>(null);
const getDCPowerState = useCallback(() => {
send("getDCPowerState", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get DC power state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setPowerState(resp.result as DCPowerState);
});
}, [send]);
const handlePowerToggle = (enabled: boolean) => {
send("setDCPowerState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`,
);
return;
}
getDCPowerState(); // Refresh state after change
});
};
useEffect(() => {
getDCPowerState();
// Set up polling interval to update status
const interval = setInterval(getDCPowerState, 1000);
return () => clearInterval(interval);
}, [getDCPowerState]);
return (
<div className="space-y-4">
<SectionHeader
title="DC Power Control"
description="Control your DC power settings"
/>
{powerState === null ? (
<Card className="flex h-[160px] justify-center p-3">
<LoadingSpinner className="w-6 h-6 text-blue-500 dark:text-blue-400" />
</Card>
) : (
<Card className="h-[160px] animate-fadeIn opacity-0">
<div className="p-3 space-y-4">
{/* Power Controls */}
<div className="flex items-center space-x-2">
<Button
size="SM"
theme="light"
LeadingIcon={LuPower}
text="Power On"
onClick={() => handlePowerToggle(true)}
disabled={powerState.isOn}
/>
<Button
size="SM"
theme="light"
LeadingIcon={LuPower}
text="Power Off"
disabled={!powerState.isOn}
onClick={() => handlePowerToggle(false)}
/>
</div>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
{/* Status Display */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<FieldLabel label="Voltage" />
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{powerState.voltage.toFixed(1)}V
</p>
</div>
<div className="space-y-1">
<FieldLabel label="Current" />
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{powerState.current.toFixed(1)}A
</p>
</div>
<div className="space-y-1">
<FieldLabel label="Power" />
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{powerState.power.toFixed(1)}W
</p>
</div>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { Button } from "@components/Button";
import { LuTerminal } from "react-icons/lu";
import Card from "@components/Card";
import { SectionHeader } from "@components/SectionHeader";
import { SelectMenuBasic } from "../SelectMenuBasic";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useEffect, useState } from "react";
import notifications from "@/notifications";
import { useUiStore } from "@/hooks/stores";
interface SerialSettings {
baudRate: string;
dataBits: string;
stopBits: string;
parity: string;
}
export function SerialConsole() {
const [send] = useJsonRpc();
const [settings, setSettings] = useState<SerialSettings>({
baudRate: "9600",
dataBits: "8",
stopBits: "1",
parity: "none",
});
useEffect(() => {
send("getSerialSettings", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get serial settings: ${resp.error.data || "Unknown error"}`,
);
return;
}
setSettings(resp.result as SerialSettings);
});
}, [send]);
const handleSettingChange = (setting: keyof SerialSettings, value: string) => {
const newSettings = { ...settings, [setting]: value };
send("setSerialSettings", { settings: newSettings }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update serial settings: ${resp.error.data || "Unknown error"}`,
);
return;
}
setSettings(newSettings);
});
};
const setTerminalType = useUiStore(state => state.setTerminalType);
return (
<div className="space-y-4">
<SectionHeader
title="Serial Console"
description="Configure your serial console settings"
/>
<Card className="animate-fadeIn opacity-0">
<div className="space-y-4 p-3">
{/* Open Console Button */}
<div className="flex items-center">
<Button
size="SM"
theme="primary"
LeadingIcon={LuTerminal}
text="Open Console"
onClick={() => {
setTerminalType("serial");
console.log("Opening serial console with settings: ", settings);
}}
/>
</div>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
{/* Settings */}
<div className="grid grid-cols-2 gap-4">
<SelectMenuBasic
label="Baud Rate"
options={[
{ label: "1200", value: "1200" },
{ label: "2400", value: "2400" },
{ label: "4800", value: "4800" },
{ label: "9600", value: "9600" },
{ label: "19200", value: "19200" },
{ label: "38400", value: "38400" },
{ label: "57600", value: "57600" },
{ label: "115200", value: "115200" },
]}
value={settings.baudRate}
onChange={e => handleSettingChange("baudRate", e.target.value)}
/>
<SelectMenuBasic
label="Data Bits"
options={[
{ label: "8", value: "8" },
{ label: "7", value: "7" },
]}
value={settings.dataBits}
onChange={e => handleSettingChange("dataBits", e.target.value)}
/>
<SelectMenuBasic
label="Stop Bits"
options={[
{ label: "1", value: "1" },
{ label: "1.5", value: "1.5" },
{ label: "2", value: "2" },
]}
value={settings.stopBits}
onChange={e => handleSettingChange("stopBits", e.target.value)}
/>
<SelectMenuBasic
label="Parity"
options={[
{ label: "None", value: "none" },
{ label: "Even", value: "even" },
{ label: "Odd", value: "odd" },
]}
value={settings.parity}
onChange={e => handleSettingChange("parity", e.target.value)}
/>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { useEffect, useState } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import Card, { GridCard } from "@components/Card";
import { SectionHeader } from "@components/SectionHeader";
import { Button } from "../Button";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
import { DCPowerControl } from "@components/extensions/DCPowerControl";
import { SerialConsole } from "@components/extensions/SerialConsole";
import notifications from "../../notifications";
interface Extension {
id: string;
name: string;
description: string;
icon: any;
}
const AVAILABLE_EXTENSIONS: Extension[] = [
{
id: "atx-power",
name: "ATX Power Control",
description: "Control your ATX Power extension",
icon: LuPower,
},
{
id: "dc-power",
name: "DC Power Control",
description: "Control your DC Power extension",
icon: LuPlugZap,
},
{
id: "serial-console",
name: "Serial Console",
description: "Access your serial console extension",
icon: LuTerminal,
},
];
export default function ExtensionPopover() {
const [send] = useJsonRpc();
const [activeExtension, setActiveExtension] = useState<Extension | null>(null);
// Load active extension on component mount
useEffect(() => {
send("getActiveExtension", {}, resp => {
if ("error" in resp) return;
const extensionId = resp.result as string;
if (extensionId) {
const extension = AVAILABLE_EXTENSIONS.find(ext => ext.id === extensionId);
if (extension) {
setActiveExtension(extension);
}
}
});
}, [send]);
const handleSetActiveExtension = (extension: Extension | null) => {
send("setActiveExtension", { extensionId: extension?.id || "" }, resp => {
if ("error" in resp) {
notifications.error(`Failed to set active extension: ${resp.error.data || "Unknown error"}`);
return;
}
setActiveExtension(extension);
});
};
const renderActiveExtension = () => {
switch (activeExtension?.id) {
case "atx-power":
return <ATXPowerControl />;
case "dc-power":
return <DCPowerControl />;
case "serial-console":
return <SerialConsole />;
default:
return null;
}
};
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div className="grid h-full grid-rows-headerBody">
<div className="space-y-4">
{activeExtension ? (
// Extension Control View
<div className="space-y-4">
{renderActiveExtension()}
<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="Unload Extension"
onClick={() => handleSetActiveExtension(null)}
/>
</div>
</div>
) : (
// Extensions List View
<div className="space-y-4">
<SectionHeader
title="Extensions"
description="Load and manage your extensions"
/>
<Card className="opacity-0 animate-fadeIn">
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{AVAILABLE_EXTENSIONS.map(extension => (
<div
key={extension.id}
className="flex items-center justify-between p-3"
>
<div className="space-y-0.5">
<p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">
{extension.name}
</p>
<p className="text-sm text-slate-600 dark:text-slate-400">
{extension.description}
</p>
</div>
<Button
size="XS"
theme="light"
text="Load"
onClick={() => handleSetActiveExtension(extension)}
/>
</div>
))}
</div>
</Card>
</div>
)}
</div>
</div>
</div>
</GridCard>
);
}

View File

@@ -22,6 +22,7 @@ const appendStatToMap = <T extends { timestamp: number }>(
// Constants and types
export type AvailableSidebarViews = "system" | "connection-stats";
export type AvailableModalViews = "connection-stats" | "settings";
export type AvailableTerminalTypes = "kvm" | "serial" | "none";
export interface User {
sub: string;
@@ -52,13 +53,13 @@ interface UIState {
isAttachedVirtualKeyboardVisible: boolean;
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void;
enableTerminal: boolean;
setEnableTerminal: (enabled: UIState["enableTerminal"]) => void;
terminalType: AvailableTerminalTypes;
setTerminalType: (enabled: UIState["terminalType"]) => void;
}
export const useUiStore = create<UIState>(set => ({
enableTerminal: false,
setEnableTerminal: enabled => set({ enableTerminal: enabled }),
terminalType: "none",
setTerminalType: type => set({ terminalType: type }),
sidebarView: null,
setSidebarView: view => set({ sidebarView: view }),

View File

@@ -5,12 +5,12 @@ import {
HidState,
UpdateState,
useHidStore,
useMountMediaStore,
User,
useRTCStore,
useUiStore,
useUpdateStore,
useVideoStore,
useMountMediaStore,
VideoState,
} from "@/hooks/stores";
import WebRTCVideo from "@components/WebRTCVideo";
@@ -35,7 +35,7 @@ import api from "../api";
import { DeviceStatus } from "./welcome-local";
import FocusTrap from "focus-trap-react";
import OtherSessionConnectedModal from "@/components/OtherSessionConnectedModal";
import TerminalWrapper from "../components/Terminal";
import Terminal from "@components/Terminal";
import { CLOUD_API, SIGNAL_API } from "@/ui.config";
interface LocalLoaderResp {
@@ -328,6 +328,7 @@ export default function KvmIdRoute() {
const setHdmiState = useVideoStore(state => state.setHdmiState);
const [hasUpdated, setHasUpdated] = useState(false);
function onJsonRpcRequest(resp: JsonRpcRequest) {
if (resp.method === "otherSessionConnected") {
console.log("otherSessionConnected", resp.params);
@@ -413,10 +414,39 @@ export default function KvmIdRoute() {
// System update
const disableKeyboardFocusTrap = useUiStore(state => state.disableVideoFocusTrap);
const [kvmTerminal, setKvmTerminal] = useState<RTCDataChannel | null>(null);
const [serialConsole, setSerialConsole] = useState<RTCDataChannel | null>(null);
useEffect(() => {
if (!peerConnection) return;
if (!kvmTerminal) {
console.log('Creating data channel "terminal"');
setKvmTerminal(peerConnection.createDataChannel("terminal"));
}
if (!serialConsole) {
console.log('Creating data channel "serial"');
setSerialConsole(peerConnection.createDataChannel("serial"));
}
}, [kvmTerminal, peerConnection, serialConsole]);
useEffect(() => {
kvmTerminal?.addEventListener("message", e => {
console.log(e.data);
});
return () => {
kvmTerminal?.removeEventListener("message", e => {
console.log(e.data);
});
};
}, [kvmTerminal]);
return (
<>
<Transition show={!isUpdateDialogOpen && otaState.updating}>
<div className="fixed inset-0 z-10 flex items-start justify-center w-full h-full max-w-xl mx-auto translate-y-8 pointer-events-none">
<div className="pointer-events-none fixed inset-0 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center">
<div className="transition duration-1000 ease-in data-[closed]:opacity-0">
<UpdateInProgressStatusCard
setIsUpdateDialogOpen={setIsUpdateDialogOpen}
@@ -425,7 +455,6 @@ export default function KvmIdRoute() {
</div>
</div>
</Transition>
<div className="relative h-full">
<FocusTrap
paused={disableKeyboardFocusTrap}
@@ -459,9 +488,7 @@ export default function KvmIdRoute() {
<OtherSessionConnectedModal
open={isOtherSessionConnectedModalOpen}
setOpen={state => {
if (state === false) {
connectWebRTC();
}
if (!state) connectWebRTC().then(r => r);
// It takes some time for the WebRTC connection to be established, so we wait a bit before closing the modal
setTimeout(() => {
@@ -469,7 +496,12 @@ export default function KvmIdRoute() {
}, 1000);
}}
/>
<TerminalWrapper />
{kvmTerminal && (
<Terminal type="kvm" dataChannel={kvmTerminal} title="KVM Terminal" />
)}
{serialConsole && (
<Terminal type="serial" dataChannel={serialConsole} title="Serial Console" />
)}
</>
);
}