diff --git a/cloud.go b/cloud.go index 57d4366..fb1998a 100644 --- a/cloud.go +++ b/cloud.go @@ -165,9 +165,12 @@ func setCloudConnectionState(state CloudConnectionState) { state = CloudConnectionStateNotConfigured } + previousState := cloudConnectionState cloudConnectionState = state - go waitCtrlAndRequestDisplayUpdate() + go waitCtrlAndRequestDisplayUpdate( + previousState != state, + ) } func wsResetMetrics(established bool, sourceType string, source string) { diff --git a/display.go b/display.go index b60f9f8..b983efc 100644 --- a/display.go +++ b/display.go @@ -179,7 +179,7 @@ var ( waitDisplayUpdate = sync.Mutex{} ) -func requestDisplayUpdate() { +func requestDisplayUpdate(shouldWakeDisplay bool) { displayUpdateLock.Lock() defer displayUpdateLock.Unlock() @@ -188,19 +188,21 @@ func requestDisplayUpdate() { return } go func() { - wakeDisplay(false) + if shouldWakeDisplay { + wakeDisplay(false) + } displayLogger.Debug().Msg("display updating") //TODO: only run once regardless how many pending updates updateDisplay() }() } -func waitCtrlAndRequestDisplayUpdate() { +func waitCtrlAndRequestDisplayUpdate(shouldWakeDisplay bool) { waitDisplayUpdate.Lock() defer waitDisplayUpdate.Unlock() waitCtrlClientConnected() - requestDisplayUpdate() + requestDisplayUpdate(shouldWakeDisplay) } func updateStaticContents() { @@ -376,7 +378,7 @@ func init() { displayLogger.Info().Msg("display inited") startBacklightTickers() wakeDisplay(true) - requestDisplayUpdate() + requestDisplayUpdate(true) }() go watchTsEvents() diff --git a/internal/udhcpc/parser.go b/internal/udhcpc/parser.go index 0820409..66c3ba2 100644 --- a/internal/udhcpc/parser.go +++ b/internal/udhcpc/parser.go @@ -1,9 +1,11 @@ package udhcpc import ( + "bufio" "encoding/json" "fmt" "net" + "os" "reflect" "strconv" "strings" @@ -41,6 +43,8 @@ type Lease struct { Message string `env:"message" json:"reason,omitempty"` // Reason for a DHCPNAK TFTPServerName string `env:"tftp" json:"tftp,omitempty"` // The TFTP server name BootFileName string `env:"bootfile" json:"bootfile,omitempty"` // The boot file name + Uptime time.Duration `env:"uptime" json:"uptime,omitempty"` // The uptime of the device when the lease was obtained, in seconds + LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease isEmpty map[string]bool } @@ -60,6 +64,40 @@ func (l *Lease) ToJSON() string { return string(json) } +func (l *Lease) SetLeaseExpiry() (time.Time, error) { + if l.Uptime == 0 || l.LeaseTime == 0 { + return time.Time{}, fmt.Errorf("uptime or lease time isn't set") + } + + // get the uptime of the device + + file, err := os.Open("/proc/uptime") + if err != nil { + return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err) + } + defer file.Close() + + var uptime time.Duration + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + text := scanner.Text() + parts := strings.Split(text, " ") + uptime, err = time.ParseDuration(parts[0] + "s") + + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err) + } + } + + relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime + leaseExpiry := time.Now().Add(relativeLeaseRemaining) + + l.LeaseExpiry = &leaseExpiry + + return leaseExpiry, nil +} + func UnmarshalDHCPCLease(lease *Lease, str string) error { // parse the lease file as a map data := make(map[string]string) diff --git a/internal/udhcpc/udhcpc.go b/internal/udhcpc/udhcpc.go index 4ff75ab..927d551 100644 --- a/internal/udhcpc/udhcpc.go +++ b/internal/udhcpc/udhcpc.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "time" "github.com/fsnotify/fsnotify" "github.com/rs/zerolog" @@ -140,6 +141,17 @@ func (c *DHCPClient) loadLeaseFile() error { msg = "udhcpc lease loaded" } + leaseExpiry, err := lease.SetLeaseExpiry() + if err != nil { + c.logger.Error().Err(err).Msg("failed to get dhcp lease expiry") + } else { + expiresIn := leaseExpiry.Sub(time.Now()) + c.logger.Info(). + Interface("expiry", leaseExpiry). + Str("expiresIn", expiresIn.String()). + Msg("current dhcp lease expiry time calculated") + } + c.onLeaseChange(lease) c.logger.Info(). diff --git a/jsonrpc.go b/jsonrpc.go index 3e28054..d39fdb1 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -961,6 +961,7 @@ var rpcHandlers = map[string]RPCHandler{ "deregisterDevice": {Func: rpcDeregisterDevice}, "getCloudState": {Func: rpcGetCloudState}, "getNetworkState": {Func: rpcGetNetworkState}, + "getNetworkSettings": {Func: rpcGetNetworkSettings}, "renewDHCPLease": {Func: rpcRenewDHCPLease}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, diff --git a/network.go b/network.go index e298d52..5ce9861 100644 --- a/network.go +++ b/network.go @@ -29,11 +29,22 @@ const ( DhcpTargetStateRelease ) +type IPv6Address struct { + Address net.IP `json:"address"` + Prefix net.IPNet `json:"prefix"` + ValidLifetime *time.Time `json:"valid_lifetime"` + PreferredLifetime *time.Time `json:"preferred_lifetime"` + Scope int `json:"scope"` +} + type NetworkInterfaceState struct { interfaceName string interfaceUp bool ipv4Addr *net.IP + ipv4Addresses []string ipv6Addr *net.IP + ipv6Addresses []IPv6Address + ipv6LinkLocal *net.IP macAddr *net.HardwareAddr l *zerolog.Logger @@ -47,12 +58,65 @@ type NetworkInterfaceState struct { checked bool } +type NetworkConfig struct { + Hostname string `json:"hostname,omitempty"` + Domain string `json:"domain,omitempty"` + + IPv4Mode string `json:"ipv4_mode" one_of:"dhcp,static,disabled" default:"dhcp"` + IPv4Static struct { + Address string `json:"address" validate_type:"ipv4"` + Netmask string `json:"netmask" validate_type:"ipv4"` + Gateway string `json:"gateway" validate_type:"ipv4"` + DNS []string `json:"dns" validate_type:"ipv4"` + } `json:"ipv4_static,omitempty" required_if:"ipv4_mode,static"` + + IPv6Mode string `json:"ipv6_mode" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"` + IPv6Static struct { + Address string `json:"address" validate_type:"ipv6"` + Netmask string `json:"netmask" validate_type:"ipv6"` + Gateway string `json:"gateway" validate_type:"ipv6"` + DNS []string `json:"dns" validate_type:"ipv6"` + } `json:"ipv6_static,omitempty" required_if:"ipv6_mode,static"` + + LLDPMode string `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"` + LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"` + MDNSMode string `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"` + TimeSyncMode string `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"` +} + +type RpcIPv6Address struct { + Address string `json:"address"` + ValidLifetime *time.Time `json:"valid_lifetime,omitempty"` + PreferredLifetime *time.Time `json:"preferred_lifetime,omitempty"` + Scope int `json:"scope"` +} + type RpcNetworkState struct { - InterfaceName string `json:"interface_name"` - MacAddress string `json:"mac_address"` - IPv4 string `json:"ipv4,omitempty"` - IPv6 string `json:"ipv6,omitempty"` - DHCPLease *udhcpc.Lease `json:"dhcp_lease,omitempty"` + InterfaceName string `json:"interface_name"` + MacAddress string `json:"mac_address"` + IPv4 string `json:"ipv4,omitempty"` + IPv6 string `json:"ipv6,omitempty"` + IPv6LinkLocal string `json:"ipv6_link_local,omitempty"` + IPv4Addresses []string `json:"ipv4_addresses,omitempty"` + IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses,omitempty"` + DHCPLease *udhcpc.Lease `json:"dhcp_lease,omitempty"` +} + +type RpcNetworkSettings struct { + IPv4Mode string `json:"ipv4_mode,omitempty"` + IPv6Mode string `json:"ipv6_mode,omitempty"` + LLDPMode string `json:"lldp_mode,omitempty"` + LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty"` + MDNSMode string `json:"mdns_mode,omitempty"` + TimeSyncMode string `json:"time_sync_mode,omitempty"` +} + +func lifetimeToTime(lifetime int) *time.Time { + if lifetime == 0 { + return nil + } + t := time.Now().Add(time.Duration(lifetime) * time.Second) + return &t } func (s *NetworkInterfaceState) IsUp() bool { @@ -115,13 +179,13 @@ func NewNetworkInterfaceState(ifname string) *NetworkInterfaceState { onStateChange: func(state *NetworkInterfaceState) { go func() { waitCtrlClientConnected() - requestDisplayUpdate() + requestDisplayUpdate(true) }() }, onInitialCheck: func(state *NetworkInterfaceState) { go func() { waitCtrlClientConnected() - requestDisplayUpdate() + requestDisplayUpdate(true) }() }, } @@ -140,7 +204,18 @@ func NewNetworkInterfaceState(ifname string) *NetworkInterfaceState { PidFile: dhcpPidFile, Logger: &logger, OnLeaseChange: func(lease *udhcpc.Lease) { - _, _ = s.update() + _, err := s.update() + if err != nil { + logger.Error().Err(err).Msg("failed to update network state") + return + } + + if currentSession == nil { + logger.Info().Msg("No active RPC session, skipping network state update") + return + } + + writeJSONRPCEvent("networkState", rpcGetNetworkState(), currentSession) }, }) @@ -196,8 +271,11 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { } var ( - ipv4Addresses = make([]net.IP, 0) - ipv6Addresses = make([]net.IP, 0) + ipv4Addresses = make([]net.IP, 0) + ipv4AddressesString = make([]string, 0) + ipv6Addresses = make([]IPv6Address, 0) + ipv6AddressesString = make([]string, 0) + ipv6LinkLocal *net.IP ) for _, addr := range addrs { @@ -215,9 +293,15 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { continue } ipv4Addresses = append(ipv4Addresses, addr.IP) + ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String()) } else if addr.IP.To16() != nil { scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger() // check if it's a link local address + if addr.IP.IsLinkLocalUnicast() { + ipv6LinkLocal = &addr.IP + continue + } + if !addr.IP.IsGlobalUnicast() { scopedLogger.Trace().Msg("not a global unicast address, skipping") continue @@ -231,7 +315,14 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { } continue } - ipv6Addresses = append(ipv6Addresses, addr.IP) + ipv6Addresses = append(ipv6Addresses, IPv6Address{ + Address: addr.IP, + Prefix: *addr.IPNet, + ValidLifetime: lifetimeToTime(addr.ValidLft), + PreferredLifetime: lifetimeToTime(addr.PreferedLft), + Scope: addr.Scope, + }) + ipv6AddressesString = append(ipv6AddressesString, addr.IPNet.String()) } } @@ -250,11 +341,28 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { changed = true } } + s.ipv4Addresses = ipv4AddressesString + + if ipv6LinkLocal != nil { + if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() { + scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger() + if s.ipv6LinkLocal != nil { + scopedLogger.Info(). + Str("old_ipv6", s.ipv6LinkLocal.String()). + Msg("IPv6 link local address changed") + } else { + scopedLogger.Info().Msg("IPv6 link local address found") + } + s.ipv6LinkLocal = ipv6LinkLocal + changed = true + } + } + s.ipv6Addresses = ipv6Addresses if len(ipv6Addresses) > 0 { // compare the addresses to see if there's a change - if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].String() { - scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].String()).Logger() + if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() { + scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger() if s.ipv6Addr != nil { scopedLogger.Info(). Str("old_ipv6", s.ipv6Addr.String()). @@ -262,7 +370,7 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { } else { scopedLogger.Info().Msg("IPv6 address found") } - s.ipv6Addr = &ipv6Addresses[0] + s.ipv6Addr = &ipv6Addresses[0].Address changed = true } } @@ -313,15 +421,38 @@ func (s *NetworkInterfaceState) HandleLinkUpdate(update netlink.LinkUpdate) { } func rpcGetNetworkState() RpcNetworkState { + ipv6Addresses := make([]RpcIPv6Address, 0) + for _, addr := range networkState.ipv6Addresses { + ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{ + Address: addr.Prefix.String(), + ValidLifetime: addr.ValidLifetime, + PreferredLifetime: addr.PreferredLifetime, + Scope: addr.Scope, + }) + } return RpcNetworkState{ InterfaceName: networkState.interfaceName, MacAddress: networkState.macAddr.String(), IPv4: networkState.ipv4Addr.String(), IPv6: networkState.ipv6Addr.String(), + IPv6LinkLocal: networkState.ipv6LinkLocal.String(), + IPv4Addresses: networkState.ipv4Addresses, + IPv6Addresses: ipv6Addresses, DHCPLease: networkState.dhcpClient.GetLease(), } } +func rpcGetNetworkSettings() RpcNetworkSettings { + return RpcNetworkSettings{ + IPv4Mode: "dhcp", + IPv6Mode: "slaac", + LLDPMode: "basic", + LLDPTxTLVs: []string{"chassis", "port", "system", "vlan"}, + MDNSMode: "auto", + TimeSyncMode: "ntp_and_http", + } +} + func rpcRenewDHCPLease() error { if networkState == nil { return fmt.Errorf("network state not initialized") diff --git a/ui/dev_device.sh b/ui/dev_device.sh index 650cadd..2c7b497 100755 --- a/ui/dev_device.sh +++ b/ui/dev_device.sh @@ -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 diff --git a/ui/package-lock.json b/ui/package-lock.json index ebce148..6e58f39 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -18,6 +18,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", @@ -2460,6 +2461,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", diff --git a/ui/package.json b/ui/package.json index a248616..1683ee8 100644 --- a/ui/package.json +++ b/ui/package.json @@ -28,6 +28,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", diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 0fa4121..f20eee2 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -663,6 +663,93 @@ export const useDeviceStore = create(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 { + ipv4_mode: IPv4Mode; + ipv6_mode: IPv6Mode; + lldp_mode: LLDPMode; + lldp_tx_tlvs: string[]; + mdns_mode: mDNSMode; + time_sync_mode: TimeSyncMode; +} + +export const useNetworkStateStore = create((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.toISOString(); + set({ dhcp_lease: lease }); + } +})); + export interface KeySequenceStep { keys: string[]; modifiers: string[]; @@ -767,8 +854,8 @@ export const useMacrosStore = create((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`); } } } diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index c7ade5f..c1e8468 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -1,62 +1,79 @@ import { useCallback, useEffect, useState } from "react"; -import { SettingsPageHeader } from "../components/SettingsPageheader"; import { SelectMenuBasic } from "../components/SelectMenuBasic"; +import { SettingsPageHeader } from "../components/SettingsPageheader"; -import { SettingsItem } from "./devices.$id.settings"; +import { IPv4Mode, IPv6Mode, LLDPMode, mDNSMode, NetworkSettings, NetworkState, TimeSyncMode, useNetworkStateStore } from "@/hooks/stores"; import { useJsonRpc } from "@/hooks/useJsonRpc"; -import { Button } from "@components/Button"; 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"; -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; - dhcp_type?: string; - server_id?: string; - message?: string; - tftp?: string; - bootfile?: string; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; + +dayjs.extend(relativeTime); + +const defaultNetworkSettings: NetworkSettings = { + 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 N/A; + } -interface NetworkState { - interface_name?: string; - mac_address?: string; - ipv4?: string; - ipv6?: string; - dhcp_lease?: DhcpLease; + const [remaining, setRemaining] = useState(null); + + useEffect(() => { + setRemaining(dayjs(lifetime).fromNow()); + + const interval = setInterval(() => { + setRemaining(dayjs(lifetime).fromNow()); + }, 1000 * 30); + return () => clearInterval(interval); + }, [lifetime]); + + return <> + {dayjs(lifetime).format()} + {remaining && <> + {" "} + ({remaining}) + + } + } export default function SettingsNetworkRoute() { const [send] = useJsonRpc(); - const [networkState, setNetworkState] = useState(null); + const [networkState, setNetworkState] = useNetworkStateStore(state => [state, state.setNetworkState]); + const [networkSettings, setNetworkSettings] = useState(defaultNetworkSettings); + const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false); + + const [dhcpLeaseExpiry, setDhcpLeaseExpiry] = useState(null); + const [dhcpLeaseExpiryRemaining, setDhcpLeaseExpiryRemaining] = useState(null); + + const getNetworkSettings = useCallback(() => { + setNetworkSettingsLoaded(false); + send("getNetworkSettings", {}, resp => { + if ("error" in resp) return; + setNetworkSettings(resp.result as NetworkSettings); + setNetworkSettingsLoaded(true); + }); + }, [send]); const getNetworkState = useCallback(() => { send("getNetworkState", {}, resp => { if ("error" in resp) return; + console.log(resp.result); setNetworkState(resp.result as NetworkState); }); }, [send]); @@ -74,7 +91,37 @@ export default function SettingsNetworkRoute() { useEffect(() => { getNetworkState(); - }, [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 (
@@ -82,54 +129,265 @@ export default function SettingsNetworkRoute() { title="Network" description="Configure your network settings" /> -
- {networkState?.ipv4} - } - /> -
-
- {networkState?.ipv6}} - /> -
{networkState?.mac_address}} - /> + description={<>} + > + + {networkState?.mac_address} + +
-
    - {networkState?.dhcp_lease?.ip &&
  • IP: {networkState?.dhcp_lease?.ip}
  • } - {networkState?.dhcp_lease?.netmask &&
  • Subnet: {networkState?.dhcp_lease?.netmask}
  • } - {networkState?.dhcp_lease?.broadcast &&
  • Broadcast: {networkState?.dhcp_lease?.broadcast}
  • } - {networkState?.dhcp_lease?.ttl &&
  • TTL: {networkState?.dhcp_lease?.ttl}
  • } - {networkState?.dhcp_lease?.mtu &&
  • MTU: {networkState?.dhcp_lease?.mtu}
  • } - {networkState?.dhcp_lease?.hostname &&
  • Hostname: {networkState?.dhcp_lease?.hostname}
  • } - {networkState?.dhcp_lease?.domain &&
  • Domain: {networkState?.dhcp_lease?.domain}
  • } - {networkState?.dhcp_lease?.routers &&
  • Gateway: {networkState?.dhcp_lease?.routers.join(", ")}
  • } - {networkState?.dhcp_lease?.dns &&
  • DNS: {networkState?.dhcp_lease?.dns.join(", ")}
  • } - {networkState?.dhcp_lease?.ntp_servers &&
  • NTP Servers: {networkState?.dhcp_lease?.ntp_servers.join(", ")}
  • } -
- } + title="Hostname" + description={ + <> + Hostname for the device +
+ + Leave blank for default + + + } > -
+
+ + Domain for the device +
+ + Leave blank to use DHCP provided domain, if there is no domain, use local + + + } + > + { + console.log(e.target.value); + }} + /> +
+
+
+ + handleIpv4ModeChange(e.target.value)} + disabled={!networkSettingsLoaded} + options={filterUnknown([ + { value: "dhcp", label: "DHCP" }, + // { value: "static", label: "Static" }, + ])} + /> + + {networkState?.dhcp_lease && ( + +
+
+
+

+ Current DHCP Lease +

+
+
    + {networkState?.dhcp_lease?.ip &&
  • IP: {networkState?.dhcp_lease?.ip}
  • } + {networkState?.dhcp_lease?.netmask &&
  • Subnet: {networkState?.dhcp_lease?.netmask}
  • } + {networkState?.dhcp_lease?.broadcast &&
  • Broadcast: {networkState?.dhcp_lease?.broadcast}
  • } + {networkState?.dhcp_lease?.ttl &&
  • TTL: {networkState?.dhcp_lease?.ttl}
  • } + {networkState?.dhcp_lease?.mtu &&
  • MTU: {networkState?.dhcp_lease?.mtu}
  • } + {networkState?.dhcp_lease?.hostname &&
  • Hostname: {networkState?.dhcp_lease?.hostname}
  • } + {networkState?.dhcp_lease?.domain &&
  • Domain: {networkState?.dhcp_lease?.domain}
  • } + {networkState?.dhcp_lease?.routers &&
  • Gateway: {networkState?.dhcp_lease?.routers.join(", ")}
  • } + {networkState?.dhcp_lease?.dns &&
  • DNS: {networkState?.dhcp_lease?.dns.join(", ")}
  • } + {networkState?.dhcp_lease?.ntp_servers &&
  • NTP Servers: {networkState?.dhcp_lease?.ntp_servers.join(", ")}
  • } + {networkState?.dhcp_lease?.server_id &&
  • Server ID: {networkState?.dhcp_lease?.server_id}
  • } + {networkState?.dhcp_lease?.bootp_next_server &&
  • BootP Next Server: {networkState?.dhcp_lease?.bootp_next_server}
  • } + {networkState?.dhcp_lease?.bootp_server_name &&
  • BootP Server Name: {networkState?.dhcp_lease?.bootp_server_name}
  • } + {networkState?.dhcp_lease?.bootp_file &&
  • Boot File: {networkState?.dhcp_lease?.bootp_file}
  • } + {networkState?.dhcp_lease?.lease_expiry &&
  • + Lease Expiry: +
  • } + {/* {JSON.stringify(networkState?.dhcp_lease)} */} +
+
+
+
+
+
+
+
+
+ )} +
+
+ + 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" }, + ])} + /> + + {networkState?.ipv6_addresses && ( + +
+
+
+

+ IPv6 Information +

+
+
+

+ IPv6 Link-local +

+

+ {networkState?.ipv6_link_local} +

+
+
+

+ IPv6 Addresses +

+
    + {networkState?.ipv6_addresses && networkState?.ipv6_addresses.map(addr => ( +
  • + {addr.address} + {addr.valid_lifetime && <> +
    + - valid_lft: {" "} + + + + } + {addr.preferred_lifetime && <> +
    + - pref_lft: {" "} + + + + } +
  • + ))} +
+
+
+
+
+
+
+ )} +
+
+ + handleLldpModeChange(e.target.value)} + disabled={!networkSettingsLoaded} + options={filterUnknown([ + { value: "disabled", label: "Disabled" }, + { value: "basic", label: "Basic" }, + { value: "all", label: "All" }, + ])} + /> + +
+
+ + 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" }, + ])} + /> + +
+
+ + 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" }, + ])} + /> + +
+
+
); } diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 82bb542..161f494 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -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[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); diff --git a/usb.go b/usb.go index 3395db4..91674c9 100644 --- a/usb.go +++ b/usb.go @@ -66,6 +66,6 @@ func checkUSBState() { usbState = newState usbLogger.Info().Str("from", usbState).Str("to", newState).Msg("USB state changed") - requestDisplayUpdate() + requestDisplayUpdate(true) triggerUSBStateUpdate() } diff --git a/video.go b/video.go index d74add8..6fa77b9 100644 --- a/video.go +++ b/video.go @@ -43,7 +43,7 @@ func HandleVideoStateMessage(event CtrlResponse) { } lastVideoState = videoState triggerVideoStateUpdate() - requestDisplayUpdate() + requestDisplayUpdate(true) } func rpcGetVideoState() (VideoInputState, error) { diff --git a/webrtc.go b/webrtc.go index 1e093e2..5324b23 100644 --- a/webrtc.go +++ b/webrtc.go @@ -205,7 +205,7 @@ func newSession(config SessionConfig) (*Session, error) { var actionSessions = 0 func onActiveSessionsChanged() { - requestDisplayUpdate() + requestDisplayUpdate(true) } func onFirstSessionConnected() {