mirror of https://github.com/jetkvm/kvm.git
feat: implement fail-safe mode UI
This commit is contained in:
parent
542f9c0862
commit
fb3e57aa86
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
Loading…
Reference in New Issue