Compare commits

...

7 Commits

5 changed files with 234 additions and 84 deletions

View File

@ -1,13 +1,10 @@
import {
CheckCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline";
import { CheckCircleIcon } from "@heroicons/react/24/outline";
import { CloseButton } from "@headlessui/react";
import { LuInfo, LuOctagonAlert, LuTriangleAlert } from "react-icons/lu";
import { Button } from "@/components/Button";
import Modal from "@/components/Modal";
import { cx } from "@/cva.config";
type Variant = "danger" | "success" | "warning" | "info";
interface ConfirmDialogProps {
@ -24,27 +21,27 @@ interface ConfirmDialogProps {
const variantConfig = {
danger: {
icon: ExclamationTriangleIcon,
icon: LuOctagonAlert,
iconClass: "text-red-600",
iconBgClass: "bg-red-100",
iconBgClass: "bg-red-100 border border-red-500/90",
buttonTheme: "danger",
},
success: {
icon: CheckCircleIcon,
iconClass: "text-green-600",
iconBgClass: "bg-green-100",
iconBgClass: "bg-green-100 border border-green-500/90",
buttonTheme: "primary",
},
warning: {
icon: ExclamationTriangleIcon,
icon: LuTriangleAlert,
iconClass: "text-yellow-600",
iconBgClass: "bg-yellow-100",
buttonTheme: "lightDanger",
iconBgClass: "bg-yellow-100 border border-yellow-500/90",
buttonTheme: "primary",
},
info: {
icon: InformationCircleIcon,
icon: LuInfo,
iconClass: "text-blue-600",
iconBgClass: "bg-blue-100",
iconBgClass: "bg-blue-100 border border-blue-500/90",
buttonTheme: "primary",
},
} as Record<
@ -94,12 +91,13 @@ export function ConfirmDialog({
</div>
</div>
<div className="flex justify-end gap-x-2">
<div className="flex justify-end gap-x-2" autoFocus>
{cancelText && (
<Button size="SM" theme="blank" text={cancelText} onClick={onClose} />
<CloseButton as={Button} size="SM" theme="blank" text={cancelText} />
)}
<Button
size="SM"
type="button"
theme={buttonTheme}
text={isConfirming ? `${confirmText}...` : confirmText}
onClick={onConfirm}
@ -111,4 +109,4 @@ export function ConfirmDialog({
</div>
</Modal>
);
}
}

View File

@ -5,6 +5,8 @@ import { GridCard } from "@/components/Card";
import { LifeTimeLabel } from "@/routes/devices.$id.settings.network";
import { NetworkState } from "@/hooks/stores";
import EmptyCard from "./EmptyCard";
export default function DhcpLeaseCard({
networkState,
setShowRenewLeaseConfirm,
@ -12,13 +14,38 @@ export default function DhcpLeaseCard({
networkState: NetworkState | null;
setShowRenewLeaseConfirm: (show: boolean) => void;
}) {
const isDhcpLeaseEmpty = Object.keys(networkState?.dhcp_lease || {}).length === 0;
if (isDhcpLeaseEmpty) {
return (
<EmptyCard
headline="No DHCP Lease information"
description="We haven't received any DHCP lease information from the device yet."
/>
);
}
return (
<GridCard>
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
<div className="space-y-3">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease Information
</h3>
<div className="flex items-center justify-between">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease Information
</h3>
<div>
<Button
size="XS"
theme="light"
type="button"
className="text-red-500"
text="Renew DHCP Lease"
LeadingIcon={LuRefreshCcw}
onClick={() => setShowRenewLeaseConfirm(true)}
/>
</div>
</div>
<div className="flex gap-x-6 gap-y-2">
<div className="flex-1 space-y-2">
@ -196,17 +223,6 @@ export default function DhcpLeaseCard({
)}
</div>
</div>
<div>
<Button
size="SM"
theme="light"
className="text-red-500"
text="Renew DHCP Lease"
LeadingIcon={LuRefreshCcw}
onClick={() => setShowRenewLeaseConfirm(true)}
/>
</div>
</div>
</div>
</GridCard>

View File

@ -1,13 +1,15 @@
import { LuPlus, LuX } from "react-icons/lu";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useEffect } from "react";
import validator from "validator";
import { GridCard } from "@/components/Card";
import { Button } from "@/components/Button";
import { InputFieldWithLabel } from "@/components/InputField";
import { NetworkSettings } from "@/hooks/stores";
export default function StaticIpv4Card() {
const formMethods = useFormContext();
const formMethods = useFormContext<NetworkSettings>();
const { register, formState, watch } = formMethods;
const { fields, append, remove } = useFieldArray({ name: "ipv4_static.dns" });
@ -17,6 +19,12 @@ export default function StaticIpv4Card() {
}, [append, fields.length]);
const dns = watch("ipv4_static.dns");
const validate = (value: string) => {
if (!validator.isIP(value)) return "Invalid IP address";
return true;
};
return (
<GridCard>
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
@ -31,7 +39,8 @@ export default function StaticIpv4Card() {
type="text"
size="SM"
placeholder="192.168.1.100"
{...register("ipv4_static.address")}
{...register("ipv4_static.address", { validate })}
error={formState.errors.ipv4_static?.address?.message}
/>
<InputFieldWithLabel
@ -39,7 +48,8 @@ export default function StaticIpv4Card() {
type="text"
size="SM"
placeholder="255.255.255.0"
{...register("ipv4_static.netmask")}
{...register("ipv4_static.netmask", { validate })}
error={formState.errors.ipv4_static?.netmask?.message}
/>
</div>
@ -48,7 +58,8 @@ export default function StaticIpv4Card() {
type="text"
size="SM"
placeholder="192.168.1.1"
{...register("ipv4_static.gateway")}
{...register("ipv4_static.gateway", { validate })}
error={formState.errors.ipv4_static?.gateway?.message}
/>
{/* DNS server fields */}
@ -63,13 +74,7 @@ export default function StaticIpv4Card() {
type="text"
size="SM"
placeholder="1.1.1.1"
{...register(`ipv4_static.dns.${index}`, {
// validate: (value: string) => {
// if (value === "") return true;
// if (!validator.isIP(value)) return "Invalid IP address";
// return true;
// },
})}
{...register(`ipv4_static.dns.${index}`, { validate })}
error={formState.errors.ipv4_static?.dns?.[index]?.message}
/>
</div>

View File

@ -6,9 +6,10 @@ import { useEffect } from "react";
import { GridCard } from "@/components/Card";
import { Button } from "@/components/Button";
import { InputFieldWithLabel } from "@/components/InputField";
import { NetworkSettings } from "@/hooks/stores";
export default function StaticIpv6Card() {
const formMethods = useFormContext();
const formMethods = useFormContext<NetworkSettings>();
const { register, formState, watch } = formMethods;
const { fields, append, remove } = useFieldArray({ name: "ipv6_static.dns" });
@ -18,6 +19,29 @@ export default function StaticIpv6Card() {
}, [append, fields.length]);
const dns = watch("ipv6_static.dns");
const cidrValidation = (value: string) => {
if (value === "") return true;
// Check if it's a valid IPv6 address with CIDR notation
const parts = value.split("/");
if (parts.length !== 2) return "Please use CIDR notation (e.g., 2001:db8::1/64)";
const [address, prefix] = parts;
if (!validator.isIP(address, 6)) return "Invalid IPv6 address";
const prefixNum = parseInt(prefix);
if (isNaN(prefixNum) || prefixNum < 0 || prefixNum > 128) {
return "Prefix must be between 0 and 128";
}
return true;
};
const ipv6Validation = (value: string) => {
if (!validator.isIP(value, 6)) return "Invalid IPv6 address";
return true;
};
return (
<GridCard>
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
@ -31,22 +55,7 @@ export default function StaticIpv6Card() {
type="text"
size="SM"
placeholder="2001:db8::1/64"
{...register("ipv6_static.address", {
validate: (value: string) => {
if (value === "") return true;
// Check if it's a valid IPv6 address with CIDR notation
const parts = value.split("/");
if (parts.length !== 2)
return "Please use CIDR notation (e.g., 2001:db8::1/64)";
const [address, prefix] = parts;
if (!validator.isIP(address, 6)) return "Invalid IPv6 address";
const prefixNum = parseInt(prefix);
if (isNaN(prefixNum) || prefixNum < 0 || prefixNum > 128) {
return "Prefix must be between 0 and 128";
}
return true;
},
})}
{...register("ipv6_static.address", { validate: cidrValidation })}
error={formState.errors.ipv6_static?.address?.message}
/>
@ -55,7 +64,8 @@ export default function StaticIpv6Card() {
type="text"
size="SM"
placeholder="2001:db8::1"
{...register("ipv6_static.gateway")}
{...register("ipv6_static.gateway", { validate: ipv6Validation })}
error={formState.errors.ipv6_static?.gateway?.message}
/>
{/* DNS server fields */}
@ -71,11 +81,7 @@ export default function StaticIpv6Card() {
size="SM"
placeholder="2001:4860:4860::8888"
{...register(`ipv6_static.dns.${index}`, {
validate: (value: string) => {
if (value === "") return true;
if (!validator.isIP(value)) return "Invalid IP address";
return true;
},
validate: ipv6Validation,
})}
error={formState.errors.ipv6_static?.dns?.[index]?.message}
/>

View File

@ -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<NetworkSettings | null>(null);
const [showCriticalSettingsConfirm, setShowCriticalSettingsConfirm] = useState(false);
const [stagedSettings, setStagedSettings] = useState<NetworkSettings | null>(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,28 +145,18 @@ 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
domain: data.domain === "custom" ? customDomain : data.domain,
ipv4_static: {
...data.ipv4_static,
} as NetworkSettings;
};
// Remove empty DNS entries
dns: data.ipv4_static?.dns.filter((dns: string) => dns.trim() !== ""),
},
ipv6_static: {
...data.ipv6_static,
// Remove empty DNS entries
dns: data.ipv6_static?.dns.filter((dns: string) => dns.trim() !== ""),
},
};
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 +172,54 @@ 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");
const onDhcpLeaseRenew = () => {
send("renewDHCPLease", {}, resp => {
if ("error" in resp) {
notifications.error("Failed to renew lease: " + resp.error.message);
} else {
notifications.success("DHCP lease renewed");
}
});
};
return (
<>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<form onSubmit={handleSubmit(onSubmitGate)} className="space-y-4">
<SettingsPageHeader
title="Network"
description="Configure the network settings for the device"
@ -326,6 +366,12 @@ export default function SettingsNetworkRoute() {
</GridCard>
) : ipv4mode === "static" ? (
<StaticIpv4Card />
) : ipv4mode === "dhcp" && !!formState.dirtyFields.ipv4_mode ? (
<EmptyCard
IconElm={LuEthernetPort}
headline="Pending DHCP IPv4 mode change"
description="Save settings to enable DHCP mode and view lease information"
/>
) : ipv4mode === "dhcp" ? (
<DhcpLeaseCard
networkState={networkState}
@ -393,12 +439,91 @@ export default function SettingsNetworkRoute() {
</div>
</form>
</FormProvider>
{/* Critical change confirm */}
<ConfirmDialog
open={showCriticalSettingsConfirm}
title="Apply network settings"
variant="warning"
confirmText="Apply changes"
onConfirm={() => {
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={
<div className="space-y-4">
<p>
This will update the device&apos;s network configuration and may briefly
disconnect your session.
</p>
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 dark:border-slate-700 dark:bg-slate-900/40">
<div className="mb-2 text-xs font-semibold tracking-wide text-slate-500 uppercase dark:text-slate-400">
Pending changes
</div>
<dl className="grid grid-cols-1 gap-y-2">
{criticalChanges.map((c, idx) => (
<div key={idx} className="w-full not-last:pb-2">
<div className="flex items-center gap-2 gap-x-8">
<dt className="text-sm text-slate-500 dark:text-slate-400">
{c.label}
</dt>
<div className="flex items-center gap-2">
<span className="rounded-sm bg-slate-200 px-1.5 py-0.5 text-sm font-medium text-slate-900 dark:bg-slate-700 dark:text-slate-100">
{c.from || "—"}
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
</span>
<span className="rounded-sm bg-slate-200 px-1.5 py-0.5 text-sm font-medium text-slate-900 dark:bg-slate-700 dark:text-slate-100">
{c.to}
</span>
</div>
</div>
</div>
))}
</dl>
</div>
<p className="text-sm">
If the network settings are invalid,{" "}
<strong>the device may become unreachable</strong> and require a factory
reset to restore connectivity.
</p>
</div>
}
/>
<ConfirmDialog
open={showRenewLeaseConfirm}
title="Renew DHCP Lease"
description="Are you sure you want to renew the DHCP lease? This may temporarily disconnect the device."
variant="warning"
confirmText="Renew Lease"
description={
<p>
This will request a new IP address from your router. The device may briefly
disconnect during the renewal process.
<br />
<br />
If you receive a new IP address,{" "}
<strong>you may need to reconnect using the new address</strong>.
</p>
}
onConfirm={() => {
setShowRenewLeaseConfirm(false);
onDhcpLeaseRenew();
}}
onClose={() => setShowRenewLeaseConfirm(false)}
/>