diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 6781cf0..9f8cecf 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import { LuEthernetPort } from "react-icons/lu"; @@ -83,6 +83,13 @@ export default function SettingsNetworkRoute() { // Confirm dialog const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false); + 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 fetchNetworkData = useCallback(async () => { try { @@ -116,6 +123,7 @@ export default function SettingsNetworkRoute() { }, }; + initialSettingsRef.current = settingsWithDefaults; return { settings: settingsWithDefaults, state }; } catch (err) { notifications.error(err instanceof Error ? err.message : "Unknown error"); @@ -137,10 +145,8 @@ export default function SettingsNetworkRoute() { }, }); - const { register, handleSubmit, watch, formState, reset } = formMethods; - - const onSubmit = async (data: FieldValues) => { - const settings = { + const prepareSettings = (data: FieldValues) => { + return { ...data, // If custom domain option is selected, use the custom domain as value @@ -157,8 +163,12 @@ export default function SettingsNetworkRoute() { // Remove empty DNS entries dns: data.ipv6_static?.dns.filter((dns: string) => dns.trim() !== ""), }, - }; + } as NetworkSettings; + }; + const { register, handleSubmit, watch, formState, reset } = formMethods; + + const onSubmit = async (settings: NetworkSettings) => { send("setNetworkSettings", { settings }, async resp => { if ("error" in resp) { return notifications.error( @@ -174,12 +184,44 @@ export default function SettingsNetworkRoute() { }); }; + const onSubmitGate = async (data: FieldValues) => { + const settings = prepareSettings(data); + const dirty = formState.dirtyFields; + + // These fields will prompt a confirm dialog, all else save immediately + const criticalFields = [ + // Label is for the UI, key is the internal key of the field + { label: "IPv4 mode", key: "ipv4_mode" }, + { label: "IPv6 mode", key: "ipv6_mode" }, + ] as { label: string; key: keyof NetworkSettings }[]; + + const criticalChanged = criticalFields.some(field => dirty[field.key]); + + // If no critical fields are changed, save immediately + if (!criticalChanged) return onSubmit(settings); + + const changes = new Set<{ label: string; from: string; to: string }>(); + criticalFields.forEach(field => { + const { key, label } = field; + if (dirty[key]) { + const from = initialSettingsRef?.current?.[key] as string; + const to = data[key] as string; + changes.add({ label, from, to }); + } + }); + + setStagedSettings(settings); + setCriticalChanges(Array.from(changes)); + setShowCriticalSettingsConfirm(true); + }; + const ipv4mode = watch("ipv4_mode"); const ipv6mode = watch("ipv6_mode"); + return ( <> -
+ ) : ipv4mode === "static" ? ( + ) : ipv4mode === "dhcp" && !!formState.dirtyFields.ipv4_mode ? ( + ) : ipv4mode === "dhcp" ? (
+ + {/* Critical change confirm */} + { + setShowCriticalSettingsConfirm(false); + if (stagedSettings) onSubmit(stagedSettings); + + // Wait for the close animation to finish before resetting the staged settings + setTimeout(() => { + setStagedSettings(null); + setCriticalChanges([]); + }, 500); + }} + onClose={() => { + // close(); + setShowCriticalSettingsConfirm(false); + }} + isConfirming={formState.isSubmitting} + description={ +
+

+ This will update the device's network configuration and may briefly + disconnect your session. +

+ +
+
+ Pending changes +
+
+ {criticalChanges.map((c, idx) => ( +
+
+
+ {c.label} +
+
+ + {c.from || "—"} + + + + → + + + + {c.to} + +
+
+
+ ))} +
+
+ +

+ If the network settings are invalid,{" "} + the device may become unreachable and require a factory + reset to restore connectivity. +

+
+ } + /> + { setShowRenewLeaseConfirm(false); }}