mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-12 14:21:01 +00:00
feat: filter inbounds and clients by node (#4997)
Multi-node panels had no way to narrow the inbounds or clients lists to a single node. Add a node filter to both pages: - Inbounds: a toolbar select (All / Local / each node) that filters the list client-side; shown only when the panel has nodes or node-attached inbounds. - Clients: a Nodes multi-select in the filter drawer. Node selections are mapped onto inbound IDs client-side and fed through the existing inbound CSV paging parameter, so the paging backend is untouched; an impossible id (-1) is sent when no inbound matches so the filter yields an honest empty result. InboundOption now carries nodeId to make the mapping possible. The local panel is selectable via a 0 sentinel (inbounds without a nodeId). New i18n keys in all 13 locales.
This commit is contained in:
@@ -315,6 +315,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
},
|
||||
"InboundOption": {
|
||||
"id": 1,
|
||||
"nodeId": null,
|
||||
"port": 443,
|
||||
"protocol": "vless",
|
||||
"remark": "VLESS-443",
|
||||
|
||||
@@ -1433,6 +1433,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"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"
|
||||
|
||||
@@ -319,6 +319,7 @@ export interface InboundFallback {
|
||||
|
||||
export interface InboundOption {
|
||||
id: number;
|
||||
nodeId?: number | null;
|
||||
port: number;
|
||||
protocol: string;
|
||||
remark: string;
|
||||
|
||||
@@ -343,6 +343,7 @@ export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
|
||||
|
||||
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(),
|
||||
|
||||
@@ -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<string | null>(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}
|
||||
/>
|
||||
</LazyMount>
|
||||
</Layout>
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{nodes.length > 0 && (
|
||||
<Form.Item label={t('pages.clients.filters.nodes')}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={filters.nodeIds}
|
||||
onChange={(v) => patch('nodeIds', v as number[])}
|
||||
options={nodeOptions}
|
||||
placeholder={t('pages.clients.filters.nodes')}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
listHeight={220}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label={t('pages.clients.group')}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
|
||||
@@ -2,6 +2,9 @@ export interface ClientFilters {
|
||||
buckets: string[];
|
||||
protocols: string[];
|
||||
inboundIds: number[];
|
||||
// Node ids to filter by; 0 is the "local panel" sentinel (inbounds with
|
||||
// no nodeId). Mapped onto inbound ids client-side — see ClientsPage.
|
||||
nodeIds: number[];
|
||||
groups: string[];
|
||||
expiryFrom?: number;
|
||||
expiryTo?: number;
|
||||
@@ -17,6 +20,7 @@ export function emptyFilters(): ClientFilters {
|
||||
buckets: [],
|
||||
protocols: [],
|
||||
inboundIds: [],
|
||||
nodeIds: [],
|
||||
groups: [],
|
||||
autoRenew: '',
|
||||
hasTgId: '',
|
||||
@@ -29,6 +33,7 @@ export function activeFilterCount(f: ClientFilters): number {
|
||||
if (f.buckets.length) n++;
|
||||
if (f.protocols.length) n++;
|
||||
if (f.inboundIds.length) n++;
|
||||
if (f.nodeIds.length) n++;
|
||||
if (f.groups.length) n++;
|
||||
if (f.expiryFrom || f.expiryTo) n++;
|
||||
if (f.usageFromGB || f.usageToGB) n++;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Card,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
@@ -50,6 +51,29 @@ export default function InboundList({
|
||||
const { t } = useTranslation();
|
||||
const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
// 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<number | 'all'>('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')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
{showNodeFilter && (
|
||||
<Select
|
||||
value={nodeFilter}
|
||||
onChange={(v) => setNodeFilter(v)}
|
||||
options={nodeFilterOptions}
|
||||
popupMatchSelectWidth={false}
|
||||
style={{ minWidth: isMobile ? 90 : 140 }}
|
||||
/>
|
||||
)}
|
||||
{selectedRowKeys.length > 0 && (
|
||||
<>
|
||||
<Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
|
||||
@@ -147,7 +180,7 @@ export default function InboundList({
|
||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||
{isMobile ? (
|
||||
<div className="inbound-cards">
|
||||
{dbInbounds.length === 0 ? (
|
||||
{visibleInbounds.length === 0 ? (
|
||||
<div className="card-empty">
|
||||
<ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
|
||||
<div>{t('noData')}</div>
|
||||
@@ -166,7 +199,7 @@ export default function InboundList({
|
||||
<span className="bulk-count">{selectedRowKeys.length}</span>
|
||||
)}
|
||||
</div>
|
||||
{dbInbounds.map((record) => (
|
||||
{visibleInbounds.map((record) => (
|
||||
<div key={record.id} className={`inbound-card${selectedRowKeys.includes(record.id) ? ' is-selected' : ''}`}>
|
||||
<div className="card-head">
|
||||
<Checkbox
|
||||
@@ -204,13 +237,13 @@ export default function InboundList({
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dbInbounds}
|
||||
dataSource={visibleInbounds}
|
||||
rowKey={(r) => r.id}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
|
||||
}}
|
||||
pagination={paginationFor(dbInbounds)}
|
||||
pagination={paginationFor(visibleInbounds)}
|
||||
scroll={{ x: 1000 }}
|
||||
style={{ marginTop: 10 }}
|
||||
size="small"
|
||||
|
||||
@@ -44,6 +44,8 @@ export const InboundOptionSchema = z.object({
|
||||
port: z.number().optional(),
|
||||
tlsFlowCapable: z.boolean().optional(),
|
||||
ssMethod: z.string().optional(),
|
||||
// Hosting node id; absent/null for this panel's own inbounds (#4997).
|
||||
nodeId: z.number().nullable().optional(),
|
||||
}).loose();
|
||||
|
||||
export const InboundOptionsSchema = z.array(InboundOptionSchema);
|
||||
|
||||
Reference in New Issue
Block a user