import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Divider, Modal, Popover, Tag, Tooltip, message } from 'antd'; import { CopyOutlined, DownloadOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined, } from '@ant-design/icons'; import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils'; import { formatInboundLabel, formatTunnelConfigMeta } from '@/lib/inbounds/label'; import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log'; import { useDatepicker } from '@/hooks/useDatepicker'; import { useClientHwids } from '@/hooks/useClientHwids'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import { isPostQuantumLink } from '@/lib/xray/inbound-link'; import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label'; import { QrPanel } from '@/pages/inbounds/qr'; import ClientHwidListModal from '@/components/clients/ClientHwidList'; import ConfigBlock from '@/components/clients/ConfigBlock'; import { buildWireguardClientConfig, findWireguardInbounds, isWireguardClient, } from './wireguardConfig'; import { buildAmneziaWGClientConfig, findAmneziaWGInbounds, isAmneziaWGClient, } from './amneziawgConfig'; import { tunnelConfigEndpoints, tunnelEndpointLabel } from './tunnelEndpoints'; import type { HostRecord } from '@/schemas/api/host'; import './ClientInfoModal.css'; const INBOUND_PROTOCOL_COLORS: Record = { vless: 'blue', vmess: 'geekblue', trojan: 'volcano', shadowsocks: 'magenta', hysteria: 'cyan', hysteria2: 'green', wireguard: 'gold', amneziawg: 'yellow', http: 'purple', mixed: 'lime', tunnel: 'orange', tuic: 'orange', }; const INBOUND_CHIP_LIMIT = 1; interface SubSettings { enable: boolean; subURI: string; subJsonURI: string; subJsonEnable: boolean; subClashURI: string; subClashEnable: boolean; publicHost?: string; } interface ClientInfoModalProps { open: boolean; client: ClientRecord | null; inboundsById: Record; tunnelAllowedIPs?: Record; isOnline: boolean; subSettings?: SubSettings; hosts?: HostRecord[]; onOpenChange: (open: boolean) => void; } const NO_HOSTS: HostRecord[] = []; interface ApiMsg { success?: boolean; obj?: T; } const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false, subClashURI: '', subClashEnable: false, publicHost: '', }; const SUBSCRIPTION_DOWNLOAD_NAMES = { standard: 'subscription-standard.txt', json: 'subscription-json.json', clash: 'subscription-clash.yaml', } as const; export default function ClientInfoModal({ open, client, inboundsById, tunnelAllowedIPs, isOnline, subSettings = DEFAULT_SUB, hosts = NO_HOSTS, onOpenChange, }: ClientInfoModalProps) { const { datepicker } = useDatepicker(); const { t } = useTranslation(); const expiryLabel = (ts?: number) => { if (!ts) return '∞'; if (ts < 0) { const days = Math.round(ts / -86400000); return `${t('pages.clients.delayedStart')}: ${days}d`; } return IntlUtil.formatDate(ts, datepicker); }; const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker)); const [messageApi, messageContextHolder] = message.useMessage(); const [links, setLinks] = useState([]); const [clientIps, setClientIps] = useState([]); const [ipsLoading, setIpsLoading] = useState(false); const [ipsClearing, setIpsClearing] = useState(false); const [ipsModalOpen, setIpsModalOpen] = useState(false); const { clientHwids, hwidsLoading, hwidsClearing, deletingHwidId, loadHwids, clearHwids, deleteHwid, resetHwids, } = useClientHwids(client?.email); const [hwidsModalOpen, setHwidsModalOpen] = useState(false); const [downloadingFormat, setDownloadingFormat] = useState< keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null >(null); // Clearing on close happens during render; the effect owns only the fetch. const openSubId = open ? (client?.subId ?? '') : null; const [syncedSubId, setSyncedSubId] = useState(openSubId); if (openSubId !== syncedSubId) { setSyncedSubId(openSubId); if (openSubId === null) { setLinks([]); setClientIps([]); setIpsModalOpen(false); resetHwids(); setHwidsModalOpen(false); } } useEffect(() => { if (!open || !client?.subId) return; let cancelled = false; (async () => { const msg = (await HttpUtil.get( `/panel/api/clients/subLinks/${encodeURIComponent(client.subId!)}`, )) as ApiMsg; if (cancelled) return; setLinks(msg?.success && Array.isArray(msg.obj) ? msg.obj : []); })(); return () => { cancelled = true; }; }, [open, client?.subId]); const traffic = client?.traffic || null; const totalBytes = client?.totalGB || 0; const used = (traffic?.up || 0) + (traffic?.down || 0); const remaining = useMemo(() => { if (totalBytes <= 0) return -1; const r = totalBytes - used; return r > 0 ? r : 0; }, [totalBytes, used]); const subId = client?.subId; const subLink = subId && subSettings?.subURI ? subSettings.subURI + subId : ''; const subJsonLink = subId && subSettings?.subJsonEnable && subSettings?.subJsonURI ? subSettings.subJsonURI + subId : ''; const subClashLink = subId && subSettings?.subClashEnable && subSettings?.subClashURI ? subSettings.subClashURI + subId : ''; const showSubscription = !!(subSettings?.enable && client?.subId); const wgInbounds = useMemo( () => findWireguardInbounds(client, inboundsById), [client, inboundsById], ); const wgConfigs = useMemo(() => { if (!client || !isWireguardClient(client)) return []; const host = window.location.hostname; const publicHost = subSettings?.publicHost ?? ''; return wgInbounds .flatMap((ib) => { const address = tunnelAllowedIPs?.[ib.id] ?? ''; return tunnelConfigEndpoints(ib, hosts, host, publicHost).map((ep) => ({ inbound: ib, endpoint: tunnelEndpointLabel(ep), text: buildWireguardClientConfig(client, ib, host, publicHost, address, ep), })); }) .filter((c) => !!c.text); }, [client, wgInbounds, tunnelAllowedIPs, subSettings?.publicHost, hosts]); const awgInbounds = useMemo( () => findAmneziaWGInbounds(client, inboundsById), [client, inboundsById], ); const awgConfigs = useMemo(() => { if (!client || !isAmneziaWGClient(client)) return []; const host = window.location.hostname; const publicHost = subSettings?.publicHost ?? ''; return awgInbounds .flatMap((ib) => { const address = tunnelAllowedIPs?.[ib.id] ?? ''; return tunnelConfigEndpoints(ib, hosts, host, publicHost).map((ep) => ({ inbound: ib, endpoint: tunnelEndpointLabel(ep), text: buildAmneziaWGClientConfig(client, ib, host, publicHost, address, ep), })); }) .filter((c) => !!c.text); }, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost, hosts]); async function copyValue(text: string) { if (!text) return; const ok = await ClipboardManager.copyText(String(text)); if (ok) messageApi.success(t('copied')); } async function downloadSubscription( url: string, format: keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES, ) { if (!url || downloadingFormat) return; setDownloadingFormat(format); try { const response = await fetch(url); if (!response.ok) throw new Error('Subscription download failed'); const content = await response.text(); FileManager.downloadTextFile(content, SUBSCRIPTION_DOWNLOAD_NAMES[format]); } catch (_) { messageApi.error(t('somethingWentWrong')); } finally { setDownloadingFormat(null); } } async function loadIps() { if (!client?.email) return; setIpsLoading(true); try { const msg = (await HttpUtil.post( `/panel/api/clients/ips/${encodeURIComponent(client.email)}`, )) as ApiMsg; if (!msg?.success) { setClientIps([]); return; } setClientIps(normalizeClientIps(msg.obj)); } finally { setIpsLoading(false); } } async function clearIps() { if (!client?.email) return; setIpsClearing(true); try { const msg = (await HttpUtil.post( `/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`, )) as ApiMsg; if (msg?.success) setClientIps([]); } finally { setIpsClearing(false); } } function openIpsModal() { setIpsModalOpen(true); if (clientIps.length === 0) void loadIps(); } function openHwidsModal() { setHwidsModalOpen(true); if (clientHwids.length === 0) void loadHwids(); } return ( <> {messageContextHolder} onOpenChange(false)} > {client && ( <> {client.uuid && ( )} {client.password && ( )} {client.auth && ( )} {(traffic?.resetMax ?? 0) > 0 && ( )} {client.group && ( )} {client.comment && ( )}
{t('pages.clients.online')} {client.enable && isOnline ? ( {t('pages.clients.online')} ) : ( {t('pages.clients.offline')} )} {t('lastOnline')}: {dateLabel(traffic?.lastOnline)} {' · '} {t('lastSubFetch')}: {dateLabel(traffic?.lastSubFetch)}
{t('status')} {client.enable ? t('enabled') : t('disabled')}
{t('pages.clients.email')} {client.email ? ( {client.email} ) : ( {t('none')} )}
{t('pages.clients.subId')} {client.subId || '-'} {client.subId && (
{t('pages.clients.uuid')} {client.uuid}
{t('password')} {client.password}
{t('pages.clients.auth')} {client.auth}
{t('pages.clients.flow')} {client.flow ? {client.flow} : {t('none')}}
{t('pages.inbounds.traffic')} ↑ {SizeFormatter.sizeFormat(traffic?.up || 0)} / ↓{' '} {SizeFormatter.sizeFormat(traffic?.down || 0)} {SizeFormatter.sizeFormat(used)} /{' '} {totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞'}
{t('remained')} {remaining < 0 ? ( ∞ ) : ( 0 ? '' : 'red'}> {SizeFormatter.sizeFormat(remaining)} )}
{t('pages.inbounds.expireDate')} {!client.expiryTime ? ( ∞ ) : ( {expiryLabel(client.expiryTime)} )} {(client.expiryTime ?? 0) > 0 && ( {IntlUtil.formatRelativeTime(client.expiryTime)} )}
{t('pages.clients.ipLimit')} {!client.limitIp ? ∞ : {client.limitIp}}
{t('pages.inbounds.IPLimitlog')}
{t('pages.clients.renewsUsed')} = (traffic?.resetMax ?? 0) ? 'red' : 'blue' } > {traffic?.resetCount ?? 0} / {traffic?.resetMax}
{t('pages.clients.limitHwid')} {!client.limitHwid ? ∞ : {client.limitHwid}}
{t('pages.clients.hwidLog')}
{t('pages.inbounds.createdAt')} {dateLabel(client.createdAt)}
{t('pages.inbounds.updatedAt')} {dateLabel(client.updatedAt)}
{t('pages.clients.group')} {client.group}
{t('pages.clients.comment')} {client.comment}
{t('pages.clients.attachedInbounds')} {(() => { const ids = client.inboundIds || []; if (ids.length === 0) return —; const visible = ids.slice(0, INBOUND_CHIP_LIMIT); const overflow = ids.slice(INBOUND_CHIP_LIMIT); const inboundChip = (id: number) => { const ib = inboundsById[id]; const proto = (ib?.protocol || '').toLowerCase(); const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default'; const label = formatInboundLabel(ib?.tag, ib?.remark); return ( {label} ); }; return (
{visible.map((id) => inboundChip(id))} {overflow.length > 0 && ( {overflow.map((id) => inboundChip(id))}
} > +{overflow.length} {t('more') !== 'more' ? t('more') : 'more'} )} ); })()}
{showSubscription && subLink && ( <> {t('subscription.title')}
SUB {client.subId}
{subJsonLink && (
JSON {client.subId}
)} {subClashLink && (
CLASH {client.subId}
)} )} {links.length > 0 && ( <> {t('pages.inbounds.copyLink')} {links.map((link, idx) => { const parts = parseLinkParts(link); const fallback = `${t('pages.clients.link')} ${idx + 1}`; const rowTitle = (parts && linkMetaText(parts)) || fallback; const qrRemark = parts?.remark || rowTitle; const canQr = !isPostQuantumLink(link); return (
{parts ? ( ) : ( LINK )} {rowTitle}
); })} )} {wgConfigs.length > 0 && client && ( <> {t('pages.clients.wireguardConfig')} {wgConfigs.map(({ inbound, endpoint, text }) => { const meta = formatTunnelConfigMeta( inbound, client.email, wgConfigs.length, endpoint, ); return ( ); })} )} {awgConfigs.length > 0 && client && ( <> {t('pages.clients.amneziaWgConfig')} {awgConfigs.map(({ inbound, endpoint, text }) => { const meta = formatTunnelConfigMeta( inbound, client.email, awgConfigs.length, endpoint, ); return ( ); })} )} )}
setIpsModalOpen(false)} footer={[ , , , ]} > {clientIps.length > 0 ? (
{clientIps.map((entry, idx) => ( {entry.ip} {entry.time ? ` (${entry.time})` : ''} {entry.node ? ( @ {entry.node} ) : null} ))}
) : ( {t('tgbot.noIpRecord')} )}
setHwidsModalOpen(false)} /> ); }