feat(ui): add IPv6 static configuration support in network settings

This commit is contained in:
Adam Shiervani 2025-08-06 21:16:45 +02:00
parent fe7450d6ec
commit 1099f56054
6 changed files with 212 additions and 71 deletions

View File

@ -29,7 +29,7 @@ type IPv4StaticConfig struct {
type IPv6StaticConfig struct {
Address null.String `json:"address,omitempty" validate_type:"ipv6" required:"true"`
Prefix null.String `json:"prefix,omitempty" validate_type:"ipv6" required:"true"`
Prefix null.String `json:"prefix,omitempty" required:"true"`
Gateway null.String `json:"gateway,omitempty" validate_type:"ipv6" required:"true"`
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
}

View File

@ -33,56 +33,54 @@ export default function Ipv6NetworkCard({
{networkState?.ipv6_addresses && networkState?.ipv6_addresses.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold">IPv6 Addresses</h4>
{networkState.ipv6_addresses.map(
addr => (
<div
key={addr.address}
className="rounded-md rounded-l-none border border-slate-500/10 border-l-blue-700/50 bg-white p-4 pl-4 backdrop-blur-sm dark:bg-transparent"
>
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
<div className="col-span-2 flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Address
</span>
<span className="text-sm font-medium">{addr.address}</span>
</div>
{addr.valid_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Valid Lifetime
</span>
<span className="text-sm font-medium">
{addr.valid_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel lifetime={`${addr.valid_lifetime}`} />
)}
</span>
</div>
)}
{addr.preferred_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Preferred Lifetime
</span>
<span className="text-sm font-medium">
{addr.preferred_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} />
)}
</span>
</div>
)}
{networkState.ipv6_addresses.map(addr => (
<div
key={addr.address}
className="rounded-md rounded-l-none border border-slate-500/10 border-l-blue-700/50 bg-white p-4 pl-4 backdrop-blur-sm dark:bg-transparent"
>
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
<div className="col-span-2 flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Address
</span>
<span className="text-sm font-medium">{addr.address}</span>
</div>
{addr.valid_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Valid Lifetime
</span>
<span className="text-sm font-medium">
{addr.valid_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel lifetime={`${addr.valid_lifetime}`} />
)}
</span>
</div>
)}
{addr.preferred_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Preferred Lifetime
</span>
<span className="text-sm font-medium">
{addr.preferred_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} />
)}
</span>
</div>
)}
</div>
),
)}
</div>
))}
</div>
)}
</div>

View File

@ -1,6 +1,6 @@
import { LuPlus, LuX } from "react-icons/lu";
import { useFieldArray, useFormContext } from "react-hook-form";
import validator from "validator";
import { useEffect } from "react";
import { GridCard } from "@/components/Card";
import { Button } from "@/components/Button";
@ -8,10 +8,15 @@ import { InputFieldWithLabel } from "@/components/InputField";
export default function StaticIpv4Card() {
const formMethods = useFormContext();
const { register, formState } = formMethods;
const { register, formState, watch } = formMethods;
const { fields, append, remove } = useFieldArray({ name: "ipv4_static.dns" });
useEffect(() => {
if (fields.length === 0) append("");
}, [append, fields.length]);
const dns = watch("ipv4_static.dns");
return (
<GridCard>
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
@ -59,11 +64,11 @@ export default function StaticIpv4Card() {
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;
},
// validate: (value: string) => {
// if (value === "") return true;
// if (!validator.isIP(value)) return "Invalid IP address";
// return true;
// },
})}
error={formState.errors.ipv4_static?.dns?.[index]?.message}
/>
@ -92,6 +97,7 @@ export default function StaticIpv4Card() {
LeadingIcon={LuPlus}
type="button"
text="Add DNS Server"
disabled={dns[0] === ""}
/>
</div>
</div>

View File

@ -0,0 +1,107 @@
import { LuPlus, LuX } from "react-icons/lu";
import { useFieldArray, useFormContext } from "react-hook-form";
import validator from "validator";
import { useEffect } from "react";
import { GridCard } from "@/components/Card";
import { Button } from "@/components/Button";
import { InputFieldWithLabel } from "@/components/InputField";
export default function StaticIpv6Card() {
const formMethods = useFormContext();
const { register, formState, watch } = formMethods;
const { fields, append, remove } = useFieldArray({ name: "ipv6_static.dns" });
useEffect(() => {
if (fields.length === 0) append("");
}, [append, fields.length]);
const dns = watch("ipv6_static.dns");
return (
<GridCard>
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Static IPv6 Configuration
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputFieldWithLabel
label="IP Address"
type="text"
size="SM"
placeholder="2001:db8::1"
{...register("ipv6_static.address")}
/>
<InputFieldWithLabel
label="Prefix"
type="text"
size="SM"
placeholder="64"
{...register("ipv6_static.prefix")}
/>
</div>
<InputFieldWithLabel
label="Gateway"
type="text"
size="SM"
placeholder="2001:db8::1"
{...register("ipv6_static.gateway")}
/>
{/* DNS server fields */}
<div className="space-y-4">
{fields.map((dns, index) => {
return (
<div key={dns.id}>
<div className="flex items-start gap-x-2">
<div className="flex-1">
<InputFieldWithLabel
label={index === 0 ? "DNS Server" : null}
type="text"
size="SM"
placeholder="1.1.1.1"
{...register(`ipv6_static.dns.${index}`, {
validate: (value: string) => {
if (value === "") return true;
if (!validator.isIP(value)) return "Invalid IP address";
return true;
},
})}
error={formState.errors.ipv6_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>
<Button
size="SM"
theme="light"
onClick={() => append("", { shouldFocus: true })}
LeadingIcon={LuPlus}
type="button"
text="Add DNS Server"
disabled={dns[0] === ""}
/>
</div>
</div>
</GridCard>
);
}

View File

@ -752,6 +752,13 @@ export interface IPv4StaticConfig {
dns: string[];
}
export interface IPv6StaticConfig {
address: string;
prefix: string;
gateway: string;
dns: string[];
}
export interface NetworkSettings {
hostname: string | null;
domain: string | null;
@ -759,6 +766,7 @@ export interface NetworkSettings {
ipv4_mode: IPv4Mode;
ipv4_static?: IPv4StaticConfig;
ipv6_mode: IPv6Mode;
ipv6_static?: IPv6StaticConfig;
lldp_mode: LLDPMode;
lldp_tx_tlvs: string[];
mdns_mode: mDNSMode;

View File

@ -21,6 +21,7 @@ import AutoHeight from "../components/AutoHeight";
import DhcpLeaseCard from "../components/DhcpLeaseCard";
import StaticIpv4Card from "../components/StaticIpv4Card";
import { useJsonRpc } from "../hooks/useJsonRpc";
import StaticIpv6Card from "../components/StaticIpv6Card";
import { SettingsItem } from "./devices.$id.settings";
@ -106,6 +107,13 @@ export default function SettingsNetworkRoute() {
gateway: settings.ipv4_static?.gateway || state.dhcp_lease?.routers?.[0] || "",
dns: settings.ipv4_static?.dns || state.dhcp_lease?.dns_servers || [],
},
ipv6_static: {
address:
settings.ipv6_static?.address || state.ipv6_addresses?.[0]?.address || "",
prefix: settings.ipv6_static?.prefix || state.ipv6_addresses?.[0]?.prefix || "",
gateway: settings.ipv6_static?.gateway || "",
dns: settings.ipv6_static?.dns || [],
},
};
return { settings: settingsWithDefaults, state };
@ -143,6 +151,12 @@ export default function SettingsNetworkRoute() {
// 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() !== ""),
},
};
send("setNetworkSettings", { settings }, async resp => {
@ -160,7 +174,8 @@ export default function SettingsNetworkRoute() {
});
};
const isIPv4Mode = watch("ipv4_mode");
const ipv4mode = watch("ipv4_mode");
const ipv6mode = watch("ipv6_mode");
return (
<>
<FormProvider {...formMethods}>
@ -309,9 +324,9 @@ export default function SettingsNetworkRoute() {
</div>
</div>
</GridCard>
) : isIPv4Mode === "static" ? (
) : ipv4mode === "static" ? (
<StaticIpv4Card />
) : isIPv4Mode === "dhcp" ? (
) : ipv4mode === "dhcp" ? (
<DhcpLeaseCard
networkState={networkState}
setShowRenewLeaseConfirm={setShowRenewLeaseConfirm}
@ -329,7 +344,10 @@ export default function SettingsNetworkRoute() {
<SettingsItem title="IPv6 Mode" description="Configure the IPv6 mode">
<SelectMenuBasic
size="SM"
options={[{ value: "slaac", label: "SLAAC" }]}
options={[
{ value: "slaac", label: "SLAAC" },
{ value: "static", label: "Static" },
]}
{...register("ipv6_mode")}
/>
</SettingsItem>
@ -350,23 +368,27 @@ export default function SettingsNetworkRoute() {
</div>
</div>
</GridCard>
) : ipv6mode === "static" ? (
<StaticIpv6Card />
) : (
<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>
<>
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<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>
</>
)}
</div>
</form>