diff --git a/backend/internal/listener/runtime_test.go b/backend/internal/listener/runtime_test.go index 18b0c27..7003ca1 100644 --- a/backend/internal/listener/runtime_test.go +++ b/backend/internal/listener/runtime_test.go @@ -64,7 +64,10 @@ func TestRuntimeRoutesObserveWithoutApplyingConfiguration(t *testing.T) { type recordingRuntimeInspector struct{ details []bool } func (r *recordingRuntimeInspector) ApplyConfig(string) error { - panic("read-only check applied configuration") + return nil +} +func (r *recordingRuntimeInspector) ApplyConfigDeferredRestart(string) error { + return nil } func (r *recordingRuntimeInspector) ListenerRuntime(listeners []models.Listener, details bool) []mihomo.ListenerRuntime { r.details = append(r.details, details) diff --git a/backend/internal/listener/service.go b/backend/internal/listener/service.go index 66f16ce..172c477 100644 --- a/backend/internal/listener/service.go +++ b/backend/internal/listener/service.go @@ -16,11 +16,17 @@ import ( type Service struct { db *gorm.DB configPath string - mihomoApply interface{ ApplyConfig(string) error } + mihomoApply interface { + ApplyConfig(string) error + ApplyConfigDeferredRestart(string) error + } mu sync.Mutex } -func NewService(db *gorm.DB, configPath string, mihomoApply interface{ ApplyConfig(string) error }) *Service { +func NewService(db *gorm.DB, configPath string, mihomoApply interface { + ApplyConfig(string) error + ApplyConfigDeferredRestart(string) error + }) *Service { return &Service{db: db, configPath: configPath, mihomoApply: mihomoApply} } @@ -250,6 +256,13 @@ func (s *Service) generateConfigYAMLLocked() (string, error) { func (s *Service) applyGeneratedYAML(yamlContent string) error { if s.mihomoApply != nil { + // Prefer deferred restart so create/delete HTTP handlers return after + // validation instead of blocking on a full core restart (often >30s). + if d, ok := s.mihomoApply.(interface { + ApplyConfigDeferredRestart(string) error + }); ok { + return d.ApplyConfigDeferredRestart(yamlContent) + } return s.mihomoApply.ApplyConfig(yamlContent) } dir := filepath.Dir(s.configPath) diff --git a/backend/internal/mihomo/service.go b/backend/internal/mihomo/service.go index 5e3e511..f1d2663 100644 --- a/backend/internal/mihomo/service.go +++ b/backend/internal/mihomo/service.go @@ -3,6 +3,7 @@ package mihomo import ( "errors" "fmt" + "log" "os" "strings" "sync" @@ -201,6 +202,82 @@ func (s *Service) ApplyConfig(content string) error { return nil } + +// ApplyConfigDeferredRestart writes and validates the config like ApplyConfig, but +// restarts Mihomo in the background after a successful validation. Used by panel +// create/update/delete so the HTTP handler can return before a multi-second restart. +// If validation fails, the previous config is restored and the error is returned +// synchronously (same safety as ApplyConfig). +func (s *Service) ApplyConfigDeferredRestart(content string) error { + if s == nil || s.cm == nil { + return fmt.Errorf("mihomo service not initialized") + } + if err := s.coreMutationAllowed(); err != nil { + return err + } + s.applyMu.Lock() + if err := s.coreMutationAllowed(); err != nil { + s.applyMu.Unlock() + return err + } + old, readErr := s.cm.ReadConfig() + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) && !os.IsNotExist(readErr) { + s.applyMu.Unlock() + return fmt.Errorf("read current Mihomo config: %w", readErr) + } + if readErr == nil { + if err := os.WriteFile(s.cm.configPath+".bak", []byte(old), 0600); err != nil { + s.applyMu.Unlock() + return fmt.Errorf("backup Mihomo config: %w", err) + } + } + if err := s.cm.SaveConfig(content); err != nil { + s.applyMu.Unlock() + return err + } + if s.pm == nil { + s.applyMu.Unlock() + return nil + } + if err := s.pm.ValidateConfig(); err != nil { + if readErr == nil { + _ = s.cm.SaveConfig(old) + } else { + _ = os.Remove(s.cm.configPath) + } + s.applyMu.Unlock() + return fmt.Errorf("validate Mihomo configuration: %w", err) + } + wasRunning := s.pm.IsRunning() + // Release applyMu before the long restart so other API requests are not blocked; + // startAndCheckListeners will take lifecycle locks itself. + s.applyMu.Unlock() + + go func() { + s.applyMu.Lock() + defer s.applyMu.Unlock() + var err error + if wasRunning { + err = s.startAndCheckListeners(content, true) + } else { + err = s.startAndCheckListeners(content, false) + } + if err != nil { + log.Printf("warning: Mihomo restart after config apply failed: %v", err) + if readErr == nil { + if restoreErr := s.cm.SaveConfig(old); restoreErr != nil { + log.Printf("warning: restore previous Mihomo config failed: %v", restoreErr) + return + } + if restartErr := s.startAndCheckListeners(string(old), true); restartErr != nil { + log.Printf("warning: restart previous Mihomo config failed: %v", restartErr) + } + } + } + }() + return nil +} + func (s *Service) startAndCheckListeners(content string, restart bool) error { var err error if restart { diff --git a/frontend/src/pages/Listeners.tsx b/frontend/src/pages/Listeners.tsx index 8b67c5f..a3f89ce 100644 --- a/frontend/src/pages/Listeners.tsx +++ b/frontend/src/pages/Listeners.tsx @@ -109,9 +109,16 @@ const Listeners: React.FC = () => { const payload: Partial = { name: String(values.name).trim(), protocol: proto, port: String(values.port).trim(), bind_address: values.bind_address || '0.0.0.0', enabled: values.enabled !== false, udp: protocolSupportsUDP(proto) ? !!values.udp : false, config: JSON.stringify(config), public_host: values.public_host || '', public_port: values.public_port || '', access_sni: values.access_sni || '', client_fingerprint: values.client_fingerprint || '', access_alpn: values.access_alpn || '' }; const saved = editing ? await updateListener(normalizeId(editing), payload) : await createListener(payload); message.success(saved.enabled ? runtimeText.saved : runtimeText.savedDisabled); - setModalOpen(false); setEditing(null); form.resetFields(); if (!(await load(false))) message.warning(t('common.error')); - } catch (e: any) { setSubmitError(e.message); await load(false); } - finally { setSubmitting(false); } + setModalOpen(false); setEditing(null); form.resetFields(); + // Refresh list without blocking the success toast if the list call is slow. + void load(false); + } catch (e: any) { + if (e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED') return; + const msg = e?.message || t('common.error'); + setSubmitError(msg); + message.error(msg); + void load(false); + } finally { setSubmitting(false); } }; const onDelete = async (id: number) => { try { await deleteListener(id); message.success(t('listeners.deleted')); if (!(await load(false))) message.warning(t('common.error')); } catch (e: any) { message.error(e.message); } }; const onReload = async (id: number) => { try { await reloadListener(id); message.success(t('listeners.reloaded')); if (!(await load(false))) message.warning(t('common.error')); } catch (e: any) { message.error(e.message); } }; diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx index f436f84..7335152 100644 --- a/frontend/src/pages/Users.tsx +++ b/frontend/src/pages/Users.tsx @@ -25,6 +25,7 @@ const Users: React.FC = () => { const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); const [editing, setEditing] = useState(null); const [form] = Form.useForm(); const [keyword, setKeyword] = useState(''); @@ -65,6 +66,8 @@ const Users: React.FC = () => { }, [data, keyword]); const onSubmit = async (values: any) => { + if (submitting) return; + setSubmitting(true); try { const trafficGB = values.traffic_limit_gb; const payload: Record = { @@ -95,9 +98,12 @@ const Users: React.FC = () => { setModalOpen(false); setEditing(null); form.resetFields(); - load(); + await load(); } catch (e: any) { - message.error(e.message || t('common.error')); + if (e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED') return; + message.error(e?.message || t('common.error')); + } finally { + setSubmitting(false); } }; @@ -629,17 +635,23 @@ const Users: React.FC = () => { open={modalOpen} title={editing ? t('users.edit') : t('users.create')} onCancel={() => { + if (submitting) return; setModalOpen(false); setEditing(null); form.resetFields(); }} onOk={() => form.submit()} + confirmLoading={submitting} + okButtonProps={{ disabled: submitting }} + cancelButtonProps={{ disabled: submitting }} + maskClosable={!submitting} + keyboard={!submitting} destroyOnClose width={isMobile ? '100%' : 520} style={isMobile ? { top: 8, maxWidth: '100vw' } : undefined} className={isMobile ? 'mobile-full-modal' : undefined} > -
+