diff --git a/cmd/maxx/main.go b/cmd/maxx/main.go index 6dd9991f..92c6f62d 100644 --- a/cmd/maxx/main.go +++ b/cmd/maxx/main.go @@ -7,9 +7,11 @@ import ( "log" "net/http" "os" + "os/exec" "os/signal" "path/filepath" "syscall" + "sync/atomic" "time" "github.com/awsl-project/maxx/internal/adapter/client" @@ -399,6 +401,81 @@ func main() { codexOAuthServer := core.NewCodexOAuthServer(codexHandler) codexHandler.SetOAuthServer(codexOAuthServer) + var restartInProgress int32 + + shutdownServer := func(reason string) { + log.Printf("Initiating graceful shutdown (%s)...", reason) + + // Step 1: Wait for active proxy requests to complete + activeCount := requestTracker.ActiveCount() + if activeCount > 0 { + log.Printf("Waiting for %d active proxy requests to complete...", activeCount) + completed := requestTracker.GracefulShutdown(core.GracefulShutdownTimeout) + if !completed { + log.Printf("Graceful shutdown timeout, some requests may be interrupted") + } else { + log.Printf("All proxy requests completed successfully") + } + } else { + // Mark as shutting down to reject new requests + requestTracker.GracefulShutdown(0) + log.Printf("No active proxy requests") + } + + // Step 2: Stop pprof manager + shutdownCtx, cancel := context.WithTimeout(context.Background(), core.HTTPShutdownTimeout) + defer cancel() + + // Stop background cleanup task + cleanupCancel() + + // Stop pprof manager + if err := pprofMgr.Stop(shutdownCtx); err != nil { + log.Printf("Warning: Failed to stop pprof manager: %v", err) + } + + // Stop Codex OAuth server + if err := codexOAuthServer.Stop(shutdownCtx); err != nil { + log.Printf("Warning: Failed to stop Codex OAuth server: %v", err) + } + + // Step 3: Shutdown HTTP server + if err := server.Shutdown(shutdownCtx); err != nil { + log.Printf("HTTP server graceful shutdown failed: %v, forcing close", err) + if closeErr := server.Close(); closeErr != nil { + log.Printf("Force close error: %v", closeErr) + } + } + } + + restartServer := func() error { + if !atomic.CompareAndSwapInt32(&restartInProgress, 0, 1) { + return fmt.Errorf("restart already in progress") + } + + shutdownServer("restart") + + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to locate executable: %w", err) + } + + cmd := exec.Command(executable, os.Args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start new process: %w", err) + } + + log.Printf("[Admin] Started new process (pid=%d). Exiting current process.", cmd.Process.Pid) + os.Exit(0) + return nil + } + + adminHandler.SetRestartFunc(restartServer) + // Start server in goroutine log.Printf("Starting Maxx server %s on %s", version.Info(), *addr) log.Printf("Data directory: %s", dataDirPath) @@ -425,47 +502,7 @@ func main() { signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) sig := <-sigCh log.Printf("Received signal %v, initiating graceful shutdown...", sig) - - // Step 1: Wait for active proxy requests to complete - activeCount := requestTracker.ActiveCount() - if activeCount > 0 { - log.Printf("Waiting for %d active proxy requests to complete...", activeCount) - completed := requestTracker.GracefulShutdown(core.GracefulShutdownTimeout) - if !completed { - log.Printf("Graceful shutdown timeout, some requests may be interrupted") - } else { - log.Printf("All proxy requests completed successfully") - } - } else { - // Mark as shutting down to reject new requests - requestTracker.GracefulShutdown(0) - log.Printf("No active proxy requests") - } - - // Step 2: Stop pprof manager - shutdownCtx, cancel := context.WithTimeout(context.Background(), core.HTTPShutdownTimeout) - defer cancel() - - // Stop background cleanup task - cleanupCancel() - - // Stop pprof manager - if err := pprofMgr.Stop(shutdownCtx); err != nil { - log.Printf("Warning: Failed to stop pprof manager: %v", err) - } - - // Stop Codex OAuth server - if err := codexOAuthServer.Stop(shutdownCtx); err != nil { - log.Printf("Warning: Failed to stop Codex OAuth server: %v", err) - } - - // Step 3: Shutdown HTTP server - if err := server.Shutdown(shutdownCtx); err != nil { - log.Printf("HTTP server graceful shutdown failed: %v, forcing close", err) - if closeErr := server.Close(); closeErr != nil { - log.Printf("Force close error: %v", closeErr) - } - } + shutdownServer(fmt.Sprintf("signal %v", sig)) log.Printf("Server stopped") } diff --git a/internal/desktop/launcher.go b/internal/desktop/launcher.go index 73cea37e..7045ac11 100644 --- a/internal/desktop/launcher.go +++ b/internal/desktop/launcher.go @@ -206,6 +206,11 @@ func (a *LauncherApp) startServerAsync() { } a.components = components + // Allow Web admin endpoint to trigger desktop restart + if components.AdminHandler != nil { + components.AdminHandler.SetRestartFunc(a.RestartServer) + } + // 设置 Wails context 用于事件广播 if components.WailsBroadcaster != nil { components.WailsBroadcaster.SetContext(a.ctx) diff --git a/internal/handler/admin.go b/internal/handler/admin.go index f18dadc1..b9db7eea 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -21,6 +21,7 @@ type AdminHandler struct { svc *service.AdminService backupSvc *service.BackupService logPath string + restartFn func() error } // NewAdminHandler creates a new admin handler @@ -32,6 +33,11 @@ func NewAdminHandler(svc *service.AdminService, backupSvc *service.BackupService } } +// SetRestartFunc sets the restart callback for admin restart endpoint. +func (h *AdminHandler) SetRestartFunc(fn func() error) { + h.restartFn = fn +} + // ServeHTTP routes admin requests func (h *AdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/admin") @@ -50,6 +56,8 @@ func (h *AdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } switch resource { + case "restart": + h.handleRestart(w, r) case "providers": h.handleProviders(w, r, id) case "routes": @@ -99,6 +107,27 @@ func (h *AdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +func (h *AdminHandler) handleRestart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + if h.restartFn == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "restart not supported"}) + return + } + + go func() { + if err := h.restartFn(); err != nil { + log.Printf("[Admin] Restart failed: %v", err) + } + }() + + writeJSON(w, http.StatusAccepted, map[string]string{"status": "restarting"}) +} + // Provider handlers func (h *AdminHandler) handleProviders(w http.ResponseWriter, r *http.Request, id uint64) { // Check for special endpoints diff --git a/web/src/components/layout/app-sidebar/nav-user.tsx b/web/src/components/layout/app-sidebar/nav-user.tsx index 70083f31..a799e5ea 100644 --- a/web/src/components/layout/app-sidebar/nav-user.tsx +++ b/web/src/components/layout/app-sidebar/nav-user.tsx @@ -1,8 +1,9 @@ 'use client'; -import { Moon, Sun, Laptop, Sparkles, Gem, Github, ChevronsUp } from 'lucide-react'; +import { Moon, Sun, Laptop, Sparkles, Gem, Github, ChevronsUp, RefreshCw } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useTheme } from '@/components/theme-provider'; +import { useTransport } from '@/lib/transport/context'; import type { Theme } from '@/lib/theme'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { cn } from '@/lib/utils'; @@ -13,6 +14,7 @@ import { DropdownMenuTrigger, DropdownMenuGroup, DropdownMenuLabel, + DropdownMenuItem, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, @@ -29,6 +31,7 @@ import { export function NavUser() { const { isMobile, state } = useSidebar(); const { t, i18n } = useTranslation(); + const { transport } = useTransport(); const { theme, setTheme } = useTheme(); const isCollapsed = !isMobile && state === 'collapsed'; const currentLanguage = (i18n.resolvedLanguage || i18n.language || 'en').toLowerCase().startsWith('zh') @@ -36,11 +39,37 @@ export function NavUser() { : 'en'; const currentLanguageLabel = currentLanguage === 'zh' ? t('settings.languages.zh') : t('settings.languages.en'); + const desktopRestartAvailable = + typeof window !== 'undefined' && + !!(window as unknown as { go?: { desktop?: { LauncherApp?: { RestartServer?: () => unknown } } } }) + .go?.desktop?.LauncherApp?.RestartServer; const handleToggleLanguage = () => { i18n.changeLanguage(currentLanguage === 'zh' ? 'en' : 'zh'); }; + const handleRestartServer = async () => { + if (!window.confirm(t('nav.restartServerConfirm'))) return; + try { + if (desktopRestartAvailable) { + const launcher = (window as unknown as { + go?: { desktop?: { LauncherApp?: { RestartServer?: () => Promise } } }; + }).go?.desktop?.LauncherApp; + if (!launcher?.RestartServer) { + throw new Error('Desktop restart is unavailable.'); + } + await launcher.RestartServer(); + return; + } + await transport.restartServer(); + } catch (error) { + console.error('Restart server failed:', error); + if (typeof window !== 'undefined') { + window.alert(t('nav.restartServerFailed')); + } + } + }; + const user = { name: 'Maxx', avatar: '/logo.png', @@ -123,8 +152,8 @@ export function NavUser() { )} /> + <> + + + + {t('nav.restartServer')} + + @@ -202,3 +238,4 @@ export function NavUser() { ); } + diff --git a/web/src/lib/transport/http-transport.ts b/web/src/lib/transport/http-transport.ts index d3a7ba8c..2c69db79 100644 --- a/web/src/lib/transport/http-transport.ts +++ b/web/src/lib/transport/http-transport.ts @@ -323,6 +323,12 @@ export class HttpTransport implements Transport { return data; } + // ===== System API ===== + + async restartServer(): Promise { + await this.client.post('/restart'); + } + // ===== Provider Stats API ===== async getProviderStats( diff --git a/web/src/lib/transport/interface.ts b/web/src/lib/transport/interface.ts index 5e474a38..b7ec024e 100644 --- a/web/src/lib/transport/interface.ts +++ b/web/src/lib/transport/interface.ts @@ -117,6 +117,9 @@ export interface Transport { // ===== Proxy Status API ===== getProxyStatus(): Promise; + // ===== System API ===== + restartServer(): Promise; + // ===== Provider Stats API ===== getProviderStats(clientType?: string, projectId?: number): Promise>; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 93971a39..8074461c 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -83,6 +83,9 @@ "account": "Account", "notifications": "Notifications", "theme": "Theme", + "restartServer": "Restart Server", + "restartServerConfirm": "Restart the server? Active connections may be briefly interrupted.", + "restartServerFailed": "Restart failed. Please check the server status.", "language": "Language", "logout": "Log out" }, diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index 67c93712..a3a40ed9 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -83,6 +83,9 @@ "account": "账户", "notifications": "通知", "theme": "主题", + "restartServer": "重启服务器", + "restartServerConfirm": "确定重启服务器?当前连接可能会短暂中断。", + "restartServerFailed": "重启失败,请检查服务状态。", "language": "语言", "logout": "退出登录" },