Files
3x-ui/frontend/src/pages/inbounds/CloneInboundModal.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

148 lines
5.2 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, Select, Typography, message } from 'antd';
import { HttpUtil } from '@/utils';
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
import { buildClonePayload, pickClonePort } from '@/lib/xray/inbound-clone';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import type { DBInbound } from '@/models/dbinbound';
// 0 is the "local panel" sentinel (inbounds without a nodeId) — the same
// convention as the clients page node filter (#4997).
const LOCAL_PANEL = 0;
interface CloneInboundModalProps {
open: boolean;
dbInbound: DBInbound | null;
nodes: NodeRecord[];
portsInUse: Map<number, Set<number>>;
onClose: () => void;
onCloned: () => void | Promise<void>;
}
export default function CloneInboundModal({
open,
dbInbound,
nodes,
portsInUse,
onClose,
onCloned,
}: CloneInboundModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [targets, setTargets] = useState<number[]>([LOCAL_PANEL]);
const [submitting, setSubmitting] = useState(false);
const targetOptions = useMemo(
() => [
{ value: LOCAL_PANEL, label: t('pages.inbounds.localPanel'), disabled: false },
...(nodes || [])
.filter((n) => n.enable)
.map((n) => ({
value: n.id,
// Only online nodes are deployable targets: nodes report `unknown`
// until their first heartbeat, and the backend refuses any status
// other than online.
label: `${n.name}${n.status === 'online' ? '' : ` (${n.status || 'offline'})`}`,
disabled: n.status !== 'online',
})),
],
[nodes, t],
);
// "Select all" must not pick targets the user can't pick manually —
// offline nodes are disabled options in the dropdown.
const selectableOptions = useMemo(
() => targetOptions.filter((o) => !o.disabled),
[targetOptions],
);
// Reset the selection when the dialog OPENS: pre-select the source
// inbound's own node when it is a selectable target, otherwise the local
// panel (the only destination the clone action had before this picker).
// Deps are deliberately `[open]` only — `nodes` gets a new identity on every
// background refetch (heartbeats bump latency/status), and keying the reset
// on it would clobber the user's selection mid-dialog.
useEffect(() => {
if (!open || !dbInbound) return;
const src = dbInbound.nodeId ?? LOCAL_PANEL;
const srcNode = (nodes || []).find((n) => n.id === src);
const selectable = !!srcNode && !!srcNode.enable && srcNode.status === 'online';
setTargets([selectable ? src : LOCAL_PANEL]);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [open]);
async function submit() {
if (!dbInbound || targets.length === 0) return;
setSubmitting(true);
try {
// Sequential posts keep per-target results in selection order; every
// target gets its own fresh port because ports are only node-scoped.
const results: { ok: boolean; reason: string }[] = [];
for (const target of targets) {
const msg = await HttpUtil.post(
'/panel/api/inbounds/add',
buildClonePayload(
dbInbound,
pickClonePort(portsInUse.get(target)),
target === LOCAL_PANEL ? null : target,
),
{ silent: true },
);
results.push({ ok: !!msg?.success, reason: msg?.success ? '' : msg?.msg || '' });
}
const okCount = results.filter((r) => r.ok).length;
const failed = results.length - okCount;
if (failed === 0) {
messageApi.success(
okCount === 1
? t('pages.inbounds.toasts.inboundCreateSuccess')
: t('pages.inbounds.toasts.clonedMany', { count: okCount }),
);
} else {
const firstError = results.find((r) => !r.ok)?.reason ?? '';
const base = t('pages.inbounds.toasts.clonedMixed', { ok: okCount, failed });
messageApi.warning(firstError ? `${base} — ${firstError}` : base);
}
if (okCount > 0) await onCloned();
onClose();
} finally {
setSubmitting(false);
}
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound?.remark ?? '' })}
okText={t('pages.inbounds.clone')}
cancelText={t('cancel')}
okButtonProps={{ disabled: targets.length === 0, loading: submitting }}
onCancel={onClose}
onOk={submit}
destroyOnHidden
>
<Typography.Paragraph type="secondary">
{t('pages.inbounds.cloneConfirmContent')}
</Typography.Paragraph>
<SelectAllClearButtons options={selectableOptions} value={targets} onChange={setTargets} />
<Select
aria-label={t('pages.inbounds.deployTo')}
mode="multiple"
tagRender={selectTagRender}
style={{ width: '100%' }}
value={targets}
onChange={setTargets}
options={targetOptions}
placeholder={t('pages.inbounds.deployTo')}
showSearch={{ optionFilterProp: 'label' }}
autoFocus
/>
</Modal>
</>
);
}