fix(ui): create user/node returns after validate; loading feedback; deferred Mihomo restart

Symptom: confirm button seemed dead, refresh showed the create had succeeded —
HTTP waited on full core restart and often timed out after DB write.
- ApplyConfigDeferredRestart: save+validate sync, restart async
- Listener path uses deferred apply so create/delete respond quickly
- Users modal: confirmLoading + disable while submitting
- Listeners: always toast errors; refresh list without blocking success path
fix(ui): create user/node returns after validate; loading feedback; deferred Mihomo restart
Symptom: confirm button seemed dead, refresh showed the create had succeeded —
HTTP waited on full core restart and often timed out after DB write.
- ApplyConfigDeferredRestart: save+validate sync, restart async
- Listener path uses deferred apply so create/delete respond quickly
- Users modal: confirmLoading + disable while submitting
- Listeners: always toast errors; refresh list without blocking success path
fix(ui): create user/node returns after validate; loading feedback; deferred Mihomo restart
Symptom: confirm button seemed dead, refresh showed the create had succeeded —
HTTP waited on full core restart and often timed out after DB write.
- ApplyConfigDeferredRestart: save+validate sync, restart async
- Listener path uses deferred apply so create/delete respond quickly
- Users modal: confirmLoading + disable while submitting
- Listeners: always toast errors; refresh list without blocking success path
fix(ui): create user/node returns after validate; loading feedback; deferred Mihomo restart
Symptom: confirm button seemed dead, refresh showed the create had succeeded —
HTTP waited on full core restart and often timed out after DB write.
- ApplyConfigDeferredRestart: save+validate sync, restart async
- Listener path uses deferred apply so create/delete respond quickly
- Users modal: confirmLoading + disable while submitting
- Listeners: always toast errors; refresh list without blocking success path
fix(ui): create user/node returns after validate; loading feedback; deferred Mihomo restart
Symptom: confirm button seemed dead, refresh showed the create had succeeded —
HTTP waited on full core restart and often timed out after DB write.
- ApplyConfigDeferredRestart: save+validate sync, restart async
- Listener path uses deferred apply so create/delete respond quickly
- Users modal: confirmLoading + disable while submitting
- Listeners: always toast errors; refresh list without blocking success path
fix(ui): create user/node returns after validate; loading feedback; deferred Mihomo restart
Symptom: confirm button seemed dead, refresh showed the create had succeeded —
HTTP waited on full core restart and often timed out after DB write.
- ApplyConfigDeferredRestart: save+validate sync, restart async
- Listener path uses deferred apply so create/delete respond quickly
- Users modal: confirmLoading + disable while submitting
- Listeners: always toast errors; refresh list without blocking success path
fix(ui): create user/node returns after validate; loading feedback; deferred Mihomo restart
Symptom: confirm button seemed dead, refresh showed the create had succeeded —
HTTP waited on full core restart and often timed out after DB write.
- ApplyConfigDeferredRestart: save+validate sync, restart async
- Listener path uses deferred apply so create/delete respond quickly
- Users modal: confirmLoading + disable while submitting
- Listeners: always toast errors; refresh list without blocking success path
fix(ui): create user/node returns after validate; loading feedback; deferred Mihomo restart
Symptom: confirm button seemed dead, refresh showed the create had succeeded —
HTTP waited on full core restart and often timed out after DB write.
- ApplyConfigDeferredRestart: save+validate sync, restart async
- Listener path uses deferred apply so create/delete respond quickly
- Users modal: confirmLoading + disable while submitting
- Listeners: always toast errors; refresh list without blocking success path
fix(ui): create user/node returns after validate; loading feedback; deferred Mihomo restart
Symptom: confirm button seemed dead, refresh showed the create had succeeded —
HTTP waited on full core restart and often timed out after DB write.
- ApplyConfigDeferredRestart: save+validate sync, restart async
- Listener path uses deferred apply so create/delete respond quickly
- Users modal: confirmLoading + disable while submitting
- Listeners: always toast errors; refresh list without blocking success path
fix(ui): create user/node returns after validate; loading feedback; deferred Mihomo restart
Symptom: confirm button seemed dead, refresh showed the create had succeeded —
HTTP waited on full core restart and often timed out after DB write.
- ApplyConfigDeferredRestart: save+validate sync, restart async
- Listener path uses deferred apply so create/delete respond quickly
- Users modal: confirmLoading + disable while submitting
- Listeners: always toast errors; refresh list without blocking success path
fix(ui): create user/node returns after validate; loading feedback; deferred Mihomo restart
Symptom: confirm button seemed dead, refresh showed the create had succeeded —
HTTP waited on full core restart and often timed out after DB write.
- ApplyConfigDeferredRestart: save+validate sync, restart async
- Listener path uses deferred apply so create/delete respond quickly
- Users modal: confirmLoading + disable while submitting
- Listeners: always toast errors; refresh list without blocking success path
fix(ui): create user/node returns after validate; loading feedback; deferred Mihomo restart
Symptom: confirm button seemed dead, refresh showed the create had succeeded —
HTTP waited on full core restart and often timed out after DB write.
- ApplyConfigDeferredRestart: save+validate sync, restart async
- Listener path uses deferred apply so create/delete respond quickly
- Users modal: confirmLoading + disable while submitting
- Listeners: always toast errors; refresh list without blocking success path
This commit is contained in:
Z User committed 2026-09-11 15:55:57 +00:00
1 parent cc5b206f5e
commit 4f59ab4e7a
5 files changed
+121 -9

No files matched your search

+4 -1
View File
@@ -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)
+15 -2
View File
@@ -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)
+77
View File
@@ -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 {
+10 -3
View File
@@ -109,9 +109,16 @@ const Listeners: React.FC = () => {
const payload: Partial<Listener> = { 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); } };
+15 -3
View File
@@ -25,6 +25,7 @@ const Users: React.FC = () => {
const [data, setData] = useState<ProxyUser[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [editing, setEditing] = useState<ProxyUser | null>(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<string, unknown> = {
@@ -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}
>
<Form form={form} layout="vertical" onFinish={onSubmit}>
<Form form={form} layout="vertical" onFinish={onSubmit} disabled={submitting}>
<Form.Item name="username" label={t('users.username')} rules={[{ required: true }]}>
<Input />
</Form.Item>