feat(ui): enhance network settings with static IP configuration

This commit is contained in:
Adam Shiervani 2025-08-06 14:06:42 +02:00
parent 55fbd6c359
commit 3476ace47d
4 changed files with 309 additions and 13 deletions

View File

@ -346,6 +346,17 @@ func (f *FieldConfig) validateField() error {
return nil
}
// Handle []string types, like dns servers, time sync ntp servers, etc.
if slice, ok := f.CurrentValue.([]string); ok {
for i, item := range slice {
if err := f.validateSingleValue(item, i); err != nil {
return err
}
}
return nil
}
// Handle single string types
val, err := toString(f.CurrentValue)
if err != nil {
return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err)
@ -355,27 +366,46 @@ func (f *FieldConfig) validateField() error {
return nil
}
return f.validateSingleValue(val, -1)
}
func (f *FieldConfig) validateSingleValue(val string, index int) error {
for _, validateType := range f.ValidateTypes {
var fieldRef string
if index >= 0 {
fieldRef = fmt.Sprintf("field `%s[%d]`", f.Name, index)
} else {
fieldRef = fmt.Sprintf("field `%s`", f.Name)
}
switch validateType {
case "ipv4":
if net.ParseIP(val).To4() == nil {
return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", f.Name, val)
return fmt.Errorf("%s is not a valid IPv4 address: %s", fieldRef, val)
}
case "ipv6":
if net.ParseIP(val).To16() == nil {
return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", f.Name, val)
return fmt.Errorf("%s is not a valid IPv6 address: %s", fieldRef, val)
}
case "ipv4_or_ipv6":
if net.ParseIP(val) == nil {
return fmt.Errorf("%s is not a valid IPv4 or IPv6 address: %s", fieldRef, val)
}
case "hwaddr":
if _, err := net.ParseMAC(val); err != nil {
return fmt.Errorf("field `%s` is not a valid MAC address: %s", f.Name, val)
return fmt.Errorf("%s is not a valid MAC address: %s", fieldRef, val)
}
case "hostname":
if _, err := idna.Lookup.ToASCII(val); err != nil {
return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val)
return fmt.Errorf("%s is not a valid hostname: %s", fieldRef, val)
}
case "proxy":
if url, err := url.Parse(val); err != nil || (url.Scheme != "http" && url.Scheme != "https") || url.Host == "" {
return fmt.Errorf("field `%s` is not a valid HTTP proxy URL: %s", f.Name, val)
return fmt.Errorf("%s is not a valid HTTP proxy URL: %s", fieldRef, val)
}
case "url":
if _, err := url.Parse(val); err != nil {
return fmt.Errorf("%s is not a valid URL: %s", fieldRef, val)
}
default:
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)

View File

@ -0,0 +1,237 @@
import { useState, useEffect } from "react";
import { LuPlus, LuX } from "react-icons/lu";
import isIP from "validator/es/lib/isIP";
import { GridCard } from "@/components/Card";
import { Button } from "@/components/Button";
import { InputFieldWithLabel } from "@/components/InputField";
import { IPv4StaticConfig, NetworkSettings, NetworkState } from "@/hooks/stores";
interface StaticIpv4CardProps {
networkSettings: NetworkSettings;
onUpdate: (settings: NetworkSettings) => void;
networkState?: NetworkState;
onApply: () => void;
}
export default function StaticIpv4Card({
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 (
<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 IPv4 Configuration
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputFieldWithLabel
label="IP Address"
type="text"
size="SM"
placeholder="192.168.1.100"
value={staticConfig.address}
onChange={e => handleConfigChange("address", e.target.value)}
error={addressError}
/>
<InputFieldWithLabel
label="Subnet Mask"
type="text"
size="SM"
placeholder="255.255.255.0"
value={staticConfig.netmask}
onChange={e => handleConfigChange("netmask", e.target.value)}
error={netmaskError}
/>
</div>
<InputFieldWithLabel
label="Gateway"
type="text"
size="SM"
placeholder="192.168.1.1"
value={staticConfig.gateway}
onChange={e => handleConfigChange("gateway", e.target.value)}
error={gatewayError}
/>
{/* DNS server fields */}
<div className="space-y-2">
{staticConfig.dns.length === 0 && (
<div className="flex items-center gap-2">
<div className="flex-1">
<InputFieldWithLabel
label="Primary DNS Server"
type="text"
size="SM"
placeholder="8.8.8.8"
value=""
onChange={e => {
const updatedConfig = { ...staticConfig, dns: [e.target.value] };
setStaticConfig(updatedConfig);
onUpdate({ ...networkSettings, ipv4_static: updatedConfig });
}}
/>
</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>
</div>
</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

@ -683,7 +683,7 @@ export interface DhcpLease {
bootp_file?: string;
timezone?: string;
routers?: string[];
dns?: string[];
dns_servers?: string[];
ntp_servers?: string[];
lpr_servers?: string[];
_time_servers?: string[];
@ -744,11 +744,19 @@ export type TimeSyncMode =
| "custom"
| "unknown";
export interface IPv4StaticConfig {
address: string;
netmask: string;
gateway: string;
dns: string[];
}
export interface NetworkSettings {
hostname: string;
domain: string;
http_proxy: string;
ipv4_mode: IPv4Mode;
ipv4_static?: IPv4StaticConfig;
ipv6_mode: IPv6Mode;
lldp_mode: LLDPMode;
lldp_tx_tlvs: string[];

View File

@ -27,6 +27,7 @@ import Ipv6NetworkCard from "../components/Ipv6NetworkCard";
import EmptyCard from "../components/EmptyCard";
import AutoHeight from "../components/AutoHeight";
import DhcpLeaseCard from "../components/DhcpLeaseCard";
import StaticIpv4Card from "../components/StaticIpv4Card";
import { SettingsItem } from "./devices.$id.settings";
@ -37,6 +38,7 @@ const defaultNetworkSettings: NetworkSettings = {
http_proxy: "",
domain: "",
ipv4_mode: "unknown",
ipv4_static: undefined,
ipv6_mode: "unknown",
lldp_mode: "unknown",
lldp_tx_tlvs: [],
@ -127,7 +129,17 @@ export default function SettingsNetworkRoute() {
const setNetworkSettingsRemote = useCallback(
(settings: NetworkSettings) => {
setNetworkSettingsLoaded(false);
send("setNetworkSettings", { settings }, resp => {
// 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: " +
@ -375,7 +387,7 @@ export default function SettingsNetworkRoute() {
onChange={e => handleIpv4ModeChange(e.target.value)}
options={filterUnknown([
{ value: "dhcp", label: "DHCP" },
// { value: "static", label: "Static" },
{ value: "static", label: "Static" },
])}
/>
</SettingsItem>
@ -390,12 +402,21 @@ export default function SettingsNetworkRoute() {
<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 classNa
onApply={() => setNetworkSettingsRemote(networkSettings)} </div>
</div>
</div>
</GridCard>
) : networkState?.dhcp_lease && networkState.dhcp_lease.ip ? (
) : networkSettings.ipv4_mode === "static" ? (
<StaticIpv4Card
networkSettings={networkSettings}
onUpdate={setNetworkSettings}
networkState={networkState}
onApply={() => setNetworkSettingsRemote(networkSettings)}
/>
) : networkSettings.ipv4_mode === "dhcp" &&
networkState?.dhcp_lease &&
networkState.dhcp_lease.ip ? (
<DhcpLeaseCard
networkState={networkState}
setShowRenewLeaseConfirm={setShowRenewLeaseConfirm}
@ -403,8 +424,8 @@ export default function SettingsNetworkRoute() {
) : (
<EmptyCard
IconElm={LuEthernetPort}
headline="DHCP Information"
description="No DHCP lease information available"
headline="Network Information"
description="No network configuration available"
/>
)}
</AutoHeight>