Skip to content

Commit 2b7964a

Browse files
committed
feat: Web端支持重启服务器
1 parent 8bfe2e5 commit 2b7964a

File tree

8 files changed

+167
-44
lines changed

8 files changed

+167
-44
lines changed

cmd/maxx/main.go

Lines changed: 78 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import (
77
"log"
88
"net/http"
99
"os"
10+
"os/exec"
1011
"os/signal"
1112
"path/filepath"
1213
"syscall"
14+
"sync/atomic"
1315
"time"
1416

1517
"github.com/awsl-project/maxx/internal/adapter/client"
@@ -399,6 +401,81 @@ func main() {
399401
codexOAuthServer := core.NewCodexOAuthServer(codexHandler)
400402
codexHandler.SetOAuthServer(codexOAuthServer)
401403

404+
var restartInProgress int32
405+
406+
shutdownServer := func(reason string) {
407+
log.Printf("Initiating graceful shutdown (%s)...", reason)
408+
409+
// Step 1: Wait for active proxy requests to complete
410+
activeCount := requestTracker.ActiveCount()
411+
if activeCount > 0 {
412+
log.Printf("Waiting for %d active proxy requests to complete...", activeCount)
413+
completed := requestTracker.GracefulShutdown(core.GracefulShutdownTimeout)
414+
if !completed {
415+
log.Printf("Graceful shutdown timeout, some requests may be interrupted")
416+
} else {
417+
log.Printf("All proxy requests completed successfully")
418+
}
419+
} else {
420+
// Mark as shutting down to reject new requests
421+
requestTracker.GracefulShutdown(0)
422+
log.Printf("No active proxy requests")
423+
}
424+
425+
// Step 2: Stop pprof manager
426+
shutdownCtx, cancel := context.WithTimeout(context.Background(), core.HTTPShutdownTimeout)
427+
defer cancel()
428+
429+
// Stop background cleanup task
430+
cleanupCancel()
431+
432+
// Stop pprof manager
433+
if err := pprofMgr.Stop(shutdownCtx); err != nil {
434+
log.Printf("Warning: Failed to stop pprof manager: %v", err)
435+
}
436+
437+
// Stop Codex OAuth server
438+
if err := codexOAuthServer.Stop(shutdownCtx); err != nil {
439+
log.Printf("Warning: Failed to stop Codex OAuth server: %v", err)
440+
}
441+
442+
// Step 3: Shutdown HTTP server
443+
if err := server.Shutdown(shutdownCtx); err != nil {
444+
log.Printf("HTTP server graceful shutdown failed: %v, forcing close", err)
445+
if closeErr := server.Close(); closeErr != nil {
446+
log.Printf("Force close error: %v", closeErr)
447+
}
448+
}
449+
}
450+
451+
restartServer := func() error {
452+
if !atomic.CompareAndSwapInt32(&restartInProgress, 0, 1) {
453+
return fmt.Errorf("restart already in progress")
454+
}
455+
456+
shutdownServer("restart")
457+
458+
executable, err := os.Executable()
459+
if err != nil {
460+
return fmt.Errorf("failed to locate executable: %w", err)
461+
}
462+
463+
cmd := exec.Command(executable, os.Args[1:]...)
464+
cmd.Stdout = os.Stdout
465+
cmd.Stderr = os.Stderr
466+
cmd.Env = os.Environ()
467+
468+
if err := cmd.Start(); err != nil {
469+
return fmt.Errorf("failed to start new process: %w", err)
470+
}
471+
472+
log.Printf("[Admin] Started new process (pid=%d). Exiting current process.", cmd.Process.Pid)
473+
os.Exit(0)
474+
return nil
475+
}
476+
477+
adminHandler.SetRestartFunc(restartServer)
478+
402479
// Start server in goroutine
403480
log.Printf("Starting Maxx server %s on %s", version.Info(), *addr)
404481
log.Printf("Data directory: %s", dataDirPath)
@@ -425,47 +502,7 @@ func main() {
425502
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
426503
sig := <-sigCh
427504
log.Printf("Received signal %v, initiating graceful shutdown...", sig)
428-
429-
// Step 1: Wait for active proxy requests to complete
430-
activeCount := requestTracker.ActiveCount()
431-
if activeCount > 0 {
432-
log.Printf("Waiting for %d active proxy requests to complete...", activeCount)
433-
completed := requestTracker.GracefulShutdown(core.GracefulShutdownTimeout)
434-
if !completed {
435-
log.Printf("Graceful shutdown timeout, some requests may be interrupted")
436-
} else {
437-
log.Printf("All proxy requests completed successfully")
438-
}
439-
} else {
440-
// Mark as shutting down to reject new requests
441-
requestTracker.GracefulShutdown(0)
442-
log.Printf("No active proxy requests")
443-
}
444-
445-
// Step 2: Stop pprof manager
446-
shutdownCtx, cancel := context.WithTimeout(context.Background(), core.HTTPShutdownTimeout)
447-
defer cancel()
448-
449-
// Stop background cleanup task
450-
cleanupCancel()
451-
452-
// Stop pprof manager
453-
if err := pprofMgr.Stop(shutdownCtx); err != nil {
454-
log.Printf("Warning: Failed to stop pprof manager: %v", err)
455-
}
456-
457-
// Stop Codex OAuth server
458-
if err := codexOAuthServer.Stop(shutdownCtx); err != nil {
459-
log.Printf("Warning: Failed to stop Codex OAuth server: %v", err)
460-
}
461-
462-
// Step 3: Shutdown HTTP server
463-
if err := server.Shutdown(shutdownCtx); err != nil {
464-
log.Printf("HTTP server graceful shutdown failed: %v, forcing close", err)
465-
if closeErr := server.Close(); closeErr != nil {
466-
log.Printf("Force close error: %v", closeErr)
467-
}
468-
}
505+
shutdownServer(fmt.Sprintf("signal %v", sig))
469506

470507
log.Printf("Server stopped")
471508
}

internal/desktop/launcher.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ func (a *LauncherApp) startServerAsync() {
206206
}
207207
a.components = components
208208

209+
// Allow Web admin endpoint to trigger desktop restart
210+
if components.AdminHandler != nil {
211+
components.AdminHandler.SetRestartFunc(a.RestartServer)
212+
}
213+
209214
// 设置 Wails context 用于事件广播
210215
if components.WailsBroadcaster != nil {
211216
components.WailsBroadcaster.SetContext(a.ctx)

internal/handler/admin.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type AdminHandler struct {
2121
svc *service.AdminService
2222
backupSvc *service.BackupService
2323
logPath string
24+
restartFn func() error
2425
}
2526

2627
// NewAdminHandler creates a new admin handler
@@ -32,6 +33,11 @@ func NewAdminHandler(svc *service.AdminService, backupSvc *service.BackupService
3233
}
3334
}
3435

36+
// SetRestartFunc sets the restart callback for admin restart endpoint.
37+
func (h *AdminHandler) SetRestartFunc(fn func() error) {
38+
h.restartFn = fn
39+
}
40+
3541
// ServeHTTP routes admin requests
3642
func (h *AdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
3743
path := strings.TrimPrefix(r.URL.Path, "/admin")
@@ -50,6 +56,8 @@ func (h *AdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
5056
}
5157

5258
switch resource {
59+
case "restart":
60+
h.handleRestart(w, r)
5361
case "providers":
5462
h.handleProviders(w, r, id)
5563
case "routes":
@@ -99,6 +107,27 @@ func (h *AdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
99107
}
100108
}
101109

110+
func (h *AdminHandler) handleRestart(w http.ResponseWriter, r *http.Request) {
111+
if r.Method != http.MethodPost {
112+
w.Header().Set("Allow", http.MethodPost)
113+
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
114+
return
115+
}
116+
117+
if h.restartFn == nil {
118+
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "restart not supported"})
119+
return
120+
}
121+
122+
go func() {
123+
if err := h.restartFn(); err != nil {
124+
log.Printf("[Admin] Restart failed: %v", err)
125+
}
126+
}()
127+
128+
writeJSON(w, http.StatusAccepted, map[string]string{"status": "restarting"})
129+
}
130+
102131
// Provider handlers
103132
func (h *AdminHandler) handleProviders(w http.ResponseWriter, r *http.Request, id uint64) {
104133
// Check for special endpoints

web/src/components/layout/app-sidebar/nav-user.tsx

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
'use client';
22

3-
import { Moon, Sun, Laptop, Sparkles, Gem, Github, ChevronsUp } from 'lucide-react';
3+
import { Moon, Sun, Laptop, Sparkles, Gem, Github, ChevronsUp, RefreshCw } from 'lucide-react';
44
import { useTranslation } from 'react-i18next';
55
import { useTheme } from '@/components/theme-provider';
6+
import { useTransport } from '@/lib/transport/context';
67
import type { Theme } from '@/lib/theme';
78
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
89
import { cn } from '@/lib/utils';
@@ -13,6 +14,7 @@ import {
1314
DropdownMenuTrigger,
1415
DropdownMenuGroup,
1516
DropdownMenuLabel,
17+
DropdownMenuItem,
1618
DropdownMenuSub,
1719
DropdownMenuSubTrigger,
1820
DropdownMenuSubContent,
@@ -29,18 +31,45 @@ import {
2931
export function NavUser() {
3032
const { isMobile, state } = useSidebar();
3133
const { t, i18n } = useTranslation();
34+
const { transport } = useTransport();
3235
const { theme, setTheme } = useTheme();
3336
const isCollapsed = !isMobile && state === 'collapsed';
3437
const currentLanguage = (i18n.resolvedLanguage || i18n.language || 'en').toLowerCase().startsWith('zh')
3538
? 'zh'
3639
: 'en';
3740
const currentLanguageLabel =
3841
currentLanguage === 'zh' ? t('settings.languages.zh') : t('settings.languages.en');
42+
const desktopRestartAvailable =
43+
typeof window !== 'undefined' &&
44+
!!(window as unknown as { go?: { desktop?: { LauncherApp?: { RestartServer?: () => unknown } } } })
45+
.go?.desktop?.LauncherApp?.RestartServer;
3946

4047
const handleToggleLanguage = () => {
4148
i18n.changeLanguage(currentLanguage === 'zh' ? 'en' : 'zh');
4249
};
4350

51+
const handleRestartServer = async () => {
52+
if (!window.confirm(t('nav.restartServerConfirm'))) return;
53+
try {
54+
if (desktopRestartAvailable) {
55+
const launcher = (window as unknown as {
56+
go?: { desktop?: { LauncherApp?: { RestartServer?: () => Promise<void> } } };
57+
}).go?.desktop?.LauncherApp;
58+
if (!launcher?.RestartServer) {
59+
throw new Error('Desktop restart is unavailable.');
60+
}
61+
await launcher.RestartServer();
62+
return;
63+
}
64+
await transport.restartServer();
65+
} catch (error) {
66+
console.error('Restart server failed:', error);
67+
if (typeof window !== 'undefined') {
68+
window.alert(t('nav.restartServerFailed'));
69+
}
70+
}
71+
};
72+
4473
const user = {
4574
name: 'Maxx',
4675
avatar: '/logo.png',
@@ -123,8 +152,8 @@ export function NavUser() {
123152
)}
124153
/>
125154
<DropdownMenuContent
126-
className="!w-40 rounded-lg max-w-xs !min-w-0"
127-
style={{ width: '10rem' }}
155+
className="!w-32 rounded-lg max-w-xs !min-w-0"
156+
style={{ width: '8rem' }}
128157
side={isMobile ? 'bottom' : 'right'}
129158
align="end"
130159
sideOffset={4}
@@ -194,6 +223,13 @@ export function NavUser() {
194223
</DropdownMenuPortal>
195224
</DropdownMenuSub>
196225
</DropdownMenuGroup>
226+
<>
227+
<DropdownMenuSeparator />
228+
<DropdownMenuItem onClick={handleRestartServer}>
229+
<RefreshCw />
230+
<span>{t('nav.restartServer')}</span>
231+
</DropdownMenuItem>
232+
</>
197233
</DropdownMenuContent>
198234
</DropdownMenu>
199235
</div>
@@ -202,3 +238,4 @@ export function NavUser() {
202238
);
203239
}
204240

241+

web/src/lib/transport/http-transport.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,12 @@ export class HttpTransport implements Transport {
323323
return data;
324324
}
325325

326+
// ===== System API =====
327+
328+
async restartServer(): Promise<void> {
329+
await this.client.post('/restart');
330+
}
331+
326332
// ===== Provider Stats API =====
327333

328334
async getProviderStats(

web/src/lib/transport/interface.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ export interface Transport {
117117
// ===== Proxy Status API =====
118118
getProxyStatus(): Promise<ProxyStatus>;
119119

120+
// ===== System API =====
121+
restartServer(): Promise<void>;
122+
120123
// ===== Provider Stats API =====
121124
getProviderStats(clientType?: string, projectId?: number): Promise<Record<number, ProviderStats>>;
122125

web/src/locales/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@
8383
"account": "Account",
8484
"notifications": "Notifications",
8585
"theme": "Theme",
86+
"restartServer": "Restart Server",
87+
"restartServerConfirm": "Restart the server? Active connections may be briefly interrupted.",
88+
"restartServerFailed": "Restart failed. Please check the server status.",
8689
"language": "Language",
8790
"logout": "Log out"
8891
},

web/src/locales/zh.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@
8383
"account": "账户",
8484
"notifications": "通知",
8585
"theme": "主题",
86+
"restartServer": "重启服务器",
87+
"restartServerConfirm": "确定重启服务器?当前连接可能会短暂中断。",
88+
"restartServerFailed": "重启失败,请检查服务状态。",
8689
"language": "语言",
8790
"logout": "退出登录"
8891
},

0 commit comments

Comments
 (0)