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