Skip to content

Commit 32dd326

Browse files
fix: handle versioned cloud URLs in reboot flow (#1048)
1 parent 428191b commit 32dd326

File tree

5 files changed

+133
-119
lines changed

5 files changed

+133
-119
lines changed

ui/src/components/KvmCard.tsx

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@ import { Link } from "react-router";
22
import { MdConnectWithoutContact } from "react-icons/md";
33
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
44
import { LuEllipsisVertical } from "react-icons/lu";
5-
import semver from "semver";
65
import { useMemo } from "react";
76

87
import Card from "@components/Card";
98
import { Button, LinkButton } from "@components/Button";
109
import { m } from "@localizations/messages.js";
11-
import { CLOUD_BACKWARDS_COMPATIBLE_VERSION, CLOUD_ENABLE_VERSIONED_UI } from "@/ui.config";
10+
import { buildCloudUrl } from "@/utils";
1211

1312
function getRelativeTimeString(date: Date | number, lang = navigator.language): string {
1413
// Allow dates or times to be passed
@@ -56,28 +55,7 @@ export default function KvmCard({
5655
lastSeen: Date | null;
5756
appVersion?: string;
5857
}) {
59-
/**
60-
* Constructs the URL for connecting to this KVM device's interface.
61-
*
62-
* CLOUD_BACKWARDS_COMPATIBLE_VERSION is the last backwards-compatible UI that works with older devices.
63-
* Devices on CLOUD_BACKWARDS_COMPATIBLE_VERSION or below are served that version, while newer devices get
64-
* their actual version. Unparseable versions fall back to CLOUD_BACKWARDS_COMPATIBLE_VERSION for safety.
65-
*/
66-
const kvmUrl = useMemo(() => {
67-
let uri = `/devices/${id}`;
68-
69-
// Only use versioned path if versioned UI is enabled
70-
if (CLOUD_ENABLE_VERSIONED_UI) {
71-
// Use device version if valid and >= 0.5.0, otherwise fall back to backwards-compatible version
72-
let version = CLOUD_BACKWARDS_COMPATIBLE_VERSION;
73-
if (appVersion && semver.valid(appVersion) && semver.gte(appVersion, CLOUD_BACKWARDS_COMPATIBLE_VERSION)) {
74-
version = appVersion;
75-
}
76-
uri = `/v/${version}${uri}`;
77-
}
78-
79-
return new URL(uri, window.location.origin).toString();
80-
}, [appVersion, id]);
58+
const kvmUrl = useMemo(() => buildCloudUrl(id, appVersion), [id, appVersion]);
8159

8260

8361
return (

ui/src/components/VideoOverlay.tsx

Lines changed: 93 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useState, useRef } from "react";
1+
import React, { useEffect, useState, useRef, useCallback } from "react";
22
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
33
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
44
import { motion, AnimatePresence } from "framer-motion";
@@ -13,7 +13,8 @@ import { useRTCStore, PostRebootAction } from "@/hooks/stores";
1313
import LogoBlue from "@/assets/logo-blue.svg";
1414
import LogoWhite from "@/assets/logo-white.svg";
1515
import { isOnDevice } from "@/main";
16-
import { sleep } from "@/utils";
16+
import { sleep, buildCloudUrl } from "@/utils";
17+
import { getLocalVersion } from "@/utils/jsonrpc";
1718

1819

1920
interface OverlayContentProps {
@@ -398,124 +399,133 @@ export function PointerLockBar({ show }: PointerLockBarProps) {
398399
interface RebootingOverlayProps {
399400
readonly show: boolean;
400401
readonly postRebootAction: PostRebootAction;
402+
readonly deviceId?: string; // Required for cloud mode to build versioned URLs
401403
}
402404

403-
export function RebootingOverlay({ show, postRebootAction }: RebootingOverlayProps) {
405+
export function RebootingOverlay({ show, postRebootAction, deviceId }: RebootingOverlayProps) {
404406
const { peerConnectionState } = useRTCStore();
405-
406-
// Check if we've already seen the connection drop (confirms reboot actually started)
407407
const [hasSeenDisconnect, setHasSeenDisconnect] = useState(
408-
['disconnected', 'closed', 'failed'].includes(peerConnectionState ?? '')
408+
["disconnected", "closed", "failed"].includes(peerConnectionState ?? ""),
409409
);
410-
411-
// Track if we've timed out
412410
const [hasTimedOut, setHasTimedOut] = useState(false);
411+
const isCheckingRef = useRef(false);
412+
const abortControllerRef = useRef<AbortController | null>(null);
413413

414-
// Monitor for disconnect after reboot is initiated
414+
// Detect connection drop (confirms reboot started)
415415
useEffect(() => {
416-
if (!show) return;
417-
if (hasSeenDisconnect) return;
418-
419-
if (['disconnected', 'closed', 'failed'].includes(peerConnectionState ?? '')) {
420-
console.log('hasSeenDisconnect', hasSeenDisconnect);
416+
if (!show || hasSeenDisconnect) return;
417+
if (["disconnected", "closed", "failed"].includes(peerConnectionState ?? "")) {
421418
setHasSeenDisconnect(true);
422419
}
423420
}, [show, peerConnectionState, hasSeenDisconnect]);
424421

425-
// Set timeout after 30 seconds
422+
// Timeout after 30 seconds
426423
useEffect(() => {
427424
if (!show) {
428425
setHasTimedOut(false);
429426
return;
430427
}
431-
432-
const timeoutId = setTimeout(() => {
433-
setHasTimedOut(true);
434-
}, 30 * 1000);
435-
436-
return () => {
437-
clearTimeout(timeoutId);
438-
};
428+
const id = setTimeout(() => setHasTimedOut(true), 30_000);
429+
return () => clearTimeout(id);
439430
}, [show]);
440431

432+
// Redirect helper - navigates and forces reload
433+
const redirectTo = useCallback(async (url: string) => {
434+
console.log("Redirecting to", url);
435+
window.location.href = url;
436+
await sleep(1000);
437+
window.location.reload();
438+
}, []);
441439

442-
// Poll suggested IP in device mode to detect when it's available
443-
const abortControllerRef = useRef<AbortController | null>(null);
444-
const isFetchingRef = useRef(false);
445-
440+
// Local mode: poll HTTP health endpoint
446441
useEffect(() => {
447-
// Only run in device mode with a postRebootAction
448-
if (!isOnDevice || !postRebootAction || !show || !hasSeenDisconnect) {
449-
return;
450-
}
442+
if (!isOnDevice || !postRebootAction || !show || !hasSeenDisconnect) return;
451443

452-
const checkPostRebootHealth = async () => {
453-
// Don't start a new fetch if one is already in progress
454-
if (isFetchingRef.current) {
455-
return;
456-
}
457-
458-
// Cancel any pending fetch
459-
if (abortControllerRef.current) {
460-
abortControllerRef.current.abort();
461-
}
444+
const checkHealth = async () => {
445+
if (isCheckingRef.current) return;
462446

463-
// Create new abort controller for this fetch
464-
const abortController = new AbortController();
465-
abortControllerRef.current = abortController;
466-
isFetchingRef.current = true;
447+
abortControllerRef.current?.abort();
448+
const controller = new AbortController();
449+
abortControllerRef.current = controller;
450+
isCheckingRef.current = true;
467451

468-
console.log('Checking post-reboot health endpoint:', postRebootAction.healthCheck);
469-
const timeoutId = window.setTimeout(() => abortController.abort(), 2000);
452+
const timeout = setTimeout(() => controller.abort(), 2000);
470453
try {
471-
const response = await fetch(
472-
postRebootAction.healthCheck,
473-
{ signal: abortController.signal, }
474-
);
475-
476-
if (response.ok) {
477-
// Device is available, redirect to the specified URL
478-
console.log('Device is available, redirecting to:', postRebootAction.redirectTo);
479-
480-
// URL constructor handles all cases elegantly:
481-
// - Absolute paths: resolved against current origin
482-
// - Protocol-relative URLs: resolved with current protocol
483-
// - Fully qualified URLs: used as-is
484-
const targetUrl = new URL(postRebootAction.redirectTo, window.location.origin);
485-
clearInterval(intervalId); // Stop polling before redirect
486-
487-
window.location.href = targetUrl.href;
488-
// Add 1s delay between setting location.href and calling reload() to prevent reload from interrupting the navigation.
489-
await sleep(1000);
490-
window.location.reload();
454+
// URL constructor handles relative paths, protocol-relative URLs, and absolute URLs
455+
// Relative path → resolves against origin
456+
// new URL("/device/status", "http://192.168.1.77").href
457+
// // → "http://192.168.1.77/device/status"
458+
459+
// // Protocol-relative URL → uses protocol from base, host from URL
460+
// new URL("//192.168.1.100/device/status", "http://192.168.1.77").href
461+
// // → "http://192.168.1.100/device/status"
462+
463+
// // Fully qualified URL → base is ignored entirely
464+
// new URL("http://192.168.1.100/device/status", "http://192.168.1.77").href
465+
// // → "http://192.168.1.100/device/status"
466+
const healthUrl = new URL(postRebootAction.healthCheck, window.location.origin).href;
467+
const res = await fetch(healthUrl, { signal: controller.signal });
468+
if (res.ok) {
469+
clearInterval(intervalId);
470+
const targetUrl = new URL(postRebootAction.redirectTo, window.location.origin).href;
471+
await redirectTo(targetUrl);
491472
}
492473
} catch (err) {
493-
// Ignore errors - they're expected while device is rebooting
494-
// Only log if it's not an abort error
495-
if (err instanceof Error && err.name !== 'AbortError') {
496-
console.debug('Error checking post-reboot health:', err);
474+
if (err instanceof Error && err.name !== "AbortError") {
475+
console.debug("Health check failed:", err.message);
497476
}
498477
} finally {
499-
clearTimeout(timeoutId);
500-
isFetchingRef.current = false;
478+
clearTimeout(timeout);
479+
isCheckingRef.current = false;
501480
}
502481
};
503482

504-
// Start interval (check every 2 seconds)
505-
const intervalId = setInterval(checkPostRebootHealth, 2000);
506-
507-
// Also check immediately
508-
checkPostRebootHealth();
483+
const intervalId = setInterval(checkHealth, 2000);
484+
checkHealth();
509485

510-
// Cleanup on unmount or when dependencies change
511486
return () => {
512487
clearInterval(intervalId);
513-
if (abortControllerRef.current) {
514-
abortControllerRef.current.abort();
488+
abortControllerRef.current?.abort();
489+
isCheckingRef.current = false;
490+
};
491+
}, [show, postRebootAction, hasSeenDisconnect, redirectTo]);
492+
493+
// Cloud mode: wait for WebRTC reconnection via RPC, then redirect with versioned URL
494+
useEffect(() => {
495+
if (isOnDevice) return;
496+
if (!postRebootAction || !deviceId || !show || !hasSeenDisconnect) return;
497+
498+
let cancelled = false;
499+
500+
const waitForReconnectAndRedirect = async () => {
501+
if (isCheckingRef.current) return;
502+
isCheckingRef.current = true;
503+
504+
try {
505+
const { appVersion } = await getLocalVersion({
506+
attemptTimeoutMs: 2000,
507+
});
508+
509+
if (cancelled) return;
510+
511+
clearInterval(intervalId);
512+
const targetUrl = buildCloudUrl(deviceId, appVersion, postRebootAction.redirectTo);
513+
await redirectTo(targetUrl);
514+
} catch (err) {
515+
console.debug("Cloud reconnect check failed:", err);
516+
isCheckingRef.current = false;
515517
}
516-
isFetchingRef.current = false;
517518
};
518-
}, [show, postRebootAction, hasTimedOut, hasSeenDisconnect]);
519+
520+
const intervalId = setInterval(waitForReconnectAndRedirect, 3000);
521+
waitForReconnectAndRedirect();
522+
523+
return () => {
524+
cancelled = true;
525+
clearInterval(intervalId);
526+
isCheckingRef.current = false;
527+
};
528+
}, [show, postRebootAction, deviceId, hasSeenDisconnect, redirectTo]);
519529

520530
return (
521531
<AnimatePresence>

ui/src/routes/devices.$id.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -718,15 +718,13 @@ export default function KvmIdRoute() {
718718
}
719719

720720
if (resp.method === "willReboot") {
721-
const postRebootAction = resp.params as unknown as PostRebootAction;
722-
console.debug("Setting reboot state", postRebootAction);
723-
721+
const action = resp.params as PostRebootAction | undefined;
724722
setRebootState({
725723
isRebooting: true,
726724
postRebootAction: {
727-
healthCheck: postRebootAction?.healthCheck || `${window.location.origin}/device/status`,
728-
redirectTo: postRebootAction?.redirectTo || window.location.href,
729-
}
725+
healthCheck: action?.healthCheck || "/device/status",
726+
redirectTo: action?.redirectTo || "/",
727+
},
730728
});
731729
navigateTo("/");
732730
}
@@ -842,7 +840,7 @@ export default function KvmIdRoute() {
842840

843841
// Rebooting takes priority over connection status
844842
if (rebootState?.isRebooting) {
845-
return <RebootingOverlay show={true} postRebootAction={rebootState.postRebootAction} />;
843+
return <RebootingOverlay show={true} postRebootAction={rebootState.postRebootAction} deviceId={params.id} />;
846844
}
847845

848846
if (isFailsafeMode && failsafeReason) {
@@ -873,7 +871,7 @@ export default function KvmIdRoute() {
873871
}
874872

875873
return null;
876-
}, [location.pathname, rebootState?.isRebooting, rebootState?.postRebootAction, isFailsafeMode, failsafeReason, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]);
874+
}, [location.pathname, rebootState?.isRebooting, rebootState?.postRebootAction, params.id, isFailsafeMode, failsafeReason, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]);
877875

878876
return (
879877
<FeatureFlagProvider appVersion={appVersion}>

ui/src/utils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import semver from "semver";
2+
13
import { KeySequence } from "@hooks/stores";
24
import { getLocale, locales } from "@localizations/runtime.js";
35
import { m } from "@localizations/messages.js";
6+
import { CLOUD_BACKWARDS_COMPATIBLE_VERSION, CLOUD_ENABLE_VERSIONED_UI } from "@/ui.config";
47

58
const isInvalidDate = (date: Date) => date instanceof Date && isNaN(date.getTime());
69

@@ -303,3 +306,20 @@ export function deleteCookie(name: string, domain?: string, path = "/") {
303306
export function sleep(ms: number): Promise<void> {
304307
return new Promise(resolve => setTimeout(resolve, ms));
305308
}
309+
310+
/**
311+
* Builds a versioned cloud URL for a device.
312+
* Uses the device's app version to construct /v/{version}/devices/{id}{path}
313+
* Falls back to CLOUD_BACKWARDS_COMPATIBLE_VERSION for older or invalid versions.
314+
*/
315+
export function buildCloudUrl(deviceId: string, appVersion: string | undefined, path = ""): string {
316+
let uri = `/devices/${deviceId}${path}`;
317+
if (CLOUD_ENABLE_VERSIONED_UI) {
318+
const version =
319+
appVersion && semver.valid(appVersion) && semver.gte(appVersion, CLOUD_BACKWARDS_COMPATIBLE_VERSION)
320+
? appVersion
321+
: CLOUD_BACKWARDS_COMPATIBLE_VERSION;
322+
uri = `/v/${version}${uri}`;
323+
}
324+
return new URL(uri, window.location.origin).href;
325+
}

ui/src/utils/jsonrpc.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,13 @@ export async function getUpdateStatus() {
242242
return response.result;
243243
}
244244

245-
export async function getLocalVersion() {
246-
const response = await callJsonRpc<VersionInfo>({ method: "getLocalVersion" });
245+
export async function getLocalVersion(
246+
options?: Partial<JsonRpcCallOptions>,
247+
): Promise<VersionInfo> {
248+
const response = await callJsonRpc<VersionInfo>({
249+
method: "getLocalVersion",
250+
...options,
251+
});
247252
if (response.error) throw response.error;
248253
return response.result;
249254
}
@@ -255,7 +260,10 @@ export interface updateParams {
255260
components?: UpdateComponents;
256261
}
257262

258-
export async function checkUpdateComponents(params: updateParams, includePreRelease: boolean) {
263+
export async function checkUpdateComponents(
264+
params: updateParams,
265+
includePreRelease: boolean,
266+
) {
259267
const response = await callJsonRpc<SystemVersionInfo>({
260268
method: "checkUpdateComponents",
261269
params: {
@@ -270,4 +278,4 @@ export async function checkUpdateComponents(params: updateParams, includePreRele
270278
});
271279
if (response.error) throw response.error;
272280
return response.result;
273-
}
281+
}

0 commit comments

Comments
 (0)