Compare commits

..

No commits in common. "d48c2eeb9cf74c8b922b9a33d3fde48c47382f42" and "bccb03d6fbcb8f34dd5c55edb13cca26edf3d961" have entirely different histories.

10 changed files with 629 additions and 1052 deletions

View File

@ -24,7 +24,6 @@ show_help() {
REMOTE_USER="root" REMOTE_USER="root"
REMOTE_PATH="/userdata/jetkvm/bin" REMOTE_PATH="/userdata/jetkvm/bin"
SKIP_UI_BUILD=false SKIP_UI_BUILD=false
RESET_USB_HID_DEVICE=false
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}" LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
# Parse command line arguments # Parse command line arguments
@ -42,10 +41,6 @@ while [[ $# -gt 0 ]]; do
SKIP_UI_BUILD=true SKIP_UI_BUILD=true
shift shift
;; ;;
--reset-usb-hid)
RESET_USB_HID_DEVICE=true
shift
;;
--help) --help)
show_help show_help
exit 0 exit 0
@ -79,12 +74,6 @@ ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Copy the binary to the remote host # Copy the binary to the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < jetkvm_app ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < jetkvm_app
if [ "$RESET_USB_HID_DEVICE" = true ]; then
# Remove the old USB gadget configuration
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
fi
# Deploy and run the application on the remote host # Deploy and run the application on the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
set -e set -e

View File

@ -1,11 +1,6 @@
import { useRef } from "react"; import { useRef } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
Combobox as HeadlessCombobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/react";
import { cva } from "@/cva.config"; import { cva } from "@/cva.config";
@ -29,7 +24,7 @@ const comboboxVariants = cva({
type BaseProps = React.ComponentProps<typeof HeadlessCombobox>; type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
interface ComboboxProps extends Omit<BaseProps, "displayValue"> { interface ComboboxProps extends Omit<BaseProps, 'displayValue'> {
displayValue: (option: ComboboxOption) => string; displayValue: (option: ComboboxOption) => string;
onInputChange: (option: string) => void; onInputChange: (option: string) => void;
options: () => ComboboxOption[]; options: () => ComboboxOption[];
@ -55,54 +50,56 @@ export function Combobox({
const classes = comboboxVariants({ size }); const classes = comboboxVariants({ size });
return ( return (
<HeadlessCombobox onChange={onChange} {...otherProps}> <HeadlessCombobox
onChange={onChange}
{...otherProps}
>
{() => ( {() => (
<> <>
<Card className="w-auto !border border-solid !border-slate-800/30 shadow-xs outline-0 dark:!border-slate-300/30"> <Card className="w-auto !border border-solid !border-slate-800/30 shadow-xs outline-0 dark:!border-slate-300/30">
<ComboboxInput <ComboboxInput
ref={inputRef} ref={inputRef}
className={clsx( className={clsx(
classes, classes,
// General styling // General styling
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300", "block w-full cursor-pointer rounded-sm border-none py-0 font-medium shadow-none outline-0 transition duration-300",
// Hover // Hover
"hover:bg-blue-50/80 active:bg-blue-100/60", "hover:bg-blue-50/80 active:bg-blue-100/60",
// Dark mode // Dark mode
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60", "dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
// Focus // Focus
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500", "focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
// Disabled // Disabled
disabled && disabled && "pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80 disabled:hover:bg-white dark:disabled:hover:bg-slate-800"
"pointer-events-none select-none bg-slate-50 text-slate-500/80 disabled:hover:bg-white dark:bg-slate-800 dark:text-slate-400/80 dark:disabled:hover:bg-slate-800", )}
)} placeholder={disabled ? disabledMessage : placeholder}
placeholder={disabled ? disabledMessage : placeholder} displayValue={displayValue}
displayValue={displayValue} onChange={(event) => onInputChange(event.target.value)}
onChange={event => onInputChange(event.target.value)} disabled={disabled}
disabled={disabled}
/> />
</Card> </Card>
{options().length > 0 && ( {options().length > 0 && (
<ComboboxOptions className="hide-scrollbar absolute left-0 z-[100] mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700"> <ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar">
{options().map(option => ( {options().map((option) => (
<ComboboxOption <ComboboxOption
key={option.value} key={option.value}
value={option} value={option}
className={clsx( className={clsx(
// General styling // General styling
"cursor-default select-none px-4 py-2", "cursor-default select-none py-2 px-4",
// Hover and active states // Hover and active states
"hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900", "hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900",
// Dark mode // Dark mode
"dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200", "dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200"
)} )}
> >
{option.label} {option.label}
</ComboboxOption> </ComboboxOption>
@ -111,8 +108,10 @@ export function Combobox({
)} )}
{options().length === 0 && inputRef.current?.value && ( {options().length === 0 && inputRef.current?.value && (
<div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white px-4 py-2 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700"> <div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white dark:bg-slate-800 py-2 px-4 text-sm shadow-lg ring-1 ring-black/5 dark:ring-slate-700">
<div className="text-slate-500 dark:text-slate-400">{emptyMessage}</div> <div className="text-slate-500 dark:text-slate-400">
{emptyMessage}
</div>
</div> </div>
)} )}
</> </>

View File

@ -1,8 +1,4 @@
import { import { ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/24/outline";
ExclamationTriangleIcon,
CheckCircleIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
@ -47,15 +43,12 @@ const variantConfig = {
iconBgClass: "bg-blue-100", iconBgClass: "bg-blue-100",
buttonTheme: "primary", buttonTheme: "primary",
}, },
} as Record< } as Record<Variant, {
Variant,
{
icon: React.ElementType; icon: React.ElementType;
iconClass: string; iconClass: string;
iconBgClass: string; iconBgClass: string;
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger"; buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
} }>;
>;
export function ConfirmDialog({ export function ConfirmDialog({
open, open,
@ -73,18 +66,13 @@ export function ConfirmDialog({
return ( return (
<Modal open={open} onClose={onClose}> <Modal open={open} onClose={onClose}>
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out"> <div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800"> <div className="relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800 pointer-events-auto">
<div className="space-y-4"> <div className="space-y-4">
<div className="sm:flex sm:items-start"> <div className="sm:flex sm:items-start">
<div <div className={cx("mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10", iconBgClass)}>
className={cx(
"mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10",
iconBgClass,
)}
>
<Icon aria-hidden="true" className={cx("size-6", iconClass)} /> <Icon aria-hidden="true" className={cx("size-6", iconClass)} />
</div> </div>
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left"> <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h2 className="text-lg font-bold leading-tight text-black dark:text-white"> <h2 className="text-lg font-bold leading-tight text-black dark:text-white">
{title} {title}
</h2> </h2>
@ -96,7 +84,12 @@ export function ConfirmDialog({
<div className="flex justify-end gap-x-2"> <div className="flex justify-end gap-x-2">
{cancelText && ( {cancelText && (
<Button size="SM" theme="blank" text={cancelText} onClick={onClose} /> <Button
size="SM"
theme="blank"
text={cancelText}
onClick={onClose}
/>
)} )}
<Button <Button
size="SM" size="SM"

View File

@ -6,23 +6,16 @@ import { Button } from "@/components/Button";
import { InputFieldWithLabel, FieldError } from "@/components/InputField"; import { InputFieldWithLabel, FieldError } from "@/components/InputField";
import Fieldset from "@/components/Fieldset"; import Fieldset from "@/components/Fieldset";
import { MacroStepCard } from "@/components/MacroStepCard"; import { MacroStepCard } from "@/components/MacroStepCard";
import {
DEFAULT_DELAY,
MAX_STEPS_PER_MACRO,
MAX_KEYS_PER_STEP,
} from "@/constants/macros";
import FieldLabel from "@/components/FieldLabel"; import FieldLabel from "@/components/FieldLabel";
import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
interface ValidationErrors { interface ValidationErrors {
name?: string; name?: string;
steps?: Record< steps?: Record<number, {
number, keys?: string;
{ modifiers?: string;
keys?: string; delay?: string;
modifiers?: string; }>;
delay?: string;
}
>;
} }
interface MacroFormProps { interface MacroFormProps {
@ -63,14 +56,12 @@ export function MacroForm({
if (!macro.steps?.length) { if (!macro.steps?.length) {
newErrors.steps = { 0: { keys: "At least one step is required" } }; newErrors.steps = { 0: { keys: "At least one step is required" } };
} else { } else {
const hasKeyOrModifier = macro.steps.some( const hasKeyOrModifier = macro.steps.some(step =>
step => (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0, (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
); );
if (!hasKeyOrModifier) { if (!hasKeyOrModifier) {
newErrors.steps = { newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
0: { keys: "At least one step must have keys or modifiers" },
};
} }
} }
@ -95,10 +86,7 @@ export function MacroForm({
} }
}; };
const handleKeySelect = ( const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => {
stepIndex: number,
option: { value: string | null; keys?: string[] },
) => {
const newSteps = [...(macro.steps || [])]; const newSteps = [...(macro.steps || [])];
if (!newSteps[stepIndex]) return; if (!newSteps[stepIndex]) return;
@ -108,9 +96,7 @@ export function MacroForm({
if (!newSteps[stepIndex].keys) { if (!newSteps[stepIndex].keys) {
newSteps[stepIndex].keys = []; newSteps[stepIndex].keys = [];
} }
const keysArray = Array.isArray(newSteps[stepIndex].keys) const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : [];
? newSteps[stepIndex].keys
: [];
if (keysArray.length >= MAX_KEYS_PER_STEP) { if (keysArray.length >= MAX_KEYS_PER_STEP) {
showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`); showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
return; return;
@ -161,9 +147,9 @@ export function MacroForm({
setMacro({ ...macro, steps: newSteps }); setMacro({ ...macro, steps: newSteps });
}; };
const handleStepMove = (stepIndex: number, direction: "up" | "down") => { const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => {
const newSteps = [...(macro.steps || [])]; const newSteps = [...(macro.steps || [])];
const newIndex = direction === "up" ? stepIndex - 1 : stepIndex + 1; const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
[newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]]; [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
setMacro({ ...macro, steps: newSteps }); setMacro({ ...macro, steps: newSteps });
}; };
@ -194,10 +180,7 @@ export function MacroForm({
<div> <div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FieldLabel <FieldLabel label="Steps" description={`Keys/modifiers executed in sequence with a delay between each step.`} />
label="Steps"
description={`Keys/modifiers executed in sequence with a delay between each step.`}
/>
</div> </div>
<span className="text-slate-500 dark:text-slate-400"> <span className="text-slate-500 dark:text-slate-400">
{macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps {macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
@ -215,24 +198,18 @@ export function MacroForm({
key={stepIndex} key={stepIndex}
step={step} step={step}
stepIndex={stepIndex} stepIndex={stepIndex}
onDelete={ onDelete={macro.steps && macro.steps.length > 1 ? () => {
macro.steps && macro.steps.length > 1 const newSteps = [...(macro.steps || [])];
? () => { newSteps.splice(stepIndex, 1);
const newSteps = [...(macro.steps || [])]; setMacro(prev => ({ ...prev, steps: newSteps }));
newSteps.splice(stepIndex, 1); } : undefined}
setMacro(prev => ({ ...prev, steps: newSteps })); onMoveUp={() => handleStepMove(stepIndex, 'up')}
} onMoveDown={() => handleStepMove(stepIndex, 'down')}
: undefined onKeySelect={(option) => handleKeySelect(stepIndex, option)}
} onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
onMoveUp={() => handleStepMove(stepIndex, "up")} keyQuery={keyQueries[stepIndex] || ''}
onMoveDown={() => handleStepMove(stepIndex, "down")} onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
onKeySelect={option => handleKeySelect(stepIndex, option)} onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
onKeyQueryChange={query => handleKeyQueryChange(stepIndex, query)}
keyQuery={keyQueries[stepIndex] || ""}
onModifierChange={modifiers =>
handleModifierChange(stepIndex, modifiers)
}
onDelayChange={delay => handleDelayChange(stepIndex, delay)}
isLastStep={stepIndex === (macro.steps?.length || 0) - 1} isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
/> />
))} ))}
@ -245,12 +222,10 @@ export function MacroForm({
theme="light" theme="light"
fullWidth fullWidth
LeadingIcon={LuPlus} LeadingIcon={LuPlus}
text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ""}`} text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
onClick={() => { onClick={() => {
if (isMaxStepsReached) { if (isMaxStepsReached) {
showTemporaryError( showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`,
);
return; return;
} }
@ -258,7 +233,7 @@ export function MacroForm({
...prev, ...prev,
steps: [ steps: [
...(prev.steps || []), ...(prev.steps || []),
{ keys: [], modifiers: [], delay: DEFAULT_DELAY }, { keys: [], modifiers: [], delay: DEFAULT_DELAY }
], ],
})); }));
setErrors({}); setErrors({});
@ -281,7 +256,12 @@ export function MacroForm({
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting} disabled={isSubmitting}
/> />
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} /> <Button
size="SM"
theme="light"
text="Cancel"
onClick={onCancel}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -9,6 +9,7 @@ import { Button, LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
interface OverlayContentProps { interface OverlayContentProps {
children: React.ReactNode; children: React.ReactNode;
} }
@ -242,8 +243,8 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
Ensure source device is powered on and outputting a signal Ensure source device is powered on and outputting a signal
</li> </li>
<li> <li>
If using an adapter, ensure it&apos;s compatible and functioning If using an adapter, ensure it&apos;s compatible and
correctly functioning correctly
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -151,7 +151,7 @@ export default function WebRTCVideo() {
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock"); const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
if (isKeyboardLockGranted) { if (isKeyboardLockGranted) {
if ("keyboard" in navigator) { if ("keyboard" in navigator) {
// @ts-expect-error - keyboard lock is not supported in all browsers // @ts-expect-error because ignore error isn't good enough
await navigator.keyboard.lock(); await navigator.keyboard.lock();
} }
} }

View File

@ -1,11 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware"; import { createJSONStorage, persist } from "zustand/middleware";
import { import { MAX_STEPS_PER_MACRO, MAX_TOTAL_MACROS, MAX_KEYS_PER_STEP } from "@/constants/macros";
MAX_STEPS_PER_MACRO,
MAX_TOTAL_MACROS,
MAX_KEYS_PER_STEP,
} from "@/constants/macros";
// Define the JsonRpc types for better type checking // Define the JsonRpc types for better type checking
interface JsonRpcResponse { interface JsonRpcResponse {
@ -576,12 +572,12 @@ export interface UpdateState {
setOtaState: (state: UpdateState["otaState"]) => void; setOtaState: (state: UpdateState["otaState"]) => void;
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void; setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
modalView: modalView:
| "loading" | "loading"
| "updating" | "updating"
| "upToDate" | "upToDate"
| "updateAvailable" | "updateAvailable"
| "updateCompleted" | "updateCompleted"
| "error"; | "error";
setModalView: (view: UpdateState["modalView"]) => void; setModalView: (view: UpdateState["modalView"]) => void;
setUpdateErrorMessage: (errorMessage: string) => void; setUpdateErrorMessage: (errorMessage: string) => void;
updateErrorMessage: string | null; updateErrorMessage: string | null;
@ -645,12 +641,12 @@ export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({
interface LocalAuthModalState { interface LocalAuthModalState {
modalView: modalView:
| "createPassword" | "createPassword"
| "deletePassword" | "deletePassword"
| "updatePassword" | "updatePassword"
| "creationSuccess" | "creationSuccess"
| "deleteSuccess" | "deleteSuccess"
| "updateSuccess"; | "updateSuccess";
setModalView: (view: LocalAuthModalState["modalView"]) => void; setModalView: (view: LocalAuthModalState["modalView"]) => void;
} }
@ -731,23 +727,12 @@ export interface NetworkState {
setDhcpLeaseExpiry: (expiry: Date) => void; setDhcpLeaseExpiry: (expiry: Date) => void;
} }
export type IPv6Mode =
| "disabled" export type IPv6Mode = "disabled" | "slaac" | "dhcpv6" | "slaac_and_dhcpv6" | "static" | "link_local" | "unknown";
| "slaac"
| "dhcpv6"
| "slaac_and_dhcpv6"
| "static"
| "link_local"
| "unknown";
export type IPv4Mode = "disabled" | "static" | "dhcp" | "unknown"; export type IPv4Mode = "disabled" | "static" | "dhcp" | "unknown";
export type LLDPMode = "disabled" | "basic" | "all" | "unknown"; export type LLDPMode = "disabled" | "basic" | "all" | "unknown";
export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown"; export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown";
export type TimeSyncMode = export type TimeSyncMode = "ntp_only" | "ntp_and_http" | "http_only" | "custom" | "unknown";
| "ntp_only"
| "ntp_and_http"
| "http_only"
| "custom"
| "unknown";
export interface NetworkSettings { export interface NetworkSettings {
hostname: string; hostname: string;
@ -772,7 +757,7 @@ export const useNetworkStateStore = create<NetworkState>((set, get) => ({
lease.lease_expiry = expiry; lease.lease_expiry = expiry;
set({ dhcp_lease: lease }); set({ dhcp_lease: lease });
}, }
})); }));
export interface KeySequenceStep { export interface KeySequenceStep {
@ -794,20 +779,8 @@ export interface MacrosState {
initialized: boolean; initialized: boolean;
loadMacros: () => Promise<void>; loadMacros: () => Promise<void>;
saveMacros: (macros: KeySequence[]) => Promise<void>; saveMacros: (macros: KeySequence[]) => Promise<void>;
sendFn: sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void) | null;
| (( setSendFn: (sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void)) => void;
method: string,
params: unknown,
callback?: ((resp: JsonRpcResponse) => void) | undefined,
) => void)
| null;
setSendFn: (
sendFn: (
method: string,
params: unknown,
callback?: ((resp: JsonRpcResponse) => void) | undefined,
) => void,
) => void;
} }
export const generateMacroId = () => { export const generateMacroId = () => {
@ -820,7 +793,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
initialized: false, initialized: false,
sendFn: null, sendFn: null,
setSendFn: sendFn => { setSendFn: (sendFn) => {
set({ sendFn }); set({ sendFn });
}, },
@ -837,7 +810,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
sendFn("getKeyboardMacros", {}, response => { sendFn("getKeyboardMacros", {}, (response) => {
if (response.error) { if (response.error) {
console.error("Error loading macros:", response.error); console.error("Error loading macros:", response.error);
reject(new Error(response.error.message)); reject(new Error(response.error.message));
@ -857,7 +830,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
set({ set({
macros: sortedMacros, macros: sortedMacros,
initialized: true, initialized: true
}); });
resolve(); resolve();
@ -884,23 +857,15 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
for (const macro of macros) { for (const macro of macros) {
if (macro.steps.length > MAX_STEPS_PER_MACRO) { if (macro.steps.length > MAX_STEPS_PER_MACRO) {
console.error( console.error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`);
`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`, throw new Error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`);
);
throw new Error(
`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`,
);
} }
for (let i = 0; i < macro.steps.length; i++) { for (let i = 0; i < macro.steps.length; i++) {
const step = macro.steps[i]; const step = macro.steps[i];
if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) { if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) {
console.error( console.error(`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`);
`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`, throw new Error(`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`);
);
throw new Error(
`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`,
);
} }
} }
} }
@ -910,25 +875,20 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
try { try {
const macrosWithSortOrder = macros.map((macro, index) => ({ const macrosWithSortOrder = macros.map((macro, index) => ({
...macro, ...macro,
sortOrder: macro.sortOrder !== undefined ? macro.sortOrder : index, sortOrder: macro.sortOrder !== undefined ? macro.sortOrder : index
})); }));
const response = await new Promise<JsonRpcResponse>(resolve => { const response = await new Promise<JsonRpcResponse>((resolve) => {
sendFn( sendFn("setKeyboardMacros", { params: { macros: macrosWithSortOrder } }, (response) => {
"setKeyboardMacros", resolve(response);
{ params: { macros: macrosWithSortOrder } }, });
response => {
resolve(response);
},
);
}); });
if (response.error) { if (response.error) {
console.error("Error saving macros:", response.error); console.error("Error saving macros:", response.error);
const errorMessage = const errorMessage = typeof response.error.data === 'string'
typeof response.error.data === "string" ? response.error.data
? response.error.data : response.error.message || "Failed to save macros";
: response.error.message || "Failed to save macros";
throw new Error(errorMessage); throw new Error(errorMessage);
} }
@ -940,5 +900,5 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
} finally { } finally {
set({ loading: false }); set({ loading: false });
} }
}, }
})); }));

View File

@ -1,15 +1,6 @@
import { useEffect, Fragment, useMemo, useState, useCallback } from "react"; import { useEffect, Fragment, useMemo, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import { LuPenLine, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown, LuTrash2, LuCommand } from "react-icons/lu";
LuPenLine,
LuCopy,
LuMoveRight,
LuCornerDownRight,
LuArrowUp,
LuArrowDown,
LuTrash2,
LuCommand,
} from "react-icons/lu";
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores"; import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
import { SettingsPageHeader } from "@/components/SettingsPageheader"; import { SettingsPageHeader } from "@/components/SettingsPageheader";
@ -36,9 +27,9 @@ export default function SettingsMacrosRoute() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null); const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
const isMaxMacrosReached = useMemo( const isMaxMacrosReached = useMemo(() =>
() => macros.length >= MAX_TOTAL_MACROS, macros.length >= MAX_TOTAL_MACROS,
[macros.length], [macros.length]
); );
useEffect(() => { useEffect(() => {
@ -47,83 +38,75 @@ export default function SettingsMacrosRoute() {
} }
}, [initialized, loadMacros]); }, [initialized, loadMacros]);
const handleDuplicateMacro = useCallback( const handleDuplicateMacro = useCallback(async (macro: KeySequence) => {
async (macro: KeySequence) => { if (!macro?.id || !macro?.name) {
if (!macro?.id || !macro?.name) { notifications.error("Invalid macro data");
notifications.error("Invalid macro data"); return;
return; }
if (isMaxMacrosReached) {
notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
return;
}
setActionLoadingId(macro.id);
const newMacroCopy: KeySequence = {
...JSON.parse(JSON.stringify(macro)),
id: generateMacroId(),
name: `${macro.name} ${COPY_SUFFIX}`,
sortOrder: macros.length + 1,
};
try {
await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to duplicate macro: ${error.message}`);
} else {
notifications.error("Failed to duplicate macro");
} }
} finally {
setActionLoadingId(null);
}
}, [isMaxMacrosReached, macros, saveMacros, setActionLoadingId]);
if (isMaxMacrosReached) { const handleMoveMacro = useCallback(async (index: number, direction: 'up' | 'down', macroId: string) => {
notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); if (!Array.isArray(macros) || macros.length === 0) {
return; notifications.error("No macros available");
return;
}
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= macros.length) return;
setActionLoadingId(macroId);
try {
const newMacros = [...macros];
[newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
const updatedMacros = normalizeSortOrders(newMacros);
await saveMacros(updatedMacros);
notifications.success("Macro order updated successfully");
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to reorder macros: ${error.message}`);
} else {
notifications.error("Failed to reorder macros");
} }
} finally {
setActionLoadingId(macro.id); setActionLoadingId(null);
}
const newMacroCopy: KeySequence = { }, [macros, saveMacros, setActionLoadingId]);
...JSON.parse(JSON.stringify(macro)),
id: generateMacroId(),
name: `${macro.name} ${COPY_SUFFIX}`,
sortOrder: macros.length + 1,
};
try {
await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to duplicate macro: ${error.message}`);
} else {
notifications.error("Failed to duplicate macro");
}
} finally {
setActionLoadingId(null);
}
},
[isMaxMacrosReached, macros, saveMacros, setActionLoadingId],
);
const handleMoveMacro = useCallback(
async (index: number, direction: "up" | "down", macroId: string) => {
if (!Array.isArray(macros) || macros.length === 0) {
notifications.error("No macros available");
return;
}
const newIndex = direction === "up" ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= macros.length) return;
setActionLoadingId(macroId);
try {
const newMacros = [...macros];
[newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
const updatedMacros = normalizeSortOrders(newMacros);
await saveMacros(updatedMacros);
notifications.success("Macro order updated successfully");
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to reorder macros: ${error.message}`);
} else {
notifications.error("Failed to reorder macros");
}
} finally {
setActionLoadingId(null);
}
},
[macros, saveMacros, setActionLoadingId],
);
const handleDeleteMacro = useCallback(async () => { const handleDeleteMacro = useCallback(async () => {
if (!macroToDelete?.id) return; if (!macroToDelete?.id) return;
setActionLoadingId(macroToDelete.id); setActionLoadingId(macroToDelete.id);
try { try {
const updatedMacros = normalizeSortOrders( const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macroToDelete.id));
macros.filter(m => m.id !== macroToDelete.id),
);
await saveMacros(updatedMacros); await saveMacros(updatedMacros);
notifications.success(`Macro "${macroToDelete.name}" deleted successfully`); notifications.success(`Macro "${macroToDelete.name}" deleted successfully`);
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
@ -139,168 +122,135 @@ export default function SettingsMacrosRoute() {
} }
}, [macroToDelete, macros, saveMacros]); }, [macroToDelete, macros, saveMacros]);
const MacroList = useMemo( const MacroList = useMemo(() => (
() => ( <div className="space-y-2">
<div className="space-y-2"> {macros.map((macro, index) => (
{macros.map((macro, index) => ( <Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
<Card key={macro.id} className="bg-white p-2 dark:bg-slate-800"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-1 px-2">
<div className="flex flex-col gap-1 px-2"> <Button
<Button size="XS"
size="XS" theme="light"
theme="light" onClick={() => handleMoveMacro(index, 'up', macro.id)}
onClick={() => handleMoveMacro(index, "up", macro.id)} disabled={index === 0 || actionLoadingId === macro.id}
disabled={index === 0 || actionLoadingId === macro.id} LeadingIcon={LuArrowUp}
LeadingIcon={LuArrowUp} aria-label={`Move ${macro.name} up`}
aria-label={`Move ${macro.name} up`} />
/> <Button
<Button size="XS"
size="XS" theme="light"
theme="light" onClick={() => handleMoveMacro(index, 'down', macro.id)}
onClick={() => handleMoveMacro(index, "down", macro.id)} disabled={index === macros.length - 1 || actionLoadingId === macro.id}
disabled={index === macros.length - 1 || actionLoadingId === macro.id} LeadingIcon={LuArrowDown}
LeadingIcon={LuArrowDown} aria-label={`Move ${macro.name} down`}
aria-label={`Move ${macro.name} down`} />
/>
</div>
<div className="ml-2 flex min-w-0 flex-1 flex-col justify-center">
<h3 className="truncate text-sm font-semibold text-black dark:text-white">
{macro.name}
</h3>
<p className="ml-4 mt-1 overflow-hidden text-xs text-slate-500 dark:text-slate-400">
<span className="flex flex-col items-start gap-1">
{macro.steps.map((step, stepIndex) => {
const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
return (
<span key={stepIndex} className="inline-flex items-center">
<StepIcon className="mr-1 h-3 w-3 shrink-0 text-slate-400 dark:text-slate-500" />
<span className="px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50 bg-slate-50 dark:bg-slate-800">
{(Array.isArray(step.modifiers) &&
step.modifiers.length > 0) ||
(Array.isArray(step.keys) && step.keys.length > 0) ? (
<>
{Array.isArray(step.modifiers) &&
step.modifiers.map((modifier, idx) => (
<Fragment key={`mod-${idx}`}>
<span className="font-medium text-slate-600 dark:text-slate-200">
{modifierDisplayMap[modifier] || modifier}
</span>
{idx < step.modifiers.length - 1 && (
<span className="text-slate-400 dark:text-slate-600">
{" "}
+{" "}
</span>
)}
</Fragment>
))}
{Array.isArray(step.modifiers) &&
step.modifiers.length > 0 &&
Array.isArray(step.keys) &&
step.keys.length > 0 && (
<span className="text-slate-400 dark:text-slate-600">
{" "}
+{" "}
</span>
)}
{Array.isArray(step.keys) &&
step.keys.map((key, idx) => (
<Fragment key={`key-${idx}`}>
<span className="font-medium text-blue-600 dark:text-blue-400">
{keyDisplayMap[key] || key}
</span>
{idx < step.keys.length - 1 && (
<span className="text-slate-400 dark:text-slate-600">
{" "}
+{" "}
</span>
)}
</Fragment>
))}
</>
) : (
<span className="font-medium text-slate-500 dark:text-slate-400">
Delay only
</span>
)}
{step.delay !== DEFAULT_DELAY && (
<span className="ml-1 text-slate-400 dark:text-slate-500">
({step.delay}ms)
</span>
)}
</span>
</span>
);
})}
</span>
</p>
</div>
<div className="ml-4 flex items-center gap-1">
<Button
size="XS"
className="text-red-500 dark:text-red-400"
theme="light"
LeadingIcon={LuTrash2}
onClick={() => {
setMacroToDelete(macro);
setShowDeleteConfirm(true);
}}
disabled={actionLoadingId === macro.id}
aria-label={`Delete macro ${macro.name}`}
/>
<Button
size="XS"
theme="light"
LeadingIcon={LuCopy}
onClick={() => handleDuplicateMacro(macro)}
disabled={actionLoadingId === macro.id}
aria-label={`Duplicate macro ${macro.name}`}
/>
<Button
size="XS"
theme="light"
LeadingIcon={LuPenLine}
text="Edit"
onClick={() => navigate(`${macro.id}/edit`)}
disabled={actionLoadingId === macro.id}
aria-label={`Edit macro ${macro.name}`}
/>
</div>
</div> </div>
</Card>
))}
<ConfirmDialog <div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
open={showDeleteConfirm} <h3 className="truncate text-sm font-semibold text-black dark:text-white">
onClose={() => { {macro.name}
setShowDeleteConfirm(false); </h3>
setMacroToDelete(null); <p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
}} <span className="flex flex-col items-start gap-1">
title="Delete Macro" {macro.steps.map((step, stepIndex) => {
description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`} const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
variant="danger"
confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"} return (
onConfirm={handleDeleteMacro} <span key={stepIndex} className="inline-flex items-center">
isConfirming={actionLoadingId === macroToDelete?.id} <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 shrink-0" />
/> <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
</div> {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
), <>
[ {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => (
macros, <Fragment key={`mod-${idx}`}>
showDeleteConfirm, <span className="font-medium text-slate-600 dark:text-slate-200">
macroToDelete?.name, {modifierDisplayMap[modifier] || modifier}
macroToDelete?.id, </span>
actionLoadingId, {idx < step.modifiers.length - 1 && (
handleDeleteMacro, <span className="text-slate-400 dark:text-slate-600"> + </span>
handleMoveMacro, )}
handleDuplicateMacro, </Fragment>
navigate, ))}
],
); {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
<span className="text-slate-400 dark:text-slate-600"> + </span>
)}
{Array.isArray(step.keys) && step.keys.map((key, idx) => (
<Fragment key={`key-${idx}`}>
<span className="font-medium text-blue-600 dark:text-blue-400">
{keyDisplayMap[key] || key}
</span>
{idx < step.keys.length - 1 && (
<span className="text-slate-400 dark:text-slate-600"> + </span>
)}
</Fragment>
))}
</>
) : (
<span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
)}
{step.delay !== DEFAULT_DELAY && (
<span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
)}
</span>
</span>
);
})}
</span>
</p>
</div>
<div className="flex items-center gap-1 ml-4">
<Button
size="XS"
className="text-red-500 dark:text-red-400"
theme="light"
LeadingIcon={LuTrash2}
onClick={() => {
setMacroToDelete(macro);
setShowDeleteConfirm(true);
}}
disabled={actionLoadingId === macro.id}
aria-label={`Delete macro ${macro.name}`}
/>
<Button
size="XS"
theme="light"
LeadingIcon={LuCopy}
onClick={() => handleDuplicateMacro(macro)}
disabled={actionLoadingId === macro.id}
aria-label={`Duplicate macro ${macro.name}`}
/>
<Button
size="XS"
theme="light"
LeadingIcon={LuPenLine}
text="Edit"
onClick={() => navigate(`${macro.id}/edit`)}
disabled={actionLoadingId === macro.id}
aria-label={`Edit macro ${macro.name}`}
/>
</div>
</div>
</Card>
))}
<ConfirmDialog
open={showDeleteConfirm}
onClose={() => {
setShowDeleteConfirm(false);
setMacroToDelete(null);
}}
title="Delete Macro"
description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`}
variant="danger"
confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"}
onConfirm={handleDeleteMacro}
isConfirming={actionLoadingId === macroToDelete?.id}
/>
</div>
), [macros, actionLoadingId, showDeleteConfirm, macroToDelete, handleDeleteMacro, handleDuplicateMacro, handleMoveMacro, navigate]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -309,7 +259,7 @@ export default function SettingsMacrosRoute() {
title="Keyboard Macros" title="Keyboard Macros"
description={`Combine keystrokes into a single action for faster workflows.`} description={`Combine keystrokes into a single action for faster workflows.`}
/> />
{macros.length > 0 && ( { macros.length > 0 && (
<div className="flex items-center pl-2"> <div className="flex items-center pl-2">
<Button <Button
size="SM" size="SM"
@ -349,9 +299,7 @@ export default function SettingsMacrosRoute() {
/> />
} }
/> />
) : ( ) : MacroList}
MacroList
)}
</div> </div>
</div> </div>
); );

View File

@ -1,28 +1,18 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import dayjs from "dayjs"; import dayjs from 'dayjs';
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from 'dayjs/plugin/relativeTime';
import { ArrowPathIcon } from "@heroicons/react/24/outline";
import { import { IPv4Mode, IPv6Mode, LLDPMode, mDNSMode, NetworkSettings, NetworkState, TimeSyncMode, useNetworkStateStore } from "@/hooks/stores";
IPv4Mode,
IPv6Mode,
LLDPMode,
mDNSMode,
NetworkSettings,
NetworkState,
TimeSyncMode,
useNetworkStateStore,
} from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import InputField from "@components/InputField"; import InputField from "@components/InputField";
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import { SettingsPageHeader } from "@/components/SettingsPageheader";
import Fieldset from "@/components/Fieldset";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings"; import { SettingsItem } from "./devices.$id.settings";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@ -36,7 +26,7 @@ const defaultNetworkSettings: NetworkSettings = {
lldp_tx_tlvs: [], lldp_tx_tlvs: [],
mdns_mode: "unknown", mdns_mode: "unknown",
time_sync_mode: "unknown", time_sync_mode: "unknown",
}; }
export function LifeTimeLabel({ lifetime }: { lifetime: string }) { export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
const [remaining, setRemaining] = useState<string | null>(null); const [remaining, setRemaining] = useState<string | null>(null);
@ -54,87 +44,46 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
return <strong>N/A</strong>; return <strong>N/A</strong>;
} }
return ( return <>
<> <strong>{dayjs(lifetime).format()}</strong>
<strong>{dayjs(lifetime).format("YYYY-MM-DD HH:mm")}</strong> {remaining && <>
{remaining && ( {" "}<span className="text-xs text-slate-700 dark:text-slate-300">
<> ({remaining})
{" "} </span>
<span className="text-xs text-slate-700 dark:text-slate-300"> </>}
({remaining}) </>
</span>
</>
)}
</>
);
} }
export default function SettingsNetworkRoute() { export default function SettingsNetworkRoute() {
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const [networkState, setNetworkState] = useNetworkStateStore(state => [ const [networkState, setNetworkState] = useNetworkStateStore(state => [state, state.setNetworkState]);
state,
state.setNetworkState,
]);
const [networkSettings, setNetworkSettings] =
useState<NetworkSettings>(defaultNetworkSettings);
// We use this to determine whether the settings have changed
const firstNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
const [networkSettings, setNetworkSettings] = useState<NetworkSettings>(defaultNetworkSettings);
const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false); const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false);
const [customDomain, setCustomDomain] = useState<string>("");
const [selectedDomainOption, setSelectedDomainOption] = useState<string>("dhcp");
useEffect(() => {
if (networkSettings.domain && networkSettingsLoaded) {
// Check if the domain is one of the predefined options
const predefinedOptions = ["dhcp", "local"];
if (predefinedOptions.includes(networkSettings.domain)) {
setSelectedDomainOption(networkSettings.domain);
} else {
setSelectedDomainOption("custom");
setCustomDomain(networkSettings.domain);
}
}
}, [networkSettings.domain, networkSettingsLoaded]);
const getNetworkSettings = useCallback(() => { const getNetworkSettings = useCallback(() => {
setNetworkSettingsLoaded(false); setNetworkSettingsLoaded(false);
send("getNetworkSettings", {}, resp => { send("getNetworkSettings", {}, resp => {
if ("error" in resp) return; if ("error" in resp) return;
console.log(resp.result); console.log(resp.result);
setNetworkSettings(resp.result as NetworkSettings); setNetworkSettings(resp.result as NetworkSettings);
if (!firstNetworkSettings.current) {
firstNetworkSettings.current = resp.result as NetworkSettings;
}
setNetworkSettingsLoaded(true); setNetworkSettingsLoaded(true);
}); });
}, [send]); }, [send]);
const setNetworkSettingsRemote = useCallback( const setNetworkSettingsRemote = useCallback((settings: NetworkSettings) => {
(settings: NetworkSettings) => { setNetworkSettingsLoaded(false);
setNetworkSettingsLoaded(false); send("setNetworkSettings", { settings }, resp => {
send("setNetworkSettings", { settings }, resp => { if ("error" in resp) {
if ("error" in resp) { notifications.error("Failed to save network settings: " + (resp.error.data ? resp.error.data : resp.error.message));
notifications.error(
"Failed to save network settings: " +
(resp.error.data ? resp.error.data : resp.error.message),
);
setNetworkSettingsLoaded(true);
return;
}
// We need to update the firstNetworkSettings ref to the new settings so we can use it to determine if the settings have changed
firstNetworkSettings.current = resp.result as NetworkSettings;
setNetworkSettings(resp.result as NetworkSettings);
setNetworkSettingsLoaded(true); setNetworkSettingsLoaded(true);
notifications.success("Network settings saved"); return;
}); }
}, setNetworkSettings(resp.result as NetworkSettings);
[send], setNetworkSettingsLoaded(true);
); notifications.success("Network settings saved");
});
}, [send]);
const getNetworkState = useCallback(() => { const getNetworkState = useCallback(() => {
send("getNetworkState", {}, resp => { send("getNetworkState", {}, resp => {
@ -183,520 +132,278 @@ export default function SettingsNetworkRoute() {
setNetworkSettings({ ...networkSettings, time_sync_mode: value as TimeSyncMode }); setNetworkSettings({ ...networkSettings, time_sync_mode: value as TimeSyncMode });
}; };
const handleHostnameChange = (value: string) => { const filterUnknown = useCallback((options: { value: string; label: string; }[]) => {
setNetworkSettings({ ...networkSettings, hostname: value }); if (!networkSettingsLoaded) return options;
}; return options.filter(option => option.value !== "unknown");
}, [networkSettingsLoaded]);
const handleDomainChange = (value: string) => {
setNetworkSettings({ ...networkSettings, domain: value });
};
const handleDomainOptionChange = (value: string) => {
setSelectedDomainOption(value);
if (value !== "custom") {
handleDomainChange(value);
}
};
const handleCustomDomainChange = (value: string) => {
setCustomDomain(value);
handleDomainChange(value);
};
const filterUnknown = useCallback(
(options: { value: string; label: string }[]) => {
if (!networkSettingsLoaded) return options;
return options.filter(option => option.value !== "unknown");
},
[networkSettingsLoaded],
);
const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false);
return ( return (
<> <div className="space-y-4">
<Fieldset disabled={!networkSettingsLoaded} className="space-y-4"> <SettingsPageHeader
<SettingsPageHeader title="Network"
title="Network" description="Configure your network settings"
description="Configure your network settings" />
/> <div className="space-y-4">
<div className="space-y-4"> <SettingsItem
<SettingsItem title="MAC Address"
title="MAC Address" description={<></>}
description="Hardware identifier for the network interface" >
> <span className="select-auto font-mono text-xs text-slate-700 dark:text-slate-300">
<InputField {networkState?.mac_address}
type="text" </span>
size="SM" </SettingsItem>
value={networkState?.mac_address} </div>
error={""} <div className="space-y-4">
disabled={true} <SettingsItem
readOnly={true} title="Hostname"
className="dark:!text-opacity-60" description={
/> <>
</SettingsItem> Hostname for the device
</div> <br />
<div className="space-y-4"> <span className="text-xs text-slate-700 dark:text-slate-300">
<SettingsItem Leave blank for default
title="Hostname" </span>
description="Device identifier on the network. Blank for system default" </>
> }
<div className="relative"> >
<div> <InputField
<InputField type="text"
size="SM" placeholder="jetkvm"
type="text" value={networkSettings.hostname}
placeholder="jetkvm" error={""}
defaultValue={networkSettings.hostname} onChange={e => {
onChange={e => { setNetworkSettings({ ...networkSettings, hostname: e.target.value });
handleHostnameChange(e.target.value); }}
}} disabled={!networkSettingsLoaded}
/>
</div>
</div>
</SettingsItem>
</div>
<div className="space-y-4">
<div className="space-y-4">
<SettingsItem
title="Domain"
description="Network domain suffix for the device"
>
<div className="space-y-2">
<SelectMenuBasic
size="SM"
value={selectedDomainOption}
onChange={e => handleDomainOptionChange(e.target.value)}
options={[
{ value: "dhcp", label: "DHCP provided" },
{ value: "local", label: ".local" },
{ value: "custom", label: "Custom" },
]}
/>
</div>
</SettingsItem>
{selectedDomainOption === "custom" && (
<div className="flex items-center justify-between gap-x-2">
<InputField
size="SM"
type="text"
placeholder="home"
value={customDomain}
onChange={e => setCustomDomain(e.target.value)}
/>
<Button
size="SM"
theme="primary"
text="Save Domain"
onClick={() => handleCustomDomainChange(customDomain)}
/>
</div>
)}
</div>
<div className="space-y-4">
<SettingsItem
title="mDNS"
description="Control mDNS (multicast DNS) operational mode"
>
<SelectMenuBasic
size="SM"
value={networkSettings.mdns_mode}
onChange={e => handleMdnsModeChange(e.target.value)}
options={filterUnknown([
{ value: "disabled", label: "Disabled" },
{ value: "auto", label: "Auto" },
{ value: "ipv4_only", label: "IPv4 only" },
{ value: "ipv6_only", label: "IPv6 only" },
])}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Time synchronization"
description="Configure time synchronization settings"
>
<SelectMenuBasic
size="SM"
value={networkSettings.time_sync_mode}
onChange={e => {
handleTimeSyncModeChange(e.target.value);
}}
options={filterUnknown([
{ value: "unknown", label: "..." },
// { value: "auto", label: "Auto" },
{ value: "ntp_only", label: "NTP only" },
{ value: "ntp_and_http", label: "NTP and HTTP" },
{ value: "http_only", label: "HTTP only" },
// { value: "custom", label: "Custom" },
])}
/>
</SettingsItem>
</div>
<Button
size="SM"
theme="primary"
disabled={firstNetworkSettings.current === networkSettings}
text="Save Settings"
onClick={() => setNetworkSettingsRemote(networkSettings)}
/> />
</div> </SettingsItem>
</div>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" /> <div className="space-y-4">
<SettingsItem
<div className="space-y-4"> title="Domain"
<SettingsItem title="IPv4 Mode" description="Configure the IPv4 mode"> description={
<SelectMenuBasic <>
size="SM" Domain for the device
value={networkSettings.ipv4_mode} <br />
onChange={e => handleIpv4ModeChange(e.target.value)} <span className="text-xs text-slate-700 dark:text-slate-300">
options={filterUnknown([ Leave blank to use DHCP provided domain, if there is no domain, use <span className="font-mono">local</span>
{ value: "dhcp", label: "DHCP" }, </span>
// { value: "static", label: "Static" }, </>
])} }
/> >
</SettingsItem> <InputField
{networkState?.dhcp_lease && ( type="text"
<GridCard> placeholder="local"
<div className="p-4"> value={networkSettings.domain}
<div className="space-y-4"> error={""}
onChange={e => {
setNetworkSettings({ ...networkSettings, domain: e.target.value });
}}
disabled={!networkSettingsLoaded}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="IPv4 Mode"
description="Configure the IPv4 mode"
>
<SelectMenuBasic
size="SM"
value={networkSettings.ipv4_mode}
onChange={e => handleIpv4ModeChange(e.target.value)}
disabled={!networkSettingsLoaded}
options={filterUnknown([
{ value: "dhcp", label: "DHCP" },
// { value: "static", label: "Static" },
])}
/>
</SettingsItem>
{networkState?.dhcp_lease && (
<GridCard>
<div className="flex items-start gap-x-4 p-4">
<div className="space-y-3 w-full">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white"> <h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease Current DHCP Lease
</h3> </h3>
<div className="flex gap-x-6 gap-y-2">
<div className="flex-1 space-y-2">
{networkState?.dhcp_lease?.ip && (
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
IP Address
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.ip}
</span>
</div>
)}
{networkState?.dhcp_lease?.netmask && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Subnet Mask
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.netmask}
</span>
</div>
)}
{networkState?.dhcp_lease?.dns && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
DNS Servers
</span>
<span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.dns.map(dns => (
<div key={dns}>{dns}</div>
))}
</span>
</div>
)}
{networkState?.dhcp_lease?.broadcast && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Broadcast
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.broadcast}
</span>
</div>
)}
{networkState?.dhcp_lease?.domain && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Domain
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.domain}
</span>
</div>
)}
{networkState?.dhcp_lease?.ntp_servers &&
networkState?.dhcp_lease?.ntp_servers.length > 0 && (
<div className="flex justify-between gap-x-8 border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<div className="w-full grow text-sm text-slate-600 dark:text-slate-400">
NTP Servers
</div>
<div className="shrink text-right text-sm font-medium">
{networkState?.dhcp_lease?.ntp_servers.map(server => (
<div key={server}>{server}</div>
))}
</div>
</div>
)}
{networkState?.dhcp_lease?.hostname && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Hostname
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.hostname}
</span>
</div>
)}
</div>
<div className="flex-1 space-y-2">
{networkState?.dhcp_lease?.routers &&
networkState?.dhcp_lease?.routers.length > 0 && (
<div className="flex justify-between pt-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
Gateway
</span>
<span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.routers.map(router => (
<div key={router}>{router}</div>
))}
</span>
</div>
)}
{networkState?.dhcp_lease?.server_id && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
DHCP Server
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.server_id}
</span>
</div>
)}
{networkState?.dhcp_lease?.lease_expiry && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Lease Expires
</span>
<span className="text-sm font-medium">
<LifeTimeLabel
lifetime={`${networkState?.dhcp_lease?.lease_expiry}`}
/>
</span>
</div>
)}
{networkState?.dhcp_lease?.mtu && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
MTU
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.mtu}
</span>
</div>
)}
{networkState?.dhcp_lease?.ttl && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
TTL
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.ttl}
</span>
</div>
)}
{networkState?.dhcp_lease?.bootp_next_server && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot Next Server
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_next_server}
</span>
</div>
)}
{networkState?.dhcp_lease?.bootp_server_name && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot Server Name
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_server_name}
</span>
</div>
)}
{networkState?.dhcp_lease?.bootp_file && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot File
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_file}
</span>
</div>
)}
</div>
</div>
<div> <div>
<Button <ul className="list-none space-y-1 text-xs text-slate-700 dark:text-slate-300">
size="SM" {networkState?.dhcp_lease?.ip && <li>IP: <strong>{networkState?.dhcp_lease?.ip}</strong></li>}
theme="light" {networkState?.dhcp_lease?.netmask && <li>Subnet: <strong>{networkState?.dhcp_lease?.netmask}</strong></li>}
className="text-red-500" {networkState?.dhcp_lease?.broadcast && <li>Broadcast: <strong>{networkState?.dhcp_lease?.broadcast}</strong></li>}
text="Renew DHCP Lease" {networkState?.dhcp_lease?.ttl && <li>TTL: <strong>{networkState?.dhcp_lease?.ttl}</strong></li>}
LeadingIcon={ArrowPathIcon} {networkState?.dhcp_lease?.mtu && <li>MTU: <strong>{networkState?.dhcp_lease?.mtu}</strong></li>}
onClick={() => setShowRenewLeaseConfirm(true)} {networkState?.dhcp_lease?.hostname && <li>Hostname: <strong>{networkState?.dhcp_lease?.hostname}</strong></li>}
/> {networkState?.dhcp_lease?.domain && <li>Domain: <strong>{networkState?.dhcp_lease?.domain}</strong></li>}
{networkState?.dhcp_lease?.routers && <li>Gateway: <strong>{networkState?.dhcp_lease?.routers.join(", ")}</strong></li>}
{networkState?.dhcp_lease?.dns && <li>DNS: <strong>{networkState?.dhcp_lease?.dns.join(", ")}</strong></li>}
{networkState?.dhcp_lease?.ntp_servers && <li>NTP Servers: <strong>{networkState?.dhcp_lease?.ntp_servers.join(", ")}</strong></li>}
{networkState?.dhcp_lease?.server_id && <li>Server ID: <strong>{networkState?.dhcp_lease?.server_id}</strong></li>}
{networkState?.dhcp_lease?.bootp_next_server && <li>BootP Next Server: <strong>{networkState?.dhcp_lease?.bootp_next_server}</strong></li>}
{networkState?.dhcp_lease?.bootp_server_name && <li>BootP Server Name: <strong>{networkState?.dhcp_lease?.bootp_server_name}</strong></li>}
{networkState?.dhcp_lease?.bootp_file && <li>Boot File: <strong>{networkState?.dhcp_lease?.bootp_file}</strong></li>}
{networkState?.dhcp_lease?.lease_expiry && <li>
Lease Expiry: <LifeTimeLabel lifetime={`${networkState?.dhcp_lease?.lease_expiry}`} />
</li>}
{/* {JSON.stringify(networkState?.dhcp_lease)} */}
</ul>
</div> </div>
</div> </div>
<hr className="block w-full dark:border-slate-600" />
<div>
<Button
size="SM"
theme="danger"
text="Renew lease"
onClick={() => {
handleRenewLease();
}}
/>
</div>
</div> </div>
</GridCard> </div>
)} </GridCard>
</div> )}
<div className="space-y-4"> </div>
<SettingsItem title="IPv6 Mode" description="Configure the IPv6 mode"> <div className="space-y-4">
<SelectMenuBasic <SettingsItem
size="SM" title="IPv6 Mode"
value={networkSettings.ipv6_mode} description="Configure the IPv6 mode"
onChange={e => handleIpv6ModeChange(e.target.value)} >
options={filterUnknown([ <SelectMenuBasic
// { value: "disabled", label: "Disabled" }, size="SM"
{ value: "slaac", label: "SLAAC" }, value={networkSettings.ipv6_mode}
// { value: "dhcpv6", label: "DHCPv6" }, onChange={e => handleIpv6ModeChange(e.target.value)}
// { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" }, disabled={!networkSettingsLoaded}
// { value: "static", label: "Static" }, options={filterUnknown([
// { value: "link_local", label: "Link-local only" }, // { value: "disabled", label: "Disabled" },
])} { value: "slaac", label: "SLAAC" },
/> // { value: "dhcpv6", label: "DHCPv6" },
</SettingsItem> // { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" },
{networkState?.ipv6_addresses && ( // { value: "static", label: "Static" },
<GridCard> // { value: "link_local", label: "Link-local only" },
<div className="p-4"> ])}
<div className="space-y-4"> />
</SettingsItem>
{networkState?.ipv6_addresses && (
<GridCard>
<div className="flex items-start gap-x-4 p-4">
<div className="space-y-3 w-full">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white"> <h3 className="text-base font-bold text-slate-900 dark:text-white">
IPv6 Information IPv6 Information
</h3> </h3>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-x-6 gap-y-2"> <div>
{networkState?.dhcp_lease?.ip && ( <h4 className="text-sm font-bold text-slate-900 dark:text-white">
<div className="flex flex-col justify-between"> IPv6 Link-local
<span className="text-sm text-slate-600 dark:text-slate-400"> </h4>
Link-local <p className="text-xs text-slate-700 dark:text-slate-300">
</span> {networkState?.ipv6_link_local}
<span className="text-sm font-medium"> </p>
{networkState?.ipv6_link_local} </div>
</span> <div>
</div> <h4 className="text-sm font-bold text-slate-900 dark:text-white">
)} IPv6 Addresses
</div> </h4>
<ul className="list-none space-y-1 text-xs text-slate-700 dark:text-slate-300">
<div className="space-y-3 pt-2"> {networkState?.ipv6_addresses && networkState?.ipv6_addresses.map(addr => (
{networkState?.ipv6_addresses && <li key={addr.address}>
networkState?.ipv6_addresses.length > 0 && ( {addr.address}
<div className="space-y-3"> {addr.valid_lifetime && <>
<h4 className="text-sm font-semibold">IPv6 Addresses</h4> <br />
{networkState.ipv6_addresses.map(addr => ( - valid_lft: {" "}
<div <span className="text-xs text-slate-700 dark:text-slate-300">
key={addr.address} <LifeTimeLabel lifetime={`${addr.valid_lifetime}`} />
className="rounded-md rounded-l-none border border-slate-500/10 border-l-blue-700/50 bg-slate-100/40 p-4 pl-4 dark:border-blue-500 dark:bg-slate-900" </span>
> </>}
<div className="grid grid-cols-2 gap-x-8 gap-y-4"> {addr.preferred_lifetime && <>
<div className="col-span-2 flex flex-col justify-between"> <br />
<span className="text-sm text-slate-600 dark:text-slate-400"> - pref_lft: {" "}
Address <span className="text-xs text-slate-700 dark:text-slate-300">
</span> <LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} />
<span className="text-sm font-medium"> </span>
{addr.address} </>}
</span> </li>
</div> ))}
</ul>
{addr.valid_lifetime && ( </div>
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Valid Lifetime
</span>
<span className="text-sm font-medium">
{addr.valid_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel
lifetime={`${addr.valid_lifetime}`}
/>
)}
</span>
</div>
)}
{addr.preferred_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Preferred Lifetime
</span>
<span className="text-sm font-medium">
{addr.preferred_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel
lifetime={`${addr.preferred_lifetime}`}
/>
)}
</span>
</div>
)}
</div>
</div>
))}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
</GridCard> </div>
)} </GridCard>
</div> )}
<div className="hidden space-y-4"> </div>
<SettingsItem <div className="space-y-4 hidden">
title="LLDP" <SettingsItem
description="Control which TLVs will be sent over Link Layer Discovery Protocol" title="LLDP"
> description="Control which TLVs will be sent over Link Layer Discovery Protocol"
<SelectMenuBasic >
size="SM" <SelectMenuBasic
value={networkSettings.lldp_mode} size="SM"
onChange={e => handleLldpModeChange(e.target.value)} value={networkSettings.lldp_mode}
options={filterUnknown([ onChange={e => handleLldpModeChange(e.target.value)}
{ value: "disabled", label: "Disabled" }, disabled={!networkSettingsLoaded}
{ value: "basic", label: "Basic" }, options={filterUnknown([
{ value: "all", label: "All" }, { value: "disabled", label: "Disabled" },
])} { value: "basic", label: "Basic" },
/> { value: "all", label: "All" },
</SettingsItem> ])}
</div> />
</Fieldset> </SettingsItem>
<ConfirmDialog </div>
open={showRenewLeaseConfirm} <div className="space-y-4">
onClose={() => setShowRenewLeaseConfirm(false)} <SettingsItem
title="Renew DHCP Lease" title="mDNS"
description="This will request a new IP address from your DHCP server. Your device may temporarily lose network connectivity during this process." description="Control mDNS (multicast DNS) operational mode"
variant="danger" >
confirmText="Renew Lease" <SelectMenuBasic
onConfirm={() => { size="SM"
handleRenewLease(); value={networkSettings.mdns_mode}
setShowRenewLeaseConfirm(false); onChange={e => handleMdnsModeChange(e.target.value)}
}} disabled={!networkSettingsLoaded}
/> options={filterUnknown([
</> { value: "disabled", label: "Disabled" },
{ value: "auto", label: "Auto" },
{ value: "ipv4_only", label: "IPv4 only" },
{ value: "ipv6_only", label: "IPv6 only" },
])}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Time synchronization"
description="Configure time synchronization settings"
>
<SelectMenuBasic
size="SM"
value={networkSettings.time_sync_mode}
onChange={e => handleTimeSyncModeChange(e.target.value)}
disabled={!networkSettingsLoaded}
options={filterUnknown([
{ value: "unknown", label: "..." },
// { value: "auto", label: "Auto" },
{ value: "ntp_only", label: "NTP only" },
{ value: "ntp_and_http", label: "NTP and HTTP" },
{ value: "http_only", label: "HTTP only" },
// { value: "custom", label: "Custom" },
])}
/>
</SettingsItem>
</div>
<div className="flex items-end gap-x-2">
<Button
onClick={() => {
setNetworkSettingsRemote(networkSettings);
}}
size="SM"
theme="light"
text="Save Settings"
/>
</div>
</div>
); );
} }

View File

@ -53,7 +53,7 @@ export default function WelcomeRoute() {
</div> </div>
<div <div
className="space-y-1 animate-fadeIn" className="space-y-1 opacity-0 animate-fadeIn"
style={{ animationDelay: "1500ms" }} style={{ animationDelay: "1500ms" }}
> >
<h1 className="text-4xl font-semibold text-black dark:text-white"> <h1 className="text-4xl font-semibold text-black dark:text-white">