diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index d87b5265e..af5e24ffd 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1459,6 +1459,11 @@ "example": 1, "type": "integer" }, + "nodeId": { + "description": "Hosting node; nil for this panel's own inbounds. Lets the clients\npage map a node filter onto inbound IDs (#4997).", + "nullable": true, + "type": "integer" + }, "port": { "example": 443, "type": "integer" @@ -2244,6 +2249,7 @@ "obj": [ { "id": 1, + "nodeId": null, "port": 443, "protocol": "vless", "remark": "VLESS-443", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 3f4ea7e32..77ba68441 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -315,6 +315,7 @@ export const EXAMPLES: Record = { }, "InboundOption": { "id": 1, + "nodeId": null, "port": 443, "protocol": "vless", "remark": "VLESS-443", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 4a8b6d186..ba3f23efd 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1433,6 +1433,11 @@ export const SCHEMAS: Record = { "example": 1, "type": "integer" }, + "nodeId": { + "description": "Hosting node; nil for this panel's own inbounds. Lets the clients\npage map a node filter onto inbound IDs (#4997).", + "nullable": true, + "type": "integer" + }, "port": { "example": 443, "type": "integer" diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 725b99120..0f7b6c0ef 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -319,6 +319,7 @@ export interface InboundFallback { export interface InboundOption { id: number; + nodeId?: number | null; port: number; protocol: string; remark: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 3db667349..d0a89e63a 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -343,6 +343,7 @@ export type InboundFallback = z.infer; export const InboundOptionSchema = z.object({ id: z.number().int(), + nodeId: z.number().int().nullable().optional(), port: z.number().int(), protocol: z.string(), remark: z.string(), diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 478524a83..ecc8db07f 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -51,6 +51,7 @@ import { formatInboundLabel } from '@/lib/inbounds/label'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useWebSocket } from '@/hooks/useWebSocket'; import { useClients } from '@/hooks/useClients'; +import { useNodesQuery } from '@/api/queries/useNodesQuery'; import { useDatepicker } from '@/hooks/useDatepicker'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import ClientTrafficCell from '@/components/clients/ClientTrafficCell'; @@ -148,6 +149,7 @@ function readFilterState(): PersistedFilterState { buckets: Array.isArray(fromRaw.buckets) ? fromRaw.buckets : [], protocols: Array.isArray(fromRaw.protocols) ? fromRaw.protocols : [], inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [], + nodeIds: Array.isArray(fromRaw.nodeIds) ? fromRaw.nodeIds : [], groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [], }, sort: typeof raw.sort === 'string' ? raw.sort : '', @@ -209,6 +211,10 @@ export default function ClientsPage() { client_stats: applyClientStatsEvent, }); + // Node list for the Nodes filter; the section only renders when the panel + // actually manages nodes (#4997). + const { nodes } = useNodesQuery(); + const [togglingEmail, setTogglingEmail] = useState(null); const [formOpen, setFormOpen] = useState(false); const [formMode, setFormMode] = useState<'add' | 'edit'>('add'); @@ -255,6 +261,23 @@ export default function ClientsPage() { setCurrentPage(1); }, [debouncedSearch, filters, sortColumn, sortOrder]); + // The node filter maps onto inbound ids client-side (#4997): the paging API + // already accepts an inbound CSV, so nodes never have to reach the backend. + // Sentinel 0 = "local panel" (inbounds without a nodeId). + const effectiveInboundCsv = useMemo(() => { + if (!filters.nodeIds.length) return filters.inboundIds.join(','); + const nodeSet = new Set(filters.nodeIds); + const nodeInboundIds = inbounds + .filter((ib) => nodeSet.has(ib.nodeId ?? 0)) + .map((ib) => ib.id); + const pool = filters.inboundIds.length + ? nodeInboundIds.filter((id) => filters.inboundIds.includes(id)) + : nodeInboundIds; + // Nothing matches the selected nodes: send an impossible id so the filter + // yields an honest empty result instead of being silently ignored. + return pool.length ? pool.join(',') : '-1'; + }, [filters.nodeIds, filters.inboundIds, inbounds]); + useEffect(() => { setQuery({ page: currentPage, @@ -262,7 +285,7 @@ export default function ClientsPage() { search: debouncedSearch, filter: filters.buckets.join(','), protocol: filters.protocols.join(','), - inbound: filters.inboundIds.join(','), + inbound: effectiveInboundCsv, expiryFrom: filters.expiryFrom, expiryTo: filters.expiryTo, usageFrom: gbToBytes(filters.usageFromGB), @@ -274,7 +297,7 @@ export default function ClientsPage() { sort: sortColumn || undefined, order: sortOrder || undefined, }); - }, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, sortColumn, sortOrder]); + }, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]); const activeCount = activeFilterCount(filters); @@ -1333,6 +1356,7 @@ export default function ClientsPage() { inbounds={inbounds} protocols={protocolOptions} groups={groupOptions} + nodes={nodes} /> diff --git a/frontend/src/pages/clients/FilterDrawer.tsx b/frontend/src/pages/clients/FilterDrawer.tsx index dfc34043e..851e30c3e 100644 --- a/frontend/src/pages/clients/FilterDrawer.tsx +++ b/frontend/src/pages/clients/FilterDrawer.tsx @@ -18,6 +18,7 @@ import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; import type { InboundOption } from '@/hooks/useClients'; +import type { NodeRecord } from '@/schemas/node'; import { formatInboundLabel } from '@/lib/inbounds/label'; import { emptyFilters, type ClientFilters } from './filters'; @@ -29,6 +30,7 @@ interface FilterDrawerProps { inbounds: InboundOption[]; protocols: string[]; groups: string[]; + nodes: NodeRecord[]; } const BUCKET_KEYS = ['active', 'expiring', 'depleted', 'deactive', 'online'] as const; @@ -41,6 +43,7 @@ export default function FilterDrawer({ inbounds, protocols, groups, + nodes, }: FilterDrawerProps) { const { t } = useTranslation(); @@ -66,6 +69,16 @@ export default function FilterDrawer({ [groups], ); + // 0 is the "local panel" sentinel (inbounds without a nodeId) — see + // ClientFilters.nodeIds (#4997). + const nodeOptions = useMemo( + () => [ + { value: 0, label: t('pages.clients.filters.localPanel') }, + ...nodes.map((n) => ({ value: n.id, label: n.name || `#${n.id}` })), + ], + [nodes, t], + ); + const dateRange: [Dayjs | null, Dayjs | null] = [ filters.expiryFrom ? dayjs(filters.expiryFrom) : null, filters.expiryTo ? dayjs(filters.expiryTo) : null, @@ -132,6 +145,23 @@ export default function FilterDrawer({ /> + {nodes.length > 0 && ( + + (null); const [selectedRowKeys, setSelectedRowKeys] = useState([]); + // Node filter (#4997): 'all' shows everything, 0 is the local-panel + // sentinel (inbounds without a nodeId), otherwise a node id. Session-only. + const [nodeFilter, setNodeFilter] = useState('all'); + + const showNodeFilter = useMemo( + () => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null), + [nodesById, dbInbounds], + ); + + const nodeFilterOptions = useMemo( + () => [ + { value: 'all' as const, label: t('pages.clients.filters.nodes') }, + { value: 0, label: t('pages.clients.filters.localPanel') }, + ...Array.from(nodesById.values()).map((n) => ({ value: n.id, label: n.name || `#${n.id}` })), + ], + [nodesById, t], + ); + + const visibleInbounds = useMemo(() => { + if (nodeFilter === 'all') return dbInbounds; + if (nodeFilter === 0) return dbInbounds.filter((ib) => ib.nodeId == null); + return dbInbounds.filter((ib) => ib.nodeId === nodeFilter); + }, [dbInbounds, nodeFilter]); const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => { const previous = dbInbound.enable; @@ -78,11 +102,11 @@ export default function InboundList({ }, []); const selectAll = useCallback((checked: boolean) => { - setSelectedRowKeys(checked ? dbInbounds.map((i) => i.id) : []); - }, [dbInbounds]); + setSelectedRowKeys(checked ? visibleInbounds.map((i) => i.id) : []); + }, [visibleInbounds]); - const allSelected = dbInbounds.length > 0 && selectedRowKeys.length === dbInbounds.length; - const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < dbInbounds.length; + const allSelected = visibleInbounds.length > 0 && selectedRowKeys.length === visibleInbounds.length; + const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < visibleInbounds.length; const handleBulkDelete = useCallback(async () => { const ok = await onBulkDelete(selectedRowKeys); @@ -131,6 +155,15 @@ export default function InboundList({ {!isMobile && t('pages.inbounds.generalActions')} + {showNodeFilter && ( +