From 3675f88caf92c06e788af5fba46a2989cb6ceb7a Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 27 May 2026 12:54:06 +0200 Subject: [PATCH] feat(clients): advanced filter drawer with multi-select state/protocol/inbound + expiry/usage ranges + auto-renew/tg/comment The old toolbar exposed a single-value Search box, a single bucket radio, and one Protocol + Inbound dropdown. Real panels with hundreds of clients across mixed protocols need to slice by combinations (active + expiring, two specific inbounds, expiring within a window, high-usage subset, etc.), which the old shape couldn't express. Backend ClientPageParams now accepts comma-separated multi values for Filter / Protocol / Inbound and three new structured fields each: expiry/usage ranges (ms / bytes), and three trinary toggles (AutoRenew / HasTgID / HasComment with on/off, yes/no). The free-text search predicate also picks up UUID / Password / Auth, which were previously invisible to search. Frontend introduces a dedicated FilterDrawer (multi-select for state/protocol/inbound, DatePicker.RangePicker for expiry, paired InputNumbers for usage, radio buttons for the trinary toggles) opened from a single Filter button with a badge for the active count. Active filters render as closable chips above the table so the user can drop them one at a time, with a Clear-all next to the Filter button. The search box stays inline and always visible. --- frontend/src/hooks/useClients.ts | 28 ++- frontend/src/pages/clients/ClientsPage.css | 14 ++ frontend/src/pages/clients/ClientsPage.tsx | 248 ++++++++++++-------- frontend/src/pages/clients/FilterDrawer.tsx | 222 ++++++++++++++++++ frontend/src/pages/clients/filters.ts | 36 +++ web/service/client.go | 207 ++++++++++++++-- web/translation/en-US.json | 9 + 7 files changed, 642 insertions(+), 122 deletions(-) create mode 100644 frontend/src/pages/clients/FilterDrawer.tsx create mode 100644 frontend/src/pages/clients/filters.ts diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index dc6f8989..3e559fca 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -42,11 +42,19 @@ export interface ClientQueryParams { page: number; pageSize: number; search?: string; + // CSV strings — frontend joins arrays on ',', backend splits the same way. filter?: string; protocol?: string; - inbound?: number; + inbound?: string; sort?: string; order?: 'ascend' | 'descend'; + expiryFrom?: number; + expiryTo?: number; + usageFrom?: number; + usageTo?: number; + autoRenew?: 'on' | 'off' | ''; + hasTgId?: 'yes' | 'no' | ''; + hasComment?: 'yes' | 'no' | ''; } const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 }; @@ -61,9 +69,16 @@ function buildQS(p: ClientQueryParams): string { if (p.search) sp.set('search', p.search); if (p.filter) sp.set('filter', p.filter); if (p.protocol) sp.set('protocol', p.protocol); - if (p.inbound && p.inbound > 0) sp.set('inbound', String(p.inbound)); + if (p.inbound) sp.set('inbound', p.inbound); if (p.sort) sp.set('sort', p.sort); if (p.order) sp.set('order', p.order); + if (p.expiryFrom && p.expiryFrom > 0) sp.set('expiryFrom', String(p.expiryFrom)); + if (p.expiryTo && p.expiryTo > 0) sp.set('expiryTo', String(p.expiryTo)); + if (p.usageFrom && p.usageFrom > 0) sp.set('usageFrom', String(p.usageFrom)); + if (p.usageTo && p.usageTo > 0) sp.set('usageTo', String(p.usageTo)); + if (p.autoRenew) sp.set('autoRenew', p.autoRenew); + if (p.hasTgId) sp.set('hasTgId', p.hasTgId); + if (p.hasComment) sp.set('hasComment', p.hasComment); return sp.toString(); } @@ -105,9 +120,16 @@ export function useClients() { && (prev.search ?? '') === (next.search ?? '') && (prev.filter ?? '') === (next.filter ?? '') && (prev.protocol ?? '') === (next.protocol ?? '') - && (prev.inbound ?? 0) === (next.inbound ?? 0) + && (prev.inbound ?? '') === (next.inbound ?? '') && (prev.sort ?? '') === (next.sort ?? '') && (prev.order ?? '') === (next.order ?? '') + && (prev.expiryFrom ?? 0) === (next.expiryFrom ?? 0) + && (prev.expiryTo ?? 0) === (next.expiryTo ?? 0) + && (prev.usageFrom ?? 0) === (next.usageFrom ?? 0) + && (prev.usageTo ?? 0) === (next.usageTo ?? 0) + && (prev.autoRenew ?? '') === (next.autoRenew ?? '') + && (prev.hasTgId ?? '') === (next.hasTgId ?? '') + && (prev.hasComment ?? '') === (next.hasComment ?? '') ) return prev; return next; }); diff --git a/frontend/src/pages/clients/ClientsPage.css b/frontend/src/pages/clients/ClientsPage.css index 8153b279..4560cd10 100644 --- a/frontend/src/pages/clients/ClientsPage.css +++ b/frontend/src/pages/clients/ClientsPage.css @@ -33,6 +33,20 @@ flex: 0 0 auto; } +.filter-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0 0 12px; + padding: 6px 8px; + background: var(--ant-color-fill-quaternary); + border-radius: 8px; +} + +.filter-chips .ant-tag { + margin: 0; +} + .dot { display: inline-block; width: 8px; diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 5ab46558..b9be924a 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -13,9 +13,7 @@ import { Modal, Pagination, Popover, - Radio, Row, - Select, Space, Spin, Statistic, @@ -58,18 +56,18 @@ const ClientInfoModal = lazy(() => import('./ClientInfoModal')); const ClientQrModal = lazy(() => import('./ClientQrModal')); const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal')); const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal')); +const FilterDrawer = lazy(() => import('./FilterDrawer')); +import { emptyFilters, activeFilterCount } from './filters'; +import type { ClientFilters } from './filters'; import './ClientsPage.css'; const FILTER_STATE_KEY = 'clientsFilterState'; type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring'; -interface FilterState { - enableFilter: boolean; +interface PersistedFilterState { searchKey: string; - filterBy: string; - protocolFilter?: string; - inboundFilter?: number; + filters: ClientFilters; } const INBOUND_PROTOCOL_COLORS: Record = { @@ -86,22 +84,30 @@ const INBOUND_PROTOCOL_COLORS: Record = { }; const INBOUND_CHIP_LIMIT = 1; -function readFilterState(): FilterState { +function readFilterState(): PersistedFilterState { try { const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}'); - const inb = typeof raw.inboundFilter === 'number' && raw.inboundFilter > 0 ? raw.inboundFilter : undefined; + const fromRaw = (raw.filters ?? {}) as Partial; return { - enableFilter: !!raw.enableFilter, - searchKey: raw.searchKey || '', - filterBy: raw.filterBy || '', - protocolFilter: raw.protocolFilter, - inboundFilter: inb, + searchKey: typeof raw.searchKey === 'string' ? raw.searchKey : '', + filters: { + ...emptyFilters(), + ...fromRaw, + buckets: Array.isArray(fromRaw.buckets) ? fromRaw.buckets : [], + protocols: Array.isArray(fromRaw.protocols) ? fromRaw.protocols : [], + inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [], + }, }; } catch { - return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined, inboundFilter: undefined }; + return { searchKey: '', filters: emptyFilters() }; } } +function gbToBytes(gb: number | undefined): number { + if (!gb || gb <= 0) return 0; + return Math.round(gb * 1024 * 1024 * 1024); +} + export default function ClientsPage() { const { t } = useTranslation(); const { isDark, isUltra, antdThemeConfig } = useTheme(); @@ -142,11 +148,9 @@ export default function ClientsPage() { const [selectedRowKeys, setSelectedRowKeys] = useState([]); const initial = readFilterState(); - const [enableFilter, setEnableFilter] = useState(initial.enableFilter); const [searchKey, setSearchKey] = useState(initial.searchKey); - const [filterBy, setFilterBy] = useState(initial.filterBy); - const [protocolFilter, setProtocolFilter] = useState(initial.protocolFilter); - const [inboundFilter, setInboundFilter] = useState(initial.inboundFilter); + const [filters, setFilters] = useState(initial.filters); + const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [sortColumn, setSortColumn] = useState(null); const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null); @@ -157,10 +161,8 @@ export default function ClientsPage() { const [debouncedSearch, setDebouncedSearch] = useState(searchKey); useEffect(() => { - localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ - enableFilter, searchKey, filterBy, protocolFilter, inboundFilter, - })); - }, [enableFilter, searchKey, filterBy, protocolFilter, inboundFilter]); + localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters })); + }, [searchKey, filters]); useEffect(() => { const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300); @@ -171,20 +173,29 @@ export default function ClientsPage() { // 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". setCurrentPage(1); - }, [debouncedSearch, enableFilter, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]); + }, [debouncedSearch, filters, sortColumn, sortOrder]); useEffect(() => { setQuery({ page: currentPage, pageSize: tablePageSize, - search: enableFilter ? '' : debouncedSearch, - filter: enableFilter ? (filterBy || '') : '', - protocol: protocolFilter || '', - inbound: inboundFilter, + search: debouncedSearch, + filter: filters.buckets.join(','), + protocol: filters.protocols.join(','), + inbound: filters.inboundIds.join(','), + expiryFrom: filters.expiryFrom, + expiryTo: filters.expiryTo, + usageFrom: gbToBytes(filters.usageFromGB), + usageTo: gbToBytes(filters.usageToGB), + autoRenew: filters.autoRenew || undefined, + hasTgId: filters.hasTgId || undefined, + hasComment: filters.hasComment || undefined, sort: sortColumn || undefined, order: sortOrder || undefined, }); - }, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]); + }, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, sortColumn, sortOrder]); + + const activeCount = activeFilterCount(filters); useEffect(() => { if (pageSize > 0) { @@ -640,10 +651,16 @@ export default function ClientsPage() { const allSelected = filteredClients.length > 0 && selectedRowKeys.length === filteredClients.length; const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length; - function onToggleFilter(checked: boolean) { - setEnableFilter(checked); - if (checked) setSearchKey(''); - else setFilterBy(''); + function clearOneFilter(key: K) { + if (key === 'expiryFrom' || key === 'expiryTo') { + setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined }); + return; + } + if (key === 'usageFromGB' || key === 'usageToGB') { + setFilters({ ...filters, usageFromGB: undefined, usageToGB: undefined }); + return; + } + setFilters({ ...filters, [key]: emptyFilters()[key] }); } return ( @@ -741,72 +758,96 @@ export default function ClientsPage() { } >
- } - unCheckedChildren={} + setSearchKey(e.target.value)} + placeholder={t('pages.clients.searchPlaceholder')} + allowClear + prefix={} + size={isMobile ? 'small' : 'middle'} + style={{ maxWidth: 320 }} /> - {!enableFilter && ( - setSearchKey(e.target.value)} - placeholder={t('search')} - autoFocus - size={isMobile ? 'small' : 'middle'} - style={{ maxWidth: 300 }} - /> - )} - {enableFilter && ( - setFilterBy(e.target.value)} - optionType="button" - buttonStyle="solid" + + + + {activeCount > 0 && ( + )} - 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}` : ''}`, - }))} - />
+ {activeCount > 0 && ( +
+ {filters.buckets.map((b) => ( + setFilters({ ...filters, buckets: filters.buckets.filter((x) => x !== b) })} + > + {bucketChipLabel(b, t)} + + ))} + {filters.protocols.map((p) => ( + setFilters({ ...filters, protocols: filters.protocols.filter((x) => x !== p) })} + > + {p} + + ))} + {filters.inboundIds.map((id) => ( + setFilters({ ...filters, inboundIds: filters.inboundIds.filter((x) => x !== id) })} + > + {inboundLabel(id)} + + ))} + {(filters.expiryFrom || filters.expiryTo) && ( + clearOneFilter('expiryFrom')}> + {t('pages.clients.expiryTime')}: {filters.expiryFrom ? IntlUtil.formatDate(filters.expiryFrom, datepicker) : '…'} + {' → '} + {filters.expiryTo ? IntlUtil.formatDate(filters.expiryTo, datepicker) : '…'} + + )} + {(filters.usageFromGB || filters.usageToGB) && ( + clearOneFilter('usageFromGB')}> + {t('pages.clients.traffic')}: {filters.usageFromGB ?? 0}{filters.usageToGB ? `–${filters.usageToGB}` : '+'} GB + + )} + {filters.autoRenew && ( + clearOneFilter('autoRenew')}> + {t('pages.clients.renew')}: {filters.autoRenew === 'on' ? t('enabled') : t('disabled')} + + )} + {filters.hasTgId && ( + clearOneFilter('hasTgId')}> + {t('pages.clients.telegramId')}: {filters.hasTgId === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')} + + )} + {filters.hasComment && ( + clearOneFilter('hasComment')}> + {t('pages.clients.comment')}: {filters.hasComment === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')} + + )} +
+ )} + {!isMobile ? ( columns={columns} @@ -993,7 +1034,28 @@ export default function ClientsPage() { }} /> + + + ); } + +function bucketChipLabel(b: string, t: (k: string) => string): string { + switch (b) { + case 'active': return t('subscription.active'); + case 'expiring': return t('depletingSoon'); + case 'depleted': return t('depleted'); + case 'deactive': return t('disabled'); + case 'online': return t('online'); + default: return b; + } +} diff --git a/frontend/src/pages/clients/FilterDrawer.tsx b/frontend/src/pages/clients/FilterDrawer.tsx new file mode 100644 index 00000000..4f7cdfed --- /dev/null +++ b/frontend/src/pages/clients/FilterDrawer.tsx @@ -0,0 +1,222 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + Checkbox, + Col, + DatePicker, + Drawer, + Form, + InputNumber, + Radio, + Row, + Select, + Space, + Typography, +} from 'antd'; +import dayjs from 'dayjs'; +import type { Dayjs } from 'dayjs'; + +import type { InboundOption } from '@/hooks/useClients'; +import { emptyFilters, type ClientFilters } from './filters'; + +interface FilterDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + filters: ClientFilters; + onChange: (next: ClientFilters) => void; + inbounds: InboundOption[]; + protocols: string[]; +} + +const BUCKET_KEYS = ['active', 'expiring', 'depleted', 'deactive', 'online'] as const; + +export default function FilterDrawer({ + open, + onOpenChange, + filters, + onChange, + inbounds, + protocols, +}: FilterDrawerProps) { + const { t } = useTranslation(); + + function patch(key: K, value: ClientFilters[K]) { + onChange({ ...filters, [key]: value }); + } + + const inboundOptions = useMemo( + () => inbounds.map((ib) => ({ + value: ib.id, + label: ib.remark + ? `${ib.remark} (${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''})` + : `#${ib.id} ${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''}`, + })), + [inbounds], + ); + + const protocolOptions = useMemo( + () => protocols.map((p) => ({ value: p, label: p })), + [protocols], + ); + + const dateRange: [Dayjs | null, Dayjs | null] = [ + filters.expiryFrom ? dayjs(filters.expiryFrom) : null, + filters.expiryTo ? dayjs(filters.expiryTo) : null, + ]; + + return ( + onOpenChange(false)} + width={420} + destroyOnHidden + footer={ +
+ + +
+ } + > +
+ {t('status')}}> + patch('buckets', v as string[])} + > + + {BUCKET_KEYS.map((k) => ( + + {bucketLabel(k, t)} + + ))} + + + + + + patch('inboundIds', v as number[])} + options={inboundOptions} + placeholder={t('inbounds')} + maxTagCount="responsive" + allowClear + showSearch + optionFilterProp="label" + listHeight={220} + /> + + + + { + const from = range?.[0]?.startOf('day').valueOf(); + const to = range?.[1]?.endOf('day').valueOf(); + onChange({ ...filters, expiryFrom: from || undefined, expiryTo: to || undefined }); + }} + style={{ width: '100%' }} + allowEmpty={[true, true]} + /> + + + + + + patch('usageFromGB', typeof v === 'number' ? v : undefined)} + /> + + + patch('usageToGB', typeof v === 'number' ? v : undefined)} + /> + + + + + + patch('autoRenew', e.target.value)} + optionType="button" + buttonStyle="solid" + options={[ + { value: '', label: t('all') }, + { value: 'on', label: t('enabled') }, + { value: 'off', label: t('disabled') }, + ]} + /> + + + + patch('hasTgId', e.target.value)} + optionType="button" + buttonStyle="solid" + options={[ + { value: '', label: t('all') }, + { value: 'yes', label: t('pages.clients.has') }, + { value: 'no', label: t('pages.clients.hasNot') }, + ]} + /> + + + + patch('hasComment', e.target.value)} + optionType="button" + buttonStyle="solid" + options={[ + { value: '', label: t('all') }, + { value: 'yes', label: t('pages.clients.has') }, + { value: 'no', label: t('pages.clients.hasNot') }, + ]} + /> + +
+
+ ); +} + +function bucketLabel(key: string, t: (k: string) => string): string { + switch (key) { + case 'active': return t('subscription.active'); + case 'expiring': return t('depletingSoon'); + case 'depleted': return t('depleted'); + case 'deactive': return t('disabled'); + case 'online': return t('online'); + default: return key; + } +} diff --git a/frontend/src/pages/clients/filters.ts b/frontend/src/pages/clients/filters.ts new file mode 100644 index 00000000..27459795 --- /dev/null +++ b/frontend/src/pages/clients/filters.ts @@ -0,0 +1,36 @@ +export interface ClientFilters { + buckets: string[]; + protocols: string[]; + inboundIds: number[]; + expiryFrom?: number; + expiryTo?: number; + usageFromGB?: number; + usageToGB?: number; + autoRenew: '' | 'on' | 'off'; + hasTgId: '' | 'yes' | 'no'; + hasComment: '' | 'yes' | 'no'; +} + +export function emptyFilters(): ClientFilters { + return { + buckets: [], + protocols: [], + inboundIds: [], + autoRenew: '', + hasTgId: '', + hasComment: '', + }; +} + +export function activeFilterCount(f: ClientFilters): number { + let n = 0; + if (f.buckets.length) n++; + if (f.protocols.length) n++; + if (f.inboundIds.length) n++; + if (f.expiryFrom || f.expiryTo) n++; + if (f.usageFromGB || f.usageToGB) n++; + if (f.autoRenew) n++; + if (f.hasTgId) n++; + if (f.hasComment) n++; + return n; +} diff --git a/web/service/client.go b/web/service/client.go index 05b39b54..562571b8 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -8,6 +8,7 @@ import ( "fmt" "slices" "sort" + "strconv" "strings" "sync" "time" @@ -864,15 +865,28 @@ type ClientSlim struct { // ClientPageParams are the query params accepted by /panel/api/clients/list/paged. // All fields are optional — the empty value means "no filter" / defaults. +// +// Filter / Protocol / Inbound accept either a single value or a comma-separated +// list; matching is OR within a field and AND across fields. The numeric range +// fields treat 0 as "unset" on the lower bound and 0 (or negative) as +// "unbounded" on the upper bound. type ClientPageParams struct { Page int `form:"page"` PageSize int `form:"pageSize"` Search string `form:"search"` Filter string `form:"filter"` Protocol string `form:"protocol"` - Inbound int `form:"inbound"` + Inbound string `form:"inbound"` Sort string `form:"sort"` Order string `form:"order"` + + ExpiryFrom int64 `form:"expiryFrom"` + ExpiryTo int64 `form:"expiryTo"` + UsageFrom int64 `form:"usageFrom"` + UsageTo int64 `form:"usageTo"` + AutoRenew string `form:"autoRenew"` + HasTgID string `form:"hasTgId"` + HasComment string `form:"hasComment"` } // ClientPageResponse is the shape returned by ListPaged. `Total` is the @@ -931,8 +945,12 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin page = 1 } + protocols := parseCSVStrings(params.Protocol) + inboundIDs := parseCSVInts(params.Inbound) + buckets := parseCSVStrings(params.Filter) + var protocolByInbound map[int]string - if params.Protocol != "" { + if len(protocols) > 0 { inbounds, err := inboundSvc.GetAllInbounds() if err == nil { protocolByInbound = make(map[int]string, len(inbounds)) @@ -968,13 +986,28 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin if needle != "" && !clientMatchesSearch(c, needle) { continue } - if params.Protocol != "" && !clientMatchesProtocol(c, params.Protocol, protocolByInbound) { + if len(protocols) > 0 && !clientMatchesAnyProtocol(c, protocols, protocolByInbound) { continue } - if params.Inbound > 0 && !clientMatchesInbound(c, params.Inbound) { + if len(inboundIDs) > 0 && !clientMatchesAnyInbound(c, inboundIDs) { continue } - if params.Filter != "" && !clientMatchesBucket(c, params.Filter, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) { + if len(buckets) > 0 && !clientMatchesAnyBucket(c, buckets, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) { + continue + } + if !clientMatchesExpiryRange(c, params.ExpiryFrom, params.ExpiryTo) { + continue + } + if !clientMatchesUsageRange(c, params.UsageFrom, params.UsageTo) { + continue + } + if !clientMatchesAutoRenew(c, params.AutoRenew) { + continue + } + if !clientMatchesHasTgID(c, params.HasTgID) { + continue + } + if !clientMatchesHasComment(c, params.HasComment) { continue } filtered = append(filtered, c) @@ -1068,35 +1101,157 @@ func clientMatchesSearch(c ClientWithAttachments, needle string) bool { if needle == "" { return true } - if strings.Contains(strings.ToLower(c.Email), needle) { - return true - } - if strings.Contains(strings.ToLower(c.SubID), needle) { - return true - } - if strings.Contains(strings.ToLower(c.Comment), needle) { - return true - } - return false -} - -func clientMatchesProtocol(c ClientWithAttachments, protocol string, byInbound map[int]string) bool { - if protocol == "" { - return true - } - for _, id := range c.InboundIds { - if byInbound[id] == protocol { + candidates := [...]string{c.Email, c.SubID, c.Comment, c.UUID, c.Password, c.Auth} + for _, v := range candidates { + if v != "" && strings.Contains(strings.ToLower(v), needle) { return true } } return false } -func clientMatchesInbound(c ClientWithAttachments, inboundId int) bool { - if inboundId <= 0 { +// parseCSVStrings splits a comma-separated list, trims/lower-cases each item, +// and drops blanks. Returns nil when the input has no usable entries — the +// caller can then skip the predicate entirely. +func parseCSVStrings(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + s := strings.ToLower(strings.TrimSpace(p)) + if s != "" { + out = append(out, s) + } + } + if len(out) == 0 { + return nil + } + return out +} + +// parseCSVInts is parseCSVStrings for positive integer IDs; non-numeric or +// non-positive entries are silently dropped. +func parseCSVInts(raw string) []int { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]int, 0, len(parts)) + for _, p := range parts { + s := strings.TrimSpace(p) + if s == "" { + continue + } + if n, err := strconv.Atoi(s); err == nil && n > 0 { + out = append(out, n) + } + } + if len(out) == 0 { + return nil + } + return out +} + +func clientMatchesAnyProtocol(c ClientWithAttachments, protocols []string, byInbound map[int]string) bool { + for _, id := range c.InboundIds { + p := byInbound[id] + if p == "" { + continue + } + if slices.Contains(protocols, strings.ToLower(p)) { + return true + } + } + return false +} + +func clientMatchesAnyInbound(c ClientWithAttachments, inboundIds []int) bool { + for _, id := range c.InboundIds { + if slices.Contains(inboundIds, id) { + return true + } + } + return false +} + +func clientMatchesAnyBucket(c ClientWithAttachments, buckets []string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool { + for _, b := range buckets { + if clientMatchesBucket(c, b, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) { + return true + } + } + return false +} + +func clientMatchesExpiryRange(c ClientWithAttachments, fromMs, toMs int64) bool { + if fromMs <= 0 && toMs <= 0 { return true } - return slices.Contains(c.InboundIds, inboundId) + // expiryTime of 0 means "never expires"; treat it as outside any bounded + // range so users filtering by date see only clients with concrete expiries. + if c.ExpiryTime == 0 { + return false + } + // Negative expiry is the "delayed start" sentinel; same treatment as never. + if c.ExpiryTime < 0 { + return false + } + if fromMs > 0 && c.ExpiryTime < fromMs { + return false + } + if toMs > 0 && c.ExpiryTime > toMs { + return false + } + return true +} + +func clientMatchesUsageRange(c ClientWithAttachments, fromBytes, toBytes int64) bool { + if fromBytes <= 0 && toBytes <= 0 { + return true + } + used := int64(0) + if c.Traffic != nil { + used = c.Traffic.Up + c.Traffic.Down + } + if fromBytes > 0 && used < fromBytes { + return false + } + if toBytes > 0 && used > toBytes { + return false + } + return true +} + +func clientMatchesAutoRenew(c ClientWithAttachments, mode string) bool { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "on": + return c.Reset > 0 + case "off": + return c.Reset <= 0 + } + return true +} + +func clientMatchesHasTgID(c ClientWithAttachments, mode string) bool { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "yes": + return c.TgID != 0 + case "no": + return c.TgID == 0 + } + return true +} + +func clientMatchesHasComment(c ClientWithAttachments, mode string) bool { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "yes": + return strings.TrimSpace(c.Comment) != "" + case "no": + return strings.TrimSpace(c.Comment) == "" + } + return true } func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool { diff --git a/web/translation/en-US.json b/web/translation/en-US.json index c52edc88..f80e5cf9 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -18,6 +18,10 @@ "protocol": "Protocol", "search": "Search", "filter": "Filter", + "all": "All", + "from": "From", + "to": "To", + "done": "Done", "loading": "Loading...", "refresh": "Refresh", "clear": "Clear", @@ -454,6 +458,11 @@ "days": "Day(s)", "renew": "Auto Renew", "renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)", + "searchPlaceholder": "Search email, comment, sub ID, UUID, password, auth…", + "filterTitle": "Filter clients", + "clearAllFilters": "Clear all", + "has": "Has", + "hasNot": "Doesn't have", "title": "Clients", "actions": "Actions", "totalGB": "Total Sent/Received (GB)",