diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index a2089ad9..281194ab 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -54,12 +54,65 @@ interface SubSettings { subJsonEnable: boolean; } +export interface ClientQueryParams { + page: number; + pageSize: number; + search?: string; + filter?: string; + protocol?: string; + sort?: string; + order?: 'ascend' | 'descend'; +} + +export interface ClientsSummary { + total: number; + active: number; + online: string[]; + depleted: string[]; + expiring: string[]; + deactive: string[]; +} + +interface ClientPageResponse { + items: ClientRecord[]; + total: number; + filtered: number; + page: number; + pageSize: number; + summary?: ClientsSummary; +} + +const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 }; + export function useClients() { const [clients, setClients] = useState([]); + const [total, setTotal] = useState(0); + const [filtered, setFiltered] = useState(0); + const [summary, setSummary] = useState({ + total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [], + }); const [inbounds, setInbounds] = useState([]); const [onlines, setOnlines] = useState([]); const [loading, setLoading] = useState(false); const [fetched, setFetched] = useState(false); + const [query, setQueryState] = useState(DEFAULT_QUERY); + // Shallow-compare against the previous query so callers can pass a fresh + // object on 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.pageSize === next.pageSize + && (prev.search ?? '') === (next.search ?? '') + && (prev.filter ?? '') === (next.filter ?? '') + && (prev.protocol ?? '') === (next.protocol ?? '') + && (prev.sort ?? '') === (next.sort ?? '') + && (prev.order ?? '') === (next.order ?? '') + ) return prev; + return next; + }); + }, []); const [subSettings, setSubSettings] = useState({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false, }); @@ -70,22 +123,35 @@ export function useClients() { const [pageSize, setPageSize] = useState(0); const clientsRef = useRef([]); + const queryRef = useRef(query); const invalidateTimerRef = useRef(null); useEffect(() => { clientsRef.current = clients; }, [clients]); + useEffect(() => { queryRef.current = query; }, [query]); - const refresh = useCallback(async () => { + const buildQS = (p: ClientQueryParams) => { + const sp = new URLSearchParams(); + sp.set('page', String(p.page || 1)); + sp.set('pageSize', String(p.pageSize || DEFAULT_QUERY.pageSize)); + 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.sort) sp.set('sort', p.sort); + if (p.order) sp.set('order', p.order); + return sp.toString(); + }; + + const refresh = useCallback(async (override?: ClientQueryParams) => { setLoading(true); try { - const [clientsMsg, inboundsMsg] = await Promise.all([ - HttpUtil.get('/panel/api/clients/list') as Promise>, - HttpUtil.get('/panel/api/inbounds/options') as Promise>, - ]); - if (clientsMsg?.success) { - setClients(Array.isArray(clientsMsg.obj) ? clientsMsg.obj : []); - } - if (inboundsMsg?.success) { - setInbounds(Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : []); + const params = override ?? queryRef.current; + const qs = buildQS(params); + const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`) as ApiMsg; + if (msg?.success && msg.obj) { + setClients(Array.isArray(msg.obj.items) ? msg.obj.items : []); + setTotal(msg.obj.total ?? 0); + setFiltered(msg.obj.filtered ?? 0); + if (msg.obj.summary) setSummary(msg.obj.summary); } setFetched(true); } finally { @@ -93,6 +159,18 @@ export function useClients() { } }, []); + // Inbound options are picker-shaped and don't depend on the clients query — + // fetch them once on mount instead of every refresh. + useEffect(() => { + let cancelled = false; + (async () => { + const msg = await HttpUtil.get('/panel/api/inbounds/options') as ApiMsg; + if (cancelled) return; + if (msg?.success) setInbounds(Array.isArray(msg.obj) ? msg.obj : []); + })(); + return () => { cancelled = true; }; + }, []); + const fetchSubSettings = useCallback(async () => { const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg>; if (!msg?.success) return; @@ -110,6 +188,17 @@ export function useClients() { setPageSize((s.pageSize as number) ?? 0); }, []); + // hydrate fetches the full client record (uuid, password, flow, ...) for a + // single email. The paged list endpoint omits these to keep the row payload + // tiny; edit / info / qr / link modals call this to get a complete record + // before opening. + const hydrate = useCallback(async (email: string): Promise<{ client: ClientRecord; inboundIds: number[] } | null> => { + if (!email) return null; + const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`) as ApiMsg<{ client: ClientRecord; inboundIds: number[] }>; + if (!msg?.success || !msg.obj) return null; + return msg.obj; + }, []); + const create = useCallback(async (payload: unknown) => { const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as ApiMsg; if (msg?.success) await refresh(); @@ -258,13 +347,18 @@ export function useClients() { }, [refresh]); useEffect(() => { - - Promise.all([refresh(), fetchSubSettings()]); - - }, [refresh, fetchSubSettings]); + Promise.all([refresh(query), fetchSubSettings()]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, fetchSubSettings]); return { clients, + total, + filtered, + summary, + hydrate, + query, + setQuery, inbounds, onlines, loading, diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js index 1fca7994..4efeefb3 100644 --- a/frontend/src/pages/api-docs/endpoints.js +++ b/frontend/src/pages/api-docs/endpoints.js @@ -80,6 +80,13 @@ export const sections = [ response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": {\n "clients": [],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "tag": "inbound-443",\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n },\n "clientStats": []\n }\n ]\n}', }, + { + method: 'GET', + path: '/panel/api/inbounds/list/slim', + summary: 'Same shape as /list but with settings.clients[] stripped down to {email, enable, comment} and ClientStats not enriched with UUID/SubId. Use this for list pages; fetch /get/:id when you need the full per-client payload (uuid, password, flow, ...).', + response: + '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "remark": "VLESS-443",\n "settings": {\n "clients": [\n { "email": "alice", "enable": true }\n ],\n "decryption": "none"\n },\n "clientStats": []\n }\n ]\n}', + }, { method: 'GET', path: '/panel/api/inbounds/options', @@ -386,6 +393,22 @@ export const sections = [ response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "email": "alice@example.com",\n "subId": "abcd1234",\n "uuid": "...",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "enable": true,\n "reverse": null,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true }\n }\n ]\n}', }, + { + 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.', + 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.' }, + { name: 'search', in: 'query', type: 'string', desc: 'Case-insensitive substring match on email / subId / comment.' }, + { name: 'filter', in: 'query', type: 'string', desc: 'Status bucket: online | active | deactive | depleted | expiring.' }, + { name: 'protocol', in: 'query', type: 'string', desc: 'Match clients attached to at least one inbound of this protocol (vless, vmess, trojan, shadowsocks, ...).' }, + { name: 'sort', in: 'query', type: 'string', desc: 'Sort key: enable | email | inboundIds | traffic | remaining | expiryTime.' }, + { 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}', + }, { method: 'GET', path: '/panel/api/clients/get/:email', diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 788df288..06d015f7 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -49,7 +49,7 @@ import { useDatepicker } from '@/hooks/useDatepicker'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import AppSidebar from '@/components/AppSidebar'; import CustomStatistic from '@/components/CustomStatistic'; -import { IntlUtil, ObjectUtil, SizeFormatter } from '@/utils'; +import { IntlUtil, SizeFormatter } from '@/utils'; import { setMessageInstance } from '@/utils/messageBus'; import ClientFormModal from './ClientFormModal'; import ClientInfoModal from './ClientInfoModal'; @@ -96,11 +96,15 @@ export default function ClientsPage() { useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); const { - clients, inbounds, onlines, loading, fetched, subSettings, + clients, filtered, + summary: serverSummary, + setQuery, + inbounds, onlines, loading, fetched, subSettings, ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize, create, update, remove, removeMany, bulkAdjust, attach, detach, resetTraffic, resetAllTraffics, delDepleted, setEnable, applyTrafficEvent, applyClientStatsEvent, applyInvalidate, + hydrate, } = useClients(); useWebSocket({ @@ -131,7 +135,10 @@ export default function ClientsPage() { const [sortColumn, setSortColumn] = useState(null); const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null); const [currentPage, setCurrentPage] = useState(1); - const [tablePageSize, setTablePageSize] = useState(20); + const [tablePageSize, setTablePageSize] = useState(25); + // 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({ @@ -139,6 +146,29 @@ export default function ClientsPage() { })); }, [enableFilter, searchKey, filterBy, protocolFilter]); + useEffect(() => { + const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300); + return () => window.clearTimeout(handle); + }, [searchKey]); + + useEffect(() => { + // 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, sortColumn, sortOrder]); + + useEffect(() => { + setQuery({ + page: currentPage, + pageSize: tablePageSize, + search: enableFilter ? '' : debouncedSearch, + filter: enableFilter ? (filterBy || '') : '', + protocol: protocolFilter || '', + sort: sortColumn || undefined, + order: sortOrder || undefined, + }); + }, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, sortColumn, sortOrder]); + useEffect(() => { if (pageSize > 0) { @@ -192,81 +222,18 @@ export default function ClientsPage() { } } - function clientMatchesProtocol(row: ClientRecord, protocol?: string) { - if (!protocol) return true; - const ids = Array.isArray(row.inboundIds) ? row.inboundIds : []; - for (const id of ids) { - const ib = inboundsById[id]; - if (ib && ib.protocol === protocol) return true; - } - return false; - } + // The list page renders rows the server already sorted, filtered, and + // paginated. Local filtering is gone — keep the variable name so the rest + // of the file (table dataSource, mobile cards, select-all) doesn't need + // a rename. + const filteredClients = clients; - const filteredClients = useMemo(() => { - let rows = clients || []; - if (enableFilter) { - if (filterBy === 'online') { - rows = rows.filter((r) => r.enable && isOnline(r.email)); - } else if (filterBy) { - rows = rows.filter((r) => clientBucket(r) === filterBy); - } - } else if (!ObjectUtil.isEmpty(searchKey)) { - rows = rows.filter((r) => ObjectUtil.deepSearch(r, searchKey)); - } - if (protocolFilter) { - rows = rows.filter((r) => clientMatchesProtocol(r, protocolFilter)); - } - return rows; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [clients, enableFilter, filterBy, searchKey, protocolFilter, clientBucket]); + // Server-computed counts that stay stable as the user paginates/filters. + const summary = serverSummary; - const summary = useMemo(() => { - const rows = clients || []; - const deactive: string[] = []; - const depleted: string[] = []; - const expiring: string[] = []; - const online: string[] = []; - let active = 0; - for (const row of rows) { - const bucket = clientBucket(row); - if (bucket === 'deactive') deactive.push(row.email); - else if (bucket === 'depleted') depleted.push(row.email); - else if (bucket === 'expiring') expiring.push(row.email); - else if (bucket === 'active') active++; - if (row.enable && isOnline(row.email)) online.push(row.email); - } - return { total: rows.length, active, deactive, depleted, expiring, online }; - }, [clients, clientBucket, isOnline]); - - const sortFns: Record number> = { - enable: (a, b) => Number(a.enable) - Number(b.enable), - email: (a, b) => (a.email || '').localeCompare(b.email || ''), - inboundIds: (a, b) => (a.inboundIds?.length || 0) - (b.inboundIds?.length || 0), - traffic: (a, b) => { - const ua = (a.traffic?.up || 0) + (a.traffic?.down || 0); - const ub = (b.traffic?.up || 0) + (b.traffic?.down || 0); - return ua - ub; - }, - remaining: (a, b) => { - const ra = (a.totalGB || 0) > 0 ? (a.totalGB || 0) - ((a.traffic?.up || 0) + (a.traffic?.down || 0)) : Infinity; - const rb = (b.totalGB || 0) > 0 ? (b.totalGB || 0) - ((b.traffic?.up || 0) + (b.traffic?.down || 0)) : Infinity; - return ra - rb; - }, - expiryTime: (a, b) => { - const ea = (a.expiryTime ?? 0) > 0 ? (a.expiryTime ?? 0) : Infinity; - const eb = (b.expiryTime ?? 0) > 0 ? (b.expiryTime ?? 0) : Infinity; - return ea - eb; - }, - }; - - const sortedClients = useMemo(() => { - if (!sortColumn || !sortOrder) return filteredClients; - const fn = sortFns[sortColumn]; - if (!fn) return filteredClients; - const sorted = [...filteredClients].sort(fn); - return sortOrder === 'descend' ? sorted.reverse() : sorted; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filteredClients, sortColumn, sortOrder]); + // Sort is server-side now; the page already arrives in the requested + // order, so we just hand it through. + const sortedClients = filteredClients; function trafficLabel(row: ClientRecord) { const t0 = row.traffic; @@ -341,10 +308,15 @@ export default function ClientsPage() { setFormOpen(true); } - function onEdit(row: ClientRecord) { + async function onEdit(row: ClientRecord) { setFormMode('edit'); - setEditingClient({ ...row }); - setEditingAttachedIds(Array.isArray(row.inboundIds) ? [...row.inboundIds] : []); + // Paged list omits per-client secrets to keep the row payload tiny; + // edit needs them, so fetch the full record first. + const full = await hydrate(row.email); + const merged: ClientRecord = full ? { ...row, ...full.client } : { ...row }; + setEditingClient(merged); + const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []); + setEditingAttachedIds([...ids]); setFormOpen(true); } @@ -379,13 +351,15 @@ export default function ClientsPage() { }); } - function onShowInfo(row: ClientRecord) { - setInfoClient(row); + async function onShowInfo(row: ClientRecord) { + const full = await hydrate(row.email); + setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row); setInfoOpen(true); } - function onShowQr(row: ClientRecord) { - setQrClient(row); + async function onShowQr(row: ClientRecord) { + const full = await hydrate(row.email); + setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row); setQrOpen(true); } @@ -595,10 +569,11 @@ export default function ClientsPage() { const tablePagination = { current: currentPage, pageSize: tablePageSize, - total: sortedClients.length, - showSizeChanger: sortedClients.length > 10, - pageSizeOptions: ['10', '20', '50', '100'], - hideOnSinglePage: sortedClients.length <= tablePageSize, + total: filtered, + showSizeChanger: filtered > 10, + pageSizeOptions: ['10', '25', '50', '100', '200'], + hideOnSinglePage: filtered <= tablePageSize, + showTotal: (n: number) => `${n}`, }; const rowSelection = { diff --git a/frontend/src/pages/inbounds/InboundFormModal.tsx b/frontend/src/pages/inbounds/InboundFormModal.tsx index 2eaaa852..6c7213ec 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/InboundFormModal.tsx @@ -60,7 +60,7 @@ import { DBInbound } from '@/models/dbinbound.js'; import FinalMaskForm from '@/components/FinalMaskForm'; import DateTimePicker from '@/components/DateTimePicker'; import JsonEditor from '@/components/JsonEditor'; -import { useNodes, type NodeRecord } from '@/hooks/useNodes'; +import type { NodeRecord } from '@/hooks/useNodes'; import './InboundFormModal.css'; const { TextArea } = Input; @@ -73,6 +73,7 @@ interface InboundFormModalProps { mode: 'add' | 'edit'; dbInbound: any; dbInbounds: any[]; + availableNodes?: NodeRecord[]; } const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly']; @@ -156,10 +157,10 @@ export default function InboundFormModal({ mode, dbInbound, dbInbounds, + availableNodes, }: InboundFormModalProps) { const { t } = useTranslation(); const [messageApi, messageContextHolder] = message.useMessage(); - const { nodes: availableNodes } = useNodes(); const selectableNodes = useMemo( () => (availableNodes || []).filter((n: NodeRecord) => n.enable), [availableNodes], diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index cde83b53..27323fe4 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -72,6 +72,7 @@ export default function InboundsPage() { ipLimitEnable, remarkModel, refresh, + hydrateInbound, fetchDefaultSettings, applyTrafficEvent, applyClientStatsEvent, @@ -259,18 +260,24 @@ export default function InboundsPage() { }); }, [subSettings, openText]); - const exportAllLinks = useCallback(() => { + const exportAllLinks = useCallback(async () => { + const hydrated = await Promise.all( + (dbInbounds as any[]).map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)), + ); const out: string[] = []; - for (const ib of dbInbounds as any[]) { + for (const ib of hydrated) { const projected = checkFallback(ib); out.push(projected.genInboundLinks(remarkModel, hostOverrideFor(ib))); } openText({ title: 'Export all inbound links', content: out.join('\r\n'), fileName: 'All-Inbounds' }); - }, [dbInbounds, checkFallback, remarkModel, hostOverrideFor, openText]); + }, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, openText]); - const exportAllSubs = useCallback(() => { + const exportAllSubs = useCallback(async () => { + const hydrated = await Promise.all( + (dbInbounds as any[]).map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)), + ); const out: string[] = []; - for (const ib of dbInbounds as any[]) { + for (const ib of hydrated) { const inbound = ib.toInbound(); const clients = inbound?.clients || []; for (const c of clients) { @@ -280,7 +287,7 @@ export default function InboundsPage() { } } openText({ title: 'Export all subscription links', content: [...new Set(out)].join('\r\n'), fileName: 'All-Inbounds-Subs' }); - }, [dbInbounds, subSettings, openText]); + }, [dbInbounds, hydrateInbound, subSettings, openText]); const importInbound = useCallback(() => { openPrompt({ @@ -395,42 +402,51 @@ export default function InboundsPage() { } }, [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi]); - const onRowAction = useCallback(({ key, dbInbound }: { key: RowAction; dbInbound: any }) => { + const onRowAction = useCallback(async ({ key, dbInbound }: { key: RowAction; dbInbound: any }) => { + // Actions that touch per-client secrets (uuid, password, flow, ...) need + // the full payload that the slim list view does not ship. Hydrate first + // and then operate on the rehydrated record. + const hydratingKeys: RowAction[] = ['edit', 'showInfo', 'qrcode', 'export', 'subs', 'clipboard', 'clone']; + let target = dbInbound; + if (hydratingKeys.includes(key)) { + const hydrated = await hydrateInbound(dbInbound.id); + if (hydrated) target = hydrated; + } switch (key) { case 'edit': - openEdit(dbInbound); + openEdit(target); break; case 'showInfo': - setInfoDbInbound(checkFallback(dbInbound)); - setInfoClientIndex(findClientIndex(dbInbound, null)); + setInfoDbInbound(checkFallback(target)); + setInfoClientIndex(findClientIndex(target, null)); setInfoOpen(true); break; case 'qrcode': - setQrDbInbound(checkFallback(dbInbound)); + setQrDbInbound(checkFallback(target)); setQrOpen(true); break; case 'export': - exportInboundLinks(dbInbound); + exportInboundLinks(target); break; case 'subs': - exportInboundSubs(dbInbound); + exportInboundSubs(target); break; case 'clipboard': - exportInboundClipboard(dbInbound); + exportInboundClipboard(target); break; case 'delete': - confirmDelete(dbInbound); + confirmDelete(target); break; case 'resetTraffic': - confirmResetTraffic(dbInbound); + confirmResetTraffic(target); break; case 'clone': - confirmClone(dbInbound); + confirmClone(target); break; default: messageApi.info(`Action "${key}" — coming in a later 5f subphase`); } - }, [openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmClone, messageApi]); + }, [hydrateInbound, openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmClone, messageApi]); const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || ''; const requestUri = typeof window !== 'undefined' ? window.location.pathname : ''; @@ -508,6 +524,7 @@ export default function InboundsPage() { mode={formMode} dbInbound={formDbInbound} dbInbounds={dbInbounds as any[]} + availableNodes={nodesList} /> { + const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`); + if (!msg?.success || !msg.obj) return null; + const full = msg.obj as { id: number; protocol: string }; + const dbInbound = new DBInbound(full) as DBInboundInstance; + setDbInbounds((prev) => { + const next = prev.map((row) => ( + (row as unknown as { id: number }).id === id ? dbInbound : row + )); + dbInboundsRef.current = next; + return next; + }); + rebuildClientCount(); + return dbInbound; + }, [rebuildClientCount]); + const applyTrafficEvent = useCallback( (payload: unknown) => { if (!payload || typeof payload !== 'object') return; @@ -340,6 +360,7 @@ export function useInbounds() { ipLimitEnable, pageSize, refresh, + hydrateInbound, fetchDefaultSettings, applyTrafficEvent, applyClientStatsEvent, diff --git a/frontend/src/pages/settings/GeneralTab.tsx b/frontend/src/pages/settings/GeneralTab.tsx index e620c724..a8c20cd6 100644 --- a/frontend/src/pages/settings/GeneralTab.tsx +++ b/frontend/src/pages/settings/GeneralTab.tsx @@ -38,7 +38,9 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp useEffect(() => { let cancelled = false; (async () => { - const msg = await HttpUtil.get('/panel/api/inbounds/list') as ApiMsg<{ + // /options is the slim picker-shaped endpoint — it skips the heavy + // per-client settings and clientStats payloads that /list ships. + const msg = await HttpUtil.get('/panel/api/inbounds/options') as ApiMsg<{ tag: string; protocol: string; port: number; }[]>; if (cancelled) return; diff --git a/web/controller/client.go b/web/controller/client.go index fe567488..b2d1837b 100644 --- a/web/controller/client.go +++ b/web/controller/client.go @@ -20,6 +20,7 @@ type ClientController struct { clientService service.ClientService inboundService service.InboundService xrayService service.XrayService + settingService service.SettingService } func NewClientController(g *gin.RouterGroup) *ClientController { @@ -30,6 +31,7 @@ func NewClientController(g *gin.RouterGroup) *ClientController { func (a *ClientController) initRouter(g *gin.RouterGroup) { g.GET("/list", a.list) + g.GET("/list/paged", a.listPaged) g.GET("/get/:email", a.get) g.GET("/traffic/:email", a.getTrafficByEmail) g.GET("/subLinks/:subId", a.getSubLinks) @@ -60,6 +62,20 @@ func (a *ClientController) list(c *gin.Context) { jsonObj(c, rows, nil) } +func (a *ClientController) listPaged(c *gin.Context) { + var params service.ClientPageParams + if err := c.ShouldBindQuery(¶ms); err != nil { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) + return + } + resp, err := a.clientService.ListPaged(&a.inboundService, &a.settingService, params) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) + return + } + jsonObj(c, resp, nil) +} + func (a *ClientController) get(c *gin.Context) { email := c.Param("email") rec, err := a.clientService.GetRecordByEmail(nil, email) diff --git a/web/controller/inbound.go b/web/controller/inbound.go index 383121fa..a74d6e6e 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -61,6 +61,7 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) { func (a *InboundController) initRouter(g *gin.RouterGroup) { g.GET("/list", a.getInbounds) + g.GET("/list/slim", a.getInboundsSlim) g.GET("/options", a.getInboundOptions) g.GET("/get/:id", a.getInbound) g.GET("/:id/fallbacks", a.getFallbacks) @@ -86,6 +87,18 @@ func (a *InboundController) getInbounds(c *gin.Context) { jsonObj(c, inbounds, nil) } +// getInboundsSlim is the list-page variant that strips full client +// payloads from settings.clients[]. Detail-view flows still use /get/:id. +func (a *InboundController) getInboundsSlim(c *gin.Context) { + user := session.GetLoginUser(c) + inbounds, err := a.inboundService.GetInboundsSlim(user.Id) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) + return + } + jsonObj(c, inbounds, nil) +} + // getInboundOptions returns a lightweight projection of the user's inbounds // (id, remark, protocol, port, tlsFlowCapable) for pickers in the clients UI. // Avoids shipping per-client settings and traffic stats just to fill a dropdown. diff --git a/web/service/client.go b/web/service/client.go index d9d726e4..92b27566 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "sort" "strings" "sync" "time" @@ -803,6 +804,351 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st return needRestart, nil } +// ClientSlim is the row-shape used by the clients page. It drops fields the +// table never reads (UUID, password, auth, flow, security, reverse, tgId) +// so the list payload stays compact even when the panel manages thousands +// of clients. Modals that need the full record still call /get/:email. +type ClientSlim struct { + Email string `json:"email"` + SubID string `json:"subId"` + Enable bool `json:"enable"` + TotalGB int64 `json:"totalGB"` + ExpiryTime int64 `json:"expiryTime"` + LimitIP int `json:"limitIp"` + Reset int `json:"reset"` + Comment string `json:"comment,omitempty"` + InboundIds []int `json:"inboundIds"` + Traffic *xray.ClientTraffic `json:"traffic,omitempty"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +// ClientPageParams are the query params accepted by /panel/api/clients/list/paged. +// All fields are optional — the empty value means "no filter" / defaults. +type ClientPageParams struct { + Page int `form:"page"` + PageSize int `form:"pageSize"` + Search string `form:"search"` + Filter string `form:"filter"` + Protocol string `form:"protocol"` + Sort string `form:"sort"` + Order string `form:"order"` +} + +// ClientPageResponse is the shape returned by ListPaged. `Total` is the +// row count in the DB; `Filtered` is the count after Search/Filter/Protocol +// were applied, before pagination. The page contains at most PageSize items. +// Summary is computed across the full DB row set so dashboard counters +// on the clients page stay stable as the user paginates/filters. +type ClientPageResponse struct { + Items []ClientSlim `json:"items"` + Total int `json:"total"` + Filtered int `json:"filtered"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Summary ClientsSummary `json:"summary"` +} + +// 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. +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"` +} + +const ( + clientPageDefaultSize = 25 + clientPageMaxSize = 200 +) + +// 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 + } + total := len(all) + + pageSize := params.PageSize + if pageSize <= 0 { + pageSize = clientPageDefaultSize + } + if pageSize > clientPageMaxSize { + pageSize = clientPageMaxSize + } + page := params.Page + if page <= 0 { + page = 1 + } + + var protocolByInbound map[int]string + if params.Protocol != "" { + 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 { + expireDiffMs = int64(v) * 86400000 + } + if v, err := settingSvc.GetTrafficDiff(); err == nil { + trafficDiffBytes = int64(v) * 1073741824 + } + } + + nowMs := time.Now().UnixMilli() + summary := buildClientsSummary(all, onlineSet, nowMs, 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 params.Protocol != "" && !clientMatchesProtocol(c, params.Protocol, protocolByInbound) { + continue + } + if params.Filter != "" && !clientMatchesBucket(c, params.Filter, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) { + continue + } + filtered = append(filtered, c) + } + + 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)) + } + + return &ClientPageResponse{ + Items: items, + Total: total, + Filtered: filteredCount, + Page: page, + PageSize: pageSize, + Summary: summary, + }, nil +} + +func buildClientsSummary(all []ClientWithAttachments, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) ClientsSummary { + s := ClientsSummary{ + Total: len(all), + 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 + } + 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) + } + } + 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++ + } + } + return s +} + +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, + Comment: c.Comment, + InboundIds: c.InboundIds, + Traffic: c.Traffic, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } +} + +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 { + 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 + } + return false + } + sort.SliceStable(rows, func(i, j int) bool { + if desc { + return less(j, i) + } + return less(i, j) + }) +} + // BulkAdjustResult is returned by BulkAdjust to report how many clients were // successfully updated and which were skipped (typically because the field // being adjusted was unlimited for that client) or failed. diff --git a/web/service/inbound.go b/web/service/inbound.go index 0a996952..9064fa70 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -135,6 +135,71 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) { return inbounds, nil } +// GetInboundsSlim returns the same list of inbounds as GetInbounds but +// strips every per-client field other than email / enable / comment from +// settings.clients and skips UUID/SubId enrichment on ClientStats. The +// inbounds page only needs those three to roll up client counts and +// render badges, so this trims tens of bytes per client (UUID, password, +// flow, security, totalGB, expiryTime, limitIp, tgId, ...) which adds +// up fast on installs with thousands of clients. +// +// Full client data is still available through GET /panel/api/inbounds/get/:id +// for the edit/info/qr/export/clone flows that need it. +func (s *InboundService) GetInboundsSlim(userId int) ([]*model.Inbound, error) { + db := database.GetDB() + var inbounds []*model.Inbound + err := db.Model(model.Inbound{}).Preload("ClientStats").Where("user_id = ?", userId).Find(&inbounds).Error + if err != nil && err != gorm.ErrRecordNotFound { + return nil, err + } + s.annotateFallbackParents(db, inbounds) + for _, ib := range inbounds { + ib.Settings = slimSettingsClients(ib.Settings) + } + return inbounds, nil +} + +// slimSettingsClients rewrites the inbound settings JSON so settings.clients[] +// keeps only the fields the list view actually reads. Returns the input +// unchanged when the JSON can't be parsed or has no clients array. +func slimSettingsClients(settings string) string { + if settings == "" { + return settings + } + var raw map[string]any + if err := json.Unmarshal([]byte(settings), &raw); err != nil { + return settings + } + clients, ok := raw["clients"].([]any) + if !ok || len(clients) == 0 { + return settings + } + slim := make([]any, 0, len(clients)) + for _, entry := range clients { + c, ok := entry.(map[string]any) + if !ok { + continue + } + row := make(map[string]any, 3) + if v, ok := c["email"]; ok { + row["email"] = v + } + if v, ok := c["enable"]; ok { + row["enable"] = v + } + if v, ok := c["comment"]; ok && v != "" { + row["comment"] = v + } + slim = append(slim, row) + } + raw["clients"] = slim + out, err := json.Marshal(raw) + if err != nil { + return settings + } + return string(out) +} + // annotateFallbackParents fills FallbackParent on each inbound that is // the child side of a fallback rule. One DB round-trip serves the full // list — the frontend needs this to rewrite the child's client-share @@ -177,6 +242,7 @@ func (s *InboundService) annotateFallbackParents(db *gorm.DB, inbounds []*model. type InboundOption struct { Id int `json:"id"` Remark string `json:"remark"` + Tag string `json:"tag"` Protocol string `json:"protocol"` Port int `json:"port"` TlsFlowCapable bool `json:"tlsFlowCapable"` @@ -191,12 +257,13 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) var rows []struct { Id int `gorm:"column:id"` Remark string `gorm:"column:remark"` + Tag string `gorm:"column:tag"` Protocol string `gorm:"column:protocol"` Port int `gorm:"column:port"` StreamSettings string `gorm:"column:stream_settings"` } err := db.Table("inbounds"). - Select("id, remark, protocol, port, stream_settings"). + Select("id, remark, tag, protocol, port, stream_settings"). Where("user_id = ?", userId). Order("id ASC"). Scan(&rows).Error @@ -208,6 +275,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) out = append(out, InboundOption{ Id: r.Id, Remark: r.Remark, + Tag: r.Tag, Protocol: r.Protocol, Port: r.Port, TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings),