diff --git a/ui/src/components/Combobox.tsx b/ui/src/components/Combobox.tsx index 8055043..661b77b 100644 --- a/ui/src/components/Combobox.tsx +++ b/ui/src/components/Combobox.tsx @@ -1,7 +1,14 @@ import { useRef } from "react"; import clsx from "clsx"; -import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; +import { + Combobox as HeadlessCombobox, + ComboboxInput, + ComboboxOption, + ComboboxOptions, +} from "@headlessui/react"; + import { cva } from "@/cva.config"; + import Card from "./Card"; export interface ComboboxOption { @@ -22,7 +29,7 @@ const comboboxVariants = cva({ type BaseProps = React.ComponentProps; -interface ComboboxProps extends Omit { +interface ComboboxProps extends Omit { displayValue: (option: ComboboxOption) => string; onInputChange: (option: string) => void; options: () => ComboboxOption[]; @@ -48,72 +55,68 @@ export function Combobox({ const classes = comboboxVariants({ size }); return ( - + {() => ( <> onInputChange(event.target.value)} - disabled={disabled} + ref={inputRef} + className={clsx( + classes, + + // General styling + "block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300", + + // Hover + "hover:bg-blue-50/80 active:bg-blue-100/60", + + // Dark mode + "dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60", + + // 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", + + // Disabled + disabled && + "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} + displayValue={displayValue} + onChange={event => onInputChange(event.target.value)} + disabled={disabled} /> - + {options().length > 0 && ( - - {options().map((option) => ( - + {options().map(option => ( + {option.label} ))} )} - + {options().length === 0 && inputRef.current?.value && ( -
-
- {emptyMessage} -
+
+
{emptyMessage}
)} )} ); -} \ No newline at end of file +} diff --git a/ui/src/components/ConfirmDialog.tsx b/ui/src/components/ConfirmDialog.tsx index 57391e2..3771096 100644 --- a/ui/src/components/ConfirmDialog.tsx +++ b/ui/src/components/ConfirmDialog.tsx @@ -1,4 +1,9 @@ -import { ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/24/outline"; +import { + ExclamationTriangleIcon, + CheckCircleIcon, + InformationCircleIcon, +} from "@heroicons/react/24/outline"; + import { cx } from "@/cva.config"; import { Button } from "@/components/Button"; import Modal from "@/components/Modal"; @@ -42,12 +47,15 @@ const variantConfig = { iconBgClass: "bg-blue-100", buttonTheme: "primary", }, -} as Record; + } +>; export function ConfirmDialog({ open, @@ -65,13 +73,18 @@ export function ConfirmDialog({ return (
-
+
-
+
-
+

{title}

@@ -83,12 +96,7 @@ export function ConfirmDialog({
{cancelText && ( -
); -} \ No newline at end of file +} diff --git a/ui/src/components/VideoOverlay.tsx b/ui/src/components/VideoOverlay.tsx index 0d71dcd..ada8f20 100644 --- a/ui/src/components/VideoOverlay.tsx +++ b/ui/src/components/VideoOverlay.tsx @@ -3,11 +3,11 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid"; import { motion, AnimatePresence } from "framer-motion"; import { LuPlay } from "react-icons/lu"; +import { BsMouseFill } from "react-icons/bs"; import { Button, LinkButton } from "@components/Button"; import LoadingSpinner from "@components/LoadingSpinner"; import Card, { GridCard } from "@components/Card"; -import { BsMouseFill } from "react-icons/bs"; interface OverlayContentProps { children: React.ReactNode; @@ -242,8 +242,8 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) { Ensure source device is powered on and outputting a signal
  • - If using an adapter, ensure it's compatible and - functioning correctly + If using an adapter, ensure it's compatible and functioning + correctly
  • diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index b73135b..2b4cac6 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -151,7 +151,7 @@ export default function WebRTCVideo() { const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock"); if (isKeyboardLockGranted) { if ("keyboard" in navigator) { - // @ts-ignore + // @ts-expect-error - keyboard lock is not supported in all browsers await navigator.keyboard.lock(); } } diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index db1fd04..045655e 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -1,6 +1,11 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; -import { MAX_STEPS_PER_MACRO, MAX_TOTAL_MACROS, MAX_KEYS_PER_STEP } from "@/constants/macros"; + +import { + MAX_STEPS_PER_MACRO, + MAX_TOTAL_MACROS, + MAX_KEYS_PER_STEP, +} from "@/constants/macros"; // Define the JsonRpc types for better type checking interface JsonRpcResponse { @@ -564,12 +569,12 @@ export interface UpdateState { setOtaState: (state: UpdateState["otaState"]) => void; setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void; modalView: - | "loading" - | "updating" - | "upToDate" - | "updateAvailable" - | "updateCompleted" - | "error"; + | "loading" + | "updating" + | "upToDate" + | "updateAvailable" + | "updateCompleted" + | "error"; setModalView: (view: UpdateState["modalView"]) => void; setUpdateErrorMessage: (errorMessage: string) => void; updateErrorMessage: string | null; @@ -633,12 +638,12 @@ export const useUsbConfigModalStore = create(set => ({ interface LocalAuthModalState { modalView: - | "createPassword" - | "deletePassword" - | "updatePassword" - | "creationSuccess" - | "deleteSuccess" - | "updateSuccess"; + | "createPassword" + | "deletePassword" + | "updatePassword" + | "creationSuccess" + | "deleteSuccess" + | "updateSuccess"; setModalView: (view: LocalAuthModalState["modalView"]) => void; } @@ -719,12 +724,23 @@ export interface NetworkState { setDhcpLeaseExpiry: (expiry: Date) => void; } - -export type IPv6Mode = "disabled" | "slaac" | "dhcpv6" | "slaac_and_dhcpv6" | "static" | "link_local" | "unknown"; +export type IPv6Mode = + | "disabled" + | "slaac" + | "dhcpv6" + | "slaac_and_dhcpv6" + | "static" + | "link_local" + | "unknown"; export type IPv4Mode = "disabled" | "static" | "dhcp" | "unknown"; export type LLDPMode = "disabled" | "basic" | "all" | "unknown"; export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown"; -export type TimeSyncMode = "ntp_only" | "ntp_and_http" | "http_only" | "custom" | "unknown"; +export type TimeSyncMode = + | "ntp_only" + | "ntp_and_http" + | "http_only" + | "custom" + | "unknown"; export interface NetworkSettings { hostname: string; @@ -749,7 +765,7 @@ export const useNetworkStateStore = create((set, get) => ({ lease.lease_expiry = expiry; set({ dhcp_lease: lease }); - } + }, })); export interface KeySequenceStep { @@ -771,8 +787,20 @@ export interface MacrosState { initialized: boolean; loadMacros: () => Promise; saveMacros: (macros: KeySequence[]) => Promise; - 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; + 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; } export const generateMacroId = () => { @@ -785,7 +813,7 @@ export const useMacrosStore = create((set, get) => ({ initialized: false, sendFn: null, - setSendFn: (sendFn) => { + setSendFn: sendFn => { set({ sendFn }); }, @@ -802,7 +830,7 @@ export const useMacrosStore = create((set, get) => ({ try { await new Promise((resolve, reject) => { - sendFn("getKeyboardMacros", {}, (response) => { + sendFn("getKeyboardMacros", {}, response => { if (response.error) { console.error("Error loading macros:", response.error); reject(new Error(response.error.message)); @@ -822,7 +850,7 @@ export const useMacrosStore = create((set, get) => ({ set({ macros: sortedMacros, - initialized: true + initialized: true, }); resolve(); @@ -849,15 +877,23 @@ export const useMacrosStore = create((set, get) => ({ for (const macro of macros) { if (macro.steps.length > MAX_STEPS_PER_MACRO) { - console.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`); + console.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++) { const step = macro.steps[i]; if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) { - console.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`); + console.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`, + ); } } } @@ -867,20 +903,25 @@ export const useMacrosStore = create((set, get) => ({ try { const macrosWithSortOrder = macros.map((macro, index) => ({ ...macro, - sortOrder: macro.sortOrder !== undefined ? macro.sortOrder : index + sortOrder: macro.sortOrder !== undefined ? macro.sortOrder : index, })); - const response = await new Promise((resolve) => { - sendFn("setKeyboardMacros", { params: { macros: macrosWithSortOrder } }, (response) => { - resolve(response); - }); + const response = await new Promise(resolve => { + sendFn( + "setKeyboardMacros", + { params: { macros: macrosWithSortOrder } }, + response => { + resolve(response); + }, + ); }); if (response.error) { console.error("Error saving macros:", response.error); - const errorMessage = typeof response.error.data === 'string' - ? response.error.data - : response.error.message || "Failed to save macros"; + const errorMessage = + typeof response.error.data === "string" + ? response.error.data + : response.error.message || "Failed to save macros"; throw new Error(errorMessage); } @@ -892,5 +933,5 @@ export const useMacrosStore = create((set, get) => ({ } finally { set({ loading: false }); } - } -})); \ No newline at end of file + }, +})); diff --git a/ui/src/routes/devices.$id.settings.macros.tsx b/ui/src/routes/devices.$id.settings.macros.tsx index f809f57..ba1a2ba 100644 --- a/ui/src/routes/devices.$id.settings.macros.tsx +++ b/ui/src/routes/devices.$id.settings.macros.tsx @@ -1,6 +1,15 @@ import { useEffect, Fragment, useMemo, useState, useCallback } from "react"; import { useNavigate } from "react-router-dom"; -import { LuPenLine, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown, LuTrash2, LuCommand } from "react-icons/lu"; +import { + LuPenLine, + LuCopy, + LuMoveRight, + LuCornerDownRight, + LuArrowUp, + LuArrowDown, + LuTrash2, + LuCommand, +} from "react-icons/lu"; import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores"; import { SettingsPageHeader } from "@/components/SettingsPageheader"; @@ -26,10 +35,10 @@ export default function SettingsMacrosRoute() { const [actionLoadingId, setActionLoadingId] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [macroToDelete, setMacroToDelete] = useState(null); - - const isMaxMacrosReached = useMemo(() => - macros.length >= MAX_TOTAL_MACROS, - [macros.length] + + const isMaxMacrosReached = useMemo( + () => macros.length >= MAX_TOTAL_MACROS, + [macros.length], ); useEffect(() => { @@ -38,75 +47,83 @@ export default function SettingsMacrosRoute() { } }, [initialized, loadMacros]); - const handleDuplicateMacro = useCallback(async (macro: KeySequence) => { - if (!macro?.id || !macro?.name) { - notifications.error("Invalid macro data"); - 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"); + const handleDuplicateMacro = useCallback( + async (macro: KeySequence) => { + if (!macro?.id || !macro?.name) { + notifications.error("Invalid macro data"); + return; } - } 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"); + if (isMaxMacrosReached) { + notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); + return; } - } finally { - setActionLoadingId(null); - } - }, [macros, saveMacros, setActionLoadingId]); + + 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], + ); + + 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 () => { if (!macroToDelete?.id) return; setActionLoadingId(macroToDelete.id); try { - const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macroToDelete.id)); + const updatedMacros = normalizeSortOrders( + macros.filter(m => m.id !== macroToDelete.id), + ); await saveMacros(updatedMacros); notifications.success(`Macro "${macroToDelete.name}" deleted successfully`); setShowDeleteConfirm(false); @@ -122,135 +139,168 @@ export default function SettingsMacrosRoute() { } }, [macroToDelete, macros, saveMacros]); - const MacroList = useMemo(() => ( -
    - {macros.map((macro, index) => ( - -
    -
    -
    + const MacroList = useMemo( + () => ( +
    + {macros.map((macro, index) => ( + +
    +
    +
    -
    -

    - {macro.name} -

    -

    - - {macro.steps.map((step, stepIndex) => { - const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight; +

    +

    + {macro.name} +

    +

    + + {macro.steps.map((step, stepIndex) => { + const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight; - return ( - - - - {(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) => ( - - - {modifierDisplayMap[modifier] || modifier} - - {idx < step.modifiers.length - 1 && ( - + + return ( + + + + {(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) => ( + + + {modifierDisplayMap[modifier] || modifier} + + {idx < step.modifiers.length - 1 && ( + + {" "} + +{" "} + + )} + + ))} + + {Array.isArray(step.modifiers) && + step.modifiers.length > 0 && + Array.isArray(step.keys) && + step.keys.length > 0 && ( + + {" "} + +{" "} + )} - - ))} - {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && ( - + - )} - - {Array.isArray(step.keys) && step.keys.map((key, idx) => ( - - - {keyDisplayMap[key] || key} - - {idx < step.keys.length - 1 && ( - + - )} - - ))} - - ) : ( - Delay only - )} - {step.delay !== DEFAULT_DELAY && ( - ({step.delay}ms) - )} + {Array.isArray(step.keys) && + step.keys.map((key, idx) => ( + + + {keyDisplayMap[key] || key} + + {idx < step.keys.length - 1 && ( + + {" "} + +{" "} + + )} + + ))} + + ) : ( + + Delay only + + )} + {step.delay !== DEFAULT_DELAY && ( + + ({step.delay}ms) + + )} + - - ); - })} - -

    -
    + ); + })} + +

    +
    -
    -
    -
    - - ))} + + ))} - { - 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} - /> -
    - ), [macros, actionLoadingId, showDeleteConfirm, macroToDelete, handleDeleteMacro]); + { + 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} + /> +
    + ), + [ + macros, + showDeleteConfirm, + macroToDelete?.name, + macroToDelete?.id, + actionLoadingId, + handleDeleteMacro, + handleMoveMacro, + handleDuplicateMacro, + navigate, + ], + ); return (
    @@ -259,7 +309,7 @@ export default function SettingsMacrosRoute() { title="Keyboard Macros" description={`Combine keystrokes into a single action for faster workflows.`} /> - { macros.length > 0 && ( + {macros.length > 0 && (
    ); diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 59d52ef..1811ca0 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -1,18 +1,30 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { ArrowPathIcon } from "@heroicons/react/24/outline"; -import { SelectMenuBasic } from "../components/SelectMenuBasic"; -import { SettingsPageHeader } from "../components/SettingsPageheader"; - -import { IPv4Mode, IPv6Mode, LLDPMode, mDNSMode, NetworkSettings, NetworkState, TimeSyncMode, useNetworkStateStore } from "@/hooks/stores"; +import { + IPv4Mode, + IPv6Mode, + LLDPMode, + mDNSMode, + NetworkSettings, + NetworkState, + TimeSyncMode, + useNetworkStateStore, +} from "@/hooks/stores"; import { useJsonRpc } from "@/hooks/useJsonRpc"; import notifications from "@/notifications"; import { Button } from "@components/Button"; import { GridCard } from "@components/Card"; import InputField from "@components/InputField"; -import { SettingsItem } from "./devices.$id.settings"; -import dayjs from 'dayjs'; -import relativeTime from 'dayjs/plugin/relativeTime'; +import { SettingsPageHeader } from "../components/SettingsPageheader"; +import { SelectMenuBasic } from "../components/SelectMenuBasic"; +import Fieldset from "../components/Fieldset"; +import { ConfirmDialog } from "../components/ConfirmDialog"; + +import { SettingsItem } from "./devices.$id.settings"; dayjs.extend(relativeTime); @@ -25,13 +37,9 @@ const defaultNetworkSettings: NetworkSettings = { lldp_tx_tlvs: [], mdns_mode: "unknown", time_sync_mode: "unknown", -} +}; export function LifeTimeLabel({ lifetime }: { lifetime: string }) { - if (lifetime == "") { - return N/A; - } - const [remaining, setRemaining] = useState(null); useEffect(() => { @@ -43,46 +51,87 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) { return () => clearInterval(interval); }, [lifetime]); - return <> - {dayjs(lifetime).format()} - {remaining && <> - {" "} - ({remaining}) - - } - + return ( + <> + {dayjs(lifetime).format("YYYY-MM-DD HH:mm")} + {remaining && ( + <> + {" "} + + ({remaining}) + + + )} + + ); } export default function SettingsNetworkRoute() { const [send] = useJsonRpc(); - const [networkState, setNetworkState] = useNetworkStateStore(state => [state, state.setNetworkState]); + const [networkState, setNetworkState] = useNetworkStateStore(state => [ + state, + state.setNetworkState, + ]); + + const [networkSettings, setNetworkSettings] = + useState(defaultNetworkSettings); + + // We use this to determine whether the settings have changed + const firstNetworkSettings = useRef(); - const [networkSettings, setNetworkSettings] = useState(defaultNetworkSettings); const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false); + const [customDomain, setCustomDomain] = useState(""); + const [selectedDomainOption, setSelectedDomainOption] = useState("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(() => { setNetworkSettingsLoaded(false); send("getNetworkSettings", {}, resp => { if ("error" in resp) return; console.log(resp.result); setNetworkSettings(resp.result as NetworkSettings); + + if (!firstNetworkSettings.current) { + firstNetworkSettings.current = resp.result as NetworkSettings; + } setNetworkSettingsLoaded(true); }); }, [send]); - const setNetworkSettingsRemote = useCallback((settings: NetworkSettings) => { - setNetworkSettingsLoaded(false); - send("setNetworkSettings", { settings }, resp => { - if ("error" in resp) { - notifications.error("Failed to save network settings: " + (resp.error.data ? resp.error.data : resp.error.message)); + const setNetworkSettingsRemote = useCallback( + (settings: NetworkSettings) => { + setNetworkSettingsLoaded(false); + send("setNetworkSettings", { settings }, resp => { + if ("error" in resp) { + 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); - return; - } - setNetworkSettings(resp.result as NetworkSettings); - setNetworkSettingsLoaded(true); - notifications.success("Network settings saved"); - }); - }, [send]); + notifications.success("Network settings saved"); + }); + }, + [send], + ); const getNetworkState = useCallback(() => { send("getNetworkState", {}, resp => { @@ -90,7 +139,7 @@ export default function SettingsNetworkRoute() { console.log(resp.result); setNetworkState(resp.result as NetworkState); }); - }, [send]); + }, [send, setNetworkState]); const handleRenewLease = useCallback(() => { send("renewDHCPLease", {}, resp => { @@ -131,278 +180,520 @@ export default function SettingsNetworkRoute() { setNetworkSettings({ ...networkSettings, time_sync_mode: value as TimeSyncMode }); }; - const filterUnknown = useCallback((options: { value: string; label: string; }[]) => { - if (!networkSettingsLoaded) return options; - return options.filter(option => option.value !== "unknown"); - }, [networkSettingsLoaded]); + const handleHostnameChange = (value: string) => { + setNetworkSettings({ ...networkSettings, hostname: value }); + }; + + 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 ( -
    - -
    - } - > - - {networkState?.mac_address} - - -
    -
    - - Hostname for the device -
    - - Leave blank for default - - - } - > - { - setNetworkSettings({ ...networkSettings, hostname: e.target.value }); - }} - disabled={!networkSettingsLoaded} - /> -
    -
    -
    - - Domain for the device -
    - - Leave blank to use DHCP provided domain, if there is no domain, use local - - - } - > - { - setNetworkSettings({ ...networkSettings, domain: e.target.value }); - }} - disabled={!networkSettingsLoaded} - /> -
    -
    -
    - - handleIpv4ModeChange(e.target.value)} - disabled={!networkSettingsLoaded} - options={filterUnknown([ - { value: "dhcp", label: "DHCP" }, - // { value: "static", label: "Static" }, - ])} - /> - - {networkState?.dhcp_lease && ( - -
    -
    -
    -

    - Current DHCP Lease -

    -
    -
      - {networkState?.dhcp_lease?.ip &&
    • IP: {networkState?.dhcp_lease?.ip}
    • } - {networkState?.dhcp_lease?.netmask &&
    • Subnet: {networkState?.dhcp_lease?.netmask}
    • } - {networkState?.dhcp_lease?.broadcast &&
    • Broadcast: {networkState?.dhcp_lease?.broadcast}
    • } - {networkState?.dhcp_lease?.ttl &&
    • TTL: {networkState?.dhcp_lease?.ttl}
    • } - {networkState?.dhcp_lease?.mtu &&
    • MTU: {networkState?.dhcp_lease?.mtu}
    • } - {networkState?.dhcp_lease?.hostname &&
    • Hostname: {networkState?.dhcp_lease?.hostname}
    • } - {networkState?.dhcp_lease?.domain &&
    • Domain: {networkState?.dhcp_lease?.domain}
    • } - {networkState?.dhcp_lease?.routers &&
    • Gateway: {networkState?.dhcp_lease?.routers.join(", ")}
    • } - {networkState?.dhcp_lease?.dns &&
    • DNS: {networkState?.dhcp_lease?.dns.join(", ")}
    • } - {networkState?.dhcp_lease?.ntp_servers &&
    • NTP Servers: {networkState?.dhcp_lease?.ntp_servers.join(", ")}
    • } - {networkState?.dhcp_lease?.server_id &&
    • Server ID: {networkState?.dhcp_lease?.server_id}
    • } - {networkState?.dhcp_lease?.bootp_next_server &&
    • BootP Next Server: {networkState?.dhcp_lease?.bootp_next_server}
    • } - {networkState?.dhcp_lease?.bootp_server_name &&
    • BootP Server Name: {networkState?.dhcp_lease?.bootp_server_name}
    • } - {networkState?.dhcp_lease?.bootp_file &&
    • Boot File: {networkState?.dhcp_lease?.bootp_file}
    • } - {networkState?.dhcp_lease?.lease_expiry &&
    • - Lease Expiry: -
    • } - {/* {JSON.stringify(networkState?.dhcp_lease)} */} -
    -
    -
    -
    -
    -
    + <> +
    + +
    + + + +
    +
    + +
    +
    + { + handleHostnameChange(e.target.value); + }} + />
    - - )} -
    -
    - - +
    + +
    +
    + +
    + handleDomainOptionChange(e.target.value)} + options={[ + { value: "dhcp", label: "DHCP provided" }, + { value: "local", label: ".local" }, + { value: "custom", label: "Custom" }, + ]} + /> +
    +
    + {selectedDomainOption === "custom" && ( +
    + setCustomDomain(e.target.value)} + /> +
    + )} +
    +
    + + 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" }, + ])} + /> + +
    + +
    + + { + 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" }, + ])} + /> + +
    + +
    +
    +
    +
    + )} +
    +
    + + handleIpv6ModeChange(e.target.value)} + options={filterUnknown([ + // { value: "disabled", label: "Disabled" }, + { value: "slaac", label: "SLAAC" }, + // { value: "dhcpv6", label: "DHCPv6" }, + // { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" }, + // { value: "static", label: "Static" }, + // { value: "link_local", label: "Link-local only" }, + ])} + /> + + {networkState?.ipv6_addresses && ( + +
    +

    IPv6 Information

    -
    -
    -

    - IPv6 Link-local -

    -

    - {networkState?.ipv6_link_local} -

    -
    -
    -

    - IPv6 Addresses -

    -
      - {networkState?.ipv6_addresses && networkState?.ipv6_addresses.map(addr => ( -
    • - {addr.address} - {addr.valid_lifetime && <> -
      - - valid_lft: {" "} - - - - } - {addr.preferred_lifetime && <> -
      - - pref_lft: {" "} - - - - } -
    • - ))} -
    -
    + +
    + {networkState?.dhcp_lease?.ip && ( +
    + + Link-local + + + {networkState?.ipv6_link_local} + +
    + )} +
    + +
    + {networkState?.ipv6_addresses && + networkState?.ipv6_addresses.length > 0 && ( +
    +

    IPv6 Addresses

    + {networkState.ipv6_addresses.map(addr => ( +
    +
    +
    + + Address + + + {addr.address} + +
    + + {addr.valid_lifetime && ( +
    + + Valid Lifetime + + + {addr.valid_lifetime === "" ? ( + + N/A + + ) : ( + + )} + +
    + )} + {addr.preferred_lifetime && ( +
    + + Preferred Lifetime + + + {addr.preferred_lifetime === "" ? ( + + N/A + + ) : ( + + )} + +
    + )} +
    +
    + ))} +
    + )}
    -
    -
    - )} -
    -
    - - handleLldpModeChange(e.target.value)} - disabled={!networkSettingsLoaded} - options={filterUnknown([ - { value: "disabled", label: "Disabled" }, - { value: "basic", label: "Basic" }, - { value: "all", label: "All" }, - ])} - /> - -
    -
    - - 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" }, - ])} - /> - -
    -
    - - 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" }, - ])} - /> - -
    -
    -
    -
    + + )} +
    +
    + + handleLldpModeChange(e.target.value)} + options={filterUnknown([ + { value: "disabled", label: "Disabled" }, + { value: "basic", label: "Basic" }, + { value: "all", label: "All" }, + ])} + /> + +
    + + setShowRenewLeaseConfirm(false)} + title="Renew DHCP Lease" + description="This will request a new IP address from your DHCP server. Your device may temporarily lose network connectivity during this process." + variant="danger" + confirmText="Renew Lease" + onConfirm={() => { + handleRenewLease(); + setShowRenewLeaseConfirm(false); + }} + /> + ); }