mirror of https://github.com/jetkvm/kvm.git
Merge branch 'dev' into chore/es-lint-redux
This commit is contained in:
commit
02ba560027
4
Makefile
4
Makefile
|
|
@ -2,8 +2,8 @@ BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
|
|||
BUILDDATE := $(shell date -u +%FT%T%z)
|
||||
BUILDTS := $(shell date -u +%s)
|
||||
REVISION := $(shell git rev-parse HEAD)
|
||||
VERSION_DEV := 0.4.9-dev$(shell date +%Y%m%d%H%M)
|
||||
VERSION := 0.4.8
|
||||
VERSION_DEV := 0.5.0-dev$(shell date +%Y%m%d%H%M)
|
||||
VERSION := 0.4.9
|
||||
|
||||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||
KVM_PKG_NAME := github.com/jetkvm/kvm
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/supervisor"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -78,6 +80,10 @@ func checkFailsafeReason() {
|
|||
|
||||
// TODO: read the goroutine stack trace and check which goroutine is panicking
|
||||
failsafeModeActive = true
|
||||
if strings.Contains(failsafeCrashLog, supervisor.FailsafeReasonVideoMaxRestartAttemptsReached) {
|
||||
failsafeModeReason = "video"
|
||||
return
|
||||
}
|
||||
if strings.Contains(failsafeCrashLog, "runtime.cgocall") {
|
||||
failsafeModeReason = "video"
|
||||
return
|
||||
|
|
|
|||
|
|
@ -79,6 +79,18 @@ func NewGRPCClient(opts grpcClientOptions) (*GRPCClient, error) {
|
|||
// Start event stream
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
func (c *GRPCClient) Close() error {
|
||||
c.closeM.Lock()
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ type grpcServer struct {
|
|||
pb.UnimplementedNativeServiceServer
|
||||
native *Native
|
||||
logger *zerolog.Logger
|
||||
eventChs []chan *pb.Event
|
||||
eventM sync.Mutex
|
||||
eventStreamChan chan *pb.Event
|
||||
eventStreamMu sync.Mutex
|
||||
eventStreamCtx context.Context
|
||||
eventStreamCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewGRPCServer creates a new gRPC server for the native service
|
||||
|
|
@ -26,7 +28,7 @@ func NewGRPCServer(n *Native, logger *zerolog.Logger) *grpcServer {
|
|||
s := &grpcServer{
|
||||
native: n,
|
||||
logger: logger,
|
||||
eventChs: make([]chan *pb.Event, 0),
|
||||
eventStreamChan: make(chan *pb.Event, 100),
|
||||
}
|
||||
|
||||
// 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) {
|
||||
s.eventM.Lock()
|
||||
defer s.eventM.Unlock()
|
||||
|
||||
for _, ch := range s.eventChs {
|
||||
select {
|
||||
case ch <- event:
|
||||
default:
|
||||
// Channel full, skip
|
||||
}
|
||||
}
|
||||
s.eventStreamChan <- event
|
||||
}
|
||||
|
||||
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")
|
||||
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
|
||||
s.eventM.Lock()
|
||||
s.eventChs = append(s.eventChs, eventCh)
|
||||
s.eventM.Unlock()
|
||||
// Create a cancellable context for this stream
|
||||
ctx, cancel := context.WithCancel(stream.Context())
|
||||
s.eventStreamCtx = ctx
|
||||
s.eventStreamCancel = cancel
|
||||
s.eventStreamMu.Unlock()
|
||||
|
||||
// Unregister on exit
|
||||
// Clean up when this stream ends
|
||||
defer func() {
|
||||
s.eventM.Lock()
|
||||
defer s.eventM.Unlock()
|
||||
for i, ch := range s.eventChs {
|
||||
if ch == eventCh {
|
||||
s.eventChs = append(s.eventChs[:i], s.eventChs[i+1:]...)
|
||||
break
|
||||
s.eventStreamMu.Lock()
|
||||
defer s.eventStreamMu.Unlock()
|
||||
if s.eventStreamCtx == ctx {
|
||||
s.eventStreamCancel = nil
|
||||
s.eventStreamCtx = nil
|
||||
}
|
||||
}
|
||||
close(eventCh)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Stream events
|
||||
for {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
case <-stream.Context().Done():
|
||||
return stream.Context().Err()
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/jetkvm/kvm/internal/supervisor"
|
||||
"github.com/jetkvm/kvm/internal/utils"
|
||||
"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()
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ type MDNSListenOptions struct {
|
|||
|
||||
// NetworkConfig represents the complete network configuration for an interface
|
||||
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"`
|
||||
HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"`
|
||||
|
|
|
|||
|
|
@ -6,4 +6,6 @@ const (
|
|||
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
|
||||
ErrorDumpTemplate = "jetkvm-%s.log" // The error dump template is the template for the error dump file
|
||||
|
||||
FailsafeReasonVideoMaxRestartAttemptsReached = "failsafe::video.max_restart_attempts_reached"
|
||||
)
|
||||
|
|
|
|||
3
main.go
3
main.go
|
|
@ -70,9 +70,6 @@ func Main() {
|
|||
|
||||
initOta()
|
||||
|
||||
initNative(systemVersionLocal, appVersionLocal)
|
||||
initDisplay()
|
||||
|
||||
http.DefaultClient.Timeout = 1 * time.Minute
|
||||
|
||||
// Initialize network
|
||||
|
|
|
|||
|
|
@ -284,6 +284,11 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
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 { GridCard } from "@components/Card";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||
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) {
|
||||
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) {
|
||||
|
|
@ -112,7 +84,6 @@ export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) {
|
|||
URL.revokeObjectURL(url);
|
||||
|
||||
notifications.success("Crash logs downloaded successfully");
|
||||
setHasDownloadedLogs(true);
|
||||
|
||||
// Open GitHub issue
|
||||
const issueBody = `## Issue Description
|
||||
|
|
@ -143,7 +114,7 @@ Please attach the recovery logs file that was downloaded to your computer:
|
|||
};
|
||||
|
||||
const handleDowngrade = () => {
|
||||
navigateTo(`/settings/general/update?app=${DOWNGRADE_VERSION}`);
|
||||
navigateTo(`/settings/general/update?custom_app_version=${DOWNGRADE_VERSION}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -5,13 +5,22 @@ interface SettingsItemProps {
|
|||
readonly title: string;
|
||||
readonly description: string | React.ReactNode;
|
||||
readonly badge?: string;
|
||||
readonly badgeTheme?: keyof typeof badgeTheme;
|
||||
readonly className?: string;
|
||||
readonly loading?: boolean;
|
||||
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) {
|
||||
const { title, description, badge, children, className, loading } = props;
|
||||
const { title, description, badge, badgeTheme: badgeThemeProp = "danger", children, className, loading } = props;
|
||||
const badgeThemeClass = badgeTheme[badgeThemeProp];
|
||||
|
||||
return (
|
||||
<label
|
||||
|
|
@ -22,7 +31,7 @@ export function SettingsItem(props: SettingsItemProps) {
|
|||
<div className="flex items-center text-base font-semibold text-black dark:text-white">
|
||||
{title}
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -528,7 +528,7 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
|
|||
controlsList="nofullscreen"
|
||||
style={videoStyle}
|
||||
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,
|
||||
"!opacity-0":
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@ export default function SettingsGeneralRoute() {
|
|||
<div className="space-y-4 pb-2">
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
badge="Beta"
|
||||
badgeTheme="info"
|
||||
title={m.user_interface_language_title()}
|
||||
description={m.user_interface_language_description()}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -291,6 +291,14 @@ export default function SettingsNetworkRoute() {
|
|||
});
|
||||
}
|
||||
|
||||
if (dirty.hostname) {
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -705,7 +705,14 @@ export default function KvmIdRoute() {
|
|||
if (resp.method === "willReboot") {
|
||||
const postRebootAction = resp.params as unknown as 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("/");
|
||||
}
|
||||
|
||||
|
|
@ -830,6 +837,10 @@ export default function KvmIdRoute() {
|
|||
return <RebootingOverlay show={true} postRebootAction={rebootState.postRebootAction} />;
|
||||
}
|
||||
|
||||
if (isFailsafeMode && failsafeReason) {
|
||||
return <FailSafeModeOverlay reason={failsafeReason} />;
|
||||
}
|
||||
|
||||
const hasConnectionFailed =
|
||||
connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? "");
|
||||
|
||||
|
|
@ -851,16 +862,7 @@ export default function KvmIdRoute() {
|
|||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
location.pathname,
|
||||
rebootState?.isRebooting,
|
||||
rebootState?.postRebootAction,
|
||||
connectionFailed,
|
||||
peerConnectionState,
|
||||
peerConnection,
|
||||
setupPeerConnection,
|
||||
loadingMessage,
|
||||
]);
|
||||
}, [location.pathname, rebootState?.isRebooting, rebootState?.postRebootAction, isFailsafeMode, failsafeReason, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]);
|
||||
|
||||
return (
|
||||
<FeatureFlagProvider appVersion={appVersion}>
|
||||
|
|
@ -909,12 +911,8 @@ export default function KvmIdRoute() {
|
|||
style={{ animationDuration: "500ms" }}
|
||||
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">
|
||||
{isFailsafeMode && failsafeReason ? (
|
||||
<FailSafeModeOverlay reason={failsafeReason} />
|
||||
) : (
|
||||
!!ConnectionStatusElement && ConnectionStatusElement
|
||||
)}
|
||||
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
||||
{!!ConnectionStatusElement && ConnectionStatusElement}
|
||||
</div>
|
||||
</div>
|
||||
<SidebarContainer sidebarView={sidebarView} />
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export default defineConfig(({ mode, command }) => {
|
|||
outdir: "./localization/paraglide",
|
||||
outputStructure: 'message-modules',
|
||||
cookieName: 'JETKVM_LOCALE',
|
||||
strategy: ['cookie', 'preferredLanguage', 'baseLocale'],
|
||||
strategy: ['cookie', 'baseLocale'],
|
||||
}))
|
||||
|
||||
return {
|
||||
|
|
|
|||
Loading…
Reference in New Issue