From 6be9a10ddcf7839d5792cd0290e72b046745829b Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 14 Oct 2025 21:15:42 +0200 Subject: [PATCH] feat: implement post-reboot action handling for better reboot handling --- network.go | 28 ++++++++++++------ ota.go | 9 ++++++ ui/src/components/VideoOverlay.tsx | 47 +++++++++++------------------- ui/src/hooks/stores.ts | 9 ++++-- ui/src/routes/devices.$id.tsx | 11 +++---- 5 files changed, 58 insertions(+), 46 deletions(-) diff --git a/network.go b/network.go index d839bab2..c22a2fc3 100644 --- a/network.go +++ b/network.go @@ -27,6 +27,11 @@ func (s *RpcNetworkSettings) ToNetworkConfig() *types.NetworkConfig { return &s.NetworkConfig } +type PostRebootAction struct { + HealthCheck string `json:"healthCheck"` + RedirectUrl string `json:"redirectUrl"` +} + func toRpcNetworkSettings(config *types.NetworkConfig) *RpcNetworkSettings { return &RpcNetworkSettings{ NetworkConfig: *config, @@ -171,9 +176,9 @@ func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error { return nm.SetHostname(hostname, domain) } -func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (bool, *string) { +func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (bool, *PostRebootAction) { var rebootRequired bool - var suggestedIp *string + var postRebootAction *PostRebootAction oldDhcpClient := oldConfig.DHCPClient.String @@ -183,8 +188,8 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (bo networkLogger.Info().Str("old", oldDhcpClient).Str("new", newConfig.DHCPClient.String).Msg("DHCP client changed, reboot required") } - // IPv4 mode change requires reboot when using udhcpc - if newConfig.IPv4Mode.String != oldConfig.IPv4Mode.String && oldDhcpClient == "udhcpc" { + // IPv4 mode change requires reboot + if newConfig.IPv4Mode.String != oldConfig.IPv4Mode.String { rebootRequired = true networkLogger.Info().Str("old", oldConfig.IPv4Mode.String).Str("new", newConfig.IPv4Mode.String).Msg("IPv4 mode changed with udhcpc, reboot required") } @@ -193,8 +198,13 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (bo if newConfig.IPv4Static != nil && oldConfig.IPv4Static != nil { if newConfig.IPv4Static.Address.String != oldConfig.IPv4Static.Address.String { rebootRequired = true - suggestedIp = &newConfig.IPv4Static.Address.String - networkLogger.Info().Str("old", oldConfig.IPv4Static.Address.String).Str("new", newConfig.IPv4Static.Address.String).Msg("IPv4 address changed, reboot required") + newIP := newConfig.IPv4Static.Address.String + postRebootAction = &PostRebootAction{ + // The user can be using self-signed certificates, so we use don't specify the protocol + HealthCheck: fmt.Sprintf("//%s/device/status", newIP), + RedirectUrl: fmt.Sprintf("//%s", newIP), + } + networkLogger.Info().Str("old", oldConfig.IPv4Static.Address.String).Str("new", newIP).Msg("IPv4 address changed, reboot required") } if newConfig.IPv4Static.Netmask.String != oldConfig.IPv4Static.Netmask.String { rebootRequired = true @@ -216,7 +226,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (bo networkLogger.Info().Str("old", oldConfig.IPv6Mode.String).Str("new", newConfig.IPv6Mode.String).Msg("IPv6 mode changed with udhcpc, reboot required") } - return rebootRequired, suggestedIp + return rebootRequired, postRebootAction } func rpcGetNetworkState() *types.RpcInterfaceState { @@ -239,12 +249,12 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er l.Debug().Msg("setting new config") // Check if reboot is needed - rebootRequired, suggestedIp := shouldRebootForNetworkChange(config.NetworkConfig, netConfig) + rebootRequired, postRebootAction := shouldRebootForNetworkChange(config.NetworkConfig, netConfig) // If reboot required, send willReboot event before applying network config if rebootRequired { l.Info().Msg("Sending willReboot event before applying network config") - writeJSONRPCEvent("willReboot", suggestedIp, currentSession) + writeJSONRPCEvent("willReboot", postRebootAction, currentSession) } _ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String) diff --git a/ota.go b/ota.go index bf0828dc..7063c7ff 100644 --- a/ota.go +++ b/ota.go @@ -488,6 +488,15 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err if rebootNeeded { scopedLogger.Info().Msg("System Rebooting in 10s") + + // TODO: Future enhancement - send postRebootAction to redirect to release notes + // Example: + // postRebootAction := &PostRebootAction{ + // HealthCheck: "[..]/device/status", + // RedirectUrl: "[..]/settings/general/update?version=X.Y.Z", + // } + // writeJSONRPCEvent("willReboot", postRebootAction, currentSession) + time.Sleep(10 * time.Second) cmd := exec.Command("reboot") err := cmd.Start() diff --git a/ui/src/components/VideoOverlay.tsx b/ui/src/components/VideoOverlay.tsx index 717307da..4493d633 100644 --- a/ui/src/components/VideoOverlay.tsx +++ b/ui/src/components/VideoOverlay.tsx @@ -8,7 +8,7 @@ import { BsMouseFill } from "react-icons/bs"; import { Button, LinkButton } from "@components/Button"; import LoadingSpinner from "@components/LoadingSpinner"; import Card, { GridCard } from "@components/Card"; -import { useRTCStore } from "@/hooks/stores"; +import { useRTCStore, PostRebootAction } from "@/hooks/stores"; import LogoBlue from "@/assets/logo-blue.svg"; import LogoWhite from "@/assets/logo-white.svg"; import { isOnDevice } from "@/main"; @@ -400,10 +400,10 @@ export function PointerLockBar({ show }: PointerLockBarProps) { interface RebootingOverlayProps { readonly show: boolean; - readonly suggestedIp: string | null; + readonly postRebootAction: PostRebootAction; } -export function RebootingOverlay({ show, suggestedIp }: RebootingOverlayProps) { +export function RebootingOverlay({ show, postRebootAction }: RebootingOverlayProps) { const { peerConnectionState } = useRTCStore(); // Check if we've already seen the connection drop (confirms reboot actually started) @@ -447,12 +447,12 @@ export function RebootingOverlay({ show, suggestedIp }: RebootingOverlayProps) { const isFetchingRef = useRef(false); useEffect(() => { - // Only run in device mode with a suggested IP - if (!isOnDevice || !suggestedIp || !show || !hasSeenDisconnect) { + // Only run in device mode with a postRebootAction + if (!isOnDevice || !postRebootAction || !show || !hasSeenDisconnect) { return; } - const checkSuggestedIp = async () => { + const checkPostRebootHealth = async () => { // Don't start a new fetch if one is already in progress if (isFetchingRef.current) { return; @@ -468,26 +468,24 @@ export function RebootingOverlay({ show, suggestedIp }: RebootingOverlayProps) { abortControllerRef.current = abortController; isFetchingRef.current = true; - console.log('Checking suggested IP:', suggestedIp); + console.log('Checking post-reboot health endpoint:', postRebootAction.healthCheck); const timeoutId = window.setTimeout(() => abortController.abort(), 2000); try { const response = await fetch( - `${window.location.protocol}//${suggestedIp}/device/status`, - { - signal: abortController.signal, - } + postRebootAction.healthCheck, + { signal: abortController.signal, } ); if (response.ok) { - // Device is available at the new IP, redirect to it - console.log('Device is available at the new IP, redirecting to it'); - window.location.href = `${window.location.protocol}//${suggestedIp}`; + // Device is available, redirect to the specified URL + console.log('Device is available, redirecting to:', postRebootAction.redirectUrl); + window.location.href = postRebootAction.redirectUrl; } } catch (err) { // Ignore errors - they're expected while device is rebooting // Only log if it's not an abort error if (err instanceof Error && err.name !== 'AbortError') { - console.debug('Error checking suggested IP:', err); + console.debug('Error checking post-reboot health:', err); } } finally { clearTimeout(timeoutId); @@ -496,10 +494,10 @@ export function RebootingOverlay({ show, suggestedIp }: RebootingOverlayProps) { }; // Start interval (check every 2 seconds) - const intervalId = setInterval(checkSuggestedIp, 2000); + const intervalId = setInterval(checkPostRebootHealth, 2000); // Also check immediately - checkSuggestedIp(); + checkPostRebootHealth(); // Cleanup on unmount or when dependencies change return () => { @@ -509,7 +507,7 @@ export function RebootingOverlay({ show, suggestedIp }: RebootingOverlayProps) { } isFetchingRef.current = false; }; - }, [show, suggestedIp, hasTimedOut, hasSeenDisconnect]); + }, [show, postRebootAction, hasTimedOut, hasSeenDisconnect]); return ( @@ -543,18 +541,7 @@ export function RebootingOverlay({ show, suggestedIp }: RebootingOverlayProps) { ) : ( <> Please wait while the device restarts. This usually takes 20-30 seconds. - {suggestedIp && ( - <> - {" "}If reconnection fails, the device may be at{" "} - - {suggestedIp} - - . - - )} + )}

diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 4f7e66ac..488bca5e 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -19,6 +19,11 @@ interface JsonRpcResponse { id: number | string | null; } +export type PostRebootAction = { + healthCheck: string; + redirectUrl: string; +} | null; + // Utility function to append stats to a Map const appendStatToMap = ( stat: T, @@ -70,9 +75,9 @@ export interface UIState { terminalType: AvailableTerminalTypes; setTerminalType: (type: UIState["terminalType"]) => void; - rebootState: { isRebooting: boolean; suggestedIp: string | null } | null; + rebootState: { isRebooting: boolean; postRebootAction: PostRebootAction } | null; setRebootState: ( - state: { isRebooting: boolean; suggestedIp: string | null } | null, + state: { isRebooting: boolean; postRebootAction: PostRebootAction } | null, ) => void; } diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index b44cd612..3aa5c3a1 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -24,6 +24,7 @@ import { KeysDownState, NetworkState, OtaState, + PostRebootAction, USBStates, useHidStore, useNetworkStateStore, @@ -274,7 +275,7 @@ export default function KvmIdRoute() { // 5. WS tries to reconnect // 6. WS reconnects // 7. This function is called and now we clear the reboot state - setRebootState({ isRebooting: false, suggestedIp: null }); + setRebootState({ isRebooting: false, postRebootAction: null }); }, onMessage: message => { @@ -677,8 +678,8 @@ export default function KvmIdRoute() { } if (resp.method === "willReboot") { - const suggestedIp = resp.params as unknown as string | null; - setRebootState({ isRebooting: true, suggestedIp }); + const postRebootAction = resp.params as unknown as PostRebootAction; + setRebootState({ isRebooting: true, postRebootAction }); navigateTo("/"); } } @@ -785,7 +786,7 @@ export default function KvmIdRoute() { // Rebooting takes priority over connection status if (rebootState?.isRebooting) { - return ; + return ; } const hasConnectionFailed = @@ -812,7 +813,7 @@ export default function KvmIdRoute() { } return null; - }, [location.pathname, rebootState?.isRebooting, rebootState?.suggestedIp, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]); + }, [location.pathname, rebootState?.isRebooting, rebootState?.postRebootAction, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]); return (