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 {
|
func rpcReboot(force bool) error {
|
||||||
logger.Info().Msg("Got reboot request from JSONRPC, rebooting...")
|
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")
|
nativeInstance.SwitchToScreenIfDifferent("rebooting_screen")
|
||||||
|
|
||||||
args := []string{}
|
args := []string{}
|
||||||
|
|
|
||||||
61
network.go
61
network.go
|
|
@ -3,6 +3,7 @@ package kvm
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
"github.com/jetkvm/kvm/internal/confparser"
|
"github.com/jetkvm/kvm/internal/confparser"
|
||||||
"github.com/jetkvm/kvm/internal/mdns"
|
"github.com/jetkvm/kvm/internal/mdns"
|
||||||
|
|
@ -170,6 +171,54 @@ func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
|
||||||
return nm.SetHostname(hostname, domain)
|
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 {
|
func rpcGetNetworkState() *types.RpcInterfaceState {
|
||||||
state, _ := networkManager.GetInterfaceState(NetIfName)
|
state, _ := networkManager.GetInterfaceState(NetIfName)
|
||||||
return state.ToRpcInterfaceState()
|
return state.ToRpcInterfaceState()
|
||||||
|
|
@ -189,9 +238,13 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er
|
||||||
|
|
||||||
l.Debug().Msg("setting new config")
|
l.Debug().Msg("setting new config")
|
||||||
|
|
||||||
var rebootRequired bool
|
// Check if reboot is needed
|
||||||
if netConfig.DHCPClient.String != config.NetworkConfig.DHCPClient.String {
|
rebootRequired, suggestedIp := shouldRebootForNetworkChange(config.NetworkConfig, netConfig)
|
||||||
rebootRequired = true
|
|
||||||
|
// 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)
|
_ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String)
|
||||||
|
|
@ -238,5 +291,5 @@ func rpcToggleDHCPClient() error {
|
||||||
return err
|
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
|
// Set up DHCP client callbacks
|
||||||
im.dhcpClient.SetOnLeaseChange(func(lease *types.DHCPLease) {
|
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 {
|
if err := im.applyDHCPLease(lease); err != nil {
|
||||||
im.logger.Error().Err(err).Msg("failed to apply DHCP lease")
|
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 { 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 { Button } from "@/components/Button";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
type Variant = "danger" | "success" | "warning" | "info";
|
type Variant = "danger" | "success" | "warning" | "info";
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
|
|
@ -21,27 +21,23 @@ interface ConfirmDialogProps {
|
||||||
|
|
||||||
const variantConfig = {
|
const variantConfig = {
|
||||||
danger: {
|
danger: {
|
||||||
icon: LuOctagonAlert,
|
icon: LuCircleAlert,
|
||||||
iconClass: "text-red-600",
|
iconClass: "text-red-600 dark:text-red-400",
|
||||||
iconBgClass: "bg-red-100 border border-red-500/90",
|
|
||||||
buttonTheme: "danger",
|
buttonTheme: "danger",
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
icon: CheckCircleIcon,
|
icon: LuCircleAlert,
|
||||||
iconClass: "text-green-600",
|
iconClass: "text-emerald-600 dark:text-emerald-400",
|
||||||
iconBgClass: "bg-green-100 border border-green-500/90",
|
|
||||||
buttonTheme: "primary",
|
buttonTheme: "primary",
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
icon: LuTriangleAlert,
|
icon: LuTriangleAlert,
|
||||||
iconClass: "text-yellow-600",
|
iconClass: "text-amber-600 dark:text-amber-400",
|
||||||
iconBgClass: "bg-yellow-100 border border-yellow-500/90",
|
|
||||||
buttonTheme: "primary",
|
buttonTheme: "primary",
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
icon: LuInfo,
|
icon: LuInfo,
|
||||||
iconClass: "text-blue-600",
|
iconClass: "text-slate-700 dark:text-slate-300",
|
||||||
iconBgClass: "bg-blue-100 border border-blue-500/90",
|
|
||||||
buttonTheme: "primary",
|
buttonTheme: "primary",
|
||||||
},
|
},
|
||||||
} as Record<
|
} as Record<
|
||||||
|
|
@ -49,7 +45,6 @@ const variantConfig = {
|
||||||
{
|
{
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
iconClass: string;
|
iconClass: string;
|
||||||
iconBgClass: string;
|
|
||||||
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
|
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
@ -65,7 +60,7 @@ export function ConfirmDialog({
|
||||||
onConfirm,
|
onConfirm,
|
||||||
isConfirming = false,
|
isConfirming = false,
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant];
|
const { icon: Icon, iconClass, buttonTheme } = variantConfig[variant];
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
|
|
@ -77,29 +72,22 @@ export function ConfirmDialog({
|
||||||
return (
|
return (
|
||||||
<div onKeyDown={handleKeyDown}>
|
<div onKeyDown={handleKeyDown}>
|
||||||
<Modal open={open} onClose={onClose}>
|
<Modal open={open} onClose={onClose}>
|
||||||
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
|
<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 bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800">
|
<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="space-y-4">
|
<div className="p-6">
|
||||||
<div className="sm:flex sm:items-start">
|
<div className="flex items-start gap-3.5">
|
||||||
<div
|
<Icon aria-hidden="true" className={cx("size-[18px] shrink-0 mt-[2px]", iconClass)} />
|
||||||
className={cx(
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
"mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10",
|
<h2 className="font-semibold text-slate-950 dark:text-white">
|
||||||
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">
|
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</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}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-x-2" autoFocus>
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
{cancelText && (
|
{cancelText && (
|
||||||
<CloseButton as={Button} size="SM" theme="blank" text={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 { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||||
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
|
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
@ -8,6 +8,11 @@ import { BsMouseFill } from "react-icons/bs";
|
||||||
import { Button, LinkButton } from "@components/Button";
|
import { Button, LinkButton } from "@components/Button";
|
||||||
import LoadingSpinner from "@components/LoadingSpinner";
|
import LoadingSpinner from "@components/LoadingSpinner";
|
||||||
import Card, { GridCard } from "@components/Card";
|
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 {
|
interface OverlayContentProps {
|
||||||
readonly children: React.ReactNode;
|
readonly children: React.ReactNode;
|
||||||
|
|
@ -392,3 +397,197 @@ export function PointerLockBar({ show }: PointerLockBarProps) {
|
||||||
</AnimatePresence>
|
</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,
|
PointerLockBar,
|
||||||
} from "./VideoOverlay";
|
} from "./VideoOverlay";
|
||||||
|
|
||||||
export default function WebRTCVideo() {
|
export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssues: boolean }) {
|
||||||
// Video and stream related refs and states
|
// Video and stream related refs and states
|
||||||
const videoElm = useRef<HTMLVideoElement>(null);
|
const videoElm = useRef<HTMLVideoElement>(null);
|
||||||
const { mediaStream, peerConnectionState } = useRTCStore();
|
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",
|
"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,
|
"cursor-none": settings.isCursorHidden,
|
||||||
"opacity-0":
|
"!opacity-0":
|
||||||
isVideoLoading ||
|
isVideoLoading ||
|
||||||
hdmiError ||
|
hdmiError ||
|
||||||
|
hasConnectionIssues ||
|
||||||
peerConnectionState !== "connected",
|
peerConnectionState !== "connected",
|
||||||
"opacity-60!": showPointerLockBar,
|
"opacity-60!": showPointerLockBar,
|
||||||
"animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20":
|
"animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20":
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,16 @@ export interface UIState {
|
||||||
|
|
||||||
terminalType: AvailableTerminalTypes;
|
terminalType: AvailableTerminalTypes;
|
||||||
setTerminalType: (type: UIState["terminalType"]) => void;
|
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 => ({
|
export const useUiStore = create<UIState>(set => ({
|
||||||
terminalType: "none",
|
terminalType: "none",
|
||||||
setTerminalType: (type: UIState["terminalType"]) => set({ terminalType: type }),
|
setTerminalType: (type: UIState["terminalType"]) => set({ terminalType: type }),
|
||||||
|
|
||||||
sidebarView: null,
|
sidebarView: null,
|
||||||
setSidebarView: (view: AvailableSidebarViews | null) => set({ sidebarView: view }),
|
setSidebarView: (view: AvailableSidebarViews | null) => set({ sidebarView: view }),
|
||||||
|
|
@ -82,7 +87,8 @@ export const useUiStore = create<UIState>(set => ({
|
||||||
setDisableVideoFocusTrap: (enabled: boolean) => set({ disableVideoFocusTrap: enabled }),
|
setDisableVideoFocusTrap: (enabled: boolean) => set({ disableVideoFocusTrap: enabled }),
|
||||||
|
|
||||||
isWakeOnLanModalVisible: false,
|
isWakeOnLanModalVisible: false,
|
||||||
setWakeOnLanModalVisibility: (enabled: boolean) => set({ isWakeOnLanModalVisible: enabled }),
|
setWakeOnLanModalVisibility: (enabled: boolean) =>
|
||||||
|
set({ isWakeOnLanModalVisible: enabled }),
|
||||||
|
|
||||||
toggleSidebarView: view =>
|
toggleSidebarView: view =>
|
||||||
set(state => {
|
set(state => {
|
||||||
|
|
@ -96,6 +102,9 @@ export const useUiStore = create<UIState>(set => ({
|
||||||
isAttachedVirtualKeyboardVisible: true,
|
isAttachedVirtualKeyboardVisible: true,
|
||||||
setAttachedVirtualKeyboardVisibility: (enabled: boolean) =>
|
setAttachedVirtualKeyboardVisibility: (enabled: boolean) =>
|
||||||
set({ isAttachedVirtualKeyboardVisible: enabled }),
|
set({ isAttachedVirtualKeyboardVisible: enabled }),
|
||||||
|
|
||||||
|
rebootState: null,
|
||||||
|
setRebootState: state => set({ rebootState: state }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export interface RTCState {
|
export interface RTCState {
|
||||||
|
|
@ -465,7 +474,7 @@ export interface KeysDownState {
|
||||||
keys: number[];
|
keys: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type USBStates =
|
export type USBStates =
|
||||||
| "configured"
|
| "configured"
|
||||||
| "attached"
|
| "attached"
|
||||||
| "not attached"
|
| "not attached"
|
||||||
|
|
@ -954,4 +963,4 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,9 @@ export default function SettingsGeneralRebootRoute() {
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
const onConfirmUpdate = useCallback(() => {
|
const onConfirmUpdate = useCallback(() => {
|
||||||
// This is where we send the RPC to the golang binary
|
send("reboot", { force: true});
|
||||||
send("reboot", {force: true});
|
|
||||||
}, [send]);
|
}, [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} />;
|
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -186,31 +186,71 @@ export default function SettingsNetworkRoute() {
|
||||||
const settings = prepareSettings(data);
|
const settings = prepareSettings(data);
|
||||||
const dirty = formState.dirtyFields;
|
const dirty = formState.dirtyFields;
|
||||||
|
|
||||||
// These fields will prompt a confirm dialog, all else save immediately
|
// Build list of critical changes for display
|
||||||
const criticalFields = [
|
const changes: { label: string; from: string; to: string }[] = [];
|
||||||
// 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 }[];
|
|
||||||
|
|
||||||
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 no critical fields are changed, save immediately
|
||||||
if (!criticalChanged) return onSubmit(settings);
|
if (changes.length === 0) 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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Show confirmation dialog for critical changes
|
||||||
setStagedSettings(settings);
|
setStagedSettings(settings);
|
||||||
setCriticalChanges(Array.from(changes));
|
setCriticalChanges(changes);
|
||||||
setShowCriticalSettingsConfirm(true);
|
setShowCriticalSettingsConfirm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -263,7 +303,7 @@ export default function SettingsNetworkRoute() {
|
||||||
{networkState?.mac_address} {" "}
|
{networkState?.mac_address} {" "}
|
||||||
</div>
|
</div>
|
||||||
</GridCard>
|
</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 || "")) {
|
if (await copy(networkState?.mac_address || "")) {
|
||||||
notifications.success("MAC address copied to clipboard");
|
notifications.success("MAC address copied to clipboard");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -356,7 +396,7 @@ export default function SettingsNetworkRoute() {
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</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
|
<SelectMenuBasic
|
||||||
size="SM"
|
size="SM"
|
||||||
options={[
|
options={[
|
||||||
|
|
@ -484,52 +524,41 @@ export default function SettingsNetworkRoute() {
|
||||||
}, 500);
|
}, 500);
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
// close();
|
|
||||||
setShowCriticalSettingsConfirm(false);
|
setShowCriticalSettingsConfirm(false);
|
||||||
}}
|
}}
|
||||||
isConfirming={formState.isSubmitting}
|
isConfirming={formState.isSubmitting}
|
||||||
description={
|
description={
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p>
|
<div>
|
||||||
This will update the device's network configuration and may briefly
|
<p className="text-sm leading-relaxed text-slate-700 dark:text-slate-300">
|
||||||
disconnect your session.
|
The following network settings will be applied. These changes may require a reboot and cause brief disconnection.
|
||||||
</p>
|
</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="space-y-2.5">
|
||||||
<div className="mb-2 text-xs font-semibold tracking-wide text-slate-500 uppercase dark:text-slate-400">
|
<div className="flex items-center justify-between text-[13px] font-medium text-slate-900 dark:text-white">
|
||||||
Pending changes
|
Configuration changes
|
||||||
</div>
|
</div>
|
||||||
<dl className="grid grid-cols-1 gap-y-2">
|
<div className="space-y-2.5">
|
||||||
{criticalChanges.map((c, idx) => (
|
{criticalChanges.map((c, idx) => (
|
||||||
<div key={idx} className="w-full not-last:pb-2">
|
<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">
|
||||||
<div className="flex items-center gap-2 gap-x-8">
|
<span className="text-xs text-slate-600 dark:text-slate-400">{c.label}</span>
|
||||||
<dt className="text-sm text-slate-500 dark:text-slate-400">
|
<div className="flex items-center gap-2.5">
|
||||||
{c.label}
|
<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">
|
||||||
</dt>
|
{c.from || "—"}
|
||||||
<div className="flex items-center gap-2">
|
</code>
|
||||||
<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">
|
<svg className="size-3.5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
{c.from || "—"}
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||||
</span>
|
</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">
|
||||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
{c.to}
|
||||||
→
|
</code>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import {
|
||||||
ConnectionFailedOverlay,
|
ConnectionFailedOverlay,
|
||||||
LoadingConnectionOverlay,
|
LoadingConnectionOverlay,
|
||||||
PeerConnectionDisconnectedOverlay,
|
PeerConnectionDisconnectedOverlay,
|
||||||
|
RebootingOverlay,
|
||||||
} from "@/components/VideoOverlay";
|
} from "@/components/VideoOverlay";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
|
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
|
||||||
|
|
@ -122,7 +123,7 @@ export default function KvmIdRoute() {
|
||||||
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
|
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
|
||||||
|
|
||||||
const params = useParams() as { id: string };
|
const params = useParams() as { id: string };
|
||||||
const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore();
|
const { sidebarView, setSidebarView, disableVideoFocusTrap, rebootState, setRebootState } = useUiStore();
|
||||||
const [queryParams, setQueryParams] = useSearchParams();
|
const [queryParams, setQueryParams] = useSearchParams();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -241,7 +242,7 @@ export default function KvmIdRoute() {
|
||||||
{
|
{
|
||||||
heartbeat: true,
|
heartbeat: true,
|
||||||
retryOnError: true,
|
retryOnError: true,
|
||||||
reconnectAttempts: 15,
|
reconnectAttempts: 2000,
|
||||||
reconnectInterval: 1000,
|
reconnectInterval: 1000,
|
||||||
onReconnectStop: () => {
|
onReconnectStop: () => {
|
||||||
console.debug("Reconnect stopped");
|
console.debug("Reconnect stopped");
|
||||||
|
|
@ -250,8 +251,7 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
shouldReconnect(event) {
|
shouldReconnect(event) {
|
||||||
console.debug("[Websocket] shouldReconnect", event);
|
console.debug("[Websocket] shouldReconnect", event);
|
||||||
// TODO: Why true?
|
return !isLegacySignalingEnabled.current;
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onClose(event) {
|
onClose(event) {
|
||||||
|
|
@ -265,6 +265,16 @@ export default function KvmIdRoute() {
|
||||||
},
|
},
|
||||||
onOpen() {
|
onOpen() {
|
||||||
console.debug("[Websocket] 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 => {
|
onMessage: message => {
|
||||||
|
|
@ -340,10 +350,7 @@ export default function KvmIdRoute() {
|
||||||
peerConnection.addIceCandidate(candidate);
|
peerConnection.addIceCandidate(candidate);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
|
|
||||||
// Don't even retry once we declare failure
|
|
||||||
!connectionFailed && isLegacySignalingEnabled.current === false,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendWebRTCSignal = useCallback(
|
const sendWebRTCSignal = useCallback(
|
||||||
|
|
@ -594,6 +601,8 @@ export default function KvmIdRoute() {
|
||||||
api.POST(`${CLOUD_API}/webrtc/turn_activity`, {
|
api.POST(`${CLOUD_API}/webrtc/turn_activity`, {
|
||||||
bytesReceived: bytesReceivedDelta,
|
bytesReceived: bytesReceivedDelta,
|
||||||
bytesSent: bytesSentDelta,
|
bytesSent: bytesSentDelta,
|
||||||
|
}).catch(() => {
|
||||||
|
// we don't care about errors here, but we don't want unhandled promise rejections
|
||||||
});
|
});
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
|
|
@ -666,6 +675,12 @@ export default function KvmIdRoute() {
|
||||||
window.location.href = currentUrl.toString();
|
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);
|
const { send } = useJsonRpc(onJsonRpcRequest);
|
||||||
|
|
@ -765,6 +780,14 @@ export default function KvmIdRoute() {
|
||||||
}, [appVersion, getLocalVersion]);
|
}, [appVersion, getLocalVersion]);
|
||||||
|
|
||||||
const ConnectionStatusElement = useMemo(() => {
|
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 =
|
const hasConnectionFailed =
|
||||||
connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? "");
|
connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? "");
|
||||||
|
|
||||||
|
|
@ -774,9 +797,6 @@ export default function KvmIdRoute() {
|
||||||
|
|
||||||
const isDisconnected = peerConnectionState === "disconnected";
|
const isDisconnected = peerConnectionState === "disconnected";
|
||||||
|
|
||||||
const isOtherSession = location.pathname.includes("other-session");
|
|
||||||
|
|
||||||
if (isOtherSession) return null;
|
|
||||||
if (peerConnectionState === "connected") return null;
|
if (peerConnectionState === "connected") return null;
|
||||||
if (isDisconnected) {
|
if (isDisconnected) {
|
||||||
return <PeerConnectionDisconnectedOverlay show={true} />;
|
return <PeerConnectionDisconnectedOverlay show={true} />;
|
||||||
|
|
@ -792,14 +812,7 @@ export default function KvmIdRoute() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [
|
}, [location.pathname, rebootState?.isRebooting, rebootState?.suggestedIp, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]);
|
||||||
connectionFailed,
|
|
||||||
loadingMessage,
|
|
||||||
location.pathname,
|
|
||||||
peerConnection,
|
|
||||||
peerConnectionState,
|
|
||||||
setupPeerConnection,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FeatureFlagProvider appVersion={appVersion}>
|
<FeatureFlagProvider appVersion={appVersion}>
|
||||||
|
|
@ -841,7 +854,7 @@ export default function KvmIdRoute() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative flex h-full w-full overflow-hidden">
|
<div className="relative flex h-full w-full overflow-hidden">
|
||||||
<WebRTCVideo />
|
<WebRTCVideo hasConnectionIssues={!!ConnectionStatusElement} />
|
||||||
<div
|
<div
|
||||||
style={{ animationDuration: "500ms" }}
|
style={{ animationDuration: "500ms" }}
|
||||||
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"
|
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) {
|
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{
|
response := DeviceStatus{
|
||||||
IsSetup: config.LocalAuthMode != "",
|
IsSetup: config.LocalAuthMode != "",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue