Compare commits

..

14 Commits

Author SHA1 Message Date
Siyuan 6a2a33b00a fix: ignore DHCP lease if not in DHCP mode 2025-10-14 15:07:23 +00:00
Adam Shiervani c1f1da2640 fix: ui tweak 2025-10-14 16:57:57 +02:00
Adam Shiervani 008460d899 fix: improve layout of critical changes display in network settings 2025-10-14 16:51:47 +02:00
Adam Shiervani e7afa1206c fix: update expected reboot duration message in RebootingOverlay component 2025-10-14 16:50:51 +02:00
Adam Shiervani 29b0ac778a fix: correct websocket reconnection logic for legacy signaling 2025-10-14 16:50:36 +02:00
Adam Shiervani 1cf7dd4be9 feat: enhance network change handling and reboot logic 2025-10-14 16:18:33 +02:00
Adam Shiervani 895cd5cc39 feat: add CORS support for device status endpoint 2025-10-14 16:17:43 +02:00
Adam Shiervani 53556cb64c feat: add JSONRPC event for reboot notification 2025-10-14 16:17:32 +02:00
Adam Shiervani 80ae3f7fb3 Improve network setting UI 2025-10-14 16:01:43 +02:00
Adam Shiervani f24ca5136e refactor: clean up reboot route 2025-10-14 15:56:32 +02:00
Adam Shiervani b3141e02ad feat: add reboot state management to UI store 2025-10-14 15:54:54 +02:00
Adam Shiervani 63d1a68d99 feat: hide video if there are connectionIssues 2025-10-14 15:53:36 +02:00
Adam Shiervani 6ff5fb7583 feat: implement RebootingOverlay component to handle device reboot 2025-10-14 15:52:55 +02:00
Adam Shiervani 710f082b15 refactor: update ConfirmDialog component styles and icons 2025-10-14 15:50:34 +02:00
11 changed files with 430 additions and 121 deletions

View File

@ -175,6 +175,10 @@ func rpcGetDeviceID() (string, error) {
func rpcReboot(force bool) error {
logger.Info().Msg("Got reboot request from JSONRPC, rebooting...")
writeJSONRPCEvent("willReboot", nil, currentSession)
// Wait for the JSONRPCEvent to be sent
time.Sleep(1 * time.Second)
nativeInstance.SwitchToScreenIfDifferent("rebooting_screen")
args := []string{}

View File

@ -3,6 +3,7 @@ package kvm
import (
"context"
"fmt"
"reflect"
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/mdns"
@ -170,6 +171,54 @@ func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
return nm.SetHostname(hostname, domain)
}
func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (bool, *string) {
var rebootRequired bool
var suggestedIp *string
oldDhcpClient := oldConfig.DHCPClient.String
// DHCP client change always requires reboot
if newConfig.DHCPClient.String != oldDhcpClient {
rebootRequired = true
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" {
rebootRequired = true
networkLogger.Info().Str("old", oldConfig.IPv4Mode.String).Str("new", newConfig.IPv4Mode.String).Msg("IPv4 mode changed with udhcpc, reboot required")
}
// IPv4 static config changes require reboot
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")
}
if newConfig.IPv4Static.Netmask.String != oldConfig.IPv4Static.Netmask.String {
rebootRequired = true
networkLogger.Info().Str("old", oldConfig.IPv4Static.Netmask.String).Str("new", newConfig.IPv4Static.Netmask.String).Msg("IPv4 netmask changed, reboot required")
}
if newConfig.IPv4Static.Gateway.String != oldConfig.IPv4Static.Gateway.String {
rebootRequired = true
networkLogger.Info().Str("old", oldConfig.IPv4Static.Gateway.String).Str("new", newConfig.IPv4Static.Gateway.String).Msg("IPv4 gateway changed, reboot required")
}
if !reflect.DeepEqual(newConfig.IPv4Static.DNS, oldConfig.IPv4Static.DNS) {
rebootRequired = true
networkLogger.Info().Strs("old", oldConfig.IPv4Static.DNS).Strs("new", newConfig.IPv4Static.DNS).Msg("IPv4 DNS changed, reboot required")
}
}
// IPv6 mode change requires reboot when using udhcpc
if newConfig.IPv6Mode.String != oldConfig.IPv6Mode.String && oldDhcpClient == "udhcpc" {
rebootRequired = true
networkLogger.Info().Str("old", oldConfig.IPv6Mode.String).Str("new", newConfig.IPv6Mode.String).Msg("IPv6 mode changed with udhcpc, reboot required")
}
return rebootRequired, suggestedIp
}
func rpcGetNetworkState() *types.RpcInterfaceState {
state, _ := networkManager.GetInterfaceState(NetIfName)
return state.ToRpcInterfaceState()
@ -189,9 +238,13 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er
l.Debug().Msg("setting new config")
var rebootRequired bool
if netConfig.DHCPClient.String != config.NetworkConfig.DHCPClient.String {
rebootRequired = true
// Check if reboot is needed
rebootRequired, suggestedIp := 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)
}
_ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String)
@ -238,5 +291,5 @@ func rpcToggleDHCPClient() error {
return err
}
return rpcReboot(false)
return rpcReboot(true)
}

View File

@ -90,6 +90,11 @@ func NewInterfaceManager(ctx context.Context, ifaceName string, config *types.Ne
// Set up DHCP client callbacks
im.dhcpClient.SetOnLeaseChange(func(lease *types.DHCPLease) {
if im.config.IPv4Mode.String != "dhcp" {
im.logger.Warn().Str("mode", im.config.IPv4Mode.String).Msg("ignoring DHCP lease, current mode is not DHCP")
return
}
if err := im.applyDHCPLease(lease); err != nil {
im.logger.Error().Err(err).Msg("failed to apply DHCP lease")
}

View File

@ -1,10 +1,10 @@
import { CheckCircleIcon } from "@heroicons/react/24/outline";
import { CloseButton } from "@headlessui/react";
import { LuInfo, LuOctagonAlert, LuTriangleAlert } from "react-icons/lu";
import { LuCircleAlert, LuInfo, LuTriangleAlert } from "react-icons/lu";
import { Button } from "@/components/Button";
import Modal from "@/components/Modal";
import { cx } from "@/cva.config";
type Variant = "danger" | "success" | "warning" | "info";
interface ConfirmDialogProps {
@ -21,27 +21,23 @@ interface ConfirmDialogProps {
const variantConfig = {
danger: {
icon: LuOctagonAlert,
iconClass: "text-red-600",
iconBgClass: "bg-red-100 border border-red-500/90",
icon: LuCircleAlert,
iconClass: "text-red-600 dark:text-red-400",
buttonTheme: "danger",
},
success: {
icon: CheckCircleIcon,
iconClass: "text-green-600",
iconBgClass: "bg-green-100 border border-green-500/90",
icon: LuCircleAlert,
iconClass: "text-emerald-600 dark:text-emerald-400",
buttonTheme: "primary",
},
warning: {
icon: LuTriangleAlert,
iconClass: "text-yellow-600",
iconBgClass: "bg-yellow-100 border border-yellow-500/90",
iconClass: "text-amber-600 dark:text-amber-400",
buttonTheme: "primary",
},
info: {
icon: LuInfo,
iconClass: "text-blue-600",
iconBgClass: "bg-blue-100 border border-blue-500/90",
iconClass: "text-slate-700 dark:text-slate-300",
buttonTheme: "primary",
},
} as Record<
@ -49,7 +45,6 @@ const variantConfig = {
{
icon: React.ElementType;
iconClass: string;
iconBgClass: string;
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
}
>;
@ -65,7 +60,7 @@ export function ConfirmDialog({
onConfirm,
isConfirming = false,
}: ConfirmDialogProps) {
const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant];
const { icon: Icon, iconClass, buttonTheme } = variantConfig[variant];
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
@ -77,29 +72,22 @@ export function ConfirmDialog({
return (
<div onKeyDown={handleKeyDown}>
<Modal open={open} onClose={onClose}>
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
<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="space-y-4">
<div className="sm:flex sm:items-start">
<div
className={cx(
"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>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h2 className="text-lg leading-tight font-bold text-black dark:text-white">
<div className="mx-auto max-w-md px-4 transition-all duration-300 ease-in-out">
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm transition-all dark:border-slate-800 dark:bg-slate-900">
<div className="p-6">
<div className="flex items-start gap-3.5">
<Icon aria-hidden="true" className={cx("size-[18px] shrink-0 mt-[2px]", iconClass)} />
<div className="flex-1 min-w-0 space-y-2">
<h2 className="font-semibold text-slate-950 dark:text-white">
{title}
</h2>
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">
<div className="text-sm text-slate-700 dark:text-slate-300">
{description}
</div>
</div>
</div>
<div className="flex justify-end gap-x-2" autoFocus>
<div className="mt-6 flex justify-end gap-2">
{cancelText && (
<CloseButton as={Button} size="SM" theme="blank" text={cancelText} />
)}

View File

@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect, useState, useRef } from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion";
@ -8,6 +8,11 @@ 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 LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg";
import { isOnDevice } from "@/main";
interface OverlayContentProps {
readonly children: React.ReactNode;
@ -392,3 +397,197 @@ export function PointerLockBar({ show }: PointerLockBarProps) {
</AnimatePresence>
);
}
interface RebootingOverlayProps {
readonly show: boolean;
readonly suggestedIp: string | null;
}
export function RebootingOverlay({ show, suggestedIp }: RebootingOverlayProps) {
const { peerConnectionState } = useRTCStore();
// Check if we've already seen the connection drop (confirms reboot actually started)
const [hasSeenDisconnect, setHasSeenDisconnect] = useState(
['disconnected', 'closed', 'failed'].includes(peerConnectionState ?? '')
);
// Track if we've timed out
const [hasTimedOut, setHasTimedOut] = useState(false);
// Monitor for disconnect after reboot is initiated
useEffect(() => {
if (!show) return;
if (hasSeenDisconnect) return;
if (['disconnected', 'closed', 'failed'].includes(peerConnectionState ?? '')) {
console.log('hasSeenDisconnect', hasSeenDisconnect);
setHasSeenDisconnect(true);
}
}, [show, peerConnectionState, hasSeenDisconnect]);
// Set timeout after 30 seconds
useEffect(() => {
if (!show) {
setHasTimedOut(false);
return;
}
const timeoutId = setTimeout(() => {
setHasTimedOut(true);
}, 30 * 1000);
return () => {
clearTimeout(timeoutId);
};
}, [show]);
// Poll suggested IP in device mode to detect when it's available
const abortControllerRef = useRef<AbortController | null>(null);
const isFetchingRef = useRef(false);
useEffect(() => {
// Only run in device mode with a suggested IP
if (!isOnDevice || !suggestedIp || !show || !hasSeenDisconnect) {
return;
}
const checkSuggestedIp = async () => {
// Don't start a new fetch if one is already in progress
if (isFetchingRef.current) {
return;
}
// Cancel any pending fetch
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller for this fetch
const abortController = new AbortController();
abortControllerRef.current = abortController;
isFetchingRef.current = true;
console.log('Checking suggested IP:', suggestedIp);
const timeoutId = window.setTimeout(() => abortController.abort(), 2000);
try {
const response = await fetch(
`${window.location.protocol}//${suggestedIp}/device/status`,
{
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}`;
}
} 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);
}
} finally {
clearTimeout(timeoutId);
isFetchingRef.current = false;
}
};
// Start interval (check every 2 seconds)
const intervalId = setInterval(checkSuggestedIp, 2000);
// Also check immediately
checkSuggestedIp();
// Cleanup on unmount or when dependencies change
return () => {
clearInterval(intervalId);
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
isFetchingRef.current = false;
};
}, [show, suggestedIp, hasTimedOut, hasSeenDisconnect]);
return (
<AnimatePresence>
{show && (
<motion.div
className="aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={{
duration: 0.4,
ease: "easeInOut",
}}
>
<OverlayContent>
<div className="flex flex-col items-start gap-y-4 w-full max-w-md">
<div className="h-[24px]">
<img src={LogoBlue} alt="" className="h-full dark:hidden" />
<img src={LogoWhite} alt="" className="hidden h-full dark:block" />
</div>
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">{hasTimedOut ? "Unable to Reconnect" : "Device is Rebooting"}</h2>
<p className="text-sm text-slate-700 dark:text-slate-300">
{hasTimedOut ? (
<>
The device may have restarted with a different IP address. Check the JetKVM&apos;s physical display to find the current IP address and reconnect.
</>
) : (
<>
Please wait while the device restarts. This usually takes 20-30 seconds.
{suggestedIp && (
<>
{" "}If reconnection fails, the device may be at{" "}
<a
href={`${window.location.protocol}//${suggestedIp}`}
className="font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{suggestedIp}
</a>
.
</>
)}
</>
)}
</p>
</div>
<div className="flex items-center gap-x-2">
<Card>
<div className="flex items-center gap-x-2 p-4">
{!hasTimedOut ? (
<>
<LoadingSpinner className="h-4 w-4 text-blue-800 dark:text-blue-200" />
<p className="text-sm text-slate-700 dark:text-slate-300">
Waiting for device to restart...
</p>
</>
) : (
<div className="flex flex-col gap-y-2">
<div className="flex items-center gap-x-2">
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />
<p className="text-sm text-black dark:text-white">
Automatic Reconnection Timed Out
</p>
</div>
</div>
)}
</div>
</Card>
</div>
</div>
</div>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -23,7 +23,7 @@ import {
PointerLockBar,
} from "./VideoOverlay";
export default function WebRTCVideo() {
export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssues: boolean }) {
// Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null);
const { mediaStream, peerConnectionState } = useRTCStore();
@ -527,9 +527,10 @@ export default function WebRTCVideo() {
"max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
{
"cursor-none": settings.isCursorHidden,
"opacity-0":
"!opacity-0":
isVideoLoading ||
hdmiError ||
hasConnectionIssues ||
peerConnectionState !== "connected",
"opacity-60!": showPointerLockBar,
"animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20":

View File

@ -69,11 +69,16 @@ export interface UIState {
terminalType: AvailableTerminalTypes;
setTerminalType: (type: UIState["terminalType"]) => void;
rebootState: { isRebooting: boolean; suggestedIp: string | null } | null;
setRebootState: (
state: { isRebooting: boolean; suggestedIp: string | null } | null,
) => void;
}
export const useUiStore = create<UIState>(set => ({
terminalType: "none",
setTerminalType: (type: UIState["terminalType"]) => set({ terminalType: type }),
setTerminalType: (type: UIState["terminalType"]) => set({ terminalType: type }),
sidebarView: null,
setSidebarView: (view: AvailableSidebarViews | null) => set({ sidebarView: view }),
@ -82,7 +87,8 @@ export const useUiStore = create<UIState>(set => ({
setDisableVideoFocusTrap: (enabled: boolean) => set({ disableVideoFocusTrap: enabled }),
isWakeOnLanModalVisible: false,
setWakeOnLanModalVisibility: (enabled: boolean) => set({ isWakeOnLanModalVisible: enabled }),
setWakeOnLanModalVisibility: (enabled: boolean) =>
set({ isWakeOnLanModalVisible: enabled }),
toggleSidebarView: view =>
set(state => {
@ -96,6 +102,9 @@ export const useUiStore = create<UIState>(set => ({
isAttachedVirtualKeyboardVisible: true,
setAttachedVirtualKeyboardVisibility: (enabled: boolean) =>
set({ isAttachedVirtualKeyboardVisible: enabled }),
rebootState: null,
setRebootState: state => set({ rebootState: state }),
}));
export interface RTCState {

View File

@ -9,13 +9,9 @@ export default function SettingsGeneralRebootRoute() {
const { send } = useJsonRpc();
const onConfirmUpdate = useCallback(() => {
// This is where we send the RPC to the golang binary
send("reboot", {force: true});
send("reboot", { force: true});
}, [send]);
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
}

View File

@ -186,31 +186,71 @@ export default function SettingsNetworkRoute() {
const settings = prepareSettings(data);
const dirty = formState.dirtyFields;
// These fields will prompt a confirm dialog, all else save immediately
const criticalFields = [
// Label is for the UI, key is the internal key of the field
{ label: "IPv4 mode", key: "ipv4_mode" },
{ label: "IPv6 mode", key: "ipv6_mode" },
{ label: "DHCP client", key: "dhcp_client" },
] as { label: string; key: keyof NetworkSettings }[];
// Build list of critical changes for display
const changes: { label: string; from: string; to: string }[] = [];
const criticalChanged = criticalFields.some(field => dirty[field.key]);
if (dirty.dhcp_client) {
changes.push({
label: "DHCP client",
from: initialSettingsRef.current?.dhcp_client as string,
to: data.dhcp_client as string,
});
}
if (dirty.ipv4_mode) {
changes.push({
label: "IPv4 mode",
from: initialSettingsRef.current?.ipv4_mode as string,
to: data.ipv4_mode as string,
});
}
if (dirty.ipv4_static?.address) {
changes.push({
label: "IPv4 address",
from: initialSettingsRef.current?.ipv4_static?.address as string,
to: data.ipv4_static?.address as string,
});
}
if (dirty.ipv4_static?.netmask) {
changes.push({
label: "IPv4 netmask",
from: initialSettingsRef.current?.ipv4_static?.netmask as string,
to: data.ipv4_static?.netmask as string,
});
}
if (dirty.ipv4_static?.gateway) {
changes.push({
label: "IPv4 gateway",
from: initialSettingsRef.current?.ipv4_static?.gateway as string,
to: data.ipv4_static?.gateway as string,
});
}
if (dirty.ipv4_static?.dns) {
changes.push({
label: "IPv4 DNS",
from: initialSettingsRef.current?.ipv4_static?.dns.join(", ").toString() ?? "",
to: data.ipv4_static?.dns.join(", ").toString() ?? "",
});
}
if (dirty.ipv6_mode) {
changes.push({
label: "IPv6 mode",
from: initialSettingsRef.current?.ipv6_mode as string,
to: data.ipv6_mode as string,
});
}
// If no critical fields are changed, save immediately
if (!criticalChanged) return onSubmit(settings);
const changes = new Set<{ label: string; from: string; to: string }>();
criticalFields.forEach(field => {
const { key, label } = field;
if (dirty[key]) {
const from = initialSettingsRef?.current?.[key] as string;
const to = data[key] as string;
changes.add({ label, from, to });
}
});
if (changes.length === 0) return onSubmit(settings);
// Show confirmation dialog for critical changes
setStagedSettings(settings);
setCriticalChanges(Array.from(changes));
setCriticalChanges(changes);
setShowCriticalSettingsConfirm(true);
};
@ -263,7 +303,7 @@ export default function SettingsNetworkRoute() {
{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 () => {
<Button className="rounded-l-none border-l-slate-800/30 dark:border-slate-300/20" size="SM" type="button" theme="light" LeadingIcon={LuCopy} onClick={async () => {
if (await copy(networkState?.mac_address || "")) {
notifications.success("MAC address copied to clipboard");
} else {
@ -356,7 +396,7 @@ export default function SettingsNetworkRoute() {
/>
</SettingsItem>
<SettingsItem title="DHCP client" description="Configure which DHCP client to use (reboot required)">
<SettingsItem title="DHCP client" description="Configure which DHCP client to use">
<SelectMenuBasic
size="SM"
options={[
@ -484,52 +524,41 @@ export default function SettingsNetworkRoute() {
}, 500);
}}
onClose={() => {
// close();
setShowCriticalSettingsConfirm(false);
}}
isConfirming={formState.isSubmitting}
description={
<div className="space-y-4">
<p>
This will update the device&apos;s network configuration and may briefly
disconnect your session.
</p>
<div>
<p className="text-sm leading-relaxed text-slate-700 dark:text-slate-300">
The following network settings will be applied. These changes may require a reboot and cause brief disconnection.
</p>
</div>
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 dark:border-slate-700 dark:bg-slate-900/40">
<div className="mb-2 text-xs font-semibold tracking-wide text-slate-500 uppercase dark:text-slate-400">
Pending changes
<div className="space-y-2.5">
<div className="flex items-center justify-between text-[13px] font-medium text-slate-900 dark:text-white">
Configuration changes
</div>
<dl className="grid grid-cols-1 gap-y-2">
<div className="space-y-2.5">
{criticalChanges.map((c, idx) => (
<div key={idx} className="w-full not-last:pb-2">
<div className="flex items-center gap-2 gap-x-8">
<dt className="text-sm text-slate-500 dark:text-slate-400">
{c.label}
</dt>
<div className="flex items-center gap-2">
<span className="rounded-sm bg-slate-200 px-1.5 py-0.5 text-sm font-medium text-slate-900 dark:bg-slate-700 dark:text-slate-100">
{c.from || "—"}
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
</span>
<span className="rounded-sm bg-slate-200 px-1.5 py-0.5 text-sm font-medium text-slate-900 dark:bg-slate-700 dark:text-slate-100">
{c.to}
</span>
</div>
<div key={idx + c.label} className="flex items-center gap-x-2 gap-y-1 flex-wrap bg-slate-100/50 dark:bg-slate-800/50 border border-slate-800/10 dark:border-slate-300/20 rounded-md py-2 px-3">
<span className="text-xs text-slate-600 dark:text-slate-400">{c.label}</span>
<div className="flex items-center gap-2.5">
<code className="rounded border border-slate-800/20 bg-slate-50 px-1.5 py-1 text-xs text-black font-mono dark:border-slate-300/20 dark:bg-slate-800 dark:text-slate-100">
{c.from || "—"}
</code>
<svg className="size-3.5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<code className="rounded border border-slate-800/20 bg-slate-50 px-1.5 py-1 text-xs text-black font-mono dark:border-slate-300/20 dark:bg-slate-800 dark:text-slate-100">
{c.to}
</code>
</div>
</div>
))}
</dl>
</div>
</div>
<p className="text-sm">
If the network settings are invalid,{" "}
<strong>the device may become unreachable</strong> and require a factory
reset to restore connectivity.
</p>
</div>
}
/>

View File

@ -45,6 +45,7 @@ import {
ConnectionFailedOverlay,
LoadingConnectionOverlay,
PeerConnectionDisconnectedOverlay,
RebootingOverlay,
} from "@/components/VideoOverlay";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
@ -122,7 +123,7 @@ export default function KvmIdRoute() {
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
const params = useParams() as { id: string };
const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore();
const { sidebarView, setSidebarView, disableVideoFocusTrap, rebootState, setRebootState } = useUiStore();
const [queryParams, setQueryParams] = useSearchParams();
const {
@ -241,7 +242,7 @@ export default function KvmIdRoute() {
{
heartbeat: true,
retryOnError: true,
reconnectAttempts: 15,
reconnectAttempts: 2000,
reconnectInterval: 1000,
onReconnectStop: () => {
console.debug("Reconnect stopped");
@ -250,8 +251,7 @@ export default function KvmIdRoute() {
shouldReconnect(event) {
console.debug("[Websocket] shouldReconnect", event);
// TODO: Why true?
return true;
return !isLegacySignalingEnabled.current;
},
onClose(event) {
@ -265,6 +265,16 @@ export default function KvmIdRoute() {
},
onOpen() {
console.debug("[Websocket] onOpen");
// We want to clear the reboot state when the websocket connection is opened
// Currently the flow is:
// 1. User clicks reboot
// 2. Device sends event 'willReboot'
// 3. We set the reboot state
// 4. Reboot modal is shown
// 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 });
},
onMessage: message => {
@ -340,10 +350,7 @@ export default function KvmIdRoute() {
peerConnection.addIceCandidate(candidate);
}
},
},
// Don't even retry once we declare failure
!connectionFailed && isLegacySignalingEnabled.current === false,
}
);
const sendWebRTCSignal = useCallback(
@ -594,6 +601,8 @@ export default function KvmIdRoute() {
api.POST(`${CLOUD_API}/webrtc/turn_activity`, {
bytesReceived: bytesReceivedDelta,
bytesSent: bytesSentDelta,
}).catch(() => {
// we don't care about errors here, but we don't want unhandled promise rejections
});
}, 10000);
@ -666,6 +675,12 @@ export default function KvmIdRoute() {
window.location.href = currentUrl.toString();
}
}
if (resp.method === "willReboot") {
const suggestedIp = resp.params as unknown as string | null;
setRebootState({ isRebooting: true, suggestedIp });
navigateTo("/");
}
}
const { send } = useJsonRpc(onJsonRpcRequest);
@ -765,6 +780,14 @@ export default function KvmIdRoute() {
}, [appVersion, getLocalVersion]);
const ConnectionStatusElement = useMemo(() => {
const isOtherSession = location.pathname.includes("other-session");
if (isOtherSession) return null;
// Rebooting takes priority over connection status
if (rebootState?.isRebooting) {
return <RebootingOverlay show={true} suggestedIp={rebootState.suggestedIp} />;
}
const hasConnectionFailed =
connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? "");
@ -774,9 +797,6 @@ export default function KvmIdRoute() {
const isDisconnected = peerConnectionState === "disconnected";
const isOtherSession = location.pathname.includes("other-session");
if (isOtherSession) return null;
if (peerConnectionState === "connected") return null;
if (isDisconnected) {
return <PeerConnectionDisconnectedOverlay show={true} />;
@ -792,14 +812,7 @@ export default function KvmIdRoute() {
}
return null;
}, [
connectionFailed,
loadingMessage,
location.pathname,
peerConnection,
peerConnectionState,
setupPeerConnection,
]);
}, [location.pathname, rebootState?.isRebooting, rebootState?.suggestedIp, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]);
return (
<FeatureFlagProvider appVersion={appVersion}>
@ -841,7 +854,7 @@ export default function KvmIdRoute() {
/>
<div className="relative flex h-full w-full overflow-hidden">
<WebRTCVideo />
<WebRTCVideo hasConnectionIssues={!!ConnectionStatusElement} />
<div
style={{ animationDuration: "500ms" }}
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"

12
web.go
View File

@ -725,6 +725,18 @@ func handleDeletePassword(c *gin.Context) {
}
func handleDeviceStatus(c *gin.Context) {
// Add CORS headers to allow cross-origin requests
// This is safe because device/status is a public endpoint
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type")
// Handle preflight requests
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
response := DeviceStatus{
IsSetup: config.LocalAuthMode != "",
}