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:
1 parent
cc5b206f5e
commit
4f59ab4e7a
5 files changed
+121
-9
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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); } };
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user