Merge branch 'dev' into chore/es-lint-redux

This commit is contained in:
Marc Brooks 2025-11-20 12:54:17 -06:00 committed by GitHub
commit 02ba560027
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 113 additions and 109 deletions

View File

@ -2,8 +2,8 @@ BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE := $(shell date -u +%FT%T%z) BUILDDATE := $(shell date -u +%FT%T%z)
BUILDTS := $(shell date -u +%s) BUILDTS := $(shell date -u +%s)
REVISION := $(shell git rev-parse HEAD) REVISION := $(shell git rev-parse HEAD)
VERSION_DEV := 0.4.9-dev$(shell date +%Y%m%d%H%M) VERSION_DEV := 0.5.0-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.4.8 VERSION := 0.4.9
PROMETHEUS_TAG := github.com/prometheus/common/version PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm KVM_PKG_NAME := github.com/jetkvm/kvm

View File

@ -5,6 +5,8 @@ import (
"os" "os"
"strings" "strings"
"sync" "sync"
"github.com/jetkvm/kvm/internal/supervisor"
) )
const ( const (
@ -78,6 +80,10 @@ func checkFailsafeReason() {
// TODO: read the goroutine stack trace and check which goroutine is panicking // TODO: read the goroutine stack trace and check which goroutine is panicking
failsafeModeActive = true failsafeModeActive = true
if strings.Contains(failsafeCrashLog, supervisor.FailsafeReasonVideoMaxRestartAttemptsReached) {
failsafeModeReason = "video"
return
}
if strings.Contains(failsafeCrashLog, "runtime.cgocall") { if strings.Contains(failsafeCrashLog, "runtime.cgocall") {
failsafeModeReason = "video" failsafeModeReason = "video"
return return

View File

@ -79,6 +79,18 @@ func NewGRPCClient(opts grpcClientOptions) (*GRPCClient, error) {
// Start event stream // Start event stream
go grpcClient.startEventStream() go grpcClient.startEventStream()
// Start event handler to process events from the channel
go func() {
for {
select {
case event := <-grpcClient.eventCh:
grpcClient.handleEvent(event)
case <-grpcClient.eventDone:
return
}
}
}()
return grpcClient, nil return grpcClient, nil
} }
@ -234,20 +246,6 @@ func (c *GRPCClient) handleEvent(event *pb.Event) {
} }
} }
// OnEvent registers an event handler
func (c *GRPCClient) OnEvent(eventType string, handler func(data interface{})) {
go func() {
for {
select {
case event := <-c.eventCh:
c.handleEvent(event)
case <-c.eventDone:
return
}
}
}()
}
// Close closes the gRPC client // Close closes the gRPC client
func (c *GRPCClient) Close() error { func (c *GRPCClient) Close() error {
c.closeM.Lock() c.closeM.Lock()

View File

@ -15,18 +15,20 @@ import (
// grpcServer wraps the Native instance and implements the gRPC service // grpcServer wraps the Native instance and implements the gRPC service
type grpcServer struct { type grpcServer struct {
pb.UnimplementedNativeServiceServer pb.UnimplementedNativeServiceServer
native *Native native *Native
logger *zerolog.Logger logger *zerolog.Logger
eventChs []chan *pb.Event eventStreamChan chan *pb.Event
eventM sync.Mutex eventStreamMu sync.Mutex
eventStreamCtx context.Context
eventStreamCancel context.CancelFunc
} }
// NewGRPCServer creates a new gRPC server for the native service // NewGRPCServer creates a new gRPC server for the native service
func NewGRPCServer(n *Native, logger *zerolog.Logger) *grpcServer { func NewGRPCServer(n *Native, logger *zerolog.Logger) *grpcServer {
s := &grpcServer{ s := &grpcServer{
native: n, native: n,
logger: logger, logger: logger,
eventChs: make([]chan *pb.Event, 0), eventStreamChan: make(chan *pb.Event, 100),
} }
// Store original callbacks and wrap them to also broadcast events // Store original callbacks and wrap them to also broadcast events
@ -82,16 +84,7 @@ func NewGRPCServer(n *Native, logger *zerolog.Logger) *grpcServer {
} }
func (s *grpcServer) broadcastEvent(event *pb.Event) { func (s *grpcServer) broadcastEvent(event *pb.Event) {
s.eventM.Lock() s.eventStreamChan <- event
defer s.eventM.Unlock()
for _, ch := range s.eventChs {
select {
case ch <- event:
default:
// Channel full, skip
}
}
} }
func (s *grpcServer) IsReady(ctx context.Context, req *pb.IsReadyRequest) (*pb.IsReadyResponse, error) { func (s *grpcServer) IsReady(ctx context.Context, req *pb.IsReadyRequest) (*pb.IsReadyResponse, error) {
@ -103,35 +96,49 @@ func (s *grpcServer) StreamEvents(req *pb.Empty, stream pb.NativeService_StreamE
setProcTitle("connected") setProcTitle("connected")
defer setProcTitle("waiting") defer setProcTitle("waiting")
eventCh := make(chan *pb.Event, 100) // Cancel previous stream if exists
s.eventStreamMu.Lock()
if s.eventStreamCancel != nil {
s.logger.Debug().Msg("cancelling previous StreamEvents call")
s.eventStreamCancel()
}
// Register this channel for events // Create a cancellable context for this stream
s.eventM.Lock() ctx, cancel := context.WithCancel(stream.Context())
s.eventChs = append(s.eventChs, eventCh) s.eventStreamCtx = ctx
s.eventM.Unlock() s.eventStreamCancel = cancel
s.eventStreamMu.Unlock()
// Unregister on exit // Clean up when this stream ends
defer func() { defer func() {
s.eventM.Lock() s.eventStreamMu.Lock()
defer s.eventM.Unlock() defer s.eventStreamMu.Unlock()
for i, ch := range s.eventChs { if s.eventStreamCtx == ctx {
if ch == eventCh { s.eventStreamCancel = nil
s.eventChs = append(s.eventChs[:i], s.eventChs[i+1:]...) s.eventStreamCtx = nil
break
}
} }
close(eventCh) cancel()
}() }()
// Stream events // Stream events
for { for {
select { select {
case event := <-eventCh: case event := <-s.eventStreamChan:
// Check if this stream is still the active one
s.eventStreamMu.Lock()
isActive := s.eventStreamCtx == ctx
s.eventStreamMu.Unlock()
if !isActive {
s.logger.Debug().Msg("stream replaced by new call, exiting")
return context.Canceled
}
if err := stream.Send(event); err != nil { if err := stream.Send(event); err != nil {
return err return err
} }
case <-stream.Context().Done(): case <-ctx.Done():
return stream.Context().Err() return ctx.Err()
} }
} }
} }

View File

@ -15,6 +15,7 @@ import (
"time" "time"
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
"github.com/jetkvm/kvm/internal/supervisor"
"github.com/jetkvm/kvm/internal/utils" "github.com/jetkvm/kvm/internal/utils"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@ -422,7 +423,7 @@ func (p *NativeProxy) restartProcess() error {
logger := p.logger.With().Uint("attempt", p.restarts).Uint("maxAttempts", p.options.MaxRestartAttempts).Logger() logger := p.logger.With().Uint("attempt", p.restarts).Uint("maxAttempts", p.options.MaxRestartAttempts).Logger()
if p.restarts >= p.options.MaxRestartAttempts { if p.restarts >= p.options.MaxRestartAttempts {
logger.Fatal().Msg("max restart attempts reached, exiting") logger.Fatal().Msgf("max restart attempts reached, exiting: %s", supervisor.FailsafeReasonVideoMaxRestartAttemptsReached)
return fmt.Errorf("max restart attempts reached") return fmt.Errorf("max restart attempts reached")
} }

View File

@ -30,7 +30,7 @@ type MDNSListenOptions struct {
// NetworkConfig represents the complete network configuration for an interface // NetworkConfig represents the complete network configuration for an interface
type NetworkConfig struct { type NetworkConfig struct {
DHCPClient null.String `json:"dhcp_client,omitempty" one_of:"jetdhcpc,udhcpc" default:"jetdhcpc"` DHCPClient null.String `json:"dhcp_client,omitempty" one_of:"jetdhcpc,udhcpc" default:"udhcpc"`
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"` Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"` HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"`

View File

@ -6,4 +6,6 @@ const (
ErrorDumpDir = "/userdata/jetkvm/crashdump" // The error dump directory is the directory where the error dumps are stored ErrorDumpDir = "/userdata/jetkvm/crashdump" // The error dump directory is the directory where the error dumps are stored
ErrorDumpLastFile = "last-crash.log" // The error dump last file is the last error dump file ErrorDumpLastFile = "last-crash.log" // The error dump last file is the last error dump file
ErrorDumpTemplate = "jetkvm-%s.log" // The error dump template is the template for the error dump file ErrorDumpTemplate = "jetkvm-%s.log" // The error dump template is the template for the error dump file
FailsafeReasonVideoMaxRestartAttemptsReached = "failsafe::video.max_restart_attempts_reached"
) )

View File

@ -70,9 +70,6 @@ func Main() {
initOta() initOta()
initNative(systemVersionLocal, appVersionLocal)
initDisplay()
http.DefaultClient.Timeout = 1 * time.Minute http.DefaultClient.Timeout = 1 * time.Minute
// Initialize network // Initialize network

View File

@ -284,6 +284,11 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
l.Info().Msg("IPv6 mode changed with udhcpc, reboot required") l.Info().Msg("IPv6 mode changed with udhcpc, reboot required")
} }
if newConfig.Hostname.String != oldConfig.Hostname.String {
rebootRequired = true
l.Info().Msg("Hostname changed, reboot required")
}
return rebootRequired, postRebootAction return rebootRequired, postRebootAction
} }

View File

@ -1,10 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { LuInfo } from "react-icons/lu";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import Card, { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { useVersion } from "@/hooks/useVersion"; import { useVersion } from "@/hooks/useVersion";
@ -32,39 +31,12 @@ function OverlayContent({ children }: OverlayContentProps) {
); );
} }
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="flex items-center justify-center gap-1 px-2 py-1 text-xs whitespace-nowrap">
<LuInfo className="h-3 w-3 text-slate-700 dark:text-slate-300" />
{text}
</div>
</Card>
</div>
</div>
);
}
export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) { export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
const { appVersion } = useVersion(); const { appVersion } = useVersion();
const { systemVersion } = useDeviceStore(); const { systemVersion } = useDeviceStore();
const [isDownloadingLogs, setIsDownloadingLogs] = useState(false); const [isDownloadingLogs, setIsDownloadingLogs] = useState(false);
const [hasDownloadedLogs, setHasDownloadedLogs] = useState(false);
const getReasonCopy = () => { const getReasonCopy = () => {
switch (reason) { switch (reason) {
@ -112,7 +84,6 @@ export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
notifications.success("Crash logs downloaded successfully"); notifications.success("Crash logs downloaded successfully");
setHasDownloadedLogs(true);
// Open GitHub issue // Open GitHub issue
const issueBody = `## Issue Description const issueBody = `## Issue Description
@ -143,7 +114,7 @@ Please attach the recovery logs file that was downloaded to your computer:
}; };
const handleDowngrade = () => { const handleDowngrade = () => {
navigateTo(`/settings/general/update?app=${DOWNGRADE_VERSION}`); navigateTo(`/settings/general/update?custom_app_version=${DOWNGRADE_VERSION}`);
}; };
return ( return (

View File

@ -5,13 +5,22 @@ interface SettingsItemProps {
readonly title: string; readonly title: string;
readonly description: string | React.ReactNode; readonly description: string | React.ReactNode;
readonly badge?: string; readonly badge?: string;
readonly badgeTheme?: keyof typeof badgeTheme;
readonly className?: string; readonly className?: string;
readonly loading?: boolean; readonly loading?: boolean;
readonly children?: React.ReactNode; readonly children?: React.ReactNode;
} }
const badgeTheme = {
info: "bg-blue-500 text-white",
success: "bg-green-500 text-white",
warning: "bg-yellow-500 text-white",
danger: "bg-red-500 text-white",
};
export function SettingsItem(props: SettingsItemProps) { export function SettingsItem(props: SettingsItemProps) {
const { title, description, badge, children, className, loading } = props; const { title, description, badge, badgeTheme: badgeThemeProp = "danger", children, className, loading } = props;
const badgeThemeClass = badgeTheme[badgeThemeProp];
return ( return (
<label <label
@ -22,7 +31,7 @@ export function SettingsItem(props: SettingsItemProps) {
<div className="flex items-center text-base font-semibold text-black dark:text-white"> <div className="flex items-center text-base font-semibold text-black dark:text-white">
{title} {title}
{badge && ( {badge && (
<span className="ml-2 rounded-full bg-red-500 px-2 py-1 text-[10px] leading-none font-medium text-white dark:border dark:border-red-700 dark:bg-red-800 dark:text-red-50"> <span className={cx("ml-2 rounded-full px-2 py-1 text-[10px] font-medium leading-none text-white", badgeThemeClass)}>
{badge} {badge}
</span> </span>
)} )}

View File

@ -528,7 +528,7 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
controlsList="nofullscreen" controlsList="nofullscreen"
style={videoStyle} style={videoStyle}
className={cx( className={cx(
"max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000", "max-h-full max-w-full sm:min-h-[384px] sm:min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
{ {
"cursor-none": settings.isCursorHidden, "cursor-none": settings.isCursorHidden,
"!opacity-0": "!opacity-0":

View File

@ -83,6 +83,8 @@ export default function SettingsGeneralRoute() {
<div className="space-y-4 pb-2"> <div className="space-y-4 pb-2">
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
badge="Beta"
badgeTheme="info"
title={m.user_interface_language_title()} title={m.user_interface_language_title()}
description={m.user_interface_language_description()} description={m.user_interface_language_description()}
> >

View File

@ -291,8 +291,16 @@ export default function SettingsNetworkRoute() {
}); });
} }
// If no critical fields are changed, save immediately if (dirty.hostname) {
if (changes.length === 0) return onSubmit(settings); changes.push({
label: m.network_hostname_title(),
from: initialSettingsRef.current?.hostname?.toString() ?? "",
to: data.hostname?.toString() ?? "",
});
}
// If no critical fields are changed, save immediately
if (changes.length === 0) return onSubmit(settings);
// Show confirmation dialog for critical changes // Show confirmation dialog for critical changes
setStagedSettings(settings); setStagedSettings(settings);

View File

@ -705,7 +705,14 @@ export default function KvmIdRoute() {
if (resp.method === "willReboot") { if (resp.method === "willReboot") {
const postRebootAction = resp.params as unknown as PostRebootAction; const postRebootAction = resp.params as unknown as PostRebootAction;
console.debug("Setting reboot state", postRebootAction); console.debug("Setting reboot state", postRebootAction);
setRebootState({ isRebooting: true, postRebootAction });
setRebootState({
isRebooting: true,
postRebootAction: {
healthCheck: postRebootAction?.healthCheck || `${window.location.origin}/device/status`,
redirectTo: postRebootAction?.redirectTo || window.location.href,
}
});
navigateTo("/"); navigateTo("/");
} }
@ -830,6 +837,10 @@ export default function KvmIdRoute() {
return <RebootingOverlay show={true} postRebootAction={rebootState.postRebootAction} />; return <RebootingOverlay show={true} postRebootAction={rebootState.postRebootAction} />;
} }
if (isFailsafeMode && failsafeReason) {
return <FailSafeModeOverlay reason={failsafeReason} />;
}
const hasConnectionFailed = const hasConnectionFailed =
connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? ""); connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? "");
@ -851,16 +862,7 @@ export default function KvmIdRoute() {
} }
return null; return null;
}, [ }, [location.pathname, rebootState?.isRebooting, rebootState?.postRebootAction, isFailsafeMode, failsafeReason, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]);
location.pathname,
rebootState?.isRebooting,
rebootState?.postRebootAction,
connectionFailed,
peerConnectionState,
peerConnection,
setupPeerConnection,
loadingMessage,
]);
return ( return (
<FeatureFlagProvider appVersion={appVersion}> <FeatureFlagProvider appVersion={appVersion}>
@ -909,12 +911,8 @@ export default function KvmIdRoute() {
style={{ animationDuration: "500ms" }} style={{ animationDuration: "500ms" }}
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center p-4" className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center p-4"
> >
<div className="relative h-full max-h-[720px] w-full max-w-7xl rounded-md"> <div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
{isFailsafeMode && failsafeReason ? ( {!!ConnectionStatusElement && ConnectionStatusElement}
<FailSafeModeOverlay reason={failsafeReason} />
) : (
!!ConnectionStatusElement && ConnectionStatusElement
)}
</div> </div>
</div> </div>
<SidebarContainer sidebarView={sidebarView} /> <SidebarContainer sidebarView={sidebarView} />

View File

@ -33,7 +33,7 @@ export default defineConfig(({ mode, command }) => {
outdir: "./localization/paraglide", outdir: "./localization/paraglide",
outputStructure: 'message-modules', outputStructure: 'message-modules',
cookieName: 'JETKVM_LOCALE', cookieName: 'JETKVM_LOCALE',
strategy: ['cookie', 'preferredLanguage', 'baseLocale'], strategy: ['cookie', 'baseLocale'],
})) }))
return { return {