Files
3x-ui/frontend/src/pages/clients/ClientInfoModal.tsx
T
MHSanaei aee45ca3fe fix(hosts): advertise Hosts in every WireGuard, AmneziaWG and TUIC config
Invariant: an inbound's enabled Hosts are the endpoints every client config
for it advertises, whichever surface renders that config.

WireGuard and AmneziaWG broke it. Their raw generators ignored the
externalProxy entries Hosts are injected as and always emitted
resolveInboundAddress, so the raw subscription, the sub page .conf, the
clients links API and "export all links" gave out the panel address while
the JSON and Clash formats of the same inbound used the Host.
advertisedEndpoints now states the fan-out once for mtproto, wireguard and
amneziawg.

The browser-built configs had the same gap. The Clients page WireGuard and
AmneziaWG config blocks and QR panels, and its TUIC Clash config, used the
panel hostname next to server links that already used Hosts; the Inbounds
page peer configs, QR and export ignored them too. withMtprotoHostEndpoints
becomes withHostEndpoints over a shared hostEndpointsFor mirror of the
backend, the tunnel fan-outs render one config per Host, and the clients
page waits for the hosts list the way the inbounds page does, so an empty
list means "no hosts" rather than "not loaded yet".
2026-09-30 15:03:16 +02:00

919 lines
32 KiB
TypeScript

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<string, string> = {
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<number, InboundOption>;
tunnelAllowedIPs?: Record<number, string>;
isOnline: boolean;
subSettings?: SubSettings;
hosts?: HostRecord[];
onOpenChange: (open: boolean) => void;
}
const NO_HOSTS: HostRecord[] = [];
interface ApiMsg<T = unknown> {
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<string[]>([]);
const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
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<string[]>;
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<unknown[]>;
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}
<Modal
open={open}
title={
client
? `${t('pages.clients.clientInfo')} — ${client.email}`
: t('pages.clients.clientInfo')
}
footer={null}
width={640}
onCancel={() => onOpenChange(false)}
>
{client && (
<>
<table className="info-table block">
<tbody>
<tr>
<td>{t('pages.clients.online')}</td>
<td>
{client.enable && isOnline ? (
<Tag color="green">{t('pages.clients.online')}</Tag>
) : (
<Tag>{t('pages.clients.offline')}</Tag>
)}
<span className="hint">
{t('lastOnline')}: {dateLabel(traffic?.lastOnline)}
{' · '}
{t('lastSubFetch')}: {dateLabel(traffic?.lastSubFetch)}
</span>
</td>
</tr>
<tr>
<td>{t('status')}</td>
<td>
<Tag color={client.enable ? 'green' : 'default'}>
{client.enable ? t('enabled') : t('disabled')}
</Tag>
</td>
</tr>
<tr>
<td>{t('pages.clients.email')}</td>
<td>
{client.email ? (
<Tag color="green">{client.email}</Tag>
) : (
<Tag color="red">{t('none')}</Tag>
)}
</td>
</tr>
<tr>
<td>{t('pages.clients.subId')}</td>
<td>
<Tag className="info-large-tag">{client.subId || '-'}</Tag>
{client.subId && (
<Button
size="small"
type="text"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(client.subId!)}
/>
)}
</td>
</tr>
{client.uuid && (
<tr>
<td>{t('pages.clients.uuid')}</td>
<td>
<Tag className="info-large-tag">{client.uuid}</Tag>
<Button
size="small"
type="text"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(client.uuid!)}
/>
</td>
</tr>
)}
{client.password && (
<tr>
<td>{t('password')}</td>
<td>
<Tag className="info-large-tag">{client.password}</Tag>
<Button
size="small"
type="text"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(client.password!)}
/>
</td>
</tr>
)}
{client.auth && (
<tr>
<td>{t('pages.clients.auth')}</td>
<td>
<Tag className="info-large-tag">{client.auth}</Tag>
<Button
size="small"
type="text"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(client.auth!)}
/>
</td>
</tr>
)}
<tr>
<td>{t('pages.clients.flow')}</td>
<td>
{client.flow ? <Tag>{client.flow}</Tag> : <Tag color="orange">{t('none')}</Tag>}
</td>
</tr>
<tr>
<td>{t('pages.inbounds.traffic')}</td>
<td>
<Tag>
↑ {SizeFormatter.sizeFormat(traffic?.up || 0)} / ↓{' '}
{SizeFormatter.sizeFormat(traffic?.down || 0)}
</Tag>
<span className="hint">
{SizeFormatter.sizeFormat(used)} /{' '}
{totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞'}
</span>
</td>
</tr>
<tr>
<td>{t('remained')}</td>
<td>
{remaining < 0 ? (
<Tag color="purple">∞</Tag>
) : (
<Tag color={remaining > 0 ? '' : 'red'}>
{SizeFormatter.sizeFormat(remaining)}
</Tag>
)}
</td>
</tr>
<tr>
<td>{t('pages.inbounds.expireDate')}</td>
<td>
{!client.expiryTime ? (
<Tag color="purple">∞</Tag>
) : (
<Tag color={client.expiryTime < 0 ? 'blue' : undefined}>
{expiryLabel(client.expiryTime)}
</Tag>
)}
{(client.expiryTime ?? 0) > 0 && (
<span className="hint">{IntlUtil.formatRelativeTime(client.expiryTime)}</span>
)}
</td>
</tr>
<tr>
<td>{t('pages.clients.ipLimit')}</td>
<td>{!client.limitIp ? <Tag>∞</Tag> : <Tag>{client.limitIp}</Tag>}</td>
</tr>
<tr>
<td>{t('pages.inbounds.IPLimitlog')}</td>
<td>
<Button
size="small"
icon={<EyeOutlined />}
aria-label={t('pages.clients.ipLog')}
loading={ipsLoading}
onClick={openIpsModal}
>
{clientIps.length > 0 ? clientIps.length : ''}
</Button>
</td>
</tr>
{(traffic?.resetMax ?? 0) > 0 && (
<tr>
<td>{t('pages.clients.renewsUsed')}</td>
<td>
<Tag
color={
(traffic?.resetCount ?? 0) >= (traffic?.resetMax ?? 0) ? 'red' : 'blue'
}
>
{traffic?.resetCount ?? 0} / {traffic?.resetMax}
</Tag>
</td>
</tr>
)}
<tr>
<td>{t('pages.clients.limitHwid')}</td>
<td>{!client.limitHwid ? <Tag>∞</Tag> : <Tag>{client.limitHwid}</Tag>}</td>
</tr>
<tr>
<td>{t('pages.clients.hwidLog')}</td>
<td>
<Button
size="small"
icon={<EyeOutlined />}
aria-label={t('pages.clients.hwidLog')}
loading={hwidsLoading}
onClick={openHwidsModal}
>
{clientHwids.length > 0 ? clientHwids.length : ''}
</Button>
</td>
</tr>
<tr>
<td>{t('pages.inbounds.createdAt')}</td>
<td>
<Tag>{dateLabel(client.createdAt)}</Tag>
</td>
</tr>
<tr>
<td>{t('pages.inbounds.updatedAt')}</td>
<td>
<Tag>{dateLabel(client.updatedAt)}</Tag>
</td>
</tr>
{client.group && (
<tr>
<td>{t('pages.clients.group')}</td>
<td>
<Tag color="geekblue">{client.group}</Tag>
</td>
</tr>
)}
{client.comment && (
<tr>
<td>{t('pages.clients.comment')}</td>
<td>
<Tag className="info-large-tag">{client.comment}</Tag>
</td>
</tr>
)}
<tr>
<td>{t('pages.clients.attachedInbounds')}</td>
<td>
{(() => {
const ids = client.inboundIds || [];
if (ids.length === 0) return <span className="hint">—</span>;
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 (
<Tooltip key={id} title={label}>
<Tag color={color}>{label}</Tag>
</Tooltip>
);
};
return (
<div className="chips">
{visible.map((id) => inboundChip(id))}
{overflow.length > 0 && (
<Popover
trigger="click"
placement="bottomRight"
content={
<div className="chips chips-stack">
{overflow.map((id) => inboundChip(id))}
</div>
}
>
<Tag color="default" className="chip-more">
+{overflow.length} {t('more') !== 'more' ? t('more') : 'more'}
</Tag>
</Popover>
)}
</div>
);
})()}
</td>
</tr>
</tbody>
</table>
{showSubscription && subLink && (
<>
<Divider>{t('subscription.title')}</Divider>
<div className="link-row">
<Tag color="green" className="link-row-tag">
SUB
</Tag>
<a
href={subLink}
target="_blank"
rel="noopener noreferrer"
className="link-row-title link-row-title-anchor"
title={subLink}
>
{client.subId}
</a>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(subLink)}
/>
</Tooltip>
<Tooltip title={t('download')}>
<Button
size="small"
icon={<DownloadOutlined />}
aria-label={t('download')}
loading={downloadingFormat === 'standard'}
disabled={downloadingFormat !== null}
onClick={() => void downloadSubscription(subLink, 'standard')}
/>
</Tooltip>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={
<QrPanel
value={subLink}
remark={`${client.email} — ${t('subscription.title')}`}
size={220}
/>
}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip>
</Popover>
</div>
</div>
{subJsonLink && (
<div className="link-row">
<Tag color="purple" className="link-row-tag">
JSON
</Tag>
<a
href={subJsonLink}
target="_blank"
rel="noopener noreferrer"
className="link-row-title link-row-title-anchor"
title={subJsonLink}
>
{client.subId}
</a>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(subJsonLink)}
/>
</Tooltip>
<Tooltip title={t('download')}>
<Button
size="small"
icon={<DownloadOutlined />}
aria-label={t('download')}
loading={downloadingFormat === 'json'}
disabled={downloadingFormat !== null}
onClick={() => void downloadSubscription(subJsonLink, 'json')}
/>
</Tooltip>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={
<QrPanel
value={subJsonLink}
remark={`${client.email} — JSON`}
size={220}
/>
}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip>
</Popover>
</div>
</div>
)}
{subClashLink && (
<div className="link-row">
<Tooltip title="Clash / Mihomo">
<Tag color="gold" className="link-row-tag">
CLASH
</Tag>
</Tooltip>
<a
href={subClashLink}
target="_blank"
rel="noopener noreferrer"
className="link-row-title link-row-title-anchor"
title={subClashLink}
>
{client.subId}
</a>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(subClashLink)}
/>
</Tooltip>
<Tooltip title={t('download')}>
<Button
size="small"
icon={<DownloadOutlined />}
aria-label={t('download')}
loading={downloadingFormat === 'clash'}
disabled={downloadingFormat !== null}
onClick={() => void downloadSubscription(subClashLink, 'clash')}
/>
</Tooltip>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={
<QrPanel
value={subClashLink}
remark={`${client.email} — Clash / Mihomo`}
size={220}
/>
}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip>
</Popover>
</div>
</div>
)}
</>
)}
{links.length > 0 && (
<>
<Divider>{t('pages.inbounds.copyLink')}</Divider>
{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 (
<div key={idx} className="link-row">
{parts ? (
<LinkTags parts={parts} />
) : (
<Tag className="link-row-tag">LINK</Tag>
)}
<span className="link-row-title" title={rowTitle}>
{rowTitle}
</span>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyValue(link)}
/>
</Tooltip>
{canQr && (
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={<QrPanel value={link} remark={qrRemark} size={220} />}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip>
</Popover>
)}
</div>
</div>
);
})}
</>
)}
{wgConfigs.length > 0 && client && (
<>
<Divider>{t('pages.clients.wireguardConfig')}</Divider>
{wgConfigs.map(({ inbound, endpoint, text }) => {
const meta = formatTunnelConfigMeta(
inbound,
client.email,
wgConfigs.length,
endpoint,
);
return (
<ConfigBlock
key={`wg-${inbound.id}-${endpoint}`}
label={meta.label || t('pages.clients.config')}
text={text}
fileName={meta.fileName}
qrRemark={meta.qrRemark}
tagColor="cyan"
/>
);
})}
</>
)}
{awgConfigs.length > 0 && client && (
<>
<Divider>{t('pages.clients.amneziaWgConfig')}</Divider>
{awgConfigs.map(({ inbound, endpoint, text }) => {
const meta = formatTunnelConfigMeta(
inbound,
client.email,
awgConfigs.length,
endpoint,
);
return (
<ConfigBlock
key={`awg-${inbound.id}-${endpoint}`}
label={meta.label || t('pages.clients.config')}
text={text}
fileName={meta.fileName}
qrRemark={meta.qrRemark}
tagColor="purple"
/>
);
})}
</>
)}
</>
)}
</Modal>
<Modal
open={ipsModalOpen}
title={`${t('pages.inbounds.IPLimitlog')}${client?.email ? ` — ${client.email}` : ''}`}
width={440}
onCancel={() => setIpsModalOpen(false)}
footer={[
<Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
{t('refresh')}
</Button>,
<Button
key="clear"
danger
loading={ipsClearing}
disabled={clientIps.length === 0}
onClick={clearIps}
>
{t('pages.clients.clearAll')}
</Button>,
<Button key="close" type="primary" onClick={() => setIpsModalOpen(false)}>
{t('close')}
</Button>,
]}
>
{clientIps.length > 0 ? (
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{clientIps.map((entry, idx) => (
<Tag
key={idx}
color="blue"
style={{
display: 'block',
width: 'fit-content',
maxWidth: '100%',
marginBottom: 6,
padding: '2px 8px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
}}
>
{entry.ip}
{entry.time ? ` (${entry.time})` : ''}
{entry.node ? (
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>
@ {entry.node}
</span>
) : null}
</Tag>
))}
</div>
) : (
<Tag>{t('tgbot.noIpRecord')}</Tag>
)}
</Modal>
<ClientHwidListModal
open={hwidsModalOpen}
email={client?.email}
hwids={clientHwids}
loading={hwidsLoading}
clearing={hwidsClearing}
deletingId={deletingHwidId}
formatDate={dateLabel}
onRefresh={loadHwids}
onClearAll={clearHwids}
onDelete={deleteHwid}
onClose={() => setHwidsModalOpen(false)}
/>
</>
);
}