mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
1bdb517b88
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.
148 lines
5.2 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|