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
|
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)
|
val, err := toString(f.CurrentValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err)
|
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 nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return f.validateSingleValue(val, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FieldConfig) validateSingleValue(val string, index int) error {
|
||||||
for _, validateType := range f.ValidateTypes {
|
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 {
|
switch validateType {
|
||||||
case "ipv4":
|
case "ipv4":
|
||||||
if net.ParseIP(val).To4() == nil {
|
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":
|
case "ipv6":
|
||||||
if net.ParseIP(val).To16() == nil {
|
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":
|
case "hwaddr":
|
||||||
if _, err := net.ParseMAC(val); err != nil {
|
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":
|
case "hostname":
|
||||||
if _, err := idna.Lookup.ToASCII(val); err != nil {
|
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":
|
case "proxy":
|
||||||
if url, err := url.Parse(val); err != nil || (url.Scheme != "http" && url.Scheme != "https") || url.Host == "" {
|
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:
|
default:
|
||||||
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
|
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;
|
bootp_file?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
routers?: string[];
|
routers?: string[];
|
||||||
dns?: string[];
|
dns_servers?: string[];
|
||||||
ntp_servers?: string[];
|
ntp_servers?: string[];
|
||||||
lpr_servers?: string[];
|
lpr_servers?: string[];
|
||||||
_time_servers?: string[];
|
_time_servers?: string[];
|
||||||
|
@ -744,11 +744,19 @@ export type TimeSyncMode =
|
||||||
| "custom"
|
| "custom"
|
||||||
| "unknown";
|
| "unknown";
|
||||||
|
|
||||||
|
export interface IPv4StaticConfig {
|
||||||
|
address: string;
|
||||||
|
netmask: string;
|
||||||
|
gateway: string;
|
||||||
|
dns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface NetworkSettings {
|
export interface NetworkSettings {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
http_proxy: string;
|
http_proxy: string;
|
||||||
ipv4_mode: IPv4Mode;
|
ipv4_mode: IPv4Mode;
|
||||||
|
ipv4_static?: IPv4StaticConfig;
|
||||||
ipv6_mode: IPv6Mode;
|
ipv6_mode: IPv6Mode;
|
||||||
lldp_mode: LLDPMode;
|
lldp_mode: LLDPMode;
|
||||||
lldp_tx_tlvs: string[];
|
lldp_tx_tlvs: string[];
|
||||||
|
|
|
@ -27,6 +27,7 @@ import Ipv6NetworkCard from "../components/Ipv6NetworkCard";
|
||||||
import EmptyCard from "../components/EmptyCard";
|
import EmptyCard from "../components/EmptyCard";
|
||||||
import AutoHeight from "../components/AutoHeight";
|
import AutoHeight from "../components/AutoHeight";
|
||||||
import DhcpLeaseCard from "../components/DhcpLeaseCard";
|
import DhcpLeaseCard from "../components/DhcpLeaseCard";
|
||||||
|
import StaticIpv4Card from "../components/StaticIpv4Card";
|
||||||
|
|
||||||
import { SettingsItem } from "./devices.$id.settings";
|
import { SettingsItem } from "./devices.$id.settings";
|
||||||
|
|
||||||
|
@ -37,6 +38,7 @@ const defaultNetworkSettings: NetworkSettings = {
|
||||||
http_proxy: "",
|
http_proxy: "",
|
||||||
domain: "",
|
domain: "",
|
||||||
ipv4_mode: "unknown",
|
ipv4_mode: "unknown",
|
||||||
|
ipv4_static: undefined,
|
||||||
ipv6_mode: "unknown",
|
ipv6_mode: "unknown",
|
||||||
lldp_mode: "unknown",
|
lldp_mode: "unknown",
|
||||||
lldp_tx_tlvs: [],
|
lldp_tx_tlvs: [],
|
||||||
|
@ -127,7 +129,17 @@ export default function SettingsNetworkRoute() {
|
||||||
const setNetworkSettingsRemote = useCallback(
|
const setNetworkSettingsRemote = useCallback(
|
||||||
(settings: NetworkSettings) => {
|
(settings: NetworkSettings) => {
|
||||||
setNetworkSettingsLoaded(false);
|
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) {
|
if ("error" in resp) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
"Failed to save network settings: " +
|
"Failed to save network settings: " +
|
||||||
|
@ -375,7 +387,7 @@ export default function SettingsNetworkRoute() {
|
||||||
onChange={e => handleIpv4ModeChange(e.target.value)}
|
onChange={e => handleIpv4ModeChange(e.target.value)}
|
||||||
options={filterUnknown([
|
options={filterUnknown([
|
||||||
{ value: "dhcp", label: "DHCP" },
|
{ value: "dhcp", label: "DHCP" },
|
||||||
// { value: "static", label: "Static" },
|
{ value: "static", label: "Static" },
|
||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
@ -390,12 +402,21 @@ export default function SettingsNetworkRoute() {
|
||||||
<div className="animate-pulse space-y-3">
|
<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/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/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 classNa
|
||||||
</div>
|
onApply={() => setNetworkSettingsRemote(networkSettings)} </div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GridCard>
|
</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
|
<DhcpLeaseCard
|
||||||
networkState={networkState}
|
networkState={networkState}
|
||||||
setShowRenewLeaseConfirm={setShowRenewLeaseConfirm}
|
setShowRenewLeaseConfirm={setShowRenewLeaseConfirm}
|
||||||
|
@ -403,8 +424,8 @@ export default function SettingsNetworkRoute() {
|
||||||
) : (
|
) : (
|
||||||
<EmptyCard
|
<EmptyCard
|
||||||
IconElm={LuEthernetPort}
|
IconElm={LuEthernetPort}
|
||||||
headline="DHCP Information"
|
headline="Network Information"
|
||||||
description="No DHCP lease information available"
|
description="No network configuration available"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AutoHeight>
|
</AutoHeight>
|
||||||
|
|
Loading…
Reference in New Issue