mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-01-18 03:28:19 +01:00
Move settings to modals & better modal handling (#194)
* feat(ui): Add other session handling route and modal * feat(ui): Add dedicated update route and refactor update dialog state management * feat(ui): Add local authentication route * refactor(ui): Remove LocalAuthPasswordDialog component and clean up related code * refactor(ui): Remove OtherSessionConnectedModal component * feat(ui): Add dedicated mount route and refactor mount media dialog * refactor(ui): Simplify Escape key navigation in device route * refactor(ui): Add TODO comments for future URL-based state migration * refactor(ui): Migrate settings and update routes to dedicated routes This commit introduces a comprehensive refactoring of the UI routing and state management: - Removed sidebar-based settings view - Replaced global modal state with URL-based routing - Added dedicated routes for settings, including general, security, and update sections - Simplified modal and sidebar interactions - Improved animation and transition handling using motion library - Removed deprecated components and simplified route structure * fix(ui): Add TODO comment for modal session interaction * refactor(ui): Move USB configuration to new settings setup This commit introduces several improvements to the USB configuration workflow: - Refactored USB configuration dialog component - Simplified USB config state management - Moved USB configuration to hardware settings route - Updated JSON-RPC type definitions - Cleaned up unused imports and components - Improved error handling and notifications * refactor(ui): Replace react-router-dom navigation with custom navigation hook This commit introduces a new custom navigation hook `useDeviceUiNavigation` to replace direct usage of `useNavigate` across multiple components: - Removed direct `useNavigate` imports in various components - Added `navigateTo` method from new navigation hook - Updated navigation calls in ActionBar, MountPopover, UpdateInProgressStatusCard, and other routes - Simplified navigation logic and prepared for potential future navigation enhancements - Removed console logs and unnecessary comments * refactor(ui): Remove unused react-router-dom import Clean up unnecessary import of `useNavigate` from react-router-dom in device settings route * feat(ui): Improve mobile navigation and scrolling in device settings * refactor(ui): Reorganize device access and security settings This commit introduces several changes to the device access and security settings: - Renamed "Security" section to "Access" in settings navigation - Moved local authentication routes from security to access - Removed deprecated security settings route - Added new route for device access settings with cloud and local authentication management - Updated cloud URL and adoption logic to be part of the access settings - Simplified routing and component structure for better user experience * fix(ui): Update logout button hover state color * fix(ui): Adjust device de-registration button size to small * fix(ui): Update appearance settings section header and description * refactor(ui): Replace SectionHeader with new SettingsPageHeader and SettingsSectionHeader components This commit introduces two new header components for settings pages: - Created SettingsPageHeader for main page headers - Created SettingsSectionHeader for subsection headers - Replaced all existing SectionHeader imports with new components - Updated styling and type definitions to support more flexible header rendering * feat(ui): Add dev channel toggle to advanced settings Move dev channel update option from general settings to advanced settings - Introduced new state and handler for dev channel toggle - Removed dev channel option from general settings route - Added dev channel toggle in advanced settings with error handling
This commit is contained in:
1593
ui/src/routes/devices.$id.mount.tsx
Normal file
1593
ui/src/routes/devices.$id.mount.tsx
Normal file
File diff suppressed because it is too large
Load Diff
46
ui/src/routes/devices.$id.other-session.tsx
Normal file
46
ui/src/routes/devices.$id.other-session.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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";
|
||||
|
||||
interface ContextType {
|
||||
connectWebRTC: () => Promise<void>;
|
||||
}
|
||||
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||
|
||||
export default function OtherSessionRoute() {
|
||||
const outletContext = useOutletContext<ContextType>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Function to handle closing the modal
|
||||
const handleClose = () => {
|
||||
outletContext?.connectWebRTC().then(() => navigate(".."));
|
||||
};
|
||||
|
||||
return (
|
||||
<GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto">
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="text-base font-semibold dark:text-white">
|
||||
Another Active Session Detected
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
Only one active session is supported at a time. Would you like to take over
|
||||
this session?
|
||||
</p>
|
||||
<div className="flex items-center justify-start space-x-4">
|
||||
<Button size="SM" theme="primary" text="Use Here" onClick={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
5
ui/src/routes/devices.$id.settings._index.tsx
Normal file
5
ui/src/routes/devices.$id.settings._index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "react-router-dom";
|
||||
|
||||
export function loader() {
|
||||
return redirect("/settings/general");
|
||||
}
|
||||
331
ui/src/routes/devices.$id.settings.access._index.tsx
Normal file
331
ui/src/routes/devices.$id.settings.access._index.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
import { useLoaderData } from "react-router-dom";
|
||||
import { Button, LinkButton } from "../components/Button";
|
||||
import { CLOUD_APP, DEVICE_API } from "../ui.config";
|
||||
import api from "../api";
|
||||
import { LocalDevice } from "./devices.$id";
|
||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||
import { isOnDevice } from "../main";
|
||||
import { GridCard } from "../components/Card";
|
||||
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
|
||||
import notifications from "../notifications";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||
import { InputFieldWithLabel } from "../components/InputField";
|
||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||
import { SettingsSectionHeader } from "../components/SettingsSectionHeader";
|
||||
|
||||
export const loader = async () => {
|
||||
const status = await api
|
||||
.GET(`${DEVICE_API}/device`)
|
||||
.then(res => res.json() as Promise<LocalDevice>);
|
||||
return status;
|
||||
};
|
||||
|
||||
export default function SettingsAccessIndexRoute() {
|
||||
const { authMode } = useLoaderData() as LocalDevice;
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const [isAdopted, setAdopted] = useState(false);
|
||||
const [deviceId, setDeviceId] = useState<string | null>(null);
|
||||
const [cloudUrl, setCloudUrl] = useState("");
|
||||
const [cloudProviders, setCloudProviders] = useState<
|
||||
{ value: string; label: string }[] | null
|
||||
>([{ value: "https://api.jetkvm.com", label: "JetKVM Cloud" }]);
|
||||
|
||||
// The default value is just there so it doesn't flicker while we fetch the default Cloud URL and available providers
|
||||
const [selectedUrlOption, setSelectedUrlOption] = useState<string>(
|
||||
"https://api.jetkvm.com",
|
||||
);
|
||||
|
||||
const [defaultCloudUrl, setDefaultCloudUrl] = useState<string>("");
|
||||
|
||||
const syncCloudUrl = useCallback(() => {
|
||||
send("getCloudUrl", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
const url = resp.result as string;
|
||||
setCloudUrl(url);
|
||||
// Check if the URL matches any predefined option
|
||||
if (cloudProviders?.some(provider => provider.value === url)) {
|
||||
setSelectedUrlOption(url);
|
||||
} else {
|
||||
setSelectedUrlOption("custom");
|
||||
// setCustomCloudUrl(url);
|
||||
}
|
||||
});
|
||||
}, [cloudProviders, send]);
|
||||
|
||||
const getCloudState = useCallback(() => {
|
||||
send("getCloudState", {}, resp => {
|
||||
if ("error" in resp) return console.error(resp.error);
|
||||
const cloudState = resp.result as { connected: boolean };
|
||||
setAdopted(cloudState.connected);
|
||||
});
|
||||
}, [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();
|
||||
return;
|
||||
});
|
||||
};
|
||||
|
||||
const onCloudAdoptClick = useCallback(
|
||||
(url: string) => {
|
||||
if (!deviceId) {
|
||||
notifications.error("No device ID available");
|
||||
return;
|
||||
}
|
||||
|
||||
send("setCloudUrl", { url }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
syncCloudUrl();
|
||||
notifications.success("Cloud URL updated successfully");
|
||||
|
||||
const returnTo = new URL(window.location.href);
|
||||
returnTo.pathname = "/adopt";
|
||||
returnTo.search = "";
|
||||
returnTo.hash = "";
|
||||
window.location.href =
|
||||
CLOUD_APP + "/signup?deviceId=" + deviceId + `&returnTo=${returnTo.toString()}`;
|
||||
});
|
||||
},
|
||||
[deviceId, syncCloudUrl, send],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaultCloudUrl) return;
|
||||
setSelectedUrlOption(defaultCloudUrl);
|
||||
setCloudProviders([
|
||||
{ value: defaultCloudUrl, label: "JetKVM Cloud" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
]);
|
||||
}, [defaultCloudUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
getCloudState();
|
||||
|
||||
send("getDeviceID", {}, async resp => {
|
||||
if ("error" in resp) return console.error(resp.error);
|
||||
setDeviceId(resp.result as string);
|
||||
});
|
||||
}, [send, getCloudState]);
|
||||
|
||||
useEffect(() => {
|
||||
send("getDefaultCloudUrl", {}, resp => {
|
||||
if ("error" in resp) return console.error(resp.error);
|
||||
setDefaultCloudUrl(resp.result as string);
|
||||
});
|
||||
}, [cloudProviders, syncCloudUrl, send]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cloudProviders?.length) return;
|
||||
syncCloudUrl();
|
||||
}, [cloudProviders, syncCloudUrl]);
|
||||
|
||||
console.log("is adopted:", isAdopted);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Access"
|
||||
description="Manage the Access Control of the device"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsSectionHeader
|
||||
title="Local"
|
||||
description="Manage the mode of local access to the device"
|
||||
/>
|
||||
<SettingsItem
|
||||
title="Authentication Mode"
|
||||
description={`Current mode: ${authMode === "password" ? "Password protected" : "No password"}`}
|
||||
>
|
||||
{authMode === "password" ? (
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Disable Protection"
|
||||
onClick={() => {
|
||||
navigateTo("./local-auth", { state: { init: "deletePassword" } });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Enable Password"
|
||||
onClick={() => {
|
||||
navigateTo("./local-auth", { state: { init: "createPassword" } });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SettingsItem>
|
||||
|
||||
{authMode === "password" && (
|
||||
<SettingsItem
|
||||
title="Change Password"
|
||||
description="Update your device access password"
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Change Password"
|
||||
onClick={() => {
|
||||
navigateTo("./local-auth", { state: { init: "updatePassword" } });
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
<div className="space-y-4">
|
||||
<SettingsSectionHeader
|
||||
title="Remote"
|
||||
description="Manage the mode of Remote access to the device"
|
||||
/>
|
||||
|
||||
{isOnDevice && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{!isAdopted && (
|
||||
<>
|
||||
<SettingsItem
|
||||
title="Cloud Provider"
|
||||
description="Select the cloud provider for your device"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={selectedUrlOption}
|
||||
onChange={e => {
|
||||
const value = e.target.value;
|
||||
setSelectedUrlOption(value);
|
||||
}}
|
||||
options={cloudProviders ?? []}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{selectedUrlOption === "custom" && (
|
||||
<div className="mt-4 flex items-end gap-x-2 space-y-4">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
label="Custom Cloud URL"
|
||||
value={cloudUrl}
|
||||
onChange={e => setCloudUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/*
|
||||
We do the harcoding here to avoid flickering when the default Cloud URL being fetched.
|
||||
I've tried to avoid harcoding api.jetkvm.com, but it's the only reasonable way I could think of to avoid flickering for now.
|
||||
*/}
|
||||
{selectedUrlOption === (defaultCloudUrl || "https://api.jetkvm.com") && (
|
||||
<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 dark:border-slate-600" />
|
||||
|
||||
<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">
|
||||
<Button
|
||||
onClick={() => onCloudAdoptClick(cloudUrl)}
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Adopt KVM to Cloud"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Your device is adopted to JetKVM 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>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
368
ui/src/routes/devices.$id.settings.access.local-auth.tsx
Normal file
368
ui/src/routes/devices.$id.settings.access.local-auth.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@components/Button";
|
||||
import { InputFieldWithLabel } from "@/components/InputField";
|
||||
import api from "@/api";
|
||||
import { useLocalAuthModalStore } from "@/hooks/stores";
|
||||
import { useLocation, useRevalidator } from "react-router-dom";
|
||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||
|
||||
export default function SecurityAccessLocalAuthRoute() {
|
||||
const { setModalView } = useLocalAuthModalStore();
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
const location = useLocation();
|
||||
const init = location.state?.init;
|
||||
|
||||
useEffect(() => {
|
||||
if (!init) {
|
||||
navigateTo("..");
|
||||
} else {
|
||||
setModalView(init);
|
||||
}
|
||||
}, [init, navigateTo, setModalView]);
|
||||
|
||||
{
|
||||
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||
}
|
||||
return <Dialog onClose={() => navigateTo("..")} />;
|
||||
}
|
||||
|
||||
export function Dialog({ onClose }: { onClose: () => void }) {
|
||||
const { modalView, setModalView } = useLocalAuthModalStore();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const handleCreatePassword = async (password: string, confirmPassword: string) => {
|
||||
if (password === "") {
|
||||
setError("Please enter a password");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.POST("/auth/password-local", { password });
|
||||
if (res.ok) {
|
||||
setModalView("creationSuccess");
|
||||
// The rest of the app needs to revalidate the device authMode
|
||||
revalidator.revalidate();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "An error occurred while setting the password");
|
||||
}
|
||||
} catch (error) {
|
||||
setError("An error occurred while setting the password");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePassword = async (
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
confirmNewPassword: string,
|
||||
) => {
|
||||
if (newPassword !== confirmNewPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldPassword === "") {
|
||||
setError("Please enter your old password");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword === "") {
|
||||
setError("Please enter a new password");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.PUT("/auth/password-local", {
|
||||
oldPassword,
|
||||
newPassword,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setModalView("updateSuccess");
|
||||
// The rest of the app needs to revalidate the device authMode
|
||||
revalidator.revalidate();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "An error occurred while changing the password");
|
||||
}
|
||||
} catch (error) {
|
||||
setError("An error occurred while changing the password");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePassword = async (password: string) => {
|
||||
if (password === "") {
|
||||
setError("Please enter your current password");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.DELETE("/auth/local-password", { password });
|
||||
if (res.ok) {
|
||||
setModalView("deleteSuccess");
|
||||
// The rest of the app needs to revalidate the device authMode
|
||||
revalidator.revalidate();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "An error occurred while disabling the password");
|
||||
}
|
||||
} catch (error) {
|
||||
setError("An error occurred while disabling the password");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{modalView === "createPassword" && (
|
||||
<CreatePasswordModal
|
||||
onSetPassword={handleCreatePassword}
|
||||
onCancel={onClose}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "deletePassword" && (
|
||||
<DeletePasswordModal
|
||||
onDeletePassword={handleDeletePassword}
|
||||
onCancel={onClose}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "updatePassword" && (
|
||||
<UpdatePasswordModal
|
||||
onUpdatePassword={handleUpdatePassword}
|
||||
onCancel={onClose}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "creationSuccess" && (
|
||||
<SuccessModal
|
||||
headline="Password Set Successfully"
|
||||
description="You've successfully set up local device protection. Your device is now secure against unauthorized local access."
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "deleteSuccess" && (
|
||||
<SuccessModal
|
||||
headline="Password Protection Disabled"
|
||||
description="You've successfully disabled the password protection for local access. Remember, your device is now less secure."
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "updateSuccess" && (
|
||||
<SuccessModal
|
||||
headline="Password Updated Successfully"
|
||||
description="You've successfully changed your local device protection password. Make sure to remember your new password for future access."
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreatePasswordModal({
|
||||
onSetPassword,
|
||||
onCancel,
|
||||
error,
|
||||
}: {
|
||||
onSetPassword: (password: string, confirmPassword: string) => void;
|
||||
onCancel: () => void;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">
|
||||
Local Device Protection
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Create a password to protect your device from unauthorized local access.
|
||||
</p>
|
||||
</div>
|
||||
<InputFieldWithLabel
|
||||
label="New Password"
|
||||
type="password"
|
||||
placeholder="Enter a strong password"
|
||||
value={password}
|
||||
autoFocus
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
<InputFieldWithLabel
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
placeholder="Re-enter your password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Secure Device"
|
||||
onClick={() => onSetPassword(password, confirmPassword)}
|
||||
/>
|
||||
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeletePasswordModal({
|
||||
onDeletePassword,
|
||||
onCancel,
|
||||
error,
|
||||
}: {
|
||||
onDeletePassword: (password: string) => void;
|
||||
onCancel: () => void;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">
|
||||
Disable Local Device Protection
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Enter your current password to disable local device protection.
|
||||
</p>
|
||||
</div>
|
||||
<InputFieldWithLabel
|
||||
label="Current Password"
|
||||
type="password"
|
||||
placeholder="Enter your current password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="danger"
|
||||
text="Disable Protection"
|
||||
onClick={() => onDeletePassword(password)}
|
||||
/>
|
||||
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdatePasswordModal({
|
||||
onUpdatePassword,
|
||||
onCancel,
|
||||
error,
|
||||
}: {
|
||||
onUpdatePassword: (
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
confirmNewPassword: string,
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmNewPassword, setConfirmNewPassword] = useState("");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">
|
||||
Change Local Device Password
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Enter your current password and a new password to update your local device
|
||||
protection.
|
||||
</p>
|
||||
</div>
|
||||
<InputFieldWithLabel
|
||||
label="Current Password"
|
||||
type="password"
|
||||
placeholder="Enter your current password"
|
||||
value={oldPassword}
|
||||
onChange={e => setOldPassword(e.target.value)}
|
||||
/>
|
||||
<InputFieldWithLabel
|
||||
label="New Password"
|
||||
type="password"
|
||||
placeholder="Enter a new strong password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<InputFieldWithLabel
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
placeholder="Re-enter your new password"
|
||||
value={confirmNewPassword}
|
||||
onChange={e => setConfirmNewPassword(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Update Password"
|
||||
onClick={() => onUpdatePassword(oldPassword, newPassword, confirmNewPassword)}
|
||||
/>
|
||||
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessModal({
|
||||
headline,
|
||||
description,
|
||||
onClose,
|
||||
}: {
|
||||
headline: string;
|
||||
description: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full max-w-lg flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
|
||||
</div>
|
||||
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
263
ui/src/routes/devices.$id.settings.advanced.tsx
Normal file
263
ui/src/routes/devices.$id.settings.advanced.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
|
||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||
import Checkbox from "../components/Checkbox";
|
||||
|
||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||
import { useCallback, useState, useEffect } from "react";
|
||||
import notifications from "../notifications";
|
||||
import { TextAreaWithLabel } from "../components/TextArea";
|
||||
import { isOnDevice } from "../main";
|
||||
import { Button } from "../components/Button";
|
||||
import { useSettingsStore } from "../hooks/stores";
|
||||
import { GridCard } from "@components/Card";
|
||||
|
||||
export default function SettingsAdvancedRoute() {
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const [sshKey, setSSHKey] = useState<string>("");
|
||||
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
|
||||
const [devChannel, setDevChannel] = useState(false);
|
||||
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
send("getUsbEmulationState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setUsbEmulationEnabled(resp.result as boolean);
|
||||
});
|
||||
|
||||
send("getDevChannelState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setDevChannel(resp.result as boolean);
|
||||
});
|
||||
}, [send, setDeveloperMode]);
|
||||
|
||||
const getUsbEmulationState = useCallback(() => {
|
||||
send("getUsbEmulationState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setUsbEmulationEnabled(resp.result as boolean);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleUsbEmulationToggle = useCallback(
|
||||
(enabled: boolean) => {
|
||||
send("setUsbEmulationState", { enabled: enabled }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setUsbEmulationEnabled(enabled);
|
||||
getUsbEmulationState();
|
||||
});
|
||||
},
|
||||
[getUsbEmulationState, send],
|
||||
);
|
||||
|
||||
const handleResetConfig = useCallback(() => {
|
||||
send("resetConfig", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("Configuration reset to default successfully");
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleUpdateSSHKey = useCallback(() => {
|
||||
send("setSSHKeyState", { sshKey }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("SSH key updated successfully");
|
||||
});
|
||||
}, [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 = (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);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Advanced"
|
||||
description="Access additional settings for troubleshooting and customization"
|
||||
/>
|
||||
|
||||
<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 select-none items-start gap-x-4 p-4">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{isOnDevice && settings.developerMode && (
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="SSH Access"
|
||||
description="Add your SSH public key to enable secure remote access to the device"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<TextAreaWithLabel
|
||||
label="SSH Public Key"
|
||||
value={sshKey || ""}
|
||||
rows={3}
|
||||
onChange={e => setSSHKey(e.target.value)}
|
||||
placeholder="Enter your SSH public key"
|
||||
/>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||
The default SSH user is <strong>root</strong>.
|
||||
</p>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Update SSH Key"
|
||||
onClick={handleUpdateSSHKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsItem
|
||||
title="Troubleshooting Mode"
|
||||
description="Diagnostic tools and additional controls for troubleshooting and development purposes"
|
||||
>
|
||||
<Checkbox
|
||||
defaultChecked={settings.debugMode}
|
||||
onChange={e => {
|
||||
settings.setDebugMode(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{settings.debugMode && (
|
||||
<>
|
||||
<SettingsItem
|
||||
title="USB Emulation"
|
||||
description="Control the USB emulation state"
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text={
|
||||
usbEmulationEnabled ? "Disable USB Emulation" : "Enable USB Emulation"
|
||||
}
|
||||
onClick={() => handleUsbEmulationToggle(!usbEmulationEnabled)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
title="Reset Configuration"
|
||||
description="Reset configuration to default. This will log you out."
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Reset Config"
|
||||
onClick={() => {
|
||||
handleResetConfig();
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
ui/src/routes/devices.$id.settings.appearance.tsx
Normal file
53
ui/src/routes/devices.$id.settings.appearance.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
|
||||
export default function SettingsAppearanceRoute() {
|
||||
const [currentTheme, setCurrentTheme] = useState(() => {
|
||||
return localStorage.theme || "system";
|
||||
});
|
||||
|
||||
const handleThemeChange = useCallback((value: string) => {
|
||||
const root = document.documentElement;
|
||||
|
||||
if (value === "system") {
|
||||
localStorage.removeItem("theme");
|
||||
// Check system preference
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(systemTheme);
|
||||
} else {
|
||||
localStorage.theme = value;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(value);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Appearance"
|
||||
description="Customize the look and feel of your JetKVM interface"
|
||||
/>
|
||||
<SettingsItem title="Theme" description="Choose your preferred color theme">
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={currentTheme}
|
||||
options={[
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
]}
|
||||
onChange={e => {
|
||||
setCurrentTheme(e.target.value);
|
||||
handleThemeChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
ui/src/routes/devices.$id.settings.general._index.tsx
Normal file
108
ui/src/routes/devices.$id.settings.general._index.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { Button } from "../components/Button";
|
||||
import notifications from "../notifications";
|
||||
import Checkbox from "../components/Checkbox";
|
||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||
|
||||
export default function SettingsGeneralRoute() {
|
||||
const [send] = useJsonRpc();
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
|
||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||
const [currentVersions, setCurrentVersions] = useState<{
|
||||
appVersion: string;
|
||||
systemVersion: string;
|
||||
} | null>(null);
|
||||
|
||||
const getCurrentVersions = useCallback(() => {
|
||||
send("getUpdateStatus", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
const result = resp.result as SystemVersionInfo;
|
||||
setCurrentVersions({
|
||||
appVersion: result.local.appVersion,
|
||||
systemVersion: result.local.systemVersion,
|
||||
});
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
useEffect(() => {
|
||||
getCurrentVersions();
|
||||
send("getAutoUpdateState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setAutoUpdate(resp.result as boolean);
|
||||
});
|
||||
}, [getCurrentVersions, send]);
|
||||
|
||||
const handleAutoUpdateChange = (enabled: boolean) => {
|
||||
send("setAutoUpdateState", { enabled }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set auto-update: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setAutoUpdate(enabled);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="General"
|
||||
description="Configure device settings and update preferences"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="mt-2 flex items-center justify-between gap-x-2">
|
||||
<SettingsItem
|
||||
title="Check for Updates"
|
||||
description={
|
||||
currentVersions ? (
|
||||
<>
|
||||
App: {currentVersions.appVersion}
|
||||
<br />
|
||||
System: {currentVersions.systemVersion}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
App: Loading...
|
||||
<br />
|
||||
System: Loading...
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Check for Updates"
|
||||
onClick={() => navigateTo("./update")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Auto Update"
|
||||
description="Automatically update the device to the latest version"
|
||||
>
|
||||
<Checkbox
|
||||
checked={autoUpdate}
|
||||
onChange={e => {
|
||||
handleAutoUpdateChange(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
505
ui/src/routes/devices.$id.settings.general.update.tsx
Normal file
505
ui/src/routes/devices.$id.settings.general.update.tsx
Normal file
@@ -0,0 +1,505 @@
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import Card from "@/components/Card";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { Button } from "@components/Button";
|
||||
import { UpdateState, useUpdateStore } from "@/hooks/stores";
|
||||
import notifications from "@/notifications";
|
||||
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||
|
||||
export default function SettingsGeneralUpdateRoute() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { updateSuccess } = location.state || {};
|
||||
|
||||
const { setModalView, otaState } = useUpdateStore();
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const onConfirmUpdate = useCallback(() => {
|
||||
send("tryUpdate", {});
|
||||
setModalView("updating");
|
||||
}, [send, setModalView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (otaState.updating) {
|
||||
setModalView("updating");
|
||||
} else if (otaState.error) {
|
||||
setModalView("error");
|
||||
} else if (updateSuccess) {
|
||||
setModalView("updateCompleted");
|
||||
} else {
|
||||
setModalView("loading");
|
||||
}
|
||||
}, [otaState.updating, otaState.error, setModalView, updateSuccess]);
|
||||
|
||||
{
|
||||
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||
}
|
||||
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
|
||||
}
|
||||
|
||||
export interface SystemVersionInfo {
|
||||
local: { appVersion: string; systemVersion: string };
|
||||
remote: { appVersion: string; systemVersion: string };
|
||||
systemUpdateAvailable: boolean;
|
||||
appUpdateAvailable: boolean;
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
onClose,
|
||||
onConfirmUpdate,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onConfirmUpdate: () => void;
|
||||
}) {
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
|
||||
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
|
||||
const { modalView, setModalView, otaState } = useUpdateStore();
|
||||
|
||||
const onFinishedLoading = useCallback(
|
||||
async (versionInfo: SystemVersionInfo) => {
|
||||
const hasUpdate =
|
||||
versionInfo?.systemUpdateAvailable || versionInfo?.appUpdateAvailable;
|
||||
|
||||
setVersionInfo(versionInfo);
|
||||
|
||||
if (hasUpdate) {
|
||||
setModalView("updateAvailable");
|
||||
} else {
|
||||
setModalView("upToDate");
|
||||
}
|
||||
},
|
||||
[setModalView],
|
||||
);
|
||||
|
||||
// Reset modal view when dialog is opened
|
||||
useEffect(() => {
|
||||
setVersionInfo(null);
|
||||
}, [setModalView]);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto relative mx-auto text-left">
|
||||
<div>
|
||||
{modalView === "error" && (
|
||||
<UpdateErrorState
|
||||
errorMessage={otaState.error}
|
||||
onClose={onClose}
|
||||
onRetryUpdate={() => setModalView("loading")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "loading" && (
|
||||
<LoadingState onFinished={onFinishedLoading} onCancelCheck={onClose} />
|
||||
)}
|
||||
|
||||
{modalView === "updateAvailable" && (
|
||||
<UpdateAvailableState
|
||||
onConfirmUpdate={onConfirmUpdate}
|
||||
onClose={onClose}
|
||||
versionInfo={versionInfo!}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "updating" && (
|
||||
<UpdatingDeviceState
|
||||
otaState={otaState}
|
||||
onMinimizeUpgradeDialog={() => navigateTo("/")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "upToDate" && (
|
||||
<SystemUpToDateState
|
||||
checkUpdate={() => setModalView("loading")}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "updateCompleted" && <UpdateCompletedState onClose={onClose} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState({
|
||||
onFinished,
|
||||
onCancelCheck,
|
||||
}: {
|
||||
onFinished: (versionInfo: SystemVersionInfo) => void;
|
||||
onCancelCheck: () => void;
|
||||
}) {
|
||||
const [progressWidth, setProgressWidth] = useState("0%");
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const getVersionInfo = useCallback(() => {
|
||||
return new Promise<SystemVersionInfo>((resolve, reject) => {
|
||||
send("getUpdateStatus", {}, async resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error("Failed to check for updates");
|
||||
reject(new Error("Failed to check for updates"));
|
||||
} else {
|
||||
const result = resp.result as SystemVersionInfo;
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
setProgressWidth("0%");
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
const signal = abortControllerRef.current.signal;
|
||||
|
||||
const animationTimer = setTimeout(() => {
|
||||
setProgressWidth("100%");
|
||||
}, 0);
|
||||
|
||||
getVersionInfo()
|
||||
.then(versionInfo => {
|
||||
// Add a small delay to ensure it's not just flickering
|
||||
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 600));
|
||||
})
|
||||
.then(versionInfo => {
|
||||
if (!signal.aborted) {
|
||||
onFinished(versionInfo as SystemVersionInfo);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (!signal.aborted) {
|
||||
console.error("LoadingState: Error fetching version info", error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearTimeout(animationTimer);
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, [getVersionInfo, onFinished]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-0">
|
||||
<p className="text-base font-semibold text-black dark:text-white">
|
||||
Checking for updates...
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
We{"'"}re ensuring your device has the latest features and improvements.
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300">
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
style={{ width: progressWidth }}
|
||||
className="h-2.5 bg-blue-700 transition-all duration-1000 ease-in-out"
|
||||
></div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button size="SM" theme="light" text="Cancel" onClick={onCancelCheck} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdatingDeviceState({
|
||||
otaState,
|
||||
onMinimizeUpgradeDialog,
|
||||
}: {
|
||||
otaState: UpdateState["otaState"];
|
||||
onMinimizeUpgradeDialog: () => void;
|
||||
}) {
|
||||
const formatProgress = (progress: number) => `${Math.round(progress)}%`;
|
||||
|
||||
const calculateOverallProgress = (type: "system" | "app") => {
|
||||
const downloadProgress = Math.round((otaState[`${type}DownloadProgress`] || 0) * 100);
|
||||
const updateProgress = Math.round((otaState[`${type}UpdateProgress`] || 0) * 100);
|
||||
const verificationProgress = Math.round(
|
||||
(otaState[`${type}VerificationProgress`] || 0) * 100,
|
||||
);
|
||||
|
||||
if (!downloadProgress && !updateProgress && !verificationProgress) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`For ${type}:\n` +
|
||||
` Download Progress: ${downloadProgress}% (${otaState[`${type}DownloadProgress`]})\n` +
|
||||
` Update Progress: ${updateProgress}% (${otaState[`${type}UpdateProgress`]})\n` +
|
||||
` Verification Progress: ${verificationProgress}% (${otaState[`${type}VerificationProgress`]})`,
|
||||
);
|
||||
|
||||
if (type === "app") {
|
||||
// App: 65% download, 34% verification, 1% update(There is no "real" update for the app)
|
||||
return Math.min(
|
||||
downloadProgress * 0.55 + verificationProgress * 0.54 + updateProgress * 0.01,
|
||||
100,
|
||||
);
|
||||
} else {
|
||||
// System: 10% download, 90% update
|
||||
return Math.min(
|
||||
downloadProgress * 0.1 + verificationProgress * 0.1 + updateProgress * 0.8,
|
||||
100,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getUpdateStatus = (type: "system" | "app") => {
|
||||
const downloadFinishedAt = otaState[`${type}DownloadFinishedAt`];
|
||||
const verfiedAt = otaState[`${type}VerifiedAt`];
|
||||
const updatedAt = otaState[`${type}UpdatedAt`];
|
||||
|
||||
if (!otaState.metadataFetchedAt) {
|
||||
return "Fetching update information...";
|
||||
} else if (!downloadFinishedAt) {
|
||||
return `Downloading ${type} update...`;
|
||||
} else if (!verfiedAt) {
|
||||
return `Verifying ${type} update...`;
|
||||
} else if (!updatedAt) {
|
||||
return `Installing ${type} update...`;
|
||||
} else {
|
||||
return `Awaiting reboot`;
|
||||
}
|
||||
};
|
||||
|
||||
const isUpdateComplete = (type: "system" | "app") => {
|
||||
return !!otaState[`${type}UpdatedAt`];
|
||||
};
|
||||
|
||||
const areAllUpdatesComplete = () => {
|
||||
if (otaState.systemUpdatePending && otaState.appUpdatePending) {
|
||||
return isUpdateComplete("system") && isUpdateComplete("app");
|
||||
}
|
||||
return (
|
||||
(otaState.systemUpdatePending && isUpdateComplete("system")) ||
|
||||
(otaState.appUpdatePending && isUpdateComplete("app"))
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="w-full max-w-sm space-y-4">
|
||||
<div className="space-y-0">
|
||||
<p className="text-base font-semibold text-black dark:text-white">
|
||||
Updating your device
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Please don{"'"}t turn off your device. This process may take a few minutes.
|
||||
</p>
|
||||
</div>
|
||||
<Card className="space-y-4 p-4">
|
||||
{areAllUpdatesComplete() ? (
|
||||
<div className="my-2 flex flex-col items-center space-y-2 text-center">
|
||||
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
|
||||
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
|
||||
<span className="font-medium text-black dark:text-white">
|
||||
Rebooting to complete the update...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!(otaState.systemUpdatePending || otaState.appUpdatePending) && (
|
||||
<div className="my-2 flex flex-col items-center space-y-2 text-center">
|
||||
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{otaState.systemUpdatePending && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-black dark:text-white">
|
||||
Linux System Update
|
||||
</p>
|
||||
{calculateOverallProgress("system") < 100 ? (
|
||||
<LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
|
||||
) : (
|
||||
<CheckCircleIcon className="h-4 w-4 text-blue-700 dark:text-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
|
||||
<div
|
||||
className="h-2.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
|
||||
style={{
|
||||
width: formatProgress(calculateOverallProgress("system")),
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
|
||||
<span>{getUpdateStatus("system")}</span>
|
||||
{calculateOverallProgress("system") < 100 ? (
|
||||
<span>{formatProgress(calculateOverallProgress("system"))}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{otaState.appUpdatePending && (
|
||||
<>
|
||||
{otaState.systemUpdatePending && (
|
||||
<hr className="dark:border-slate-600" />
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-black dark:text-white">
|
||||
App Update
|
||||
</p>
|
||||
{calculateOverallProgress("app") < 100 ? (
|
||||
<LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
|
||||
) : (
|
||||
<CheckCircleIcon className="h-4 w-4 text-blue-700 dark:text-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
|
||||
<div
|
||||
className="h-2.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
|
||||
style={{
|
||||
width: formatProgress(calculateOverallProgress("app")),
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
|
||||
<span>{getUpdateStatus("app")}</span>
|
||||
{calculateOverallProgress("system") < 100 ? (
|
||||
<span>{formatProgress(calculateOverallProgress("app"))}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<div className="mt-4 flex justify-start gap-x-2 text-white">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Update in Background"
|
||||
onClick={onMinimizeUpgradeDialog}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemUpToDateState({
|
||||
checkUpdate,
|
||||
onClose,
|
||||
}: {
|
||||
checkUpdate: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="text-left">
|
||||
<p className="text-base font-semibold text-black dark:text-white">
|
||||
System is up to date
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Your system is running the latest version. No updates are currently available.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex gap-x-2">
|
||||
<Button size="SM" theme="light" text="Check Again" onClick={checkUpdate} />
|
||||
<Button size="SM" theme="blank" text="Back" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateAvailableState({
|
||||
versionInfo,
|
||||
onConfirmUpdate,
|
||||
onClose,
|
||||
}: {
|
||||
versionInfo: SystemVersionInfo;
|
||||
onConfirmUpdate: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="text-left">
|
||||
<p className="text-base font-semibold text-black dark:text-white">
|
||||
Update available
|
||||
</p>
|
||||
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
A new update is available to enhance system performance and improve
|
||||
compatibility. We recommend updating to ensure everything runs smoothly.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
|
||||
{versionInfo?.systemUpdateAvailable ? (
|
||||
<>
|
||||
<span className="font-semibold">System:</span>{" "}
|
||||
{versionInfo?.remote.systemVersion}
|
||||
<br />
|
||||
</>
|
||||
) : null}
|
||||
{versionInfo?.appUpdateAvailable ? (
|
||||
<>
|
||||
<span className="font-semibold">App:</span> {versionInfo?.remote.appVersion}
|
||||
</>
|
||||
) : 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateCompletedState({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="text-left">
|
||||
<p className="text-base font-semibold dark:text-white">
|
||||
Update Completed Successfully
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
Your device has been successfully updated to the latest version. Enjoy the new
|
||||
features and improvements!
|
||||
</p>
|
||||
<div className="flex items-center justify-start">
|
||||
<Button size="SM" theme="primary" text="Back" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateErrorState({
|
||||
errorMessage,
|
||||
onClose,
|
||||
onRetryUpdate,
|
||||
}: {
|
||||
errorMessage: string | null;
|
||||
onClose: () => void;
|
||||
onRetryUpdate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="text-left">
|
||||
<p className="text-base font-semibold dark:text-white">Update Error</p>
|
||||
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
An error occurred while updating your device. Please try again later.
|
||||
</p>
|
||||
{errorMessage && (
|
||||
<p className="mb-4 text-sm font-medium text-red-600 dark:text-red-400">
|
||||
Error details: {errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-start gap-x-2">
|
||||
<Button size="SM" theme="light" text="Back" onClick={onClose} />
|
||||
<Button size="SM" theme="blank" text="Retry" onClick={onRetryUpdate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
288
ui/src/routes/devices.$id.settings.hardware.tsx
Normal file
288
ui/src/routes/devices.$id.settings.hardware.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { SettingsItem } from "@routes/devices.$id.settings";
|
||||
import { BacklightSettings, UsbConfigState, useSettingsStore } from "@/hooks/stores";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
|
||||
import notifications from "../notifications";
|
||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||
import USBConfigDialog from "@components/USBConfigDialog";
|
||||
|
||||
const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&");
|
||||
|
||||
function generateNumber(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
function generateHex(min: number, max: number) {
|
||||
const len = generateNumber(min, max);
|
||||
const n = (Math.random() * 0xfffff * 1000000).toString(16);
|
||||
return n.slice(0, len);
|
||||
}
|
||||
|
||||
export interface USBConfig {
|
||||
vendor_id: string;
|
||||
product_id: string;
|
||||
serial_number: string;
|
||||
manufacturer: string;
|
||||
product: string;
|
||||
}
|
||||
|
||||
const usbConfigs = [
|
||||
{
|
||||
label: "JetKVM Default",
|
||||
value: "USB Emulation Device",
|
||||
},
|
||||
{
|
||||
label: "Logitech Universal Adapter",
|
||||
value: "Logitech USB Input Device",
|
||||
},
|
||||
{
|
||||
label: "Microsoft Wireless MultiMedia Keyboard",
|
||||
value: "Wireless MultiMedia Keyboard",
|
||||
},
|
||||
{
|
||||
label: "Dell Multimedia Pro Keyboard",
|
||||
value: "Multimedia Pro Keyboard",
|
||||
},
|
||||
];
|
||||
|
||||
type UsbConfigMap = Record<string, USBConfig>;
|
||||
|
||||
export default function SettingsHardwareRoute() {
|
||||
const [send] = useJsonRpc();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const [usbConfigProduct, setUsbConfigProduct] = useState("");
|
||||
const [deviceId, setDeviceId] = useState("");
|
||||
|
||||
const setBacklightSettings = useSettingsStore(state => state.setBacklightSettings);
|
||||
|
||||
const usbConfigData: UsbConfigMap = useMemo(
|
||||
() => ({
|
||||
"USB Emulation Device": {
|
||||
vendor_id: "0x1d6b",
|
||||
product_id: "0x0104",
|
||||
serial_number: deviceId,
|
||||
manufacturer: "JetKVM",
|
||||
product: "USB Emulation Device",
|
||||
},
|
||||
"Logitech USB Input Device": {
|
||||
vendor_id: "0x046d",
|
||||
product_id: "0xc52b",
|
||||
serial_number: generatedSerialNumber,
|
||||
manufacturer: "Logitech (x64)",
|
||||
product: "Logitech USB Input Device",
|
||||
},
|
||||
"Wireless MultiMedia Keyboard": {
|
||||
vendor_id: "0x045e",
|
||||
product_id: "0x005f",
|
||||
serial_number: generatedSerialNumber,
|
||||
manufacturer: "Microsoft",
|
||||
product: "Wireless MultiMedia Keyboard",
|
||||
},
|
||||
"Multimedia Pro Keyboard": {
|
||||
vendor_id: "0x413c",
|
||||
product_id: "0x2011",
|
||||
serial_number: generatedSerialNumber,
|
||||
manufacturer: "Dell Inc.",
|
||||
product: "Multimedia Pro Keyboard",
|
||||
},
|
||||
}),
|
||||
[deviceId],
|
||||
);
|
||||
|
||||
const handleBacklightSettingsChange = (settings: BacklightSettings) => {
|
||||
// If the user has set the display to dim after it turns off, set the dim_after
|
||||
// value to never.
|
||||
if (settings.dim_after > settings.off_after && settings.off_after != 0) {
|
||||
settings.dim_after = 0;
|
||||
}
|
||||
|
||||
setBacklightSettings(settings);
|
||||
handleBacklightSettingsSave();
|
||||
};
|
||||
|
||||
const handleBacklightSettingsSave = () => {
|
||||
send("setBacklightSettings", { params: settings.backlightSettings }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set backlight settings: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("Backlight settings updated successfully");
|
||||
});
|
||||
};
|
||||
const syncUsbConfigProduct = useCallback(() => {
|
||||
send("getUsbConfig", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
console.error("Failed to load USB Config:", resp.error);
|
||||
notifications.error(
|
||||
`Failed to load USB Config: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
} else {
|
||||
console.log("syncUsbConfigProduct#getUsbConfig result:", resp.result);
|
||||
const usbConfigState = resp.result as UsbConfigState;
|
||||
const product = usbConfigs.map(u => u.value).includes(usbConfigState.product)
|
||||
? usbConfigState.product
|
||||
: "custom";
|
||||
setUsbConfigProduct(product);
|
||||
}
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleUsbConfigChange = useCallback(
|
||||
(usbConfig: USBConfig) => {
|
||||
send("setUsbConfig", { usbConfig }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set usb config: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// setUsbConfigProduct(usbConfig.product);
|
||||
notifications.success(
|
||||
`USB Config set to ${usbConfig.manufacturer} ${usbConfig.product}`,
|
||||
);
|
||||
syncUsbConfigProduct();
|
||||
});
|
||||
},
|
||||
[send, syncUsbConfigProduct],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
send("getBacklightSettings", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
return notifications.error(
|
||||
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
const result = resp.result as BacklightSettings;
|
||||
setBacklightSettings(result);
|
||||
});
|
||||
|
||||
send("getDeviceID", {}, async resp => {
|
||||
if ("error" in resp) {
|
||||
return notifications.error(
|
||||
`Failed to get device ID: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
setDeviceId(resp.result as string);
|
||||
});
|
||||
|
||||
syncUsbConfigProduct();
|
||||
}, [send, setBacklightSettings, syncUsbConfigProduct]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Hardware"
|
||||
description="Configure display settings and hardware options for your JetKVM device"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Display Brightness"
|
||||
description="Set the brightness of the display"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={settings.backlightSettings.max_brightness.toString()}
|
||||
options={[
|
||||
{ value: "0", label: "Off" },
|
||||
{ value: "10", label: "Low" },
|
||||
{ value: "35", label: "Medium" },
|
||||
{ value: "64", label: "High" },
|
||||
]}
|
||||
onChange={e => {
|
||||
settings.backlightSettings.max_brightness = parseInt(e.target.value);
|
||||
handleBacklightSettingsChange(settings.backlightSettings);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
{settings.backlightSettings.max_brightness != 0 && (
|
||||
<>
|
||||
<SettingsItem
|
||||
title="Dim Display After"
|
||||
description="Set how long to wait before dimming the display"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={settings.backlightSettings.dim_after.toString()}
|
||||
options={[
|
||||
{ value: "0", label: "Never" },
|
||||
{ value: "60", label: "1 Minute" },
|
||||
{ value: "300", label: "5 Minutes" },
|
||||
{ value: "600", label: "10 Minutes" },
|
||||
{ value: "1800", label: "30 Minutes" },
|
||||
{ value: "3600", label: "1 Hour" },
|
||||
]}
|
||||
onChange={e => {
|
||||
settings.backlightSettings.dim_after = parseInt(e.target.value);
|
||||
handleBacklightSettingsChange(settings.backlightSettings);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
title="Turn off Display After"
|
||||
description="Period of inactivity before display automatically turns off"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={settings.backlightSettings.off_after.toString()}
|
||||
options={[
|
||||
{ value: "0", label: "Never" },
|
||||
{ value: "300", label: "5 Minutes" },
|
||||
{ value: "600", label: "10 Minutes" },
|
||||
{ value: "1800", label: "30 Minutes" },
|
||||
{ value: "3600", label: "1 Hour" },
|
||||
]}
|
||||
onChange={e => {
|
||||
settings.backlightSettings.off_after = parseInt(e.target.value);
|
||||
handleBacklightSettingsChange(settings.backlightSettings);
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
|
||||
<SettingsItem
|
||||
title="USB Device Emulation"
|
||||
description="Set a Preconfigured USB Device"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
className="max-w-[192px]"
|
||||
value={usbConfigProduct}
|
||||
onChange={e => {
|
||||
if (e.target.value === "custom") {
|
||||
setUsbConfigProduct(e.target.value);
|
||||
} else {
|
||||
const usbConfig = usbConfigData[e.target.value];
|
||||
handleUsbConfigChange(usbConfig);
|
||||
}
|
||||
}}
|
||||
options={[...usbConfigs, { value: "custom", label: "Custom" }]}
|
||||
/>
|
||||
</SettingsItem>
|
||||
{usbConfigProduct === "custom" && (
|
||||
<USBConfigDialog
|
||||
onSetUsbConfig={usbConfig => handleUsbConfigChange(usbConfig)}
|
||||
onRestoreToDefault={() =>
|
||||
handleUsbConfigChange(usbConfigData[usbConfigs[0].value])
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
ui/src/routes/devices.$id.settings.mouse.tsx
Normal file
133
ui/src/routes/devices.$id.settings.mouse.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
import { Checkbox } from "@/components/Checkbox";
|
||||
import { GridCard } from "@/components/Card";
|
||||
import PointingFinger from "@/assets/pointing-finger.svg";
|
||||
import { CheckCircleIcon } from "@heroicons/react/16/solid";
|
||||
import { useSettingsStore } from "@/hooks/stores";
|
||||
import notifications from "@/notifications";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { cx } from "../cva.config";
|
||||
|
||||
export default function SettingsKeyboardMouseRoute() {
|
||||
const hideCursor = useSettingsStore(state => state.isCursorHidden);
|
||||
const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
|
||||
|
||||
const [jiggler, setJiggler] = useState(false);
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
useEffect(() => {
|
||||
send("getJigglerState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setJiggler(resp.result as boolean);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleJigglerChange = (enabled: boolean) => {
|
||||
send("setJigglerState", { enabled }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set jiggler state: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setJiggler(enabled);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Mouse"
|
||||
description="Configure cursor behavior and interaction settings for your device"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Hide Cursor"
|
||||
description="Hide the cursor when sending mouse movements"
|
||||
>
|
||||
<Checkbox
|
||||
checked={hideCursor}
|
||||
onChange={e => setHideCursor(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
title="Jiggler"
|
||||
description="Simulate movement of a computer mouse. Prevents sleep mode, standby mode or the screensaver from activating"
|
||||
>
|
||||
<Checkbox
|
||||
checked={jiggler}
|
||||
onChange={e => handleJigglerChange(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem title="Modes" description="Choose the mouse input mode" />
|
||||
<div className="flex flex-col items-center gap-4 md:flex-row">
|
||||
<button
|
||||
className="group block w-full grow"
|
||||
onClick={() => console.log("Absolute mouse mode clicked")}
|
||||
>
|
||||
<GridCard>
|
||||
<div className="group flex items-center gap-x-4 px-4 py-3">
|
||||
<img
|
||||
className="w-6 shrink-0 dark:invert"
|
||||
src={PointingFinger}
|
||||
alt="Finger touching a screen"
|
||||
/>
|
||||
<div className="flex grow items-center justify-between">
|
||||
<div className="text-left">
|
||||
<h3 className="text-sm font-semibold text-black dark:text-white">
|
||||
Absolute
|
||||
</h3>
|
||||
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
|
||||
Most convenient
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircleIcon
|
||||
className={cx(
|
||||
"h-4 w-4 text-blue-700 transition-opacity duration-300 dark:text-blue-500",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
</button>
|
||||
<button
|
||||
className="group block w-full grow cursor-not-allowed opacity-50"
|
||||
disabled
|
||||
>
|
||||
<GridCard>
|
||||
<div className="group flex items-center gap-x-4 px-4 py-3">
|
||||
<img
|
||||
className="w-6 shrink-0 dark:invert"
|
||||
src={PointingFinger}
|
||||
alt="Finger touching a screen"
|
||||
/>
|
||||
<div className="flex grow items-center justify-between">
|
||||
<div className="text-left">
|
||||
<h3 className="text-sm font-semibold text-black dark:text-white">
|
||||
Relative
|
||||
</h3>
|
||||
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
|
||||
Most Compatible
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircleIcon
|
||||
className={cx(
|
||||
"hidden",
|
||||
"h-4 w-4 text-blue-700 transition-opacity duration-300 dark:text-blue-500",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
ui/src/routes/devices.$id.settings.tsx
Normal file
254
ui/src/routes/devices.$id.settings.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
import Card from "@/components/Card";
|
||||
import {
|
||||
LuSettings,
|
||||
LuKeyboard,
|
||||
LuVideo,
|
||||
LuCpu,
|
||||
LuShieldCheck,
|
||||
LuWrench,
|
||||
LuArrowLeft,
|
||||
LuPalette,
|
||||
} from "react-icons/lu";
|
||||
import { LinkButton } from "../components/Button";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { cx } from "../cva.config";
|
||||
import { useUiStore } from "../hooks/stores";
|
||||
import useKeyboard from "../hooks/useKeyboard";
|
||||
import { useResizeObserver } from "../hooks/useResizeObserver";
|
||||
|
||||
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||
export default function SettingsRoute() {
|
||||
const location = useLocation();
|
||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
const { sendKeyboardEvent } = useKeyboard();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftGradient, setShowLeftGradient] = useState(false);
|
||||
const [showRightGradient, setShowRightGradient] = useState(false);
|
||||
const { width } = useResizeObserver({ ref: scrollContainerRef });
|
||||
|
||||
// Handle scroll position to show/hide gradients
|
||||
const handleScroll = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
|
||||
// Show left gradient only if scrolled to the right
|
||||
setShowLeftGradient(scrollLeft > 0);
|
||||
// Show right gradient only if there's more content to scroll to the right
|
||||
setShowRightGradient(scrollLeft < scrollWidth - clientWidth - 1); // -1 for rounding errors
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check initial scroll position
|
||||
handleScroll();
|
||||
|
||||
// Add scroll event listener to the container
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (scrollContainer) {
|
||||
scrollContainer.addEventListener("scroll", handleScroll);
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Clean up event listener
|
||||
if (scrollContainer) {
|
||||
scrollContainer.removeEventListener("scroll", handleScroll);
|
||||
}
|
||||
};
|
||||
}, [width]);
|
||||
|
||||
useEffect(() => {
|
||||
// disable focus trap
|
||||
setTimeout(() => {
|
||||
// Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
|
||||
sendKeyboardEvent([], []);
|
||||
setDisableVideoFocusTrap(true);
|
||||
// For some reason, the focus trap is not disabled immediately
|
||||
// so we need to blur the active element
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
console.log("Just disabled focus trap");
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
setDisableVideoFocusTrap(false);
|
||||
};
|
||||
}, [setDisableVideoFocusTrap, sendKeyboardEvent]);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white">
|
||||
<div className="h-full">
|
||||
<div className="w-full gap-x-8 gap-y-4 space-y-4 md:grid md:grid-cols-8 md:space-y-0">
|
||||
<div className="w-full select-none space-y-4 md:col-span-2">
|
||||
<Card className="flex w-full gap-x-4 overflow-hidden p-2 md:flex-col dark:bg-slate-800">
|
||||
<div className="md:hidden">
|
||||
<LinkButton
|
||||
to=".."
|
||||
size="SM"
|
||||
theme="blank"
|
||||
text="Back to KVM"
|
||||
LeadingIcon={LuArrowLeft}
|
||||
textAlign="left"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<LinkButton
|
||||
to=".."
|
||||
size="SM"
|
||||
theme="blank"
|
||||
text="Back to KVM"
|
||||
LeadingIcon={LuArrowLeft}
|
||||
textAlign="left"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="relative overflow-hidden">
|
||||
{/* Gradient overlay for left side - only visible on mobile when scrolled */}
|
||||
<div
|
||||
className={cx(
|
||||
"pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-white to-transparent transition-opacity duration-300 ease-in-out md:hidden dark:from-slate-900",
|
||||
{
|
||||
"opacity-0": !showLeftGradient,
|
||||
"opacity-100": showLeftGradient,
|
||||
},
|
||||
)}
|
||||
></div>
|
||||
{/* Gradient overlay for right side - only visible on mobile when there's more content */}
|
||||
<div
|
||||
className={cx(
|
||||
"pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-white to-transparent transition duration-300 ease-in-out md:hidden dark:from-slate-900",
|
||||
{
|
||||
"opacity-0": !showRightGradient,
|
||||
"opacity-100": showRightGradient,
|
||||
},
|
||||
)}
|
||||
></div>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="hide-scrollbar relative flex w-full gap-x-4 overflow-x-auto whitespace-nowrap p-2 md:flex-col md:overflow-visible md:whitespace-normal dark:bg-slate-800"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="general"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||
<LuSettings className="h-4 w-4 shrink-0" />
|
||||
<h1>General</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="mouse"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||
<LuKeyboard className="h-4 w-4 shrink-0" />
|
||||
<h1>Mouse</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="video"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||
<LuVideo className="h-4 w-4 shrink-0" />
|
||||
<h1>Video</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="hardware"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||
<LuCpu className="h-4 w-4 shrink-0" />
|
||||
<h1>Hardware</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="access"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||
<LuShieldCheck className="h-4 w-4 shrink-0" />
|
||||
<h1>Access</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="appearance"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||
<LuPalette className="h-4 w-4 shrink-0" />
|
||||
<h1>Appearance</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="advanced"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||
<LuWrench className="h-4 w-4 shrink-0" />
|
||||
<h1>Advanced</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="w-full md:col-span-5">
|
||||
{/* <AutoHeight> */}
|
||||
<Card className="dark:bg-slate-800">
|
||||
<div
|
||||
className="space-y-4 px-8 py-6"
|
||||
style={{ animationDuration: "0.7s" }}
|
||||
key={location.pathname} // This is a workaround to force the animation to run when the route changes
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Card>
|
||||
{/* </AutoHeight> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsItem({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
description: string | React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
name?: string;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={cx(
|
||||
"flex select-none items-center justify-between gap-x-8 rounded",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="text-base font-semibold text-black dark:text-white">{title}</h3>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">{description}</p>
|
||||
</div>
|
||||
{children ? <div>{children}</div> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
185
ui/src/routes/devices.$id.settings.video.tsx
Normal file
185
ui/src/routes/devices.$id.settings.video.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
import { Button } from "@/components/Button";
|
||||
import { TextAreaWithLabel } from "@/components/TextArea";
|
||||
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useState, useEffect } from "react";
|
||||
import notifications from "../notifications";
|
||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||
const defaultEdid =
|
||||
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
|
||||
const edids = [
|
||||
{
|
||||
value: defaultEdid,
|
||||
label: "JetKVM Default",
|
||||
},
|
||||
{
|
||||
value:
|
||||
"00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096",
|
||||
label: "Acer B246WL, 1920x1200",
|
||||
},
|
||||
{
|
||||
value:
|
||||
"00FFFFFFFFFFFF0006B3872401010101021F010380342078EA6DB5A7564EA0250D5054BF6F00714F8180814081C0A9409500B300D1C0283C80A070B023403020360006442100001A000000FD00314B1E5F19000A202020202020000000FC00504132343851560A2020202020000000FF004D314C4D51533035323135370A014D02032AF14B900504030201111213141F230907078301000065030C001000681A00000101314BE6E2006A023A801871382D40582C450006442100001ECD5F80B072B0374088D0360006442100001C011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E960006442100001800000000000000000000000000DC",
|
||||
label: "ASUS PA248QV, 1920x1200",
|
||||
},
|
||||
{
|
||||
value:
|
||||
"00FFFFFFFFFFFF0010AC132045393639201E0103803C22782ACD25A3574B9F270D5054A54B00714F8180A9C0D1C00101010101010101023A801871382D40582C450056502100001E000000FF00335335475132330A2020202020000000FC0044454C4C204432373231480A20000000FD00384C1E5311000A202020202020018102031AB14F90050403020716010611121513141F65030C001000023A801871382D40582C450056502100001E011D8018711C1620582C250056502100009E011D007251D01E206E28550056502100001E8C0AD08A20E02D10103E960056502100001800000000000000000000000000000000000000000000000000000000004F",
|
||||
label: "DELL D2721H, 1920x1080",
|
||||
},
|
||||
];
|
||||
|
||||
const streamQualityOptions = [
|
||||
{ value: "1", label: "High" },
|
||||
{ value: "0.5", label: "Medium" },
|
||||
{ value: "0.1", label: "Low" },
|
||||
];
|
||||
|
||||
export default function SettingsVideoRoute() {
|
||||
const [send] = useJsonRpc();
|
||||
const [streamQuality, setStreamQuality] = useState("1");
|
||||
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
||||
const [edid, setEdid] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
send("getStreamQualityFactor", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setStreamQuality(String(resp.result));
|
||||
});
|
||||
|
||||
send("getEDID", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const receivedEdid = resp.result as string;
|
||||
|
||||
const matchingEdid = edids.find(
|
||||
x => x.value.toLowerCase() === receivedEdid.toLowerCase(),
|
||||
);
|
||||
|
||||
if (matchingEdid) {
|
||||
// EDID is stored in uppercase in the UI
|
||||
setEdid(matchingEdid.value.toUpperCase());
|
||||
// Reset custom EDID value
|
||||
setCustomEdidValue(null);
|
||||
} else {
|
||||
setEdid("custom");
|
||||
setCustomEdidValue(receivedEdid);
|
||||
}
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleStreamQualityChange = (factor: string) => {
|
||||
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set stream quality: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.success(`Stream quality set to ${streamQualityOptions.find(x => x.value === factor)?.label}`);
|
||||
setStreamQuality(factor);
|
||||
});
|
||||
};
|
||||
|
||||
const handleEDIDChange = (newEdid: string) => {
|
||||
send("setEDID", { edid: newEdid }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.success(
|
||||
`EDID set successfully to ${edids.find(x => x.value === newEdid)?.label}`,
|
||||
);
|
||||
// Update the EDID value in the UI
|
||||
setEdid(newEdid);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Video"
|
||||
description="Configure display settings and EDID for optimal compatibility"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Stream Quality"
|
||||
description="Adjust the quality of the video stream"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={streamQuality}
|
||||
options={streamQualityOptions}
|
||||
onChange={e => handleStreamQualityChange(e.target.value)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
title="EDID"
|
||||
description="Adjust the EDID settings for the display"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
fullWidth
|
||||
value={customEdidValue ? "custom" : edid || "asd"}
|
||||
onChange={e => {
|
||||
if (e.target.value === "custom") {
|
||||
setEdid("custom");
|
||||
setCustomEdidValue("");
|
||||
} else {
|
||||
setCustomEdidValue(null);
|
||||
handleEDIDChange(e.target.value as string);
|
||||
}
|
||||
}}
|
||||
options={[...edids, { value: "custom", label: "Custom" }]}
|
||||
/>
|
||||
</SettingsItem>
|
||||
{customEdidValue !== null && (
|
||||
<>
|
||||
<SettingsItem
|
||||
title="Custom EDID"
|
||||
description="EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments."
|
||||
/>
|
||||
<TextAreaWithLabel
|
||||
label="EDID File"
|
||||
placeholder="00F..."
|
||||
rows={3}
|
||||
value={customEdidValue}
|
||||
onChange={e => setCustomEdidValue(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-start gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Set Custom EDID"
|
||||
onClick={() => handleEDIDChange(customEdidValue)}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Restore to default"
|
||||
onClick={() => {
|
||||
setCustomEdidValue(null);
|
||||
handleEDIDChange(defaultEdid);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cx } from "@/cva.config";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import {
|
||||
HidState,
|
||||
UpdateState,
|
||||
@@ -16,27 +15,30 @@ import {
|
||||
import WebRTCVideo from "@components/WebRTCVideo";
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
Outlet,
|
||||
Params,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useOutlet,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { checkAuth, isInCloud, isOnDevice } from "@/main";
|
||||
import DashboardNavbar from "@components/Header";
|
||||
import { useInterval } from "usehooks-ts";
|
||||
import SettingsSidebar from "@/components/sidebar/settings";
|
||||
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
|
||||
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import UpdateDialog from "@components/UpdateDialog";
|
||||
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
|
||||
import api from "../api";
|
||||
import { DeviceStatus } from "./welcome-local";
|
||||
import FocusTrap from "focus-trap-react";
|
||||
import OtherSessionConnectedModal from "@/components/OtherSessionConnectedModal";
|
||||
import Terminal from "@components/Terminal";
|
||||
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||
import Modal from "../components/Modal";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||
|
||||
interface LocalLoaderResp {
|
||||
authMode: "password" | "noPassword" | null;
|
||||
@@ -50,8 +52,9 @@ interface CloudLoaderResp {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type AuthMode = "password" | "noPassword" | null;
|
||||
export interface LocalDevice {
|
||||
authMode: "password" | "noPassword" | null;
|
||||
authMode: AuthMode;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
@@ -123,16 +126,7 @@ export default function KvmIdRoute() {
|
||||
const setTransceiver = useRTCStore(state => state.setTransceiver);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
otaState,
|
||||
setOtaState,
|
||||
isUpdateDialogOpen,
|
||||
setIsUpdateDialogOpen,
|
||||
setModalView,
|
||||
} = useUpdateStore();
|
||||
|
||||
const [isOtherSessionConnectedModalOpen, setIsOtherSessionConnectedModalOpen] =
|
||||
useState(false);
|
||||
const { otaState, setOtaState, setModalView } = useUpdateStore();
|
||||
|
||||
const sdp = useCallback(
|
||||
async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => {
|
||||
@@ -243,8 +237,7 @@ export default function KvmIdRoute() {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// We don't want to connect if another session is connected
|
||||
if (isOtherSessionConnectedModalOpen) return;
|
||||
if (location.pathname.includes("other-session")) return;
|
||||
connectWebRTC();
|
||||
}, 3000);
|
||||
|
||||
@@ -330,11 +323,11 @@ export default function KvmIdRoute() {
|
||||
const setHdmiState = useVideoStore(state => state.setHdmiState);
|
||||
|
||||
const [hasUpdated, setHasUpdated] = useState(false);
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
|
||||
function onJsonRpcRequest(resp: JsonRpcRequest) {
|
||||
if (resp.method === "otherSessionConnected") {
|
||||
console.log("otherSessionConnected", resp.params);
|
||||
setIsOtherSessionConnectedModalOpen(true);
|
||||
navigateTo("/other-session");
|
||||
}
|
||||
|
||||
if (resp.method === "usbState") {
|
||||
@@ -358,7 +351,7 @@ export default function KvmIdRoute() {
|
||||
|
||||
if (otaState.error) {
|
||||
setModalView("error");
|
||||
setIsUpdateDialogOpen(true);
|
||||
navigateTo("/settings/general/update");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -388,11 +381,9 @@ export default function KvmIdRoute() {
|
||||
// When the update is successful, we need to refresh the client javascript and show a success modal
|
||||
useEffect(() => {
|
||||
if (queryParams.get("updateSuccess")) {
|
||||
setModalView("updateCompleted");
|
||||
setIsUpdateDialogOpen(true);
|
||||
setQueryParams({});
|
||||
navigateTo("/settings/general/update", { state: { updateSuccess: true } });
|
||||
}
|
||||
}, [queryParams, setIsUpdateDialogOpen, setModalView, setQueryParams]);
|
||||
}, [navigate, navigateTo, queryParams, setModalView, setQueryParams]);
|
||||
|
||||
const diskChannel = useRTCStore(state => state.diskChannel)!;
|
||||
const file = useMountMediaStore(state => state.localFile)!;
|
||||
@@ -445,18 +436,27 @@ export default function KvmIdRoute() {
|
||||
};
|
||||
}, [kvmTerminal]);
|
||||
|
||||
const outlet = useOutlet();
|
||||
const location = useLocation();
|
||||
const onModalClose = useCallback(() => {
|
||||
if (location.pathname !== "/other-session") navigateTo("..");
|
||||
}, [navigateTo, location.pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition show={!isUpdateDialogOpen && otaState.updating}>
|
||||
<div className="pointer-events-none fixed inset-0 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center">
|
||||
<div className="transition duration-1000 ease-in data-[closed]:opacity-0">
|
||||
<UpdateInProgressStatusCard
|
||||
setIsUpdateDialogOpen={setIsUpdateDialogOpen}
|
||||
setModalView={setModalView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
{!outlet && otaState.updating && (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="pointer-events-none fixed inset-0 top-16 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<UpdateInProgressStatusCard />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
<div className="relative h-full">
|
||||
<FocusTrap
|
||||
paused={disableKeyboardFocusTrap}
|
||||
@@ -486,21 +486,24 @@ export default function KvmIdRoute() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateDialog open={isUpdateDialogOpen} setOpen={setIsUpdateDialogOpen} />
|
||||
<OtherSessionConnectedModal
|
||||
open={isOtherSessionConnectedModalOpen}
|
||||
setOpen={state => {
|
||||
if (!state) connectWebRTC().then(r => r);
|
||||
|
||||
// It takes some time for the WebRTC connection to be established, so we wait a bit before closing the modal
|
||||
setTimeout(() => {
|
||||
setIsOtherSessionConnectedModalOpen(state);
|
||||
}, 1000);
|
||||
<div
|
||||
className="isolate"
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") navigateTo("/");
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Modal open={outlet !== null} onClose={onModalClose}>
|
||||
<Outlet context={{ connectWebRTC }} />
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
{kvmTerminal && (
|
||||
<Terminal type="kvm" dataChannel={kvmTerminal} title="KVM Terminal" />
|
||||
)}
|
||||
|
||||
{serialConsole && (
|
||||
<Terminal type="serial" dataChannel={serialConsole} title="Serial Console" />
|
||||
)}
|
||||
@@ -518,16 +521,22 @@ function SidebarContainer({ sidebarView }: { sidebarView: string | null }) {
|
||||
style={{ width: sidebarView ? "493px" : 0 }}
|
||||
>
|
||||
<div className="relative w-[493px] shrink-0">
|
||||
<Transition show={sidebarView === "system"} unmount={false}>
|
||||
<div className="absolute inset-0">
|
||||
<SettingsSidebar />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition show={sidebarView === "connection-stats"} unmount={false}>
|
||||
<div className="absolute inset-0">
|
||||
<ConnectionStatsSidebar />
|
||||
</div>
|
||||
</Transition>
|
||||
<AnimatePresence>
|
||||
{sidebarView === "connection-stats" && (
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<ConnectionStatsSidebar />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user