From fb3e57aa865675556c17ef8121cccd6b8e4b00c1 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 7 Nov 2025 13:38:50 +0100 Subject: [PATCH] feat: implement fail-safe mode UI --- ui/src/components/FailSafeModeBanner.tsx | 30 +++ ui/src/components/FaileSafeModeOverlay.tsx | 203 +++++++++++++++++++++ ui/src/hooks/stores.ts | 15 +- ui/src/hooks/useJsonRpc.ts | 43 ++++- ui/src/index.css | 8 + ui/src/routes/devices.$id.settings.tsx | 17 +- ui/src/routes/devices.$id.tsx | 26 ++- 7 files changed, 331 insertions(+), 11 deletions(-) create mode 100644 ui/src/components/FailSafeModeBanner.tsx create mode 100644 ui/src/components/FaileSafeModeOverlay.tsx diff --git a/ui/src/components/FailSafeModeBanner.tsx b/ui/src/components/FailSafeModeBanner.tsx new file mode 100644 index 00000000..04fddc67 --- /dev/null +++ b/ui/src/components/FailSafeModeBanner.tsx @@ -0,0 +1,30 @@ +import { LuTriangleAlert } from "react-icons/lu"; + +import Card from "@components/Card"; + +interface FailsafeModeBannerProps { + reason: string; +} + +export function FailsafeModeBanner({ reason }: FailsafeModeBannerProps) { + const getReasonMessage = () => { + switch (reason) { + case "video": + return "Failsafe Mode Active: Video-related settings are currently unavailable"; + default: + return "Failsafe Mode Active: Some settings may be unavailable"; + } + }; + + return ( + +
+ +

+ {getReasonMessage()} +

+
+
+ ); +} + diff --git a/ui/src/components/FaileSafeModeOverlay.tsx b/ui/src/components/FaileSafeModeOverlay.tsx new file mode 100644 index 00000000..80c56b0f --- /dev/null +++ b/ui/src/components/FaileSafeModeOverlay.tsx @@ -0,0 +1,203 @@ +import { useState } from "react"; +import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; +import { motion, AnimatePresence } from "framer-motion"; + +import { Button } from "@/components/Button"; +import { GridCard } from "@components/Card"; +import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; +import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; +import { useVersion } from "@/hooks/useVersion"; +import { useDeviceStore } from "@/hooks/stores"; +import notifications from "@/notifications"; + +import { GitHubIcon } from "./Icons"; + +interface FailSafeModeOverlayProps { + reason: string; +} + +interface OverlayContentProps { + readonly children: React.ReactNode; +} + +function OverlayContent({ children }: OverlayContentProps) { + return ( + +
+ {children} +
+
+ ); +} + +export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) { + const { send } = useJsonRpc(); + const { navigateTo } = useDeviceUiNavigation(); + const { appVersion } = useVersion(); + const { systemVersion } = useDeviceStore(); + const [showRebootConfirm, setShowRebootConfirm] = useState(false); + const [isDownloadingLogs, setIsDownloadingLogs] = useState(false); + + const getReasonCopy = () => { + switch (reason) { + case "video": + return { + message: + "We've detected an issue with the video capture process. Your device is still running and accessible, but video streaming is temporarily unavailable. You can reboot to attempt recovery, report the issue, or downgrade to the last stable version.", + }; + default: + return { + message: + "A critical process has encountered an issue. Your device is still accessible, but some functionality may be temporarily unavailable.", + }; + } + }; + + const { title, message } = getReasonCopy(); + + const handleReboot = () => { + if (!showRebootConfirm) { + setShowRebootConfirm(true); + return; + } + + send("reboot", { force: true }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error(`Failed to reboot: ${resp.error.message}`); + } + }); + }; + + const handleReportAndDownloadLogs = () => { + setIsDownloadingLogs(true); + + send("getFailSafeLogs", {}, (resp: JsonRpcResponse) => { + setIsDownloadingLogs(false); + + if ("error" in resp) { + notifications.error(`Failed to get recovery logs: ${resp.error.message}`); + return; + } + + // Download logs + const logContent = resp.result as string; + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filename = `jetkvm-recovery-${reason}-${timestamp}.txt`; + + const blob = new Blob([logContent], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + notifications.success("Recovery logs downloaded successfully"); + + // Open GitHub issue + const issueBody = `## Issue Description +The ${reason} process encountered an error and recovery mode was activated. + +**Reason:** ${reason} +**Timestamp:** ${new Date().toISOString()} +**App Version:** ${appVersion || "Unknown"} +**System Version:** ${systemVersion || "Unknown"} + +## Logs +Please attach the recovery logs file that was downloaded to your computer: +\`${filename}\` + +## Additional Context +[Please describe what you were doing when this occurred]`; + + const issueUrl = + `https://github.com/jetkvm/kvm/issues/new?` + + `title=${encodeURIComponent(`Recovery Mode: ${reason} process issue`)}&` + + `body=${encodeURIComponent(issueBody)}`; + + window.open(issueUrl, "_blank"); + }); + }; + + const handleDowngrade = () => { + navigateTo("/settings/general/update?appVersion=0.4.8"); + }; + + return ( + + + +
+ +
+
+
+

Fail safe mode activated

+

{message}

+
+ {showRebootConfirm ? ( +
+

+ Rebooting will restart your device. This may resolve the issue. Continue? +

+
+
+
+ ) : ( +
+
+ )} +
+
+
+
+
+
+ ); +} + diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index bfbbb26e..b1270b1c 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -465,7 +465,7 @@ export interface KeysDownState { keys: number[]; } -export type USBStates = +export type USBStates = | "configured" | "attached" | "not attached" @@ -926,3 +926,16 @@ export const useMacrosStore = create((set, get) => ({ } } })); + +export interface FailsafeModeState { + isFailsafeMode: boolean; + reason: string | null; // "video", "network", etc. + setFailsafeMode: (enabled: boolean, reason: string | null) => void; +} + +export const useFailsafeModeStore = create(set => ({ + isFailsafeMode: false, + reason: null, + setFailsafeMode: (enabled: boolean, reason: string | null) => + set({ isFailsafeMode: enabled, reason }), +})); diff --git a/ui/src/hooks/useJsonRpc.ts b/ui/src/hooks/useJsonRpc.ts index 5c52d59c..5ad4d366 100644 --- a/ui/src/hooks/useJsonRpc.ts +++ b/ui/src/hooks/useJsonRpc.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect } from "react"; -import { useRTCStore } from "@/hooks/stores"; +import { useRTCStore, useFailsafeModeStore } from "@/hooks/stores"; export interface JsonRpcRequest { jsonrpc: string; @@ -34,12 +34,51 @@ export const RpcMethodNotFound = -32601; const callbackStore = new Map void>(); let requestCounter = 0; +// Map of blocked RPC methods by failsafe reason +const blockedMethodsByReason: Record = { + video: [ + 'setStreamQualityFactor', + 'getEDID', + 'setEDID', + 'getVideoLogStatus', + 'setDisplayRotation', + 'getVideoSleepMode', + 'setVideoSleepMode', + 'getVideoState', + ], +}; + export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { const { rpcDataChannel } = useRTCStore(); + const { isFailsafeMode: isFailsafeMode, reason } = useFailsafeModeStore(); const send = useCallback( async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => { if (rpcDataChannel?.readyState !== "open") return; + + // Check if method is blocked in failsafe mode + if (isFailsafeMode && reason) { + const blockedMethods = blockedMethodsByReason[reason] || []; + if (blockedMethods.includes(method)) { + console.warn(`RPC method "${method}" is blocked in failsafe mode (reason: ${reason})`); + + // Call callback with error if provided + if (callback) { + const errorResponse: JsonRpcErrorResponse = { + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method unavailable in failsafe mode", + data: `This feature is unavailable while in failsafe mode (${reason})`, + }, + id: requestCounter + 1, + }; + callback(errorResponse); + } + return; + } + } + requestCounter++; const payload = { jsonrpc: "2.0", method, params, id: requestCounter }; // Store the callback if it exists @@ -47,7 +86,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { rpcDataChannel.send(JSON.stringify(payload)); }, - [rpcDataChannel] + [rpcDataChannel, isFailsafeMode, reason] ); useEffect(() => { diff --git a/ui/src/index.css b/ui/src/index.css index b13fc3a1..12f9141e 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -354,3 +354,11 @@ video::-webkit-media-controls { .hide-scrollbar::-webkit-scrollbar { display: none; } + +.diagonal-stripes { + background: repeating-linear-gradient( + 135deg, + rgba(255, 0, 0, 0.2) 0 12px, /* red-50 with 20% opacity */ + transparent 12px 24px + ); +} diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 338beb97..bb97003b 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -19,7 +19,8 @@ import { cx } from "@/cva.config"; import Card from "@components/Card"; import { LinkButton } from "@components/Button"; import { FeatureFlag } from "@components/FeatureFlag"; -import { useUiStore } from "@/hooks/stores"; +import { useUiStore, useFailsafeModeStore } from "@/hooks/stores"; +import { FailsafeModeBanner } from "@components/FailSafeModeBanner"; /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ export default function SettingsRoute() { @@ -29,6 +30,8 @@ export default function SettingsRoute() { const [showLeftGradient, setShowLeftGradient] = useState(false); const [showRightGradient, setShowRightGradient] = useState(false); const { width = 0 } = useResizeObserver({ ref: scrollContainerRef as React.RefObject }); + const { isFailsafeMode: isFailsafeMode, reason: failsafeReason } = useFailsafeModeStore(); + const isVideoDisabled = isFailsafeMode && failsafeReason === "video"; // Handle scroll position to show/hide gradients const handleScroll = () => { @@ -157,7 +160,9 @@ export default function SettingsRoute() { -
+
(isActive ? "active" : "")} @@ -168,7 +173,9 @@ export default function SettingsRoute() {
-
+
(isActive ? "active" : "")} @@ -237,8 +244,8 @@ export default function SettingsRoute() {
-
- {/* */} +
+ {isFailsafeMode && failsafeReason && }
import('@/components/sidebar/connectio const Terminal = lazy(() => import('@components/Terminal')); const UpdateInProgressStatusCard = lazy(() => import("@/components/UpdateInProgressStatusCard")); import Modal from "@/components/Modal"; +import { FailSafeModeOverlay } from "@components/FaileSafeModeOverlay"; import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc"; import { ConnectionFailedOverlay, @@ -113,6 +115,7 @@ const loader: LoaderFunction = ({ params }: LoaderFunctionArgs) => { return import.meta.env.MODE === "device" ? deviceLoader() : cloudLoader(params); }; + export default function KvmIdRoute() { const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp; // Depending on the mode, we set the appropriate variables @@ -125,7 +128,7 @@ export default function KvmIdRoute() { const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore(); const [ queryParams, setQueryParams ] = useSearchParams(); - const { + const { peerConnection, setPeerConnection, peerConnectionState, setPeerConnectionState, setMediaStream, @@ -480,6 +483,11 @@ export default function KvmIdRoute() { const rpcDataChannel = pc.createDataChannel("rpc"); rpcDataChannel.onopen = () => { setRpcDataChannel(rpcDataChannel); + + + setTimeout(() => { + useFailsafeModeStore.setState({ isFailsafeMode: true, reason: "video" }); + }, 1000); }; const rpcHidChannel = pc.createDataChannel("hidrpc"); @@ -599,11 +607,12 @@ export default function KvmIdRoute() { const { setNetworkState} = useNetworkStateStore(); const { setHdmiState } = useVideoStore(); - const { + const { keyboardLedState, setKeyboardLedState, keysDownState, setKeysDownState, setUsbState, } = useHidStore(); const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled); + const { setFailsafeMode } = useFailsafeModeStore(); const [hasUpdated, setHasUpdated] = useState(false); const { navigateTo } = useDeviceUiNavigation(); @@ -666,6 +675,12 @@ export default function KvmIdRoute() { window.location.href = currentUrl.toString(); } } + + if (resp.method === "failsafeMode") { + const { enabled, reason } = resp.params as { enabled: boolean; reason: string }; + console.debug("Setting failsafe mode", { enabled, reason }); + setFailsafeMode(enabled, reason); + } } const { send } = useJsonRpc(onJsonRpcRequest); @@ -764,6 +779,8 @@ export default function KvmIdRoute() { getLocalVersion(); }, [appVersion, getLocalVersion]); + const { isFailsafeMode, reason: failsafeReason } = useFailsafeModeStore(); + const ConnectionStatusElement = useMemo(() => { const hasConnectionFailed = connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? ""); @@ -841,13 +858,16 @@ export default function KvmIdRoute() { />
- + {!isFailsafeMode && }
{!!ConnectionStatusElement && ConnectionStatusElement} + {isFailsafeMode && failsafeReason && ( + + )}