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 <gaoyanbo@gaoyanbodeMacBook-Air.local>
This commit is contained in:
PathGao
2026-07-30 08:59:53 +08:00
committed by GitHub
parent 8d02ae28f5
commit 66740b7ef4
6 changed files with 333 additions and 47 deletions

View File

@@ -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,

View File

@@ -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 };
}

View File

@@ -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]);

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});