mirror of https://github.com/jetkvm/kvm.git
Compare commits
8 Commits
e72300e0a9
...
2f8af11917
| Author | SHA1 | Date |
|---|---|---|
|
|
2f8af11917 | |
|
|
9b46209f1b | |
|
|
667877ff50 | |
|
|
b6a1eecc1f | |
|
|
aa6f5b496d | |
|
|
e38b087ff6 | |
|
|
5e06625966 | |
|
|
76d256b69a |
|
|
@ -8561,7 +8561,7 @@
|
||||||
},
|
},
|
||||||
"group": "",
|
"group": "",
|
||||||
"groupIndex": 0,
|
"groupIndex": 0,
|
||||||
"text": "Press and hold for\n10 seconds",
|
"text": "Press and hold for\n5 seconds",
|
||||||
"textType": "literal",
|
"textType": "literal",
|
||||||
"longMode": "WRAP",
|
"longMode": "WRAP",
|
||||||
"recolor": false,
|
"recolor": false,
|
||||||
|
|
|
||||||
|
|
@ -2341,7 +2341,7 @@ void create_screen_switch_dhcp_client_screen() {
|
||||||
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
|
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
|
||||||
add_style_info_content_label(obj);
|
add_style_info_content_label(obj);
|
||||||
lv_obj_set_style_text_font(obj, &ui_font_font_book20, LV_PART_MAIN | LV_STATE_DEFAULT);
|
lv_obj_set_style_text_font(obj, &ui_font_font_book20, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||||
lv_label_set_text(obj, "Press and hold for\n10 seconds");
|
lv_label_set_text(obj, "Press and hold for\n5 seconds");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@ func initNetwork() error {
|
||||||
if err := nm.AddInterface(NetIfName, nc); err != nil {
|
if err := nm.AddInterface(NetIfName, nc); err != nil {
|
||||||
return fmt.Errorf("failed to add interface: %w", err)
|
return fmt.Errorf("failed to add interface: %w", err)
|
||||||
}
|
}
|
||||||
|
_ = nm.CleanUpLegacyDHCPClients()
|
||||||
|
|
||||||
networkManager = nm
|
networkManager = nm
|
||||||
|
|
||||||
|
|
@ -233,5 +234,9 @@ func rpcToggleDHCPClient() error {
|
||||||
config.NetworkConfig.DHCPClient.String = "jetdhcpc"
|
config.NetworkConfig.DHCPClient.String = "jetdhcpc"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return rpcReboot(false)
|
return rpcReboot(false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
func readFileNoStat(filename string) ([]byte, error) {
|
func readFileNoStat(filename string) ([]byte, error) {
|
||||||
|
|
@ -36,7 +38,8 @@ func toCmdline(path string) ([]string, error) {
|
||||||
return strings.Split(string(bytes.TrimRight(data, "\x00")), "\x00"), nil
|
return strings.Split(string(bytes.TrimRight(data, "\x00")), "\x00"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) killUdhcpc() error {
|
// KillUdhcpC kills all udhcpc processes
|
||||||
|
func KillUdhcpC(l *zerolog.Logger) error {
|
||||||
// read procfs for udhcpc processes
|
// read procfs for udhcpc processes
|
||||||
// we do not use procfs.AllProcs() because we want to avoid the overhead of reading the entire procfs
|
// we do not use procfs.AllProcs() because we want to avoid the overhead of reading the entire procfs
|
||||||
processes, err := os.ReadDir("/proc")
|
processes, err := os.ReadDir("/proc")
|
||||||
|
|
@ -76,11 +79,11 @@ func (c *Client) killUdhcpc() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(matchedPids) == 0 {
|
if len(matchedPids) == 0 {
|
||||||
c.l.Info().Msg("no udhcpc processes found")
|
l.Info().Msg("no udhcpc processes found")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
c.l.Info().Ints("pids", matchedPids).Msg("found udhcpc processes, terminating")
|
l.Info().Ints("pids", matchedPids).Msg("found udhcpc processes, terminating")
|
||||||
|
|
||||||
for _, pid := range matchedPids {
|
for _, pid := range matchedPids {
|
||||||
err := syscall.Kill(pid, syscall.SIGTERM)
|
err := syscall.Kill(pid, syscall.SIGTERM)
|
||||||
|
|
@ -88,8 +91,12 @@ func (c *Client) killUdhcpc() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
c.l.Info().Int("pid", pid).Msg("terminated udhcpc process")
|
l.Info().Int("pid", pid).Msg("terminated udhcpc process")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) killUdhcpc() error {
|
||||||
|
return KillUdhcpC(c.l)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
|
|
||||||
"github.com/jetkvm/kvm/internal/logging"
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
"github.com/jetkvm/kvm/internal/network/types"
|
"github.com/jetkvm/kvm/internal/network/types"
|
||||||
|
"github.com/jetkvm/kvm/pkg/nmlite/jetdhcpc"
|
||||||
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
@ -214,6 +215,32 @@ func (nm *NetworkManager) SetOnDHCPLeaseChange(callback func(iface string, lease
|
||||||
nm.onDHCPLeaseChange = callback
|
nm.onDHCPLeaseChange = callback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (nm *NetworkManager) shouldKillLegacyDHCPClients() bool {
|
||||||
|
nm.mu.RLock()
|
||||||
|
defer nm.mu.RUnlock()
|
||||||
|
|
||||||
|
// TODO: remove it when we need to support multiple interfaces
|
||||||
|
for _, im := range nm.interfaces {
|
||||||
|
if im.dhcpClient.clientType != "udhcpc" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if im.config.IPv4Mode.String != "dhcp" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanUpLegacyDHCPClients cleans up legacy DHCP clients
|
||||||
|
func (nm *NetworkManager) CleanUpLegacyDHCPClients() error {
|
||||||
|
shouldKill := nm.shouldKillLegacyDHCPClients()
|
||||||
|
if shouldKill {
|
||||||
|
return jetdhcpc.KillUdhcpC(nm.logger)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Stop stops the network manager and all managed interfaces
|
// Stop stops the network manager and all managed interfaces
|
||||||
func (nm *NetworkManager) Stop() error {
|
func (nm *NetworkManager) Stop() error {
|
||||||
nm.mu.Lock()
|
nm.mu.Lock()
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-animate-height": "^3.2.3",
|
"react-animate-height": "^3.2.3",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-hook-form": "^7.62.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router": "^7.9.3",
|
"react-router": "^7.9.3",
|
||||||
|
|
@ -5856,8 +5857,6 @@
|
||||||
"react": "^19.1.1"
|
"react": "^19.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
<<<<<<< Updated upstream
|
|
||||||
=======
|
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.62.0",
|
"version": "7.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
|
||||||
|
|
@ -5874,7 +5873,6 @@
|
||||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
>>>>>>> Stashed changes
|
|
||||||
"node_modules/react-hot-toast": {
|
"node_modules/react-hot-toast": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -67,46 +67,55 @@ export function ConfirmDialog({
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant];
|
const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant];
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose}>
|
<div onKeyDown={handleKeyDown}>
|
||||||
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
|
<Modal open={open} onClose={onClose}>
|
||||||
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800">
|
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
|
||||||
<div className="space-y-4">
|
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800">
|
||||||
<div className="sm:flex sm:items-start">
|
<div className="space-y-4">
|
||||||
<div
|
<div className="sm:flex sm:items-start">
|
||||||
className={cx(
|
<div
|
||||||
"mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10",
|
className={cx(
|
||||||
iconBgClass,
|
"mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10",
|
||||||
)}
|
iconBgClass,
|
||||||
>
|
)}
|
||||||
<Icon aria-hidden="true" className={cx("size-6", iconClass)} />
|
>
|
||||||
</div>
|
<Icon aria-hidden="true" className={cx("size-6", iconClass)} />
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
</div>
|
||||||
<h2 className="text-lg leading-tight font-bold text-black dark:text-white">
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||||
{title}
|
<h2 className="text-lg leading-tight font-bold text-black dark:text-white">
|
||||||
</h2>
|
{title}
|
||||||
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">
|
</h2>
|
||||||
{description}
|
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-x-2" autoFocus>
|
<div className="flex justify-end gap-x-2" autoFocus>
|
||||||
{cancelText && (
|
{cancelText && (
|
||||||
<CloseButton as={Button} size="SM" theme="blank" text={cancelText} />
|
<CloseButton as={Button} size="SM" theme="blank" text={cancelText} />
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="SM"
|
size="SM"
|
||||||
type="button"
|
type="button"
|
||||||
theme={buttonTheme}
|
theme={buttonTheme}
|
||||||
text={isConfirming ? `${confirmText}...` : confirmText}
|
text={isConfirming ? `${confirmText}...` : confirmText}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={isConfirming}
|
disabled={isConfirming}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</Modal>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,31 +2,49 @@ import { LuPlus, LuX } from "react-icons/lu";
|
||||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import validator from "validator";
|
import validator from "validator";
|
||||||
|
import { cx } from "cva";
|
||||||
|
|
||||||
import { GridCard } from "@/components/Card";
|
import { GridCard } from "@/components/Card";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { InputFieldWithLabel } from "@/components/InputField";
|
import { InputFieldWithLabel } from "@/components/InputField";
|
||||||
import { NetworkSettings } from "@/hooks/stores";
|
import { NetworkSettings } from "@/hooks/stores";
|
||||||
|
import { netMaskFromCidr4 } from "@/utils/ip";
|
||||||
|
|
||||||
export default function StaticIpv4Card() {
|
export default function StaticIpv4Card() {
|
||||||
const formMethods = useFormContext<NetworkSettings>();
|
const formMethods = useFormContext<NetworkSettings>();
|
||||||
const { register, formState, watch } = formMethods;
|
const { register, formState, watch, setValue } = formMethods;
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({ name: "ipv4_static.dns" });
|
const { fields, append, remove } = useFieldArray({ name: "ipv4_static.dns" });
|
||||||
|
|
||||||
// TODO: set subnet mask if IP address is in CIDR notation
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fields.length === 0) append("");
|
if (fields.length === 0) append("");
|
||||||
}, [append, fields.length]);
|
}, [append, fields.length]);
|
||||||
|
|
||||||
const dns = watch("ipv4_static.dns");
|
const dns = watch("ipv4_static.dns");
|
||||||
|
|
||||||
|
const ipv4StaticAddress = watch("ipv4_static.address");
|
||||||
|
const hideSubnetMask = ipv4StaticAddress?.includes("/");
|
||||||
|
useEffect(() => {
|
||||||
|
const parts = ipv4StaticAddress?.split("/", 2);
|
||||||
|
if (parts.length !== 2) return;
|
||||||
|
|
||||||
|
const cidrNotation = parseInt(parts[1]);
|
||||||
|
if (isNaN(cidrNotation) || cidrNotation < 0 || cidrNotation > 32) return;
|
||||||
|
|
||||||
|
const mask = netMaskFromCidr4(cidrNotation);
|
||||||
|
setValue("ipv4_static.netmask", mask);
|
||||||
|
}, [ipv4StaticAddress, setValue]);
|
||||||
|
|
||||||
const validate = (value: string) => {
|
const validate = (value: string) => {
|
||||||
if (!validator.isIP(value)) return "Invalid IP address";
|
if (!validator.isIP(value)) return "Invalid IP address";
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateIsIPOrCIDR4 = (value: string) => {
|
||||||
|
if (!validator.isIP(value, 4) && !validator.isIPRange(value, 4)) return "Invalid IP address or CIDR notation";
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
|
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
|
||||||
|
|
@ -35,24 +53,25 @@ export default function StaticIpv4Card() {
|
||||||
Static IPv4 Configuration
|
Static IPv4 Configuration
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className={cx("grid grid-cols-1 gap-4", hideSubnetMask ? "md:grid-cols-1" : "md:grid-cols-2")}>
|
||||||
<InputFieldWithLabel
|
<InputFieldWithLabel
|
||||||
label="IP Address"
|
label="IP Address"
|
||||||
type="text"
|
type="text"
|
||||||
size="SM"
|
size="SM"
|
||||||
placeholder="192.168.1.100"
|
placeholder="192.168.1.100"
|
||||||
{...register("ipv4_static.address", { validate })}
|
{
|
||||||
|
...register("ipv4_static.address", { validate: validateIsIPOrCIDR4 })}
|
||||||
error={formState.errors.ipv4_static?.address?.message}
|
error={formState.errors.ipv4_static?.address?.message}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputFieldWithLabel
|
{!hideSubnetMask && <InputFieldWithLabel
|
||||||
label="Subnet Mask"
|
label="Subnet Mask"
|
||||||
type="text"
|
type="text"
|
||||||
size="SM"
|
size="SM"
|
||||||
placeholder="255.255.255.0"
|
placeholder="255.255.255.0"
|
||||||
{...register("ipv4_static.netmask", { validate })}
|
{...register("ipv4_static.netmask", { validate })}
|
||||||
error={formState.errors.ipv4_static?.netmask?.message}
|
error={formState.errors.ipv4_static?.netmask?.message}
|
||||||
/>
|
/>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InputFieldWithLabel
|
<InputFieldWithLabel
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
export function useCopyToClipboard(resetInterval = 2000) {
|
||||||
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
|
|
||||||
|
const copy = useCallback(async (text: string) => {
|
||||||
|
if (!text) return false;
|
||||||
|
|
||||||
|
let success = false;
|
||||||
|
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
success = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Clipboard API failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for insecure contexts
|
||||||
|
if (!success) {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
success = document.execCommand("copy");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fallback copy failed:", err);
|
||||||
|
success = false;
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCopied(success);
|
||||||
|
if (success && resetInterval > 0) {
|
||||||
|
setTimeout(() => setIsCopied(false), resetInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}, [resetInterval]);
|
||||||
|
|
||||||
|
return { copy, isCopied };
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { FieldValues, FormProvider, useForm } from "react-hook-form";
|
import { FieldValues, FormProvider, useForm } from "react-hook-form";
|
||||||
import { LuEthernetPort } from "react-icons/lu";
|
import { LuCopy, LuEthernetPort } from "react-icons/lu";
|
||||||
import validator from "validator";
|
import validator from "validator";
|
||||||
|
|
||||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
|
|
@ -14,6 +14,7 @@ import { getNetworkSettings, getNetworkState } from "@/utils/jsonrpc";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import { GridCard } from "@components/Card";
|
import { GridCard } from "@components/Card";
|
||||||
import InputField, { InputFieldWithLabel } from "@components/InputField";
|
import InputField, { InputFieldWithLabel } from "@components/InputField";
|
||||||
|
import { netMaskFromCidr4 } from "@/utils/ip";
|
||||||
|
|
||||||
import AutoHeight from "../components/AutoHeight";
|
import AutoHeight from "../components/AutoHeight";
|
||||||
import DhcpLeaseCard from "../components/DhcpLeaseCard";
|
import DhcpLeaseCard from "../components/DhcpLeaseCard";
|
||||||
|
|
@ -23,6 +24,7 @@ import StaticIpv4Card from "../components/StaticIpv4Card";
|
||||||
import StaticIpv6Card from "../components/StaticIpv6Card";
|
import StaticIpv6Card from "../components/StaticIpv6Card";
|
||||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||||
import { SettingsItem } from "../components/SettingsItem";
|
import { SettingsItem } from "../components/SettingsItem";
|
||||||
|
import { useCopyToClipboard } from "../components/useCopyToClipBoard";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
|
@ -155,6 +157,16 @@ export default function SettingsNetworkRoute() {
|
||||||
const { register, handleSubmit, watch, formState, reset } = formMethods;
|
const { register, handleSubmit, watch, formState, reset } = formMethods;
|
||||||
|
|
||||||
const onSubmit = async (settings: NetworkSettings) => {
|
const onSubmit = async (settings: NetworkSettings) => {
|
||||||
|
if (settings.ipv4_static?.address?.includes("/")) {
|
||||||
|
const parts = settings.ipv4_static.address.split("/");
|
||||||
|
const cidrNotation = parseInt(parts[1]);
|
||||||
|
if (isNaN(cidrNotation) || cidrNotation < 0 || cidrNotation > 32) {
|
||||||
|
return notifications.error("Invalid CIDR notation for IPv4 address");
|
||||||
|
}
|
||||||
|
settings.ipv4_static.netmask = netMaskFromCidr4(cidrNotation);
|
||||||
|
settings.ipv4_static.address = parts[0];
|
||||||
|
}
|
||||||
|
|
||||||
send("setNetworkSettings", { settings }, async (resp) => {
|
send("setNetworkSettings", { settings }, async (resp) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
return notifications.error(
|
return notifications.error(
|
||||||
|
|
@ -179,6 +191,7 @@ export default function SettingsNetworkRoute() {
|
||||||
// Label is for the UI, key is the internal key of the field
|
// Label is for the UI, key is the internal key of the field
|
||||||
{ label: "IPv4 mode", key: "ipv4_mode" },
|
{ label: "IPv4 mode", key: "ipv4_mode" },
|
||||||
{ label: "IPv6 mode", key: "ipv6_mode" },
|
{ label: "IPv6 mode", key: "ipv6_mode" },
|
||||||
|
{ label: "DHCP client", key: "dhcp_client" },
|
||||||
] as { label: string; key: keyof NetworkSettings }[];
|
] as { label: string; key: keyof NetworkSettings }[];
|
||||||
|
|
||||||
const criticalChanged = criticalFields.some(field => dirty[field.key]);
|
const criticalChanged = criticalFields.some(field => dirty[field.key]);
|
||||||
|
|
@ -214,6 +227,8 @@ export default function SettingsNetworkRoute() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { copy } = useCopyToClipboard();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormProvider {...formMethods}>
|
<FormProvider {...formMethods}>
|
||||||
|
|
@ -237,19 +252,26 @@ export default function SettingsNetworkRoute() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsItem
|
<div className="flex items-center justify-between">
|
||||||
title="MAC Address"
|
<SettingsItem
|
||||||
description="Hardware identifier for the network interface"
|
title="MAC Address"
|
||||||
>
|
description="Hardware identifier for the network interface"
|
||||||
<InputField
|
|
||||||
type="text"
|
|
||||||
size="SM"
|
|
||||||
value={networkState?.mac_address}
|
|
||||||
error={""}
|
|
||||||
readOnly={true}
|
|
||||||
className="dark:!text-opacity-60"
|
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
<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-blue-900 dark:border-l-blue-600" size="SM" type="button" theme="primary" LeadingIcon={LuCopy} onClick={async () => {
|
||||||
|
if (await copy(networkState?.mac_address || "")) {
|
||||||
|
notifications.success("MAC address copied to clipboard");
|
||||||
|
} else {
|
||||||
|
notifications.error("Failed to copy MAC address");
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<SettingsItem title="Hostname" description="Set the device hostname">
|
<SettingsItem title="Hostname" description="Set the device hostname">
|
||||||
<InputField
|
<InputField
|
||||||
size="SM"
|
size="SM"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
export const netMaskFromCidr4 = (cidr: number) => {
|
||||||
|
const mask = [];
|
||||||
|
let bitCount = cidr;
|
||||||
|
for(let i=0; i<4; i++) {
|
||||||
|
const n = Math.min(bitCount, 8);
|
||||||
|
mask.push(256 - Math.pow(2, 8-n));
|
||||||
|
bitCount -= n;
|
||||||
|
}
|
||||||
|
return mask.join('.');
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue