浏览代码

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.
MHSanaei 3 天之前
父节点
当前提交
253063b785

+ 6 - 0
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",

+ 1 - 0
frontend/src/generated/examples.ts

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

+ 5 - 0
frontend/src/generated/schemas.ts

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

+ 1 - 0
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;

+ 1 - 0
frontend/src/generated/zod.ts

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

+ 26 - 2
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<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>

+ 30 - 0
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({
           />
         </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"

+ 5 - 0
frontend/src/pages/clients/filters.ts

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

+ 41 - 8
frontend/src/pages/inbounds/list/InboundList.tsx

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

+ 2 - 0
frontend/src/schemas/client.ts

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

+ 6 - 1
internal/web/service/inbound.go

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

+ 4 - 0
internal/web/translation/ar-EG.json

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

+ 4 - 0
internal/web/translation/en-US.json

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

+ 4 - 0
internal/web/translation/es-ES.json

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

+ 4 - 0
internal/web/translation/fa-IR.json

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

+ 4 - 0
internal/web/translation/id-ID.json

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

+ 4 - 0
internal/web/translation/ja-JP.json

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

+ 4 - 0
internal/web/translation/pt-BR.json

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

+ 4 - 0
internal/web/translation/ru-RU.json

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

+ 4 - 0
internal/web/translation/tr-TR.json

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

+ 4 - 0
internal/web/translation/uk-UA.json

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

+ 4 - 0
internal/web/translation/vi-VN.json

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

+ 4 - 0
internal/web/translation/zh-CN.json

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

+ 4 - 0
internal/web/translation/zh-TW.json

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