feat: failsafe mode (#952)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
This commit is contained in:
Aveline 2025-11-10 17:57:12 +01:00 committed by Siyuan
parent 31ea366e51
commit 8d2876f957
19 changed files with 643 additions and 28 deletions

View File

@ -74,7 +74,12 @@ func supervise() error {
// run the child binary // run the child binary
cmd := exec.Command(binPath) cmd := exec.Command(binPath)
cmd.Env = append(os.Environ(), []string{envChildID + "=" + kvm.GetBuiltAppVersion()}...) lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile)
cmd.Env = append(os.Environ(), []string{
fmt.Sprintf("%s=%s", envChildID, kvm.GetBuiltAppVersion()),
fmt.Sprintf("JETKVM_LAST_ERROR_PATH=%s", lastFilePath),
}...)
cmd.Args = os.Args cmd.Args = os.Args
logFile, err := os.CreateTemp("", "jetkvm-stdout.log") logFile, err := os.CreateTemp("", "jetkvm-stdout.log")

107
failsafe.go Normal file
View File

@ -0,0 +1,107 @@
package kvm
import (
"fmt"
"os"
"strings"
"sync"
)
const (
failsafeDefaultLastCrashPath = "/userdata/jetkvm/crashdump/last-crash.log"
failsafeFile = "/userdata/jetkvm/.enablefailsafe"
failsafeLastCrashEnv = "JETKVM_LAST_ERROR_PATH"
failsafeEnv = "JETKVM_FORCE_FAILSAFE"
)
var (
failsafeOnce sync.Once
failsafeCrashLog = ""
failsafeModeActive = false
failsafeModeReason = ""
)
type FailsafeModeNotification struct {
Active bool `json:"active"`
Reason string `json:"reason"`
}
// this function has side effects and can be only executed once
func checkFailsafeReason() {
failsafeOnce.Do(func() {
// check if the failsafe environment variable is set
if os.Getenv(failsafeEnv) == "1" {
failsafeModeActive = true
failsafeModeReason = "failsafe_env_set"
return
}
// check if the failsafe file exists
if _, err := os.Stat(failsafeFile); err == nil {
failsafeModeActive = true
failsafeModeReason = "failsafe_file_exists"
_ = os.Remove(failsafeFile)
return
}
// get the last crash log path from the environment variable
lastCrashPath := os.Getenv(failsafeLastCrashEnv)
if lastCrashPath == "" {
lastCrashPath = failsafeDefaultLastCrashPath
}
// check if the last crash log file exists
l := failsafeLogger.With().Str("path", lastCrashPath).Logger()
fi, err := os.Lstat(lastCrashPath)
if err != nil {
if !os.IsNotExist(err) {
l.Warn().Err(err).Msg("failed to stat last crash log")
}
return
}
if fi.Mode()&os.ModeSymlink != os.ModeSymlink {
l.Warn().Msg("last crash log is not a symlink, ignoring")
return
}
// open the last crash log file and find if it contains the string "panic"
content, err := os.ReadFile(lastCrashPath)
if err != nil {
l.Warn().Err(err).Msg("failed to read last crash log")
return
}
// unlink the last crash log file
failsafeCrashLog = string(content)
_ = os.Remove(lastCrashPath)
// TODO: read the goroutine stack trace and check which goroutine is panicking
if strings.Contains(failsafeCrashLog, "runtime.cgocall") {
failsafeModeActive = true
failsafeModeReason = "video"
return
}
})
}
func notifyFailsafeMode(session *Session) {
if !failsafeModeActive || session == nil {
return
}
jsonRpcLogger.Info().Str("reason", failsafeModeReason).Msg("sending failsafe mode notification")
writeJSONRPCEvent("failsafeMode", FailsafeModeNotification{
Active: true,
Reason: failsafeModeReason,
}, session)
}
func rpcGetFailsafeLogs() (string, error) {
if !failsafeModeActive {
return "", fmt.Errorf("failsafe mode is not active")
}
return failsafeCrashLog, nil
}

View File

@ -1,5 +1,8 @@
//go:build linux //go:build linux
// TODO: use a generator to generate the cgo code for the native functions
// there's too much boilerplate code to write manually
package native package native
import ( import (
@ -46,7 +49,17 @@ static inline void jetkvm_cgo_setup_rpc_handler() {
*/ */
import "C" import "C"
var cgoLock sync.Mutex var (
cgoLock sync.Mutex
cgoDisabled bool
)
func setCgoDisabled(disabled bool) {
cgoLock.Lock()
defer cgoLock.Unlock()
cgoDisabled = disabled
}
//export jetkvm_go_video_state_handler //export jetkvm_go_video_state_handler
func jetkvm_go_video_state_handler(state *C.jetkvm_video_state_t) { func jetkvm_go_video_state_handler(state *C.jetkvm_video_state_t) {
@ -91,6 +104,10 @@ func jetkvm_go_rpc_handler(method *C.cchar_t, params *C.cchar_t) {
var eventCodeToNameMap = map[int]string{} var eventCodeToNameMap = map[int]string{}
func uiEventCodeToName(code int) string { func uiEventCodeToName(code int) string {
if cgoDisabled {
return ""
}
name, ok := eventCodeToNameMap[code] name, ok := eventCodeToNameMap[code]
if !ok { if !ok {
cCode := C.int(code) cCode := C.int(code)
@ -103,6 +120,10 @@ func uiEventCodeToName(code int) string {
} }
func setUpNativeHandlers() { func setUpNativeHandlers() {
if cgoDisabled {
return
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -114,6 +135,10 @@ func setUpNativeHandlers() {
} }
func uiInit(rotation uint16) { func uiInit(rotation uint16) {
if cgoDisabled {
return
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -123,6 +148,10 @@ func uiInit(rotation uint16) {
} }
func uiTick() { func uiTick() {
if cgoDisabled {
return
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -130,6 +159,10 @@ func uiTick() {
} }
func videoInit(factor float64) error { func videoInit(factor float64) error {
if cgoDisabled {
return nil
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -143,6 +176,10 @@ func videoInit(factor float64) error {
} }
func videoShutdown() { func videoShutdown() {
if cgoDisabled {
return
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -150,6 +187,10 @@ func videoShutdown() {
} }
func videoStart() { func videoStart() {
if cgoDisabled {
return
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -157,6 +198,10 @@ func videoStart() {
} }
func videoStop() { func videoStop() {
if cgoDisabled {
return
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -164,6 +209,10 @@ func videoStop() {
} }
func videoLogStatus() string { func videoLogStatus() string {
if cgoDisabled {
return ""
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -174,6 +223,10 @@ func videoLogStatus() string {
} }
func uiSetVar(name string, value string) { func uiSetVar(name string, value string) {
if cgoDisabled {
return
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -187,6 +240,10 @@ func uiSetVar(name string, value string) {
} }
func uiGetVar(name string) string { func uiGetVar(name string) string {
if cgoDisabled {
return ""
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -197,6 +254,10 @@ func uiGetVar(name string) string {
} }
func uiSwitchToScreen(screen string) { func uiSwitchToScreen(screen string) {
if cgoDisabled {
return
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -206,6 +267,10 @@ func uiSwitchToScreen(screen string) {
} }
func uiGetCurrentScreen() string { func uiGetCurrentScreen() string {
if cgoDisabled {
return ""
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -214,6 +279,10 @@ func uiGetCurrentScreen() string {
} }
func uiObjAddState(objName string, state string) (bool, error) { func uiObjAddState(objName string, state string) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -226,6 +295,10 @@ func uiObjAddState(objName string, state string) (bool, error) {
} }
func uiObjClearState(objName string, state string) (bool, error) { func uiObjClearState(objName string, state string) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -238,6 +311,10 @@ func uiObjClearState(objName string, state string) (bool, error) {
} }
func uiGetLVGLVersion() string { func uiGetLVGLVersion() string {
if cgoDisabled {
return ""
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -246,6 +323,10 @@ func uiGetLVGLVersion() string {
// TODO: use Enum instead of string but it's not a hot path and performance is not a concern now // TODO: use Enum instead of string but it's not a hot path and performance is not a concern now
func uiObjAddFlag(objName string, flag string) (bool, error) { func uiObjAddFlag(objName string, flag string) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -258,6 +339,10 @@ func uiObjAddFlag(objName string, flag string) (bool, error) {
} }
func uiObjClearFlag(objName string, flag string) (bool, error) { func uiObjClearFlag(objName string, flag string) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -278,6 +363,10 @@ func uiObjShow(objName string) (bool, error) {
} }
func uiObjSetOpacity(objName string, opacity int) (bool, error) { func uiObjSetOpacity(objName string, opacity int) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -289,6 +378,10 @@ func uiObjSetOpacity(objName string, opacity int) (bool, error) {
} }
func uiObjFadeIn(objName string, duration uint32) (bool, error) { func uiObjFadeIn(objName string, duration uint32) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -301,6 +394,10 @@ func uiObjFadeIn(objName string, duration uint32) (bool, error) {
} }
func uiObjFadeOut(objName string, duration uint32) (bool, error) { func uiObjFadeOut(objName string, duration uint32) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -313,6 +410,10 @@ func uiObjFadeOut(objName string, duration uint32) (bool, error) {
} }
func uiLabelSetText(objName string, text string) (bool, error) { func uiLabelSetText(objName string, text string) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -330,6 +431,10 @@ func uiLabelSetText(objName string, text string) (bool, error) {
} }
func uiImgSetSrc(objName string, src string) (bool, error) { func uiImgSetSrc(objName string, src string) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -345,6 +450,10 @@ func uiImgSetSrc(objName string, src string) (bool, error) {
} }
func uiDispSetRotation(rotation uint16) (bool, error) { func uiDispSetRotation(rotation uint16) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -357,6 +466,10 @@ func uiDispSetRotation(rotation uint16) (bool, error) {
} }
func videoGetStreamQualityFactor() (float64, error) { func videoGetStreamQualityFactor() (float64, error) {
if cgoDisabled {
return 0, nil
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -365,6 +478,10 @@ func videoGetStreamQualityFactor() (float64, error) {
} }
func videoSetStreamQualityFactor(factor float64) error { func videoSetStreamQualityFactor(factor float64) error {
if cgoDisabled {
return nil
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -373,6 +490,10 @@ func videoSetStreamQualityFactor(factor float64) error {
} }
func videoGetEDID() (string, error) { func videoGetEDID() (string, error) {
if cgoDisabled {
return "", nil
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
@ -381,6 +502,10 @@ func videoGetEDID() (string, error) {
} }
func videoSetEDID(edid string) error { func videoSetEDID(edid string) error {
if cgoDisabled {
return nil
}
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()

View File

@ -9,6 +9,7 @@ import (
) )
type Native struct { type Native struct {
disable bool
ready chan struct{} ready chan struct{}
l *zerolog.Logger l *zerolog.Logger
lD *zerolog.Logger lD *zerolog.Logger
@ -27,6 +28,7 @@ type Native struct {
} }
type NativeOptions struct { type NativeOptions struct {
Disable bool
SystemVersion *semver.Version SystemVersion *semver.Version
AppVersion *semver.Version AppVersion *semver.Version
DisplayRotation uint16 DisplayRotation uint16
@ -74,6 +76,7 @@ func NewNative(opts NativeOptions) *Native {
} }
return &Native{ return &Native{
disable: opts.Disable,
ready: make(chan struct{}), ready: make(chan struct{}),
l: nativeLogger, l: nativeLogger,
lD: displayLogger, lD: displayLogger,
@ -92,6 +95,12 @@ func NewNative(opts NativeOptions) *Native {
} }
func (n *Native) Start() { func (n *Native) Start() {
if n.disable {
nativeLogger.Warn().Msg("native is disabled, skipping initialization")
setCgoDisabled(true)
return
}
// set up singleton // set up singleton
setInstance(n) setInstance(n)
setUpNativeHandlers() setUpNativeHandlers()

View File

@ -1248,4 +1248,5 @@ var rpcHandlers = map[string]RPCHandler{
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly}, "getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, "setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
"getFailSafeLogs": {Func: rpcGetFailsafeLogs},
} }

1
log.go
View File

@ -11,6 +11,7 @@ func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error {
var ( var (
logger = logging.GetSubsystemLogger("jetkvm") logger = logging.GetSubsystemLogger("jetkvm")
failsafeLogger = logging.GetSubsystemLogger("failsafe")
networkLogger = logging.GetSubsystemLogger("network") networkLogger = logging.GetSubsystemLogger("network")
cloudLogger = logging.GetSubsystemLogger("cloud") cloudLogger = logging.GetSubsystemLogger("cloud")
websocketLogger = logging.GetSubsystemLogger("websocket") websocketLogger = logging.GetSubsystemLogger("websocket")

View File

@ -15,6 +15,12 @@ var appCtx context.Context
func Main() { func Main() {
logger.Log().Msg("JetKVM Starting Up") logger.Log().Msg("JetKVM Starting Up")
checkFailsafeReason()
if failsafeModeActive {
logger.Warn().Str("reason", failsafeModeReason).Msg("failsafe mode activated")
}
LoadConfig() LoadConfig()
var cancel context.CancelFunc var cancel context.CancelFunc
@ -50,6 +56,7 @@ func Main() {
// Initialize network // Initialize network
if err := initNetwork(); err != nil { if err := initNetwork(); err != nil {
logger.Error().Err(err).Msg("failed to initialize network") logger.Error().Err(err).Msg("failed to initialize network")
// TODO: reset config to default
os.Exit(1) os.Exit(1)
} }
@ -60,7 +67,6 @@ func Main() {
// Initialize mDNS // Initialize mDNS
if err := initMdns(); err != nil { if err := initMdns(); err != nil {
logger.Error().Err(err).Msg("failed to initialize mDNS") logger.Error().Err(err).Msg("failed to initialize mDNS")
os.Exit(1)
} }
initPrometheus() initPrometheus()

View File

@ -17,6 +17,7 @@ var (
func initNative(systemVersion *semver.Version, appVersion *semver.Version) { func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
nativeInstance = native.NewNative(native.NativeOptions{ nativeInstance = native.NewNative(native.NativeOptions{
Disable: failsafeModeActive,
SystemVersion: systemVersion, SystemVersion: systemVersion,
AppVersion: appVersion, AppVersion: appVersion,
DisplayRotation: config.GetDisplayRotation(), DisplayRotation: config.GetDisplayRotation(),

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,216 @@
import { useState } from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { motion, AnimatePresence } from "framer-motion";
import { LuInfo } from "react-icons/lu";
import { Button } from "@/components/Button";
import Card, { 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 { DOWNGRADE_VERSION } from "@/ui.config";
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>
);
}
interface TooltipProps {
readonly children: React.ReactNode;
readonly text: string;
readonly show: boolean;
}
function Tooltip({ children, text, show }: TooltipProps) {
if (!show) {
return <>{children}</>;
}
return (
<div className="group/tooltip relative">
{children}
<div className="pointer-events-none absolute bottom-full left-1/2 mb-2 hidden -translate-x-1/2 opacity-0 transition-opacity group-hover/tooltip:block group-hover/tooltip:opacity-100">
<Card>
<div className="whitespace-nowrap px-2 py-1 text-xs flex items-center gap-1 justify-center">
<LuInfo className="h-3 w-3 text-slate-700 dark:text-slate-300" />
{text}
</div>
</Card>
</div>
</div>
);
}
export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) {
const { send } = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation();
const { appVersion } = useVersion();
const { systemVersion } = useDeviceStore();
const [isDownloadingLogs, setIsDownloadingLogs] = useState(false);
const [hasDownloadedLogs, setHasDownloadedLogs] = 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.",
};
default:
return {
message:
"A critical process has encountered an issue. Your device is still accessible, but some functionality may be temporarily unavailable.",
};
}
};
const { message } = getReasonCopy();
const handleReportAndDownloadLogs = () => {
setIsDownloadingLogs(true);
send("getFailSafeLogs", {}, async (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);
await new Promise(resolve => setTimeout(resolve, 1000));
a.click();
await new Promise(resolve => setTimeout(resolve, 1000));
document.body.removeChild(a);
URL.revokeObjectURL(url);
notifications.success("Crash logs downloaded successfully");
setHasDownloadedLogs(true);
// Open GitHub issue
const issueBody = `## Issue Description
The \`${reason}\` process encountered an error and failsafe 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}\`
> [!NOTE]
> Please remove any sensitive information from the logs. The reports are public and can be viewed by anyone.
## 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?app=${DOWNGRADE_VERSION}`);
};
return (
<AnimatePresence>
<motion.div
className="aspect-video h-full w-full isolate"
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>
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={handleReportAndDownloadLogs}
theme="primary"
size="SM"
disabled={isDownloadingLogs}
LeadingIcon={GitHubIcon}
text={isDownloadingLogs ? "Downloading Logs..." : "Download Logs & Report Issue"}
/>
<div className="h-8 w-px bg-slate-200 dark:bg-slate-700 block" />
<Tooltip text="Download logs first" show={!hasDownloadedLogs}>
<Button
onClick={() => navigateTo("/settings/general/reboot")}
theme="light"
size="SM"
text="Reboot Device"
disabled={!hasDownloadedLogs}
/>
</Tooltip>
<Tooltip text="Download logs first" show={!hasDownloadedLogs}>
<Button
size="SM"
onClick={handleDowngrade}
theme="light"
text={`Downgrade to v${DOWNGRADE_VERSION}`}
disabled={!hasDownloadedLogs}
/>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
</OverlayContent>
</motion.div>
</AnimatePresence>
);
}

View File

@ -1003,3 +1003,15 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
} }
}, },
})); }));
export interface FailsafeModeState {
isFailsafeMode: boolean;
reason: string; // "video", "network", etc.
setFailsafeMode: (active: boolean, reason: string) => void;
}
export const useFailsafeModeStore = create<FailsafeModeState>(set => ({
isFailsafeMode: false,
reason: "",
setFailsafeMode: (active, reason) => set({ isFailsafeMode: active, reason }),
}));

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useRTCStore } from "@hooks/stores"; import { useRTCStore, useFailsafeModeStore } from "@hooks/stores";
export interface JsonRpcRequest { export interface JsonRpcRequest {
jsonrpc: string; jsonrpc: string;
@ -34,12 +34,51 @@ export const RpcMethodNotFound = -32601;
const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>(); const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>();
let requestCounter = 0; 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) { export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
const { rpcDataChannel } = useRTCStore(); const { rpcDataChannel } = useRTCStore();
const { isFailsafeMode, reason } = useFailsafeModeStore();
const send = useCallback( const send = useCallback(
async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => { async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
if (rpcDataChannel?.readyState !== "open") return; 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++; requestCounter++;
const payload = { jsonrpc: "2.0", method, params, id: requestCounter }; const payload = { jsonrpc: "2.0", method, params, id: requestCounter };
// Store the callback if it exists // Store the callback if it exists
@ -47,7 +86,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
rpcDataChannel.send(JSON.stringify(payload)); rpcDataChannel.send(JSON.stringify(payload));
}, },
[rpcDataChannel] [rpcDataChannel, isFailsafeMode, reason]
); );
useEffect(() => { useEffect(() => {

View File

@ -363,3 +363,11 @@ video::-webkit-media-controls {
.hide-scrollbar::-webkit-scrollbar { .hide-scrollbar::-webkit-scrollbar {
display: none; display: none;
} }
.diagonal-stripes {
background: repeating-linear-gradient(
135deg,
rgba(255, 0, 0, 0.1) 0 12px, /* red-50 with 20% opacity */
transparent 12px 24px
);
}

View File

@ -1,14 +1,24 @@
import { useCallback } from "react"; import { useCallback , useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useJsonRpc } from "@hooks/useJsonRpc"; import { useJsonRpc } from "@hooks/useJsonRpc";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { m } from "@localizations/messages.js"; import { useFailsafeModeStore } from "@/hooks/stores";
import { sleep } from "@/utils"; import { sleep } from "@/utils";
import { m } from "@localizations/messages.js";
import LoadingSpinner from "../components/LoadingSpinner";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
// Time to wait after initiating reboot before redirecting to home
const REBOOT_REDIRECT_DELAY_MS = 5000;
export default function SettingsGeneralRebootRoute() { export default function SettingsGeneralRebootRoute() {
const navigate = useNavigate(); const navigate = useNavigate();
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const [isRebooting, setIsRebooting] = useState(false);
const { navigateTo } = useDeviceUiNavigation();
const { setFailsafeMode } = useFailsafeModeStore();
const onClose = useCallback(async () => { const onClose = useCallback(async () => {
navigate(".."); // back to the devices.$id.settings page navigate(".."); // back to the devices.$id.settings page
@ -18,17 +28,24 @@ export default function SettingsGeneralRebootRoute() {
}, [navigate]); }, [navigate]);
const onConfirmUpdate = useCallback(() => { const onConfirmUpdate = useCallback(async () => {
setIsRebooting(true);
send("reboot", { force: true }); send("reboot", { force: true });
}, [send]);
return <Dialog onClose={onClose} onConfirmUpdate={onConfirmUpdate} />; await new Promise(resolve => setTimeout(resolve, REBOOT_REDIRECT_DELAY_MS));
setFailsafeMode(false, "");
navigateTo("/");
}, [navigateTo, send, setFailsafeMode]);
return <Dialog isRebooting={isRebooting} onClose={onClose} onConfirmUpdate={onConfirmUpdate} />;
} }
export function Dialog({ export function Dialog({
isRebooting,
onClose, onClose,
onConfirmUpdate, onConfirmUpdate,
}: Readonly<{ }: Readonly<{
isRebooting: boolean;
onClose: () => void; onClose: () => void;
onConfirmUpdate: () => void; onConfirmUpdate: () => void;
}>) { }>) {
@ -37,6 +54,7 @@ export function Dialog({
<div className="pointer-events-auto relative mx-auto text-left"> <div className="pointer-events-auto relative mx-auto text-left">
<div> <div>
<ConfirmationBox <ConfirmationBox
isRebooting={isRebooting}
onYes={onConfirmUpdate} onYes={onConfirmUpdate}
onNo={onClose} onNo={onClose}
/> />
@ -46,9 +64,11 @@ export function Dialog({
} }
function ConfirmationBox({ function ConfirmationBox({
isRebooting,
onYes, onYes,
onNo, onNo,
}: { }: {
isRebooting: boolean;
onYes: () => void; onYes: () => void;
onNo: () => void; onNo: () => void;
}) { }) {
@ -61,11 +81,16 @@ function ConfirmationBox({
<p className="text-sm text-slate-600 dark:text-slate-300"> <p className="text-sm text-slate-600 dark:text-slate-300">
{m.general_reboot_description()} {m.general_reboot_description()}
</p> </p>
{isRebooting ? (
<div className="mt-4 flex items-center justify-center">
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
</div>
) : (
<div className="mt-4 flex gap-x-2"> <div className="mt-4 flex gap-x-2">
<Button size="SM" theme="light" text={m.general_reboot_yes_button()} onClick={onYes} /> <Button size="SM" theme="light" text={m.general_reboot_yes_button()} onClick={onYes} />
<Button size="SM" theme="blank" text={m.general_reboot_no_button()} onClick={onNo} /> <Button size="SM" theme="blank" text={m.general_reboot_no_button()} onClick={onNo} />
</div> </div>
)}
</div> </div>
</div> </div>
); );

View File

@ -20,7 +20,8 @@ import { useUiStore } from "@hooks/stores";
import Card from "@components/Card"; import Card from "@components/Card";
import { LinkButton } from "@components/Button"; import { LinkButton } from "@components/Button";
import { FeatureFlag } from "@components/FeatureFlag"; import { FeatureFlag } from "@components/FeatureFlag";
import { m } from "@localizations/messages.js"; import { m, useFailsafeModeStore } from "@localizations/messages.js";
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. */ /* 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() { export default function SettingsRoute() {
@ -30,6 +31,8 @@ export default function SettingsRoute() {
const [showLeftGradient, setShowLeftGradient] = useState(false); const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false); const [showRightGradient, setShowRightGradient] = useState(false);
const { width = 0 } = useResizeObserver({ ref: scrollContainerRef as React.RefObject<HTMLDivElement> }); 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 // Handle scroll position to show/hide gradients
const handleScroll = () => { const handleScroll = () => {
@ -158,21 +161,28 @@ export default function SettingsRoute() {
</NavLink> </NavLink>
</div> </div>
</FeatureFlag> </FeatureFlag>
<div className="shrink-0"> <div className={cx("shrink-0", {
"opacity-50 cursor-not-allowed": isVideoDisabled
})}>
<NavLink <NavLink
to="video" to="video"
className={({ isActive }) => (isActive ? "active" : "")} className={({ isActive }) => cx(isActive ? "active" : "", {
> "pointer-events-none": isVideoDisabled
})} >
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuVideo className="h-4 w-4 shrink-0" /> <LuVideo className="h-4 w-4 shrink-0" />
<h1>{m.settings_video()}</h1> <h1>{m.settings_video()}</h1>
</div> </div>
</NavLink> </NavLink>
</div> </div>
<div className="shrink-0"> <div className={cx("shrink-0", {
"opacity-50 cursor-not-allowed": isVideoDisabled
})}>
<NavLink <NavLink
to="hardware" to="hardware"
className={({ isActive }) => (isActive ? "active" : "")} className={({ isActive }) => cx(isActive ? "active" : "", {
"pointer-events-none": isVideoDisabled
})}
> >
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent"> <div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuCpu className="h-4 w-4 shrink-0" /> <LuCpu className="h-4 w-4 shrink-0" />
@ -238,8 +248,8 @@ export default function SettingsRoute() {
</div> </div>
</Card> </Card>
</div> </div>
<div className="w-full md:col-span-6"> <div className="w-full md:col-span-6 space-y-4">
{/* <AutoHeight> */} {isFailsafeMode && failsafeReason && <FailsafeModeBanner reason={failsafeReason} />}
<Card className="dark:bg-slate-800"> <Card className="dark:bg-slate-800">
<div <div
className="space-y-4 px-8 py-6" className="space-y-4 px-8 py-6"

View File

@ -33,6 +33,7 @@ import {
useUpdateStore, useUpdateStore,
useVideoStore, useVideoStore,
VideoState, VideoState,
useFailsafeModeStore,
} from "@hooks/stores"; } from "@hooks/stores";
import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@hooks/useJsonRpc"; import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
@ -43,6 +44,7 @@ const ConnectionStatsSidebar = lazy(() => import('@components/sidebar/connection
const Terminal = lazy(() => import('@components/Terminal')); const Terminal = lazy(() => import('@components/Terminal'));
const UpdateInProgressStatusCard = lazy(() => import("@components/UpdateInProgressStatusCard")); const UpdateInProgressStatusCard = lazy(() => import("@components/UpdateInProgressStatusCard"));
import Modal from "@components/Modal"; import Modal from "@components/Modal";
import { FailSafeModeOverlay } from "@components/FailSafeModeOverlay";
import { import {
ConnectionFailedOverlay, ConnectionFailedOverlay,
LoadingConnectionOverlay, LoadingConnectionOverlay,
@ -101,6 +103,7 @@ const loader: LoaderFunction = ({ params }: LoaderFunctionArgs) => {
return isOnDevice ? deviceLoader() : cloudLoader(params); return isOnDevice ? deviceLoader() : cloudLoader(params);
}; };
export default function KvmIdRoute() { export default function KvmIdRoute() {
const loaderResp = useLoaderData(); const loaderResp = useLoaderData();
// Depending on the mode, we set the appropriate variables // Depending on the mode, we set the appropriate variables
@ -619,7 +622,8 @@ export default function KvmIdRoute() {
keysDownState, setKeysDownState, keysDownState, setKeysDownState,
setUsbState, setUsbState,
} = useHidStore(); } = useHidStore();
const { setHidRpcDisabled } = useRTCStore(); const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled);
const { setFailsafeMode } = useFailsafeModeStore();
const [hasUpdated, setHasUpdated] = useState(false); const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
@ -696,6 +700,12 @@ export default function KvmIdRoute() {
setRebootState({ isRebooting: true, postRebootAction }); setRebootState({ isRebooting: true, postRebootAction });
navigateTo("/"); navigateTo("/");
} }
if (resp.method === "failsafeMode") {
const { active, reason } = resp.params as { active: boolean; reason: string };
console.debug("Setting failsafe mode", { active, reason });
setFailsafeMode(active, reason);
}
} }
const { send } = useJsonRpc(onJsonRpcRequest); const { send } = useJsonRpc(onJsonRpcRequest);
@ -794,6 +804,8 @@ export default function KvmIdRoute() {
getLocalVersion(); getLocalVersion();
}, [appVersion, getLocalVersion]); }, [appVersion, getLocalVersion]);
const { isFailsafeMode, reason: failsafeReason } = useFailsafeModeStore();
const ConnectionStatusElement = useMemo(() => { const ConnectionStatusElement = useMemo(() => {
const isOtherSession = location.pathname.includes("other-session"); const isOtherSession = location.pathname.includes("other-session");
if (isOtherSession) return null; if (isOtherSession) return null;
@ -869,13 +881,15 @@ 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 hasConnectionIssues={!!ConnectionStatusElement} /> {(isFailsafeMode && failsafeReason === "video") ? null : <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"
> >
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md"> <div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
{!!ConnectionStatusElement && ConnectionStatusElement} {isFailsafeMode && failsafeReason ? (
<FailSafeModeOverlay reason={failsafeReason} />
) : !!ConnectionStatusElement && ConnectionStatusElement}
</div> </div>
</div> </div>
<SidebarContainer sidebarView={sidebarView} /> <SidebarContainer sidebarView={sidebarView} />

View File

@ -1,4 +1,6 @@
export const CLOUD_API = import.meta.env.VITE_CLOUD_API; export const CLOUD_API = import.meta.env.VITE_CLOUD_API;
export const DOWNGRADE_VERSION = import.meta.env.VITE_DOWNGRADE_VERSION || "0.4.8";
// In device mode, an empty string uses the current hostname (the JetKVM device's IP) as the API endpoint // In device mode, an empty string uses the current hostname (the JetKVM device's IP) as the API endpoint
export const DEVICE_API = ""; export const DEVICE_API = "";

View File

@ -27,6 +27,7 @@ func triggerVideoStateUpdate() {
} }
func rpcGetVideoState() (native.VideoState, error) { func rpcGetVideoState() (native.VideoState, error) {
notifyFailsafeMode(currentSession)
return lastVideoState, nil return lastVideoState, nil
} }

View File

@ -289,6 +289,7 @@ func newSession(config SessionConfig) (*Session, error) {
triggerOTAStateUpdate() triggerOTAStateUpdate()
triggerVideoStateUpdate() triggerVideoStateUpdate()
triggerUSBStateUpdate() triggerUSBStateUpdate()
notifyFailsafeMode(session)
case "terminal": case "terminal":
handleTerminalChannel(d) handleTerminalChannel(d)
case "serial": case "serial":
@ -391,10 +392,12 @@ func newSession(config SessionConfig) (*Session, error) {
} }
func onActiveSessionsChanged() { func onActiveSessionsChanged() {
notifyFailsafeMode(currentSession)
requestDisplayUpdate(true, "active_sessions_changed") requestDisplayUpdate(true, "active_sessions_changed")
} }
func onFirstSessionConnected() { func onFirstSessionConnected() {
notifyFailsafeMode(currentSession)
_ = nativeInstance.VideoStart() _ = nativeInstance.VideoStart()
stopVideoSleepModeTicker() stopVideoSleepModeTicker()
} }