Files
3x-ui/frontend/src/pages/xray/dns/DnsTab.tsx
T
Alireza Ahmadi 1bdb517b88 fix(a11y): silence live stats and make Select tags removable for screen readers
Ant Design's Spin wraps its content in aria-live="polite", so every
WebSocket tick on the dashboard, inbounds, clients and nodes pages was
announced, and only the changed number was read, with no label.
Pass aria-live="off" on every Spin that wraps content; aria-busy still
signals loading. Each vital tile is now a group named by its metric,
so "47.2 %" is read as CPU.

rc-select renders each tag's remove "x" as an aria-hidden span with no
tabIndex, so screen-reader users could only Backspace the last value
away, never remove one SNI or inbound from the middle.
selectTagRender swaps in a real button named "Remove <value>" and
moves focus to the next tag after removal. It is applied to every
tags/multiple Select. maxTagCount="responsive" is dropped because it
hid selected inbounds behind "+N", where no screen reader could reach
them.

Two oxlint rules (antd-a11y/spin-content-not-live and
antd-a11y/select-tag-remove) keep new code from regressing either fix.
2026-11-01 16:49:19 +01:00

541 lines
18 KiB
TypeScript

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 {
DatabaseOutlined,
DeleteOutlined,
ExperimentOutlined,
MenuOutlined,
PlusOutlined,
ProfileOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { selectTagRender } from '@/components/form';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import DnsServerModal from './DnsServerModal';
import type { DnsServerValue } from './DnsServerModal';
import DnsPresetsModal from './DnsPresetsModal';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import './DnsTab.css';
import { STRATEGIES, DEFAULT_FAKEDNS } from './helpers';
import type { DnsConfig, HostRow, FakednsRow } from './types';
import { useDnsServerColumns, useFakednsColumns } from './useDnsColumns';
interface DnsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
}
export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [hostsList, setHostsList] = useState<HostRow[]>([]);
const [serverModalOpen, setServerModalOpen] = useState(false);
const [editingServer, setEditingServer] = useState<DnsServerValue | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [presetsModalOpen, setPresetsModalOpen] = useState(false);
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 (!dnsEnabled) {
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) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
function toggleDNS(enabled: boolean) {
mutate((next) => {
if (enabled) {
(next as { dns?: DnsConfig }).dns = {
tag: 'dns_inbound',
queryStrategy: 'UseIP',
disableCache: false,
disableFallback: false,
disableFallbackIfMatch: false,
useSystemHosts: false,
enableParallelQuery: false,
serveStale: false,
serveExpiredTTL: 0,
hosts: {},
servers: [],
};
next.fakedns = null;
} else {
delete next.dns;
delete next.fakedns;
}
});
}
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;
if (Object.keys(obj).length > 0) {
(tt.dns as DnsConfig).hosts = obj;
} else if ('hosts' in (tt.dns as DnsConfig)) {
delete (tt.dns as DnsConfig).hosts;
}
});
}
function setDnsField<K extends keyof DnsConfig>(key: K, value: DnsConfig[K], omit = false) {
mutate((tt) => {
if (!tt.dns) return;
if (omit && (value == null || (typeof value === 'string' && value.trim() === ''))) {
delete (tt.dns as Record<string, unknown>)[key as string];
} else {
(tt.dns as Record<string, unknown>)[key as string] = value;
}
});
}
const dnsServers = useMemo(() => {
const list = dns?.servers || [];
return list.map((server, idx) => ({ key: idx, server }));
}, [dns?.servers]);
// Stable callbacks: the column definitions in useDnsServerColumns are
// memoized, so they must be able to depend on these (see issue #5155)
const openEditServer = useCallback(
(idx: number) => {
setEditingServer((dns?.servers || [])[idx] || null);
setEditingIndex(idx);
setServerModalOpen(true);
},
[dns?.servers],
);
const deleteServer = useCallback(
(idx: number) => {
mutate((tt) => {
const cfg = tt.dns as DnsConfig | undefined;
if (cfg?.servers) cfg.servers.splice(idx, 1);
});
},
[mutate],
);
const dnsColumns = useDnsServerColumns({ openEditServer, deleteServer });
function openAddServer() {
setEditingServer(null);
setEditingIndex(null);
setServerModalOpen(true);
}
function onServerConfirm(value: DnsServerValue) {
mutate((tt) => {
if (!tt.dns) return;
const cfg = tt.dns as DnsConfig;
if (!Array.isArray(cfg.servers)) cfg.servers = [];
if (editingIndex == null) cfg.servers.push(value);
else cfg.servers[editingIndex] = value;
});
setServerModalOpen(false);
}
function clearAllServers() {
modal.confirm({
title: t('pages.xray.dns.clearAllTitle'),
content: t('pages.xray.dns.clearAllConfirm'),
okText: t('delete'),
okButtonProps: { danger: true },
cancelText: t('cancel'),
onOk: () =>
mutate((tt) => {
if (tt.dns) (tt.dns as DnsConfig).servers = [];
}),
});
}
function onPresetInstall(servers: string[]) {
mutate((tt) => {
if (tt.dns) (tt.dns as DnsConfig).servers = servers;
});
setPresetsModalOpen(false);
}
const fakeDnsList = useMemo<{ key: number; ipPool: string; poolSize: number }[]>(() => {
const list = Array.isArray(templateSettings?.fakedns)
? (templateSettings?.fakedns as FakednsRow[])
: [];
return list.map((entry, idx) => ({ key: idx, ...entry }));
}, [templateSettings?.fakedns]);
const deleteFakedns = useCallback(
(idx: number) => {
mutate((tt) => {
const list = tt.fakedns as FakednsRow[] | undefined;
if (!list) return;
list.splice(idx, 1);
if (list.length === 0) tt.fakedns = null;
});
},
[mutate],
);
const updateFakednsField = useCallback(
(idx: number, field: 'ipPool' | 'poolSize', value: string | number) => {
mutate((tt) => {
const list = tt.fakedns as FakednsRow[] | undefined;
if (!list?.[idx]) return;
(list[idx] as unknown as Record<string, unknown>)[field] = value;
});
},
[mutate],
);
const fakednsColumns = useFakednsColumns({ deleteFakedns, updateFakednsField });
function addFakedns() {
mutate((tt) => {
if (!Array.isArray(tt.fakedns)) tt.fakedns = [];
(tt.fakedns as FakednsRow[]).push(DEFAULT_FAKEDNS());
});
}
const items = useMemo(() => {
const out = [
{
key: '1',
label: catTabLabel(
<SettingOutlined aria-hidden />,
t('pages.xray.generalConfigs'),
isMobile,
),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.enable')}
description={t('pages.xray.dns.enableDesc')}
control={<Switch checked={dnsEnabled} onChange={toggleDNS} />}
/>
{dnsEnabled && (
<>
<Alert
type="warning"
showIcon
title={t('pages.xray.dns.dnsLeakWarning')}
style={{ marginBottom: 12 }}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.tag')}
description={t('pages.xray.dns.tagDesc')}
control={
<Input
value={dns?.tag ?? 'dns_inbound'}
onChange={(e) => setDnsField('tag', e.target.value)}
/>
}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.clientIp')}
description={t('pages.xray.dns.clientIpDesc')}
control={
<Input
value={dns?.clientIp ?? ''}
onChange={(e) => setDnsField('clientIp', e.target.value, true)}
/>
}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.strategy')}
description={t('pages.xray.dns.strategyDesc')}
control={
<Select
value={dns?.queryStrategy ?? 'UseIP'}
style={{ width: '100%' }}
options={STRATEGIES.map((s) => ({ value: s, label: s }))}
onChange={(v) => setDnsField('queryStrategy', v)}
/>
}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.script')}
description={t('pages.xray.dns.scriptDesc')}
control={
<Input
value={dns?.script ?? ''}
placeholder="dns.lua"
onChange={(e) => setDnsField('script', e.target.value, true)}
/>
}
/>
{(
[
[
'disableCache',
'pages.xray.dns.disableCache',
'pages.xray.dns.disableCacheDesc',
],
[
'disableFallback',
'pages.xray.dns.disableFallback',
'pages.xray.dns.disableFallbackDesc',
],
[
'disableFallbackIfMatch',
'pages.xray.dns.disableFallbackIfMatch',
'pages.xray.dns.disableFallbackIfMatchDesc',
],
[
'enableParallelQuery',
'pages.xray.dns.enableParallelQuery',
'pages.xray.dns.enableParallelQueryDesc',
],
[
'useSystemHosts',
'pages.xray.dns.useSystemHosts',
'pages.xray.dns.useSystemHostsDesc',
],
['serveStale', 'pages.xray.dns.serveStale', 'pages.xray.dns.serveStaleDesc'],
] as const
).map(([field, titleKey, descKey]) => (
<SettingListItem
key={field}
paddings="small"
title={t(titleKey)}
description={t(descKey)}
control={
<Switch
checked={!!dns?.[field]}
onChange={(v) => setDnsField(field as keyof DnsConfig, v as never)}
/>
}
/>
))}
<SettingListItem
paddings="small"
title={t('pages.xray.dns.serveExpiredTTL')}
description={t('pages.xray.dns.serveExpiredTTLDesc')}
control={
<InputNumber
value={dns?.serveExpiredTTL ?? 0}
min={0}
step={60}
style={{ width: '100%' }}
onChange={onNumber((v) => setDnsField('serveExpiredTTL', v))}
/>
}
/>
</>
)}
</>
),
},
];
if (dnsEnabled) {
out.push({
key: 'hosts',
label: catTabLabel(<ProfileOutlined aria-hidden />, t('pages.xray.dns.hosts'), isMobile),
children:
hostsList.length === 0 ? (
<Empty description={t('pages.xray.dns.hostsEmpty')}>
<Button
type="primary"
icon={<PlusOutlined aria-hidden />}
onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}
>
{t('pages.xray.dns.hostsAdd')}
</Button>
</Empty>
) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button
type="primary"
icon={<PlusOutlined aria-hidden />}
onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}
>
{t('pages.xray.dns.hostsAdd')}
</Button>
{hostsList.map((row, idx) => (
<div key={`h${idx}`} className="hosts-row">
<Input
value={row.domain}
aria-label={t('pages.xray.dns.hostsDomain')}
placeholder={t('pages.xray.dns.hostsDomain')}
style={{ flex: '1 1 220px' }}
onChange={(e) => {
const next = hostsList.map((r, i) =>
i === idx ? { ...r, domain: e.target.value } : r,
);
syncHosts(next);
}}
/>
<Select
mode="tags"
tagRender={selectTagRender}
value={row.values}
aria-label={t('pages.xray.dns.hostsValues')}
placeholder={t('pages.xray.dns.hostsValues')}
style={{ flex: '2 1 320px' }}
tokenSeparators={[',', ' ']}
onChange={(values) => {
const next = hostsList.map((r, i) => (i === idx ? { ...r, values } : r));
syncHosts(next);
}}
/>
<Button
danger
aria-label={t('delete')}
icon={<DeleteOutlined aria-hidden />}
onClick={() => syncHosts(hostsList.filter((_, i) => i !== idx))}
/>
</div>
))}
</Space>
),
});
out.push({
key: '2',
label: catTabLabel(<DatabaseOutlined aria-hidden />, 'DNS', isMobile),
children:
dnsServers.length === 0 ? (
<Empty description={t('emptyDnsDesc')}>
<Space>
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={openAddServer}>
{t('pages.xray.dns.add')}
</Button>
<Button
icon={<MenuOutlined aria-hidden />}
onClick={() => setPresetsModalOpen(true)}
>
{t('pages.xray.dns.usePreset')}
</Button>
</Space>
</Empty>
) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap>
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={openAddServer}>
{t('pages.xray.dns.add')}
</Button>
<Button
icon={<MenuOutlined aria-hidden />}
onClick={() => setPresetsModalOpen(true)}
>
{t('pages.xray.dns.usePreset')}
</Button>
<Button danger icon={<DeleteOutlined aria-hidden />} onClick={clearAllServers}>
{t('pages.xray.dns.clearAll')}
</Button>
</Space>
<Table
columns={dnsColumns}
dataSource={dnsServers}
rowKey={(r) => r.key}
pagination={false}
size="small"
bordered
/>
</Space>
),
});
out.push({
key: '3',
label: catTabLabel(<ExperimentOutlined aria-hidden />, 'Fake DNS', isMobile),
children:
fakeDnsList.length === 0 ? (
<Empty description={t('emptyFakeDnsDesc')}>
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={addFakedns}>
{t('pages.xray.fakedns.add')}
</Button>
</Empty>
) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={addFakedns}>
{t('pages.xray.fakedns.add')}
</Button>
<Table
columns={fakednsColumns}
dataSource={fakeDnsList}
rowKey={(r) => r.key}
pagination={false}
size="small"
bordered
/>
</Space>
),
});
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, isMobile, dnsEnabled, dns, hostsList, dnsServers, fakeDnsList]);
return (
<>
{modalContextHolder}
<Tabs defaultActiveKey="1" items={items} />
<DnsServerModal
open={serverModalOpen}
server={editingServer}
isEdit={editingIndex != null}
onClose={() => setServerModalOpen(false)}
onConfirm={onServerConfirm}
/>
<DnsPresetsModal
open={presetsModalOpen}
onClose={() => setPresetsModalOpen(false)}
onInstall={onPresetInstall}
/>
</>
);
}