feat(ui): enhance network settings form with react-hook-form integration

This commit is contained in:
Adam Shiervani 2025-08-06 19:13:29 +02:00
parent 839201cc7a
commit fe7450d6ec
8 changed files with 486 additions and 626 deletions

View File

@ -9,12 +9,12 @@ export default function DhcpLeaseCard({
networkState, networkState,
setShowRenewLeaseConfirm, setShowRenewLeaseConfirm,
}: { }: {
networkState: NetworkState; networkState: NetworkState | null;
setShowRenewLeaseConfirm: (show: boolean) => void; setShowRenewLeaseConfirm: (show: boolean) => void;
}) { }) {
return ( return (
<GridCard> <GridCard>
<div className="animate-fadeIn p-4 opacity-0 animation-duration-500 text-black dark:text-white"> <div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
<div className="space-y-3"> <div className="space-y-3">
<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 Information DHCP Lease Information
@ -44,24 +44,15 @@ export default function DhcpLeaseCard({
</div> </div>
)} )}
{networkState?.dhcp_lease?.dns && ( {networkState?.dhcp_lease?.dns_servers && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20"> <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"> <span className="text-sm text-slate-600 dark:text-slate-400">
DNS Servers DNS Servers
</span> </span>
<span className="text-right text-sm font-medium"> <span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.dns.map(dns => <div key={dns}>{dns}</div>)} {networkState?.dhcp_lease?.dns_servers.map(dns => (
</span> <div key={dns}>{dns}</div>
</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> </span>
</div> </div>
)} )}
@ -142,6 +133,17 @@ export default function DhcpLeaseCard({
</div> </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?.mtu && ( {networkState?.dhcp_lease?.mtu && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20"> <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 text-slate-600 dark:text-slate-400">MTU</span>

View File

@ -6,7 +6,7 @@ import { GridCard } from "./Card";
export default function Ipv6NetworkCard({ export default function Ipv6NetworkCard({
networkState, networkState,
}: { }: {
networkState: NetworkState; networkState: NetworkState | undefined;
}) { }) {
return ( return (
<GridCard> <GridCard>

View File

@ -3,14 +3,19 @@ import { ReactNode } from "react";
export function SettingsPageHeader({ export function SettingsPageHeader({
title, title,
description, description,
action,
}: { }: {
title: string | ReactNode; title: string | ReactNode;
description: string | ReactNode; description: string | ReactNode;
action?: ReactNode;
}) { }) {
return ( return (
<div className="select-none"> <div className="flex items-center justify-between gap-x-2 select-none">
<h2 className=" text-xl font-extrabold text-black dark:text-white">{title}</h2> <div className="flex flex-col gap-y-1">
<div className="text-sm text-black dark:text-slate-300">{description}</div> <h2 className="text-xl font-extrabold text-black dark:text-white">{title}</h2>
<div className="text-sm text-black dark:text-slate-300">{description}</div>
</div>
{action && <div className="">{action}</div>}
</div> </div>
); );
} }

View File

@ -1,111 +1,16 @@
import { useState, useEffect } from "react";
import { LuPlus, LuX } from "react-icons/lu"; import { LuPlus, LuX } from "react-icons/lu";
import isIP from "validator/es/lib/isIP"; import { useFieldArray, useFormContext } from "react-hook-form";
import validator from "validator";
import { GridCard } from "@/components/Card"; import { GridCard } from "@/components/Card";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { InputFieldWithLabel } from "@/components/InputField"; import { InputFieldWithLabel } from "@/components/InputField";
import { IPv4StaticConfig, NetworkSettings, NetworkState } from "@/hooks/stores";
interface StaticIpv4CardProps { export default function StaticIpv4Card() {
networkSettings: NetworkSettings; const formMethods = useFormContext();
onUpdate: (settings: NetworkSettings) => void; const { register, formState } = formMethods;
networkState?: NetworkState;
onApply: () => void;
}
export default function StaticIpv4Card({ const { fields, append, remove } = useFieldArray({ name: "ipv4_static.dns" });
networkSettings,
onUpdate,
networkState,
onApply,
}: StaticIpv4CardProps) {
const [staticConfig, setStaticConfig] = useState<IPv4StaticConfig>({
address: "",
netmask: "",
gateway: "",
dns: [],
});
// Validation errors
const addressError =
staticConfig.address && !isIP(staticConfig.address, "4") ? "Invalid IP address" : "";
const netmaskError =
staticConfig.netmask && !isIP(staticConfig.netmask, "4") ? "Invalid subnet mask" : "";
const gatewayError =
staticConfig.gateway && !isIP(staticConfig.gateway, "4")
? "Invalid gateway address"
: "";
const dnsErrors = staticConfig.dns.map(dns =>
dns && !isIP(dns, "4") ? "Invalid DNS server" : "",
);
// Check if any field has an error or if required fields are empty
const hasValidationErrors = !!(
addressError ||
netmaskError ||
gatewayError ||
dnsErrors.some(error => error)
);
const hasEmptyRequiredFields =
!staticConfig.address ||
!staticConfig.netmask ||
!staticConfig.gateway ||
staticConfig.dns.length === 0;
const isFormValid = !hasValidationErrors && !hasEmptyRequiredFields;
// Initialize from existing settings or use current network state as defaults
useEffect(() => {
if (networkSettings.ipv4_static) {
setStaticConfig(networkSettings.ipv4_static);
} else if (networkState?.dhcp_lease) {
// Use current DHCP values as defaults
const defaults: IPv4StaticConfig = {
address: networkState.dhcp_lease.ip || "",
netmask: networkState.dhcp_lease.netmask || "",
gateway: networkState.dhcp_lease.routers?.[0] || "",
dns: networkState.dhcp_lease.dns_servers || [],
};
setStaticConfig(defaults);
// Update the parent with these default values
onUpdate({ ...networkSettings, ipv4_static: defaults });
}
}, [networkSettings.ipv4_static, networkState?.dhcp_lease, networkSettings, onUpdate]);
const handleConfigChange = (
field: keyof Omit<IPv4StaticConfig, "dns">,
value: string,
) => {
const updatedConfig = { ...staticConfig, [field]: value };
setStaticConfig(updatedConfig);
onUpdate({ ...networkSettings, ipv4_static: updatedConfig });
};
const handleDnsChange = (index: number, value: string) => {
const updatedDns = [...staticConfig.dns];
updatedDns[index] = value;
const updatedConfig = { ...staticConfig, dns: updatedDns };
setStaticConfig(updatedConfig);
onUpdate({ ...networkSettings, ipv4_static: updatedConfig });
};
const handleAddDnsField = () => {
const updatedConfig = {
...staticConfig,
dns: [...staticConfig.dns, ""],
};
setStaticConfig(updatedConfig);
onUpdate({ ...networkSettings, ipv4_static: updatedConfig });
};
const handleRemoveDnsServer = (index: number) => {
const updatedConfig = {
...staticConfig,
dns: staticConfig.dns.filter((_, i) => i !== index),
};
setStaticConfig(updatedConfig);
onUpdate({ ...networkSettings, ipv4_static: updatedConfig });
};
return ( return (
<GridCard> <GridCard>
@ -121,9 +26,7 @@ export default function StaticIpv4Card({
type="text" type="text"
size="SM" size="SM"
placeholder="192.168.1.100" placeholder="192.168.1.100"
value={staticConfig.address} {...register("ipv4_static.address")}
onChange={e => handleConfigChange("address", e.target.value)}
error={addressError}
/> />
<InputFieldWithLabel <InputFieldWithLabel
@ -131,9 +34,7 @@ export default function StaticIpv4Card({
type="text" type="text"
size="SM" size="SM"
placeholder="255.255.255.0" placeholder="255.255.255.0"
value={staticConfig.netmask} {...register("ipv4_static.netmask")}
onChange={e => handleConfigChange("netmask", e.target.value)}
error={netmaskError}
/> />
</div> </div>
@ -142,96 +43,58 @@ export default function StaticIpv4Card({
type="text" type="text"
size="SM" size="SM"
placeholder="192.168.1.1" placeholder="192.168.1.1"
value={staticConfig.gateway} {...register("ipv4_static.gateway")}
onChange={e => handleConfigChange("gateway", e.target.value)}
error={gatewayError}
/> />
{/* DNS server fields */} {/* DNS server fields */}
<div className="space-y-2"> <div className="space-y-4">
{staticConfig.dns.length === 0 && ( {fields.map((dns, index) => {
<div className="flex items-center gap-2"> return (
<div className="flex-1"> <div key={dns.id}>
<InputFieldWithLabel <div className="flex items-start gap-x-2">
label="Primary DNS Server" <div className="flex-1">
type="text" <InputFieldWithLabel
size="SM" label={index === 0 ? "DNS Server" : null}
placeholder="8.8.8.8" type="text"
value="" size="SM"
onChange={e => { placeholder="1.1.1.1"
const updatedConfig = { ...staticConfig, dns: [e.target.value] }; {...register(`ipv4_static.dns.${index}`, {
setStaticConfig(updatedConfig); validate: (value: string) => {
onUpdate({ ...networkSettings, ipv4_static: updatedConfig }); if (value === "") return true;
}} if (!validator.isIP(value)) return "Invalid IP address";
/> return true;
},
})}
error={formState.errors.ipv4_static?.dns?.[index]?.message}
/>
</div>
{index > 0 && (
<div className="flex-shrink-0">
<Button
size="SM"
theme="light"
type="button"
onClick={() => remove(index)}
LeadingIcon={LuX}
/>
</div>
)}
</div>
</div> </div>
</div> );
)} })}
{staticConfig.dns.map((dns, index) => (
<StaticIpv4DnsField
key={index}
value={dns}
index={index}
isLast={index === staticConfig.dns.length - 1}
onChange={e => handleDnsChange(index, e)}
onAdd={handleAddDnsField}
onRemove={handleRemoveDnsServer}
error={dnsErrors[index]}
/>
))}
</div> </div>
<Button
size="SM"
theme="light"
onClick={() => append("", { shouldFocus: true })}
LeadingIcon={LuPlus}
type="button"
text="Add DNS Server"
/>
</div> </div>
</div> </div>
</GridCard> </GridCard>
); );
} }
export function StaticIpv4DnsField({
value,
index,
isLast,
onChange,
onAdd,
onRemove,
error,
}: {
value: string;
index: number;
isLast: boolean;
onChange: (dns: string) => void;
onAdd: () => void;
onRemove: (index: number) => void;
error?: string;
}) {
return (
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
<InputFieldWithLabel
label={index === 0 ? "Primary DNS Server" : `DNS Server ${index + 1}`}
type="text"
size="SM"
placeholder="8.8.8.8"
value={value}
onChange={e => onChange(e.target.value)}
error={error || ""}
/>
</div>
<div className="mt-[21.875px] flex-shrink-0">
{
// if last item, show add button
isLast ? (
<Button size="SM" theme="light" onClick={onAdd} LeadingIcon={LuPlus} />
) : (
<Button
size="SM"
theme="danger"
onClick={() => onRemove(index)}
LeadingIcon={LuX}
/>
)
}
</div>
</div>
);
}

View File

@ -436,7 +436,7 @@ export interface KeyboardLedState {
scroll_lock: boolean; scroll_lock: boolean;
compose: boolean; compose: boolean;
kana: boolean; kana: boolean;
}; }
const defaultKeyboardLedState: KeyboardLedState = { const defaultKeyboardLedState: KeyboardLedState = {
num_lock: false, num_lock: false,
caps_lock: false, caps_lock: false,
@ -516,7 +516,8 @@ export const useHidStore = create<HidState>((set, get) => ({
}, },
keyboardLedStateSyncAvailable: false, keyboardLedStateSyncAvailable: false,
setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }), setKeyboardLedStateSyncAvailable: available =>
set({ keyboardLedStateSyncAvailable: available }),
isVirtualKeyboardEnabled: false, isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }), setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
@ -752,9 +753,9 @@ export interface IPv4StaticConfig {
} }
export interface NetworkSettings { export interface NetworkSettings {
hostname: string; hostname: string | null;
domain: string; domain: string | null;
http_proxy: string; http_proxy: string | null;
ipv4_mode: IPv4Mode; ipv4_mode: IPv4Mode;
ipv4_static?: IPv4StaticConfig; ipv4_static?: IPv4StaticConfig;
ipv6_mode: IPv6Mode; ipv6_mode: IPv6Mode;
@ -944,5 +945,5 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
} finally { } finally {
set({ loading: false }); set({ loading: false });
} }
} },
})); }));

View File

@ -32,6 +32,7 @@
--animate-fadeInScaleFloat: --animate-fadeInScaleFloat:
fadeInScaleFloat 1s ease-out forwards, float 3s ease-in-out infinite; fadeInScaleFloat 1s ease-out forwards, float 3s ease-in-out infinite;
--animate-fadeIn: fadeIn 1s ease-out forwards; --animate-fadeIn: fadeIn 1s ease-out forwards;
--animate-fadeInStill: fadeInStill 1s ease-out forwards;
--animate-slideUpFade: slideUpFade 1s ease-out forwards; --animate-slideUpFade: slideUpFade 1s ease-out forwards;
--container-8xl: 88rem; --container-8xl: 88rem;
@ -110,6 +111,15 @@
} }
} }
@keyframes fadeInStill {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes slideUpFade { @keyframes slideUpFade {
0% { 0% {
opacity: 0; opacity: 0;

View File

@ -1,49 +1,48 @@
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 { LuEthernetPort } from "react-icons/lu"; import { LuEthernetPort } from "react-icons/lu";
import { useForm, FormProvider, FieldValues } from "react-hook-form";
import validator from "validator";
import { import { NetworkSettings, NetworkState, useRTCStore } from "@/hooks/stores";
IPv4Mode,
IPv6Mode,
LLDPMode,
mDNSMode,
NetworkSettings,
NetworkState,
TimeSyncMode,
useNetworkStateStore,
} from "@/hooks/stores";
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, { InputFieldWithLabel } from "@components/InputField"; import InputField, { InputFieldWithLabel } from "@components/InputField";
import { SelectMenuBasic } from "@/components/SelectMenuBasic"; import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import { SettingsPageHeader } from "@/components/SettingsPageheader"; import { SettingsPageHeader } from "@/components/SettingsPageheader";
import Fieldset from "@/components/Fieldset";
import { ConfirmDialog } from "@/components/ConfirmDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { getNetworkSettings, getNetworkState } from "@/utils/jsonrpc";
import Ipv6NetworkCard from "../components/Ipv6NetworkCard"; import Ipv6NetworkCard from "../components/Ipv6NetworkCard";
import EmptyCard from "../components/EmptyCard"; import EmptyCard from "../components/EmptyCard";
import AutoHeight from "../components/AutoHeight"; import AutoHeight from "../components/AutoHeight";
import DhcpLeaseCard from "../components/DhcpLeaseCard"; import DhcpLeaseCard from "../components/DhcpLeaseCard";
import StaticIpv4Card from "../components/StaticIpv4Card"; import StaticIpv4Card from "../components/StaticIpv4Card";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { SettingsItem } from "./devices.$id.settings"; import { SettingsItem } from "./devices.$id.settings";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
const defaultNetworkSettings: NetworkSettings = { const resolveOnRtcReady = () => {
hostname: "", return new Promise(resolve => {
http_proxy: "", // Check if RTC is already connected
domain: "", const currentState = useRTCStore.getState();
ipv4_mode: "unknown", if (currentState.rpcDataChannel?.readyState === "open") {
ipv4_static: undefined, // Already connected, fetch data immediately
ipv6_mode: "unknown", return resolve(void 0);
lldp_mode: "unknown", }
lldp_tx_tlvs: [],
mdns_mode: "unknown", // Not connected yet, subscribe to state changes
time_sync_mode: "unknown", const unsubscribe = useRTCStore.subscribe(state => {
if (state.rpcDataChannel?.readyState === "open") {
unsubscribe(); // Clean up subscription
return resolve(void 0);
}
});
});
}; };
export function LifeTimeLabel({ lifetime }: { lifetime: string }) { export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
@ -75,434 +74,311 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
export default function SettingsNetworkRoute() { export default function SettingsNetworkRoute() {
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const [networkState, setNetworkState] = useNetworkStateStore(state => [
state,
state.setNetworkState,
]);
const [networkSettings, setNetworkSettings] = const [networkState, setNetworkState] = useState<NetworkState | null>(null);
useState<NetworkSettings>(defaultNetworkSettings);
// We use this to determine whether the settings have changed
const firstNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false);
// Some input needs direct state management. Mostly options that open more details
const [customDomain, setCustomDomain] = useState<string>(""); 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(() => {
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 getNetworkState = useCallback(() => {
send("getNetworkState", {}, resp => {
if ("error" in resp) return;
console.log(resp.result);
setNetworkState(resp.result as NetworkState);
});
}, [send, setNetworkState]);
const setNetworkSettingsRemote = useCallback(
(settings: NetworkSettings) => {
setNetworkSettingsLoaded(false);
// Filter out empty DNS strings from static IP config before sending
const filteredSettings = { ...settings };
if (filteredSettings.ipv4_static?.dns) {
filteredSettings.ipv4_static = {
...filteredSettings.ipv4_static,
dns: filteredSettings.ipv4_static.dns.filter(dns => dns.trim() !== "")
};
}
send("setNetworkSettings", { settings: filteredSettings }, 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);
getNetworkState();
setNetworkSettingsLoaded(true);
notifications.success("Network settings saved");
});
},
[getNetworkState, send],
);
const handleRenewLease = useCallback(() => {
send("renewDHCPLease", {}, resp => {
if ("error" in resp) {
notifications.error("Failed to renew lease: " + resp.error.message);
} else {
notifications.success("DHCP lease renewed");
}
});
}, [send]);
useEffect(() => {
getNetworkState();
getNetworkSettings();
}, [getNetworkState, getNetworkSettings]);
const handleIpv4ModeChange = (value: IPv4Mode | string) => {
setNetworkSettings({ ...networkSettings, ipv4_mode: value as IPv4Mode });
};
const handleIpv6ModeChange = (value: IPv6Mode | string) => {
setNetworkSettings({ ...networkSettings, ipv6_mode: value as IPv6Mode });
};
const handleLldpModeChange = (value: LLDPMode | string) => {
setNetworkSettings({ ...networkSettings, lldp_mode: value as LLDPMode });
};
const handleMdnsModeChange = (value: mDNSMode | string) => {
setNetworkSettings({ ...networkSettings, mdns_mode: value as mDNSMode });
};
const handleTimeSyncModeChange = (value: TimeSyncMode | string) => {
setNetworkSettings({ ...networkSettings, time_sync_mode: value as TimeSyncMode });
};
const handleHostnameChange = (value: string) => {
setNetworkSettings({ ...networkSettings, hostname: value });
};
const handleProxyChange = (value: string) => {
setNetworkSettings({ ...networkSettings, http_proxy: 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],
);
// Confirm dialog
const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false); const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false);
const fetchNetworkData = useCallback(async () => {
try {
console.log("Fetching network data...");
const [settings, state] = (await Promise.all([
getNetworkSettings(),
getNetworkState(),
])) as [NetworkSettings, NetworkState];
setNetworkState(state as NetworkState);
const settingsWithDefaults = {
...settings,
domain: settings.domain || "local", // TODO: null means local domain TRUE?????
mdns_mode: settings.mdns_mode || "disabled",
time_sync_mode: settings.time_sync_mode || "ntp_only",
ipv4_static: {
address: settings.ipv4_static?.address || state.dhcp_lease?.ip || "",
netmask: settings.ipv4_static?.netmask || state.dhcp_lease?.netmask || "",
gateway: settings.ipv4_static?.gateway || state.dhcp_lease?.routers?.[0] || "",
dns: settings.ipv4_static?.dns || state.dhcp_lease?.dns_servers || [],
},
};
return { settings: settingsWithDefaults, state };
} catch (err) {
notifications.error(err instanceof Error ? err.message : "Unknown error");
throw err;
}
}, []);
const formMethods = useForm<NetworkSettings>({
mode: "onBlur",
defaultValues: async () => {
console.log("Preparing form default values...");
// Ensure data channel is ready, before fetching network data from the device
await resolveOnRtcReady();
const { settings } = await fetchNetworkData();
return settings;
},
});
const { register, handleSubmit, watch, formState, reset } = formMethods;
const onSubmit = async (data: FieldValues) => {
const settings = {
...data,
// If custom domain option is selected, use the custom domain as value
domain: data.domain === "custom" ? customDomain : data.domain,
ipv4_static: {
...data.ipv4_static,
// Remove empty DNS entries
dns: data.ipv4_static?.dns.filter((dns: string) => dns.trim() !== ""),
},
};
send("setNetworkSettings", { settings }, async resp => {
if ("error" in resp) {
return notifications.error(
resp.error.data ? resp.error.data : resp.error.message,
);
} else {
// If the settings are saved successfully, fetch the latest network data and reset the form
// We do this so we get all the form state values, for stuff like is the form dirty, etc...
const networkData = await fetchNetworkData();
reset(networkData.settings);
notifications.success("Network settings saved");
}
});
};
const isIPv4Mode = watch("ipv4_mode");
return ( return (
<> <>
<Fieldset disabled={!networkSettingsLoaded} className="space-y-4"> <FormProvider {...formMethods}>
<SettingsPageHeader <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
title="Network" <SettingsPageHeader
description="Configure your network settings" title="Network"
/> description="Configure the network settings for the device"
<div className="space-y-4"> action={
<SettingsItem <>
title="MAC Address" {(formState.isDirty || formState.isSubmitting) && (
description="Hardware identifier for the network interface" <div className="animate-fadeInStill opacity-0 animation-duration-300">
> <Button
<InputField size="SM"
type="text" theme="primary"
size="SM" disabled={formState.isSubmitting}
value={networkState?.mac_address} loading={formState.isSubmitting}
error={""} type="submit"
readOnly={true} text={formState.isSubmitting ? "Saving..." : "Save Settings"}
className="dark:!text-opacity-60" />
/> </div>
</SettingsItem> )}
</div> </>
<div className="space-y-4"> }
<SettingsItem />
title="Hostname"
description="Device identifier on the network. Blank for system default"
>
<div className="relative">
<div>
<InputField
size="SM"
type="text"
placeholder="jetkvm"
defaultValue={networkSettings.hostname}
onChange={e => {
handleHostnameChange(e.target.value);
}}
/>
</div>
</div>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="HTTP Proxy"
description="Proxy server for outgoing HTTP(S) requests from the device. Blank for none."
>
<div className="relative">
<div>
<InputField
size="SM"
type="text"
placeholder="http://proxy.example.com:8080/"
defaultValue={networkSettings.http_proxy}
onChange={e => {
handleProxyChange(e.target.value);
}}
/>
</div>
</div>
</SettingsItem>
</div>
<div className="space-y-4">
<div className="space-y-1">
<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="mt-2 w-1/3 border-l border-slate-800/10 pl-4 dark:border-slate-300/20">
<InputFieldWithLabel
size="SM"
type="text"
label="Custom Domain"
placeholder="home"
value={customDomain}
onChange={e => {
setCustomDomain(e.target.value);
handleCustomDomainChange(e.target.value);
}}
/>
</div>
)}
</div>
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="mDNS" title="MAC Address"
description="Control mDNS (multicast DNS) operational mode" description="Hardware identifier for the network interface"
> >
<InputField
type="text"
size="SM"
value={networkState?.mac_address}
error={""}
readOnly={true}
className="dark:!text-opacity-60"
/>
</SettingsItem>
<SettingsItem title="Hostname" description="Set the device hostname">
<InputField
size="SM"
{...register("hostname")}
error={formState.errors.hostname?.message}
/>
</SettingsItem>
<SettingsItem title="HTTP Proxy" description="Configure HTTP proxy settings">
<InputField
size="SM"
placeholder="http://proxy.example.com:8080"
{...register("http_proxy", {
validate: (value: string | null) => {
if (value === "") return true;
if (!validator.isURL(value || "", { protocols: ["http", "https"] })) {
return "Invalid HTTP proxy URL";
}
return true;
},
})}
error={formState.errors.http_proxy?.message}
/>
</SettingsItem>
<div className="space-y-1">
<SettingsItem
title="Domain"
description="Network domain suffix for the device"
>
<div className="space-y-2">
<SelectMenuBasic
size="SM"
options={[
{ value: "dhcp", label: "DHCP provided" },
{ value: "local", label: ".local" },
{ value: "custom", label: "Custom" },
]}
{...register("domain")}
error={formState.errors.domain?.message}
/>
</div>
</SettingsItem>
{watch("domain") === "custom" && (
<div className="mt-2 w-1/3 border-l border-slate-800/10 pl-4 dark:border-slate-300/20">
<InputFieldWithLabel
size="SM"
type="text"
label="Custom Domain"
placeholder="home"
onChange={e => {
setCustomDomain(e.target.value);
}}
/>
</div>
)}
</div>
<SettingsItem title="mDNS Mode" description="Configure mDNS settings">
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
value={networkSettings.mdns_mode} options={[
onChange={e => handleMdnsModeChange(e.target.value)}
options={filterUnknown([
{ value: "disabled", label: "Disabled" }, { value: "disabled", label: "Disabled" },
{ value: "auto", label: "Auto" }, { value: "auto", label: "Auto" },
{ value: "ipv4_only", label: "IPv4 only" }, { value: "ipv4_only", label: "IPv4 only" },
{ value: "ipv6_only", label: "IPv6 only" }, { value: "ipv6_only", label: "IPv6 only" },
])} ]}
{...register("mdns_mode")}
/> />
</SettingsItem> </SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem <SettingsItem
title="Time synchronization" title="Time synchronization"
description="Configure time synchronization settings" description="Configure time synchronization settings"
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
value={networkSettings.time_sync_mode} options={[
onChange={e => {
handleTimeSyncModeChange(e.target.value);
}}
options={filterUnknown([
{ value: "unknown", label: "..." },
// { value: "auto", label: "Auto" },
{ value: "ntp_only", label: "NTP only" }, { value: "ntp_only", label: "NTP only" },
{ value: "ntp_and_http", label: "NTP and HTTP" }, { value: "ntp_and_http", label: "NTP and HTTP" },
{ value: "http_only", label: "HTTP only" }, { value: "http_only", label: "HTTP only" },
// { value: "custom", label: "Custom" }, ]}
])} {...register("time_sync_mode")}
/> />
</SettingsItem> </SettingsItem>
</div>
<Button <SettingsItem title="IPv4 Mode" description="Configure the IPv4 mode">
size="SM" <SelectMenuBasic
theme="primary" size="SM"
disabled={firstNetworkSettings.current === networkSettings} options={[
text="Save Settings" { value: "dhcp", label: "DHCP" },
onClick={() => setNetworkSettingsRemote(networkSettings)} { value: "static", label: "Static" },
/> ]}
</div> {...register("ipv4_mode")}
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<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)}
options={filterUnknown([
{ value: "dhcp", label: "DHCP" },
{ value: "static", label: "Static" },
])}
/>
</SettingsItem>
<AutoHeight>
{!networkSettingsLoaded && !networkState?.dhcp_lease ? (
<GridCard>
<div className="p-4">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease Information
</h3>
<div className="animate-pulse space-y-3">
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
<div classNa
onApply={() => setNetworkSettingsRemote(networkSettings)} </div>
</div>
</div>
</GridCard>
) : networkSettings.ipv4_mode === "static" ? (
<StaticIpv4Card
networkSettings={networkSettings}
onUpdate={setNetworkSettings}
networkState={networkState}
onApply={() => setNetworkSettingsRemote(networkSettings)}
/> />
) : networkSettings.ipv4_mode === "dhcp" && </SettingsItem>
networkState?.dhcp_lease && <div>
networkState.dhcp_lease.ip ? ( <AutoHeight>
<DhcpLeaseCard {formState.isLoading ? (
networkState={networkState} <GridCard>
setShowRenewLeaseConfirm={setShowRenewLeaseConfirm} <div className="p-4">
/> <div className="space-y-4">
) : ( <div className="h-6 w-1/3 animate-pulse rounded bg-slate-200 dark:bg-slate-700" />
<EmptyCard <div className="animate-pulse space-y-2">
IconElm={LuEthernetPort} <div className="h-4 w-1/4 rounded bg-slate-200 dark:bg-slate-700" />
headline="Network Information" <div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
description="No network configuration available" <div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
/> <div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
)} <div className="h-4 w-1/4 rounded bg-slate-200 dark:bg-slate-700" />
</AutoHeight> </div>
</div> </div>
<div className="space-y-4">
<SettingsItem title="IPv6 Mode" description="Configure the IPv6 mode">
<SelectMenuBasic
size="SM"
value={networkSettings.ipv6_mode}
onChange={e => 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" },
])}
/>
</SettingsItem>
<AutoHeight>
{!networkSettingsLoaded &&
!(networkState?.ipv6_addresses && networkState.ipv6_addresses.length > 0) ? (
<GridCard>
<div className="p-4">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
IPv6 Information
</h3>
<div className="animate-pulse space-y-3">
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
</div> </div>
</div> </GridCard>
</div> ) : isIPv4Mode === "static" ? (
</GridCard> <StaticIpv4Card />
) : networkState?.ipv6_addresses && networkState.ipv6_addresses.length > 0 ? ( ) : isIPv4Mode === "dhcp" ? (
<Ipv6NetworkCard networkState={networkState} /> <DhcpLeaseCard
) : ( networkState={networkState}
<EmptyCard setShowRenewLeaseConfirm={setShowRenewLeaseConfirm}
IconElm={LuEthernetPort} />
headline="IPv6 Information" ) : (
description="No IPv6 addresses configured" <EmptyCard
IconElm={LuEthernetPort}
headline="Network Information"
description="No network configuration available"
/>
)}
</AutoHeight>
</div>
<SettingsItem title="IPv6 Mode" description="Configure the IPv6 mode">
<SelectMenuBasic
size="SM"
options={[{ value: "slaac", label: "SLAAC" }]}
{...register("ipv6_mode")}
/> />
</SettingsItem>
<div className="space-y-4">
<AutoHeight>
{!networkState?.ipv6_addresses ? (
<GridCard>
<div className="p-4">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
IPv6 Network Information
</h3>
<div className="animate-pulse space-y-3">
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
</div>
</div>
</div>
</GridCard>
) : (
<Ipv6NetworkCard networkState={networkState || undefined} />
)}
</AutoHeight>
</div>
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
{(formState.isDirty || formState.isSubmitting) && (
<div className="animate-fadeInStill opacity-0 animation-duration-300">
<Button
size="SM"
theme="primary"
disabled={formState.isSubmitting}
loading={formState.isSubmitting}
type="submit"
text={formState.isSubmitting ? "Saving..." : "Save Settings"}
/>
</div>
)} )}
</AutoHeight> </div>
</div> </form>
<div className="hidden space-y-4"> </FormProvider>
<SettingsItem
title="LLDP"
description="Control which TLVs will be sent over Link Layer Discovery Protocol"
>
<SelectMenuBasic
size="SM"
value={networkSettings.lldp_mode}
onChange={e => handleLldpModeChange(e.target.value)}
options={filterUnknown([
{ value: "disabled", label: "Disabled" },
{ value: "basic", label: "Basic" },
{ value: "all", label: "All" },
])}
/>
</SettingsItem>
</div>
</Fieldset>
<ConfirmDialog <ConfirmDialog
open={showRenewLeaseConfirm} open={showRenewLeaseConfirm}
onClose={() => setShowRenewLeaseConfirm(false)}
title="Renew DHCP Lease" 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." description="Are you sure you want to renew the DHCP lease? This may temporarily disconnect the device."
variant="danger"
confirmText="Renew Lease"
onConfirm={() => { onConfirm={() => {
handleRenewLease();
setShowRenewLeaseConfirm(false); setShowRenewLeaseConfirm(false);
}} }}
onClose={() => setShowRenewLeaseConfirm(false)}
/> />
</> </>
); );

103
ui/src/utils/jsonrpc.ts Normal file
View File

@ -0,0 +1,103 @@
import { useRTCStore } from "@/hooks/stores";
// JSON-RPC utility for use outside of React components
export interface JsonRpcCallOptions {
method: string;
params?: unknown;
timeout?: number;
}
export interface JsonRpcCallResponse {
jsonrpc: string;
result?: unknown;
error?: {
code: number;
message: string;
data?: unknown;
};
id: number | string | null;
}
let rpcCallCounter = 0;
export function callJsonRpc(options: JsonRpcCallOptions): Promise<JsonRpcCallResponse> {
return new Promise((resolve, reject) => {
// Access the RTC store directly outside of React context
const rpcDataChannel = useRTCStore.getState().rpcDataChannel;
if (!rpcDataChannel || rpcDataChannel.readyState !== "open") {
reject(new Error("RPC data channel not available"));
return;
}
rpcCallCounter++;
const requestId = `rpc_${Date.now()}_${rpcCallCounter}`;
const request = {
jsonrpc: "2.0",
method: options.method,
params: options.params || {},
id: requestId,
};
const timeout = options.timeout || 5000;
let timeoutId: number | undefined;
const messageHandler = (event: MessageEvent) => {
try {
const response = JSON.parse(event.data) as JsonRpcCallResponse;
if (response.id === requestId) {
clearTimeout(timeoutId);
rpcDataChannel.removeEventListener("message", messageHandler);
resolve(response);
}
} catch (error) {
// Ignore parse errors from other messages
}
};
timeoutId = setTimeout(() => {
rpcDataChannel.removeEventListener("message", messageHandler);
reject(new Error(`JSON-RPC call timed out after ${timeout}ms`));
}, timeout);
rpcDataChannel.addEventListener("message", messageHandler);
rpcDataChannel.send(JSON.stringify(request));
});
}
// Specific network settings API calls
export async function getNetworkSettings() {
const response = await callJsonRpc({ method: "getNetworkSettings" });
if (response.error) {
throw new Error(response.error.message);
}
return response.result;
}
export async function setNetworkSettings(settings: unknown) {
const response = await callJsonRpc({
method: "setNetworkSettings",
params: { settings },
});
if (response.error) {
throw new Error(response.error.message);
}
return response.result;
}
export async function getNetworkState() {
const response = await callJsonRpc({ method: "getNetworkState" });
if (response.error) {
throw new Error(response.error.message);
}
return response.result;
}
export async function renewDHCPLease() {
const response = await callJsonRpc({ method: "renewDHCPLease" });
if (response.error) {
throw new Error(response.error.message);
}
return response.result;
}