diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index fd4b0bfe0..1dea05543 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -5691,7 +5691,7 @@ "tags": [ "Clients" ], - "summary": "Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.", + "summary": "Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters: the *Count fields are exact, while the email arrays beside them stop at 200 entries so the payload does not grow with the panel. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.", "operationId": "get_panel_api_clients_list_paged", "parameters": [ { @@ -5807,12 +5807,18 @@ "summary": { "total": 2000, "active": 1850, + "onlineCount": 1, + "depletedCount": 0, + "expiringCount": 0, + "deactiveCount": 150, "online": [ "alice@example.com" ], "depleted": [], "expiring": [], - "deactive": [] + "deactive": [ + "bob@example.com" + ] } } } diff --git a/frontend/src/components/clients/ClientTrafficCell.tsx b/frontend/src/components/clients/ClientTrafficCell.tsx index a99f643da..62fc6bcf1 100644 --- a/frontend/src/components/clients/ClientTrafficCell.tsx +++ b/frontend/src/components/clients/ClientTrafficCell.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Popover, Progress } from 'antd'; @@ -17,7 +17,11 @@ export interface ClientTrafficCellProps { compact?: boolean; } -export default function ClientTrafficCell({ +// Every prop is a primitive and the component is pure, so the memo bails out +// whenever a client's counters did not move — which is most of them on most +// pushes. Each skipped instance is one antd Popover (rc-trigger), one Progress, +// a useTranslation subscription and a theme context read, times up to 200 rows. +const ClientTrafficCell = memo(function ClientTrafficCell({ up = 0, down = 0, total = 0, @@ -83,4 +87,6 @@ export default function ClientTrafficCell({ ); -} +}); + +export default ClientTrafficCell; diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index a23ccca96..40ba94bb8 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -73,7 +73,9 @@ export interface ClientQueryParams { const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 }; const DEFAULT_SUMMARY: ClientsSummary = { - total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [], + total: 0, active: 0, + onlineCount: 0, depletedCount: 0, expiringCount: 0, deactiveCount: 0, + online: [], depleted: [], expiring: [], deactive: [], }; export interface ClientSpeedEntry { @@ -114,7 +116,50 @@ export function computeClientsSummary( if (nearExpiry || nearLimit) expiring.push(email); else active += 1; } - return { total: stats.length, active, online, depleted, expiring, deactive }; + return { + total: stats.length, + active, + onlineCount: online.length, + depletedCount: depleted.length, + expiringCount: expiring.length, + deactiveCount: deactive.length, + online, + depleted, + expiring, + deactive, + }; +} + +export function sameSpeedMap( + a: Record, + b: Record, +): boolean { + const aKeys = Object.keys(a); + if (aKeys.length !== Object.keys(b).length) return false; + for (const key of aKeys) { + const left = a[key]; + const right = b[key]; + if (!right || left.up !== right.up || left.down !== right.down) return false; + } + return true; +} + +// The field list computeClientsSummary reads, and deliberately nothing else. +// lastOnline in particular churns for every online client on every push and no +// counter depends on it, so including it here would defeat the comparison. +export function sameSummaryInputs(a: ClientStatRow[], b: ClientStatRow[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const left = a[i]; + const right = b[i]; + if (left.email !== right.email + || left.up !== right.up + || left.down !== right.down + || left.total !== right.total + || left.enable !== right.enable + || left.expiryTime !== right.expiryTime) return false; + } + return true; } export function pickClientsSummary( @@ -174,17 +219,31 @@ async function fetchDefaults(): Promise> { return validated.obj || {}; } -export function useClients() { +export interface UseClientsOptions { + // Callers that only need the mutations — the bulk modals, the groups page — + // pass false. Mounting them used to start a second 5-second poll of the paged + // list whose result they never read, which on a large panel means a full + // summary aggregate every 5 seconds for nothing. + list?: boolean; +} + +export function useClients(options: UseClientsOptions = {}) { + const withList = options.list ?? true; const queryClient = useQueryClient(); - const [query, setQueryState] = useState(DEFAULT_QUERY); + // Null until the page has settled on a query. The clients page cannot build + // one until the persisted sort and the panel's configured page size are both + // known, and fetching before then cost three sequential requests per load — + // the first two thrown away (#trace). + const [query, setQueryState] = useState(null); // setQuery shallow-compares so callers can pass a fresh object every render // (the common React pattern) without triggering a re-fetch when nothing // actually changed. const setQuery = useCallback((next: ClientQueryParams) => { setQueryState((prev) => { if ( - prev.page === next.page + prev + && prev.page === next.page && prev.pageSize === next.pageSize && (prev.search ?? '') === (next.search ?? '') && (prev.filter ?? '') === (next.filter ?? '') @@ -206,8 +265,9 @@ export function useClients() { }, []); const listQuery = useQuery({ - queryKey: keys.clients.list(query), - queryFn: () => fetchClientPage(query), + queryKey: keys.clients.list(query ?? DEFAULT_QUERY), + queryFn: () => fetchClientPage(query ?? DEFAULT_QUERY), + enabled: withList && query !== null, staleTime: Infinity, // List is sorted/paged server-side, so the WS patch can't add new or // re-sort rows; poll the current page to keep it live (pauses when hidden). @@ -218,6 +278,7 @@ export function useClients() { const inboundOptionsQuery = useQuery({ queryKey: keys.inbounds.options(), queryFn: fetchInboundOptions, + enabled: withList, staleTime: Infinity, }); @@ -235,6 +296,7 @@ export function useClients() { const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines'); return Array.isArray(validated.obj) ? validated.obj : []; }, + enabled: withList, staleTime: Infinity, }); @@ -244,7 +306,11 @@ export function useClients() { const allGroups = listQuery.data?.groups ?? []; const fetched = listQuery.data !== undefined || listQuery.isError; const fetchError = listQuery.error ? (listQuery.error as Error).message : ''; - const loading = listQuery.isFetching; + // isFetching is deliberately NOT read here. Touching it makes it a tracked + // property, so the 5s refetchInterval notifies twice per cycle — two whole + // page renders even when structural sharing leaves the data identical, and + // each one bumps rc-table's immutable mark and re-runs every cell renderer. + // Callers that want a spinner for an explicit refresh drive it locally. // Showing kept-previous data for a new key (filter/sort/page) — drives the // table overlay so the 5s background poll doesn't flash it. const transitioning = listQuery.isPlaceholderData; @@ -277,6 +343,11 @@ export function useClients() { const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000; const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824; const pageSize = (defaults.pageSize as number) ?? 0; + // pageSize 0 means "one long page", which is indistinguishable from "the + // settings have not arrived yet" — so callers need this flag to know when the + // configured page size is real. isFetched (not isSuccess) so a failed + // settings request still lets the page fall back and render. + const settingsReady = defaultsQuery.isFetched; const [allClientStats, setAllClientStats] = useState([]); const [clientSpeed, setClientSpeed] = useState>({}); @@ -565,15 +636,23 @@ export function useClients() { queryClient.setQueryData(keys.clients.onlines(), p.onlineClients); } if (Array.isArray(p.clientTraffics)) { + // Xray reports a row per client whether or not it moved a byte, so most of + // this map used to be zeros. A missing entry and a zero entry render + // identically (isActiveSpeed treats both as inactive), so the zeros are + // dropped and an unchanged result returns the previous object — which lets + // React bail out of the update instead of re-rendering the table. const next: Record = {}; for (const ct of p.clientTraffics) { if (!ct || !ct.email) continue; + const up = ct.up || 0; + const down = ct.down || 0; + if (up === 0 && down === 0) continue; next[ct.email] = { - up: (ct.up || 0) / TRAFFIC_POLL_INTERVAL_S, - down: (ct.down || 0) / TRAFFIC_POLL_INTERVAL_S, + up: up / TRAFFIC_POLL_INTERVAL_S, + down: down / TRAFFIC_POLL_INTERVAL_S, }; } - setClientSpeed(next); + setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next)); } }, [queryClient]); @@ -581,12 +660,17 @@ export function useClients() { if (!payload || typeof payload !== 'object') return; const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean }; if (!Array.isArray(p.clients) || p.clients.length === 0) return; - if (p.snapshot !== false) setAllClientStats(p.clients); + if (p.snapshot !== false) { + const rows = p.clients; + setAllClientStats((prev) => (sameSummaryInputs(prev, rows) ? prev : rows)); + } + const active = queryRef.current; + if (!active) return; const byEmail = new Map(); for (const row of p.clients) { if (row && row.email) byEmail.set(row.email, row); } - queryClient.setQueryData(keys.clients.list(queryRef.current), (prev) => { + queryClient.setQueryData(keys.clients.list(active), (prev) => { if (!prev) return prev; let touched = false; const next = prev.items.slice(); @@ -624,7 +708,6 @@ export function useClients() { setQuery, inbounds, onlines, - loading, transitioning, fetched, fetchError, @@ -634,6 +717,7 @@ export function useClients() { expireDiff, trafficDiff, pageSize, + settingsReady, refresh, create, bulkCreate, diff --git a/frontend/src/hooks/useTheme.tsx b/frontend/src/hooks/useTheme.tsx index 4ef11a081..a3620e035 100644 --- a/frontend/src/hooks/useTheme.tsx +++ b/frontend/src/hooks/useTheme.tsx @@ -92,9 +92,24 @@ const LIGHT_BUTTON_TOKENS = { colorPrimaryActive: '#073ea8', }; +// hashed:false drops the `:where(.css-)` wrapper antd puts around every +// rule. It costs nothing in specificity — `:where()` contributes zero, so the +// panel's own `.ant-*` overrides still win — and it removes roughly 5,700 +// wrappers, 16% of the generated stylesheet, from what the browser has to parse. +// +// cssVar.key pins the CSS-variable scope. Every panel page mounts its own +// ConfigProvider (there is no root one), and without a fixed key each mints a +// fresh useId-derived scope, so navigating re-serialises and re-injects the whole +// token block under a new class instead of reusing the one already in the head. +const SHARED_STYLE_CONFIG = { + hashed: false, + cssVar: { key: 'xui' }, +} as const; + export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig { if (!isDark) { return { + ...SHARED_STYLE_CONFIG, algorithm: antdTheme.defaultAlgorithm, token: LIGHT_CONTRAST_TOKENS, components: { @@ -104,6 +119,7 @@ export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeCo }; } return { + ...SHARED_STYLE_CONFIG, algorithm: antdTheme.darkAlgorithm, token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS, components: { diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index b4262b1b7..1f8eb8409 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -564,7 +564,7 @@ export const sections: readonly Section[] = [ { method: 'GET', path: '/panel/api/clients/list/paged', - summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.', + summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters: the *Count fields are exact, while the email arrays beside them stop at 200 entries so the payload does not grow with the panel. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.', params: [ { name: 'page', in: 'query', type: 'number', desc: '1-indexed page number. Defaults to 1.' }, { name: 'pageSize', in: 'query', type: 'number', desc: 'Rows per page. Defaults to 25, capped at 200.' }, @@ -575,7 +575,7 @@ export const sections: readonly Section[] = [ { name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' }, ], response: - '{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": []\n }\n }\n}', + '{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "onlineCount": 1,\n "depletedCount": 0,\n "expiringCount": 0,\n "deactiveCount": 150,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": ["bob@example.com"]\n }\n }\n}', }, { method: 'GET', diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index 6f5083cc3..2b05d682e 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -56,7 +56,7 @@ export default function ClientBulkAddModal({ }: ClientBulkAddModalProps) { const { t } = useTranslation(); const [messageApi, messageContextHolder] = message.useMessage(); - const { bulkCreate } = useClients(); + const { bulkCreate } = useClients({ list: false }); const methods = useForm({ defaultValues: EMPTY }); const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' }); diff --git a/frontend/src/pages/clients/ClientsPage.css b/frontend/src/pages/clients/ClientsPage.css index 850b1a59f..93d63c783 100644 --- a/frontend/src/pages/clients/ClientsPage.css +++ b/frontend/src/pages/clients/ClientsPage.css @@ -16,6 +16,13 @@ white-space: nowrap; } +.client-email-more { + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid var(--ant-color-border-secondary, rgba(128, 128, 128, 0.2)); + opacity: 0.65; +} + .filter-bar { display: flex; flex-wrap: wrap; diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 2e556f860..7035c9e2c 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -1,4 +1,4 @@ -import { lazy, useCallback, useEffect, useMemo, useState } from 'react'; +import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge, @@ -16,7 +16,6 @@ import { Result, Row, Select, - Space, Spin, Statistic, Switch, @@ -80,12 +79,14 @@ const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal')); const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal')); const TextModal = lazy(() => import('@/components/feedback/TextModal')); const PromptModal = lazy(() => import('@/components/feedback/PromptModal')); +import { ClientInboundChips, ClientRowActions } from './RowCells'; import { emptyFilters, activeFilterCount } from './filters'; import type { ClientFilters } from './filters'; import './ClientsPage.css'; const FILTER_STATE_KEY = 'clientsFilterState'; const DISABLED_PAGE_SIZE = 200; +const DEFAULT_TABLE_PAGE_SIZE = 25; function UngroupIcon() { return ( @@ -126,12 +127,29 @@ function UngroupIcon() { ); } +// The server sends exact counters but caps the email arrays behind them, so a +// panel with thousands of depleted clients neither ships nor renders them all. +// The trailing chip reports what the popover left out. +function ClientEmailList({ emails, total }: { emails: string[]; total: number }) { + const hidden = total - emails.length; + return ( +
+ {emails.map((e) =>
{e}
)} + {hidden > 0 &&
+{hidden}
} +
+ ); +} + type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring'; interface PersistedFilterState { searchKey: string; filters: ClientFilters; sort: string; + // The page size resolved on the previous visit. Without it the first list + // request has to wait for /setting/defaultSettings just to learn how many rows + // to ask for, which serialises two round trips on every load. + pageSize: number | null; } const INBOUND_PROTOCOL_COLORS: Record = { @@ -147,6 +165,9 @@ const INBOUND_PROTOCOL_COLORS: Record = { tunnel: 'orange', }; const INBOUND_CHIP_LIMIT = 1; +// A shared empty array keeps the memoised chip cell from seeing a fresh prop for +// every unattached client on every render. +const EMPTY_INBOUND_IDS: number[] = []; function readFilterState(): PersistedFilterState { try { @@ -164,9 +185,10 @@ function readFilterState(): PersistedFilterState { groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [], }, sort: typeof raw.sort === 'string' ? raw.sort : '', + pageSize: typeof raw.pageSize === 'number' && raw.pageSize > 0 ? raw.pageSize : null, }; } catch { - return { searchKey: '', filters: emptyFilters(), sort: '' }; + return { searchKey: '', filters: emptyFilters(), sort: '', pageSize: null }; } } @@ -208,8 +230,8 @@ export default function ClientsPage() { summary, allGroups, setQuery, - inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings, - tgBotEnable, expireDiff, trafficDiff, pageSize, + inbounds, onlines, transitioning, fetched, fetchError, subSettings, + tgBotEnable, expireDiff, trafficDiff, pageSize, settingsReady, create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach, resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable, clientSpeed, @@ -265,14 +287,31 @@ export default function ClientsPage() { const [sortColumn, setSortColumn] = useState(initialSort.column); const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(initialSort.order); const [currentPage, setCurrentPage] = useState(1); - const [tablePageSize, setTablePageSize] = useState(25); + // Derived, not mirrored into state by an effect: an effect lags one render + // behind the settings arriving, and that lag is what made the page fetch the + // list once with the placeholder size and again with the real one. + const [pageSizeChoice, setPageSizeChoice] = useState(null); + const settingsPageSize = settingsReady ? (pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE) : null; + // Last visit's resolved size stands in until the settings land, so the list + // request goes out with the page mount instead of queueing behind them. If the + // admin has since changed the setting the authoritative value replaces it and + // costs one refetch — only on the load that follows the change. Null means + // nothing is known yet, which is the one case worth waiting for. + const resolvedPageSize = pageSizeChoice ?? settingsPageSize ?? initial.pageSize; + const tablePageSize = resolvedPageSize ?? DEFAULT_TABLE_PAGE_SIZE; // debouncedSearch lags behind the input so we don't spam the server on every // keystroke; the search box still feels instant locally. const [debouncedSearch, setDebouncedSearch] = useState(searchKey); useEffect(() => { - localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters, sort: sortValueFor(sortColumn, sortOrder) })); - }, [searchKey, filters, sortColumn, sortOrder]); + localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ + searchKey, + filters, + sort: sortValueFor(sortColumn, sortOrder), + // Only ever persist a size we actually resolved, never the render fallback. + pageSize: resolvedPageSize, + })); + }, [searchKey, filters, sortColumn, sortOrder, resolvedPageSize]); useEffect(() => { const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300); @@ -303,6 +342,10 @@ export default function ClientsPage() { }, [filters.nodeIds, filters.inboundIds, inbounds]); useEffect(() => { + // With no remembered size and no settings yet, any query we build would be a + // guess, and issuing it costs a full server round trip that is thrown away as + // soon as the real size arrives. + if (resolvedPageSize === null) return; setQuery({ page: currentPage, pageSize: tablePageSize, @@ -321,13 +364,21 @@ export default function ClientsPage() { sort: sortColumn || undefined, order: sortOrder || undefined, }); - }, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]); + }, [setQuery, resolvedPageSize, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]); const activeCount = activeFilterCount(filters); - useEffect(() => { - setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE); - }, [pageSize]); + // Row handlers take an email and look the row up here at call time. Keying + // them on the record object instead would defeat the memoised cells: every + // traffic push replaces the row object of every client whose counters moved, + // so the memo would miss on exactly the rows that are busy. Reading through + // the ref also means a modal opened mid-poll shows current usage. + const rowsByEmail = useRef(new Map()); + rowsByEmail.current = useMemo(() => { + const map = new Map(); + for (const c of clients) map.set(c.email, c); + return map; + }, [clients]); const onlineSet = useMemo(() => new Set(onlines || []), [onlines]); const inboundsById = useMemo(() => { @@ -454,7 +505,9 @@ export default function ClientsPage() { setFormOpen(true); } - async function onEdit(row: ClientRecord) { + const onEdit = useCallback(async (email: string) => { + const row = rowsByEmail.current.get(email); + if (!row) return; setFormMode('edit'); // Paged list omits per-client secrets to keep the row payload tiny; // edit needs them, so fetch the full record first. @@ -465,9 +518,11 @@ export default function ClientsPage() { setEditingAttachedIds([...ids]); setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []); setFormOpen(true); - } + }, [hydrate]); - function onDelete(row: ClientRecord) { + const onDelete = useCallback((email: string) => { + const row = rowsByEmail.current.get(email); + if (!row) return; modal.confirm({ title: t('pages.clients.deleteConfirmTitle', { email: row.email }), content: t('pages.clients.deleteConfirmContent'), @@ -479,9 +534,10 @@ export default function ClientsPage() { if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted')); }, }); - } + }, [modal, t, remove, messageApi]); - function onResetTraffic(row: ClientRecord) { + const onResetTraffic = useCallback((email: string) => { + const row = rowsByEmail.current.get(email); if (!row?.email) { messageApi.warning(t('pages.clients.resetNotPossible')); return; @@ -496,19 +552,33 @@ export default function ClientsPage() { if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset')); }, }); - } + }, [modal, t, resetTraffic, messageApi]); - async function onShowInfo(row: ClientRecord) { + const onShowInfo = useCallback(async (email: string) => { + const row = rowsByEmail.current.get(email); + if (!row) return; const full = await hydrate(row.email); setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row); setInfoOpen(true); - } + }, [hydrate]); - async function onShowQr(row: ClientRecord) { + const onShowQr = useCallback(async (email: string) => { + const row = rowsByEmail.current.get(email); + if (!row) return; const full = await hydrate(row.email); setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row); setQrOpen(true); - } + }, [hydrate]); + + const [refreshing, setRefreshing] = useState(false); + const onRefreshClick = useCallback(async () => { + setRefreshing(true); + try { + await refresh(); + } finally { + setRefreshing(false); + } + }, [refresh]); const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => { setTextTitle(opts.title); @@ -743,7 +813,7 @@ export default function ClientsPage() { const onTableChange: NonNullable['onChange']> = (pag) => { if (pag?.current) setCurrentPage(pag.current); - if (pag?.pageSize) setTablePageSize(pag.pageSize); + if (pag?.pageSize) setPageSizeChoice(pag.pageSize); }; const columns = useMemo>(() => [ @@ -752,23 +822,14 @@ export default function ClientsPage() { key: 'actions', width: 200, render: (_v, record) => ( - - - } + extra={} /> ) : ( @@ -1007,37 +1039,37 @@ export default function ClientsPage() { {summary.online.map((e) =>
{e}
)}} + open={summary.onlineCount ? undefined : false} + content={} > - } /> + } />
{summary.depleted.map((e) =>
{e}
)}} + open={summary.depletedCount ? undefined : false} + content={} > - } /> + } />
{summary.expiring.map((e) =>
{e}
)}} + open={summary.expiringCount ? undefined : false} + content={} > - } /> + } />
{summary.deactive.map((e) =>
{e}
)}} + open={summary.deactiveCount ? undefined : false} + content={} > - } /> + } />
@@ -1364,7 +1396,7 @@ export default function ClientsPage() { showTotal={(n) => `${n}`} onChange={(p, s) => { setCurrentPage(p); - if (s && s !== tablePageSize) setTablePageSize(s); + if (s && s !== tablePageSize) setPageSizeChoice(s); }} /> @@ -1391,8 +1423,8 @@ export default function ClientsPage() { role="button" tabIndex={0} aria-label={t('pages.clients.clientInfo')} - onClick={() => onShowInfo(row)} - onKeyDown={activateOnKey(() => onShowInfo(row))} + onClick={() => onShowInfo(row.email)} + onKeyDown={activateOnKey(() => onShowInfo(row.email))} />
{t('pages.clients.qrCode')}, - onClick: () => onShowQr(row), + onClick: () => onShowQr(row.email), }, { key: 'reset', label: <> {t('pages.inbounds.resetTraffic')}, - onClick: () => onResetTraffic(row), + onClick: () => onResetTraffic(row.email), }, { key: 'edit', label: <> {t('edit')}, - onClick: () => onEdit(row), + onClick: () => onEdit(row.email), }, { key: 'delete', danger: true, label: <> {t('delete')}, - onClick: () => onDelete(row), + onClick: () => onDelete(row.email), }, ], }} diff --git a/frontend/src/pages/clients/RowCells.tsx b/frontend/src/pages/clients/RowCells.tsx new file mode 100644 index 000000000..109cdc8d7 --- /dev/null +++ b/frontend/src/pages/clients/RowCells.tsx @@ -0,0 +1,154 @@ +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Popover, Space, Tag, Tooltip } from 'antd'; +import { + DeleteOutlined, + EditOutlined, + InfoCircleOutlined, + QrcodeOutlined, + RetweetOutlined, +} from '@ant-design/icons'; + +import { formatInboundLabel } from '@/lib/inbounds/label'; +import type { InboundOption } from '@/hooks/useClients'; + +const ICON_BUTTON_STYLE = { fontSize: 16 } as const; + +interface ClientRowActionsProps { + email: string; + onShowQr: (email: string) => void; + onShowInfo: (email: string) => void; + onResetTraffic: (email: string) => void; + onEdit: (email: string) => void; + onDelete: (email: string) => void; +} + +// Five Tooltip-wrapped buttons per row, none of which depend on traffic. Left +// inline they re-ran rc-tooltip's alignment machinery for every visible row on +// every traffic push — 125 Tooltips on a 25-row page, five seconds apart. +// Keyed on the email rather than the row object, because a push replaces the row +// object of every client whose counters moved; the page resolves the live row. +export const ClientRowActions = memo(function ClientRowActions({ + email, + onShowQr, + onShowInfo, + onResetTraffic, + onEdit, + onDelete, +}: ClientRowActionsProps) { + const { t } = useTranslation(); + return ( + + + + + + ); + } + render(); + const before = reads.count; + await userEvent.click(screen.getByRole('button', { name: 'swap' })); + expect(reads.count).toBeGreaterThan(before); + }); + + it('keeps the row actions wired to the right client across re-renders', async () => { + const onShowQr = vi.fn(); + const onEdit = vi.fn(); + const noop = vi.fn(); + let bump: () => void = () => {}; + + render( + + {(doBump) => { + bump = doBump; + return ( + + ); + }} + , + ); + + for (let i = 0; i < 3; i++) bump(); + + // Queried by position rather than label: the suite loads the real en-US + // bundle, so the aria-labels are translated strings, not keys. Order is + // QR, info, reset traffic, edit, delete. + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(5); + await userEvent.click(buttons[0]); + await userEvent.click(buttons[3]); + + expect(onShowQr).toHaveBeenCalledExactlyOnceWith('alice@x'); + expect(onEdit).toHaveBeenCalledExactlyOnceWith('alice@x'); + }); +}); diff --git a/frontend/src/test/clients-summary.test.ts b/frontend/src/test/clients-summary.test.ts index b43626c0f..c5a2660e3 100644 --- a/frontend/src/test/clients-summary.test.ts +++ b/frontend/src/test/clients-summary.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { computeClientsSummary, pickClientsSummary } from '@/hooks/useClients'; +import { computeClientsSummary, pickClientsSummary, sameSpeedMap, sameSummaryInputs } from '@/hooks/useClients'; import type { ClientTraffic, ClientsSummary } from '@/schemas/client'; // Parity with web/service/client.go buildClientsSummary: the same client must @@ -42,6 +42,24 @@ describe('computeClientsSummary', () => { expect(s.active).toBe(2); // online@x + offline@x }); + it('reports a counter alongside every bucket list', () => { + const stats: Row[] = [ + row({ email: 'online@x', enable: true }), + row({ email: 'disabled@x', enable: false }), + row({ email: 'exhausted@x', enable: true, total: 1 * GB, up: 1 * GB }), + row({ email: 'nearlimit@x', enable: true, total: 10 * GB, up: 9.9 * GB }), + ]; + const s = computeClientsSummary(stats, new Set(['online@x']), 3 * DAY, 1 * GB); + + // The server caps its lists but never its counters; the live recompute has + // both, so the summary card reads the same either way. + expect(s.onlineCount).toBe(s.online.length); + expect(s.depletedCount).toBe(s.depleted.length); + expect(s.expiringCount).toBe(s.expiring.length); + expect(s.deactiveCount).toBe(s.deactive.length); + expect(s.active + s.depletedCount + s.expiringCount + s.deactiveCount).toBe(s.total); + }); + it('depleted wins over disabled and over online', () => { const stats: Row[] = [ row({ email: 'a@x', enable: false, total: 1 * GB, up: 2 * GB }), @@ -63,7 +81,9 @@ describe('computeClientsSummary', () => { describe('pickClientsSummary', () => { const serverSummary: ClientsSummary = { - total: 67, active: 58, online: [], depleted: [], expiring: [], deactive: [], + total: 67, active: 58, + onlineCount: 0, depletedCount: 4, expiringCount: 3, deactiveCount: 2, + online: [], depleted: [], expiring: [], deactive: [], }; it('keeps the server summary when the snapshot is short of the server total (#6102)', () => { @@ -84,3 +104,39 @@ describe('pickClientsSummary', () => { expect(s).toEqual(serverSummary); }); }); + +describe('websocket payload identity preservation', () => { + const speed = (up: number, down: number) => ({ up, down }); + + it('treats an unchanged speed map as unchanged', () => { + const a = { 'a@x': speed(1, 2), 'b@x': speed(3, 4) }; + expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 4) })).toBe(true); + expect(sameSpeedMap(a, { 'a@x': speed(1, 2) })).toBe(false); + expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 5) })).toBe(false); + expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'c@x': speed(3, 4) })).toBe(false); + expect(sameSpeedMap({}, {})).toBe(true); + }); + + it('compares exactly the fields the summary reads, and ignores lastOnline', () => { + const base: Row[] = [row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99 })]; + + // lastOnline moves for every online client on every push and no counter + // depends on it, so it must not force a new snapshot. + const onlyLastOnlineMoved: Row[] = [ + row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, lastOnline: 12345 }), + ]; + expect(sameSummaryInputs(base, onlyLastOnlineMoved)).toBe(true); + + for (const changed of [ + row({ email: 'b@x', up: 1, down: 2, total: 10, expiryTime: 99 }), + row({ email: 'a@x', up: 2, down: 2, total: 10, expiryTime: 99 }), + row({ email: 'a@x', up: 1, down: 3, total: 10, expiryTime: 99 }), + row({ email: 'a@x', up: 1, down: 2, total: 11, expiryTime: 99 }), + row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 100 }), + row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, enable: false }), + ]) { + expect(sameSummaryInputs(base, [changed])).toBe(false); + } + expect(sameSummaryInputs(base, [])).toBe(false); + }); +}); diff --git a/internal/web/job/xray_traffic_job.go b/internal/web/job/xray_traffic_job.go index 6e6baaa89..71c8127a7 100644 --- a/internal/web/job/xray_traffic_job.go +++ b/internal/web/job/xray_traffic_job.go @@ -29,6 +29,31 @@ type XrayTrafficJob struct { // refetch for the rest. const clientStatsSnapshotMaxClients = 5000 +// splitMovedClientTraffics keeps the rows that actually moved bytes this poll, +// alongside the active-email list and set derived from the same pass. +// +// Xray reports a row for every known email whether or not it transferred +// anything, so on a large panel nearly every delta is zero. The database writes +// and the external-API inform consume the full slice before this point; the +// WebSocket frame only feeds the dashboard's live speed column, where an absent +// row and a zero row render identically. Broadcasting just the movers keeps that +// frame from growing with the client count — at 5k clients it was carrying about +// a megabyte of zeros every five seconds. +func splitMovedClientTraffics(clientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, []string, map[string]bool) { + moved := make([]*xray.ClientTraffic, 0, len(clientTraffics)) + emails := make([]string, 0, len(clientTraffics)) + active := make(map[string]bool, len(clientTraffics)) + for _, ct := range clientTraffics { + if ct == nil || ct.Up+ct.Down <= 0 { + continue + } + moved = append(moved, ct) + emails = append(emails, ct.Email) + active[ct.Email] = true + } + return moved, emails, active +} + const externalInformTimeout = 3 * time.Second var externalInformClient = &fasthttp.Client{ @@ -88,14 +113,7 @@ func (j *XrayTrafficJob) Run() { // than the shared last_online column, which remote-node syncs also bump // and would otherwise make a client active only on a remote node appear // online on local inbounds. - activeEmails := make([]string, 0, len(clientTraffics)) - deltaActive := make(map[string]bool, len(clientTraffics)) - for _, ct := range clientTraffics { - if ct != nil && ct.Up+ct.Down > 0 { - activeEmails = append(activeEmails, ct.Email) - deltaActive[ct.Email] = true - } - } + movedTraffics, activeEmails, deltaActive := splitMovedClientTraffics(clientTraffics) // When the core supports the online-stats API, union in connection-based // onlines. Neither signal alone covers everything: an idle-but-connected // client moves no bytes between polls (the delta heuristic's blind spot), @@ -179,7 +197,7 @@ func (j *XrayTrafficJob) Run() { } websocket.BroadcastTraffic(map[string]any{ "traffics": traffics, - "clientTraffics": clientTraffics, + "clientTraffics": movedTraffics, "onlineClients": onlineClients, "onlineByGuid": j.inboundService.GetOnlineClientsByGuid(), "activeInbounds": j.inboundService.GetActiveInboundsByGuid(), diff --git a/internal/web/job/xray_traffic_job_broadcast_test.go b/internal/web/job/xray_traffic_job_broadcast_test.go new file mode 100644 index 000000000..6ab39f714 --- /dev/null +++ b/internal/web/job/xray_traffic_job_broadcast_test.go @@ -0,0 +1,59 @@ +package job + +import ( + "slices" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +func TestSplitMovedClientTraffics(t *testing.T) { + rows := []*xray.ClientTraffic{ + {Email: "idle@x", Up: 0, Down: 0}, + {Email: "up@x", Up: 1024, Down: 0}, + {Email: "down@x", Up: 0, Down: 2048}, + nil, + {Email: "both@x", Up: 512, Down: 512}, + {Email: "alsoidle@x", Up: 0, Down: 0}, + } + + moved, emails, active := splitMovedClientTraffics(rows) + + t.Run("only the rows that moved bytes are broadcast", func(t *testing.T) { + got := make([]string, 0, len(moved)) + for _, ct := range moved { + got = append(got, ct.Email) + } + want := []string{"up@x", "down@x", "both@x"} + if !slices.Equal(got, want) { + t.Fatalf("moved = %v, want %v", got, want) + } + }) + + t.Run("the active list and set agree with the broadcast rows", func(t *testing.T) { + want := []string{"up@x", "down@x", "both@x"} + if !slices.Equal(emails, want) { + t.Fatalf("activeEmails = %v, want %v", emails, want) + } + if len(active) != len(want) { + t.Fatalf("deltaActive has %d entries, want %d", len(active), len(want)) + } + for _, e := range want { + if !active[e] { + t.Fatalf("deltaActive missing %q", e) + } + } + if active["idle@x"] { + t.Fatal("an idle client must not count as active") + } + }) + + t.Run("an all-idle poll broadcasts nothing", func(t *testing.T) { + moved, emails, active := splitMovedClientTraffics([]*xray.ClientTraffic{ + {Email: "a@x"}, {Email: "b@x"}, + }) + if len(moved) != 0 || len(emails) != 0 || len(active) != 0 { + t.Fatalf("expected an empty split, got %d/%d/%d", len(moved), len(emails), len(active)) + } + }) +} diff --git a/internal/web/service/client_paging.go b/internal/web/service/client_paging.go index ff317fde3..9031c5ef2 100644 --- a/internal/web/service/client_paging.go +++ b/internal/web/service/client_paging.go @@ -1,13 +1,16 @@ package service import ( - "slices" "sort" "strconv" "strings" "time" + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/xray" + + "gorm.io/gorm" ) // ClientSlim is the row-shape used by the clients page. It drops fields the @@ -74,33 +77,262 @@ type ClientPageResponse struct { // ClientsSummary collects per-bucket counts plus the matching email lists so // the clients page can render the dashboard stat cards and their hover -// popovers without shipping the full client array. +// popovers without shipping the full client array. The counters are exact; +// the lists stop at clientSummaryEmailCap entries and only back the popovers. type ClientsSummary struct { - Total int `json:"total"` - Active int `json:"active"` - Online []string `json:"online"` - Depleted []string `json:"depleted"` - Expiring []string `json:"expiring"` - Deactive []string `json:"deactive"` + Total int `json:"total"` + Active int `json:"active"` + OnlineCount int `json:"onlineCount"` + DepletedCount int `json:"depletedCount"` + ExpiringCount int `json:"expiringCount"` + DeactiveCount int `json:"deactiveCount"` + Online []string `json:"online"` + Depleted []string `json:"depleted"` + Expiring []string `json:"expiring"` + Deactive []string `json:"deactive"` } const ( clientPageDefaultSize = 25 clientPageMaxSize = 200 + // clientSummaryEmailCap bounds each bucket's email list. Shipping every + // matching email made the response — and the Zod validation the page runs + // over it — grow with the client count on a request that repeats every 5s, + // and left the hover popover rendering thousands of rows. + clientSummaryEmailCap = 200 + // sqlNeverSentinel sorts "never expires" / "unlimited quota" clients last, + // matching the sentinel the in-memory comparator used. + sqlNeverSentinel = "4611686018427387903" + // sqlClientEnabled tolerates a NULL enable column, which GORM scans as + // false: without the COALESCE such a row would match neither the enabled + // nor the disabled branch of any predicate. + sqlClientEnabled = "COALESCE(c.enable, FALSE)" ) -// ListPaged loads every client (with traffic + attachments) into memory, -// applies the requested filter / search / protocol predicates, sorts, and -// returns the requested page along with total and filtered counts. The DB -// query itself is unchanged from List(); the win is that the response -// only carries 25-ish slim rows over the wire instead of all 2000 full -// records, which on real panels was the dominant cost. -func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) { - all, err := s.List() - if err != nil { - return nil, err +const clientSearchCond = `(LOWER(c.email) LIKE ? ESCAPE '\' + OR LOWER(COALESCE(c.sub_id, '')) LIKE ? ESCAPE '\' + OR LOWER(COALESCE(c.comment, '')) LIKE ? ESCAPE '\' + OR LOWER(COALESCE(c.uuid, '')) LIKE ? ESCAPE '\' + OR LOWER(COALESCE(c.password, '')) LIKE ? ESCAPE '\' + OR LOWER(COALESCE(c.auth, '')) LIKE ? ESCAPE '\' + OR (COALESCE(c.tg_id, 0) <> 0 AND CAST(c.tg_id AS TEXT) LIKE ? ESCAPE '\'))` + +// clientQuery builds the statements behind the clients page: a clients row +// joined to its traffic counters, plus the expressions every bucket predicate +// shares. Filtering, sorting, paging and the summary all run in the database. +// Loading every client (with attachments and traffic) into Go and doing it in +// memory cost ~200ms per request at 20k clients on a page that polls every +// 5 seconds, which is what made the table feel stuck on large panels. +type clientQuery struct { + db *gorm.DB + joins []clientQueryJoin + usedExpr string + nowMs int64 + expireDiffMs int64 + trafficDiffBytes int64 +} + +type clientQueryJoin struct { + sql string + args []any +} + +func newClientQuery(db *gorm.DB, nowMs, expireDiffMs, trafficDiffBytes int64) clientQuery { + q := clientQuery{ + db: db, + nowMs: nowMs, + expireDiffMs: expireDiffMs, + trafficDiffBytes: trafficDiffBytes, + joins: []clientQueryJoin{{sql: "LEFT JOIN client_traffics ct ON ct.email = c.email"}}, + usedExpr: "(COALESCE(ct.up, 0) + COALESCE(ct.down, 0))", } - total := len(all) + freshSince := globalTrafficFreshSince() + var probe int64 + err := db.Model(&model.ClientGlobalTraffic{}). + Where("updated_at >= ?", freshSince). + Limit(1).Count(&probe).Error + if err != nil || probe == 0 { + return q + } + // A master still pushes cross-panel usage here, so the predicates have to + // see the same raised counters overlayGlobalTraffic applies on read. + q.joins = append(q.joins, clientQueryJoin{ + sql: "LEFT JOIN (SELECT email, MAX(up) AS up, MAX(down) AS down FROM client_global_traffics" + + " WHERE updated_at >= ? GROUP BY email) g ON g.email = c.email", + args: []any{freshSince}, + }) + q.usedExpr = "(CASE WHEN COALESCE(g.up, 0) > COALESCE(ct.up, 0) THEN COALESCE(g.up, 0) ELSE COALESCE(ct.up, 0) END" + + " + CASE WHEN COALESCE(g.down, 0) > COALESCE(ct.down, 0) THEN COALESCE(g.down, 0) ELSE COALESCE(ct.down, 0) END)" + return q +} + +func (q clientQuery) from() *gorm.DB { + tx := q.db.Table("clients AS c") + for _, j := range q.joins { + tx = tx.Joins(j.sql, j.args...) + } + return tx +} + +func (q clientQuery) depletedExpr() string { + return "((c.total_gb > 0 AND " + q.usedExpr + " >= c.total_gb)" + + " OR (c.expiry_time > 0 AND c.expiry_time <= " + sqlInt(q.nowMs) + "))" +} + +func (q clientQuery) nearDepletionExpr() string { + return "((c.expiry_time > 0 AND c.expiry_time - " + sqlInt(q.nowMs) + " < " + sqlInt(q.expireDiffMs) + ")" + + " OR (c.total_gb > 0 AND c.total_gb - " + q.usedExpr + " < " + sqlInt(q.trafficDiffBytes) + "))" +} + +func (q clientQuery) expiringExpr() string { + return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND " + q.nearDepletionExpr() + ")" +} + +func (q clientQuery) activeExpr() string { + return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND NOT " + q.nearDepletionExpr() + ")" +} + +// summaryDeactiveExpr is narrower than the "deactive" bucket filter: a disabled +// client that also ran out counts once, under depleted, so the stat cards add +// up to the client total. +func (q clientQuery) summaryDeactiveExpr() string { + return "(NOT " + sqlClientEnabled + " AND NOT " + q.depletedExpr() + ")" +} + +// applyParams narrows tx by every predicate the clients page sends. Matching is +// OR within a field and AND across fields, mirroring the query-param contract. +// The second return says whether anything narrowed the set, so an unfiltered +// request can reuse the total count instead of scanning for it again. +func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines []string) (*gorm.DB, bool) { + narrowed := false + where := func(cond string, args ...any) { + narrowed = true + tx = tx.Where(cond, args...) + } + + if needle := strings.ToLower(strings.TrimSpace(params.Search)); needle != "" { + pattern := "%" + escapeLikeLiteral(needle) + "%" + where(clientSearchCond, pattern, pattern, pattern, pattern, pattern, pattern, pattern) + } + if protocols := parseCSVStrings(params.Protocol); len(protocols) > 0 { + where("EXISTS (SELECT 1 FROM client_inbounds ci JOIN inbounds ib ON ib.id = ci.inbound_id"+ + " WHERE ci.client_id = c.id AND LOWER(ib.protocol) IN ?)", protocols) + } + if inboundIds := parseCSVInts(params.Inbound); len(inboundIds) > 0 { + where("EXISTS (SELECT 1 FROM client_inbounds ci WHERE ci.client_id = c.id AND ci.inbound_id IN ?)", inboundIds) + } + if buckets := parseCSVStrings(params.Filter); len(buckets) > 0 { + cond, args := q.bucketCond(buckets, onlines) + where(cond, args...) + } + if params.ExpiryFrom > 0 || params.ExpiryTo > 0 { + // 0 means "never expires" and a negative value is the delayed-start + // sentinel; both sit outside any bounded range. + where("c.expiry_time > 0") + if params.ExpiryFrom > 0 { + where("c.expiry_time >= ?", params.ExpiryFrom) + } + if params.ExpiryTo > 0 { + where("c.expiry_time <= ?", params.ExpiryTo) + } + } + if params.UsageFrom > 0 { + where(q.usedExpr+" >= ?", params.UsageFrom) + } + if params.UsageTo > 0 { + where(q.usedExpr+" <= ?", params.UsageTo) + } + switch strings.ToLower(strings.TrimSpace(params.AutoRenew)) { + case "on": + where("COALESCE(c.reset, 0) > 0") + case "off": + where("COALESCE(c.reset, 0) <= 0") + } + switch strings.ToLower(strings.TrimSpace(params.HasTgID)) { + case "yes": + where("COALESCE(c.tg_id, 0) <> 0") + case "no": + where("COALESCE(c.tg_id, 0) = 0") + } + switch strings.ToLower(strings.TrimSpace(params.HasComment)) { + case "yes": + where("TRIM(COALESCE(c.comment, '')) <> ''") + case "no": + where("TRIM(COALESCE(c.comment, '')) = ''") + } + if groups := parseCSVStrings(params.Group); len(groups) > 0 { + where("LOWER(TRIM(COALESCE(c.group_name, ''))) IN ?", groups) + } + return tx, narrowed +} + +func (q clientQuery) bucketCond(buckets, onlines []string) (string, []any) { + conds := make([]string, 0, len(buckets)) + args := make([]any, 0, len(buckets)) + for _, b := range buckets { + switch b { + case "active": + conds = append(conds, "("+sqlClientEnabled+" AND NOT "+q.depletedExpr()+")") + case "deactive": + conds = append(conds, "(NOT "+sqlClientEnabled+")") + case "depleted": + conds = append(conds, q.depletedExpr()) + case "expiring": + conds = append(conds, q.expiringExpr()) + case "online": + cond, inArgs := emailInCond("c.email", onlines) + conds = append(conds, "("+sqlClientEnabled+" AND "+cond+")") + args = append(args, inArgs...) + default: + // An unrecognised bucket name matched every client before the + // predicates moved into SQL; keep that so a stale saved filter + // cannot silently empty the table. + conds = append(conds, "(1 = 1)") + } + } + return "(" + strings.Join(conds, " OR ") + ")", args +} + +func (q clientQuery) applyOrder(tx *gorm.DB, sortKey, order string) *gorm.DB { + dir := " ASC" + if order == "descend" { + dir = " DESC" + } + // createdAt / updatedAt / lastOnline broke ties on the client id inside the + // comparator, so reversing the sort reversed the tiebreak with it. The + // other keys leaned on a stable sort over an id-ordered slice instead. + tieDir := " ASC" + var expr string + switch sortKey { + case "enable": + expr = sqlClientEnabled + case "email": + expr = "LOWER(c.email)" + case "inboundIds": + expr = "(SELECT COUNT(*) FROM client_inbounds ci WHERE ci.client_id = c.id)" + case "traffic": + expr = q.usedExpr + case "remaining": + expr = "CASE WHEN c.total_gb > 0 THEN c.total_gb - " + q.usedExpr + " ELSE " + sqlNeverSentinel + " END" + case "expiryTime": + expr = "CASE WHEN c.expiry_time > 0 THEN c.expiry_time ELSE " + sqlNeverSentinel + " END" + case "createdAt": + expr, tieDir = "c.created_at", dir + case "updatedAt": + expr, tieDir = "c.updated_at", dir + case "lastOnline": + expr, tieDir = "COALESCE(ct.last_online, 0)", dir + default: + return tx.Order("c.id ASC") + } + return tx.Order(expr + dir + ", c.id" + tieDir) +} + +// ListPaged returns one page of clients together with the counts the clients +// page header needs. Every predicate runs in SQL, so the cost tracks the page +// size rather than the number of clients on the panel. +func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) { + db := database.GetDB() pageSize := params.PageSize if pageSize <= 0 { @@ -114,27 +346,6 @@ 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 len(protocols) > 0 { - inbounds, err := inboundSvc.GetAllInbounds() - if err == nil { - protocolByInbound = make(map[int]string, len(inbounds)) - for _, ib := range inbounds { - protocolByInbound[ib.Id] = string(ib.Protocol) - } - } - } - - onlines := inboundSvc.GetOnlineClients() - onlineSet := make(map[string]struct{}, len(onlines)) - for _, e := range onlines { - onlineSet[e] = struct{}{} - } - var expireDiffMs, trafficDiffBytes int64 if settingSvc != nil { if v, err := settingSvc.GetExpireDiff(); err == nil { @@ -145,77 +356,44 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin } } - nowMs := time.Now().UnixMilli() - summary := buildClientsSummary(all, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) + onlines := inboundSvc.GetOnlineClients() + q := newClientQuery(db, time.Now().UnixMilli(), expireDiffMs, trafficDiffBytes) - needle := strings.ToLower(strings.TrimSpace(params.Search)) - - filtered := make([]ClientWithAttachments, 0, len(all)) - for _, c := range all { - if needle != "" && !clientMatchesSearch(c, needle) { - continue - } - if len(protocols) > 0 && !clientMatchesAnyProtocol(c, protocols, protocolByInbound) { - continue - } - if len(inboundIDs) > 0 && !clientMatchesAnyInbound(c, inboundIDs) { - continue - } - 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 - } - if !clientMatchesAnyGroup(c, params.Group) { - continue - } - filtered = append(filtered, c) + var total int64 + if err := db.Model(&model.ClientRecord{}).Count(&total).Error; err != nil { + return nil, err } - sortClients(filtered, params.Sort, params.Order) - - filteredCount := len(filtered) - start := (page - 1) * pageSize - end := start + pageSize - if start > filteredCount { - start = filteredCount - } - if end > filteredCount { - end = filteredCount - } - pageRows := filtered[start:end] - - items := make([]ClientSlim, 0, len(pageRows)) - for _, c := range pageRows { - items = append(items, toClientSlim(c)) + summary, err := q.summary(onlines, int(total)) + if err != nil { + return nil, err } - groupRows, gErr := s.ListGroups() - if gErr != nil { - return nil, gErr + filtered := total + if scoped, narrowed := q.applyParams(q.from(), params, onlines); narrowed { + if err := scoped.Count(&filtered).Error; err != nil { + return nil, err + } } - groups := make([]string, 0, len(groupRows)) - for _, g := range groupRows { - groups = append(groups, g.Name) + + items := []ClientSlim{} + offset := (page - 1) * pageSize + if int64(offset) < filtered { + items, err = q.pageRows(params, onlines, offset, pageSize) + if err != nil { + return nil, err + } + } + + groups, err := s.listGroupNames() + if err != nil { + return nil, err } return &ClientPageResponse{ Items: items, - Total: total, - Filtered: filteredCount, + Total: int(total), + Filtered: int(filtered), Page: page, PageSize: pageSize, Summary: summary, @@ -223,77 +401,229 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin }, nil } -func buildClientsSummary(all []ClientWithAttachments, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) ClientsSummary { +// pageRows resolves the requested page to client ids, then loads the records, +// attachments and traffic for those ids only. A page never exceeds +// clientPageMaxSize rows, which stays under sqlInChunk, so the follow-up IN +// lists need no chunking. +func (q clientQuery) pageRows(params ClientPageParams, onlines []string, offset, limit int) ([]ClientSlim, error) { + tx, _ := q.applyParams(q.from(), params, onlines) + var ids []int + if err := q.applyOrder(tx, params.Sort, params.Order). + Offset(offset).Limit(limit). + Pluck("c.id", &ids).Error; err != nil { + return nil, err + } + if len(ids) == 0 { + return []ClientSlim{}, nil + } + + var records []model.ClientRecord + if err := q.db.Where("id IN ?", ids).Find(&records).Error; err != nil { + return nil, err + } + byId := make(map[int]*model.ClientRecord, len(records)) + emails := make([]string, 0, len(records)) + for i := range records { + byId[records[i].Id] = &records[i] + if records[i].Email != "" { + emails = append(emails, records[i].Email) + } + } + + var links []model.ClientInbound + if err := q.db.Where("client_id IN ?", ids).Order("inbound_id ASC").Find(&links).Error; err != nil { + return nil, err + } + attachments := make(map[int][]int, len(ids)) + for _, l := range links { + attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId) + } + + trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails)) + if len(emails) > 0 { + var stats []xray.ClientTraffic + if err := q.db.Where("email IN ?", emails).Find(&stats).Error; err != nil { + return nil, err + } + overlayGlobalTrafficValues(q.db, stats) + for i := range stats { + trafficByEmail[stats[i].Email] = &stats[i] + } + } + + items := make([]ClientSlim, 0, len(ids)) + for _, id := range ids { + rec := byId[id] + if rec == nil { + continue + } + items = append(items, ClientSlim{ + Email: rec.Email, + SubID: rec.SubID, + Enable: rec.Enable, + TotalGB: rec.TotalGB, + ExpiryTime: rec.ExpiryTime, + LimitIP: rec.LimitIP, + Reset: rec.Reset, + Group: rec.Group, + Comment: rec.Comment, + InboundIds: attachments[rec.Id], + Traffic: trafficByEmail[rec.Email], + CreatedAt: rec.CreatedAt, + UpdatedAt: rec.UpdatedAt, + }) + } + return items, nil +} + +func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error) { s := ClientsSummary{ - Total: len(all), + Total: total, Online: []string{}, Depleted: []string{}, Expiring: []string{}, Deactive: []string{}, } - for _, c := range all { - used := int64(0) - if c.Traffic != nil { - used = c.Traffic.Up + c.Traffic.Down + + var counts struct { + Active int64 + Depleted int64 + Expiring int64 + Deactive int64 + } + // SUM over an empty table yields NULL, which not every driver scans into an + // int; COALESCE keeps a panel with no clients from erroring out. + if err := q.from().Select( + "COALESCE(SUM(CASE WHEN " + q.activeExpr() + " THEN 1 ELSE 0 END), 0) AS active," + + " COALESCE(SUM(CASE WHEN " + q.depletedExpr() + " THEN 1 ELSE 0 END), 0) AS depleted," + + " COALESCE(SUM(CASE WHEN " + q.expiringExpr() + " THEN 1 ELSE 0 END), 0) AS expiring," + + " COALESCE(SUM(CASE WHEN " + q.summaryDeactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive", + ).Scan(&counts).Error; err != nil { + return s, err + } + s.Active = int(counts.Active) + s.DepletedCount = int(counts.Depleted) + s.ExpiringCount = int(counts.Expiring) + s.DeactiveCount = int(counts.Deactive) + + buckets := []struct { + cond string + count int + out *[]string + }{ + {q.depletedExpr(), s.DepletedCount, &s.Depleted}, + {q.expiringExpr(), s.ExpiringCount, &s.Expiring}, + {q.summaryDeactiveExpr(), s.DeactiveCount, &s.Deactive}, + } + for _, b := range buckets { + // The counter already says the bucket is empty, so skip the scan that + // would look for emails it cannot find. + if b.count == 0 { + continue } - exhausted := c.TotalGB > 0 && used >= c.TotalGB - expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs - if c.Enable { - if _, ok := onlineSet[c.Email]; ok { - s.Online = append(s.Online, c.Email) + var emails []string + if err := q.from().Where(b.cond). + Order("c.id ASC").Limit(clientSummaryEmailCap). + Pluck("c.email", &emails).Error; err != nil { + return s, err + } + if len(emails) > 0 { + *b.out = emails + } + } + + online, onlineCount, err := q.onlineEmails(onlines) + if err != nil { + return s, err + } + s.Online = online + s.OnlineCount = onlineCount + return s, nil +} + +// onlineEmails intersects the emails xray reports as connected with the enabled +// clients this panel stores. The online set lives in memory and is bounded by +// live connections, so it drives the query rather than a scan of every client. +func (q clientQuery) onlineEmails(onlines []string) ([]string, int, error) { + matched := []string{} + count := 0 + for _, batch := range chunkStrings(onlines, sqlInChunk) { + var page []string + if err := q.db.Model(&model.ClientRecord{}). + Where("COALESCE(enable, FALSE) = TRUE AND email IN ?", batch). + Order("id ASC"). + Pluck("email", &page).Error; err != nil { + return nil, 0, err + } + count += len(page) + if room := clientSummaryEmailCap - len(matched); room > 0 { + matched = append(matched, page[:min(room, len(page))]...) + } + } + return matched, count, nil +} + +// listGroupNames returns the group names the clients page offers as filters: +// the stored groups plus any name a client still carries. ListGroups also sums +// per-client traffic per group, which this page never reads and which costs a +// full join over client_traffics on every poll. +func (s *ClientService) listGroupNames() ([]string, error) { + db := database.GetDB() + var stored []string + if err := db.Model(&model.ClientGroup{}).Pluck("name", &stored).Error; err != nil { + return nil, err + } + var used []string + if err := db.Model(&model.ClientRecord{}). + Where("group_name <> ''"). + Distinct(). + Pluck("group_name", &used).Error; err != nil { + return nil, err + } + seen := make(map[string]struct{}, len(stored)+len(used)) + out := make([]string, 0, len(stored)+len(used)) + for _, list := range [][]string{stored, used} { + for _, name := range list { + if name == "" { + continue } - } - if exhausted || expired { - s.Depleted = append(s.Depleted, c.Email) - continue - } - if !c.Enable { - s.Deactive = append(s.Deactive, c.Email) - continue - } - nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs - nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes - if nearExpiry || nearLimit { - s.Expiring = append(s.Expiring, c.Email) - } else { - s.Active++ + if _, dup := seen[name]; dup { + continue + } + seen[name] = struct{}{} + out = append(out, name) } } - return s + sort.Slice(out, func(i, j int) bool { + return strings.ToLower(out[i]) < strings.ToLower(out[j]) + }) + return out, nil } -func toClientSlim(c ClientWithAttachments) ClientSlim { - return ClientSlim{ - Email: c.Email, - SubID: c.SubID, - Enable: c.Enable, - TotalGB: c.TotalGB, - ExpiryTime: c.ExpiryTime, - LimitIP: c.LimitIP, - Reset: c.Reset, - Group: c.Group, - Comment: c.Comment, - InboundIds: c.InboundIds, - Traffic: c.Traffic, - CreatedAt: c.CreatedAt, - UpdatedAt: c.UpdatedAt, - } +func sqlInt(v int64) string { + return strconv.FormatInt(v, 10) } -func clientMatchesSearch(c ClientWithAttachments, needle string) bool { - if needle == "" { - return true +// escapeLikeLiteral neutralises LIKE wildcards so searching for "a_b" keeps +// matching literally, the way strings.Contains did. +func escapeLikeLiteral(s string) string { + return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s) +} + +// emailInCond renders an IN over a possibly large email set, split so no single +// IN list outgrows the drivers' bind-parameter ceiling. +func emailInCond(column string, emails []string) (string, []any) { + if len(emails) == 0 { + return "1 = 0", nil } - 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 - } + chunks := chunkStrings(emails, sqlInChunk) + parts := make([]string, 0, len(chunks)) + args := make([]any, 0, len(chunks)) + for _, chunk := range chunks { + parts = append(parts, column+" IN ?") + args = append(args, chunk) } - if c.TgID != 0 && strings.Contains(strconv.FormatInt(c.TgID, 10), needle) { - return true - } - return false + return "(" + strings.Join(parts, " OR ") + ")", args } // parseCSVStrings splits a comma-separated list, trims/lower-cases each item, @@ -339,246 +669,3 @@ func parseCSVInts(raw string) []int { } 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 - } - // 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 clientMatchesAnyGroup(c ClientWithAttachments, csv string) bool { - groups := parseCSVStrings(csv) - if len(groups) == 0 { - return true - } - current := strings.TrimSpace(c.Group) - for _, g := range groups { - if g == "" { - if current == "" { - return true - } - continue - } - if strings.EqualFold(g, current) { - return true - } - } - return false -} - -func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool { - if bucket == "" { - return true - } - used := int64(0) - if c.Traffic != nil { - used = c.Traffic.Up + c.Traffic.Down - } - exhausted := c.TotalGB > 0 && used >= c.TotalGB - expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs - switch bucket { - case "online": - if onlineSet == nil { - return false - } - _, ok := onlineSet[c.Email] - return ok && c.Enable - case "depleted": - return exhausted || expired - case "deactive": - return !c.Enable - case "active": - return c.Enable && !exhausted && !expired - case "expiring": - if !c.Enable || exhausted || expired { - return false - } - nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs - nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes - return nearExpiry || nearLimit - } - return true -} - -func sortClients(rows []ClientWithAttachments, sortKey, order string) { - if sortKey == "" { - return - } - desc := order == "descend" - less := func(i, j int) bool { - a, b := rows[i], rows[j] - switch sortKey { - case "enable": - if a.Enable == b.Enable { - return false - } - return !a.Enable && b.Enable - case "email": - return strings.ToLower(a.Email) < strings.ToLower(b.Email) - case "inboundIds": - return len(a.InboundIds) < len(b.InboundIds) - case "traffic": - ua := int64(0) - if a.Traffic != nil { - ua = a.Traffic.Up + a.Traffic.Down - } - ub := int64(0) - if b.Traffic != nil { - ub = b.Traffic.Up + b.Traffic.Down - } - return ua < ub - case "remaining": - ra := int64(1<<62 - 1) - if a.TotalGB > 0 { - used := int64(0) - if a.Traffic != nil { - used = a.Traffic.Up + a.Traffic.Down - } - ra = a.TotalGB - used - } - rb := int64(1<<62 - 1) - if b.TotalGB > 0 { - used := int64(0) - if b.Traffic != nil { - used = b.Traffic.Up + b.Traffic.Down - } - rb = b.TotalGB - used - } - return ra < rb - case "expiryTime": - ea := int64(1<<62 - 1) - if a.ExpiryTime > 0 { - ea = a.ExpiryTime - } - eb := int64(1<<62 - 1) - if b.ExpiryTime > 0 { - eb = b.ExpiryTime - } - return ea < eb - case "createdAt": - if a.CreatedAt == b.CreatedAt { - return a.Id < b.Id - } - return a.CreatedAt < b.CreatedAt - case "updatedAt": - if a.UpdatedAt == b.UpdatedAt { - return a.Id < b.Id - } - return a.UpdatedAt < b.UpdatedAt - case "lastOnline": - la := int64(0) - if a.Traffic != nil { - la = a.Traffic.LastOnline - } - lb := int64(0) - if b.Traffic != nil { - lb = b.Traffic.LastOnline - } - if la == lb { - return a.Id < b.Id - } - return la < lb - } - return false - } - sort.SliceStable(rows, func(i, j int) bool { - if desc { - return less(j, i) - } - return less(i, j) - }) -} diff --git a/internal/web/service/client_paging_test.go b/internal/web/service/client_paging_test.go new file mode 100644 index 000000000..310f028f2 --- /dev/null +++ b/internal/web/service/client_paging_test.go @@ -0,0 +1,624 @@ +package service + +import ( + "slices" + "strconv" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +const ( + pagingDay = int64(86400000) + pagingGB = int64(1) << 30 +) + +type pagingSeed struct { + email string + enable bool + totalGB int64 + expiryTime int64 + used int64 + subID string + uuid string + password string + auth string + comment string + group string + tgID int64 + reset int + lastOnline int64 + inbounds []int +} + +// seedPagingClients writes one vless and one trojan inbound plus a fixed client +// set covering every bucket, sort key and search field ListPaged supports. +// Returns "now" so the expectations can be phrased relative to it. +func seedPagingClients(t *testing.T) (int64, []pagingSeed) { + t.Helper() + db := database.GetDB() + now := time.Now().UnixMilli() + + vless := &model.Inbound{UserId: 1, Tag: "in-vless", Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`} + trojan := &model.Inbound{UserId: 1, Tag: "in-trojan", Enable: true, Port: 40002, Protocol: model.Trojan, Settings: `{"clients":[]}`} + for _, ib := range []*model.Inbound{vless, trojan} { + if err := db.Create(ib).Error; err != nil { + t.Fatalf("create inbound %s: %v", ib.Tag, err) + } + } + + seeds := []pagingSeed{ + {email: "alpha@x", enable: true, totalGB: 0, expiryTime: 0, used: 5 * pagingGB, subID: "sub-alpha", uuid: "uuid-alpha", inbounds: []int{vless.Id}, lastOnline: now - 10*pagingDay}, + {email: "bravo@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: pagingGB, password: "pw-bravo", inbounds: []int{vless.Id, trojan.Id}, lastOnline: now - pagingDay}, + {email: "charlie@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10 * pagingGB, auth: "auth-charlie", inbounds: []int{trojan.Id}}, + {email: "delta@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, used: pagingGB, inbounds: []int{vless.Id}}, + {email: "echo@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, inbounds: []int{vless.Id}}, + {email: "foxtrot@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, inbounds: []int{trojan.Id}}, + {email: "golf@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 2*pagingDay, inbounds: []int{vless.Id}}, + {email: "hotel@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10*pagingGB - pagingGB/2, inbounds: []int{vless.Id}}, + {email: "india@x", enable: true, totalGB: 0, expiryTime: -5 * pagingDay, inbounds: []int{vless.Id}}, + {email: "juliet@x", enable: true, comment: " vip customer ", group: "VIP", tgID: 555, reset: 7, inbounds: []int{vless.Id}}, + {email: "kilo_1@x", enable: true, group: "vip", inbounds: nil}, + {email: "kilo1@x", enable: true, inbounds: []int{trojan.Id}}, + } + + for i, s := range seeds { + rec := model.ClientRecord{ + Email: s.email, + SubID: s.subID, + UUID: s.uuid, + Password: s.password, + Auth: s.auth, + Comment: s.comment, + Group: s.group, + TgID: s.tgID, + Reset: s.reset, + Enable: s.enable, + TotalGB: s.totalGB, + ExpiryTime: s.expiryTime, + CreatedAt: now - int64(len(seeds)-i)*pagingDay, + UpdatedAt: now - int64(i)*pagingDay, + } + if err := db.Create(&rec).Error; err != nil { + t.Fatalf("create client %s: %v", s.email, err) + } + if !s.enable { + // clients.enable carries a `default:true` tag, so GORM leaves the + // zero value out of the INSERT and the column comes back true. + // Restate updated_at so the autoUpdateTime hook cannot reshuffle + // the sort fixtures. + if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id). + Updates(map[string]any{"enable": false, "updated_at": rec.UpdatedAt}).Error; err != nil { + t.Fatalf("disable %s: %v", s.email, err) + } + } + traffic := xray.ClientTraffic{ + Email: s.email, + Enable: s.enable, + Up: s.used / 2, + Down: s.used - s.used/2, + Total: s.totalGB, + ExpiryTime: s.expiryTime, + LastOnline: s.lastOnline, + } + if err := db.Create(&traffic).Error; err != nil { + t.Fatalf("create traffic %s: %v", s.email, err) + } + for _, id := range s.inbounds { + if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: id}).Error; err != nil { + t.Fatalf("attach %s to %d: %v", s.email, id, err) + } + } + } + return now, seeds +} + +func pagedEmails(items []ClientSlim) []string { + out := make([]string, 0, len(items)) + for _, it := range items { + out = append(out, it.Email) + } + return out +} + +func setupPagingServices(t *testing.T) (*ClientService, *InboundService, *SettingService) { + t.Helper() + setupBulkDB(t) + settingSvc := &SettingService{} + if err := settingSvc.setInt("expireDiff", 3); err != nil { + t.Fatalf("set expireDiff: %v", err) + } + if err := settingSvc.setInt("trafficDiff", 1); err != nil { + t.Fatalf("set trafficDiff: %v", err) + } + return &ClientService{}, &InboundService{}, settingSvc +} + +func TestListPagedFilters(t *testing.T) { + svc, inboundSvc, settingSvc := setupPagingServices(t) + now, _ := seedPagingClients(t) + + tests := []struct { + name string + params ClientPageParams + want []string + }{ + { + name: "no filter returns every client in id order", + params: ClientPageParams{PageSize: 50}, + want: []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"}, + }, + { + name: "depleted bucket covers quota and expiry", + params: ClientPageParams{PageSize: 50, Filter: "depleted"}, + want: []string{"charlie@x", "delta@x", "foxtrot@x"}, + }, + { + name: "deactive bucket is every disabled client", + params: ClientPageParams{PageSize: 50, Filter: "deactive"}, + want: []string{"echo@x", "foxtrot@x"}, + }, + { + name: "expiring bucket covers near expiry and near quota", + params: ClientPageParams{PageSize: 50, Filter: "expiring"}, + want: []string{"golf@x", "hotel@x"}, + }, + { + name: "active bucket keeps enabled clients that still have room", + params: ClientPageParams{PageSize: 50, Filter: "active"}, + want: []string{"alpha@x", "bravo@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"}, + }, + { + name: "buckets are ORed", + params: ClientPageParams{PageSize: 50, Filter: "depleted,expiring"}, + want: []string{"charlie@x", "delta@x", "foxtrot@x", "golf@x", "hotel@x"}, + }, + { + name: "unknown bucket keeps matching everything", + params: ClientPageParams{PageSize: 50, Filter: "nonsense"}, + want: []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"}, + }, + { + name: "protocol filter follows the attachments", + params: ClientPageParams{PageSize: 50, Protocol: "trojan"}, + want: []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"}, + }, + { + name: "inbound filter follows the attachments", + params: ClientPageParams{PageSize: 50, Inbound: "2"}, + want: []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"}, + }, + { + name: "search matches the email", + params: ClientPageParams{PageSize: 50, Search: "KILO"}, + want: []string{"kilo_1@x", "kilo1@x"}, + }, + { + name: "search treats LIKE wildcards literally", + params: ClientPageParams{PageSize: 50, Search: "kilo_1"}, + want: []string{"kilo_1@x"}, + }, + { + name: "search matches the subId", + params: ClientPageParams{PageSize: 50, Search: "sub-alpha"}, + want: []string{"alpha@x"}, + }, + { + name: "search matches the uuid", + params: ClientPageParams{PageSize: 50, Search: "uuid-alpha"}, + want: []string{"alpha@x"}, + }, + { + name: "search matches the password", + params: ClientPageParams{PageSize: 50, Search: "pw-bravo"}, + want: []string{"bravo@x"}, + }, + { + name: "search matches the auth", + params: ClientPageParams{PageSize: 50, Search: "auth-charlie"}, + want: []string{"charlie@x"}, + }, + { + name: "search matches the comment", + params: ClientPageParams{PageSize: 50, Search: "vip customer"}, + want: []string{"juliet@x"}, + }, + { + name: "search matches the telegram id", + params: ClientPageParams{PageSize: 50, Search: "555"}, + want: []string{"juliet@x"}, + }, + { + name: "group filter is case insensitive", + params: ClientPageParams{PageSize: 50, Group: "vip"}, + want: []string{"juliet@x", "kilo_1@x"}, + }, + { + name: "hasComment yes", + params: ClientPageParams{PageSize: 50, HasComment: "yes"}, + want: []string{"juliet@x"}, + }, + { + name: "hasTgId yes", + params: ClientPageParams{PageSize: 50, HasTgID: "yes"}, + want: []string{"juliet@x"}, + }, + { + name: "autoRenew on", + params: ClientPageParams{PageSize: 50, AutoRenew: "on"}, + want: []string{"juliet@x"}, + }, + { + name: "usage range is inclusive on both bounds", + params: ClientPageParams{PageSize: 50, UsageFrom: pagingGB, UsageTo: 5 * pagingGB}, + want: []string{"alpha@x", "bravo@x", "delta@x"}, + }, + { + name: "expiry range excludes never and delayed start", + params: ClientPageParams{PageSize: 50, ExpiryFrom: now, ExpiryTo: now + 10*pagingDay}, + want: []string{"golf@x"}, + }, + { + name: "filters combine with AND", + params: ClientPageParams{PageSize: 50, Filter: "depleted", Protocol: "trojan"}, + want: []string{"charlie@x", "foxtrot@x"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resp, err := svc.ListPaged(inboundSvc, settingSvc, tc.params) + if err != nil { + t.Fatalf("ListPaged: %v", err) + } + got := pagedEmails(resp.Items) + if !slices.Equal(got, tc.want) { + t.Fatalf("emails = %v, want %v", got, tc.want) + } + if resp.Filtered != len(tc.want) { + t.Fatalf("filtered = %d, want %d", resp.Filtered, len(tc.want)) + } + if resp.Total != 12 { + t.Fatalf("total = %d, want 12", resp.Total) + } + }) + } +} + +func TestListPagedSorting(t *testing.T) { + svc, inboundSvc, settingSvc := setupPagingServices(t) + seedPagingClients(t) + + tests := []struct { + name string + sort string + order string + want []string + }{ + { + name: "no sort key keeps insertion order", + want: []string{"alpha@x", "bravo@x", "charlie@x"}, + }, + { + name: "email ascending", sort: "email", order: "ascend", + want: []string{"alpha@x", "bravo@x", "charlie@x"}, + }, + { + name: "email descending", sort: "email", order: "descend", + want: []string{"kilo_1@x", "kilo1@x", "juliet@x"}, + }, + { + name: "traffic descending", sort: "traffic", order: "descend", + want: []string{"charlie@x", "hotel@x", "alpha@x"}, + }, + { + name: "remaining descending puts unlimited quotas first", sort: "remaining", order: "descend", + want: []string{"alpha@x", "india@x", "juliet@x"}, + }, + { + name: "expiry ascending starts with the expired", sort: "expiryTime", order: "ascend", + want: []string{"delta@x", "foxtrot@x", "golf@x"}, + }, + { + name: "createdAt ascending follows insertion", sort: "createdAt", order: "ascend", + want: []string{"alpha@x", "bravo@x", "charlie@x"}, + }, + { + name: "updatedAt descending starts with the newest", sort: "updatedAt", order: "descend", + want: []string{"alpha@x", "bravo@x", "charlie@x"}, + }, + { + name: "lastOnline descending breaks ties on the id, reversed too", sort: "lastOnline", order: "descend", + want: []string{"bravo@x", "alpha@x", "kilo1@x"}, + }, + { + name: "enable ascending puts disabled first", sort: "enable", order: "ascend", + want: []string{"echo@x", "foxtrot@x", "alpha@x"}, + }, + { + name: "inboundIds descending puts the widest attachment first", sort: "inboundIds", order: "descend", + want: []string{"bravo@x", "alpha@x", "charlie@x"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 3, Sort: tc.sort, Order: tc.order}) + if err != nil { + t.Fatalf("ListPaged: %v", err) + } + got := pagedEmails(resp.Items) + if !slices.Equal(got, tc.want) { + t.Fatalf("emails = %v, want %v", got, tc.want) + } + }) + } +} + +func TestListPagedPagination(t *testing.T) { + svc, inboundSvc, settingSvc := setupPagingServices(t) + seedPagingClients(t) + + t.Run("second page continues where the first stopped", func(t *testing.T) { + resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 2, PageSize: 5, Sort: "email", Order: "ascend"}) + if err != nil { + t.Fatalf("ListPaged: %v", err) + } + want := []string{"foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x"} + if got := pagedEmails(resp.Items); !slices.Equal(got, want) { + t.Fatalf("emails = %v, want %v", got, want) + } + if resp.Page != 2 || resp.PageSize != 5 { + t.Fatalf("page/pageSize = %d/%d, want 2/5", resp.Page, resp.PageSize) + } + }) + + t.Run("page past the end is empty but keeps the counts", func(t *testing.T) { + resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 9, PageSize: 5}) + if err != nil { + t.Fatalf("ListPaged: %v", err) + } + if len(resp.Items) != 0 { + t.Fatalf("items = %v, want none", pagedEmails(resp.Items)) + } + if resp.Filtered != 12 || resp.Total != 12 { + t.Fatalf("filtered/total = %d/%d, want 12/12", resp.Filtered, resp.Total) + } + }) + + t.Run("page size is clamped to the maximum", func(t *testing.T) { + resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 5000}) + if err != nil { + t.Fatalf("ListPaged: %v", err) + } + if resp.PageSize != clientPageMaxSize { + t.Fatalf("pageSize = %d, want %d", resp.PageSize, clientPageMaxSize) + } + }) +} + +func TestListPagedRowContents(t *testing.T) { + svc, inboundSvc, settingSvc := setupPagingServices(t) + seedPagingClients(t) + + resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50}) + if err != nil { + t.Fatalf("ListPaged: %v", err) + } + byEmail := make(map[string]ClientSlim, len(resp.Items)) + for _, it := range resp.Items { + byEmail[it.Email] = it + } + + t.Run("attachments are reported in inbound order", func(t *testing.T) { + got := byEmail["bravo@x"].InboundIds + if !slices.Equal(got, []int{1, 2}) { + t.Fatalf("inboundIds = %v, want [1 2]", got) + } + }) + + t.Run("an unattached client reports no inbounds", func(t *testing.T) { + if got := byEmail["kilo_1@x"].InboundIds; len(got) != 0 { + t.Fatalf("inboundIds = %v, want none", got) + } + }) + + t.Run("traffic counters ride along with the row", func(t *testing.T) { + got := byEmail["hotel@x"].Traffic + if got == nil { + t.Fatal("traffic = nil, want the seeded counters") + } + if want := 10*pagingGB - pagingGB/2; got.Up+got.Down != want { + t.Fatalf("used = %d, want %d", got.Up+got.Down, want) + } + }) + + t.Run("groups list every name in use", func(t *testing.T) { + if !slices.Equal(resp.Groups, []string{"vip", "VIP"}) && !slices.Equal(resp.Groups, []string{"VIP", "vip"}) { + t.Fatalf("groups = %v, want VIP and vip", resp.Groups) + } + }) +} + +func TestListPagedSummary(t *testing.T) { + svc, inboundSvc, settingSvc := setupPagingServices(t) + seedPagingClients(t) + + resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 5, Filter: "depleted"}) + if err != nil { + t.Fatalf("ListPaged: %v", err) + } + s := resp.Summary + + t.Run("counts stay whole-panel while the page is filtered", func(t *testing.T) { + if s.Total != 12 { + t.Fatalf("total = %d, want 12", s.Total) + } + if s.DepletedCount != 3 { + t.Fatalf("depletedCount = %d, want 3", s.DepletedCount) + } + if s.ExpiringCount != 2 { + t.Fatalf("expiringCount = %d, want 2", s.ExpiringCount) + } + if s.DeactiveCount != 1 { + t.Fatalf("deactiveCount = %d, want 1", s.DeactiveCount) + } + if s.Active != 6 { + t.Fatalf("active = %d, want 6", s.Active) + } + }) + + t.Run("every client lands in exactly one counter", func(t *testing.T) { + if sum := s.Active + s.DepletedCount + s.ExpiringCount + s.DeactiveCount; sum != s.Total { + t.Fatalf("buckets sum to %d, want %d", sum, s.Total) + } + }) + + t.Run("bucket lists carry the matching emails", func(t *testing.T) { + if want := []string{"charlie@x", "delta@x", "foxtrot@x"}; !slices.Equal(s.Depleted, want) { + t.Fatalf("depleted = %v, want %v", s.Depleted, want) + } + if want := []string{"golf@x", "hotel@x"}; !slices.Equal(s.Expiring, want) { + t.Fatalf("expiring = %v, want %v", s.Expiring, want) + } + if want := []string{"echo@x"}; !slices.Equal(s.Deactive, want) { + t.Fatalf("deactive = %v, want %v", s.Deactive, want) + } + }) +} + +func TestListPagedSummaryEmailListsAreCapped(t *testing.T) { + svc, inboundSvc, settingSvc := setupPagingServices(t) + db := database.GetDB() + + const n = clientSummaryEmailCap + 25 + past := time.Now().UnixMilli() - pagingDay + for i := range n { + rec := model.ClientRecord{Email: "bulk-" + strconv.Itoa(i) + "@x", Enable: true, TotalGB: pagingGB, ExpiryTime: past} + if err := db.Create(&rec).Error; err != nil { + t.Fatalf("create client %d: %v", i, err) + } + } + + resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 25}) + if err != nil { + t.Fatalf("ListPaged: %v", err) + } + if resp.Summary.DepletedCount != n { + t.Fatalf("depletedCount = %d, want %d", resp.Summary.DepletedCount, n) + } + if len(resp.Summary.Depleted) != clientSummaryEmailCap { + t.Fatalf("depleted list = %d entries, want %d", len(resp.Summary.Depleted), clientSummaryEmailCap) + } +} + +func TestListPagedGlobalTrafficOverlay(t *testing.T) { + svc, inboundSvc, settingSvc := setupPagingServices(t) + seedPagingClients(t) + + // bravo has used 1GB of its 10GB locally; a master reporting 10GB of + // cross-panel usage has to move it into the depleted bucket. + if err := inboundSvc.AcceptGlobalTraffic("master-guid", []*xray.ClientTraffic{ + {Email: "bravo@x", Up: 4 * pagingGB, Down: 6 * pagingGB}, + }); err != nil { + t.Fatalf("AcceptGlobalTraffic: %v", err) + } + + resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50, Filter: "depleted"}) + if err != nil { + t.Fatalf("ListPaged: %v", err) + } + want := []string{"bravo@x", "charlie@x", "delta@x", "foxtrot@x"} + if got := pagedEmails(resp.Items); !slices.Equal(got, want) { + t.Fatalf("depleted = %v, want %v", got, want) + } + if resp.Summary.DepletedCount != 4 { + t.Fatalf("depletedCount = %d, want 4", resp.Summary.DepletedCount) + } + for _, it := range resp.Items { + if it.Email != "bravo@x" { + continue + } + if it.Traffic == nil || it.Traffic.Up+it.Traffic.Down != 10*pagingGB { + t.Fatalf("bravo traffic = %+v, want the overlaid 10GB", it.Traffic) + } + } +} + +func TestClientQueryOnlineEmails(t *testing.T) { + _, _, _ = setupPagingServices(t) + seedPagingClients(t) + + q := newClientQuery(database.GetDB(), time.Now().UnixMilli(), 0, 0) + emails, count, err := q.onlineEmails([]string{"alpha@x", "echo@x", "ghost@x", "kilo1@x"}) + if err != nil { + t.Fatalf("onlineEmails: %v", err) + } + if want := []string{"alpha@x", "kilo1@x"}; !slices.Equal(emails, want) { + t.Fatalf("online = %v, want %v (disabled and unknown emails drop out)", emails, want) + } + if count != 2 { + t.Fatalf("count = %d, want 2", count) + } +} + +func TestEmailInCondChunksLargeSets(t *testing.T) { + emails := make([]string, sqlInChunk+1) + for i := range emails { + emails[i] = "e" + strconv.Itoa(i) + } + + cond, args := emailInCond("c.email", emails) + if want := "(c.email IN ? OR c.email IN ?)"; cond != want { + t.Fatalf("cond = %q, want %q", cond, want) + } + if len(args) != 2 { + t.Fatalf("args = %d chunks, want 2", len(args)) + } + if first, ok := args[0].([]string); !ok || len(first) != sqlInChunk { + t.Fatalf("first chunk = %v, want %d entries", args[0], sqlInChunk) + } + + emptyCond, emptyArgs := emailInCond("c.email", nil) + if emptyCond != "1 = 0" || emptyArgs != nil { + t.Fatalf("empty set = %q/%v, want an always-false predicate", emptyCond, emptyArgs) + } +} + +func TestEscapeLikeLiteral(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"plain", "plain"}, + {"a_b", `a\_b`}, + {"50%", `50\%`}, + {`back\slash`, `back\\slash`}, + } + for _, tc := range tests { + if got := escapeLikeLiteral(tc.in); got != tc.want { + t.Fatalf("escapeLikeLiteral(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestListPagedEmptyPanel(t *testing.T) { + svc, inboundSvc, settingSvc := setupPagingServices(t) + + resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{}) + if err != nil { + t.Fatalf("ListPaged on a panel with no clients: %v", err) + } + if len(resp.Items) != 0 || resp.Total != 0 || resp.Filtered != 0 { + t.Fatalf("items/total/filtered = %d/%d/%d, want 0/0/0", len(resp.Items), resp.Total, resp.Filtered) + } + if resp.Summary.Active != 0 || resp.Summary.DepletedCount != 0 { + t.Fatalf("summary = %+v, want zeroed counters", resp.Summary) + } + if resp.Groups == nil { + t.Fatal("groups = nil, want an empty list so the filter drawer renders") + } +}