import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Col, Dropdown, Form, Input, InputNumber, Modal, Popconfirm, Radio, Row, Space, Switch, Table, Tag, Tooltip, message, } from 'antd'; import { PlusOutlined, CloudOutlined, ApiOutlined, MoreOutlined, RetweetOutlined, PlayCircleOutlined, ReloadOutlined, DeleteOutlined, EditOutlined, EyeOutlined, ArrowUpOutlined, ArrowDownOutlined, CheckCircleOutlined, WarningOutlined, ExportOutlined, ImportOutlined, } from '@ant-design/icons'; import { HttpUtil } from '@/utils'; import PromptModal from '@/components/feedback/PromptModal'; import TextModal from '@/components/feedback/TextModal'; import OutboundFormModal from './OutboundFormModal'; import { propagateOutboundTagRename } from '../basics/helpers'; import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup'; import DeletionImpactList from '../DeletionImpactList'; import { isBalancerLoopbackTag } from '../balancers/balancer-loopback'; import type { XraySettingsValue, SetTemplate, OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import './OutboundsTab.css'; import type { OutboundRow } from './outbounds-tab-types'; import { useOutboundColumns } from './useOutboundColumns'; import OutboundCardList from './OutboundCardList'; import SubscriptionOutbounds from './SubscriptionOutbounds'; interface OutboundSub { id: number; remark?: string; url?: string; enabled?: boolean; allowPrivate?: boolean; prepend?: boolean; priority?: number; tagPrefix?: string; updateInterval?: number; lastUpdated?: number; lastError?: string; outboundCount?: number; } interface OutboundsTabProps { templateSettings: XraySettingsValue | null; setTemplateSettings: SetTemplate; outboundsTraffic: OutboundTrafficRow[]; outboundTestStates: Record; subscriptionTestStates: Record; testingAll: boolean; inboundTags: string[]; subscriptionOutbounds?: unknown[]; subscriptionOutboundTags?: string[]; isMobile: boolean; onResetTraffic: (tag: string) => void; onTest: (index: number, mode: string) => void; onTestSubscription: (outbound: Record, mode: string) => void; onTestAll: (mode: string) => void; onShowWarp: () => void; onShowNord: () => void; onRefreshXrayData?: () => void; } export default function OutboundsTab({ templateSettings, setTemplateSettings, outboundsTraffic, outboundTestStates, subscriptionTestStates, testingAll, inboundTags: _inboundTags, subscriptionOutbounds, subscriptionOutboundTags, isMobile, onResetTraffic, onTest, onTestSubscription, onTestAll, onShowWarp, onShowNord, onRefreshXrayData, }: OutboundsTabProps) { const { t } = useTranslation(); const [modal, modalContextHolder] = Modal.useModal(); const [messageApi, messageContextHolder] = message.useMessage(); const [testMode, setTestMode] = useState('tcp'); const [modalOpen, setModalOpen] = useState(false); const [editingOutbound, setEditingOutbound] = useState | null>(null); const [editingIndex, setEditingIndex] = useState(null); const [existingTags, setExistingTags] = useState([]); // Subscription manager (CRUD + reorder + refresh + preview) const [subDrawerOpen, setSubDrawerOpen] = useState(false); const [subs, setSubs] = useState([]); const [subsLoading, setSubsLoading] = useState(false); const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false }); const [editingSubId, setEditingSubId] = useState(null); const [savingSub, setSavingSub] = useState(false); const [refreshingId, setRefreshingId] = useState(null); const [refreshingAll, setRefreshingAll] = useState(false); const [busyId, setBusyId] = useState(null); const [previewing, setPreviewing] = useState(false); const [previewData, setPreviewData] = useState<{ tag?: string; protocol?: string }[] | null>(null); // Convenience: expose hours/minutes for the interval input const intervalHours = Math.floor((newSub.updateInterval || 600) / 3600); const intervalMinutes = Math.floor(((newSub.updateInterval || 600) % 3600) / 60); function setIntervalHM(h: number, m: number) { const secs = Math.max(60, (h || 0) * 3600 + (m || 0) * 60); setNewSub((prev) => ({ ...prev, updateInterval: secs })); } const outbounds = useMemo( () => (templateSettings?.outbounds || []) as unknown as OutboundRow[], [templateSettings?.outbounds], ); const rows = useMemo( () => outbounds .map((o, i) => ({ ...o, key: i })) .filter((o) => !isBalancerLoopbackTag(o.tag || '')), [outbounds], ); const dialerProxyTags = useMemo(() => { const tags = new Set(); (templateSettings?.outbounds || []).forEach((o, i) => { if (i === editingIndex) return; if (o?.protocol === 'blackhole') return; if (o?.tag) tags.add(o.tag); }); for (const tag of subscriptionOutboundTags || []) { if (tag) tags.add(tag); } return [...tags]; }, [templateSettings?.outbounds, editingIndex, subscriptionOutboundTags]); 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 openAdd() { setEditingOutbound(null); setEditingIndex(null); setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg)); setModalOpen(true); } function openSubManager() { setSubDrawerOpen(true); loadSubs(); } function openEdit(idx: number) { setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record); setEditingIndex(idx); setExistingTags( (templateSettings?.outbounds || []) .filter((_, i) => i !== idx) .map((o) => o?.tag) .filter((tg): tg is string => !!tg), ); setModalOpen(true); } function onConfirm(outbound: Record) { mutate((tt) => { if (!Array.isArray(tt.outbounds)) tt.outbounds = []; const newTag = typeof outbound.tag === 'string' ? outbound.tag : ''; if (editingIndex == null) { if (!newTag) return; tt.outbounds.push(outbound as never); } else { const oldTag = tt.outbounds[editingIndex]?.tag; tt.outbounds[editingIndex] = outbound as never; if (oldTag && newTag && oldTag !== newTag) { propagateOutboundTagRename(tt, oldTag, newTag); } } }); setModalOpen(false); } function confirmDelete(idx: number) { const impact = templateSettings ? planOutboundDeletion(templateSettings, idx) : { rules: [], balancers: [], observatory: false, burst: false }; modal.confirm({ title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`, content: , okText: t('delete'), okType: 'danger', cancelText: t('cancel'), onOk: () => mutate((tt) => applyOutboundDeletion(tt, idx)), }); } function setFirst(idx: number) { mutate((tt) => { if (!tt.outbounds) return; const [moved] = tt.outbounds.splice(idx, 1); tt.outbounds.unshift(moved); }); } function moveUp(idx: number) { if (idx <= 0) return; mutate((tt) => { if (!tt.outbounds) return; [tt.outbounds[idx - 1], tt.outbounds[idx]] = [tt.outbounds[idx], tt.outbounds[idx - 1]]; }); } function moveDown(idx: number) { mutate((tt) => { if (!tt.outbounds || idx >= tt.outbounds.length - 1) return; [tt.outbounds[idx + 1], tt.outbounds[idx]] = [tt.outbounds[idx], tt.outbounds[idx + 1]]; }); } const [importOpen, setImportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false); const [exportContent, setExportContent] = useState(''); function exportOutbounds() { setExportContent(JSON.stringify(outbounds, null, 2)); setExportOpen(true); } function importOutbounds(value: string) { let parsed: unknown; try { parsed = JSON.parse(value); } catch { messageApi.error(t('pages.xray.importInvalidJson')); return; } const obj = parsed as { outbounds?: unknown }; const list = Array.isArray(parsed) ? parsed : Array.isArray(obj?.outbounds) ? obj.outbounds : null; if (!list) { messageApi.error(t('pages.xray.importInvalidJson')); return; } mutate((tt) => { if (!Array.isArray(tt.outbounds)) tt.outbounds = []; tt.outbounds.push(...(list as never[])); }); setImportOpen(false); } // --- Subscription management (minimal inline UI) --- async function loadSubs() { setSubsLoading(true); try { const r = await HttpUtil.get('/panel/api/xray/outbound-subs'); if (r?.success) setSubs(Array.isArray(r.obj) ? r.obj : []); } catch { messageApi.error(t('pages.xray.outboundSub.toastLoadFailed')); } finally { setSubsLoading(false); } } function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; prepend?: boolean }) { return { remark: src.remark ?? '', url: src.url ?? '', tagPrefix: src.tagPrefix ?? '', updateInterval: src.updateInterval ?? 600, enabled: src.enabled ?? true, allowPrivate: src.allowPrivate ?? false, prepend: src.prepend ?? false, }; } function resetSubForm() { setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false }); setEditingSubId(null); setPreviewData(null); } function openEditSub(sub: OutboundSub) { setNewSub({ remark: sub.remark ?? '', url: sub.url ?? '', tagPrefix: sub.tagPrefix ?? '', updateInterval: sub.updateInterval ?? 600, enabled: sub.enabled ?? true, allowPrivate: sub.allowPrivate ?? false, prepend: sub.prepend ?? false, }); setEditingSubId(sub.id); setPreviewData(null); } async function saveSub() { if (!newSub.url.trim()) { messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired')); return; } setSavingSub(true); try { const url = editingSubId != null ? `/panel/api/xray/outbound-subs/${editingSubId}` : '/panel/api/xray/outbound-subs'; const r = await HttpUtil.post(url, subBody(newSub)); if (r?.success) { messageApi.success(t(editingSubId != null ? 'pages.xray.outboundSub.toastUpdated' : 'pages.xray.outboundSub.toastAdded')); const createdId = editingSubId == null ? r.obj?.id : undefined; resetSubForm(); await loadSubs(); if (createdId) await refreshOne(createdId); onRefreshXrayData?.(); } else { messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed')); } } catch { messageApi.error(t('pages.xray.outboundSub.toastAddFailed')); } finally { setSavingSub(false); } } async function previewSub() { if (!newSub.url.trim()) { messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired')); return; } setPreviewing(true); setPreviewData(null); try { const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>('/panel/api/xray/outbound-subs/parse', { url: newSub.url, allowPrivate: newSub.allowPrivate }); if (r?.success && Array.isArray(r.obj)) { setPreviewData(r.obj); if (r.obj.length === 0) messageApi.info(t('pages.xray.outboundSub.previewEmpty')); } else { messageApi.error(r?.msg || t('pages.xray.outboundSub.previewEmpty')); } } catch { messageApi.error(t('pages.xray.outboundSub.previewEmpty')); } finally { setPreviewing(false); } } async function toggleEnabled(sub: OutboundSub) { setBusyId(sub.id); try { const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${sub.id}`, subBody({ ...sub, enabled: !sub.enabled })); if (r?.success) { await loadSubs(); onRefreshXrayData?.(); } else { messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed')); } } catch { messageApi.error(t('pages.xray.outboundSub.toastAddFailed')); } finally { setBusyId(null); } } async function moveSub(id: number, dir: 'up' | 'down') { setBusyId(id); try { const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/move`, { dir }); if (r?.success) { await loadSubs(); onRefreshXrayData?.(); } } catch { /* ignore */ } finally { setBusyId(null); } } async function refreshOne(id: number) { setRefreshingId(id); try { const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/refresh`); if (r?.success) { messageApi.success(t('pages.xray.outboundSub.toastRefreshed')); await loadSubs(); onRefreshXrayData?.(); } else { messageApi.error(r?.msg || t('pages.xray.outboundSub.toastRefreshFailed')); } } catch { messageApi.error(t('pages.xray.outboundSub.toastRefreshFailed')); } finally { setRefreshingId(null); } } async function refreshAllSubs() { if (subs.length === 0) return; setRefreshingAll(true); try { for (const s of subs) { try { await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`); } catch { /* continue */ } } messageApi.success(t('pages.xray.outboundSub.toastRefreshed')); await loadSubs(); onRefreshXrayData?.(); } finally { setRefreshingAll(false); } } async function deleteOne(id: number) { try { const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/del`); if (r?.success) { messageApi.success(t('pages.xray.outboundSub.toastDeleted')); await loadSubs(); onRefreshXrayData?.(); } } catch { messageApi.error(t('pages.xray.outboundSub.toastDeleteFailed')); } } const columns = useOutboundColumns({ testMode, rows, outboundsTraffic, outboundTestStates, openEdit, setFirst, moveUp, moveDown, confirmDelete, onResetTraffic, onTest, }); return ( <> {modalContextHolder} {messageContextHolder} , label: 'WARP', onClick: onShowWarp }, { key: 'nord', icon: , label: 'NordVPN', onClick: onShowNord }, { type: 'divider' }, { key: 'import', icon: , label: t('pages.xray.importOutbounds'), onClick: () => setImportOpen(true) }, { key: 'export', icon: , label: t('pages.xray.exportOutbounds'), disabled: outbounds.length === 0, onClick: exportOutbounds }, ], }} > setTestMode(e.target.value)} buttonStyle="solid" size="small"> TCP HTTP {t('pages.xray.outbound.modeRealDelay')} onResetTraffic('-alltags-')} >