mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-08 08:12:24 +03: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 { Alert, Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
|
||||
import {
|
||||
@@ -42,6 +42,23 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
|
||||
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
|
||||
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(
|
||||
(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[]) {
|
||||
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);
|
||||
mutate((tt) => {
|
||||
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) {
|
||||
(tt.dns as DnsConfig).hosts = obj;
|
||||
} else if ('hosts' in (tt.dns as DnsConfig)) {
|
||||
|
||||
107
frontend/src/test/dns-tab.test.tsx
Normal file
107
frontend/src/test/dns-tab.test.tsx
Normal file
@@ -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