mirror of https://github.com/jetkvm/kvm.git
Compare commits
15 Commits
2f8af11917
...
9e6a9c1fc8
| Author | SHA1 | Date |
|---|---|---|
|
|
9e6a9c1fc8 | |
|
|
6a2a33b00a | |
|
|
c1f1da2640 | |
|
|
008460d899 | |
|
|
e7afa1206c | |
|
|
29b0ac778a | |
|
|
1cf7dd4be9 | |
|
|
895cd5cc39 | |
|
|
53556cb64c | |
|
|
80ae3f7fb3 | |
|
|
f24ca5136e | |
|
|
b3141e02ad | |
|
|
63d1a68d99 | |
|
|
6ff5fb7583 | |
|
|
710f082b15 |
|
|
@ -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{}
|
||||
|
|
|
|||
61
network.go
61
network.go
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
12
web.go
|
|
@ -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 != "",
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue