diff --git a/frontend/src/pages/inbounds/info/InboundInfoModal.tsx b/frontend/src/pages/inbounds/info/InboundInfoModal.tsx index e34fa4da..31b2239d 100644 --- a/frontend/src/pages/inbounds/info/InboundInfoModal.tsx +++ b/frontend/src/pages/inbounds/info/InboundInfoModal.tsx @@ -1,280 +1,30 @@ import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip } from 'antd'; -import { getMessage } from '@/utils/messageBus'; import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons'; -import { - HttpUtil, - IntlUtil, - SizeFormatter, - ColorUtils, - ClipboardManager, - FileManager, -} from '@/utils'; +import { HttpUtil, IntlUtil, SizeFormatter, ColorUtils } from '@/utils'; import { Protocols } from '@/schemas/primitives'; import { InfinityIcon } from '@/components/ui'; import { useDatepicker } from '@/hooks/useDatepicker'; -import { coerceInboundJsonField } from '@/models/dbinbound'; -import { - canEnableTlsFlow, - isSS2022 as isSS2022Helper, - isSSMultiUser as isSSMultiUserHelper, -} from '@/lib/xray/protocol-capabilities'; import { genAllLinks, genWireguardConfigs, genWireguardLinks, } from '@/lib/xray/inbound-link'; import { inboundFromDb } from '@/lib/xray/inbound-from-db'; -import type { SubSettings } from '../useInbounds'; + +import { + buildInboundInfo, + copyText, + downloadText, + formatIpInfo, + hasShareLink, + statsColor, +} from './helpers'; +import type { ClientSetting, ClientStats, InboundInfo, InboundInfoModalProps } from './types'; import './InboundInfoModal.css'; -const LINK_PROTOCOLS: ReadonlySet = new Set([ - Protocols.VMESS, - Protocols.VLESS, - Protocols.TROJAN, - Protocols.SHADOWSOCKS, - Protocols.HYSTERIA, -]); - -function hasShareLink(protocol: string): boolean { - return LINK_PROTOCOLS.has(protocol); -} - -function readHeader(headers: unknown, name: string): string { - const needle = name.toLowerCase(); - if (Array.isArray(headers)) { - for (const h of headers) { - if (h && typeof h === 'object' && String((h as { name?: string }).name ?? '').toLowerCase() === needle) { - return String((h as { value?: unknown }).value ?? ''); - } - } - return ''; - } - if (headers && typeof headers === 'object') { - for (const [k, v] of Object.entries(headers as Record)) { - if (k.toLowerCase() === needle) { - return Array.isArray(v) ? String(v[0] ?? '') : String(v ?? ''); - } - } - } - return ''; -} - -function readNetworkHost(stream: Record, network: string): string | null { - switch (network) { - case 'tcp': { - const tcp = stream.tcpSettings as { header?: { request?: { headers?: unknown } } } | undefined; - return readHeader(tcp?.header?.request?.headers, 'host'); - } - case 'ws': { - const ws = stream.wsSettings as { host?: string; headers?: unknown } | undefined; - return (ws?.host && ws.host.length > 0) ? ws.host : readHeader(ws?.headers, 'host'); - } - case 'httpupgrade': { - const hu = stream.httpupgradeSettings as { host?: string; headers?: unknown } | undefined; - return (hu?.host && hu.host.length > 0) ? hu.host : readHeader(hu?.headers, 'host'); - } - case 'xhttp': { - const xh = stream.xhttpSettings as { host?: string; headers?: unknown } | undefined; - return (xh?.host && xh.host.length > 0) ? xh.host : readHeader(xh?.headers, 'host'); - } - default: - return null; - } -} - -function readNetworkPath(stream: Record, network: string): string | null { - switch (network) { - case 'tcp': { - const tcp = stream.tcpSettings as { header?: { request?: { path?: string[] } } } | undefined; - return tcp?.header?.request?.path?.[0] ?? null; - } - case 'ws': - return (stream.wsSettings as { path?: string } | undefined)?.path ?? null; - case 'httpupgrade': - return (stream.httpupgradeSettings as { path?: string } | undefined)?.path ?? null; - case 'xhttp': - return (stream.xhttpSettings as { path?: string } | undefined)?.path ?? null; - default: - return null; - } -} - -interface ClientStats { - email: string; - up: number; - down: number; - total: number; - expiryTime: number; - enable?: boolean; -} - -interface ClientSetting { - email?: string; - id?: string; - security?: string; - password?: string; - flow?: string; - subId?: string; - totalGB?: number; - expiryTime?: number; - comment?: string; - tgId?: string; - enable?: boolean; - limitIp?: number; - created_at?: number; - updated_at?: number; -} - -interface InboundInfo { - protocol: string; - clients: ClientSetting[]; - settings: Record; - isTcp: boolean; - isWs: boolean; - isHttpupgrade: boolean; - isXHTTP: boolean; - isGrpc: boolean; - isSSMultiUser: boolean; - isSS2022: boolean; - isVlessTlsFlow: boolean; - host: string | null; - path: string | null; - serviceName: string; - serverName: string; - stream: { - network: string; - security: string; - xhttp?: { mode?: string }; - grpc?: { multiMode?: boolean }; - }; -} - -interface DBInboundLike { - id: number; - address: string; - port: number; - listen: string; - protocol: string; - remark: string; - enable?: boolean; - isVMess?: boolean; - isVLess?: boolean; - isTrojan?: boolean; - isSS?: boolean; - isMixed?: boolean; - isHTTP?: boolean; - isWireguard?: boolean; - settings: unknown; - streamSettings: unknown; - sniffing: unknown; - clientStats?: ClientStats[]; -} - -function buildInboundInfo(dbInbound: DBInboundLike): InboundInfo { - const settings = coerceInboundJsonField(dbInbound.settings) as Record; - const stream = coerceInboundJsonField(dbInbound.streamSettings) as Record; - const network = (stream.network as string | undefined) ?? ''; - const security = (stream.security as string | undefined) ?? 'none'; - const clients = Array.isArray(settings.clients) ? (settings.clients as ClientSetting[]) : []; - const xhttpSettings = stream.xhttpSettings as { mode?: string } | undefined; - const grpcSettings = stream.grpcSettings as { multiMode?: boolean; serviceName?: string } | undefined; - let serverName = ''; - if (security === 'tls') { - const tls = stream.tlsSettings as { sni?: string; serverName?: string } | undefined; - serverName = tls?.sni ?? tls?.serverName ?? ''; - } else if (security === 'reality') { - const reality = stream.realitySettings as { serverNames?: string[]; serverName?: string } | undefined; - if (Array.isArray(reality?.serverNames)) { - serverName = reality.serverNames.join(', '); - } else if (reality?.serverName) { - serverName = reality.serverName; - } - } - return { - protocol: dbInbound.protocol, - clients, - settings, - isTcp: network === 'tcp', - isWs: network === 'ws', - isHttpupgrade: network === 'httpupgrade', - isXHTTP: network === 'xhttp', - isGrpc: network === 'grpc', - isSSMultiUser: isSSMultiUserHelper({ - protocol: dbInbound.protocol, - settings: settings as { method?: string }, - }), - isSS2022: isSS2022Helper({ - protocol: dbInbound.protocol, - settings: settings as { method?: string }, - }), - isVlessTlsFlow: canEnableTlsFlow({ - protocol: dbInbound.protocol, - streamSettings: { network, security }, - }), - host: readNetworkHost(stream, network), - path: readNetworkPath(stream, network), - serviceName: grpcSettings?.serviceName ?? '', - serverName, - stream: { - network, - security, - xhttp: xhttpSettings ? { mode: xhttpSettings.mode } : undefined, - grpc: grpcSettings ? { multiMode: grpcSettings.multiMode } : undefined, - }, - }; -} - -interface InboundInfoModalProps { - open: boolean; - onClose: () => void; - dbInbound: DBInboundLike | null; - clientIndex?: number; - remarkModel?: string; - expireDiff?: number; - trafficDiff?: number; - ipLimitEnable?: boolean; - tgBotEnable?: boolean; - nodeAddress?: string; - subSettings?: SubSettings; - lastOnlineMap?: Record; -} - -function copyText(value: unknown, t: (k: string) => string) { - ClipboardManager.copyText(String(value ?? '')).then((ok) => { - if (ok) getMessage().success(t('copied')); - }); -} - -function downloadText(content: string, filename: string) { - FileManager.downloadTextFile(content, filename); -} - -function statsColor(stats: ClientStats, trafficDiff: number) { - return ColorUtils.usageColor(stats.up + stats.down, trafficDiff, stats.total); -} - -function formatIpInfo(record: unknown) { - if (record == null) return ''; - if (typeof record === 'string' || typeof record === 'number') return String(record); - const r = record as { ip?: string; IP?: string; timestamp?: number | string; Timestamp?: number | string }; - const ip = r.ip || r.IP || ''; - const ts = r.timestamp || r.Timestamp || 0; - if (!ip) return String(record); - if (!ts) return String(ip); - const date = new Date(Number(ts) * 1000); - const timeStr = date - .toLocaleString('en-GB', { - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', second: '2-digit', - hour12: false, - }) - .replace(',', ''); - return `${ip} (${timeStr})`; -} - export default function InboundInfoModal({ open, onClose, diff --git a/frontend/src/pages/inbounds/info/helpers.ts b/frontend/src/pages/inbounds/info/helpers.ts new file mode 100644 index 00000000..dd6c858b --- /dev/null +++ b/frontend/src/pages/inbounds/info/helpers.ts @@ -0,0 +1,170 @@ +import { getMessage } from '@/utils/messageBus'; +import { ColorUtils, ClipboardManager, FileManager } from '@/utils'; +import { Protocols } from '@/schemas/primitives'; +import { coerceInboundJsonField } from '@/models/dbinbound'; +import { + canEnableTlsFlow, + isSS2022 as isSS2022Helper, + isSSMultiUser as isSSMultiUserHelper, +} from '@/lib/xray/protocol-capabilities'; + +import type { ClientSetting, ClientStats, DBInboundLike, InboundInfo } from './types'; + +const LINK_PROTOCOLS: ReadonlySet = new Set([ + Protocols.VMESS, + Protocols.VLESS, + Protocols.TROJAN, + Protocols.SHADOWSOCKS, + Protocols.HYSTERIA, +]); + +export function hasShareLink(protocol: string): boolean { + return LINK_PROTOCOLS.has(protocol); +} + +function readHeader(headers: unknown, name: string): string { + const needle = name.toLowerCase(); + if (Array.isArray(headers)) { + for (const h of headers) { + if (h && typeof h === 'object' && String((h as { name?: string }).name ?? '').toLowerCase() === needle) { + return String((h as { value?: unknown }).value ?? ''); + } + } + return ''; + } + if (headers && typeof headers === 'object') { + for (const [k, v] of Object.entries(headers as Record)) { + if (k.toLowerCase() === needle) { + return Array.isArray(v) ? String(v[0] ?? '') : String(v ?? ''); + } + } + } + return ''; +} + +function readNetworkHost(stream: Record, network: string): string | null { + switch (network) { + case 'tcp': { + const tcp = stream.tcpSettings as { header?: { request?: { headers?: unknown } } } | undefined; + return readHeader(tcp?.header?.request?.headers, 'host'); + } + case 'ws': { + const ws = stream.wsSettings as { host?: string; headers?: unknown } | undefined; + return (ws?.host && ws.host.length > 0) ? ws.host : readHeader(ws?.headers, 'host'); + } + case 'httpupgrade': { + const hu = stream.httpupgradeSettings as { host?: string; headers?: unknown } | undefined; + return (hu?.host && hu.host.length > 0) ? hu.host : readHeader(hu?.headers, 'host'); + } + case 'xhttp': { + const xh = stream.xhttpSettings as { host?: string; headers?: unknown } | undefined; + return (xh?.host && xh.host.length > 0) ? xh.host : readHeader(xh?.headers, 'host'); + } + default: + return null; + } +} + +function readNetworkPath(stream: Record, network: string): string | null { + switch (network) { + case 'tcp': { + const tcp = stream.tcpSettings as { header?: { request?: { path?: string[] } } } | undefined; + return tcp?.header?.request?.path?.[0] ?? null; + } + case 'ws': + return (stream.wsSettings as { path?: string } | undefined)?.path ?? null; + case 'httpupgrade': + return (stream.httpupgradeSettings as { path?: string } | undefined)?.path ?? null; + case 'xhttp': + return (stream.xhttpSettings as { path?: string } | undefined)?.path ?? null; + default: + return null; + } +} + +export function buildInboundInfo(dbInbound: DBInboundLike): InboundInfo { + const settings = coerceInboundJsonField(dbInbound.settings) as Record; + const stream = coerceInboundJsonField(dbInbound.streamSettings) as Record; + const network = (stream.network as string | undefined) ?? ''; + const security = (stream.security as string | undefined) ?? 'none'; + const clients = Array.isArray(settings.clients) ? (settings.clients as ClientSetting[]) : []; + const xhttpSettings = stream.xhttpSettings as { mode?: string } | undefined; + const grpcSettings = stream.grpcSettings as { multiMode?: boolean; serviceName?: string } | undefined; + let serverName = ''; + if (security === 'tls') { + const tls = stream.tlsSettings as { sni?: string; serverName?: string } | undefined; + serverName = tls?.sni ?? tls?.serverName ?? ''; + } else if (security === 'reality') { + const reality = stream.realitySettings as { serverNames?: string[]; serverName?: string } | undefined; + if (Array.isArray(reality?.serverNames)) { + serverName = reality.serverNames.join(', '); + } else if (reality?.serverName) { + serverName = reality.serverName; + } + } + return { + protocol: dbInbound.protocol, + clients, + settings, + isTcp: network === 'tcp', + isWs: network === 'ws', + isHttpupgrade: network === 'httpupgrade', + isXHTTP: network === 'xhttp', + isGrpc: network === 'grpc', + isSSMultiUser: isSSMultiUserHelper({ + protocol: dbInbound.protocol, + settings: settings as { method?: string }, + }), + isSS2022: isSS2022Helper({ + protocol: dbInbound.protocol, + settings: settings as { method?: string }, + }), + isVlessTlsFlow: canEnableTlsFlow({ + protocol: dbInbound.protocol, + streamSettings: { network, security }, + }), + host: readNetworkHost(stream, network), + path: readNetworkPath(stream, network), + serviceName: grpcSettings?.serviceName ?? '', + serverName, + stream: { + network, + security, + xhttp: xhttpSettings ? { mode: xhttpSettings.mode } : undefined, + grpc: grpcSettings ? { multiMode: grpcSettings.multiMode } : undefined, + }, + }; +} + +export function copyText(value: unknown, t: (k: string) => string) { + ClipboardManager.copyText(String(value ?? '')).then((ok) => { + if (ok) getMessage().success(t('copied')); + }); +} + +export function downloadText(content: string, filename: string) { + FileManager.downloadTextFile(content, filename); +} + +export function statsColor(stats: ClientStats, trafficDiff: number) { + return ColorUtils.usageColor(stats.up + stats.down, trafficDiff, stats.total); +} + +export function formatIpInfo(record: unknown) { + if (record == null) return ''; + if (typeof record === 'string' || typeof record === 'number') return String(record); + const r = record as { ip?: string; IP?: string; timestamp?: number | string; Timestamp?: number | string }; + const ip = r.ip || r.IP || ''; + const ts = r.timestamp || r.Timestamp || 0; + if (!ip) return String(record); + if (!ts) return String(ip); + const date = new Date(Number(ts) * 1000); + const timeStr = date + .toLocaleString('en-GB', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, + }) + .replace(',', ''); + return `${ip} (${timeStr})`; +} diff --git a/frontend/src/pages/inbounds/info/types.ts b/frontend/src/pages/inbounds/info/types.ts new file mode 100644 index 00000000..60f08810 --- /dev/null +++ b/frontend/src/pages/inbounds/info/types.ts @@ -0,0 +1,87 @@ +import type { SubSettings } from '../useInbounds'; + +export interface ClientStats { + email: string; + up: number; + down: number; + total: number; + expiryTime: number; + enable?: boolean; +} + +export interface ClientSetting { + email?: string; + id?: string; + security?: string; + password?: string; + flow?: string; + subId?: string; + totalGB?: number; + expiryTime?: number; + comment?: string; + tgId?: string; + enable?: boolean; + limitIp?: number; + created_at?: number; + updated_at?: number; +} + +export interface InboundInfo { + protocol: string; + clients: ClientSetting[]; + settings: Record; + isTcp: boolean; + isWs: boolean; + isHttpupgrade: boolean; + isXHTTP: boolean; + isGrpc: boolean; + isSSMultiUser: boolean; + isSS2022: boolean; + isVlessTlsFlow: boolean; + host: string | null; + path: string | null; + serviceName: string; + serverName: string; + stream: { + network: string; + security: string; + xhttp?: { mode?: string }; + grpc?: { multiMode?: boolean }; + }; +} + +export interface DBInboundLike { + id: number; + address: string; + port: number; + listen: string; + protocol: string; + remark: string; + enable?: boolean; + isVMess?: boolean; + isVLess?: boolean; + isTrojan?: boolean; + isSS?: boolean; + isMixed?: boolean; + isHTTP?: boolean; + isWireguard?: boolean; + settings: unknown; + streamSettings: unknown; + sniffing: unknown; + clientStats?: ClientStats[]; +} + +export interface InboundInfoModalProps { + open: boolean; + onClose: () => void; + dbInbound: DBInboundLike | null; + clientIndex?: number; + remarkModel?: string; + expireDiff?: number; + trafficDiff?: number; + ipLimitEnable?: boolean; + tgBotEnable?: boolean; + nodeAddress?: string; + subSettings?: SubSettings; + lastOnlineMap?: Record; +}