feat: implement fail-safe mode UI

This commit is contained in:
Adam Shiervani 2025-11-07 13:38:50 +01:00
parent 542f9c0862
commit fb3e57aa86
7 changed files with 331 additions and 11 deletions

View File

@ -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 (
<Card>
<div className="diagonal-stripes flex items-center gap-3 p-4 rounded">
<LuTriangleAlert className="h-5 w-5 flex-shrink-0 text-red-600 dark:text-red-400" />
<p className="text-sm font-medium text-red-800 dark:text-white">
{getReasonMessage()}
</p>
</div>
</Card>
);
}

View File

@ -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 (
<GridCard cardClassName="h-full pointer-events-auto outline-hidden!">
<div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-slate-800/30 dark:border-slate-300/20">
{children}
</div>
</GridCard>
);
}
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 (
<AnimatePresence>
<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 max-w-lg flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<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">Fail safe mode activated</h2>
<p className="text-sm">{message}</p>
</div>
{showRebootConfirm ? (
<div className="rounded-md bg-amber-50 p-3 dark:bg-amber-950/20">
<p className="mb-3 text-sm text-amber-900 dark:text-amber-200">
Rebooting will restart your device. This may resolve the issue. Continue?
</p>
<div className="flex gap-2">
<Button
onClick={handleReboot}
theme="primary"
size="SM"
text="Confirm Reboot"
/>
<Button
onClick={() => setShowRebootConfirm(false)}
theme="light"
size="SM"
text="Cancel"
/>
</div>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={handleReportAndDownloadLogs}
theme="primary"
size="SM"
disabled={isDownloadingLogs}
LeadingIcon={GitHubIcon}
text={isDownloadingLogs ? "Downloading Logs..." : "Report Issue & Download Logs"}
/>
<Button
onClick={handleReboot}
theme="light"
size="SM"
text="Reboot Device"
/>
<Button
size="SM"
onClick={handleDowngrade}
theme="light"
text="Downgrade to v0.4.8"
/>
</div>
)}
</div>
</div>
</div>
</OverlayContent>
</motion.div>
</AnimatePresence>
);
}

View File

@ -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<MacrosState>((set, get) => ({
}
}
}));
export interface FailsafeModeState {
isFailsafeMode: boolean;
reason: string | null; // "video", "network", etc.
setFailsafeMode: (enabled: boolean, reason: string | null) => void;
}
export const useFailsafeModeStore = create<FailsafeModeState>(set => ({
isFailsafeMode: false,
reason: null,
setFailsafeMode: (enabled: boolean, reason: string | null) =>
set({ isFailsafeMode: enabled, reason }),
}));

View File

@ -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<number | string, (resp: JsonRpcResponse) => void>();
let requestCounter = 0;
// Map of blocked RPC methods by failsafe reason
const blockedMethodsByReason: Record<string, string[]> = {
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(() => {

View File

@ -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
);
}

View File

@ -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<HTMLDivElement> });
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() {
</NavLink>
</div>
</FeatureFlag>
<div className="shrink-0">
<div className={cx("shrink-0", {
"pointer-events-none opacity-50 cursor-not-allowed": isVideoDisabled
})}>
<NavLink
to="video"
className={({ isActive }) => (isActive ? "active" : "")}
@ -168,7 +173,9 @@ export default function SettingsRoute() {
</div>
</NavLink>
</div>
<div className="shrink-0">
<div className={cx("shrink-0", {
"pointer-events-none opacity-50 cursor-not-allowed": isVideoDisabled
})}>
<NavLink
to="hardware"
className={({ isActive }) => (isActive ? "active" : "")}
@ -237,8 +244,8 @@ export default function SettingsRoute() {
</div>
</Card>
</div>
<div className="w-full md:col-span-6">
{/* <AutoHeight> */}
<div className="w-full md:col-span-6 space-y-4">
{isFailsafeMode && failsafeReason && <FailsafeModeBanner reason={failsafeReason} />}
<Card className="dark:bg-slate-800">
<div
className="space-y-4 px-8 py-6"

View File

@ -33,6 +33,7 @@ import {
useUpdateStore,
useVideoStore,
VideoState,
useFailsafeModeStore,
} from "@/hooks/stores";
import WebRTCVideo from "@components/WebRTCVideo";
import DashboardNavbar from "@components/Header";
@ -40,6 +41,7 @@ const ConnectionStatsSidebar = lazy(() => 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() {
/>
<div className="relative flex h-full w-full overflow-hidden">
<WebRTCVideo />
{!isFailsafeMode && <WebRTCVideo />}
<div
style={{ animationDuration: "500ms" }}
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"
>
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
{!!ConnectionStatusElement && ConnectionStatusElement}
{isFailsafeMode && failsafeReason && (
<FailSafeModeOverlay reason={failsafeReason} />
)}
</div>
</div>
<SidebarContainer sidebarView={sidebarView} />