mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-01-18 03:28:19 +01:00
Add support for Luckfox PicoKVM
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
This commit is contained in:
BIN
ui/src/assets/logo-luckfox.png
Normal file
BIN
ui/src/assets/logo-luckfox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -2,7 +2,7 @@ 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 } from "react";
|
||||
import { Fragment, useCallback, useRef, useState, useEffect } from "react";
|
||||
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
import { Button } from "@components/Button";
|
||||
@@ -19,6 +19,8 @@ import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
||||
import MountPopopover from "@/components/popovers/MountPopover";
|
||||
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
|
||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||
import VolumeControl from "./VolumeControl";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
|
||||
export default function Actionbar({
|
||||
requestFullscreen,
|
||||
@@ -38,6 +40,11 @@ export default function Actionbar({
|
||||
);
|
||||
const developerMode = useSettingsStore(state => state.developerMode);
|
||||
|
||||
// Audio related
|
||||
const [audioMode, setAudioMode] = useState("disabled");
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
|
||||
// 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);
|
||||
@@ -55,7 +62,14 @@ export default function Actionbar({
|
||||
},
|
||||
[setDisableFocusTrap],
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
send("getAudioMode", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setAudioMode(String(resp.result));
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
return (
|
||||
<Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
|
||||
<div
|
||||
@@ -64,15 +78,13 @@ export default function Actionbar({
|
||||
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 && (
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Web Terminal"
|
||||
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
|
||||
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Terminal"
|
||||
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
|
||||
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverButton as={Fragment}>
|
||||
<Button
|
||||
@@ -152,7 +164,7 @@ export default function Actionbar({
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Wake on LAN"
|
||||
text="Wake"
|
||||
onClick={() => {
|
||||
setDisableFocusTrap(true);
|
||||
}}
|
||||
@@ -207,6 +219,16 @@ export default function Actionbar({
|
||||
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(audioMode !== "disabled") && (
|
||||
<div className="hidden lg:block">
|
||||
<VolumeControl
|
||||
size="XS"
|
||||
theme="light"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
|
||||
@@ -250,7 +272,7 @@ export default function Actionbar({
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Connection Stats"
|
||||
text="Connection State"
|
||||
LeadingIcon={({ className }) => (
|
||||
<LuSignal
|
||||
className={cx(className, "mb-0.5 text-green-500")}
|
||||
|
||||
@@ -7,12 +7,10 @@ import Container from "@components/Container";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import GridBackground from "@components/GridBackground";
|
||||
import StepCounter from "@components/StepCounter";
|
||||
import { CLOUD_API } from "@/ui.config";
|
||||
|
||||
interface AuthLayoutProps {
|
||||
title: string;
|
||||
description: string;
|
||||
action: string;
|
||||
cta: string;
|
||||
ctaHref: string;
|
||||
showCounter?: boolean;
|
||||
@@ -21,7 +19,6 @@ interface AuthLayoutProps {
|
||||
export default function AuthLayout({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
cta,
|
||||
ctaHref,
|
||||
showCounter,
|
||||
@@ -60,35 +57,6 @@ export default function AuthLayout({
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">{description}</p>
|
||||
</div>
|
||||
|
||||
<Fieldset className="space-y-12">
|
||||
<div className="mx-auto max-w-sm space-y-4">
|
||||
<form action={`${CLOUD_API}/oidc/google`} method="POST">
|
||||
{/*This could be the KVM ID*/}
|
||||
{deviceId ? (
|
||||
<input type="hidden" name="deviceId" value={deviceId} />
|
||||
) : null}
|
||||
{returnTo ? (
|
||||
<input type="hidden" name="returnTo" value={returnTo} />
|
||||
) : null}
|
||||
<Button
|
||||
size="LG"
|
||||
theme="light"
|
||||
fullWidth
|
||||
text={`${action}`}
|
||||
LeadingIcon={GoogleIcon}
|
||||
textAlign="center"
|
||||
type="submit"
|
||||
loading={
|
||||
(navigation.state === "submitting" ||
|
||||
navigation.state === "loading") &&
|
||||
navigation.formMethod?.toLowerCase() === "post" &&
|
||||
navigation.formAction?.includes("auth/google")
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -6,12 +6,12 @@ import { LuMonitorSmartphone } from "react-icons/lu";
|
||||
|
||||
import Container from "@/components/Container";
|
||||
import Card from "@/components/Card";
|
||||
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import { useHidStore, useRTCStore, useUserStore, useVpnStore } from "@/hooks/stores";
|
||||
import LogoLuckfox from "@/assets/logo-luckfox.png";
|
||||
import USBStateStatus from "@components/USBStateStatus";
|
||||
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
|
||||
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||
import VpnConnectionStatusCard from "@components/VpnConnectionStatusCard";
|
||||
import { DEVICE_API } from "@/ui.config";
|
||||
|
||||
import api from "../api";
|
||||
import { isOnDevice } from "../main";
|
||||
@@ -36,10 +36,12 @@ export default function DashboardNavbar({
|
||||
kvmName,
|
||||
}: NavbarProps) {
|
||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||
const tailScaleConnectionState = useVpnStore(state => state.tailScaleConnectionState);
|
||||
const zeroTierConnectionState = useVpnStore(state => state.zeroTierConnectionState);
|
||||
const setUser = useUserStore(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
const onLogout = useCallback(async () => {
|
||||
const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
|
||||
const logoutUrl = `${DEVICE_API}/auth/logout`;
|
||||
const res = await api.POST(logoutUrl);
|
||||
if (!res.ok) return;
|
||||
|
||||
@@ -60,8 +62,18 @@ export default function DashboardNavbar({
|
||||
<div className="flex h-14 items-center justify-between">
|
||||
<div className="flex shrink-0 items-center gap-x-8">
|
||||
<div className="inline-block shrink-0">
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href="https://wiki.luckfox.com/Luckfox-Pico/Download"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<img src={LogoLuckfox} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoLuckfox} alt="" className="hidden h-[24px] dark:block" />
|
||||
<b className="navbar__title text--truncate dark:text-white">LUCKFOX</b>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
@@ -96,6 +108,18 @@ export default function DashboardNavbar({
|
||||
peerConnectionState={peerConnectionState}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden w-[159px] md:block">
|
||||
<VpnConnectionStatusCard
|
||||
state={peerConnectionState === "connected" ? tailScaleConnectionState : "disconnected"}
|
||||
title="TailScale"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden w-[159px] md:block">
|
||||
<VpnConnectionStatusCard
|
||||
state={peerConnectionState === "connected" ? zeroTierConnectionState : "disconnected"}
|
||||
title="ZeroTier"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isLoggedIn ? (
|
||||
|
||||
@@ -93,7 +93,7 @@ const InputFieldWithLabel = forwardRef<HTMLInputElement, InputFieldWithLabelProp
|
||||
InputFieldWithLabel.displayName = "InputFieldWithLabel";
|
||||
|
||||
export default InputField;
|
||||
export { InputFieldWithLabel };
|
||||
export { InputField, InputFieldWithLabel };
|
||||
|
||||
export function FieldError({ error }: { error: string | React.ReactNode }) {
|
||||
return <div className="mt-[6px] text-[13px] leading-normal text-red-500">{error}</div>;
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function PeerConnectionStatusCard({
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
title={title || "JetKVM Device"}
|
||||
title={title || "KVM Device"}
|
||||
status={PeerConnectionStatusMap[state]}
|
||||
{...StatusCardProps[state]}
|
||||
/>
|
||||
|
||||
@@ -88,6 +88,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
|
||||
|
||||
// Disabled
|
||||
"disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80",
|
||||
|
||||
)}
|
||||
value={value}
|
||||
id={id}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { Link } from "react-router-dom";
|
||||
import React from "react";
|
||||
|
||||
import Container from "@/components/Container";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import LogoLuckfox from "@/assets/logo-luckfox.png";
|
||||
|
||||
interface Props { logoHref?: string; actionElement?: React.ReactNode }
|
||||
|
||||
@@ -14,8 +13,8 @@ export default function SimpleNavbar({ logoHref, actionElement }: Props) {
|
||||
<div className="pb-4 my-4 border-b border-b-800/20 isolate dark:border-b-slate-300/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link to={logoHref ?? "/"} className="hidden h-[26px] dark:inline-block">
|
||||
<img src={LogoWhiteIcon} alt="" className="h-[26px] dark:block hidden" />
|
||||
<img src={LogoBlueIcon} alt="" className="h-[26px] dark:hidden" />
|
||||
<img src={LogoLuckfox} alt="" className="h-[26px] dark:block hidden" />
|
||||
<img src={LogoLuckfox} alt="" className="h-[26px] dark:hidden" />
|
||||
</Link>
|
||||
<div>{actionElement}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "react-simple-keyboard/build/css/index.css";
|
||||
import { ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||
import { useEffect } from "react";
|
||||
import { LuPin, LuPinOff } from 'react-icons/lu'
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
@@ -71,6 +71,25 @@ function Terminal({
|
||||
|
||||
const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG });
|
||||
|
||||
const [pinned, setPinned] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!enableTerminal) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (pinned) return;
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setTerminalType("none");
|
||||
setDisableKeyboardFocusTrap(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [enableTerminal, pinned, setTerminalType, setDisableKeyboardFocusTrap]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setDisableKeyboardFocusTrap(enableTerminal);
|
||||
@@ -155,13 +174,18 @@ function Terminal({
|
||||
|
||||
// Handle resize event
|
||||
window.addEventListener("resize", handleResize);
|
||||
if (enableTerminal) {
|
||||
setTimeout(handleResize, 50);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [ref, instance]);
|
||||
}, [ref, instance, enableTerminal]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
>
|
||||
@@ -190,9 +214,8 @@ function Terminal({
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Hide"
|
||||
LeadingIcon={ChevronDownIcon}
|
||||
onClick={() => setTerminalType("none")}
|
||||
LeadingIcon={pinned ? LuPinOff : LuPin}
|
||||
onClick={() => setPinned(p => !p)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
39
ui/src/components/UploadDialog.tsx
Normal file
39
ui/src/components/UploadDialog.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
interface UploadDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: React.ReactNode
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
|
||||
export function UploadDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: UploadDialogProps) {
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={ () => {} }>
|
||||
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
|
||||
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800">
|
||||
<div className="space-y-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h2 className="text-lg leading-tight font-bold text-black dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="text-sm leading-snug text-slate-600 dark:text-slate-400">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export interface USBConfig {
|
||||
|
||||
const usbConfigs = [
|
||||
{
|
||||
label: "JetKVM Default",
|
||||
label: "KVM Default",
|
||||
value: "USB Emulation Device",
|
||||
},
|
||||
{
|
||||
@@ -65,7 +65,7 @@ export function UsbInfoSetting() {
|
||||
vendor_id: "0x1d6b",
|
||||
product_id: "0x0104",
|
||||
serial_number: deviceId,
|
||||
manufacturer: "JetKVM",
|
||||
manufacturer: "KVM",
|
||||
product: "USB Emulation Device",
|
||||
},
|
||||
"Logitech USB Input Device": {
|
||||
|
||||
@@ -128,7 +128,7 @@ export function ConnectionFailedOverlay({
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<LinkButton
|
||||
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
|
||||
to={"https://wiki.luckfox.com/intro"}
|
||||
theme="primary"
|
||||
text="Troubleshooting Guide"
|
||||
TrailingIcon={ArrowRightIcon}
|
||||
@@ -249,7 +249,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
||||
</div>
|
||||
<div>
|
||||
<LinkButton
|
||||
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
|
||||
to={"https://wiki.luckfox.com/intro"}
|
||||
theme="light"
|
||||
text="Learn more"
|
||||
TrailingIcon={ArrowRightIcon}
|
||||
@@ -291,7 +291,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
||||
</div>
|
||||
<div>
|
||||
<LinkButton
|
||||
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
|
||||
to={"https://wiki.luckfox.com/intro"}
|
||||
theme="light"
|
||||
text="Learn more"
|
||||
TrailingIcon={ArrowRightIcon}
|
||||
|
||||
210
ui/src/components/VolumeControl.tsx
Normal file
210
ui/src/components/VolumeControl.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { LuVolume2, LuVolumeX } from "react-icons/lu";
|
||||
import clsx from "clsx";
|
||||
import { cva, cx } from "@/cva.config";
|
||||
|
||||
interface VolumeControlProps {
|
||||
theme?: "primary" | "danger" | "light" | "lightDanger" | "blank";
|
||||
size?: "XS" | "SM" | "MD" | "LG" | "XL";
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
XS: "h-[28px] px-2 text-xs",
|
||||
SM: "h-[36px] px-3 text-[13px]",
|
||||
MD: "h-[40px] px-3.5 text-sm",
|
||||
LG: "h-[48px] px-4 text-base",
|
||||
XL: "h-[56px] px-5 text-base",
|
||||
};
|
||||
|
||||
const themes = {
|
||||
primary: cx(
|
||||
// Base styles
|
||||
"bg-blue-700 dark:border-blue-600 border border-blue-900/60 text-white shadow-sm",
|
||||
// Hover states
|
||||
"group-hover:bg-blue-800",
|
||||
// Active states
|
||||
"group-active:bg-blue-900",
|
||||
),
|
||||
danger: cx(
|
||||
// Base styles
|
||||
"bg-red-600 text-white border-red-700 shadow-xs shadow-red-200/80 dark:border-red-600 dark:shadow-red-900/20",
|
||||
// Hover states
|
||||
"group-hover:bg-red-700 group-hover:border-red-800 dark:group-hover:bg-red-700 dark:group-hover:border-red-600",
|
||||
// Active states
|
||||
"group-active:bg-red-800 dark:group-active:bg-red-800",
|
||||
// Focus states
|
||||
"group-focus:ring-red-700 dark:group-focus:ring-red-600",
|
||||
),
|
||||
light: cx(
|
||||
// Base styles
|
||||
"bg-white text-black border-slate-800/30 shadow-xs dark:bg-slate-800 dark:border-slate-300/20 dark:text-white",
|
||||
// Hover states
|
||||
"group-hover:bg-blue-50/80 dark:group-hover:bg-slate-700",
|
||||
// Active states
|
||||
"group-active:bg-blue-100/60 dark:group-active:bg-slate-600",
|
||||
// Disabled states
|
||||
"group-disabled:group-hover:bg-white dark:group-disabled:group-hover:bg-slate-800",
|
||||
),
|
||||
lightDanger: cx(
|
||||
// Base styles
|
||||
"bg-white text-black border-red-400/60 shadow-xs",
|
||||
// Hover states
|
||||
"group-hover:bg-red-50/80",
|
||||
// Active states
|
||||
"group-active:bg-red-100/60",
|
||||
// Focus states
|
||||
"group-focus:ring-red-700",
|
||||
),
|
||||
blank: cx(
|
||||
// Base styles
|
||||
"bg-white/0 text-black border-transparent dark:text-white",
|
||||
// Hover states
|
||||
"group-hover:bg-white group-hover:border-slate-800/30 group-hover:shadow-sm dark:group-hover:bg-slate-700 dark:group-hover:border-slate-600",
|
||||
// Active states
|
||||
"group-active:bg-slate-100/80",
|
||||
),
|
||||
};
|
||||
|
||||
const btnVariants = cva({
|
||||
base: cx(
|
||||
// Base styles
|
||||
"border rounded-sm select-none",
|
||||
// Size classes
|
||||
"justify-center items-center shrink-0",
|
||||
// Transition classes
|
||||
"outline-hidden transition-all duration-200",
|
||||
// Text classes
|
||||
"font-display text-center font-medium leading-tight",
|
||||
// States
|
||||
"group-focus:outline-hidden group-focus:ring-2 group-focus:ring-offset-2 group-focus:ring-blue-700",
|
||||
"group-disabled:opacity-50 group-disabled:pointer-events-none",
|
||||
),
|
||||
|
||||
variants: {
|
||||
size: sizes,
|
||||
theme: themes,
|
||||
},
|
||||
});
|
||||
|
||||
const iconVariants = cva({
|
||||
variants: {
|
||||
size: {
|
||||
XS: "h-3.5",
|
||||
SM: "h-3.5",
|
||||
MD: "h-5",
|
||||
LG: "h-6",
|
||||
XL: "h-6",
|
||||
},
|
||||
theme: {
|
||||
primary: "text-white",
|
||||
danger: "text-white ",
|
||||
light: "text-black dark:text-white",
|
||||
lightDanger: "text-black dark:text-white",
|
||||
blank: "text-black dark:text-white",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const VolumeControl: React.FC<VolumeControlProps> = ({
|
||||
theme = "light",
|
||||
size = "XS",
|
||||
fullWidth = false,
|
||||
className,
|
||||
}) => {
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [muted, setMuted] = useState(true);
|
||||
const [showSlider, setShowSlider] = useState(false);
|
||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = document.querySelector("audio#global-audio") as HTMLAudioElement | null;
|
||||
setAudioElement(audio);
|
||||
if (audio) {
|
||||
const savedVolume = parseFloat(localStorage.getItem("audioVolume") || "1");
|
||||
const savedMuted = localStorage.getItem("audioMuted") === "true";
|
||||
|
||||
audio.volume = savedVolume;
|
||||
audio.muted = savedMuted;
|
||||
setVolume(savedVolume);
|
||||
setMuted(savedMuted);
|
||||
|
||||
audio
|
||||
.play()
|
||||
.catch(() => {
|
||||
audio.muted = true;
|
||||
setMuted(true);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!audioElement) return;
|
||||
audioElement.muted = false;
|
||||
audioElement.volume = volume;
|
||||
audioElement.play().catch((err) => {
|
||||
console.warn("Failed to play:", err);
|
||||
});
|
||||
setMuted(false);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
setVolume(newVolume);
|
||||
setMuted(newVolume === 0);
|
||||
if (audioElement) {
|
||||
audioElement.volume = newVolume;
|
||||
audioElement.muted = newVolume === 0;
|
||||
}
|
||||
localStorage.setItem("audioVolume", String(newVolume));
|
||||
localStorage.setItem("audioMuted", String(newVolume === 0));
|
||||
};
|
||||
|
||||
const iconClass = iconVariants({ theme, size });
|
||||
const btnClass = btnVariants({ theme, size });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"relative group flex items-center gap-2",
|
||||
fullWidth ? "w-full" : "w-fit",
|
||||
className
|
||||
)}
|
||||
onMouseEnter={() => setShowSlider(true)}
|
||||
onMouseLeave={() => setShowSlider(false)}
|
||||
>
|
||||
<button
|
||||
onClick={handlePlay}
|
||||
className={clsx("group p-2 flex items-center", btnClass)}
|
||||
aria-label="Unmute & Play"
|
||||
>
|
||||
{muted || volume === 0 ? (
|
||||
<LuVolumeX className={clsx(iconClass, "shrink-0")} />
|
||||
) : (
|
||||
<LuVolume2 className={clsx(iconClass, "shrink-0")} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
"transition-all duration-300 ease-in-out",
|
||||
showSlider ? "w-16 opacity-100 ml-2" : "w-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={muted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-16 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
|
||||
aria-label="Volume slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VolumeControl;
|
||||
54
ui/src/components/VpnConnectionStatusCard.tsx
Normal file
54
ui/src/components/VpnConnectionStatusCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import StatusCard from "@components/StatusCards";
|
||||
|
||||
const VpnConnectionStatusMap = {
|
||||
connected: "Connected",
|
||||
connecting: "Connecting",
|
||||
disconnected: "Disconnected",
|
||||
closed: "Closed",
|
||||
logined: "Logined",
|
||||
};
|
||||
|
||||
export type VpnConnections = keyof typeof VpnConnectionStatusMap;
|
||||
|
||||
type StatusProps = {
|
||||
[key in VpnConnections]: {
|
||||
statusIndicatorClassName: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function VpnConnectionStatusCard({
|
||||
state,
|
||||
title,
|
||||
}: {
|
||||
state?: VpnConnections;
|
||||
title?: string;
|
||||
}) {
|
||||
if (!state) return null;
|
||||
const StatusCardProps: StatusProps = {
|
||||
logined: {
|
||||
statusIndicatorClassName: "bg-green-500 border-green-600",
|
||||
},
|
||||
connected: {
|
||||
statusIndicatorClassName: "bg-green-500 border-green-600",
|
||||
},
|
||||
connecting: {
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
disconnected: {
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
closed: {
|
||||
statusIndicatorClassName: "bg-slate-300 border-slate-400",
|
||||
},
|
||||
};
|
||||
const props = StatusCardProps[state];
|
||||
if (!props) return;
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
title={title || "Vpn Network"}
|
||||
status={VpnConnectionStatusMap[state]}
|
||||
{...StatusCardProps[state]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
PointerLockBar,
|
||||
} from "./VideoOverlay";
|
||||
|
||||
|
||||
export default function WebRTCVideo() {
|
||||
// Video and stream related refs and states
|
||||
const videoElm = useRef<HTMLVideoElement>(null);
|
||||
const audioElm = useRef<HTMLAudioElement>(null);
|
||||
const mediaStream = useRTCStore(state => state.mediaStream);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||
@@ -517,6 +519,16 @@ export default function WebRTCVideo() {
|
||||
[updateVideoSizeStore],
|
||||
);
|
||||
|
||||
const addStreamToAudioElm = useCallback(
|
||||
(mediaStream: MediaStream) => {
|
||||
if (!audioElm.current) return;
|
||||
const audioElmRefValue = audioElm.current;
|
||||
audioElmRefValue.srcObject = mediaStream;
|
||||
//audioElm.current.play();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function updateVideoStreamOnNewTrack() {
|
||||
if (!peerConnection) return;
|
||||
@@ -543,6 +555,7 @@ export default function WebRTCVideo() {
|
||||
if (!mediaStream) return;
|
||||
// We set the as early as possible
|
||||
addStreamToVideoElm(mediaStream);
|
||||
addStreamToAudioElm(mediaStream);
|
||||
},
|
||||
[
|
||||
setVideoClientSize,
|
||||
@@ -550,6 +563,7 @@ export default function WebRTCVideo() {
|
||||
updateVideoSizeStore,
|
||||
peerConnection,
|
||||
addStreamToVideoElm,
|
||||
addStreamToAudioElm,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -671,6 +685,14 @@ export default function WebRTCVideo() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
id="global-audio"
|
||||
ref={audioElm}
|
||||
autoPlay
|
||||
muted={true}
|
||||
controls={false}
|
||||
/>
|
||||
|
||||
<div ref={containerRef} className="h-full overflow-hidden">
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import notifications from "@/notifications";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
|
||||
import { useJsonRpc } from "../../hooks/useJsonRpc";
|
||||
|
||||
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">
|
||||
<SettingsPageHeader
|
||||
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="h-6 w-6 text-blue-500 dark:text-blue-400" />
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="h-[120px] animate-fadeIn opacity-0">
|
||||
<div className="space-y-4 p-3">
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { LuPower } from "react-icons/lu";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import notifications from "@/notifications";
|
||||
import FieldLabel from "@components/FieldLabel";
|
||||
import LoadingSpinner from "@components/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">
|
||||
<SettingsPageHeader
|
||||
title="DC Power Control"
|
||||
description="Control your DC power settings"
|
||||
/>
|
||||
|
||||
{powerState === null ? (
|
||||
<Card className="flex h-[160px] justify-center p-3">
|
||||
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="h-[160px] animate-fadeIn opacity-0">
|
||||
<div className="space-y-4 p-3">
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
123
ui/src/components/extensions/IOControl.tsx
Normal file
123
ui/src/components/extensions/IOControl.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Button } from "@components/Button";
|
||||
import { LuSun, LuSunset } from "react-icons/lu";
|
||||
import Card from "@components/Card";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useEffect, useState } from "react";
|
||||
import notifications from "@/notifications";
|
||||
import { cx } from "@/cva.config";
|
||||
|
||||
interface IOSettings {
|
||||
io0Status: boolean;
|
||||
io1Status: boolean;
|
||||
}
|
||||
|
||||
|
||||
export function IOControl() {
|
||||
const [send] = useJsonRpc();
|
||||
const [settings, setSettings] = useState<IOSettings>({
|
||||
io0Status: true,
|
||||
io1Status: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
send("getIOSettings", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to get IO settings: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSettings(resp.result as IOSettings);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleSettingChange = (setting: keyof IOSettings, value: boolean) => {
|
||||
const newSettings = { ...settings, [setting]: value };
|
||||
send("setIOSettings", { settings: newSettings }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to update IO settings: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSettings(newSettings);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="IO Control"
|
||||
description="Configure your io control settings"
|
||||
/>
|
||||
|
||||
<hr className="border-slate-700/30 dark:border-slate-600/30" />
|
||||
|
||||
<Card className="animate-fadeIn opacity-0">
|
||||
<div className="space-y-2 p-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-bm text-black dark:text-slate-300">IO_0</div>
|
||||
<div className={cx("w-2 h-2 rounded-full bg-red-400", {
|
||||
hidden: !settings.io0Status
|
||||
})} />
|
||||
</div>
|
||||
<div className="flex justify-between gap-x-6">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
LeadingIcon={LuSun}
|
||||
text="High"
|
||||
onClick={() => {
|
||||
handleSettingChange("io0Status", true);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
LeadingIcon={LuSunset}
|
||||
text="Low"
|
||||
onClick={() => {
|
||||
handleSettingChange("io0Status", false);
|
||||
}}
|
||||
/>
|
||||
<div className="w-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 p-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-bm text-black dark:text-slate-300">IO_1</div>
|
||||
<div className={cx("w-2 h-2 rounded-full bg-red-400", {
|
||||
hidden: !settings.io1Status
|
||||
})} />
|
||||
</div>
|
||||
<div className="flex justify-between gap-x-6">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
LeadingIcon={LuSun}
|
||||
text="High"
|
||||
onClick={() => {
|
||||
handleSettingChange("io1Status", true);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
LeadingIcon={LuSunset}
|
||||
text="Low"
|
||||
onClick={() => {
|
||||
handleSettingChange("io1Status", false);
|
||||
}}
|
||||
/>
|
||||
<div className="w-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-2"></div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
|
||||
import { LuPower, LuTerminal } from "react-icons/lu";
|
||||
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import Card, { GridCard } from "@components/Card";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
|
||||
import { DCPowerControl } from "@components/extensions/DCPowerControl";
|
||||
import { SerialConsole } from "@components/extensions/SerialConsole";
|
||||
import { IOControl } from "@components/extensions/IOControl";
|
||||
import { Button } from "@components/Button";
|
||||
import notifications from "@/notifications";
|
||||
|
||||
@@ -18,24 +17,18 @@ interface Extension {
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
{
|
||||
id: "io-console",
|
||||
name: "IO Control",
|
||||
description: "Control IO port high and low level output",
|
||||
icon: LuPower,
|
||||
}
|
||||
];
|
||||
|
||||
export default function ExtensionPopover() {
|
||||
@@ -70,12 +63,10 @@ export default function ExtensionPopover() {
|
||||
|
||||
const renderActiveExtension = () => {
|
||||
switch (activeExtension?.id) {
|
||||
case "atx-power":
|
||||
return <ATXPowerControl />;
|
||||
case "dc-power":
|
||||
return <DCPowerControl />;
|
||||
case "serial-console":
|
||||
return <SerialConsole />;
|
||||
case "io-console":
|
||||
return <IOControl />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -101,7 +92,7 @@ export default function ExtensionPopover() {
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Unload Extension"
|
||||
text="Quit"
|
||||
onClick={() => handleSetActiveExtension(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -165,7 +165,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
||||
</Card>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-black dark:text-white">
|
||||
Mounted from JetKVM Storage
|
||||
Mounted from KVM Storage
|
||||
</h3>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">
|
||||
{formatters.truncateMiddle(path, 50)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useClose } from "@headlessui/react";
|
||||
|
||||
import { Button } from "@/components/Button";
|
||||
import { GridCard } from "@components/Card";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
@@ -28,6 +29,17 @@ export default function WakeOnLanModal() {
|
||||
setDisableFocusTrap(false);
|
||||
}, [close, setDisableFocusTrap]);
|
||||
|
||||
const onSendUsbWakeupSignal = useCallback(() => {
|
||||
send("sendUsbWakeupSignal", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to send USB wakeup signal: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const onSendMagicPacket = useCallback(
|
||||
(macAddress: string) => {
|
||||
setErrorMessage(null);
|
||||
@@ -104,6 +116,26 @@ export default function WakeOnLanModal() {
|
||||
<div className="space-y-4 p-4 py-3">
|
||||
<div className="grid h-full grid-rows-(--grid-headerBody)">
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Wake On USB"
|
||||
description="Send a Signal to wake up the device connected via USB."
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Wake"
|
||||
onClick={onSendUsbWakeupSignal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsPageHeader
|
||||
title="Wake On LAN"
|
||||
description="Send a Magic Packet to wake up a remote device."
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function ConnectionStatsSidebar() {
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
|
||||
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
|
||||
<SidebarHeader title="Connection State" setSidebarView={setSidebarView} />
|
||||
<div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900">
|
||||
<div className="space-y-4">
|
||||
{/*
|
||||
|
||||
@@ -114,6 +114,9 @@ interface RTCState {
|
||||
transceiver: RTCRtpTransceiver | null;
|
||||
setTransceiver: (transceiver: RTCRtpTransceiver) => void;
|
||||
|
||||
audioTransceiver: RTCRtpTransceiver | null;
|
||||
setAudioTransceiver: (transceiver: RTCRtpTransceiver) => void;
|
||||
|
||||
mediaStream: MediaStream | null;
|
||||
setMediaStream: (stream: MediaStream) => void;
|
||||
|
||||
@@ -121,6 +124,10 @@ interface RTCState {
|
||||
appendVideoStreamStats: (state: RTCInboundRtpStreamStats) => void;
|
||||
videoStreamStatsHistory: Map<number, RTCInboundRtpStreamStats>;
|
||||
|
||||
audioStreamStats: RTCInboundRtpStreamStats | null;
|
||||
appendAudioStreamStats: (state: RTCInboundRtpStreamStats) => void;
|
||||
audioStreamStatsHistory: Map<number, RTCInboundRtpStreamStats>;
|
||||
|
||||
isTurnServerInUse: boolean;
|
||||
setTurnServerInUse: (inUse: boolean) => void;
|
||||
|
||||
@@ -157,6 +164,9 @@ export const useRTCStore = create<RTCState>(set => ({
|
||||
transceiver: null,
|
||||
setTransceiver: transceiver => set({ transceiver }),
|
||||
|
||||
audioTransceiver: null,
|
||||
setAudioTransceiver: audioTransceiver => set({ audioTransceiver }),
|
||||
|
||||
peerConnectionState: null,
|
||||
setPeerConnectionState: state => set({ peerConnectionState: state }),
|
||||
|
||||
@@ -170,6 +180,10 @@ export const useRTCStore = create<RTCState>(set => ({
|
||||
appendVideoStreamStats: stats => set({ videoStreamStats: stats }),
|
||||
videoStreamStatsHistory: new Map(),
|
||||
|
||||
audioStreamStats: null,
|
||||
appendAudioStreamStats: stats => set({ audioStreamStats: stats }),
|
||||
audioStreamStatsHistory: new Map(),
|
||||
|
||||
isTurnServerInUse: false,
|
||||
setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }),
|
||||
|
||||
@@ -304,6 +318,15 @@ interface SettingsState {
|
||||
|
||||
backlightSettings: BacklightSettings;
|
||||
setBacklightSettings: (settings: BacklightSettings) => void;
|
||||
|
||||
timeZone: string;
|
||||
setTimeZone: (timezone: string) => void;
|
||||
|
||||
ledGreenMode: string;
|
||||
setLedGreenMode: (mode: string) => void;
|
||||
|
||||
ledYellowMode: string;
|
||||
setLedYellowMode: (mode: string) => void;
|
||||
|
||||
keyboardLayout: string;
|
||||
setKeyboardLayout: (layout: string) => void;
|
||||
@@ -345,7 +368,7 @@ export const useSettingsStore = create(
|
||||
developerMode: false,
|
||||
setDeveloperMode: enabled => set({ developerMode: enabled }),
|
||||
|
||||
displayRotation: "270",
|
||||
displayRotation: "180",
|
||||
setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),
|
||||
|
||||
backlightSettings: {
|
||||
@@ -356,6 +379,15 @@ export const useSettingsStore = create(
|
||||
setBacklightSettings: (settings: BacklightSettings) =>
|
||||
set({ backlightSettings: settings }),
|
||||
|
||||
timeZone: "CST-8",
|
||||
setTimeZone: (timezone: string) => set({ timeZone: timezone }),
|
||||
|
||||
ledGreenMode: "network-rx",
|
||||
setLedGreenMode: (mode: string) => set({ ledGreenMode: mode }),
|
||||
|
||||
ledYellowMode: "activity",
|
||||
setLedYellowMode: (mode: string) => set({ ledYellowMode: mode }),
|
||||
|
||||
keyboardLayout: "en-US",
|
||||
setKeyboardLayout: layout => set({ keyboardLayout: layout }),
|
||||
|
||||
@@ -402,7 +434,7 @@ export interface MountMediaState {
|
||||
remoteVirtualMediaState: RemoteVirtualMediaState | null;
|
||||
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void;
|
||||
|
||||
modalView: "mode" | "browser" | "url" | "device" | "upload" | "error" | null;
|
||||
modalView: "mode" | "browser" | "url" | "device" | "sd" | "upload" | "upload_sd" | "error" | null;
|
||||
setModalView: (view: MountMediaState["modalView"]) => void;
|
||||
|
||||
isMountMediaDialogOpen: boolean;
|
||||
@@ -943,3 +975,45 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export interface VpnState {
|
||||
tailScaleConnectionState: "connecting" | "connected" | "disconnected" | "closed" | "logined";
|
||||
setTailScaleConnectionState: (state: VpnState["tailScaleConnectionState"]) => void;
|
||||
|
||||
tailScaleLoginUrl: string | null;
|
||||
setTailScaleLoginUrl: (url: string) => void;
|
||||
|
||||
tailScaleXEdge: boolean;
|
||||
setTailScaleXEdge: (xEdge: boolean) => void;
|
||||
|
||||
tailScaleIP: string | null;
|
||||
setTailScaleIP: (ip: string) => void;
|
||||
|
||||
zeroTierConnectionState: "connecting" | "connected" | "disconnected" | "closed" | "logined";
|
||||
setZeroTierConnectionState: (state: VpnState["zeroTierConnectionState"]) => void;
|
||||
|
||||
zeroTierNetworkID: string | null;
|
||||
setZeroTierNetworkID: (networkID: string) => void;
|
||||
|
||||
zeroTierIP: string | null;
|
||||
setZeroTierIP: (ip: string) => void;
|
||||
};
|
||||
|
||||
export const useVpnStore = create<VpnState>(set => ({
|
||||
tailScaleConnectionState: "disconnected",
|
||||
setTailScaleConnectionState: state => set({ tailScaleConnectionState: state }),
|
||||
tailScaleLoginUrl: null,
|
||||
setTailScaleLoginUrl: url => set({ tailScaleLoginUrl: url }),
|
||||
tailScaleXEdge: false,
|
||||
setTailScaleXEdge: xEdge => set({ tailScaleXEdge: xEdge }),
|
||||
tailScaleIP: null,
|
||||
setTailScaleIP: url => set({ tailScaleIP: url }),
|
||||
|
||||
zeroTierConnectionState: "disconnected",
|
||||
setZeroTierConnectionState: state => set({ zeroTierConnectionState: state }),
|
||||
zeroTierNetworkID: null,
|
||||
setZeroTierNetworkID: networkID => set({ zeroTierNetworkID: networkID }),
|
||||
zeroTierIP: null,
|
||||
setZeroTierIP: networkID => set({ zeroTierIP: networkID }),
|
||||
}));
|
||||
|
||||
|
||||
189
ui/src/main.tsx
189
ui/src/main.tsx
@@ -28,7 +28,7 @@ import LoginLocalRoute from "./routes/login-local";
|
||||
import WelcomeLocalModeRoute from "./routes/welcome-local.mode";
|
||||
import WelcomeRoute, { DeviceStatus } from "./routes/welcome-local";
|
||||
import WelcomeLocalPasswordRoute from "./routes/welcome-local.password";
|
||||
import { CLOUD_API, DEVICE_API } from "./ui.config";
|
||||
import { DEVICE_API } from "./ui.config";
|
||||
import OtherSessionRoute from "./routes/devices.$id.other-session";
|
||||
import MountRoute from "./routes/devices.$id.mount";
|
||||
import * as SettingsRoute from "./routes/devices.$id.settings";
|
||||
@@ -49,29 +49,15 @@ import SettingsMacrosRoute from "./routes/devices.$id.settings.macros";
|
||||
import SettingsMacrosAddRoute from "./routes/devices.$id.settings.macros.add";
|
||||
import SettingsMacrosEditRoute from "./routes/devices.$id.settings.macros.edit";
|
||||
|
||||
export const isOnDevice = import.meta.env.MODE === "device";
|
||||
export const isOnDevice = true;
|
||||
export const isInCloud = !isOnDevice;
|
||||
|
||||
export async function checkCloudAuth() {
|
||||
const res = await fetch(`${CLOUD_API}/me`, {
|
||||
mode: "cors",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
throw redirect(`/login?returnTo=${window.location.href}`);
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function checkDeviceAuth() {
|
||||
const res = await api
|
||||
.GET(`${DEVICE_API}/device/status`)
|
||||
.then(res => res.json() as Promise<DeviceStatus>);
|
||||
|
||||
if (!res.isSetup) return redirect("/welcome");
|
||||
if (!res.isSetup) return redirect("/mode");
|
||||
|
||||
const deviceRes = await api.GET(`${DEVICE_API}/device`);
|
||||
if (deviceRes.status === 401) return redirect("/login-local");
|
||||
@@ -84,27 +70,25 @@ export async function checkDeviceAuth() {
|
||||
}
|
||||
|
||||
export async function checkAuth() {
|
||||
return import.meta.env.MODE === "device" ? checkDeviceAuth() : checkCloudAuth();
|
||||
return checkDeviceAuth();
|
||||
}
|
||||
|
||||
let router;
|
||||
if (isOnDevice) {
|
||||
router = createBrowserRouter([
|
||||
let router = createBrowserRouter([
|
||||
{
|
||||
path: "/welcome/mode",
|
||||
path: "/mode",
|
||||
element: <WelcomeLocalModeRoute />,
|
||||
action: WelcomeLocalModeRoute.action,
|
||||
},
|
||||
{
|
||||
path: "/welcome/password",
|
||||
path: "/mode/password",
|
||||
element: <WelcomeLocalPasswordRoute />,
|
||||
action: WelcomeLocalPasswordRoute.action,
|
||||
},
|
||||
{
|
||||
path: "/welcome",
|
||||
element: <WelcomeRoute />,
|
||||
loader: WelcomeRoute.loader,
|
||||
},
|
||||
//{
|
||||
// path: "/welcome",
|
||||
// element: <WelcomeRoute />,
|
||||
// loader: WelcomeRoute.loader,
|
||||
//},
|
||||
{
|
||||
path: "/login-local",
|
||||
element: <LoginLocalRoute />,
|
||||
@@ -216,155 +200,6 @@ if (isOnDevice) {
|
||||
errorElement: <ErrorBoundary />,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
router = createBrowserRouter([
|
||||
{
|
||||
errorElement: <ErrorBoundary />,
|
||||
children: [
|
||||
{ path: "signup", element: <SignupRoute /> },
|
||||
{ path: "login", element: <LoginRoute /> },
|
||||
{
|
||||
path: "/",
|
||||
element: <Root />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
loader: async () => {
|
||||
await checkAuth();
|
||||
return redirect(`/devices`);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
path: "devices/:id/setup",
|
||||
element: <SetupRoute />,
|
||||
action: SetupRoute.action,
|
||||
loader: SetupRoute.loader,
|
||||
},
|
||||
{
|
||||
path: "devices/already-adopted",
|
||||
element: <DevicesAlreadyAdopted />,
|
||||
},
|
||||
{
|
||||
path: "devices/:id",
|
||||
element: <DeviceRoute />,
|
||||
loader: DeviceRoute.loader,
|
||||
children: [
|
||||
{
|
||||
path: "other-session",
|
||||
element: <OtherSessionRoute />,
|
||||
},
|
||||
{
|
||||
path: "mount",
|
||||
element: <MountRoute />,
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: <SettingsRoute.default />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
loader: SettingsIndexRoute.loader,
|
||||
},
|
||||
{
|
||||
path: "general",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SettingsGeneralIndexRoute.default />,
|
||||
},
|
||||
{
|
||||
path: "update",
|
||||
element: <SettingsGeneralUpdateRoute />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "mouse",
|
||||
element: <SettingsMouseRoute />,
|
||||
},
|
||||
{
|
||||
path: "keyboard",
|
||||
element: <SettingsKeyboardRoute />,
|
||||
},
|
||||
{
|
||||
path: "advanced",
|
||||
element: <SettingsAdvancedRoute />,
|
||||
},
|
||||
{
|
||||
path: "hardware",
|
||||
element: <SettingsHardwareRoute />,
|
||||
},
|
||||
{
|
||||
path: "network",
|
||||
element: <SettingsNetworkRoute />,
|
||||
},
|
||||
{
|
||||
path: "access",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SettingsAccessIndexRoute />,
|
||||
loader: SettingsAccessIndexRoute.loader,
|
||||
},
|
||||
{
|
||||
path: "local-auth",
|
||||
element: <SecurityAccessLocalAuthRoute />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "video",
|
||||
element: <SettingsVideoRoute />,
|
||||
},
|
||||
{
|
||||
path: "appearance",
|
||||
element: <SettingsAppearanceRoute />,
|
||||
},
|
||||
{
|
||||
path: "macros",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SettingsMacrosRoute />,
|
||||
},
|
||||
{
|
||||
path: "add",
|
||||
element: <SettingsMacrosAddRoute />,
|
||||
},
|
||||
{
|
||||
path: ":macroId/edit",
|
||||
element: <SettingsMacrosEditRoute />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "devices/:id/deregister",
|
||||
element: <DevicesIdDeregister />,
|
||||
loader: DevicesIdDeregister.loader,
|
||||
action: DevicesIdDeregister.action,
|
||||
},
|
||||
{
|
||||
path: "devices/:id/rename",
|
||||
element: <DeviceIdRename />,
|
||||
loader: DeviceIdRename.loader,
|
||||
action: DeviceIdRename.action,
|
||||
},
|
||||
{
|
||||
path: "devices",
|
||||
element: <DevicesRoute />,
|
||||
loader: DevicesRoute.loader
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
@@ -15,57 +15,12 @@ import DashboardNavbar from "@components/Header";
|
||||
import { User } from "@/hooks/stores";
|
||||
import { checkAuth } from "@/main";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import { CLOUD_API } from "@/ui.config";
|
||||
|
||||
interface LoaderData {
|
||||
device: { id: string; name: string; user: { googleId: string } };
|
||||
user: User;
|
||||
}
|
||||
|
||||
const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { deviceId } = Object.fromEntries(await request.formData());
|
||||
|
||||
try {
|
||||
const res = await fetch(`${CLOUD_API}/devices/${deviceId}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return { message: "There was an error renaming your device. Please try again." };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { message: "There was an error renaming your device. Please try again." };
|
||||
}
|
||||
|
||||
return redirect("/devices");
|
||||
};
|
||||
|
||||
const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const user = await checkAuth();
|
||||
const { id } = params;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${CLOUD_API}/devices/${id}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
const { device } = (await res.json()) as {
|
||||
device: { id: string; name: string; user: { googleId: string } };
|
||||
};
|
||||
|
||||
return { device, user };
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { devices: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export default function DevicesIdDeregister() {
|
||||
const { device, user } = useLoaderData() as LoaderData;
|
||||
const error = useActionData() as { message: string };
|
||||
@@ -140,6 +95,3 @@ export default function DevicesIdDeregister() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DevicesIdDeregister.loader = loader;
|
||||
DevicesIdDeregister.action = action;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,7 @@ import { useNavigate, useOutletContext } from "react-router-dom";
|
||||
|
||||
import { GridCard } from "@/components/Card";
|
||||
import { Button } from "@components/Button";
|
||||
import LogoBlue from "@/assets/logo-blue.svg";
|
||||
import LogoWhite from "@/assets/logo-white.svg";
|
||||
import LogoLuckfox from "@/assets/logo-luckfox.png";
|
||||
|
||||
interface ContextType {
|
||||
setupPeerConnection: () => Promise<void>;
|
||||
@@ -24,8 +23,8 @@ export default function OtherSessionRoute() {
|
||||
<div className="p-10">
|
||||
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="h-[24px]">
|
||||
<img src={LogoBlue} alt="" className="h-full dark:hidden" />
|
||||
<img src={LogoWhite} alt="" className="hidden h-full dark:block" />
|
||||
<img src={LogoLuckfox} alt="" className="h-full dark:hidden" />
|
||||
<img src={LogoLuckfox} alt="" className="hidden h-full dark:block" />
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
|
||||
@@ -16,7 +16,6 @@ import DashboardNavbar from "@components/Header";
|
||||
import { User } from "@/hooks/stores";
|
||||
import { checkAuth } from "@/main";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import { CLOUD_API } from "@/ui.config";
|
||||
|
||||
import api from "../api";
|
||||
|
||||
@@ -33,43 +32,9 @@ const action = async ({ params, request }: ActionFunctionArgs) => {
|
||||
return { message: "Please specify a name" };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.PUT(`${CLOUD_API}/devices/${id}`, {
|
||||
name,
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { message: "There was an error renaming your device. Please try again." };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { message: "There was an error renaming your device. Please try again." };
|
||||
}
|
||||
|
||||
return redirect("/devices");
|
||||
};
|
||||
|
||||
const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const user = await checkAuth();
|
||||
const { id } = params;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${CLOUD_API}/devices/${id}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
const { device } = (await res.json()) as {
|
||||
device: { id: string; name: string; user: { googleId: string } };
|
||||
};
|
||||
|
||||
return { device, user };
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { devices: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export default function DeviceIdRename() {
|
||||
const { device, user } = useLoaderData() as LoaderData;
|
||||
const error = useActionData() as { message: string };
|
||||
@@ -135,5 +100,4 @@ export default function DeviceIdRename() {
|
||||
);
|
||||
}
|
||||
|
||||
DeviceIdRename.loader = loader;
|
||||
DeviceIdRename.action = action;
|
||||
|
||||
@@ -19,6 +19,21 @@ import { TextAreaWithLabel } from "@components/TextArea";
|
||||
import { LocalDevice } from "./devices.$id";
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
import { CloudState } from "./adopt";
|
||||
import { useVpnStore } from "@/hooks/stores";
|
||||
import Checkbox from "../components/Checkbox";
|
||||
|
||||
export interface TailScaleResponse {
|
||||
state: string;
|
||||
loginUrl: string;
|
||||
ip: string;
|
||||
xEdge: boolean;
|
||||
}
|
||||
|
||||
export interface ZeroTierResponse {
|
||||
state: string;
|
||||
networkID: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
export interface TLSState {
|
||||
mode: "self-signed" | "custom" | "disabled";
|
||||
@@ -40,41 +55,34 @@ export default function SettingsAccessIndexRoute() {
|
||||
const loaderData = useLoaderData() as LocalDevice | null;
|
||||
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const [isAdopted, setAdopted] = useState(false);
|
||||
const [deviceId, setDeviceId] = useState<string | null>(null);
|
||||
const [cloudApiUrl, setCloudApiUrl] = useState("");
|
||||
const [cloudAppUrl, setCloudAppUrl] = useState("");
|
||||
|
||||
// Use a simple string identifier for the selected provider
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>("jetkvm");
|
||||
const [tlsMode, setTlsMode] = useState<string>("unknown");
|
||||
const [tlsCert, setTlsCert] = useState<string>("");
|
||||
const [tlsKey, setTlsKey] = useState<string>("");
|
||||
|
||||
const tailScaleConnectionState = useVpnStore(state => state.tailScaleConnectionState);
|
||||
const tailScaleLoginUrl = useVpnStore(state => state.tailScaleLoginUrl);
|
||||
const tailScaleXEdge = useVpnStore(state => state.tailScaleXEdge)
|
||||
const tailScaleIP = useVpnStore(state => state.tailScaleIP);
|
||||
const setTailScaleConnectionState = useVpnStore(state => state.setTailScaleConnectionState);
|
||||
const setTailScaleLoginUrl = useVpnStore(state => state.setTailScaleLoginUrl);
|
||||
const setTailScaleXEdge = useVpnStore(state => state.setTailScaleXEdge);
|
||||
const setTailScaleIP = useVpnStore(state => state.setTailScaleIP);
|
||||
|
||||
const zeroTierConnectionState = useVpnStore(state => state.zeroTierConnectionState);
|
||||
const zeroTierNetworkID = useVpnStore(state => state.zeroTierNetworkID);
|
||||
const zeroTierIP = useVpnStore(state => state.zeroTierIP);
|
||||
const setZeroTierConnectionState = useVpnStore(state => state.setZeroTierConnectionState);
|
||||
const setZeroTierNetworkID = useVpnStore(state => state.setZeroTierNetworkID);
|
||||
const setZeroTierIP = useVpnStore(state => state.setZeroTierIP);
|
||||
|
||||
const getCloudState = useCallback(() => {
|
||||
send("getCloudState", {}, resp => {
|
||||
if ("error" in resp) return console.error(resp.error);
|
||||
const cloudState = resp.result as CloudState;
|
||||
setAdopted(cloudState.connected);
|
||||
setCloudApiUrl(cloudState.url);
|
||||
|
||||
if (cloudState.appUrl) setCloudAppUrl(cloudState.appUrl);
|
||||
|
||||
// Find if the API URL matches any of our predefined providers
|
||||
const isAPIJetKVMProd = cloudState.url === "https://api.jetkvm.com";
|
||||
const isAppJetKVMProd = cloudState.appUrl === "https://app.jetkvm.com";
|
||||
|
||||
if (isAPIJetKVMProd && isAppJetKVMProd) {
|
||||
setSelectedProvider("jetkvm");
|
||||
} else {
|
||||
setSelectedProvider("custom");
|
||||
}
|
||||
});
|
||||
}, [send]);
|
||||
const [tempNetworkID, setTempNetworkID] = useState("");
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||
|
||||
const getTLSState = useCallback(() => {
|
||||
send("getTLSState", {}, resp => {
|
||||
@@ -87,66 +95,6 @@ export default function SettingsAccessIndexRoute() {
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const deregisterDevice = async () => {
|
||||
send("deregisterDevice", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to de-register device: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
getCloudState();
|
||||
// In cloud mode, we need to navigate to the device overview page, as we don't a connection anymore
|
||||
if (!isOnDevice) navigate("/");
|
||||
return;
|
||||
});
|
||||
};
|
||||
|
||||
const onCloudAdoptClick = useCallback(
|
||||
(cloudApiUrl: string, cloudAppUrl: string) => {
|
||||
if (!deviceId) {
|
||||
notifications.error("No device ID available");
|
||||
return;
|
||||
}
|
||||
|
||||
send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const returnTo = new URL(window.location.href);
|
||||
returnTo.pathname = "/adopt";
|
||||
returnTo.search = "";
|
||||
returnTo.hash = "";
|
||||
window.location.href =
|
||||
cloudAppUrl +
|
||||
"/signup?deviceId=" +
|
||||
deviceId +
|
||||
`&returnTo=${returnTo.toString()}`;
|
||||
});
|
||||
},
|
||||
[deviceId, send],
|
||||
);
|
||||
|
||||
// Handle provider selection change
|
||||
const handleProviderChange = (value: string) => {
|
||||
setSelectedProvider(value);
|
||||
|
||||
// If selecting a predefined provider, update both URLs
|
||||
if (value === "jetkvm") {
|
||||
setCloudApiUrl("https://api.jetkvm.com");
|
||||
setCloudAppUrl("https://app.jetkvm.com");
|
||||
} else {
|
||||
if (cloudApiUrl || cloudAppUrl) return;
|
||||
setCloudApiUrl("");
|
||||
setCloudAppUrl("");
|
||||
}
|
||||
};
|
||||
|
||||
// Function to update TLS state - accepts a mode parameter
|
||||
const updateTlsState = useCallback(
|
||||
(mode: string, cert?: string, key?: string) => {
|
||||
@@ -195,14 +143,124 @@ export default function SettingsAccessIndexRoute() {
|
||||
|
||||
// Fetch device ID and cloud state on component mount
|
||||
useEffect(() => {
|
||||
getCloudState();
|
||||
getTLSState();
|
||||
|
||||
send("getDeviceID", {}, async resp => {
|
||||
if ("error" in resp) return console.error(resp.error);
|
||||
setDeviceId(resp.result as string);
|
||||
});
|
||||
}, [send, getCloudState, getTLSState]);
|
||||
}, [send, getTLSState]);
|
||||
|
||||
const handleTailScaleLogin = useCallback(() => {
|
||||
setTailScaleConnectionState("connecting");
|
||||
|
||||
send("loginTailScale", { xEdge: tailScaleXEdge }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to login TailScale: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
setTailScaleConnectionState("closed");
|
||||
setTailScaleLoginUrl("");
|
||||
setTailScaleIP("");
|
||||
return;
|
||||
}
|
||||
const result = resp.result as TailScaleResponse;
|
||||
const validState = ["closed", "connecting", "connected", "disconnected" , "logined"].includes(result.state)
|
||||
? result.state as "closed" | "connecting" | "connected" | "disconnected" | "logined"
|
||||
: "closed";
|
||||
setTailScaleConnectionState(validState);
|
||||
setTailScaleLoginUrl(result.loginUrl);
|
||||
setTailScaleIP(result.ip);
|
||||
});
|
||||
}, [send, tailScaleXEdge]);
|
||||
|
||||
const handleTailScaleXEdgeChange = (enabled: boolean) => {
|
||||
setTailScaleXEdge(enabled);
|
||||
};
|
||||
|
||||
const handleTailScaleLogout = useCallback(() => {
|
||||
setIsDisconnecting(true);
|
||||
send("logoutTailScale", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to logout TailScale: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
setIsDisconnecting(false);
|
||||
return;
|
||||
}
|
||||
setTailScaleConnectionState("disconnected");
|
||||
setTailScaleLoginUrl("");
|
||||
setTailScaleIP("");
|
||||
setIsDisconnecting(false);
|
||||
});
|
||||
},[send]);
|
||||
|
||||
const handleTailScaleCanel = useCallback(() => {
|
||||
setIsDisconnecting(true);
|
||||
send("canelTailScale", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to logout TailScale: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
setIsDisconnecting(false);
|
||||
return;
|
||||
}
|
||||
setTailScaleConnectionState("disconnected");
|
||||
setTailScaleLoginUrl("");
|
||||
setTailScaleIP("");
|
||||
setIsDisconnecting(false);
|
||||
});
|
||||
},[send]);
|
||||
|
||||
const handleZeroTierLogin = useCallback(() => {
|
||||
setZeroTierConnectionState("connecting");
|
||||
const currentNetworkID = tempNetworkID;
|
||||
|
||||
if (!/^[0-9a-f]{16}$/.test(currentNetworkID)) {
|
||||
notifications.error("Please enter a valid Network ID");
|
||||
setZeroTierConnectionState("disconnected");
|
||||
return;
|
||||
}
|
||||
setZeroTierNetworkID(currentNetworkID);
|
||||
send("loginZeroTier", { networkID: currentNetworkID }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to login ZeroTier: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
|
||||
setZeroTierConnectionState("closed");
|
||||
setZeroTierNetworkID("");
|
||||
setZeroTierIP("");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = resp.result as ZeroTierResponse;
|
||||
const validState = ["closed", "connecting", "connected", "disconnected" , "logined" ].includes(result.state)
|
||||
? result.state as "closed" | "connecting" | "connected" | "disconnected" | "logined"
|
||||
: "closed";
|
||||
setZeroTierConnectionState(validState);
|
||||
setZeroTierIP(result.ip);
|
||||
});
|
||||
}, [send, tempNetworkID]);
|
||||
|
||||
const handleZeroTierNetworkIdChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.trim();
|
||||
setTempNetworkID(value);
|
||||
}, []);
|
||||
|
||||
const handleZeroTierLogout = useCallback(() => {
|
||||
send("logoutZeroTier", { networkID: zeroTierNetworkID }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to logout ZeroTier: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setZeroTierConnectionState("disconnected");
|
||||
setZeroTierNetworkID("");
|
||||
setZeroTierIP("");
|
||||
});
|
||||
},[send, zeroTierNetworkID]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -334,136 +392,172 @@ export default function SettingsAccessIndexRoute() {
|
||||
description="Manage the mode of Remote access to the device"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!isAdopted && (
|
||||
<>
|
||||
<SettingsItem
|
||||
title="Cloud Provider"
|
||||
description="Select the cloud provider for your device"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={selectedProvider}
|
||||
onChange={e => handleProviderChange(e.target.value)}
|
||||
options={[
|
||||
{ value: "jetkvm", label: "JetKVM Cloud" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
]}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{selectedProvider === "custom" && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
label="Cloud API URL"
|
||||
value={cloudApiUrl}
|
||||
onChange={e => setCloudApiUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
label="Cloud App URL"
|
||||
value={cloudAppUrl}
|
||||
onChange={e => setCloudAppUrl(e.target.value)}
|
||||
placeholder="https://app.example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Show security info for JetKVM Cloud */}
|
||||
{selectedProvider === "jetkvm" && (
|
||||
<GridCard>
|
||||
<div className="flex items-start gap-x-4 p-4">
|
||||
<ShieldCheckIcon className="mt-1 h-8 w-8 shrink-0 text-blue-600 dark:text-blue-500" />
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
Cloud Security
|
||||
</h3>
|
||||
<div>
|
||||
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
|
||||
<li>End-to-end encryption using WebRTC (DTLS and SRTP)</li>
|
||||
<li>Zero Trust security model</li>
|
||||
<li>OIDC (OpenID Connect) authentication</li>
|
||||
<li>All streams encrypted in transit</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-700 dark:text-slate-300">
|
||||
All cloud components are open-source and available on{" "}
|
||||
<a
|
||||
href="https://github.com/jetkvm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
<hr className="block w-full border-slate-800/20 dark:border-slate-300/20" />
|
||||
|
||||
<div>
|
||||
<LinkButton
|
||||
to="https://jetkvm.com/docs/networking/remote-access"
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Learn about our cloud security"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
)}
|
||||
|
||||
{!isAdopted ? (
|
||||
<div className="flex items-end gap-x-2">
|
||||
<div className="space-y-4">
|
||||
{/* Add TailScale settings item */}
|
||||
<SettingsItem
|
||||
title="TailScale"
|
||||
badge="Experimental"
|
||||
description="Connect to TailScale VPN network"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{ ((tailScaleConnectionState === "disconnected") || (tailScaleConnectionState === "closed")) && (
|
||||
<Button
|
||||
onClick={() => onCloudAdoptClick(cloudApiUrl, cloudAppUrl)}
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Adopt KVM to Cloud"
|
||||
theme="light"
|
||||
text="Enable TailScale"
|
||||
onClick={handleTailScaleLogin}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
title=""
|
||||
description="TailScale use xEdge server"
|
||||
>
|
||||
<Checkbox
|
||||
checked={tailScaleXEdge}
|
||||
onChange={e => {
|
||||
if (tailScaleConnectionState !== "disconnected") {
|
||||
notifications.error("TailScale is running and this setting cannot be modified");
|
||||
return;
|
||||
}
|
||||
handleTailScaleXEdgeChange(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{tailScaleConnectionState === "connecting" && (
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<p>Connecting...</p>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Canel"
|
||||
onClick={handleTailScaleCanel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{tailScaleConnectionState === "connected" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-x-2 justify-between">
|
||||
{tailScaleLoginUrl && (
|
||||
<p>Login URL: <a href={tailScaleLoginUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400">LoginUrl</a></p>
|
||||
)}
|
||||
{!tailScaleLoginUrl && (
|
||||
<p>Wait to obtain the Login URL</p>
|
||||
)}
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text= { isDisconnecting ? "Quitting..." : "Quit"}
|
||||
onClick={handleTailScaleLogout}
|
||||
disabled={ isDisconnecting === true }
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Your device is adopted to the Cloud
|
||||
</p>
|
||||
<div>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="De-register from Cloud"
|
||||
className="text-red-600"
|
||||
onClick={() => {
|
||||
if (deviceId) {
|
||||
if (
|
||||
window.confirm(
|
||||
"Are you sure you want to de-register this device?",
|
||||
)
|
||||
) {
|
||||
deregisterDevice();
|
||||
}
|
||||
} else {
|
||||
notifications.error("No device ID available");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tailScaleConnectionState === "logined" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-x-2 justify-between">
|
||||
<p>IP: {tailScaleIP}</p>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text= { isDisconnecting ? "Quitting..." : "Quit"}
|
||||
onClick={handleTailScaleLogout}
|
||||
disabled={ isDisconnecting === true }
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tailScaleConnectionState === "closed" && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
<p>Connect fail, please retry</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Add ZeroTier settings item */}
|
||||
<SettingsItem
|
||||
title="ZeroTier"
|
||||
badge="Experimental"
|
||||
description="Connect to ZeroTier VPN network"
|
||||
>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{zeroTierConnectionState === "connecting" && (
|
||||
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||
<p>Connecting...</p>
|
||||
</div>
|
||||
)}
|
||||
{zeroTierConnectionState === "connected" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-x-2 justify-between">
|
||||
<p>Network ID: {zeroTierNetworkID}</p>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Quit"
|
||||
onClick={handleZeroTierLogout}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{zeroTierConnectionState === "logined" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-x-2 justify-between">
|
||||
<p>Network ID: {zeroTierNetworkID}</p>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Quit"
|
||||
onClick={handleZeroTierLogout}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2 justify-between">
|
||||
<p>Network IP: {zeroTierIP}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{zeroTierConnectionState === "closed" && (
|
||||
<div className="flex items-center gap-x-2 justify-between">
|
||||
<p>Connect fail, please retry</p>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Retry"
|
||||
onClick={handleZeroTierLogout}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(zeroTierConnectionState === "disconnected") && (
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
label="Network ID"
|
||||
value={tempNetworkID}
|
||||
onChange={handleZeroTierNetworkIdChange}
|
||||
placeholder="Enter ZeroTier Network ID"
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Join in"
|
||||
onClick={handleZeroTierLogin}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,12 +27,6 @@ export default function SettingsAdvancedRoute() {
|
||||
const settings = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
send("getDevModeState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
const result = resp.result as { enabled: boolean };
|
||||
setDeveloperMode(result.enabled);
|
||||
});
|
||||
|
||||
send("getSSHKeyState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setSSHKey(resp.result as string);
|
||||
@@ -43,11 +37,6 @@ export default function SettingsAdvancedRoute() {
|
||||
setUsbEmulationEnabled(resp.result as boolean);
|
||||
});
|
||||
|
||||
send("getDevChannelState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setDevChannel(resp.result as boolean);
|
||||
});
|
||||
|
||||
send("getLocalLoopbackOnly", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setLocalLoopbackOnly(resp.result as boolean);
|
||||
@@ -101,36 +90,6 @@ export default function SettingsAdvancedRoute() {
|
||||
});
|
||||
}, [send, sshKey]);
|
||||
|
||||
const handleDevModeChange = useCallback(
|
||||
(developerMode: boolean) => {
|
||||
send("setDevModeState", { enabled: developerMode }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDeveloperMode(developerMode);
|
||||
});
|
||||
},
|
||||
[send, setDeveloperMode],
|
||||
);
|
||||
|
||||
const handleDevChannelChange = useCallback(
|
||||
(enabled: boolean) => {
|
||||
send("setDevChannelState", { enabled }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDevChannel(enabled);
|
||||
});
|
||||
},
|
||||
[send, setDevChannel],
|
||||
);
|
||||
|
||||
const applyLoopbackOnlyMode = useCallback(
|
||||
(enabled: boolean) => {
|
||||
send("setLocalLoopbackOnly", { enabled }, resp => {
|
||||
@@ -181,63 +140,6 @@ export default function SettingsAdvancedRoute() {
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Dev Channel Updates"
|
||||
description="Receive early updates from the development channel"
|
||||
>
|
||||
<Checkbox
|
||||
checked={devChannel}
|
||||
onChange={e => {
|
||||
handleDevChannelChange(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
title="Developer Mode"
|
||||
description="Enable advanced features for developers"
|
||||
>
|
||||
<Checkbox
|
||||
checked={settings.developerMode}
|
||||
onChange={e => handleDevModeChange(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{settings.developerMode && (
|
||||
<GridCard>
|
||||
<div className="flex items-start gap-x-4 p-4 select-none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
Developer Mode Enabled
|
||||
</h3>
|
||||
<div>
|
||||
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
|
||||
<li>Security is weakened while active</li>
|
||||
<li>Only use if you understand the risks</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-700 dark:text-slate-300">
|
||||
For advanced users only. Not for production use.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
)}
|
||||
|
||||
<SettingsItem
|
||||
title="Loopback-Only Mode"
|
||||
description="Restrict web interface access to localhost only (127.0.0.1)"
|
||||
@@ -248,7 +150,7 @@ export default function SettingsAdvancedRoute() {
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{isOnDevice && settings.developerMode && (
|
||||
{isOnDevice && (
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="SSH Access"
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function SettingsAppearanceRoute() {
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Appearance"
|
||||
description="Customize the look and feel of your JetKVM interface"
|
||||
description="Customize the look and feel of your KVM interface"
|
||||
/>
|
||||
<SettingsItem title="Theme" description="Choose your preferred color theme">
|
||||
<SelectMenuBasic
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function SettingsCtrlAltDelRoute() {
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Action Bar"
|
||||
description="Customize the action bar of your JetKVM interface"
|
||||
description="Customize the action bar of your KVM interface"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem title="Enable Ctrl-Alt-Del" description="Enable the Ctrl-Alt-Del key on the virtual keyboard">
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function SettingsGeneralRoute() {
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="mt-2 flex items-center justify-between gap-x-2">
|
||||
<SettingsItem
|
||||
title="Check for Updates"
|
||||
title="Version"
|
||||
description={
|
||||
currentVersions ? (
|
||||
<>
|
||||
@@ -79,7 +79,7 @@ export default function SettingsGeneralRoute() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="hidden space-y-4">
|
||||
<SettingsItem
|
||||
title="Auto Update"
|
||||
description="Automatically update the device to the latest version"
|
||||
|
||||
@@ -9,6 +9,13 @@ import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
|
||||
import notifications from "@/notifications";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
|
||||
const updateSourceOptions = [
|
||||
{ value: "github", label:"github"},
|
||||
{ value: "gitee", label:"gitee"},
|
||||
];
|
||||
|
||||
export default function SettingsGeneralUpdateRoute() {
|
||||
const navigate = useNavigate();
|
||||
@@ -49,6 +56,12 @@ export interface SystemVersionInfo {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LocalVersionInfo {
|
||||
appVersion: string;
|
||||
systemVersion: string;
|
||||
}
|
||||
|
||||
|
||||
export function Dialog({
|
||||
onClose,
|
||||
onConfirmUpdate,
|
||||
@@ -435,6 +448,23 @@ function UpdateAvailableState({
|
||||
onConfirmUpdate: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
const [updateSource, setUpdateSource] = useState("github");
|
||||
const handleUpdateSourceChange = (source: string) => {
|
||||
send("setUpdateSource", { source: source }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set stream quality: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.success(`Update source set to ${updateSourceOptions.find(x => x.value === source)?.label}`);
|
||||
setUpdateSource(source);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="text-left">
|
||||
@@ -460,9 +490,27 @@ function UpdateAvailableState({
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<div className="flex items-center justify-start gap-x-2">
|
||||
<Button size="SM" theme="primary" text="Update Now" onClick={onConfirmUpdate} />
|
||||
<Button size="SM" theme="light" text="Do it later" onClick={onClose} />
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Update Source"
|
||||
description="Select the update source"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={updateSource}
|
||||
options={updateSourceOptions}
|
||||
onChange={e => handleUpdateSourceChange(e.target.value)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-start gap-x-2">
|
||||
<Button size="SM" theme="primary" text="Update Now" onClick={onConfirmUpdate} />
|
||||
<Button size="SM" theme="light" text="Do it later" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useCallback } from "react";
|
||||
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { SettingsItem } from "@routes/devices.$id.settings";
|
||||
@@ -6,6 +6,8 @@ import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
||||
import { InputField } from "@/components/InputField";
|
||||
import { Button, LinkButton } from "@/components/Button";
|
||||
|
||||
import notifications from "../notifications";
|
||||
import { UsbInfoSetting } from "../components/UsbInfoSetting";
|
||||
@@ -71,11 +73,96 @@ export default function SettingsHardwareRoute() {
|
||||
});
|
||||
}, [send, setBacklightSettings]);
|
||||
|
||||
const setTimeZone = useSettingsStore(state => state.setTimeZone);
|
||||
|
||||
const handleTimeZoneSave = () => {
|
||||
send("setTimeZone", { timeZone: settings.timeZone }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set time zone: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("Time zone updated successfully");
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeZoneChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.trim();
|
||||
setTimeZone(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
send("getTimeZone", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
return notifications.error(
|
||||
`Failed to get time zone: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
console.log("Time zone:", resp.result);
|
||||
const result = resp.result as string;
|
||||
setTimeZone(result);
|
||||
});
|
||||
}, [send, setTimeZone]);
|
||||
|
||||
const setLedGreenMode = useSettingsStore(state => state.setLedGreenMode);
|
||||
const setLedYellowMode = useSettingsStore(state => state.setLedYellowMode);
|
||||
|
||||
const handleLedGreenModeChange = (mode: string) => {
|
||||
setLedGreenMode(mode);
|
||||
send("setLedGreenMode", { mode }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set LED-Green mode: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("LED-Green mode updated successfully");
|
||||
});
|
||||
};
|
||||
|
||||
const handleLedYellowModeChange = (mode: string) => {
|
||||
setLedYellowMode(mode);
|
||||
send("setLedYellowMode", { mode }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set LED-Yellow mode: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("LED-Yellow mode updated successfully");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
send("getLedGreenMode", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
return notifications.error(
|
||||
`Failed to get LED-Green mode: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
console.log("LED-Green mode:", resp.result);
|
||||
const result = resp.result as string;
|
||||
setLedGreenMode(result);
|
||||
});
|
||||
|
||||
send("getLedYellowMode", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
return notifications.error(
|
||||
`Failed to get LED-Yellow mode: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
console.log("LED-Yellow mode:", resp.result);
|
||||
const result = resp.result as string;
|
||||
setLedYellowMode(result);
|
||||
});
|
||||
}, [send, setLedGreenMode, setLedYellowMode]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Hardware"
|
||||
description="Configure display settings and hardware options for your JetKVM device"
|
||||
description="Configure display settings and hardware options for your KVM device"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
@@ -87,8 +174,10 @@ export default function SettingsHardwareRoute() {
|
||||
label=""
|
||||
value={settings.displayRotation.toString()}
|
||||
options={[
|
||||
{ value: "270", label: "Normal" },
|
||||
{ value: "90", label: "Inverted" },
|
||||
{ value: "180", label: "Normal" },
|
||||
{ value: "90", label: "90" },
|
||||
{ value: "0", label: "180" },
|
||||
{ value: "270", label: "270" },
|
||||
]}
|
||||
onChange={e => {
|
||||
settings.displayRotation = e.target.value;
|
||||
@@ -106,9 +195,9 @@ export default function SettingsHardwareRoute() {
|
||||
value={settings.backlightSettings.max_brightness.toString()}
|
||||
options={[
|
||||
{ value: "0", label: "Off" },
|
||||
{ value: "10", label: "Low" },
|
||||
{ value: "35", label: "Medium" },
|
||||
{ value: "64", label: "High" },
|
||||
{ value: "64", label: "Low" },
|
||||
{ value: "128", label: "Medium" },
|
||||
{ value: "200", label: "High" },
|
||||
]}
|
||||
onChange={e => {
|
||||
settings.backlightSettings.max_brightness = parseInt(e.target.value);
|
||||
@@ -170,11 +259,78 @@ export default function SettingsHardwareRoute() {
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||
The display will wake up when the connection state changes, or when touched.
|
||||
</p>
|
||||
|
||||
</>
|
||||
)}
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||
The display will wake up when the connection state changes, or when touched.
|
||||
</p>
|
||||
|
||||
<SettingsItem
|
||||
title="Time Zone"
|
||||
description="Set the time zone for the clock"
|
||||
>
|
||||
</SettingsItem>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputField
|
||||
size="SM"
|
||||
value={settings.timeZone.toString()}
|
||||
onChange={handleTimeZoneChange}
|
||||
placeholder="Enter Time Zone"
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Set"
|
||||
onClick={handleTimeZoneSave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsItem
|
||||
title="LED-Green Type"
|
||||
description="Set the type of system status indicated by the LED-Green"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={settings.ledGreenMode.toString()}
|
||||
options={[
|
||||
{ value: "network-link", label: "network-link" },
|
||||
{ value: "network-tx", label: "network-tx" },
|
||||
{ value: "network-rx", label: "network-rx" },
|
||||
{ value: "kernel-activity", label: "kernel-activity" },
|
||||
]}
|
||||
onChange={e => {
|
||||
settings.ledGreenMode = e.target.value;
|
||||
handleLedGreenModeChange(settings.ledGreenMode);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
title="LED-Yellow Type"
|
||||
description="Set the type of system status indicated by the LED-Yellow"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={settings.ledYellowMode.toString()}
|
||||
options={[
|
||||
{ value: "network-link", label: "network-link" },
|
||||
{ value: "network-tx", label: "network-tx" },
|
||||
{ value: "network-rx", label: "network-rx" },
|
||||
{ value: "kernel-activity", label: "kernel-activity" },
|
||||
]}
|
||||
onChange={e => {
|
||||
settings.ledYellowMode = e.target.value;
|
||||
handleLedYellowModeChange(settings.ledYellowMode);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
</div>
|
||||
|
||||
<FeatureFlag minAppVersion="0.3.8">
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function SettingsKeyboardRoute() {
|
||||
/>
|
||||
</SettingsItem>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||
Pasting text sends individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in JetKVM matches the settings in the operating system.
|
||||
Pasting text sends individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in KVM matches the settings in the operating system.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function SettingsNetworkRoute() {
|
||||
const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false);
|
||||
|
||||
const [customDomain, setCustomDomain] = useState<string>("");
|
||||
const [selectedDomainOption, setSelectedDomainOption] = useState<string>("dhcp");
|
||||
const [selectedDomainOption, setSelectedDomainOption] = useState<string>("local");
|
||||
|
||||
useEffect(() => {
|
||||
if (networkSettings.domain && networkSettingsLoaded) {
|
||||
@@ -243,7 +243,8 @@ export default function SettingsNetworkRoute() {
|
||||
<InputField
|
||||
size="SM"
|
||||
type="text"
|
||||
placeholder="jetkvm"
|
||||
value={networkSettings.hostname}
|
||||
placeholder={networkSettings.hostname}
|
||||
defaultValue={networkSettings.hostname}
|
||||
onChange={e => {
|
||||
handleHostnameChange(e.target.value);
|
||||
|
||||
@@ -15,7 +15,7 @@ const defaultEdid =
|
||||
const edids = [
|
||||
{
|
||||
value: defaultEdid,
|
||||
label: "JetKVM Default",
|
||||
label: "KVM Default",
|
||||
},
|
||||
{
|
||||
value:
|
||||
@@ -40,9 +40,16 @@ const streamQualityOptions = [
|
||||
{ value: "0.1", label: "Low" },
|
||||
];
|
||||
|
||||
const audioModeOptions = [
|
||||
{ value: "disabled", label: "Disabled"},
|
||||
{ value: "usb", label: "USB"},
|
||||
//{ value: "hdmi", label: "HDMI"},
|
||||
]
|
||||
|
||||
export default function SettingsVideoRoute() {
|
||||
const [send] = useJsonRpc();
|
||||
const [streamQuality, setStreamQuality] = useState("1");
|
||||
const [audioMode, setAudioMode] = useState("disabled");
|
||||
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
||||
const [edid, setEdid] = useState<string | null>(null);
|
||||
|
||||
@@ -55,6 +62,11 @@ export default function SettingsVideoRoute() {
|
||||
const setVideoContrast = useSettingsStore(state => state.setVideoContrast);
|
||||
|
||||
useEffect(() => {
|
||||
send("getAudioMode", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setAudioMode(String(resp.result));
|
||||
});
|
||||
|
||||
send("getStreamQualityFactor", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setStreamQuality(String(resp.result));
|
||||
@@ -84,6 +96,20 @@ export default function SettingsVideoRoute() {
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleAudioModeChange = (mode: string) => {
|
||||
send("setAudioMode", { mode }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set Audio Mode: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.success(`Audio Mode set to ${audioModeOptions.find(x => x.value === mode )?.label}.It takes effect after refreshing the page`);
|
||||
setAudioMode(mode);
|
||||
});
|
||||
};
|
||||
|
||||
const handleStreamQualityChange = (factor: string) => {
|
||||
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
|
||||
if ("error" in resp) {
|
||||
@@ -123,6 +149,20 @@ export default function SettingsVideoRoute() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Audio Mode"
|
||||
badge="Experimental"
|
||||
description="Set the working mode of the audio"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={audioMode}
|
||||
options={audioModeOptions}
|
||||
onChange={e => handleAudioModeChange(e.target.value)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
title="Stream Quality"
|
||||
description="Adjust the quality of the video stream"
|
||||
@@ -169,7 +209,7 @@ export default function SettingsVideoRoute() {
|
||||
step="0.1"
|
||||
value={videoBrightness}
|
||||
onChange={e => setVideoBrightness(parseFloat(e.target.value))}
|
||||
className="w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
|
||||
className="w-32 h-2 appearance-none bg-gray-200 dark:bg-gray-700 rounded-lg cursor-pointer"
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
|
||||
@@ -16,37 +16,9 @@ import Fieldset from "@components/Fieldset";
|
||||
import { InputFieldWithLabel } from "@components/InputField";
|
||||
import { Button } from "@components/Button";
|
||||
import { checkAuth } from "@/main";
|
||||
import { CLOUD_API } from "@/ui.config";
|
||||
|
||||
import api from "../api";
|
||||
|
||||
const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
await checkAuth();
|
||||
const res = await fetch(`${CLOUD_API}/devices/${params.id}`, {
|
||||
method: "GET",
|
||||
mode: "cors",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
} else {
|
||||
return redirect("/devices");
|
||||
}
|
||||
};
|
||||
|
||||
const action = async ({ request }: ActionFunctionArgs) => {
|
||||
// Handle form submission
|
||||
const { name, id, returnTo } = Object.fromEntries(await request.formData());
|
||||
const res = await api.PUT(`${CLOUD_API}/devices/${id}`, { name });
|
||||
|
||||
if (res.ok) {
|
||||
return redirect(returnTo?.toString() ?? `/devices/${id}`);
|
||||
} else {
|
||||
return { error: "There was an error creating your device" };
|
||||
}
|
||||
};
|
||||
|
||||
export default function SetupRoute() {
|
||||
const action = useActionData() as { error?: string };
|
||||
const { id } = useParams() as { id: string };
|
||||
@@ -105,6 +77,3 @@ export default function SetupRoute() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
SetupRoute.loader = loader;
|
||||
SetupRoute.action = action;
|
||||
|
||||
@@ -39,7 +39,7 @@ import DashboardNavbar from "@components/Header";
|
||||
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
|
||||
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import Terminal from "@components/Terminal";
|
||||
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||
import { DEVICE_API } from "@/ui.config";
|
||||
|
||||
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
|
||||
import api from "../api";
|
||||
@@ -54,7 +54,10 @@ import { FeatureFlagProvider } from "../providers/FeatureFlagProvider";
|
||||
import notifications from "../notifications";
|
||||
|
||||
import { DeviceStatus } from "./welcome-local";
|
||||
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
|
||||
import { SystemVersionInfo, LocalVersionInfo } from "./devices.$id.settings.general.update";
|
||||
|
||||
import { useVpnStore } from "@/hooks/stores";
|
||||
|
||||
|
||||
interface LocalLoaderResp {
|
||||
authMode: "password" | "noPassword" | null;
|
||||
@@ -74,12 +77,25 @@ export interface LocalDevice {
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
interface TailScaleResponse {
|
||||
state: string;
|
||||
loginUrl: string;
|
||||
ip: string;
|
||||
xEdge: boolean;
|
||||
}
|
||||
|
||||
interface ZeroTierResponse {
|
||||
state: string;
|
||||
networkID: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
const deviceLoader = async () => {
|
||||
const res = await api
|
||||
.GET(`${DEVICE_API}/device/status`)
|
||||
.then(res => res.json() as Promise<DeviceStatus>);
|
||||
|
||||
if (!res.isSetup) return redirect("/welcome");
|
||||
if (!res.isSetup) return redirect("/mode");
|
||||
|
||||
const deviceRes = await api.GET(`${DEVICE_API}/device`);
|
||||
if (deviceRes.status === 401) return redirect("/login-local");
|
||||
@@ -91,31 +107,9 @@ const deviceLoader = async () => {
|
||||
throw new Error("Error fetching device");
|
||||
};
|
||||
|
||||
const cloudLoader = async (params: Params<string>): Promise<CloudLoaderResp> => {
|
||||
const user = await checkAuth();
|
||||
|
||||
const iceResp = await api.POST(`${CLOUD_API}/webrtc/ice_config`);
|
||||
const iceConfig = await iceResp.json();
|
||||
|
||||
const deviceResp = await api.GET(`${CLOUD_API}/devices/${params.id}`);
|
||||
|
||||
if (!deviceResp.ok) {
|
||||
if (deviceResp.status === 404) {
|
||||
throw new Response("Device not found", { status: 404 });
|
||||
}
|
||||
|
||||
throw new Error("Error fetching device");
|
||||
}
|
||||
|
||||
const { device } = (await deviceResp.json()) as {
|
||||
device: { id: string; name: string; user: { googleId: string } };
|
||||
};
|
||||
|
||||
return { user, iceConfig, deviceName: device.name || device.id };
|
||||
};
|
||||
|
||||
const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
return import.meta.env.MODE === "device" ? deviceLoader() : cloudLoader(params);
|
||||
const loader = async ({}: LoaderFunctionArgs) => {
|
||||
return deviceLoader();
|
||||
};
|
||||
|
||||
export default function KvmIdRoute() {
|
||||
@@ -139,6 +133,7 @@ export default function KvmIdRoute() {
|
||||
const setDiskChannel = useRTCStore(state => state.setDiskChannel);
|
||||
const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel);
|
||||
const setTransceiver = useRTCStore(state => state.setTransceiver);
|
||||
const setAudioTransceiver = useRTCStore(state => state.setAudioTransceiver);
|
||||
const location = useLocation();
|
||||
|
||||
const isLegacySignalingEnabled = useRef(false);
|
||||
@@ -238,9 +233,7 @@ export default function KvmIdRoute() {
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
const { sendMessage, getWebSocket } = useWebSocket(
|
||||
isOnDevice
|
||||
? `${wsProtocol}//${window.location.host}/webrtc/signaling/client`
|
||||
: `${CLOUD_API.replace("http", "ws")}/webrtc/signaling/client?id=${params.id}`,
|
||||
`${wsProtocol}//${window.location.host}/webrtc/signaling/client?id=${params.id}`,
|
||||
{
|
||||
heartbeat: true,
|
||||
retryOnError: true,
|
||||
@@ -358,42 +351,6 @@ export default function KvmIdRoute() {
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
const legacyHTTPSignaling = useCallback(
|
||||
async (pc: RTCPeerConnection) => {
|
||||
const sd = btoa(JSON.stringify(pc.localDescription));
|
||||
|
||||
// Legacy mode == UI in cloud with updated code connecting to older device version.
|
||||
// In device mode, old devices wont server this JS, and on newer devices legacy mode wont be enabled
|
||||
const sessionUrl = `${CLOUD_API}/webrtc/session`;
|
||||
|
||||
console.log("Trying to get remote session description");
|
||||
setLoadingMessage(
|
||||
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
|
||||
);
|
||||
const res = await api.POST(sessionUrl, {
|
||||
sd,
|
||||
// When on device, we don't need to specify the device id, as it's already known
|
||||
...(isOnDevice ? {} : { id: params.id }),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
if (res.status === 401) return navigate(isOnDevice ? "/login-local" : "/login");
|
||||
if (!res.ok) {
|
||||
console.error("Error getting SDP", { status: res.status, json });
|
||||
cleanupAndStopReconnecting();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Successfully got Remote Session Description. Setting.");
|
||||
setLoadingMessage("Setting remote session description...");
|
||||
|
||||
const decodedSd = atob(json.sd);
|
||||
const parsedSd = JSON.parse(decodedSd);
|
||||
setRemoteSessionDescription(pc, new RTCSessionDescription(parsedSd));
|
||||
},
|
||||
[cleanupAndStopReconnecting, navigate, params.id, setRemoteSessionDescription],
|
||||
);
|
||||
|
||||
const setupPeerConnection = useCallback(async () => {
|
||||
console.log("[setupPeerConnection] Setting up peer connection");
|
||||
setConnectionFailed(false);
|
||||
@@ -464,10 +421,6 @@ export default function KvmIdRoute() {
|
||||
console.log("ICE Gathering completed");
|
||||
setLoadingMessage("ICE Gathering completed");
|
||||
|
||||
if (isLegacySignalingEnabled.current) {
|
||||
// We can now start the https/ws connection to get the remote session description from the KVM device
|
||||
legacyHTTPSignaling(pc);
|
||||
}
|
||||
} else if (pc.iceGatheringState === "gathering") {
|
||||
console.log("ICE Gathering Started");
|
||||
setLoadingMessage("Gathering ICE candidates...");
|
||||
@@ -479,6 +432,7 @@ export default function KvmIdRoute() {
|
||||
};
|
||||
|
||||
setTransceiver(pc.addTransceiver("video", { direction: "recvonly" }));
|
||||
pc.addTransceiver("audio", { direction: "recvonly" });
|
||||
|
||||
const rpcDataChannel = pc.createDataChannel("rpc");
|
||||
rpcDataChannel.onopen = () => {
|
||||
@@ -494,7 +448,6 @@ export default function KvmIdRoute() {
|
||||
}, [
|
||||
cleanupAndStopReconnecting,
|
||||
iceConfig?.iceServers,
|
||||
legacyHTTPSignaling,
|
||||
sendWebRTCSignal,
|
||||
setDiskChannel,
|
||||
setMediaMediaStream,
|
||||
@@ -502,6 +455,7 @@ export default function KvmIdRoute() {
|
||||
setPeerConnectionState,
|
||||
setRpcDataChannel,
|
||||
setTransceiver,
|
||||
setAudioTransceiver,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -547,40 +501,57 @@ export default function KvmIdRoute() {
|
||||
|
||||
setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn);
|
||||
}, [peerConnectionState, setIsTurnServerInUse]);
|
||||
|
||||
// Vpn State Update
|
||||
const tailScaleConnectionState = useVpnStore(state => state.tailScaleConnectionState);
|
||||
const setTailScaleConnectionState = useVpnStore(state => state.setTailScaleConnectionState);
|
||||
const tailScaleXEdge = useVpnStore(state => state.tailScaleXEdge);
|
||||
const setTailScaleXEdge = useVpnStore(state => state.setTailScaleXEdge);
|
||||
const setTailScaleLoginUrl = useVpnStore(state => state.setTailScaleLoginUrl);
|
||||
|
||||
const setTailScaleIP = useVpnStore(state => state.setTailScaleIP);
|
||||
|
||||
// TURN server usage reporting
|
||||
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
|
||||
const lastBytesReceived = useRef<number>(0);
|
||||
const lastBytesSent = useRef<number>(0);
|
||||
const zeroTierConnectionState = useVpnStore(state => state.zeroTierConnectionState);
|
||||
const zeroTierNetworkID = useVpnStore(state => state.zeroTierNetworkID);
|
||||
const setZeroTierConnectionState = useVpnStore(state => state.setZeroTierConnectionState);
|
||||
const setZeroTierNetworkID = useVpnStore(state => state.setZeroTierNetworkID);
|
||||
const setZeroTierIP = useVpnStore(state => state.setZeroTierIP);
|
||||
|
||||
const updateVpnStates = () => {
|
||||
// TailScaleState
|
||||
if (tailScaleConnectionState !== "connecting" && tailScaleConnectionState !== "closed") {
|
||||
send("getTailScaleSettings", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
const result = resp.result as TailScaleResponse;
|
||||
const validState = ["closed", "connecting", "connected", "disconnected", "logined"].includes(result.state)
|
||||
? result.state as "closed" | "connecting" | "connected" | "disconnected" | "logined"
|
||||
: "closed";
|
||||
|
||||
useInterval(() => {
|
||||
// Don't report usage if we're not using the turn server
|
||||
if (!isTurnServerInUse) return;
|
||||
const { candidatePairStats } = useRTCStore.getState();
|
||||
|
||||
const lastCandidatePair = Array.from(candidatePairStats).pop();
|
||||
const report = lastCandidatePair?.[1];
|
||||
if (!report) return;
|
||||
|
||||
let bytesReceivedDelta = 0;
|
||||
let bytesSentDelta = 0;
|
||||
|
||||
if (report.bytesReceived) {
|
||||
bytesReceivedDelta = report.bytesReceived - lastBytesReceived.current;
|
||||
lastBytesReceived.current = report.bytesReceived;
|
||||
if(tailScaleConnectionState !== "disconnected" ) {
|
||||
setTailScaleXEdge(result.xEdge);
|
||||
}
|
||||
setTailScaleConnectionState(validState);
|
||||
setTailScaleLoginUrl(result.loginUrl);
|
||||
setTailScaleIP(result.ip);
|
||||
});
|
||||
}
|
||||
|
||||
if (report.bytesSent) {
|
||||
bytesSentDelta = report.bytesSent - lastBytesSent.current;
|
||||
lastBytesSent.current = report.bytesSent;
|
||||
|
||||
// ZeroTier
|
||||
if (zeroTierConnectionState !== "connecting" && zeroTierConnectionState !== "closed") {
|
||||
send("getZeroTierSettings", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
const result = resp.result as ZeroTierResponse;
|
||||
const validState = ["closed", "connecting", "connected", "disconnected", "logined"].includes(result.state)
|
||||
? result.state as "closed" | "connecting" | "connected" | "disconnected" | "logined"
|
||||
: "closed";
|
||||
setZeroTierConnectionState(validState);
|
||||
setZeroTierNetworkID(result.networkID);
|
||||
setZeroTierIP(result.ip);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fire and forget
|
||||
api.POST(`${CLOUD_API}/webrtc/turn_activity`, {
|
||||
bytesReceived: bytesReceivedDelta,
|
||||
bytesSent: bytesSentDelta,
|
||||
});
|
||||
}, 10000);
|
||||
useInterval(updateVpnStates, 5000);
|
||||
|
||||
const setNetworkState = useNetworkStateStore(state => state.setNetworkState);
|
||||
|
||||
@@ -654,6 +625,7 @@ export default function KvmIdRoute() {
|
||||
if ("error" in resp) return;
|
||||
setHdmiState(resp.result as Parameters<VideoState["setHdmiState"]>[0]);
|
||||
});
|
||||
updateVpnStates();
|
||||
}, [rpcDataChannel?.readyState, send, setHdmiState]);
|
||||
|
||||
// request keyboard led state from the device
|
||||
@@ -714,14 +686,20 @@ export default function KvmIdRoute() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!peerConnection) return;
|
||||
if (!kvmTerminal) {
|
||||
setKvmTerminal(peerConnection.createDataChannel("terminal"));
|
||||
}
|
||||
//if (!kvmTerminal) {
|
||||
// setKvmTerminal(peerConnection.createDataChannel("terminal"));
|
||||
//}
|
||||
|
||||
if (!serialConsole) {
|
||||
setSerialConsole(peerConnection.createDataChannel("serial"));
|
||||
}
|
||||
}, [kvmTerminal, peerConnection, serialConsole]);
|
||||
//if (!serialConsole) {
|
||||
// setSerialConsole(peerConnection.createDataChannel("serial"));
|
||||
//}
|
||||
const terminalChannel = peerConnection.createDataChannel("terminal");
|
||||
setKvmTerminal(terminalChannel);
|
||||
const serialChannel = peerConnection.createDataChannel("serial");
|
||||
setSerialConsole(serialChannel);
|
||||
|
||||
//}, [kvmTerminal, peerConnection, serialConsole]);
|
||||
}, [peerConnection]);
|
||||
|
||||
const outlet = useOutlet();
|
||||
const onModalClose = useCallback(() => {
|
||||
@@ -735,19 +713,15 @@ export default function KvmIdRoute() {
|
||||
useEffect(() => {
|
||||
if (appVersion) return;
|
||||
|
||||
send("getUpdateStatus", {}, async resp => {
|
||||
send("getLocalUpdateStatus", {}, async resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to get device version: ${resp.error}`);
|
||||
return
|
||||
}
|
||||
|
||||
const result = resp.result as SystemVersionInfo;
|
||||
if (result.error) {
|
||||
notifications.error(`Failed to get device version: ${result.error}`);
|
||||
}
|
||||
|
||||
setAppVersion(result.local.appVersion);
|
||||
setSystemVersion(result.local.systemVersion);
|
||||
const result = resp.result as LocalVersionInfo;
|
||||
setAppVersion(result.appVersion);
|
||||
setSystemVersion(result.systemVersion);
|
||||
});
|
||||
}, [appVersion, send, setAppVersion, setSystemVersion]);
|
||||
|
||||
@@ -824,7 +798,7 @@ export default function KvmIdRoute() {
|
||||
isLoggedIn={authMode === "password" || !!user}
|
||||
userEmail={user?.email}
|
||||
picture={user?.picture}
|
||||
kvmName={deviceName ?? "JetKVM Device"}
|
||||
kvmName={deviceName ?? "KVM Device"}
|
||||
/>
|
||||
|
||||
<div className="relative flex h-full w-full overflow-hidden">
|
||||
|
||||
@@ -9,31 +9,12 @@ import KvmCard from "@components/KvmCard";
|
||||
import { LinkButton } from "@components/Button";
|
||||
import { User } from "@/hooks/stores";
|
||||
import { checkAuth } from "@/main";
|
||||
import { CLOUD_API } from "@/ui.config";
|
||||
|
||||
interface LoaderData {
|
||||
devices: { id: string; name: string; online: boolean; lastSeen: string }[];
|
||||
user: User;
|
||||
}
|
||||
|
||||
const loader = async () => {
|
||||
const user = await checkAuth();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${CLOUD_API}/devices`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
const { devices } = await res.json();
|
||||
return { devices, user };
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { devices: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export default function DevicesRoute() {
|
||||
const { devices, user } = useLoaderData() as LoaderData;
|
||||
const revalidate = useRevalidator();
|
||||
@@ -66,10 +47,10 @@ export default function DevicesRoute() {
|
||||
<EmptyCard
|
||||
IconElm={LuMonitorSmartphone}
|
||||
headline="No devices found"
|
||||
description="You don't have any devices with enabled JetKVM Cloud yet."
|
||||
description="You don't have any devices with enabled KVM Cloud yet."
|
||||
BtnElm={
|
||||
<LinkButton
|
||||
to="https://jetkvm.com/docs/networking/remote-access"
|
||||
to="https://wiki.luckfox.com/intro"
|
||||
size="SM"
|
||||
theme="primary"
|
||||
TrailingIcon={ArrowRightIcon}
|
||||
@@ -101,5 +82,3 @@ export default function DevicesRoute() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DevicesRoute.loader = loader;
|
||||
|
||||
@@ -8,8 +8,7 @@ import Container from "@components/Container";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import { InputFieldWithLabel } from "@components/InputField";
|
||||
import { Button } from "@components/Button";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import LogoLuckfox from "@/assets/logo-luckfox.png";
|
||||
import { DEVICE_API } from "@/ui.config";
|
||||
|
||||
import api from "../api";
|
||||
@@ -63,19 +62,19 @@ export default function LoginLocalRoute() {
|
||||
<div className="-mt-32 max-w-2xl space-y-8">
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
src={LogoWhiteIcon}
|
||||
src={LogoLuckfox}
|
||||
alt=""
|
||||
className="-ml-4 hidden h-[32px] dark:block"
|
||||
/>
|
||||
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
||||
<img src={LogoLuckfox} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">
|
||||
Welcome back to JetKVM
|
||||
Welcome back to KVM
|
||||
</h1>
|
||||
<p className="font-medium text-slate-600 dark:text-slate-400">
|
||||
Enter your password to access your JetKVM.
|
||||
Enter your password to access your KVM.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -120,7 +119,7 @@ export default function LoginLocalRoute() {
|
||||
|
||||
<div className="mt-4 flex justify-start text-xs text-slate-500 dark:text-slate-400">
|
||||
<ExtLink
|
||||
href="https://jetkvm.com/docs/networking/local-access#reset-password"
|
||||
href="https://wiki.luckfox.com/intro"
|
||||
className="hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
|
||||
@@ -11,9 +11,8 @@ export default function LoginRoute() {
|
||||
return (
|
||||
<AuthLayout
|
||||
showCounter={true}
|
||||
title="Connect your JetKVM to the cloud"
|
||||
title="Connect your KVM to the cloud"
|
||||
description="Unlock remote access and advanced features for your device"
|
||||
action="Log in & Connect device"
|
||||
// Header CTA
|
||||
cta="Don't have an account?"
|
||||
ctaHref={`/signup?${sq.toString()}`}
|
||||
@@ -23,11 +22,10 @@ export default function LoginRoute() {
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Log in to your JetKVM account"
|
||||
title="Log in to your KVM account"
|
||||
description="Log in to access and manage your devices securely"
|
||||
action="Log in"
|
||||
// Header CTA
|
||||
cta="New to JetKVM?"
|
||||
cta="New to KVM?"
|
||||
ctaHref={`/signup?${sq.toString()}`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -11,9 +11,8 @@ export default function SignupRoute() {
|
||||
return (
|
||||
<AuthLayout
|
||||
showCounter={true}
|
||||
title="Connect your JetKVM to the cloud"
|
||||
title="Connect your KVM to the cloud"
|
||||
description="Unlock remote access and advanced features for your device."
|
||||
action="Signup & Connect device"
|
||||
cta="Already have an account?"
|
||||
ctaHref={`/login?${sq.toString()}`}
|
||||
/>
|
||||
@@ -22,9 +21,8 @@ export default function SignupRoute() {
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Create your JetKVM account"
|
||||
title="Create your KVM account"
|
||||
description="Create your account and start managing your devices with ease."
|
||||
action="Create Account"
|
||||
// Header CTA
|
||||
cta="Already have an account?"
|
||||
ctaHref={`/login?${sq.toString()}`}
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useState } from "react";
|
||||
import GridBackground from "@components/GridBackground";
|
||||
import Container from "@components/Container";
|
||||
import { Button } from "@components/Button";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import LogoLuckfox from "@/assets/logo-luckfox.png";
|
||||
import { DEVICE_API } from "@/ui.config";
|
||||
|
||||
import { GridCard } from "../components/Card";
|
||||
@@ -29,7 +28,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
|
||||
if (!localAuthMode) return { error: "Please select an authentication mode" };
|
||||
|
||||
if (localAuthMode === "password") {
|
||||
return redirect("/welcome/password");
|
||||
return redirect("/mode/password");
|
||||
}
|
||||
|
||||
if (localAuthMode === "noPassword") {
|
||||
@@ -62,11 +61,11 @@ export default function WelcomeLocalModeRoute() {
|
||||
<div className="max-w-xl space-y-8">
|
||||
<div className="animate-fadeIn flex items-center justify-center opacity-0">
|
||||
<img
|
||||
src={LogoWhiteIcon}
|
||||
src={LogoLuckfox}
|
||||
alt=""
|
||||
className="-ml-4 hidden h-[32px] dark:block"
|
||||
/>
|
||||
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
||||
<img src={LogoLuckfox} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -77,7 +76,7 @@ export default function WelcomeLocalModeRoute() {
|
||||
Local Authentication Method
|
||||
</h1>
|
||||
<p className="font-medium text-slate-600 dark:text-slate-400">
|
||||
Select how you{"'"}d like to secure your JetKVM device locally.
|
||||
Select how you{"'"}d like to secure your KVM device locally.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ import Container from "@components/Container";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import { InputFieldWithLabel } from "@components/InputField";
|
||||
import { Button } from "@components/Button";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import LogoLuckfox from "@/assets/logo-luckfox.png";
|
||||
import { DEVICE_API } from "@/ui.config";
|
||||
|
||||
import api from "../api";
|
||||
@@ -73,11 +72,11 @@ export default function WelcomeLocalPasswordRoute() {
|
||||
<div className="max-w-2xl space-y-8">
|
||||
<div className="animate-fadeIn flex items-center justify-center opacity-0">
|
||||
<img
|
||||
src={LogoWhiteIcon}
|
||||
src={LogoLuckfox}
|
||||
alt=""
|
||||
className="-ml-4 hidden h-[32px] dark:block"
|
||||
/>
|
||||
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
||||
<img src={LogoLuckfox} alt="" className="-ml-4 h-[32px] dark:hidden" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -88,7 +87,7 @@ export default function WelcomeLocalPasswordRoute() {
|
||||
Set a Password
|
||||
</h1>
|
||||
<p className="font-medium text-slate-600 dark:text-slate-400">
|
||||
Create a strong password to secure your JetKVM device locally.
|
||||
Create a strong password to secure your KVM device locally.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,9 +5,7 @@ import { redirect } from "react-router-dom";
|
||||
import GridBackground from "@components/GridBackground";
|
||||
import Container from "@components/Container";
|
||||
import { LinkButton } from "@components/Button";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import DeviceImage from "@/assets/jetkvm-device-still.png";
|
||||
import LogoLuckfox from "@/assets/logo-luckfox.png";
|
||||
import LogoMark from "@/assets/logo-mark.png";
|
||||
import { DEVICE_API } from "@/ui.config";
|
||||
|
||||
@@ -27,19 +25,12 @@ const loader = async () => {
|
||||
};
|
||||
|
||||
export default function WelcomeRoute() {
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.src = DeviceImage;
|
||||
img.onload = () => setImageLoaded(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridBackground />
|
||||
<div className="grid min-h-screen">
|
||||
{imageLoaded && (
|
||||
|
||||
<Container>
|
||||
<div className="isolate flex h-full w-full items-center justify-center">
|
||||
<div className="max-w-3xl text-center">
|
||||
@@ -47,60 +38,51 @@ export default function WelcomeRoute() {
|
||||
<div className="space-y-4">
|
||||
<div className="animate-fadeIn animation-delay-1000 flex items-center justify-center opacity-0">
|
||||
<img
|
||||
src={LogoWhiteIcon}
|
||||
alt="JetKVM Logo"
|
||||
src={LogoLuckfox}
|
||||
alt="KVM Logo"
|
||||
className="hidden h-[32px] dark:block"
|
||||
/>
|
||||
<img
|
||||
src={LogoBlueIcon}
|
||||
alt="JetKVM Logo"
|
||||
src={LogoLuckfox}
|
||||
alt="KVM Logo"
|
||||
className="h-[32px] dark:hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="animate-fadeIn animation-delay-1500 space-y-1 opacity-0">
|
||||
<h1 className="text-4xl font-semibold text-black dark:text-white">
|
||||
Welcome to JetKVM
|
||||
Welcome to KVM
|
||||
</h1>
|
||||
<p className="text-lg font-medium text-slate-600 dark:text-slate-400">
|
||||
Control any computer remotely
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="-mt-2! -ml-6 flex items-center justify-center">
|
||||
<img
|
||||
src={DeviceImage}
|
||||
alt="JetKVM Device"
|
||||
className="animation-delay-300 animate-fadeInScaleFloat max-w-md scale-[0.98] opacity-0 transition-all duration-1000 ease-out"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-8 space-y-4">
|
||||
<p
|
||||
style={{ animationDelay: "2000ms" }}
|
||||
className="animate-fadeIn mx-auto max-w-lg text-lg text-slate-700 opacity-0 dark:text-slate-300"
|
||||
>
|
||||
JetKVM combines powerful hardware with intuitive software to provide a
|
||||
KVM combines powerful hardware with intuitive software to provide a
|
||||
seamless remote control experience.
|
||||
</p>
|
||||
<div className="animate-fadeIn animation-delay-2300 opacity-0">
|
||||
<LinkButton
|
||||
size="LG"
|
||||
theme="light"
|
||||
text="Set up your JetKVM"
|
||||
text="Set up your KVM"
|
||||
LeadingIcon={({ className }) => (
|
||||
<img src={LogoMark} className={cx(className, "mr-1.5 h-5!")} />
|
||||
)}
|
||||
textAlign="center"
|
||||
to="/welcome/mode"
|
||||
to="/mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
export const CLOUD_API = import.meta.env.VITE_CLOUD_API;
|
||||
|
||||
// In device mode, an empty string uses the current hostname (the JetKVM device's IP) as the API endpoint
|
||||
export const DEVICE_API = "";
|
||||
|
||||
Reference in New Issue
Block a user