mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-02 16:37:14 +00:00
fix(frontend): keep DNS hosts synchronized (#6158)
* fix(frontend): keep DNS hosts synchronized * fix(frontend): preserve incomplete DNS hosts * fix(frontend): reset DNS host drafts when disabled * fix(frontend): clear DNS host drafts when disabled --------- Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Alert, Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
|
import { Alert, Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +42,23 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
|||||||
|
|
||||||
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
|
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
|
||||||
const dnsEnabled = !!dns;
|
const dnsEnabled = !!dns;
|
||||||
|
const sourceHosts = dns?.hosts;
|
||||||
|
const incomingHosts = JSON.stringify(sourceHosts ?? {});
|
||||||
|
const lastWrittenHostsRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dns) {
|
||||||
|
lastWrittenHostsRef.current = '{}';
|
||||||
|
setHostsList([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (incomingHosts === lastWrittenHostsRef.current) return;
|
||||||
|
lastWrittenHostsRef.current = incomingHosts;
|
||||||
|
setHostsList(Object.entries(sourceHosts ?? {}).map(([domain, values]) => ({
|
||||||
|
domain,
|
||||||
|
values: Array.isArray(values) ? [...values] : [String(values)],
|
||||||
|
})));
|
||||||
|
}, [dnsEnabled, incomingHosts, sourceHosts]);
|
||||||
|
|
||||||
const mutate = useCallback(
|
const mutate = useCallback(
|
||||||
(mutator: (next: XraySettingsValue) => void) => {
|
(mutator: (next: XraySettingsValue) => void) => {
|
||||||
@@ -79,32 +96,18 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dns) {
|
|
||||||
setHostsList([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const src = dns.hosts || {};
|
|
||||||
setHostsList(
|
|
||||||
Object.entries(src).map(([domain, val]) => ({
|
|
||||||
domain,
|
|
||||||
values: Array.isArray(val) ? [...val] : [String(val)],
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [dnsEnabled]);
|
|
||||||
|
|
||||||
function syncHosts(next: HostRow[]) {
|
function syncHosts(next: HostRow[]) {
|
||||||
|
const obj: Record<string, string | string[]> = {};
|
||||||
|
for (const row of next) {
|
||||||
|
if (!row.domain) continue;
|
||||||
|
const vals = (row.values || []).filter(Boolean);
|
||||||
|
if (vals.length === 0) continue;
|
||||||
|
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
|
||||||
|
}
|
||||||
|
lastWrittenHostsRef.current = JSON.stringify(obj);
|
||||||
setHostsList(next);
|
setHostsList(next);
|
||||||
mutate((tt) => {
|
mutate((tt) => {
|
||||||
if (!tt.dns) return;
|
if (!tt.dns) return;
|
||||||
const obj: Record<string, string | string[]> = {};
|
|
||||||
for (const row of next) {
|
|
||||||
if (!row.domain) continue;
|
|
||||||
const vals = (row.values || []).filter(Boolean);
|
|
||||||
if (vals.length === 0) continue;
|
|
||||||
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
|
|
||||||
}
|
|
||||||
if (Object.keys(obj).length > 0) {
|
if (Object.keys(obj).length > 0) {
|
||||||
(tt.dns as DnsConfig).hosts = obj;
|
(tt.dns as DnsConfig).hosts = obj;
|
||||||
} else if ('hosts' in (tt.dns as DnsConfig)) {
|
} else if ('hosts' in (tt.dns as DnsConfig)) {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { fireEvent, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import DnsTab from '@/pages/xray/dns/DnsTab';
|
||||||
|
import type { SetTemplate, XraySettingsValue } from '@/hooks/useXraySetting';
|
||||||
|
import { renderWithProviders } from './test-utils';
|
||||||
|
|
||||||
|
function withHosts(hosts: Record<string, string>): XraySettingsValue {
|
||||||
|
return {
|
||||||
|
dns: {
|
||||||
|
hosts,
|
||||||
|
servers: [],
|
||||||
|
},
|
||||||
|
} as unknown as XraySettingsValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DnsTab', () => {
|
||||||
|
it('keeps an empty row after adding a host', () => {
|
||||||
|
function Harness() {
|
||||||
|
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||||
|
const updateTemplate: SetTemplate = (next) => {
|
||||||
|
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||||
|
};
|
||||||
|
|
||||||
|
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<Harness />,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add Host$/ }));
|
||||||
|
|
||||||
|
expect(screen.getAllByLabelText('Domain (e.g. domain:example.com)')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a row visible while its domain is incomplete', () => {
|
||||||
|
function Harness() {
|
||||||
|
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||||
|
const updateTemplate: SetTemplate = (next) => {
|
||||||
|
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||||
|
};
|
||||||
|
|
||||||
|
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderWithProviders(<Harness />);
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||||
|
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), { target: { value: '' } });
|
||||||
|
|
||||||
|
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows hosts from an externally refreshed configuration', () => {
|
||||||
|
function Harness() {
|
||||||
|
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||||
|
const updateTemplate: SetTemplate = (next) => {
|
||||||
|
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => setTemplateSettings(withHosts({ 'second.example': '2.2.2.2' }))}>
|
||||||
|
Refresh hosts
|
||||||
|
</button>
|
||||||
|
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderWithProviders(<Harness />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||||
|
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('first.example');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Refresh hosts' }));
|
||||||
|
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('second.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears an incomplete host draft when DNS is disabled', () => {
|
||||||
|
function Harness() {
|
||||||
|
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
|
||||||
|
const updateTemplate: SetTemplate = (next) => {
|
||||||
|
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => setTemplateSettings({})}>Disable DNS</button>
|
||||||
|
<button type="button" onClick={() => setTemplateSettings(withHosts({}))}>Enable DNS</button>
|
||||||
|
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderWithProviders(<Harness />);
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||||
|
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), { target: { value: '' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Disable DNS' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Enable DNS' }));
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText('Domain (e.g. domain:example.com)')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user