mirror of https://github.com/jetkvm/kvm.git
679 lines
27 KiB
TypeScript
679 lines
27 KiB
TypeScript
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { FieldValues, FormProvider, useForm } from "react-hook-form";
|
|
import { LuCopy, LuEthernetPort } from "react-icons/lu";
|
|
import dayjs from "dayjs";
|
|
import relativeTime from "dayjs/plugin/relativeTime";
|
|
import validator from "validator";
|
|
|
|
import { NetworkSettings, NetworkState, useLLDPNeighborsStore, useNetworkStateStore, useRTCStore } from "@hooks/stores";
|
|
import { useJsonRpc } from "@hooks/useJsonRpc";
|
|
import AutoHeight from "@components/AutoHeight";
|
|
import { Button } from "@components/Button";
|
|
import { ConfirmDialog } from "@components/ConfirmDialog";
|
|
import DhcpLeaseCard from "@components/DhcpLeaseCard";
|
|
import EmptyCard from "@components/EmptyCard";
|
|
import { GridCard } from "@components/Card";
|
|
import InputField, { InputFieldWithLabel } from "@components/InputField";
|
|
import Ipv6NetworkCard from "@components/Ipv6NetworkCard";
|
|
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
|
|
import { SettingsItem } from "@components/SettingsItem";
|
|
import { SettingsPageHeader } from "@/components/SettingsPageheader";
|
|
import StaticIpv4Card from "@components/StaticIpv4Card";
|
|
import StaticIpv6Card from "@components/StaticIpv6Card";
|
|
import { useCopyToClipboard } from "@components/useCopyToClipBoard";
|
|
import { netMaskFromCidr4 } from "@/utils/ip";
|
|
import { getNetworkSettings, getNetworkState, getLLDPNeighbors } from "@/utils/jsonrpc";
|
|
import notifications from "@/notifications";
|
|
import { m } from "@localizations/messages";
|
|
import LLDPNeighborsCard from "@components/LLDPNeighborsCard";
|
|
|
|
dayjs.extend(relativeTime);
|
|
|
|
const isLLDPAvailable = true; // LLDP is now supported
|
|
|
|
const resolveOnRtcReady = () => {
|
|
return new Promise(resolve => {
|
|
// Check if RTC is already connected
|
|
const currentState = useRTCStore.getState();
|
|
if (currentState.rpcDataChannel?.readyState === "open") {
|
|
// Already connected, fetch data immediately
|
|
return resolve(void 0);
|
|
}
|
|
|
|
// Not connected yet, subscribe to state changes
|
|
const unsubscribe = useRTCStore.subscribe(state => {
|
|
if (state.rpcDataChannel?.readyState === "open") {
|
|
unsubscribe(); // Clean up subscription
|
|
return resolve(void 0);
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
export function LifeTimeLabel({ lifetime }: Readonly<{ lifetime: string }>) {
|
|
const [remaining, setRemaining] = useState<string | null>(null);
|
|
|
|
// rrecalculate remaining time every 30 seconds
|
|
useEffect(() => {
|
|
// schedule immediate initial update
|
|
setInterval(() => setRemaining(dayjs(lifetime).fromNow()), 0);
|
|
|
|
const interval = setInterval(() => {
|
|
setRemaining(dayjs(lifetime).fromNow());
|
|
}, 1000 * 30);
|
|
return () => clearInterval(interval);
|
|
}, [lifetime]);
|
|
|
|
if (lifetime == "") {
|
|
return <strong>{m.not_applicable()}</strong>;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<span className="text-sm font-medium">{remaining && <> {remaining}</>}</span>
|
|
<span className="text-xs text-slate-700 dark:text-slate-300"> ({dayjs(lifetime).format("YYYY-MM-DD HH:mm")})</span>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function SettingsNetworkRoute() {
|
|
const { send } = useJsonRpc();
|
|
|
|
const networkState = useNetworkStateStore(state => state);
|
|
const setNetworkState = useNetworkStateStore(state => state.setNetworkState);
|
|
|
|
// Some input needs direct state management. Mostly options that open more details
|
|
const [customDomain, setCustomDomain] = useState<string>("");
|
|
|
|
// Confirm dialog
|
|
const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false);
|
|
|
|
// We use this to determine whether the settings have changed
|
|
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 setLLDPNeighbors = useLLDPNeighborsStore(state => state.setNeighbors);
|
|
const lldpNeighbors = useLLDPNeighborsStore(state => state.neighbors);
|
|
const fetchLLDPNeighbors = useCallback(async () => {
|
|
const neighbors = await getLLDPNeighbors();
|
|
setLLDPNeighbors(neighbors);
|
|
}, [setLLDPNeighbors]);
|
|
|
|
useEffect(() => {
|
|
fetchLLDPNeighbors();
|
|
}, [fetchLLDPNeighbors]);
|
|
|
|
const fetchNetworkData = useCallback(async () => {
|
|
try {
|
|
console.log("Fetching network data...");
|
|
|
|
const [settings, state] = (await Promise.all([
|
|
getNetworkSettings(),
|
|
getNetworkState(),
|
|
])) as [NetworkSettings, NetworkState];
|
|
|
|
setNetworkState(state);
|
|
|
|
const settingsWithDefaults = {
|
|
...settings,
|
|
|
|
domain: settings.domain || "local", // TODO: null means local domain TRUE?????
|
|
mdns_mode: settings.mdns_mode || "disabled",
|
|
time_sync_mode: settings.time_sync_mode || "ntp_only",
|
|
ipv4_static: {
|
|
address: settings.ipv4_static?.address || state.dhcp_lease?.ip || "",
|
|
netmask: settings.ipv4_static?.netmask || state.dhcp_lease?.netmask || "",
|
|
gateway: settings.ipv4_static?.gateway || state.dhcp_lease?.routers?.[0] || "",
|
|
dns: settings.ipv4_static?.dns || state.dhcp_lease?.dns_servers || [],
|
|
},
|
|
ipv6_static: {
|
|
prefix: settings.ipv6_static?.prefix || state.ipv6_addresses?.[0]?.prefix || "",
|
|
gateway: settings.ipv6_static?.gateway || "",
|
|
dns: settings.ipv6_static?.dns || [],
|
|
},
|
|
};
|
|
|
|
initialSettingsRef.current = settingsWithDefaults;
|
|
return { settings: settingsWithDefaults, state };
|
|
} catch (err) {
|
|
notifications.error(m.network_settings_load_error({ error: err instanceof Error ? err.message : m.unknown_error() }));
|
|
throw err;
|
|
}
|
|
}, [setNetworkState]);
|
|
|
|
const formMethods = useForm<NetworkSettings>({
|
|
mode: "onBlur",
|
|
|
|
defaultValues: async () => {
|
|
// Ensure data channel is ready, before fetching network data from the device
|
|
await resolveOnRtcReady();
|
|
const { settings } = await fetchNetworkData();
|
|
return settings;
|
|
},
|
|
});
|
|
|
|
const prepareSettings = useCallback((data: FieldValues) => {
|
|
return {
|
|
...data,
|
|
|
|
// If custom domain option is selected, use the custom domain as value
|
|
domain: data.domain === "custom" ? customDomain : data.domain,
|
|
} as NetworkSettings;
|
|
}, [customDomain]);
|
|
|
|
const { register, handleSubmit, watch, formState, reset } = formMethods;
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
const onSubmit = useCallback(async (settings: NetworkSettings) => {
|
|
|
|
if (settings.ipv4_static?.address?.includes("/")) {
|
|
const parts = settings.ipv4_static.address.split("/");
|
|
const cidrNotation = Number.parseInt(parts[1]);
|
|
if (Number.isNaN(cidrNotation) || cidrNotation < 0 || cidrNotation > 32) {
|
|
return notifications.error(m.network_ipv4_invalid_cidr());
|
|
}
|
|
settings.ipv4_static.netmask = netMaskFromCidr4(cidrNotation);
|
|
settings.ipv4_static.address = parts[0];
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
send("setNetworkSettings", { settings }, async (resp) => {
|
|
if ("error" in resp) {
|
|
notifications.error(m.network_save_settings_failed({ error: resp.error.message || m.unknown_error() }));
|
|
} else {
|
|
// If the settings are saved successfully, fetch the latest network data and reset the form
|
|
// We do this so we get all the form state values, for stuff like is the form dirty, etc...
|
|
|
|
try {
|
|
const networkData = await fetchNetworkData();
|
|
if (!networkData) return
|
|
|
|
reset(networkData.settings);
|
|
notifications.success(m.network_save_settings_success());
|
|
|
|
} catch (error) {
|
|
console.error("Failed to fetch network data:", error);
|
|
}
|
|
setIsSubmitting(false);
|
|
}
|
|
});
|
|
}, [fetchNetworkData, reset, send, setIsSubmitting]);
|
|
|
|
const onSubmitGate = useCallback(async (data: FieldValues) => {
|
|
const settings = prepareSettings(data);
|
|
const dirty = formState.dirtyFields;
|
|
|
|
// Build list of critical changes for display
|
|
const changes: { label: string; from: string; to: string }[] = [];
|
|
|
|
if (dirty.dhcp_client) {
|
|
changes.push({
|
|
label: m.network_dhcp_client_title(),
|
|
from: initialSettingsRef.current?.dhcp_client as string,
|
|
to: data.dhcp_client as string,
|
|
});
|
|
}
|
|
|
|
if (dirty.ipv4_mode) {
|
|
changes.push({
|
|
label: m.network_ipv4_mode_title(),
|
|
from: initialSettingsRef.current?.ipv4_mode as string,
|
|
to: data.ipv4_mode as string,
|
|
});
|
|
}
|
|
|
|
if (dirty.ipv4_static?.address) {
|
|
changes.push({
|
|
label: m.network_ipv4_address(),
|
|
from: initialSettingsRef.current?.ipv4_static?.address as string,
|
|
to: data.ipv4_static?.address as string,
|
|
});
|
|
}
|
|
|
|
if (dirty.ipv4_static?.netmask) {
|
|
changes.push({
|
|
label: m.network_ipv4_netmask(),
|
|
from: initialSettingsRef.current?.ipv4_static?.netmask as string,
|
|
to: data.ipv4_static?.netmask as string,
|
|
});
|
|
}
|
|
|
|
if (dirty.ipv4_static?.gateway) {
|
|
changes.push({
|
|
label: m.network_ipv4_gateway(),
|
|
from: initialSettingsRef.current?.ipv4_static?.gateway as string,
|
|
to: data.ipv4_static?.gateway as string,
|
|
});
|
|
}
|
|
|
|
if (dirty.ipv4_static?.dns) {
|
|
changes.push({
|
|
label: m.network_ipv4_dns(),
|
|
from: initialSettingsRef.current?.ipv4_static?.dns.join(", ").toString() ?? "",
|
|
to: data.ipv4_static?.dns.join(", ").toString() ?? "",
|
|
});
|
|
}
|
|
|
|
if (dirty.ipv6_mode) {
|
|
changes.push({
|
|
label: m.network_ipv6_mode_title(),
|
|
from: initialSettingsRef.current?.ipv6_mode as string,
|
|
to: data.ipv6_mode as string,
|
|
});
|
|
}
|
|
|
|
if (dirty.ipv6_static?.prefix) {
|
|
changes.push({
|
|
label: m.network_ipv6_prefix(),
|
|
from: initialSettingsRef.current?.ipv6_static?.prefix as string,
|
|
to: data.ipv6_static?.prefix as string,
|
|
});
|
|
}
|
|
|
|
if (dirty.ipv6_static?.gateway) {
|
|
changes.push({
|
|
label: m.network_ipv6_gateway(),
|
|
from: initialSettingsRef.current?.ipv6_static?.gateway as string,
|
|
to: data.ipv6_static?.gateway as string,
|
|
});
|
|
}
|
|
|
|
if (dirty.ipv6_static?.dns) {
|
|
changes.push({
|
|
label: m.network_ipv6_dns(),
|
|
from: initialSettingsRef.current?.ipv6_static?.dns.join(", ").toString() ?? "",
|
|
to: data.ipv6_static?.dns.join(", ").toString() ?? "",
|
|
});
|
|
}
|
|
|
|
// If no critical fields are changed, save immediately
|
|
if (changes.length === 0) return onSubmit(settings);
|
|
|
|
// Show confirmation dialog for critical changes
|
|
setStagedSettings(settings);
|
|
setCriticalChanges(changes);
|
|
setShowCriticalSettingsConfirm(true);
|
|
}, [prepareSettings, formState.dirtyFields, onSubmit]);
|
|
|
|
const ipv4mode = watch("ipv4_mode");
|
|
const ipv6mode = watch("ipv6_mode");
|
|
|
|
const onDhcpLeaseRenew = () => {
|
|
send("renewDHCPLease", {}, (resp) => {
|
|
if ("error" in resp) {
|
|
notifications.error(m.network_dhcp_lease_renew_failed({ error: resp.error.message || m.unknown_error() }));
|
|
} else {
|
|
notifications.success(m.network_dhcp_lease_renew_success());
|
|
}
|
|
});
|
|
};
|
|
|
|
const { copy } = useCopyToClipboard();
|
|
|
|
return (
|
|
<>
|
|
<FormProvider {...formMethods}>
|
|
<form onSubmit={handleSubmit(onSubmitGate)} className="space-y-4">
|
|
<SettingsPageHeader
|
|
title={m.network_title()}
|
|
description={m.network_description()}
|
|
action={
|
|
<div>
|
|
<Button
|
|
size="SM"
|
|
theme="primary"
|
|
disabled={!(formState.isDirty || formState.isSubmitting)}
|
|
loading={formState.isSubmitting || isSubmitting}
|
|
type="submit"
|
|
text={formState.isSubmitting ? m.saving() : m.network_save_settings()}
|
|
/>
|
|
</div>
|
|
}
|
|
/>
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<SettingsItem
|
|
title={m.network_mac_address_title()}
|
|
description={m.network_mac_address_description()}
|
|
/>
|
|
<div className="flex items-center">
|
|
<GridCard cardClassName="rounded-r-none">
|
|
<div className=" h-[34px] flex items-center text-xs select-all text-black font-mono dark:text-white px-3 ">
|
|
{networkState?.mac_address} {" "}
|
|
</div>
|
|
</GridCard>
|
|
<Button className="rounded-l-none border-l-slate-800/30 dark:border-slate-300/20" size="SM" type="button" theme="light" LeadingIcon={LuCopy} onClick={async () => {
|
|
const mac = networkState?.mac_address || "";
|
|
if (await copy(mac)) {
|
|
notifications.success((m.network_mac_address_copy_success({ mac: mac })));
|
|
} else {
|
|
notifications.error(m.network_mac_address_copy_error());
|
|
}
|
|
}} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<SettingsItem title={m.network_hostname_title()} description={m.network_hostname_description()}>
|
|
<InputField
|
|
size="SM"
|
|
placeholder={networkState?.hostname || "jetkvm"}
|
|
{...register("hostname")}
|
|
error={formState.errors.hostname?.message}
|
|
/>
|
|
</SettingsItem>
|
|
|
|
<SettingsItem title={m.network_http_proxy_title()} description={m.network_http_proxy_description()}>
|
|
<InputField
|
|
size="SM"
|
|
placeholder="http://proxy.example.com:8080"
|
|
{...register("http_proxy", {
|
|
validate: (value: string | null) => {
|
|
if (value === "" || value === null) return true;
|
|
if (!validator.isURL(value || "", { protocols: ["http", "https"] })) {
|
|
return m.network_http_proxy_invalid();
|
|
}
|
|
return true;
|
|
},
|
|
})}
|
|
error={formState.errors.http_proxy?.message}
|
|
/>
|
|
</SettingsItem>
|
|
|
|
<div className="space-y-1">
|
|
<SettingsItem
|
|
title={m.network_domain_title()}
|
|
description={m.network_domain_description()}
|
|
>
|
|
<div className="space-y-2">
|
|
<SelectMenuBasic
|
|
size="SM"
|
|
options={[
|
|
{ value: "dhcp", label: m.network_domain_dhcp_provided() },
|
|
{ value: "local", label: m.network_domain_local() },
|
|
{ value: "custom", label: m.network_domain_custom() },
|
|
]}
|
|
{...register("domain")}
|
|
error={formState.errors.domain?.message}
|
|
/>
|
|
</div>
|
|
</SettingsItem>
|
|
|
|
{watch("domain") === "custom" && (
|
|
<div className="mt-2 w-1/3 border-l border-slate-800/10 pl-4 dark:border-slate-300/20">
|
|
<InputFieldWithLabel
|
|
size="SM"
|
|
type="text"
|
|
label={m.network_custom_domain()}
|
|
placeholder="home.example.com"
|
|
onChange={e => {
|
|
setCustomDomain(e.target.value);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<SettingsItem
|
|
title={m.network_mdns_title()}
|
|
description={m.network_mdns_description()}
|
|
>
|
|
<SelectMenuBasic
|
|
size="SM"
|
|
options={[
|
|
{ value: "disabled", label: m.network_mdns_disabled() },
|
|
{ value: "auto", label: m.network_mdns_auto() },
|
|
{ value: "ipv4_only", label: m.network_mdns_ipv4_only() },
|
|
{ value: "ipv6_only", label: m.network_mdns_ipv6_only() },
|
|
]}
|
|
{...register("mdns_mode")}
|
|
/>
|
|
</SettingsItem>
|
|
|
|
<SettingsItem
|
|
title={m.network_time_sync_title()}
|
|
description={m.network_time_sync_description()}
|
|
>
|
|
<SelectMenuBasic
|
|
size="SM"
|
|
options={[
|
|
// { value: "auto", label: "Auto" },
|
|
{ value: "ntp_only", label: m.network_time_sync_ntp_only() },
|
|
{ value: "ntp_and_http", label: m.network_time_sync_ntp_and_http() },
|
|
{ value: "http_only", label: m.network_time_sync_http_only() },
|
|
// { value: "custom", label: "Custom" },
|
|
]}
|
|
{...register("time_sync_mode")}
|
|
/>
|
|
</SettingsItem>
|
|
|
|
<SettingsItem title={m.network_dhcp_client_title()} description={m.network_dhcp_client_description()}>
|
|
<SelectMenuBasic
|
|
size="SM"
|
|
options={[
|
|
{ value: "jetdhcpc", label: m.network_dhcp_client_jetkvm() },
|
|
{ value: "udhcpc", label: "udhcpc" }, // do not localize
|
|
]}
|
|
{...register("dhcp_client")}
|
|
/>
|
|
</SettingsItem>
|
|
|
|
<SettingsItem title={m.network_ipv4_mode_title()} description={m.network_ipv4_mode_description()}>
|
|
<SelectMenuBasic
|
|
size="SM"
|
|
options={[
|
|
{ value: "dhcp", label: m.network_ipv4_mode_dhcp() },
|
|
{ value: "static", label: m.network_ipv4_mode_static() },
|
|
]}
|
|
{...register("ipv4_mode")}
|
|
/>
|
|
</SettingsItem>
|
|
|
|
|
|
<div>
|
|
<AutoHeight>
|
|
{formState.isLoading ? (
|
|
<GridCard>
|
|
<div className="p-4">
|
|
<div className="space-y-4">
|
|
<div className="h-6 w-1/3 animate-pulse rounded bg-slate-200 dark:bg-slate-700" />
|
|
<div className="animate-pulse space-y-2">
|
|
<div className="h-4 w-1/4 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 className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
|
|
<div className="h-4 w-1/4 rounded bg-slate-200 dark:bg-slate-700" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</GridCard>
|
|
) : ipv4mode === "static" ? (
|
|
<StaticIpv4Card />
|
|
) : ipv4mode === "dhcp" && !!formState.dirtyFields.ipv4_mode ? (
|
|
<EmptyCard
|
|
IconElm={LuEthernetPort}
|
|
headline={m.network_pending_dhcp_mode_change_headline()}
|
|
description={m.network_pending_dhcp_mode_change_description()}
|
|
/>
|
|
) : ipv4mode === "dhcp" ? (
|
|
<DhcpLeaseCard
|
|
networkState={networkState}
|
|
setShowRenewLeaseConfirm={setShowRenewLeaseConfirm}
|
|
/>
|
|
) : (
|
|
<EmptyCard
|
|
IconElm={LuEthernetPort}
|
|
headline={m.network_no_information_headline()}
|
|
description={m.network_no_information_description()}
|
|
/>
|
|
)}
|
|
</AutoHeight>
|
|
</div>
|
|
|
|
<SettingsItem title={m.network_ipv6_mode_title()} description={m.network_ipv6_mode_description()}>
|
|
<SelectMenuBasic
|
|
size="SM"
|
|
options={[
|
|
//{ value: "disabled", label: m.network_ipv6_mode_disabled() },
|
|
{ value: "slaac", label: m.network_ipv6_mode_slaac() },
|
|
//{ value: "dhcpv6", label: m.network_ipv6_mode_dhcpv6() },
|
|
//{ value: "slaac_and_dhcpv6", label: m.network_ipv6_mode_slaac_dhcpv6() },
|
|
{ value: "static", label: m.network_ipv6_mode_static() },
|
|
{ value: "link_local", label: m.network_ipv6_mode_link_local() },
|
|
]}
|
|
{...register("ipv6_mode")}
|
|
/>
|
|
</SettingsItem>
|
|
|
|
<div className="space-y-4">
|
|
<AutoHeight>
|
|
{!networkState ? (
|
|
<GridCard>
|
|
<div className="p-4">
|
|
<div className="space-y-4">
|
|
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
|
{m.network_ipv6_information()}
|
|
</h3>
|
|
<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>
|
|
</div>
|
|
</GridCard>
|
|
) : ipv6mode === "static" ? (
|
|
<StaticIpv6Card />
|
|
) : (
|
|
<Ipv6NetworkCard networkState={networkState || undefined} />
|
|
)}
|
|
</AutoHeight>
|
|
</div>
|
|
|
|
{isLLDPAvailable &&
|
|
(
|
|
<div className="space-y-4">
|
|
<div className="space-y-4">
|
|
<SettingsItem
|
|
title={m.network_ll_dp_title()}
|
|
description={m.network_ll_dp_description()}
|
|
>
|
|
<SelectMenuBasic
|
|
size="SM"
|
|
options={[
|
|
{ value: "disabled", label: m.network_ll_dp_disabled() },
|
|
{ value: "rx_only", label: m.network_ll_dp_rx_only() },
|
|
{ value: "tx_only", label: m.network_ll_dp_tx_only() },
|
|
{ value: "rx_and_tx", label: m.network_ll_dp_rx_and_tx() },
|
|
]}
|
|
{...register("lldp_mode")}
|
|
/>
|
|
</SettingsItem>
|
|
</div>
|
|
{lldpNeighbors.length > 0 && <AutoHeight>
|
|
<LLDPNeighborsCard neighbors={lldpNeighbors} />
|
|
</AutoHeight>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
<div className="animate-fadeInStill animation-duration-300">
|
|
<Button
|
|
size="SM"
|
|
theme="primary"
|
|
disabled={!(formState.isDirty || formState.isSubmitting)}
|
|
loading={formState.isSubmitting}
|
|
type="submit"
|
|
text={formState.isSubmitting ? m.saving() : m.network_save_settings()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</FormProvider>
|
|
|
|
{/* Critical change confirm */}
|
|
<ConfirmDialog
|
|
open={showCriticalSettingsConfirm}
|
|
title={m.network_save_settings_apply_title()}
|
|
variant="warning"
|
|
confirmText={m.network_save_settings_confirm()}
|
|
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={() => {
|
|
setShowCriticalSettingsConfirm(false);
|
|
}}
|
|
isConfirming={formState.isSubmitting}
|
|
description={
|
|
<div className="space-y-4" >
|
|
<div>
|
|
<p className="text-sm leading-relaxed text-slate-700 dark:text-slate-300">
|
|
{m.network_save_settings_confirm_description()}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2.5">
|
|
<div className="flex items-center justify-between text-[13px] font-medium text-slate-900 dark:text-white">
|
|
{m.network_save_settings_confirm_heading()}
|
|
</div>
|
|
<div className="space-y-2.5">
|
|
{criticalChanges.map((c, idx) => (
|
|
<div key={idx + c.label} className="flex items-center gap-x-2 gap-y-1 flex-wrap bg-slate-100/50 dark:bg-slate-800/50 border border-slate-800/10 dark:border-slate-300/20 rounded-md py-2 px-3">
|
|
<span className="text-xs text-slate-600 dark:text-slate-400">{c.label}</span>
|
|
<div className="flex items-center gap-2.5">
|
|
<code className="rounded border border-slate-800/20 bg-slate-50 px-1.5 py-1 text-xs text-black font-mono dark:border-slate-300/20 dark:bg-slate-800 dark:text-slate-100">
|
|
{c.from || "—"}
|
|
</code>
|
|
<svg className="size-3.5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
|
</svg>
|
|
<code className="rounded border border-slate-800/20 bg-slate-50 px-1.5 py-1 text-xs text-black font-mono dark:border-slate-300/20 dark:bg-slate-800 dark:text-slate-100">
|
|
{c.to || "—"}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={showRenewLeaseConfirm}
|
|
title={m.dhcp_lease_renew()}
|
|
variant="warning"
|
|
confirmText={m.network_dhcp_lease_renew_confirm()}
|
|
description={
|
|
<p>
|
|
{m.network_dhcp_lease_renew_confirm_description()}
|
|
<br />
|
|
<br />
|
|
{m.network_dhcp_lease_renew_confirm_new_a()}{" "}<strong>{m.network_dhcp_lease_renew_confirm_new_b()}</strong>.
|
|
</p>
|
|
}
|
|
onConfirm={() => {
|
|
setShowRenewLeaseConfirm(false);
|
|
onDhcpLeaseRenew();
|
|
}}
|
|
onClose={() => setShowRenewLeaseConfirm(false)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|