network enhanecment / refactor (#361)

* chore(network): improve connectivity check

* refactor(network): rewrite network and timesync component

* feat(display): show cloud connection status

* chore: change logging verbosity

* chore(websecure): update log message

* fix(ota): validate root certificate when downloading update

* feat(ui): add network settings tab

* fix(display): cloud connecting animation

* fix: golintci issues

* feat: add network settings tab

* feat(timesync): query servers in parallel

* refactor(network): move to internal/network package

* feat(timesync): add metrics

* refactor(log): move log to internal/logging package

* refactor(mdms): move mdns to internal/mdns package

* feat(developer): add pprof endpoint

* feat(logging): add a simple logging streaming endpoint

* fix(mdns): do not start mdns until network is up

* feat(network): allow users to update network settings from ui

* fix(network): handle errors when net.IPAddr is nil

* fix(mdns): scopedLogger SIGSEGV

* fix(dhcp): watch directory instead of file to catch fsnotify.Create event

* refactor(nbd): move platform-specific code to different files

* refactor(native): move platform-specific code to different files

* chore: fix linter issues

* chore(dev_deploy): allow to override PION_LOG_TRACE
This commit is contained in:
Aveline
2025-04-16 01:39:23 +02:00
committed by GitHub
parent 2b2a14204d
commit 189b84380b
71 changed files with 4938 additions and 825 deletions

View File

@@ -15,5 +15,15 @@ echo "└───────────────────────
# Set the environment variable and run Vite
echo "Starting development server with JetKVM device at: $ip_address"
# Check if pwd is the current directory of the script
if [ "$(pwd)" != "$(dirname "$0")" ]; then
pushd "$(dirname "$0")" > /dev/null
echo "Changed directory to: $(pwd)"
fi
sleep 1
JETKVM_PROXY_URL="ws://$ip_address" npx vite dev --mode=device
popd > /dev/null

6
ui/package-lock.json generated
View File

@@ -19,6 +19,7 @@
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"cva": "^1.0.0-beta.1",
"dayjs": "^1.11.13",
"eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^10.2.3",
"framer-motion": "^11.15.0",
@@ -2433,6 +2434,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="
},
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",

View File

@@ -30,6 +30,7 @@
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"cva": "^1.0.0-beta.1",
"dayjs": "^1.11.13",
"eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^10.2.3",
"framer-motion": "^11.15.0",

1
ui/public/sse.html Symbolic link
View File

@@ -0,0 +1 @@
../../internal/logging/sse.html

View File

@@ -663,6 +663,95 @@ export const useDeviceStore = create<DeviceState>(set => ({
setSystemVersion: version => set({ systemVersion: version }),
}));
export interface DhcpLease {
ip?: string;
netmask?: string;
broadcast?: string;
ttl?: string;
mtu?: string;
hostname?: string;
domain?: string;
bootp_next_server?: string;
bootp_server_name?: string;
bootp_file?: string;
timezone?: string;
routers?: string[];
dns?: string[];
ntp_servers?: string[];
lpr_servers?: string[];
_time_servers?: string[];
_name_servers?: string[];
_log_servers?: string[];
_cookie_servers?: string[];
_wins_servers?: string[];
_swap_server?: string;
boot_size?: string;
root_path?: string;
lease?: string;
lease_expiry?: Date;
dhcp_type?: string;
server_id?: string;
message?: string;
tftp?: string;
bootfile?: string;
}
export interface IPv6Address {
address: string;
prefix: string;
valid_lifetime: string;
preferred_lifetime: string;
scope: string;
}
export interface NetworkState {
interface_name?: string;
mac_address?: string;
ipv4?: string;
ipv4_addresses?: string[];
ipv6?: string;
ipv6_addresses?: IPv6Address[];
ipv6_link_local?: string;
dhcp_lease?: DhcpLease;
setNetworkState: (state: NetworkState) => void;
setDhcpLease: (lease: NetworkState["dhcp_lease"]) => void;
setDhcpLeaseExpiry: (expiry: Date) => void;
}
export type IPv6Mode = "disabled" | "slaac" | "dhcpv6" | "slaac_and_dhcpv6" | "static" | "link_local" | "unknown";
export type IPv4Mode = "disabled" | "static" | "dhcp" | "unknown";
export type LLDPMode = "disabled" | "basic" | "all" | "unknown";
export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown";
export type TimeSyncMode = "ntp_only" | "ntp_and_http" | "http_only" | "custom" | "unknown";
export interface NetworkSettings {
hostname: string;
domain: string;
ipv4_mode: IPv4Mode;
ipv6_mode: IPv6Mode;
lldp_mode: LLDPMode;
lldp_tx_tlvs: string[];
mdns_mode: mDNSMode;
time_sync_mode: TimeSyncMode;
}
export const useNetworkStateStore = create<NetworkState>((set, get) => ({
setNetworkState: (state: NetworkState) => set(state),
setDhcpLease: (lease: NetworkState["dhcp_lease"]) => set({ dhcp_lease: lease }),
setDhcpLeaseExpiry: (expiry: Date) => {
const lease = get().dhcp_lease;
if (!lease) {
console.warn("No lease found");
return;
}
lease.lease_expiry = expiry;
set({ dhcp_lease: lease });
}
}));
export interface KeySequenceStep {
keys: string[];
modifiers: string[];
@@ -767,8 +856,8 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
for (let i = 0; i < macro.steps.length; i++) {
const step = macro.steps[i];
if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) {
console.error(`Cannot save: macro "${macro.name}" step ${i+1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`);
throw new Error(`Cannot save: macro "${macro.name}" step ${i+1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`);
console.error(`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`);
throw new Error(`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`);
}
}
}

View File

@@ -42,6 +42,7 @@ import SettingsVideoRoute from "./routes/devices.$id.settings.video";
import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance";
import * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index";
import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update";
import SettingsNetworkRoute from "./routes/devices.$id.settings.network";
import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth";
import SettingsMacrosRoute from "./routes/devices.$id.settings.macros";
import SettingsMacrosAddRoute from "./routes/devices.$id.settings.macros.add";
@@ -156,6 +157,10 @@ if (isOnDevice) {
path: "hardware",
element: <SettingsHardwareRoute />,
},
{
path: "network",
element: <SettingsNetworkRoute />,
},
{
path: "access",
children: [

View File

@@ -0,0 +1,408 @@
import { useCallback, useEffect, useState } from "react";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { IPv4Mode, IPv6Mode, LLDPMode, mDNSMode, NetworkSettings, NetworkState, TimeSyncMode, useNetworkStateStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
import InputField from "@components/InputField";
import { SettingsItem } from "./devices.$id.settings";
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
const defaultNetworkSettings: NetworkSettings = {
hostname: "",
domain: "",
ipv4_mode: "unknown",
ipv6_mode: "unknown",
lldp_mode: "unknown",
lldp_tx_tlvs: [],
mdns_mode: "unknown",
time_sync_mode: "unknown",
}
export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
if (lifetime == "") {
return <strong>N/A</strong>;
}
const [remaining, setRemaining] = useState<string | null>(null);
useEffect(() => {
setRemaining(dayjs(lifetime).fromNow());
const interval = setInterval(() => {
setRemaining(dayjs(lifetime).fromNow());
}, 1000 * 30);
return () => clearInterval(interval);
}, [lifetime]);
return <>
<strong>{dayjs(lifetime).format()}</strong>
{remaining && <>
{" "}<span className="text-xs text-slate-700 dark:text-slate-300">
({remaining})
</span>
</>}
</>
}
export default function SettingsNetworkRoute() {
const [send] = useJsonRpc();
const [networkState, setNetworkState] = useNetworkStateStore(state => [state, state.setNetworkState]);
const [networkSettings, setNetworkSettings] = useState<NetworkSettings>(defaultNetworkSettings);
const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false);
const getNetworkSettings = useCallback(() => {
setNetworkSettingsLoaded(false);
send("getNetworkSettings", {}, resp => {
if ("error" in resp) return;
console.log(resp.result);
setNetworkSettings(resp.result as NetworkSettings);
setNetworkSettingsLoaded(true);
});
}, [send]);
const setNetworkSettingsRemote = useCallback((settings: NetworkSettings) => {
setNetworkSettingsLoaded(false);
send("setNetworkSettings", { settings }, resp => {
if ("error" in resp) {
notifications.error("Failed to save network settings: " + (resp.error.data ? resp.error.data : resp.error.message));
setNetworkSettingsLoaded(true);
return;
}
setNetworkSettings(resp.result as NetworkSettings);
setNetworkSettingsLoaded(true);
notifications.success("Network settings saved");
});
}, [send]);
const getNetworkState = useCallback(() => {
send("getNetworkState", {}, resp => {
if ("error" in resp) return;
console.log(resp.result);
setNetworkState(resp.result as NetworkState);
});
}, [send]);
const handleRenewLease = useCallback(() => {
send("renewDHCPLease", {}, resp => {
if ("error" in resp) {
notifications.error("Failed to renew lease: " + resp.error.message);
} else {
notifications.success("DHCP lease renewed");
}
});
}, [send]);
useEffect(() => {
getNetworkState();
getNetworkSettings();
}, [getNetworkState, getNetworkSettings]);
const handleIpv4ModeChange = (value: IPv4Mode | string) => {
setNetworkSettings({ ...networkSettings, ipv4_mode: value as IPv4Mode });
};
const handleIpv6ModeChange = (value: IPv6Mode | string) => {
setNetworkSettings({ ...networkSettings, ipv6_mode: value as IPv6Mode });
};
const handleLldpModeChange = (value: LLDPMode | string) => {
setNetworkSettings({ ...networkSettings, lldp_mode: value as LLDPMode });
};
// const handleLldpTxTlvsChange = (value: string[]) => {
// setNetworkSettings({ ...networkSettings, lldp_tx_tlvs: value });
// };
const handleMdnsModeChange = (value: mDNSMode | string) => {
setNetworkSettings({ ...networkSettings, mdns_mode: value as mDNSMode });
};
const handleTimeSyncModeChange = (value: TimeSyncMode | string) => {
setNetworkSettings({ ...networkSettings, time_sync_mode: value as TimeSyncMode });
};
const filterUnknown = useCallback((options: { value: string; label: string; }[]) => {
if (!networkSettingsLoaded) return options;
return options.filter(option => option.value !== "unknown");
}, [networkSettingsLoaded]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Network"
description="Configure your network settings"
/>
<div className="space-y-4">
<SettingsItem
title="MAC Address"
description={<></>}
>
<span className="select-auto font-mono text-xs text-slate-700 dark:text-slate-300">
{networkState?.mac_address}
</span>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Hostname"
description={
<>
Hostname for the device
<br />
<span className="text-xs text-slate-700 dark:text-slate-300">
Leave blank for default
</span>
</>
}
>
<InputField
type="text"
placeholder="jetkvm"
value={networkSettings.hostname}
error={""}
onChange={e => {
setNetworkSettings({ ...networkSettings, hostname: e.target.value });
}}
disabled={!networkSettingsLoaded}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Domain"
description={
<>
Domain for the device
<br />
<span className="text-xs text-slate-700 dark:text-slate-300">
Leave blank to use DHCP provided domain, if there is no domain, use <span className="font-mono">local</span>
</span>
</>
}
>
<InputField
type="text"
placeholder="local"
value={networkSettings.domain}
error={""}
onChange={e => {
setNetworkSettings({ ...networkSettings, domain: e.target.value });
}}
disabled={!networkSettingsLoaded}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="IPv4 Mode"
description="Configure the IPv4 mode"
>
<SelectMenuBasic
size="SM"
value={networkSettings.ipv4_mode}
onChange={e => handleIpv4ModeChange(e.target.value)}
disabled={!networkSettingsLoaded}
options={filterUnknown([
{ value: "dhcp", label: "DHCP" },
// { value: "static", label: "Static" },
])}
/>
</SettingsItem>
{networkState?.dhcp_lease && (
<GridCard>
<div className="flex items-start gap-x-4 p-4">
<div className="space-y-3 w-full">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Current DHCP Lease
</h3>
<div>
<ul className="list-none space-y-1 text-xs text-slate-700 dark:text-slate-300">
{networkState?.dhcp_lease?.ip && <li>IP: <strong>{networkState?.dhcp_lease?.ip}</strong></li>}
{networkState?.dhcp_lease?.netmask && <li>Subnet: <strong>{networkState?.dhcp_lease?.netmask}</strong></li>}
{networkState?.dhcp_lease?.broadcast && <li>Broadcast: <strong>{networkState?.dhcp_lease?.broadcast}</strong></li>}
{networkState?.dhcp_lease?.ttl && <li>TTL: <strong>{networkState?.dhcp_lease?.ttl}</strong></li>}
{networkState?.dhcp_lease?.mtu && <li>MTU: <strong>{networkState?.dhcp_lease?.mtu}</strong></li>}
{networkState?.dhcp_lease?.hostname && <li>Hostname: <strong>{networkState?.dhcp_lease?.hostname}</strong></li>}
{networkState?.dhcp_lease?.domain && <li>Domain: <strong>{networkState?.dhcp_lease?.domain}</strong></li>}
{networkState?.dhcp_lease?.routers && <li>Gateway: <strong>{networkState?.dhcp_lease?.routers.join(", ")}</strong></li>}
{networkState?.dhcp_lease?.dns && <li>DNS: <strong>{networkState?.dhcp_lease?.dns.join(", ")}</strong></li>}
{networkState?.dhcp_lease?.ntp_servers && <li>NTP Servers: <strong>{networkState?.dhcp_lease?.ntp_servers.join(", ")}</strong></li>}
{networkState?.dhcp_lease?.server_id && <li>Server ID: <strong>{networkState?.dhcp_lease?.server_id}</strong></li>}
{networkState?.dhcp_lease?.bootp_next_server && <li>BootP Next Server: <strong>{networkState?.dhcp_lease?.bootp_next_server}</strong></li>}
{networkState?.dhcp_lease?.bootp_server_name && <li>BootP Server Name: <strong>{networkState?.dhcp_lease?.bootp_server_name}</strong></li>}
{networkState?.dhcp_lease?.bootp_file && <li>Boot File: <strong>{networkState?.dhcp_lease?.bootp_file}</strong></li>}
{networkState?.dhcp_lease?.lease_expiry && <li>
Lease Expiry: <LifeTimeLabel lifetime={`${networkState?.dhcp_lease?.lease_expiry}`} />
</li>}
{/* {JSON.stringify(networkState?.dhcp_lease)} */}
</ul>
</div>
</div>
<hr className="block w-full dark:border-slate-600" />
<div>
<Button
size="SM"
theme="danger"
text="Renew lease"
onClick={() => {
handleRenewLease();
}}
/>
</div>
</div>
</div>
</GridCard>
)}
</div>
<div className="space-y-4">
<SettingsItem
title="IPv6 Mode"
description="Configure the IPv6 mode"
>
<SelectMenuBasic
size="SM"
value={networkSettings.ipv6_mode}
onChange={e => handleIpv6ModeChange(e.target.value)}
disabled={!networkSettingsLoaded}
options={filterUnknown([
// { value: "disabled", label: "Disabled" },
{ value: "slaac", label: "SLAAC" },
// { value: "dhcpv6", label: "DHCPv6" },
// { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" },
// { value: "static", label: "Static" },
// { value: "link_local", label: "Link-local only" },
])}
/>
</SettingsItem>
{networkState?.ipv6_addresses && (
<GridCard>
<div className="flex items-start gap-x-4 p-4">
<div className="space-y-3 w-full">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
IPv6 Information
</h3>
<div className="space-y-2">
<div>
<h4 className="text-sm font-bold text-slate-900 dark:text-white">
IPv6 Link-local
</h4>
<p className="text-xs text-slate-700 dark:text-slate-300">
{networkState?.ipv6_link_local}
</p>
</div>
<div>
<h4 className="text-sm font-bold text-slate-900 dark:text-white">
IPv6 Addresses
</h4>
<ul className="list-none space-y-1 text-xs text-slate-700 dark:text-slate-300">
{networkState?.ipv6_addresses && networkState?.ipv6_addresses.map(addr => (
<li key={addr.address}>
{addr.address}
{addr.valid_lifetime && <>
<br />
- valid_lft: {" "}
<span className="text-xs text-slate-700 dark:text-slate-300">
<LifeTimeLabel lifetime={`${addr.valid_lifetime}`} />
</span>
</>}
{addr.preferred_lifetime && <>
<br />
- pref_lft: {" "}
<span className="text-xs text-slate-700 dark:text-slate-300">
<LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} />
</span>
</>}
</li>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
</GridCard>
)}
</div>
<div className="space-y-4 hidden">
<SettingsItem
title="LLDP"
description="Control which TLVs will be sent over Link Layer Discovery Protocol"
>
<SelectMenuBasic
size="SM"
value={networkSettings.lldp_mode}
onChange={e => handleLldpModeChange(e.target.value)}
disabled={!networkSettingsLoaded}
options={filterUnknown([
{ value: "disabled", label: "Disabled" },
{ value: "basic", label: "Basic" },
{ value: "all", label: "All" },
])}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="mDNS"
description="Control mDNS (multicast DNS) operational mode"
>
<SelectMenuBasic
size="SM"
value={networkSettings.mdns_mode}
onChange={e => handleMdnsModeChange(e.target.value)}
disabled={!networkSettingsLoaded}
options={filterUnknown([
{ value: "disabled", label: "Disabled" },
{ value: "auto", label: "Auto" },
{ value: "ipv4_only", label: "IPv4 only" },
{ value: "ipv6_only", label: "IPv6 only" },
])}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Time synchronization"
description="Configure time synchronization settings"
>
<SelectMenuBasic
size="SM"
value={networkSettings.time_sync_mode}
onChange={e => handleTimeSyncModeChange(e.target.value)}
disabled={!networkSettingsLoaded}
options={filterUnknown([
{ value: "unknown", label: "..." },
// { value: "auto", label: "Auto" },
{ value: "ntp_only", label: "NTP only" },
{ value: "ntp_and_http", label: "NTP and HTTP" },
{ value: "http_only", label: "HTTP only" },
// { value: "custom", label: "Custom" },
])}
/>
</SettingsItem>
</div>
<div className="flex items-end gap-x-2">
<Button
onClick={() => {
setNetworkSettingsRemote(networkSettings);
}}
size="SM"
theme="light"
text="Save Settings"
/>
</div>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import {
LuArrowLeft,
LuPalette,
LuCommand,
LuNetwork,
} from "react-icons/lu";
import React, { useEffect, useRef, useState } from "react";
@@ -207,6 +208,17 @@ export default function SettingsRoute() {
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="network"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuNetwork className="h-4 w-4 shrink-0" />
<h1>Network</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="advanced"

View File

@@ -20,11 +20,13 @@ import { cx } from "@/cva.config";
import {
DeviceSettingsState,
HidState,
NetworkState,
UpdateState,
useDeviceSettingsStore,
useDeviceStore,
useHidStore,
useMountMediaStore,
useNetworkStateStore,
User,
useRTCStore,
useUiStore,
@@ -581,6 +583,8 @@ export default function KvmIdRoute() {
});
}, 10000);
const setNetworkState = useNetworkStateStore(state => state.setNetworkState);
const setUsbState = useHidStore(state => state.setUsbState);
const setHdmiState = useVideoStore(state => state.setHdmiState);
@@ -600,6 +604,11 @@ export default function KvmIdRoute() {
setHdmiState(resp.params as Parameters<VideoState["setHdmiState"]>[0]);
}
if (resp.method === "networkState") {
console.log("Setting network state", resp.params);
setNetworkState(resp.params as NetworkState);
}
if (resp.method === "otaState") {
const otaState = resp.params as UpdateState["otaState"];
setOtaState(otaState);

View File

@@ -35,6 +35,7 @@ export default defineConfig(({ mode, command }) => {
"/auth": JETKVM_PROXY_URL,
"/storage": JETKVM_PROXY_URL,
"/cloud": JETKVM_PROXY_URL,
"/developer": JETKVM_PROXY_URL,
}
: undefined,
},