Преглед на файлове

fix(frontend): preserve edited server drafts (#6156)

* fix(frontend): preserve edited server drafts

* fix(frontend): retain Xray server projections

* fix(frontend): keep draft controls internal

* fix(frontend): rehydrate saved redacted settings

* fix(frontend): order saved draft hydration

* fix(frontend): preserve draft baselines on security saves

---------

Co-authored-by: PathGao <[email protected]>
PathGao преди 12 часа
родител
ревизия
66740b7ef4

+ 37 - 20
frontend/src/api/queries/useAllSettings.ts

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useMemo, useState } from 'react';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 
 import { HttpUtil, Msg } from '@/utils';
@@ -6,8 +6,13 @@ import { parseMsg } from '@/utils/zodValidate';
 import { AllSetting } from '@/models/setting';
 import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
 import { keys } from '@/api/queryKeys';
+import { useServerDraft } from '@/hooks/useServerDraft';
 
 type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
+type SettingSaveResult = {
+  msg: Msg<unknown>;
+  saved?: AllSetting;
+};
 
 async function fetchAllSetting(): Promise<AllSettingInput | null> {
   const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
@@ -18,7 +23,6 @@ async function fetchAllSetting(): Promise<AllSettingInput | null> {
 
 export function useAllSettings() {
   const queryClient = useQueryClient();
-  const [draft, setDraft] = useState<AllSetting>(() => new AllSetting());
   const [extraSpinning, setExtraSpinning] = useState(false);
 
   const query = useQuery({
@@ -28,41 +32,54 @@ export function useAllSettings() {
   });
 
   const server = useMemo(() => new AllSetting(query.data), [query.data]);
-
-  useEffect(() => {
-    if (query.data !== undefined) {
-      setDraft(new AllSetting(query.data));
-    }
-  }, [query.data]);
+  const { draft, setDraft, isDirty, markSaved } = useServerDraft(
+    query.data === undefined ? undefined : server,
+    (setting) => new AllSetting(setting),
+    (left, right) => left.equals(right),
+  );
+  const allSetting = draft ?? server;
 
   const updateSetting = useCallback((patch: Partial<AllSetting>) => {
     setDraft((prev) => {
-      const next = new AllSetting(prev);
+      const next = new AllSetting(prev ?? server);
       Object.assign(next, patch);
       return next;
     });
-  }, []);
+  }, [server, setDraft]);
 
   const saveMut = useMutation({
-    mutationFn: async (next: SettingSavePayload): Promise<Msg<unknown>> => {
-      const payload = { ...next };
-      const body = AllSettingSchema.partial().safeParse(payload);
+    mutationFn: async ({ payload, saved }: { payload: SettingSavePayload; saved?: AllSetting }): Promise<SettingSaveResult> => {
+      const next = { ...payload };
+      const body = AllSettingSchema.partial().safeParse(next);
       if (!body.success) {
         console.warn('[zod] setting/update body failed validation', body.error.issues);
       }
-      return HttpUtil.post('/panel/api/setting/update', body.success ? { ...payload, ...body.data } : payload);
+      const msg = await HttpUtil.post('/panel/api/setting/update', body.success ? { ...next, ...body.data } : next);
+      return { msg, saved };
     },
-    onSuccess: (msg) => {
-      if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
+    onSuccess: ({ msg, saved }) => {
+      if (!msg?.success) return;
+      if (saved) markSaved(saved);
+      queryClient.invalidateQueries({ queryKey: keys.settings.all() });
     },
   });
 
-  const saveAll = useCallback(() => saveMut.mutateAsync({ ...draft }), [saveMut, draft]);
-  const savePayload = useCallback((payload: SettingSavePayload) => saveMut.mutateAsync(payload), [saveMut]);
-  const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
+  const saveAll = useCallback(async () => {
+    const saved = new AllSetting(allSetting);
+    return (await saveMut.mutateAsync({ payload: { ...saved }, saved })).msg;
+  }, [allSetting, saveMut]);
+  const savePayload = useCallback(
+    async (payload: SettingSavePayload) => {
+      const saved = new AllSetting(allSetting);
+      Object.assign(saved, payload);
+      return (await saveMut.mutateAsync({ payload, saved })).msg;
+    },
+    [allSetting, saveMut],
+  );
+  const saveDisabled = !isDirty;
 
   return {
-    allSetting: draft,
+    allSetting,
     updateSetting,
     fetched: query.data !== undefined,
     spinning: extraSpinning || saveMut.isPending,

+ 37 - 0
frontend/src/hooks/useServerDraft.ts

@@ -0,0 +1,37 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+export function useServerDraft<T>(server: T | undefined, clone: (value: T) => T, equals: (left: T, right: T) => boolean) {
+  const cloneRef = useRef(clone);
+  const equalsRef = useRef(equals);
+  cloneRef.current = clone;
+  equalsRef.current = equals;
+
+  const [draft, setDraft] = useState<T | undefined>();
+  const [baseline, setBaseline] = useState<T | undefined>();
+  const draftRef = useRef(draft);
+  const baselineRef = useRef(baseline);
+  draftRef.current = draft;
+  baselineRef.current = baseline;
+
+  useEffect(() => {
+    if (server === undefined) return;
+    const currentDraft = draftRef.current;
+    const currentBaseline = baselineRef.current;
+    const isDirty = currentDraft !== undefined
+      && (currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline));
+    setBaseline(server);
+    if (isDirty && !equalsRef.current(currentDraft, server)) return;
+    setDraft(cloneRef.current(server));
+  }, [server]);
+
+  const markSaved = useCallback((value: T) => {
+    setBaseline(cloneRef.current(value));
+  }, []);
+
+  const isDirty = useMemo(
+    () => draft !== undefined && (baseline === undefined || !equalsRef.current(draft, baseline)),
+    [baseline, draft],
+  );
+
+  return { draft, setDraft, isDirty, markSaved };
+}

+ 25 - 26
frontend/src/hooks/useXraySetting.ts

@@ -14,7 +14,6 @@ import {
   type OutboundTrafficRow,
 } from '@/schemas/xray';
 
-const DIRTY_POLL_MS = 1000;
 const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
 // One HTTP-mode batch request tests this many outbounds through a single
 // shared temp xray instance; chunking keeps responses bounded (~30s worst
@@ -22,6 +21,10 @@ const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
 // results progressively.
 const HTTP_BATCH_CHUNK = 16;
 
+function normalizeOutboundTestUrl(url: string) {
+  return url || DEFAULT_TEST_URL;
+}
+
 export function isUdpOutbound(outbound: unknown): boolean {
   const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
   const p = o?.protocol;
@@ -125,10 +128,11 @@ export function useXraySetting(): UseXraySettingResult {
     staleTime: Infinity,
   });
 
-  const [saveDisabled, setSaveDisabled] = useState(true);
   const [xraySetting, setXraySettingState] = useState('');
   const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
   const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
+  const [savedXraySetting, setSavedXraySetting] = useState('');
+  const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
   const [inboundTags, setInboundTags] = useState<string[]>([]);
   const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
   const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
@@ -139,38 +143,40 @@ export function useXraySetting(): UseXraySettingResult {
   const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
   const [testingAll, setTestingAll] = useState(false);
 
-  const oldXraySettingRef = useRef('');
-  const oldOutboundTestUrlRef = useRef('');
   const syncingRef = useRef(false);
   const xraySettingRef = useRef('');
   const outboundTestUrlRef = useRef(outboundTestUrl);
+  const savedXraySettingRef = useRef(savedXraySetting);
+  const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
   const templateSettingsRef = useRef<XraySettingsValue | null>(null);
   const subscriptionOutboundsRef = useRef<unknown[]>([]);
 
   xraySettingRef.current = xraySetting;
   outboundTestUrlRef.current = outboundTestUrl;
+  savedXraySettingRef.current = savedXraySetting;
+  savedOutboundTestUrlRef.current = savedOutboundTestUrl;
   templateSettingsRef.current = templateSettings;
   subscriptionOutboundsRef.current = subscriptionOutbounds;
 
-  // Seed local editor state from the config query. Runs on first fetch and
-  // every time the query refetches (e.g. after a successful save).
   useEffect(() => {
     if (!configQuery.data) return;
     const obj = configQuery.data;
     const pretty = JSON.stringify(obj.xraySetting, null, 2);
-    syncingRef.current = true;
-    setXraySettingState(pretty);
-    setTemplateSettingsState(obj.xraySetting);
-    oldXraySettingRef.current = pretty;
-    syncingRef.current = false;
+    const nextUrl = normalizeOutboundTestUrl(obj.outboundTestUrl || '');
     setInboundTags(obj.inboundTags || []);
     setClientReverseTags(obj.clientReverseTags || []);
     setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
     setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
-    const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
+    const isDirty = savedXraySettingRef.current !== xraySettingRef.current
+      || savedOutboundTestUrlRef.current !== normalizeOutboundTestUrl(outboundTestUrlRef.current);
+    if (isDirty) return;
+    syncingRef.current = true;
+    setXraySettingState(pretty);
+    setTemplateSettingsState(obj.xraySetting);
+    setSavedXraySetting(pretty);
+    syncingRef.current = false;
     setOutboundTestUrlState(nextUrl);
-    oldOutboundTestUrlRef.current = nextUrl;
-    setSaveDisabled(true);
+    setSavedOutboundTestUrl(nextUrl);
   }, [configQuery.data]);
 
   const fetched = configQuery.data !== undefined || configQuery.isError;
@@ -220,7 +226,7 @@ export function useXraySetting(): UseXraySettingResult {
   const saveMut = useMutation({
     mutationFn: async () => {
       const sentXraySetting = xraySettingRef.current;
-      const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
+      const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current);
       const msg = await HttpUtil.post('/panel/api/xray/update', {
         xraySetting: sentXraySetting,
         outboundTestUrl: sentTestUrl,
@@ -229,9 +235,8 @@ export function useXraySetting(): UseXraySettingResult {
     },
     onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
       if (!msg?.success) return;
-      oldXraySettingRef.current = sentXraySetting;
-      oldOutboundTestUrlRef.current = sentTestUrl;
-      setSaveDisabled(true);
+      setSavedXraySetting(sentXraySetting);
+      setSavedOutboundTestUrl(sentTestUrl);
       queryClient.invalidateQueries({ queryKey: keys.xray.config() });
     },
   });
@@ -425,14 +430,8 @@ export function useXraySetting(): UseXraySettingResult {
     }
   }, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
 
-  useEffect(() => {
-    const timer = window.setInterval(() => {
-      const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
-      const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
-      setSaveDisabled(!(dirtyXray || dirtyUrl));
-    }, DIRTY_POLL_MS);
-    return () => window.clearInterval(timer);
-  }, []);
+  const saveDisabled = savedXraySetting === xraySetting
+    && savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
 
   const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
 

+ 78 - 1
frontend/src/test/use-all-settings.test.tsx

@@ -1,9 +1,10 @@
 import type { ReactNode } from 'react';
-import { renderHook, waitFor } from '@testing-library/react';
+import { act, renderHook, waitFor } from '@testing-library/react';
 import { QueryClientProvider } from '@tanstack/react-query';
 import { afterEach, describe, expect, it, vi } from 'vitest';
 
 import { useAllSettings } from '@/api/queries/useAllSettings';
+import { keys } from '@/api/queryKeys';
 import { makeTestQueryClient } from '@/test/test-utils';
 import { HttpUtil, Msg } from '@/utils';
 
@@ -25,4 +26,80 @@ describe('useAllSettings', () => {
     await waitFor(() => expect(result.current.fetched).toBe(true));
     expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
   });
+
+  it('keeps an edited setting when a refetch returns older server data', async () => {
+    const values = [
+      { webPort: 2053 },
+      { webPort: 2054 },
+    ];
+    let index = 0;
+    vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', values[index++]));
+    const queryClient = makeTestQueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+    const { result } = renderHook(() => useAllSettings(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    act(() => result.current.updateSetting({ webPort: 3000 }));
+    await queryClient.invalidateQueries({ queryKey: keys.settings.all() });
+
+    await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2));
+    expect(result.current.allSetting.webPort).toBe(3000);
+    expect(result.current.saveDisabled).toBe(false);
+  });
+
+  it('hydrates redacted secrets after a successful save', async () => {
+    let fetchCount = 0;
+    vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
+      if (url === '/panel/api/setting/all') {
+        fetchCount += 1;
+        return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
+      }
+      return new Msg(true, '');
+    });
+    const queryClient = makeTestQueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+    const { result } = renderHook(() => useAllSettings(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    act(() => result.current.updateSetting({ tgBotToken: 'secret' }));
+    await act(async () => {
+      await result.current.saveAll();
+    });
+
+    await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3));
+    expect(result.current.allSetting.tgBotToken).toBe('');
+    expect(result.current.allSetting.hasTgBotToken).toBe(true);
+    expect(result.current.saveDisabled).toBe(true);
+  });
+
+  it('establishes a saved baseline for a full-payload security save', async () => {
+    let fetchCount = 0;
+    vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
+      if (url === '/panel/api/setting/all') {
+        fetchCount += 1;
+        return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
+      }
+      return new Msg(true, '');
+    });
+    const queryClient = makeTestQueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+    const { result } = renderHook(() => useAllSettings(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    act(() => result.current.updateSetting({ tgBotToken: 'secret' }));
+    await act(async () => {
+      await result.current.savePayload({ ...result.current.allSetting, twoFactorEnable: false, twoFactorToken: '' });
+    });
+
+    await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3));
+    expect(result.current.allSetting.tgBotToken).toBe('');
+    expect(result.current.allSetting.hasTgBotToken).toBe(true);
+    expect(result.current.saveDisabled).toBe(true);
+  });
 });

+ 70 - 0
frontend/src/test/use-xray-setting.test.tsx

@@ -0,0 +1,70 @@
+import type { ReactNode } from 'react';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useXraySetting } from '@/hooks/useXraySetting';
+import { makeTestQueryClient } from '@/test/test-utils';
+import { HttpUtil, Msg } from '@/utils';
+
+function xrayPayload(overrides: Record<string, unknown> = {}) {
+  return {
+    xraySetting: {},
+    inboundTags: [],
+    clientReverseTags: [],
+    outboundTestUrl: 'https://test.example',
+    subscriptionOutbounds: [],
+    subscriptionOutboundTags: [],
+    ...overrides,
+  };
+}
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+beforeEach(() => {
+  vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
+});
+
+describe('useXraySetting', () => {
+  it('refreshes server-derived outbounds while the editor is dirty', async () => {
+    let payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'before' }] });
+    vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
+      if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
+      return new Msg(true, '');
+    });
+    const queryClient = makeTestQueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+    const { result } = renderHook(() => useXraySetting(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    act(() => result.current.setXraySetting('{"outbounds":[]}'));
+    payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'after' }] });
+    await act(async () => result.current.fetchAll());
+
+    await waitFor(() => expect(result.current.subscriptionOutbounds).toEqual([{ tag: 'after' }]));
+    expect(result.current.xraySetting).toBe('{"outbounds":[]}');
+  });
+
+  it('keeps the outbound test URL input empty when it is cleared', async () => {
+    const payload = xrayPayload({ outboundTestUrl: 'https://www.google.com/generate_204' });
+    vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
+      if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
+      return new Msg(true, '');
+    });
+    const queryClient = makeTestQueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+    const { result } = renderHook(() => useXraySetting(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    act(() => result.current.setOutboundTestUrl(''));
+
+    expect(result.current.outboundTestUrl).toBe('');
+    expect(result.current.saveDisabled).toBe(true);
+  });
+});

+ 86 - 0
frontend/src/test/useServerDraft.test.tsx

@@ -0,0 +1,86 @@
+import { StrictMode } from 'react';
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { useServerDraft } from '@/hooks/useServerDraft';
+
+describe('useServerDraft', () => {
+  it('keeps an edited draft when the server refetches', () => {
+    const { result, rerender } = renderHook(
+      ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
+      { initialProps: { server: { value: 'one' } } },
+    );
+
+    act(() => result.current.setDraft({ value: 'edited' }));
+    rerender({ server: { value: 'two' } });
+
+    expect(result.current.draft).toEqual({ value: 'edited' });
+    expect(result.current.isDirty).toBe(true);
+  });
+
+  it('accepts a refetch that matches the saved draft', () => {
+    const { result, rerender } = renderHook(
+      ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
+      { initialProps: { server: { value: 'one' } } },
+    );
+
+    act(() => result.current.setDraft({ value: 'saved' }));
+    rerender({ server: { value: 'saved' } });
+
+    expect(result.current.draft).toEqual({ value: 'saved' });
+    expect(result.current.isDirty).toBe(false);
+  });
+
+  it('compares a preserved draft with the latest server value', () => {
+    const { result, rerender } = renderHook(
+      ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
+      { initialProps: { server: { value: 'one' } } },
+    );
+
+    act(() => result.current.setDraft({ value: 'later' }));
+    rerender({ server: { value: 'saved' } });
+    act(() => result.current.setDraft({ value: 'one' }));
+
+    expect(result.current.isDirty).toBe(true);
+  });
+
+  it('hydrates clean drafts under StrictMode', () => {
+    const { result, rerender } = renderHook(
+      ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
+      {
+        initialProps: { server: { value: 'one' } },
+        wrapper: StrictMode,
+      },
+    );
+
+    rerender({ server: { value: 'two' } });
+
+    expect(result.current.draft).toEqual({ value: 'two' });
+    expect(result.current.isDirty).toBe(false);
+  });
+
+  it('preserves an edit made before the first server response', () => {
+    const { result, rerender } = renderHook(
+      ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
+      { initialProps: { server: undefined as { value: string } | undefined } },
+    );
+
+    act(() => result.current.setDraft({ value: 'edited' }));
+    rerender({ server: { value: 'one' } });
+
+    expect(result.current.draft).toEqual({ value: 'edited' });
+    expect(result.current.isDirty).toBe(true);
+  });
+
+  it('marks a sent draft clean before its refetch arrives', () => {
+    const { result } = renderHook(
+      ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
+      { initialProps: { server: { value: 'one' } } },
+    );
+
+    act(() => result.current.setDraft({ value: 'saved' }));
+    act(() => result.current.markSaved({ value: 'saved' }));
+
+    expect(result.current.isDirty).toBe(false);
+  });
+});