mirror of https://github.com/jetkvm/kvm.git
feat(ui): enhance network settings with static IP configuration
This commit is contained in:
parent
55fbd6c359
commit
3476ace47d
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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[];
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue