import { useMemo, useState } from "react"; import { ChevronDown, Globe, Info, Network, Plus, Server, Settings, Search, Copy, CheckCircle } from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useInboundsFleet, useNodes, useSubHosts, type HostSecurity, type Inbound, type InboundFleetRow, type SubHost, } from "@/api/hooks"; import type { Node } from "@/api/types"; import { api } from "@/api/client"; import { Badge, Button, Input } from "@/components/ui"; import { NodeInboundsModal } from "@/components/NodeInboundsModal"; import { SubHostsModal } from "@/components/SubHostsModal"; import { InboundBulkBar } from "@/components/InboundBulkBar"; import { InboundExpandedPanel } from "@/components/InboundExpandedPanel"; import { GlassCard, ProtocolBadge, StatusBadge } from "@/components/vortexui"; import { StaggerContainer } from "@/components/StaggerContainer"; import { EmptyState } from "@/components/EmptyState"; import { ProtocolGroupsPanel } from "@/components/ProtocolGroupsPanel"; import { useI18n } from "@/i18n/i18n"; import { useTitle } from "@/lib/useTitle"; import { useAuth } from "@/auth/auth"; import { cn } from "@/lib/utils"; function transportLabel(ib: InboundFleetRow): string { // UDP-native protocols always show "UDP" regardless of stored network value const UDP_ALWAYS = ["hysteria2", "tuic", "wireguard", "hysteria"]; if (UDP_ALWAYS.includes(ib.protocol)) return "UDP"; const net = ib.network || "tcp"; return net.toUpperCase(); } function securityColor(s?: string): string { switch (s) { case "reality": return "on_hold"; case "tls": return "active"; case "none": case "": case undefined: return "disabled"; default: return "muted"; } } function securityLabel(s?: string): string { if (!s || s === "none") return "none"; if (s === "inbound_default") return "default"; return s; } function ProtocolBar({ proto, count, total }: { proto: string; count: number; total: number }) { const colors: Record = { vless: "bg-cyan-500", vmess: "bg-blue-500", trojan: "bg-green-500", shadowsocks: "bg-yellow-500", hysteria2: "bg-purple-500", tuic: "bg-pink-500", wireguard: "bg-emerald-500", socks: "bg-orange-500", http: "bg-red-500", naive: "bg-indigo-500", dokodemo: "bg-gray-500", }; const barColor = colors[proto] ?? "bg-primary"; const pct = total > 0 ? (count / total) * 100 : 0; return (
{proto}
{count}
); } export function Inbounds() { useTitle("Inbounds"); const { can } = useAuth(); const canWrite = can("inbound:write"); const fleet = useInboundsFleet(); const nodes = useNodes(); const [nodeFilter, setNodeFilter] = useState(""); const [searchQuery, setSearchQuery] = useState(""); const [managing, setManaging] = useState(null); const [pendingEdit, setPendingEdit] = useState(null); const [subHostsFor, setSubHostsFor] = useState(null); const [expandedId, setExpandedId] = useState(null); const [copiedId, setCopiedId] = useState(null); const [selected, setSelected] = useState>(new Set()); const inbounds = fleet.data?.inbounds ?? []; const nodeList = nodes.data?.nodes ?? []; const filtered = useMemo(() => { let result = inbounds; if (nodeFilter) result = result.filter((ib) => ib.node_name === nodeFilter); if (searchQuery) { const q = searchQuery.toLowerCase(); result = result.filter((ib) => ib.tag?.toLowerCase().includes(q) || ib.node_name.toLowerCase().includes(q) || ib.protocol?.toLowerCase().includes(q) ); } return result; }, [inbounds, nodeFilter, searchQuery]); const nodeNames = useMemo( () => [...new Set(inbounds.map((ib) => ib.node_name))].sort(), [inbounds], ); const stats = useMemo(() => ({ total: inbounds.length, active: inbounds.filter((ib) => ib.enabled !== false).length, nodes: nodeNames.length, protocols: Object.keys(inbounds.reduce((acc, ib) => { acc[ib.protocol || "unknown"] = (acc[ib.protocol || "unknown"] || 0) + 1; return acc; }, {} as Record)).length, byProtocol: inbounds.reduce((acc, ib) => { const proto = ib.protocol || "unknown"; acc[proto] = (acc[proto] || 0) + 1; return acc; }, {} as Record), byNode: inbounds.reduce((acc, ib) => { acc[ib.node_name] = (acc[ib.node_name] || 0) + 1; return acc; }, {} as Record), }), [inbounds, nodeNames]); function openNodeManager(nodeName: string, edit?: Inbound) { const node = nodeList.find((n) => n.name === nodeName); if (node) { setPendingEdit(edit ?? null); setManaging(node); } } function toggleSelect(id: string) { setSelected(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function selectAll() { setSelected(new Set(filtered.map(ib => ib.id))); } function clearSelection() { setSelected(new Set()); } const qc = useQueryClient(); const bulkMutation = useMutation({ mutationFn: (input: { ids: string[]; action: string }) => api<{ affected: number }>("/api/inbounds/bulk", { method: "POST", body: input }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["inbounds-fleet"] }); clearSelection(); }, }); function bulkAction(action: string) { bulkMutation.mutate({ ids: [...selected], action }); } const protocolEntries = useMemo( () => Object.entries(stats.byProtocol).sort((a, b) => b[1] - a[1]), [stats.byProtocol], ); return (
{ setManaging(null); setPendingEdit(null); }} /> setSubHostsFor(null)} /> {/* Header */}

Inbounds & Hosts

{stats.total} INBOUNDS

{stats.total} inbound{stats.total !== 1 ? "s" : ""} across {stats.nodes} node{stats.nodes !== 1 ? "s" : ""}

{canWrite && nodeList.length > 0 && ( )}
{/* Stats cards */}

{stats.total}

Total

{stats.active}

Active

0 ? (stats.active / stats.total) * 100 : 0}%` }} />

{stats.nodes}

Nodes

{stats.protocols}

Protocols

{/* Info box */}

Subscription Hosts

Create alternate domain/SNI combinations for your inbounds. Users can choose which host to use in their subscriptions.

{/* Protocol Groups — shown when a node is filtered */} {nodeFilter && (() => { const filteredNode = nodeList.find((n) => n.name === nodeFilter); return filteredNode ? ( ) : null; })()} {/* Search + Filter */}
setSearchQuery(e.target.value)} className="ps-9" />
{nodeNames.map((name) => ( ))}
{canWrite && filtered.length > 0 && (
0} onChange={(e) => e.target.checked ? selectAll() : clearSelection()} /> Select All ({filtered.length})
)}
{/* Loading */} {fleet.isLoading && (
{Array.from({ length: 3 }).map((_, i) => (
))}
)} {/* Empty */} {!fleet.isLoading && filtered.length === 0 && (
0 ? { label: "Create Inbound", onClick: () => setManaging(nodeList[0]) } : undefined} />
)} {/* Inbound list */} {!fleet.isLoading && filtered.length > 0 && (
{filtered.map((ib) => ( toggleSelect(ib.id)} expanded={expandedId === ib.id} onToggleExpand={() => setExpandedId((cur) => (cur === ib.id ? null : ib.id))} onAddSubHost={() => setSubHostsFor(ib)} onEdit={() => openNodeManager(ib.node_name, ib)} copiedId={copiedId} onCopy={(text, id) => { navigator.clipboard.writeText(text); setCopiedId(id); setTimeout(() => setCopiedId(null), 2000); }} /> ))}
)} {/* Footer */} {!fleet.isLoading && filtered.length > 0 && (
Showing {filtered.length} of {inbounds.length} inbounds
)} {/* Protocol Distribution */} {protocolEntries.length > 0 && (

Protocol Distribution

{protocolEntries.map(([proto, count]) => ( ))}
)} bulkAction("enable")} onDisable={() => bulkAction("disable")} onDelete={() => bulkAction("delete")} onClearSelection={clearSelection} isPending={bulkMutation.isPending} />
); } function InboundRow({ ib, showNode, canWrite, selected, onToggleSelect, expanded, onToggleExpand, onAddSubHost, onEdit, copiedId, onCopy, }: { ib: InboundFleetRow; showNode: boolean; canWrite: boolean; selected: boolean; onToggleSelect: () => void; expanded: boolean; onToggleExpand: () => void; onAddSubHost: () => void; onEdit: () => void; copiedId: string | null; onCopy: (text: string, id: string) => void; }) { const sniList = (ib.sni ?? []).filter(Boolean); return (
{/* Checkbox */} {canWrite && ( e.stopPropagation()} /> )} {/* Icon */}
{/* Info */}
{ib.tag} {transportLabel(ib)} {securityLabel(ib.security)} {(ib.speed_limit ?? 0) > 0 && ( ⚡ {Math.round((ib.speed_limit ?? 0) / 125000)} Mbps )} {ib.health && ib.health !== "" && ( {ib.health} )} {ib.notes && ( 📝 )} {showNode && ( {ib.node_name} )}
:{ib.port}{ib.port_end ? `-${ib.port_end}` : ""} {ib.listen && ib.listen !== "" && ib.listen !== "0.0.0.0" && ( {ib.listen} )} {sniList.length > 0 && ( SNI: {sniList.join(", ")} )}
{/* Actions */}
{/* Copy address */} {/* Sub hosts */} {/* Edit */} {canWrite && ( )} {/* Expand */}
{expanded && ( <> )}
); } function SubHostsSection({ inboundId, onOpen }: { inboundId: string; onOpen: () => void }) { const { t } = useI18n(); const { data, isLoading } = useSubHosts(inboundId); const hosts = data?.hosts ?? []; return (

Subscription Hosts

{isLoading &&

{t("common.loading")}

} {!isLoading && hosts.length === 0 && ( )} {hosts.map((h: SubHost) => ( ))} {hosts.length > 0 && ( )}
); }