extract i18n strings

This commit is contained in:
Siyuan Miao 2025-11-19 18:04:43 +00:00
parent 161c272099
commit 75ea98d396
8 changed files with 363 additions and 585 deletions

View File

@ -24,5 +24,6 @@
],
"url": "./internal/ota/testdata/ota.schema.json"
}
]
],
"typescript.tsdk": "./ui/node_modules/typescript/lib"
}

View File

@ -1,11 +1,10 @@
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
import LoadingSpinner from "@components/LoadingSpinner";
import { cx } from "@/cva.config";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { Button } from "./Button";
import { GridCard } from "./Card";
import LoadingSpinner from "./LoadingSpinner";
export default function UpdateInProgressStatusCard() {
const { navigateTo } = useDeviceUiNavigation();
@ -22,7 +21,7 @@ export default function UpdateInProgressStatusCard() {
<div className="text-sm leading-none">
<div className="flex items-center gap-x-1">
<span className={cx("transition")}>
Please don{"'"}t turn off your device...
Please don&apos;t turn off your device
</span>
</div>
</div>

View File

@ -0,0 +1,48 @@
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import LoadingSpinner from "@components/LoadingSpinner";
export interface UpdatePart {
pending: boolean;
status: string;
progress: number;
complete: boolean;
}
export default function UpdatingStatusCard({
label,
part,
}: {
label: string;
part: UpdatePart;
}) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-black dark:text-white">{label}</p>
{part.progress < 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"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(part.progress)}
aria-label={`${label} progress`}
>
<div
className="h-2.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
style={{ width: `${part.progress}%` }}
/>
</div>
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span>{part.status}</span>
{part.progress < 100 ? <span>{`${Math.round(part.progress)}%`}</span> : null}
</div>
</div>
);
}

View File

@ -1,9 +1,9 @@
import { useCallback, useEffect, useState } from "react";
import { useLoaderData, useNavigate, type LoaderFunction } from "react-router";
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { GridCard } from "@components/Card";
import { Button, LinkButton } from "@components/Button";
import { InputFieldWithLabel } from "@components/InputField";
@ -17,7 +17,6 @@ import api from "@/api";
import notifications from "@/notifications";
import { DEVICE_API } from "@/ui.config";
import { isOnDevice } from "@/main";
import { m } from "@localizations/messages.js";
import { LocalDevice } from "./devices.$id";
import { CloudState } from "./adopt";
@ -93,7 +92,7 @@ export default function SettingsAccessIndexRoute() {
send("deregisterDevice", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
m.access_failed_deregister({ error: resp.error.data || m.unknown_error() }),
`Failed to deregister device: ${resp.error.data || "Unknown error"}`,
);
return;
}
@ -108,14 +107,14 @@ export default function SettingsAccessIndexRoute() {
const onCloudAdoptClick = useCallback(
(cloudApiUrl: string, cloudAppUrl: string) => {
if (!deviceId) {
notifications.error(m.access_no_device_id());
notifications.error("No device ID found");
return;
}
send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
m.access_failed_update_cloud_url({ error: resp.error.data || m.unknown_error() }),
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
);
return;
}
@ -161,12 +160,12 @@ export default function SettingsAccessIndexRoute() {
send("setTLSState", { state }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
m.access_failed_update_tls({ error: resp.error.data || m.unknown_error() }),
`Failed to update TLS state: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success(m.access_tls_updated());
notifications.success("TLS state updated successfully");
});
}, [send]);
@ -207,22 +206,22 @@ export default function SettingsAccessIndexRoute() {
return (
<div className="space-y-4">
<SettingsPageHeader
title={m.access_title()}
description={m.access_description()}
title="Access"
description="Manage the Access Control of the device"
/>
{loaderData?.authMode && (
<>
<div className="space-y-4">
<SettingsSectionHeader
title={m.access_local_title()}
description={m.access_local_description()}
title="Local"
description="Manage the mode of local access to the device"
/>
<>
<SettingsItem
title={m.access_https_mode_title()}
title="HTTPS Mode"
badge="Experimental"
description={m.access_https_description()}
description="Configure secure HTTPS access to your device"
>
<SelectMenuBasic
size="SM"
@ -230,9 +229,9 @@ export default function SettingsAccessIndexRoute() {
onChange={e => handleTlsModeChange(e.target.value)}
disabled={tlsMode === "unknown"}
options={[
{ value: "disabled", label: m.access_tls_disabled() },
{ value: "self-signed", label: m.access_tls_self_signed() },
{ value: "custom", label: m.access_tls_custom() },
{ value: "disabled", label: "Disabled" },
{ value: "self-signed", label: "Self-signed" },
{ value: "custom", label: "Custom" },
]}
/>
</SettingsItem>
@ -240,11 +239,11 @@ export default function SettingsAccessIndexRoute() {
{tlsMode === "custom" && (
<NestedSettingsGroup className="mt-4">
<SettingsItem
title={m.access_tls_certificate_title()}
description={m.access_tls_certificate_description()}
title="TLS Certificate"
description="Paste your TLS certificate below. For certificate chains, include the entire chain (leaf, intermediate, and root certificates)."
/>
<TextAreaWithLabel
label={m.access_certificate_label()}
label="Certificate"
rows={3}
placeholder={
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
@ -253,8 +252,8 @@ export default function SettingsAccessIndexRoute() {
onChange={e => handleTlsCertChange(e.target.value)}
/>
<TextAreaWithLabel
label={m.access_private_key_label()}
description={m.access_private_key_description()}
label="Private Key"
description="For security reasons, it will not be displayed after saving."
rows={3}
placeholder={
"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
@ -266,7 +265,7 @@ export default function SettingsAccessIndexRoute() {
<Button
size="SM"
theme="primary"
text={m.access_update_tls_settings()}
text="Update TLS Settings"
onClick={handleCustomTlsUpdate}
/>
</div>
@ -274,14 +273,14 @@ export default function SettingsAccessIndexRoute() {
)}
<SettingsItem
title={m.access_authentication_mode_title()}
description={loaderData.authMode === "password" ? m.access_auth_mode_password() : m.access_auth_mode_no_password()}
title="Authentication Mode"
description={loaderData.authMode === "password" ? "Current Mode: Password Protected" : "Current Mode: No password"}
>
{loaderData.authMode === "password" ? (
<Button
size="SM"
theme="light"
text={m.access_disable_protection()}
text="Disable Protection"
onClick={() => {
navigateTo("./local-auth", { state: { init: "deletePassword" } });
}}
@ -290,7 +289,7 @@ export default function SettingsAccessIndexRoute() {
<Button
size="SM"
theme="light"
text={m.access_enable_password()}
text="Enable Password"
onClick={() => {
navigateTo("./local-auth", { state: { init: "createPassword" } });
}}
@ -301,13 +300,13 @@ export default function SettingsAccessIndexRoute() {
{loaderData.authMode === "password" && (
<SettingsItem
title={m.access_change_password_title()}
description={m.access_change_password_description()}
title="Change Password"
description="Set a password to protect your device"
>
<Button
size="SM"
theme="light"
text={m.access_change_password_button()}
text="Change Password"
onClick={() => {
navigateTo("./local-auth", { state: { init: "updatePassword" } });
}}
@ -322,23 +321,23 @@ export default function SettingsAccessIndexRoute() {
<div className="space-y-4">
<SettingsSectionHeader
title="Remote"
description={m.access_remote_description()}
description="Manage the mode of Remote access to the device"
/>
<div className="space-y-4">
{!isAdopted && (
<>
<SettingsItem
title={m.access_cloud_provider_title()}
description={m.access_cloud_provider_description()}
title="Cloud Provider"
description="Select the cloud provider for your device"
>
<SelectMenuBasic
size="SM"
value={selectedProvider}
onChange={e => handleProviderChange(e.target.value)}
options={[
{ value: "jetkvm", label: m.access_provider_jetkvm() },
{ value: "custom", label: m.access_provider_custom() },
{ value: "jetkvm", label: "JetKVM" },
{ value: "custom", label: "Custom" },
]}
/>
</SettingsItem>
@ -348,7 +347,7 @@ export default function SettingsAccessIndexRoute() {
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label={m.access_cloud_api_url_label()}
label="API URL"
value={cloudApiUrl}
onChange={e => setCloudApiUrl(e.target.value)}
placeholder="https://api.example.com"
@ -357,7 +356,7 @@ export default function SettingsAccessIndexRoute() {
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label={m.access_cloud_app_url_label()}
label="App URL"
value={cloudAppUrl}
onChange={e => setCloudAppUrl(e.target.value)}
placeholder="https://app.example.com"
@ -376,26 +375,27 @@ export default function SettingsAccessIndexRoute() {
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
{m.access_cloud_security_title()}
Cloud Security
</h3>
<div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>{m.access_security_encryption()}</li>
<li>{m.access_security_zero_trust()}</li>
<li>{m.access_security_oidc()}</li>
<li>{m.access_security_streams()}</li>
<li>End-to-end encryption using WebRTC (DTLS and SRTP)</li>
<li>OIDC (OpenID Connect) authentication</li>
<li>All cloud components are open-source and available on GitHub.</li>
<li>All streams encrypted in transit</li>
<li>Zero Trust security model</li>
</ul>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
{m.access_security_open_source()}{" "}
All cloud components are open-source and available on GitHub.{" "}
<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"
>
{m.access_github_link()}
GitHub repository
</a>
.
</div>
@ -407,7 +407,7 @@ export default function SettingsAccessIndexRoute() {
to="https://jetkvm.com/docs/networking/remote-access"
size="SM"
theme="light"
text={m.access_learn_security()}
text="Learn more"
/>
</div>
</div>
@ -421,32 +421,32 @@ export default function SettingsAccessIndexRoute() {
onClick={() => onCloudAdoptClick(cloudApiUrl, cloudAppUrl)}
size="SM"
theme="primary"
text={m.access_adopt_kvm()}
text="Adopt KVM to Cloud"
/>
</div>
) : (
<div>
<div className="space-y-2">
<p className="text-sm text-slate-600 dark:text-slate-300">
{m.access_adopted_message()}
Your device is adopted to the Cloud. You can deregister it at any time.
</p>
<div>
<Button
size="SM"
theme="light"
text={m.access_deregister()}
text="De-register from Cloud"
className="text-red-600"
onClick={() => {
if (deviceId) {
if (
window.confirm(
m.access_confirm_deregister(),
"Are you sure you want to de-register this device?",
)
) {
deregisterDevice();
}
} else {
notifications.error(m.access_no_device_id());
notifications.error("No device ID available");
}
}}
/>
@ -460,4 +460,4 @@ export default function SettingsAccessIndexRoute() {
);
}
SettingsAccessIndexRoute.loader = loader;
SettingsAccessIndexRoute.loader = loader;

View File

@ -1,8 +1,9 @@
import { useCallback, useEffect, useState } from "react";
import { useSettingsStore } from "@hooks/stores";
import { JsonRpcError, JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { SystemVersionInfo } from "@hooks/useVersion";
import { Button } from "@components/Button";
import Checkbox, { CheckboxWithLabel } from "@components/Checkbox";
import { ConfirmDialog } from "@components/ConfirmDialog";
@ -15,10 +16,8 @@ import { InputFieldWithLabel } from "@components/InputField";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { isOnDevice } from "@/main";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
import { sleep } from "@/utils";
import { checkUpdateComponents, UpdateComponents } from "@/utils/jsonrpc";
import { SystemVersionInfo } from "@hooks/useVersion";
import { FeatureFlag } from "../components/FeatureFlag";
@ -79,8 +78,11 @@ export default function SettingsAdvancedRoute() {
(enabled: boolean) => {
send("setUsbEmulationState", { enabled: enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
const errorMsg = resp.error.data || "Unknown error";
notifications.error(
`Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`,
enabled
? `Failed to enable USB emulation: ${errorMsg}`
: `Failed to disable USB emulation: ${errorMsg}`
);
return;
}
@ -95,7 +97,7 @@ export default function SettingsAdvancedRoute() {
send("resetConfig", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`,
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`
);
return;
}
@ -107,7 +109,7 @@ export default function SettingsAdvancedRoute() {
send("setSSHKeyState", { sshKey }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`,
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`
);
return;
}
@ -120,7 +122,7 @@ export default function SettingsAdvancedRoute() {
send("setDevModeState", { enabled: developerMode }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`,
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`
);
return;
}
@ -135,7 +137,7 @@ export default function SettingsAdvancedRoute() {
send("setDevChannelState", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`
);
return;
}
@ -149,20 +151,19 @@ export default function SettingsAdvancedRoute() {
(enabled: boolean) => {
send("setLocalLoopbackOnly", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
const errorMsg = resp.error.data || "Unknown error";
notifications.error(
`Failed to ${enabled ? "enable" : "disable"} loopback-only mode: ${resp.error.data || "Unknown error"}`,
enabled
? `Failed to enable loopback-only mode: ${errorMsg}`
: `Failed to disable loopback-only mode: ${errorMsg}`
);
return;
}
setLocalLoopbackOnly(enabled);
if (enabled) {
notifications.success(
"Loopback-only mode enabled. Restart your device to apply.",
);
notifications.success("Loopback-only mode enabled. Restart your device to apply.");
} else {
notifications.success(
"Loopback-only mode disabled. Restart your device to apply.",
);
notifications.success("Loopback-only mode disabled. Restart your device to apply.");
}
});
},
@ -188,10 +189,9 @@ export default function SettingsAdvancedRoute() {
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
const handleVersionUpdateError = useCallback((error?: JsonRpcError | string) => {
const errorMessage = typeof error === "string" ? error : (error?.data ?? error?.message ?? "Unknown error");
notifications.error(
m.advanced_error_version_update({
error: typeof error === "string" ? error : (error?.data ?? error?.message ?? m.unknown_error())
}),
`Failed to initiate version update: ${errorMessage}`,
{ duration: 1000 * 15 } // 15 seconds
);
setCustomVersionUpdateLoading(false);
@ -289,17 +289,17 @@ export default function SettingsAdvancedRoute() {
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
{m.advanced_developer_mode_enabled_title()}
Developer Mode Enabled
</h3>
<div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>{m.advanced_developer_mode_warning_security()}</li>
<li>{m.advanced_developer_mode_warning_risks()}</li>
<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">
{m.advanced_developer_mode_warning_advanced()}
For advanced users only. Not for production use.
</div>
</div>
</div>
@ -308,24 +308,24 @@ export default function SettingsAdvancedRoute() {
{isOnDevice && (
<div className="space-y-4">
<SettingsItem
title={m.advanced_ssh_access_title()}
description={m.advanced_ssh_access_description()}
title="SSH Access"
description="Add your SSH public key to enable secure remote access to the device"
/>
<TextAreaWithLabel
label={m.advanced_ssh_public_key_label()}
label="SSH Public Key"
value={sshKey || ""}
rows={3}
onChange={e => setSSHKey(e.target.value)}
placeholder={m.advanced_ssh_public_key_placeholder()}
placeholder="Enter your SSH public key"
/>
<p className="text-xs text-slate-600 dark:text-slate-400">
{m.advanced_ssh_default_user()}<strong>root</strong>.
The default SSH user is<strong>root</strong>.
</p>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={m.advanced_update_ssh_key_button()}
text="Update SSH Key"
onClick={handleUpdateSSHKey}
/>
</div>
@ -335,16 +335,16 @@ export default function SettingsAdvancedRoute() {
<FeatureFlag minAppVersion="0.4.10" name="version-update">
<div className="space-y-4">
<SettingsItem
title={m.advanced_version_update_title()}
description={m.advanced_version_update_description()}
title="Update to Specific Version"
description="Install a specific version from GitHub releases"
/>
<SelectMenuBasic
label={m.advanced_version_update_target_label()}
label="What to update"
options={[
{ value: "app", label: m.advanced_version_update_target_app() },
{ value: "system", label: m.advanced_version_update_target_system() },
{ value: "both", label: m.advanced_version_update_target_both() },
{ value: "app", label: "App only" },
{ value: "system", label: "System only" },
{ value: "both", label: "Both App and System" },
]}
value={updateTarget}
onChange={e => setUpdateTarget(e.target.value)}
@ -352,7 +352,7 @@ export default function SettingsAdvancedRoute() {
{(updateTarget === "app" || updateTarget === "both") && (
<InputFieldWithLabel
label={m.advanced_version_update_app_label()}
label="App Version"
placeholder="0.4.9"
value={appVersion}
onChange={e => setAppVersion(e.target.value)}
@ -361,7 +361,7 @@ export default function SettingsAdvancedRoute() {
{(updateTarget === "system" || updateTarget === "both") && (
<InputFieldWithLabel
label={m.advanced_version_update_system_label()}
label="System Version"
placeholder="0.4.9"
value={systemVersion}
onChange={e => setSystemVersion(e.target.value)}
@ -369,21 +369,21 @@ export default function SettingsAdvancedRoute() {
)}
<p className="text-xs text-slate-600 dark:text-slate-400">
{m.advanced_version_update_helper()}{" "}
Find available versions on the{" "}
<a
href="https://github.com/jetkvm/kvm/releases"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-700 hover:underline dark:text-blue-500"
>
{m.advanced_version_update_github_link()}
JetKVM releases page
</a>
</p>
<div>
<CheckboxWithLabel
label={m.advanced_version_update_reset_config_label()}
description={m.advanced_version_update_reset_config_description()}
label="Reset configuration"
description="Reset configuration after the update"
checked={resetConfig}
onChange={e => setResetConfig(e.target.checked)}
/>
@ -400,7 +400,7 @@ export default function SettingsAdvancedRoute() {
<Button
size="SM"
theme="primary"
text={m.advanced_version_update_button()}
text="Update to Version"
disabled={
(updateTarget === "app" && !appVersion) ||
(updateTarget === "system" && !systemVersion) ||
@ -417,8 +417,8 @@ export default function SettingsAdvancedRoute() {
) : null}
<SettingsItem
title={m.advanced_loopback_only_title()}
description={m.advanced_loopback_only_description()}
title="Loopback-Only Mode"
description="Restrict web interface access to localhost only (127.0.0.1)"
>
<Checkbox
checked={localLoopbackOnly}
@ -464,8 +464,10 @@ export default function SettingsAdvancedRoute() {
size="SM"
theme="light"
text="Reset Config"
onClick={() => {
onClick={async () => {
handleResetConfig();
// Add 2s delay between resetting the configuration and calling reload() to prevent reload from interrupting the RPC call to reset things.
await sleep(2000);
window.location.reload();
}}
/>
@ -499,4 +501,4 @@ export default function SettingsAdvancedRoute() {
/>
</div>
);
}
}

View File

@ -1,7 +1,7 @@
import { useNavigate } from "react-router";
import { useCallback, useState } from "react";
import { useNavigate } from "react-router";
import { useJsonRpc } from "@hooks/useJsonRpc";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button";
import { useFailsafeModeStore } from "@/hooks/stores";
import { sleep } from "@/utils";
@ -29,29 +29,25 @@ export default function SettingsGeneralRebootRoute() {
const onConfirmUpdate = useCallback(async () => {
setIsRebooting(true);
// This is where we send the RPC to the golang binary
send("reboot", { force: true });
send("reboot", { force: true });
await new Promise(resolve => setTimeout(resolve, REBOOT_REDIRECT_DELAY_MS));
setFailsafeMode(false, "");
navigateTo("/");
}, [navigateTo, send, setFailsafeMode]);
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog isRebooting={isRebooting} onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
return <Dialog isRebooting={isRebooting} onClose={onClose} onConfirmUpdate={onConfirmUpdate} />;
}
export function Dialog({
isRebooting,
onClose,
onConfirmUpdate,
}: {
}: Readonly<{
isRebooting: boolean;
onClose: () => void;
onConfirmUpdate: () => void;
}) {
}>) {
return (
<div className="pointer-events-auto relative mx-auto text-left">
@ -97,4 +93,4 @@ function ConfirmationBox({
</div>
</div>
);
}
}

View File

@ -1,15 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import { useJsonRpc } from "@hooks/useJsonRpc";
import { UpdateState, useUpdateStore } from "@hooks/stores";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { useVersion } from "@hooks/useVersion";
import UpdatingStatusCard, { type UpdatePart } from "@components/UpdatingStatusCard";
import { Button } from "@components/Button";
import Card from "@components/Card";
import LoadingSpinner from "@components/LoadingSpinner";
import UpdatingStatusCard, { type UpdatePart } from "@components/UpdatingStatusCard";
import { m } from "@localizations/messages.js";
import { sleep } from "@/utils";
import { checkUpdateComponents, SystemVersionInfo, UpdateComponents, updateParams } from "@/utils/jsonrpc";
@ -236,10 +235,10 @@ function LoadingState({
<div className="space-y-4">
<div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white">
Checking for updates...
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.
We&apos;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">
@ -302,17 +301,18 @@ function UpdatingDeviceState({
const verifiedAt = otaState[`${type}VerifiedAt`];
const updatedAt = otaState[`${type}UpdatedAt`];
const update_type = () => (type === "system" ? "System" : "App");
if (!otaState.metadataFetchedAt) {
return "Fetching update information...";
return "Fetching update information";
} else if (!downloadFinishedAt) {
return `Downloading ${type} update...`;
return `Downloading ${update_type()} update…`;
} else if (!verifiedAt) {
return `Verifying ${type} update...`;
return `Verifying ${update_type()} update…`;
} else if (!updatedAt) {
return `Installing ${type} update...`;
return `Installing ${update_type()} update…`;
} else {
return `Awaiting reboot`;
return "Awaiting reboot";
}
};
@ -352,7 +352,6 @@ function UpdatingDeviceState({
};
}, [otaState]);
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">
@ -361,7 +360,7 @@ function UpdatingDeviceState({
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.
Please don&apos;t turn off your device. This process may take a few minutes.
</p>
</div>
<Card className="space-y-4 p-4">
@ -370,7 +369,7 @@ function UpdatingDeviceState({
<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...
Rebooting to complete the update
</span>
</div>
</div>
@ -449,27 +448,26 @@ function UpdateAvailableState({
<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
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.
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">Linux System Update</span>: {versionInfo?.local?.systemVersion} <span className="text-slate-600 dark:text-slate-300"></span> {versionInfo?.remote?.systemVersion}
<span className="font-semibold">System</span>: {versionInfo?.local?.systemVersion} <span className="text-slate-600 dark:text-slate-300"></span> {versionInfo?.remote?.systemVersion}
<br />
</>
) : null}
{versionInfo?.appUpdateAvailable ? (
<>
<span className="font-semibold">App Update</span>: {versionInfo?.local?.appVersion} <span className="text-slate-600 dark:text-slate-300"></span> {versionInfo?.remote?.appVersion}
<span className="font-semibold">App</span>: {versionInfo?.local?.appVersion} <span className="text-slate-600 dark:text-slate-300"></span> {versionInfo?.remote?.appVersion}
</>
) : null}
{versionInfo?.willDisableAutoUpdate ? (
<p className="mb-4 text-sm text-red-600 dark:text-red-400">
You{"'"}re about to manually change your device version. Auto-update will be disabled after the update is completed to prevent accidental updates.
You&apos;re about to manually change your device version. Auto-update will be disabled after the update is completed to prevent accidental updates.
</p>
) : null}
</p>
@ -490,8 +488,7 @@ function UpdateCompletedState({ onClose }: { onClose: () => void }) {
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!
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} />
@ -529,4 +526,4 @@ function UpdateErrorState({
</div>
</div>
);
}
}

View File

@ -1,502 +1,237 @@
import { useCallback, useEffect, useState } from "react";
import { useSettingsStore } from "@hooks/stores";
import { JsonRpcError, JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { SystemVersionInfo } from "@hooks/useVersion";
import { useEffect, useState } from "react";
import { BacklightSettings, useSettingsStore } from "@hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { Button } from "@components/Button";
import Checkbox, { CheckboxWithLabel } from "@components/Checkbox";
import { ConfirmDialog } from "@components/ConfirmDialog";
import { GridCard } from "@components/Card";
import { Checkbox } from "@components/Checkbox";
import { FeatureFlag } from "@components/FeatureFlag";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
import { TextAreaWithLabel } from "@components/TextArea";
import { InputFieldWithLabel } from "@components/InputField";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { isOnDevice } from "@/main";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { UsbInfoSetting } from "@components/UsbInfoSetting";
import notifications from "@/notifications";
import { sleep } from "@/utils";
import { checkUpdateComponents, UpdateComponents } from "@/utils/jsonrpc";
import { FeatureFlag } from "../components/FeatureFlag";
export default function SettingsAdvancedRoute() {
export default function SettingsHardwareRoute() {
const { send } = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation();
const [sshKey, setSSHKey] = useState<string>("");
const { setDeveloperMode } = useSettingsStore();
const [devChannel, setDevChannel] = useState(false);
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
const [updateTarget, setUpdateTarget] = useState<string>("app");
const [appVersion, setAppVersion] = useState<string>("");
const [systemVersion, setSystemVersion] = useState<string>("");
const [resetConfig, setResetConfig] = useState(false);
const [versionChangeAcknowledged, setVersionChangeAcknowledged] = useState(false);
const [customVersionUpdateLoading, setCustomVersionUpdateLoading] = useState(false);
const settings = useSettingsStore();
const { displayRotation, setDisplayRotation } = useSettingsStore();
const [powerSavingEnabled, setPowerSavingEnabled] = useState(false);
const handleDisplayRotationChange = (rotation: string) => {
setDisplayRotation(rotation);
handleDisplayRotationSave();
};
const handleDisplayRotationSave = () => {
send("setDisplayRotation", { params: { rotation: displayRotation } }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set display orientation: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Display orientation updated successfully");
});
};
const { backlightSettings, setBacklightSettings } = useSettingsStore();
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(settings);
};
const handleBacklightSettingsSave = (backlightSettings: BacklightSettings) => {
send("setBacklightSettings", { params: backlightSettings }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set backlight settings: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Backlight settings updated successfully");
});
};
const handleBacklightMaxBrightnessChange = (max_brightness: number) => {
const settings = { ...backlightSettings, max_brightness };
handleBacklightSettingsChange(settings);
};
const handleBacklightDimAfterChange = (dim_after: number) => {
const settings = { ...backlightSettings, dim_after };
handleBacklightSettingsChange(settings);
};
const handleBacklightOffAfterChange = (off_after: number) => {
const settings = { ...backlightSettings, off_after };
handleBacklightSettingsChange(settings);
};
const handlePowerSavingChange = (enabled: boolean) => {
setPowerSavingEnabled(enabled);
const duration = enabled ? 90 : -1;
send("setVideoSleepMode", { duration }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Failed to set power saving mode: ${resp.error.data || "Unknown error"}`);
setPowerSavingEnabled(!enabled); // Attempt to revert on error
return;
}
notifications.success(enabled ? 'Power saving mode enabled' : 'Power saving mode disabled');
});
};
useEffect(() => {
send("getDevModeState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
const result = resp.result as { enabled: boolean };
setDeveloperMode(result.enabled);
});
send("getSSHKeyState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setSSHKey(resp.result as string);
});
send("getUsbEmulationState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
send("getDevChannelState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setDevChannel(resp.result as boolean);
});
send("getLocalLoopbackOnly", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setLocalLoopbackOnly(resp.result as boolean);
});
}, [send, setDeveloperMode]);
const getUsbEmulationState = useCallback(() => {
send("getUsbEmulationState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
}, [send]);
const handleUsbEmulationToggle = useCallback(
(enabled: boolean) => {
send("setUsbEmulationState", { enabled: enabled }, (resp: JsonRpcResponse) => {
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: JsonRpcResponse) => {
send("getBacklightSettings", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`,
return notifications.error(
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`,
);
}
const result = resp.result as BacklightSettings;
setBacklightSettings(result);
});
}, [send, setBacklightSettings]);
useEffect(() => {
send("getVideoSleepMode", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to get power saving mode:", resp.error);
return;
}
notifications.success("Configuration reset to default successfully");
const result = resp.result as { enabled: boolean; duration: number };
setPowerSavingEnabled(result.duration >= 0);
});
}, [send]);
const handleUpdateSSHKey = useCallback(() => {
send("setSSHKeyState", { sshKey }, (resp: JsonRpcResponse) => {
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: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`,
);
return;
}
setDeveloperMode(developerMode);
});
},
[send, setDeveloperMode],
);
const handleDevChannelChange = useCallback(
(enabled: boolean) => {
send("setDevChannelState", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setDevChannel(enabled);
});
},
[send, setDevChannel],
);
const applyLoopbackOnlyMode = useCallback(
(enabled: boolean) => {
send("setLocalLoopbackOnly", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to ${enabled ? "enable" : "disable"} loopback-only mode: ${resp.error.data || "Unknown error"}`,
);
return;
}
setLocalLoopbackOnly(enabled);
if (enabled) {
notifications.success(
"Loopback-only mode enabled. Restart your device to apply.",
);
} else {
notifications.success(
"Loopback-only mode disabled. Restart your device to apply.",
);
}
});
},
[send, setLocalLoopbackOnly],
);
const handleLoopbackOnlyModeChange = useCallback(
(enabled: boolean) => {
// If trying to enable loopback-only mode, show warning first
if (enabled) {
setShowLoopbackWarning(true);
} else {
// If disabling, just proceed
applyLoopbackOnlyMode(false);
}
},
[applyLoopbackOnlyMode, setShowLoopbackWarning],
);
const confirmLoopbackModeEnable = useCallback(() => {
applyLoopbackOnlyMode(true);
setShowLoopbackWarning(false);
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
const handleVersionUpdateError = useCallback((error?: JsonRpcError | string) => {
notifications.error(
m.advanced_error_version_update({
error: typeof error === "string" ? error : (error?.data ?? error?.message ?? m.unknown_error())
}),
{ duration: 1000 * 15 } // 15 seconds
);
setCustomVersionUpdateLoading(false);
}, []);
const handleCustomVersionUpdate = useCallback(async () => {
const components: UpdateComponents = {};
if (["app", "both"].includes(updateTarget) && appVersion) components.app = appVersion;
if (["system", "both"].includes(updateTarget) && systemVersion) components.system = systemVersion;
let versionInfo: SystemVersionInfo | undefined;
try {
// we do not need to set it to false if check succeeds,
// because it will be redirected to the update page later
setCustomVersionUpdateLoading(true);
versionInfo = await checkUpdateComponents({
components,
}, devChannel);
} catch (error: unknown) {
const jsonRpcError = error as JsonRpcError;
handleVersionUpdateError(jsonRpcError);
return;
}
let hasUpdate = false;
const pageParams = new URLSearchParams();
if (components.app && versionInfo?.remote?.appVersion && versionInfo?.appUpdateAvailable) {
hasUpdate = true;
pageParams.set("custom_app_version", versionInfo.remote?.appVersion);
}
if (components.system && versionInfo?.remote?.systemVersion && versionInfo?.systemUpdateAvailable) {
hasUpdate = true;
pageParams.set("custom_system_version", versionInfo.remote?.systemVersion);
}
pageParams.set("reset_config", resetConfig.toString());
if (!hasUpdate) {
handleVersionUpdateError("No update available");
return;
}
// Navigate to update page
navigateTo(`/settings/general/update?${pageParams.toString()}`);
}, [
updateTarget, appVersion, systemVersion, devChannel,
navigateTo, resetConfig, handleVersionUpdateError,
setCustomVersionUpdateLoading
]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Advanced"
description="Access additional settings for troubleshooting and customization"
title="Hardware"
description={"Configure display settings and hardware options for your JetKVM device"}
/>
<div className="space-y-4">
<SettingsItem
title="Dev Channel Updates"
description="Receive early updates from the development channel"
title="Display Orientation"
description="Set the orientation of the display"
>
<Checkbox
checked={devChannel}
<SelectMenuBasic
size="SM"
label=""
value={settings.displayRotation.toString()}
options={[
{ value: "270", label: "Normal" },
{ value: "90", label: "Inverted" },
]}
onChange={e => {
handleDevChannelChange(e.target.checked);
handleDisplayRotationChange(e.target.value);
}}
/>
</SettingsItem>
<SettingsItem
title="Developer Mode"
description="Enable advanced features for developers"
title="Display Brightness"
description="Set the brightness of the display"
>
<Checkbox
checked={settings.developerMode}
onChange={e => handleDevModeChange(e.target.checked)}
/>
</SettingsItem>
{settings.developerMode ? (
<NestedSettingsGroup>
<GridCard>
<div className="flex items-start gap-x-4 p-4 select-none">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500"
>
<path
fillRule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clipRule="evenodd"
/>
</svg>
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
{m.advanced_developer_mode_enabled_title()}
</h3>
<div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>{m.advanced_developer_mode_warning_security()}</li>
<li>{m.advanced_developer_mode_warning_risks()}</li>
</ul>
</div>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
{m.advanced_developer_mode_warning_advanced()}
</div>
</div>
</div>
</GridCard>
{isOnDevice && (
<div className="space-y-4">
<SettingsItem
title={m.advanced_ssh_access_title()}
description={m.advanced_ssh_access_description()}
/>
<TextAreaWithLabel
label={m.advanced_ssh_public_key_label()}
value={sshKey || ""}
rows={3}
onChange={e => setSSHKey(e.target.value)}
placeholder={m.advanced_ssh_public_key_placeholder()}
/>
<p className="text-xs text-slate-600 dark:text-slate-400">
{m.advanced_ssh_default_user()}<strong>root</strong>.
</p>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={m.advanced_update_ssh_key_button()}
onClick={handleUpdateSSHKey}
/>
</div>
</div>
)}
<FeatureFlag minAppVersion="0.4.10" name="version-update">
<div className="space-y-4">
<SettingsItem
title={m.advanced_version_update_title()}
description={m.advanced_version_update_description()}
/>
<SelectMenuBasic
label={m.advanced_version_update_target_label()}
options={[
{ value: "app", label: m.advanced_version_update_target_app() },
{ value: "system", label: m.advanced_version_update_target_system() },
{ value: "both", label: m.advanced_version_update_target_both() },
]}
value={updateTarget}
onChange={e => setUpdateTarget(e.target.value)}
/>
{(updateTarget === "app" || updateTarget === "both") && (
<InputFieldWithLabel
label={m.advanced_version_update_app_label()}
placeholder="0.4.9"
value={appVersion}
onChange={e => setAppVersion(e.target.value)}
/>
)}
{(updateTarget === "system" || updateTarget === "both") && (
<InputFieldWithLabel
label={m.advanced_version_update_system_label()}
placeholder="0.4.9"
value={systemVersion}
onChange={e => setSystemVersion(e.target.value)}
/>
)}
<p className="text-xs text-slate-600 dark:text-slate-400">
{m.advanced_version_update_helper()}{" "}
<a
href="https://github.com/jetkvm/kvm/releases"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-700 hover:underline dark:text-blue-500"
>
{m.advanced_version_update_github_link()}
</a>
</p>
<div>
<CheckboxWithLabel
label={m.advanced_version_update_reset_config_label()}
description={m.advanced_version_update_reset_config_description()}
checked={resetConfig}
onChange={e => setResetConfig(e.target.checked)}
/>
</div>
<div>
<CheckboxWithLabel
label="I understand version changes may break my device and require factory reset"
checked={versionChangeAcknowledged}
onChange={e => setVersionChangeAcknowledged(e.target.checked)}
/>
</div>
<Button
size="SM"
theme="primary"
text={m.advanced_version_update_button()}
disabled={
(updateTarget === "app" && !appVersion) ||
(updateTarget === "system" && !systemVersion) ||
(updateTarget === "both" && (!appVersion || !systemVersion)) ||
!versionChangeAcknowledged ||
customVersionUpdateLoading
}
loading={customVersionUpdateLoading}
onClick={handleCustomVersionUpdate}
/>
</div>
</FeatureFlag>
</NestedSettingsGroup>
) : null}
<SettingsItem
title={m.advanced_loopback_only_title()}
description={m.advanced_loopback_only_description()}
>
<Checkbox
checked={localLoopbackOnly}
onChange={e => handleLoopbackOnlyModeChange(e.target.checked)}
/>
</SettingsItem>
<SettingsItem
title="Troubleshooting Mode"
description="Diagnostic tools and additional controls for troubleshooting and development purposes"
>
<Checkbox
defaultChecked={settings.debugMode}
<SelectMenuBasic
size="SM"
label=""
value={backlightSettings.max_brightness.toString()}
options={[
{ value: "0", label: "Off" },
{ value: "10", label: "Low" },
{ value: "35", label: "Medium" },
{ value: "64", label: "High" },
]}
onChange={e => {
settings.setDebugMode(e.target.checked);
handleBacklightMaxBrightnessChange(Number.parseInt(e.target.value));
}}
/>
</SettingsItem>
{settings.debugMode && (
{backlightSettings.max_brightness != 0 && (
<NestedSettingsGroup>
<SettingsItem
title="USB Emulation"
description="Control the USB emulation state"
title="Dim Display After"
description="Set how long to wait before dimming the display"
>
<Button
<SelectMenuBasic
size="SM"
theme="light"
text={
usbEmulationEnabled ? "Disable USB Emulation" : "Enable USB Emulation"
}
onClick={() => handleUsbEmulationToggle(!usbEmulationEnabled)}
label=""
value={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 => {
handleBacklightDimAfterChange(Number.parseInt(e.target.value));
}}
/>
</SettingsItem>
<SettingsItem
title="Reset Configuration"
description="Reset configuration to default. This will log you out."
title="Turn Off Display After"
description="Period of inactivity before display automatically turns off"
>
<Button
<SelectMenuBasic
size="SM"
theme="light"
text="Reset Config"
onClick={() => {
handleResetConfig();
window.location.reload();
label=""
value={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 => {
handleBacklightOffAfterChange(Number.parseInt(e.target.value));
}}
/>
</SettingsItem>
</NestedSettingsGroup>
)}
<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>
<ConfirmDialog
open={showLoopbackWarning}
onClose={() => {
setShowLoopbackWarning(false);
}}
title="Enable Loopback-Only Mode?"
description={
<>
<p>
WARNING: This will restrict web interface access to localhost (127.0.0.1)
only.
</p>
<p>Before enabling this feature, make sure you have either:</p>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>SSH access configured and tested</li>
<li>Cloud access enabled and working</li>
</ul>
</>
}
variant="warning"
confirmText="I Understand, Enable Anyway"
onConfirm={confirmLoopbackModeEnable}
/>
<FeatureFlag minAppVersion="0.4.9">
<div className="space-y-4">
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsSectionHeader
title="Power Saving"
description="Reduce power consumption when not in use"
/>
<SettingsItem
badge="Experimental"
title="HDMI Sleep Mode"
description="Reduce power consumption when the HDMI port is not in use"
>
<Checkbox
checked={powerSavingEnabled}
onChange={(e) => handlePowerSavingChange(e.target.checked)}
/>
</SettingsItem>
</div>
</FeatureFlag>
<FeatureFlag minAppVersion="0.3.8">
<UsbDeviceSetting />
</FeatureFlag>
<FeatureFlag minAppVersion="0.3.8">
<UsbInfoSetting />
</FeatureFlag>
</div>
);
}