Files
3x-ui/frontend/src/pages/settings/SubscriptionBalancersTab.tsx
T
Alireza Ahmadi e2184777a0 fix(frontend): make the panel usable with screen readers (#6907)
* fix(frontend): make the panel usable with screen readers

* fix(frontend): keep mobile tab names and the inbound expiry hint

Review of #6907 found two gaps in the screen-reader pass:

- On phone-width screens catTabLabel renders only the icon and names the
  tab by putting aria-label on it, but callers now pass that icon with
  aria-hidden for the desktop label, so every Settings, Xray and Host
  category tab lost its name under TalkBack. catTabLabel now clears
  aria-hidden on the icon it uses as the name.
- DateTimePicker forwarded id and aria-label but not aria-describedby,
  so the inbound expiry hint ("leave blank to never expire") was never
  attached. It now forwards it on both the Gregorian and Jalali input.
2026-10-23 18:29:47 +02:00

371 lines
11 KiB
TypeScript

import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Button,
Input,
InputNumber,
Popconfirm,
Select,
Space,
Switch,
Table,
Tabs,
Tag,
Tooltip,
} from 'antd';
import {
DeleteOutlined,
DeploymentUnitOutlined,
EditOutlined,
PlusOutlined,
RadarChartOutlined,
} from '@ant-design/icons';
import { useSubBalancersQuery } from '@/api/queries/useSubBalancersQuery';
import { useSubBalancerMutations } from '@/api/queries/useSubBalancerMutations';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { formatInboundLabel } from '@/lib/inbounds/label';
import type { AllSetting } from '@/models/setting';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import type { SubBalancer, SubBalancerFormValues } from '@/schemas/subBalancer';
import { PingConfigSchema, type PingConfigObject } from '@/schemas/observatory';
import { DEFAULT_BURST_OBSERVATORY } from '@/pages/xray/balancers/balancer-helpers';
import SubBalancerFormModal from './SubBalancerFormModal';
import { catTabLabel } from './catTabLabel';
import './SubscriptionFormatsTab.css';
const STRATEGY_COLORS: Record<string, string> = {
leastLoad: 'geekblue',
leastPing: 'green',
random: 'orange',
roundRobin: 'purple',
};
// Single source for the burst-observatory ping defaults: the Zod schema and
// DEFAULT_BURST_OBSERVATORY are kept in sync, so the tab just parses through it.
const DEFAULT_PING_CONFIG = PingConfigSchema.parse({ ...DEFAULT_BURST_OBSERVATORY.pingConfig });
function parsePingConfig(raw: string): PingConfigObject {
try {
return PingConfigSchema.parse(raw ? JSON.parse(raw) : {});
} catch {
return DEFAULT_PING_CONFIG;
}
}
interface SubscriptionBalancersTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
export default function SubscriptionBalancersTab({
allSetting,
updateSetting,
}: SubscriptionBalancersTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const { balancers, loading, fetched, fetchError, refetch } = useSubBalancersQuery();
const { create, update, remove } = useSubBalancerMutations();
const { data: inboundOptionsRaw } = useInboundOptions();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<SubBalancer | null>(null);
const inboundLabels = useMemo(() => {
const map = new Map<number, string>();
for (const ib of inboundOptionsRaw ?? []) {
map.set(ib.id, formatInboundLabel(ib.tag, ib.remark));
}
return map;
}, [inboundOptionsRaw]);
async function onConfirm(values: SubBalancerFormValues) {
const msg = editing ? await update(editing.id, values) : await create(values);
if (msg?.success) setModalOpen(false);
}
async function toggleEnabled(balancer: SubBalancer) {
await update(balancer.id, {
remark: balancer.remark,
strategy: balancer.strategy,
inboundIds: balancer.inboundIds,
memberWeights: balancer.memberWeights ?? undefined,
sortOrder: balancer.sortOrder,
enabled: !balancer.enabled,
});
}
const observatoryEnabled = allSetting.subJsonObservatory !== '';
const observatoryObj = useMemo(
() => parsePingConfig(allSetting.subJsonObservatory),
[allSetting.subJsonObservatory],
);
function setObservatoryEnabled(v: boolean) {
updateSetting({ subJsonObservatory: v ? JSON.stringify(DEFAULT_PING_CONFIG) : '' });
}
function setObservatoryField<K extends keyof PingConfigObject>(
key: K,
value: PingConfigObject[K],
) {
const next = { ...observatoryObj, [key]: value };
updateSetting({ subJsonObservatory: JSON.stringify(next) });
}
const columns = [
{
title: t('pages.settings.subBalancers.sortOrder'),
dataIndex: 'sortOrder',
key: 'sortOrder',
width: 80,
align: 'center' as const,
},
{
title: t('pages.settings.subBalancers.remark'),
dataIndex: 'remark',
key: 'remark',
},
{
title: t('pages.settings.subBalancers.strategy'),
dataIndex: 'strategy',
key: 'strategy',
width: 120,
render: (strategy: string) => (
<Tag color={STRATEGY_COLORS[strategy] ?? 'default'}>{strategy}</Tag>
),
},
{
title: t('pages.settings.subBalancers.inbounds'),
key: 'inbounds',
render: (_: unknown, r: SubBalancer) => {
const labels = r.inboundIds.map((id) => inboundLabels.get(id) ?? `#${id}`);
return (
<Tooltip title={labels.join(', ')}>
<span>{t('pages.settings.subBalancers.inboundsCount', { count: labels.length })}</span>
</Tooltip>
);
},
},
{
title: t('pages.settings.subBalancers.enabled'),
dataIndex: 'enabled',
key: 'enabled',
width: 80,
align: 'center' as const,
render: (_: unknown, r: SubBalancer) => (
<Switch
size="small"
aria-label={t('pages.settings.subBalancers.enabled')}
checked={r.enabled}
onChange={() => toggleEnabled(r)}
/>
),
},
{
title: '',
key: 'actions',
width: 96,
render: (_: unknown, r: SubBalancer) => (
<Space>
<Button
aria-label={t('edit')}
size="small"
icon={<EditOutlined aria-hidden />}
title={t('edit')}
onClick={() => {
setEditing(r);
setModalOpen(true);
}}
/>
<Popconfirm
title={t('pages.settings.subBalancers.deleteConfirm')}
okText={t('delete')}
cancelText={t('cancel')}
onConfirm={() => remove(r.id)}
>
<Button
aria-label={t('delete')}
size="small"
danger
icon={<DeleteOutlined aria-hidden />}
/>
</Popconfirm>
</Space>
),
},
];
const balancersTab = (
<div>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.settings.subBalancers.desc')}
/>
{fetchError && (
<Alert
type="error"
showIcon
style={{ marginBottom: 16 }}
title={fetchError}
action={
<Button size="small" onClick={() => refetch()}>
{t('refresh')}
</Button>
}
/>
)}
<div style={{ marginBottom: 12 }}>
<Button
type="primary"
icon={<PlusOutlined aria-hidden />}
onClick={() => {
setEditing(null);
setModalOpen(true);
}}
>
{t('pages.settings.subBalancers.add')}
</Button>
</div>
<Table
size="small"
dataSource={balancers}
rowKey={(r) => r.id}
pagination={false}
loading={loading && !fetched}
scroll={{ x: true }}
locale={{ emptyText: t('pages.settings.subBalancers.empty') }}
columns={columns}
/>
</div>
);
const observatoryTab = (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.settings.subBalancers.observatory.note')}
/>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.title')}
description={t('pages.settings.subBalancers.observatory.desc')}
>
<Switch checked={observatoryEnabled} onChange={setObservatoryEnabled} />
</SettingListItem>
{observatoryEnabled && (
<div className="format-settings">
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.destination')}
description={t('pages.settings.subBalancers.observatory.destinationDesc')}
>
<Input
value={observatoryObj.destination}
placeholder="https://www.google.com/generate_204"
onChange={(e) => setObservatoryField('destination', e.target.value)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.connectivity')}
description={t('pages.settings.subBalancers.observatory.connectivityDesc')}
>
<Input
value={observatoryObj.connectivity}
placeholder="http://connectivitycheck.platform.hicloud.com/generate_204"
onChange={(e) => setObservatoryField('connectivity', e.target.value)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.interval')}
description={t('pages.settings.subBalancers.observatory.intervalDesc')}
>
<Input
value={observatoryObj.interval}
placeholder="1m"
onChange={(e) => setObservatoryField('interval', e.target.value)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.timeout')}
description={t('pages.settings.subBalancers.observatory.timeoutDesc')}
>
<Input
value={observatoryObj.timeout}
placeholder="5s"
onChange={(e) => setObservatoryField('timeout', e.target.value)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.sampling')}
description={t('pages.settings.subBalancers.observatory.samplingDesc')}
>
<InputNumber
value={observatoryObj.sampling}
min={1}
style={{ width: '100%' }}
onChange={onNumber((v) => setObservatoryField('sampling', v))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.httpMethod')}
description={t('pages.settings.subBalancers.observatory.httpMethodDesc')}
>
<Select
value={observatoryObj.httpMethod}
style={{ width: '100%' }}
onChange={(v) => setObservatoryField('httpMethod', v)}
options={['HEAD', 'GET'].map((m) => ({ value: m, label: m }))}
/>
</SettingListItem>
</div>
)}
</>
);
return (
<>
<Tabs
defaultActiveKey="balancers"
items={[
{
key: 'balancers',
label: catTabLabel(
<DeploymentUnitOutlined aria-hidden />,
t('pages.settings.subBalancers.tabBalancers'),
isMobile,
),
children: balancersTab,
},
{
key: 'observatory',
label: catTabLabel(
<RadarChartOutlined aria-hidden />,
t('pages.settings.subBalancers.tabObservatory'),
isMobile,
),
children: observatoryTab,
},
]}
/>
<SubBalancerFormModal
open={modalOpen}
balancer={editing}
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>
</>
);
}