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:
MHSanaei
2026-06-12 09:33:35 +02:00
parent d04cb10971
commit 253063b785
24 changed files with 176 additions and 11 deletions

View File

@ -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",

View File

@ -315,6 +315,7 @@ export const EXAMPLES: Record<string, unknown> = {
},
"InboundOption": {
"id": 1,
"nodeId": null,
"port": 443,
"protocol": "vless",
"remark": "VLESS-443",

View File

@ -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"

View File

@ -319,6 +319,7 @@ export interface InboundFallback {
export interface InboundOption {
id: number;
nodeId?: number | null;
port: number;
protocol: string;
remark: string;

View File

@ -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(),

View File

@ -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>

View File

@ -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"

View File

@ -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++;

View File

@ -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"

View File

@ -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);

View File

@ -295,6 +295,9 @@ type InboundOption struct {
Port int `json:"port" example:"443"`
TlsFlowCapable bool `json:"tlsFlowCapable" example:"true"`
SsMethod string `json:"ssMethod"`
// Hosting node; nil for this panel's own inbounds. Lets the clients
// page map a node filter onto inbound IDs (#4997).
NodeId *int `json:"nodeId,omitempty"`
}
func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) {
@ -307,9 +310,10 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
Port int `gorm:"column:port"`
StreamSettings string `gorm:"column:stream_settings"`
Settings string `gorm:"column:settings"`
NodeId *int `gorm:"column:node_id"`
}
err := db.Table("inbounds").
Select("id, remark, tag, protocol, port, stream_settings, settings").
Select("id, remark, tag, protocol, port, stream_settings, settings, node_id").
Where("user_id = ?", userId).
Order("id ASC").
Scan(&rows).Error
@ -326,6 +330,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
Port: r.Port,
TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings),
NodeId: r.NodeId,
})
}
return out, nil

View File

@ -673,6 +673,10 @@
"searchPlaceholder": "ابحث بالبريد، التعليق، sub ID، UUID، كلمة المرور، auth…",
"filterTitle": "تصفية العملاء",
"clearAllFilters": "مسح الكل",
"filters": {
"nodes": "النودز",
"localPanel": "محلي (هذه اللوحة)"
},
"showingCount": "عرض {shown} من {total}",
"sortOldest": "الأقدم أولاً",
"sortNewest": "الأحدث أولاً",

View File

@ -674,6 +674,10 @@
"searchPlaceholder": "Search email, comment, sub ID, UUID, password, auth…",
"filterTitle": "Filter clients",
"clearAllFilters": "Clear all",
"filters": {
"nodes": "Nodes",
"localPanel": "Local (this panel)"
},
"showingCount": "Showing {shown} of {total}",
"sortOldest": "Oldest first",
"sortNewest": "Newest first",

View File

@ -673,6 +673,10 @@
"searchPlaceholder": "Buscar email, comentario, sub ID, UUID, contraseña, auth…",
"filterTitle": "Filtrar clientes",
"clearAllFilters": "Limpiar todo",
"filters": {
"nodes": "Nodos",
"localPanel": "Local (este panel)"
},
"showingCount": "Mostrando {shown} de {total}",
"sortOldest": "Más antiguos",
"sortNewest": "Más recientes",

View File

@ -673,6 +673,10 @@
"searchPlaceholder": "جستجوی ایمیل، توضیح، Sub ID، UUID، رمز، احراز...",
"filterTitle": "فیلتر کاربران",
"clearAllFilters": "پاک کردن همه",
"filters": {
"nodes": "نودها",
"localPanel": "محلی (همین پنل)"
},
"showingCount": "نمایش {shown} از {total}",
"sortOldest": "قدیمی‌ترین",
"sortNewest": "جدیدترین",

View File

@ -673,6 +673,10 @@
"searchPlaceholder": "Cari email, komentar, sub ID, UUID, kata sandi, auth…",
"filterTitle": "Filter klien",
"clearAllFilters": "Hapus semua",
"filters": {
"nodes": "Node",
"localPanel": "Lokal (panel ini)"
},
"showingCount": "Menampilkan {shown} dari {total}",
"sortOldest": "Terlama dulu",
"sortNewest": "Terbaru dulu",

View File

@ -673,6 +673,10 @@
"searchPlaceholder": "メール、コメント、sub ID、UUID、パスワード、auth を検索…",
"filterTitle": "クライアントをフィルタ",
"clearAllFilters": "すべてクリア",
"filters": {
"nodes": "ノード",
"localPanel": "ローカル(このパネル)"
},
"showingCount": "{total} 件中 {shown} 件を表示",
"sortOldest": "古い順",
"sortNewest": "新しい順",

View File

@ -673,6 +673,10 @@
"searchPlaceholder": "Buscar email, comentário, sub ID, UUID, senha, auth…",
"filterTitle": "Filtrar clientes",
"clearAllFilters": "Limpar tudo",
"filters": {
"nodes": "Nós",
"localPanel": "Local (este painel)"
},
"showingCount": "Mostrando {shown} de {total}",
"sortOldest": "Mais antigos primeiro",
"sortNewest": "Mais novos primeiro",

View File

@ -673,6 +673,10 @@
"searchPlaceholder": "Поиск email, комментария, sub ID, UUID, пароля, auth…",
"filterTitle": "Фильтр клиентов",
"clearAllFilters": "Очистить все",
"filters": {
"nodes": "Узлы",
"localPanel": "Локально (эта панель)"
},
"showingCount": "Показано {shown} из {total}",
"sortOldest": "Сначала старые",
"sortNewest": "Сначала новые",

View File

@ -674,6 +674,10 @@
"searchPlaceholder": "E-posta, yorum, sub ID, UUID, parola, auth ara…",
"filterTitle": "Kullanıcıları Filtrele",
"clearAllFilters": "Tümünü Temizle",
"filters": {
"nodes": "Düğümler",
"localPanel": "Yerel (bu panel)"
},
"showingCount": "{total} içinden {shown} gösteriliyor",
"sortOldest": "Önce En Eski",
"sortNewest": "Önce En Yeni",

View File

@ -673,6 +673,10 @@
"searchPlaceholder": "Пошук email, коментаря, sub ID, UUID, паролю, auth…",
"filterTitle": "Фільтр клієнтів",
"clearAllFilters": "Очистити все",
"filters": {
"nodes": "Вузли",
"localPanel": "Локально (ця панель)"
},
"showingCount": "Показано {shown} з {total}",
"sortOldest": "Спочатку старі",
"sortNewest": "Спочатку нові",

View File

@ -673,6 +673,10 @@
"searchPlaceholder": "Tìm email, ghi chú, sub ID, UUID, mật khẩu, auth…",
"filterTitle": "Lọc client",
"clearAllFilters": "Xóa tất cả",
"filters": {
"nodes": "Nút",
"localPanel": "Cục bộ (bảng này)"
},
"showingCount": "Hiển thị {shown} trên {total}",
"sortOldest": "Cũ nhất trước",
"sortNewest": "Mới nhất trước",

View File

@ -673,6 +673,10 @@
"searchPlaceholder": "搜索邮箱、备注、sub ID、UUID、密码、auth…",
"filterTitle": "筛选客户端",
"clearAllFilters": "清除全部",
"filters": {
"nodes": "节点",
"localPanel": "本机(此面板)"
},
"showingCount": "显示 {shown} / {total}",
"sortOldest": "最旧优先",
"sortNewest": "最新优先",

View File

@ -673,6 +673,10 @@
"searchPlaceholder": "搜尋電子郵件、備註、sub ID、UUID、密碼、auth…",
"filterTitle": "篩選客戶端",
"clearAllFilters": "清除全部",
"filters": {
"nodes": "節點",
"localPanel": "本機(此面板)"
},
"showingCount": "顯示 {shown} / {total}",
"sortOldest": "最舊優先",
"sortNewest": "最新優先",