Compare commits

...

8 Commits

Author SHA1 Message Date
Aveline 2f8af11917
Merge 9b46209f1b into cc9ff74276 2025-10-13 16:17:49 +00:00
Siyuan 9b46209f1b fix: clean up udhcpc processes 2025-10-13 16:17:35 +00:00
Siyuan 667877ff50 fix: save config after toggling dhcp client 2025-10-13 16:00:10 +00:00
Siyuan b6a1eecc1f fix: touchscreen dhcp client button 2025-10-13 15:57:36 +00:00
Adam Shiervani aa6f5b496d feat: add DHCP client as a critical field 2025-10-13 17:17:51 +02:00
Adam Shiervani e38b087ff6 Close Modals on Escape 2025-10-13 17:09:10 +02:00
Adam Shiervani 5e06625966 feat: add copy to clipboard functionality for MAC address in network settings 2025-10-13 17:00:43 +02:00
Siyuan 76d256b69a feat: add CIDR notation support for IPv4 address 2025-10-13 13:29:18 +00:00
11 changed files with 209 additions and 63 deletions

View File

@ -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,

View File

@ -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");
} }
} }
} }

View File

@ -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)
} }

View File

@ -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)
}

View File

@ -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()

4
ui/package-lock.json generated
View File

@ -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",

View File

@ -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>
); );
} }

View File

@ -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

View File

@ -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 };
}

View File

@ -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"

10
ui/src/utils/ip.ts Normal file
View File

@ -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('.');
};