feat(clients): add inbound filter + mobile page-size control
Some checks are pending
CI / go-test (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run

Filter bar gets an Inbound select next to Protocol — the dropdown is
narrowed to inbounds matching the chosen protocol (or shows everything
when no protocol is picked), with remark search inside the dropdown.
Choosing a protocol clears any inbound selection that no longer fits.

Server side, ClientPageParams gains an Inbound int and ListPaged runs a
clientMatchesInbound check after the protocol filter. The selection
persists in clientsFilterState localStorage alongside the existing
search/filter/protocol entries.

Mobile clients view also grows the AntD Pagination control that was
previously only on the desktop table, so page size / page navigation
are reachable from phones.
This commit is contained in:
MHSanaei 2026-05-23 23:31:41 +02:00
parent 6185db586a
commit 867a145979
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
4 changed files with 85 additions and 6 deletions

View file

@ -60,6 +60,7 @@ export interface ClientQueryParams {
search?: string; search?: string;
filter?: string; filter?: string;
protocol?: string; protocol?: string;
inbound?: number;
sort?: string; sort?: string;
order?: 'ascend' | 'descend'; order?: 'ascend' | 'descend';
} }
@ -107,6 +108,7 @@ export function useClients() {
&& (prev.search ?? '') === (next.search ?? '') && (prev.search ?? '') === (next.search ?? '')
&& (prev.filter ?? '') === (next.filter ?? '') && (prev.filter ?? '') === (next.filter ?? '')
&& (prev.protocol ?? '') === (next.protocol ?? '') && (prev.protocol ?? '') === (next.protocol ?? '')
&& (prev.inbound ?? 0) === (next.inbound ?? 0)
&& (prev.sort ?? '') === (next.sort ?? '') && (prev.sort ?? '') === (next.sort ?? '')
&& (prev.order ?? '') === (next.order ?? '') && (prev.order ?? '') === (next.order ?? '')
) return prev; ) return prev;
@ -136,6 +138,7 @@ export function useClients() {
if (p.search) sp.set('search', p.search); if (p.search) sp.set('search', p.search);
if (p.filter) sp.set('filter', p.filter); if (p.filter) sp.set('filter', p.filter);
if (p.protocol) sp.set('protocol', p.protocol); if (p.protocol) sp.set('protocol', p.protocol);
if (p.inbound && p.inbound > 0) sp.set('inbound', String(p.inbound));
if (p.sort) sp.set('sort', p.sort); if (p.sort) sp.set('sort', p.sort);
if (p.order) sp.set('order', p.order); if (p.order) sp.set('order', p.order);
return sp.toString(); return sp.toString();

View file

@ -145,6 +145,18 @@
padding: 4px 4px 8px; padding: 4px 4px 8px;
} }
.card-pagination {
display: flex;
justify-content: center;
flex-wrap: wrap;
padding: 4px 0 8px;
}
.card-pagination .ant-pagination-options-size-changer,
.card-pagination .ant-pagination-options-size-changer .ant-select-selector {
min-width: 88px !important;
}
.bulk-count { .bulk-count {
font-size: 12px; font-size: 12px;
background: rgba(22, 119, 255, 0.12); background: rgba(22, 119, 255, 0.12);

View file

@ -11,6 +11,7 @@ import {
Input, Input,
Layout, Layout,
Modal, Modal,
Pagination,
Popover, Popover,
Radio, Radio,
Row, Row,
@ -71,19 +72,22 @@ interface FilterState {
searchKey: string; searchKey: string;
filterBy: string; filterBy: string;
protocolFilter?: string; protocolFilter?: string;
inboundFilter?: number;
} }
function readFilterState(): FilterState { function readFilterState(): FilterState {
try { try {
const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}'); const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
const inb = typeof raw.inboundFilter === 'number' && raw.inboundFilter > 0 ? raw.inboundFilter : undefined;
return { return {
enableFilter: !!raw.enableFilter, enableFilter: !!raw.enableFilter,
searchKey: raw.searchKey || '', searchKey: raw.searchKey || '',
filterBy: raw.filterBy || '', filterBy: raw.filterBy || '',
protocolFilter: raw.protocolFilter, protocolFilter: raw.protocolFilter,
inboundFilter: inb,
}; };
} catch { } catch {
return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined }; return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined, inboundFilter: undefined };
} }
} }
@ -132,6 +136,7 @@ export default function ClientsPage() {
const [searchKey, setSearchKey] = useState(initial.searchKey); const [searchKey, setSearchKey] = useState(initial.searchKey);
const [filterBy, setFilterBy] = useState(initial.filterBy); const [filterBy, setFilterBy] = useState(initial.filterBy);
const [protocolFilter, setProtocolFilter] = useState<string | undefined>(initial.protocolFilter); const [protocolFilter, setProtocolFilter] = useState<string | undefined>(initial.protocolFilter);
const [inboundFilter, setInboundFilter] = useState<number | undefined>(initial.inboundFilter);
const [sortColumn, setSortColumn] = useState<string | null>(null); const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null); const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null);
@ -143,9 +148,9 @@ export default function ClientsPage() {
useEffect(() => { useEffect(() => {
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
enableFilter, searchKey, filterBy, protocolFilter, enableFilter, searchKey, filterBy, protocolFilter, inboundFilter,
})); }));
}, [enableFilter, searchKey, filterBy, protocolFilter]); }, [enableFilter, searchKey, filterBy, protocolFilter, inboundFilter]);
useEffect(() => { useEffect(() => {
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300); const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
@ -156,7 +161,7 @@ export default function ClientsPage() {
// Reset to page 1 whenever a filter or sort changes — otherwise an empty // Reset to page 1 whenever a filter or sort changes — otherwise an empty
// result set on a high page number leaves the user staring at "no clients". // result set on a high page number leaves the user staring at "no clients".
setCurrentPage(1); setCurrentPage(1);
}, [debouncedSearch, enableFilter, filterBy, protocolFilter, sortColumn, sortOrder]); }, [debouncedSearch, enableFilter, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
useEffect(() => { useEffect(() => {
setQuery({ setQuery({
@ -165,10 +170,11 @@ export default function ClientsPage() {
search: enableFilter ? '' : debouncedSearch, search: enableFilter ? '' : debouncedSearch,
filter: enableFilter ? (filterBy || '') : '', filter: enableFilter ? (filterBy || '') : '',
protocol: protocolFilter || '', protocol: protocolFilter || '',
inbound: inboundFilter,
sort: sortColumn || undefined, sort: sortColumn || undefined,
order: sortOrder || undefined, order: sortOrder || undefined,
}); });
}, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, sortColumn, sortOrder]); }, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
useEffect(() => { useEffect(() => {
if (pageSize > 0) { if (pageSize > 0) {
@ -732,13 +738,37 @@ export default function ClientsPage() {
)} )}
<Select <Select
value={protocolFilter} value={protocolFilter}
onChange={(v) => setProtocolFilter(v)} onChange={(v) => {
setProtocolFilter(v);
if (v && inboundFilter) {
const ib = inbounds.find((x) => x.id === inboundFilter);
if (!ib || ib.protocol !== v) setInboundFilter(undefined);
}
}}
allowClear allowClear
placeholder={t('pages.inbounds.protocol')} placeholder={t('pages.inbounds.protocol')}
size={isMobile ? 'small' : 'middle'} size={isMobile ? 'small' : 'middle'}
style={{ width: 150 }} style={{ width: 150 }}
options={protocolOptions.map((p) => ({ value: p, label: p }))} options={protocolOptions.map((p) => ({ value: p, label: p }))}
/> />
<Select
value={inboundFilter}
onChange={(v) => setInboundFilter(v)}
allowClear
showSearch
optionFilterProp="label"
placeholder={t('inbounds')}
size={isMobile ? 'small' : 'middle'}
style={{ minWidth: 160, maxWidth: 240 }}
options={inbounds
.filter((ib) => !protocolFilter || ib.protocol === protocolFilter)
.map((ib) => ({
value: ib.id,
label: ib.remark
? `${ib.remark} (${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''})`
: `#${ib.id} ${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''}`,
}))}
/>
</div> </div>
{!isMobile ? ( {!isMobile ? (
@ -784,6 +814,24 @@ export default function ClientsPage() {
<div>{t('pages.clients.empty')}</div> <div>{t('pages.clients.empty')}</div>
</div> </div>
)} )}
{filteredClients.length > 0 && (
<div className="card-pagination">
<Pagination
current={currentPage}
pageSize={tablePageSize}
total={filtered}
showSizeChanger={filtered > 10}
pageSizeOptions={['10', '25', '50', '100', '200']}
hideOnSinglePage={filtered <= tablePageSize}
size="small"
showTotal={(n) => `${n}`}
onChange={(p, s) => {
setCurrentPage(p);
if (s && s !== tablePageSize) setTablePageSize(s);
}}
/>
</div>
)}
{filteredClients.map((row) => { {filteredClients.map((row) => {
const bucket = clientBucket(row); const bucket = clientBucket(row);
return ( return (

View file

@ -828,6 +828,7 @@ type ClientPageParams struct {
Search string `form:"search"` Search string `form:"search"`
Filter string `form:"filter"` Filter string `form:"filter"`
Protocol string `form:"protocol"` Protocol string `form:"protocol"`
Inbound int `form:"inbound"`
Sort string `form:"sort"` Sort string `form:"sort"`
Order string `form:"order"` Order string `form:"order"`
} }
@ -928,6 +929,9 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
if params.Protocol != "" && !clientMatchesProtocol(c, params.Protocol, protocolByInbound) { if params.Protocol != "" && !clientMatchesProtocol(c, params.Protocol, protocolByInbound) {
continue continue
} }
if params.Inbound > 0 && !clientMatchesInbound(c, params.Inbound) {
continue
}
if params.Filter != "" && !clientMatchesBucket(c, params.Filter, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) { if params.Filter != "" && !clientMatchesBucket(c, params.Filter, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
continue continue
} }
@ -1046,6 +1050,18 @@ func clientMatchesProtocol(c ClientWithAttachments, protocol string, byInbound m
return false return false
} }
func clientMatchesInbound(c ClientWithAttachments, inboundId int) bool {
if inboundId <= 0 {
return true
}
for _, id := range c.InboundIds {
if id == inboundId {
return true
}
}
return false
}
func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool { func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
if bucket == "" { if bucket == "" {
return true return true