import { useCallback, useEffect, useRef, useState } from "react"; import { FieldValues, FormProvider, useForm } from "react-hook-form"; import { LuCopy, LuEthernetPort } from "react-icons/lu"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import validator from "validator"; import { NetworkSettings, NetworkState, useLLDPNeighborsStore, useNetworkStateStore, useRTCStore } from "@hooks/stores"; import { useJsonRpc } from "@hooks/useJsonRpc"; import AutoHeight from "@components/AutoHeight"; import { Button } from "@components/Button"; import { ConfirmDialog } from "@components/ConfirmDialog"; import DhcpLeaseCard from "@components/DhcpLeaseCard"; import EmptyCard from "@components/EmptyCard"; import { GridCard } from "@components/Card"; import InputField, { InputFieldWithLabel } from "@components/InputField"; import Ipv6NetworkCard from "@components/Ipv6NetworkCard"; import { SelectMenuBasic } from "@/components/SelectMenuBasic"; import { SettingsItem } from "@components/SettingsItem"; import { SettingsPageHeader } from "@/components/SettingsPageheader"; import StaticIpv4Card from "@components/StaticIpv4Card"; import StaticIpv6Card from "@components/StaticIpv6Card"; import { useCopyToClipboard } from "@components/useCopyToClipBoard"; import { netMaskFromCidr4 } from "@/utils/ip"; import { getNetworkSettings, getNetworkState, getLLDPNeighbors } from "@/utils/jsonrpc"; import notifications from "@/notifications"; import { m } from "@localizations/messages"; import LLDPNeighborsCard from "@components/LLDPNeighborsCard"; dayjs.extend(relativeTime); const isLLDPAvailable = true; // LLDP is now supported const resolveOnRtcReady = () => { return new Promise(resolve => { // Check if RTC is already connected const currentState = useRTCStore.getState(); if (currentState.rpcDataChannel?.readyState === "open") { // Already connected, fetch data immediately return resolve(void 0); } // Not connected yet, subscribe to state changes const unsubscribe = useRTCStore.subscribe(state => { if (state.rpcDataChannel?.readyState === "open") { unsubscribe(); // Clean up subscription return resolve(void 0); } }); }); }; export function LifeTimeLabel({ lifetime }: Readonly<{ lifetime: string }>) { const [remaining, setRemaining] = useState(null); // rrecalculate remaining time every 30 seconds useEffect(() => { // schedule immediate initial update setInterval(() => setRemaining(dayjs(lifetime).fromNow()), 0); const interval = setInterval(() => { setRemaining(dayjs(lifetime).fromNow()); }, 1000 * 30); return () => clearInterval(interval); }, [lifetime]); if (lifetime == "") { return {m.not_applicable()}; } return ( <> {remaining && <> {remaining}}  ({dayjs(lifetime).format("YYYY-MM-DD HH:mm")}) ); } export default function SettingsNetworkRoute() { const { send } = useJsonRpc(); const networkState = useNetworkStateStore(state => state); const setNetworkState = useNetworkStateStore(state => state.setNetworkState); // Some input needs direct state management. Mostly options that open more details const [customDomain, setCustomDomain] = useState(""); // Confirm dialog const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false); // We use this to determine whether the settings have changed const initialSettingsRef = useRef(null); const [showCriticalSettingsConfirm, setShowCriticalSettingsConfirm] = useState(false); const [stagedSettings, setStagedSettings] = useState(null); const [criticalChanges, setCriticalChanges] = useState< { label: string; from: string; to: string }[] >([]); const setLLDPNeighbors = useLLDPNeighborsStore(state => state.setNeighbors); const lldpNeighbors = useLLDPNeighborsStore(state => state.neighbors); const fetchLLDPNeighbors = useCallback(async () => { const neighbors = await getLLDPNeighbors(); setLLDPNeighbors(neighbors); }, [setLLDPNeighbors]); useEffect(() => { fetchLLDPNeighbors(); }, [fetchLLDPNeighbors]); const fetchNetworkData = useCallback(async () => { try { console.log("Fetching network data..."); const [settings, state] = (await Promise.all([ getNetworkSettings(), getNetworkState(), ])) as [NetworkSettings, NetworkState]; setNetworkState(state); 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 || [], }, ipv6_static: { prefix: settings.ipv6_static?.prefix || state.ipv6_addresses?.[0]?.prefix || "", gateway: settings.ipv6_static?.gateway || "", dns: settings.ipv6_static?.dns || [], }, }; initialSettingsRef.current = settingsWithDefaults; return { settings: settingsWithDefaults, state }; } catch (err) { notifications.error(m.network_settings_load_error({ error: err instanceof Error ? err.message : m.unknown_error() })); throw err; } }, [setNetworkState]); const formMethods = useForm({ mode: "onBlur", defaultValues: async () => { // Ensure data channel is ready, before fetching network data from the device await resolveOnRtcReady(); const { settings } = await fetchNetworkData(); return settings; }, }); const prepareSettings = useCallback((data: FieldValues) => { return { ...data, // If custom domain option is selected, use the custom domain as value domain: data.domain === "custom" ? customDomain : data.domain, } as NetworkSettings; }, [customDomain]); const { register, handleSubmit, watch, formState, reset } = formMethods; const [isSubmitting, setIsSubmitting] = useState(false); const onSubmit = useCallback(async (settings: NetworkSettings) => { if (settings.ipv4_static?.address?.includes("/")) { const parts = settings.ipv4_static.address.split("/"); const cidrNotation = Number.parseInt(parts[1]); if (Number.isNaN(cidrNotation) || cidrNotation < 0 || cidrNotation > 32) { return notifications.error(m.network_ipv4_invalid_cidr()); } settings.ipv4_static.netmask = netMaskFromCidr4(cidrNotation); settings.ipv4_static.address = parts[0]; } setIsSubmitting(true); send("setNetworkSettings", { settings }, async (resp) => { if ("error" in resp) { notifications.error(m.network_save_settings_failed({ error: resp.error.message || m.unknown_error() })); } 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... try { const networkData = await fetchNetworkData(); if (!networkData) return reset(networkData.settings); notifications.success(m.network_save_settings_success()); } catch (error) { console.error("Failed to fetch network data:", error); } setIsSubmitting(false); } }); }, [fetchNetworkData, reset, send, setIsSubmitting]); const onSubmitGate = useCallback(async (data: FieldValues) => { const settings = prepareSettings(data); const dirty = formState.dirtyFields; // Build list of critical changes for display const changes: { label: string; from: string; to: string }[] = []; if (dirty.dhcp_client) { changes.push({ label: m.network_dhcp_client_title(), from: initialSettingsRef.current?.dhcp_client as string, to: data.dhcp_client as string, }); } if (dirty.ipv4_mode) { changes.push({ label: m.network_ipv4_mode_title(), from: initialSettingsRef.current?.ipv4_mode as string, to: data.ipv4_mode as string, }); } if (dirty.ipv4_static?.address) { changes.push({ label: m.network_ipv4_address(), from: initialSettingsRef.current?.ipv4_static?.address as string, to: data.ipv4_static?.address as string, }); } if (dirty.ipv4_static?.netmask) { changes.push({ label: m.network_ipv4_netmask(), from: initialSettingsRef.current?.ipv4_static?.netmask as string, to: data.ipv4_static?.netmask as string, }); } if (dirty.ipv4_static?.gateway) { changes.push({ label: m.network_ipv4_gateway(), from: initialSettingsRef.current?.ipv4_static?.gateway as string, to: data.ipv4_static?.gateway as string, }); } if (dirty.ipv4_static?.dns) { changes.push({ label: m.network_ipv4_dns(), from: initialSettingsRef.current?.ipv4_static?.dns.join(", ").toString() ?? "", to: data.ipv4_static?.dns.join(", ").toString() ?? "", }); } if (dirty.ipv6_mode) { changes.push({ label: m.network_ipv6_mode_title(), from: initialSettingsRef.current?.ipv6_mode as string, to: data.ipv6_mode as string, }); } if (dirty.ipv6_static?.prefix) { changes.push({ label: m.network_ipv6_prefix(), from: initialSettingsRef.current?.ipv6_static?.prefix as string, to: data.ipv6_static?.prefix as string, }); } if (dirty.ipv6_static?.gateway) { changes.push({ label: m.network_ipv6_gateway(), from: initialSettingsRef.current?.ipv6_static?.gateway as string, to: data.ipv6_static?.gateway as string, }); } if (dirty.ipv6_static?.dns) { changes.push({ label: m.network_ipv6_dns(), from: initialSettingsRef.current?.ipv6_static?.dns.join(", ").toString() ?? "", to: data.ipv6_static?.dns.join(", ").toString() ?? "", }); } // If no critical fields are changed, save immediately if (changes.length === 0) return onSubmit(settings); // Show confirmation dialog for critical changes setStagedSettings(settings); setCriticalChanges(changes); setShowCriticalSettingsConfirm(true); }, [prepareSettings, formState.dirtyFields, onSubmit]); const ipv4mode = watch("ipv4_mode"); const ipv6mode = watch("ipv6_mode"); const onDhcpLeaseRenew = () => { send("renewDHCPLease", {}, (resp) => { if ("error" in resp) { notifications.error(m.network_dhcp_lease_renew_failed({ error: resp.error.message || m.unknown_error() })); } else { notifications.success(m.network_dhcp_lease_renew_success()); } }); }; const { copy } = useCopyToClipboard(); return ( <>