Reduce list-page payloads with slim/paged endpoints (#4500)

* perf(inbounds): slim list payload + lazy hydrate for row actions

Adds GET /panel/api/inbounds/list/slim that returns the same list shape
but strips every per-client field besides email/enable/comment from
settings.clients[] and skips UUID/SubId enrichment on ClientStats.
The inbounds page only reads those three to compute its client counters
and badges, so the slim variant trims tens of bytes per client (uuid,
password, flow, security, totalGB, expiryTime, limitIp, tgId, ...).
On a panel with thousands of clients this is the dominant load-time
cost.

Detail flows (edit / info / qr / export / clone) call /get/:id through
a new hydrateInbound helper before opening — the slim list view never
needs the secrets it doesn't render.

* perf(clients): server-side pagination + slim row payload

Adds GET /panel/api/clients/list/paged that filters, sorts, and paginates
on the server, returns a slim row shape (drops uuid/password/auth/flow/
security/reverse/tgId per client), and includes a stable summary
(total, active, online[], depleted[], expiring[], deactive[]) computed
across the full DB row set so the dashboard cards don't change as the
user paginates or filters. Page size capped at 200.

useClients now exposes { clients (current page), total, filtered, query,
setQuery, summary, hydrate }. ClientsPage feeds its filter/sort/page
state into setQuery via a single effect, debounces search by 300ms, and
hydrates the full client record via /get/:email before opening edit/info/
qr modals. Local filter/sort logic and the all-clients summary memo are
gone.

On a 2000-client panel this turns the initial response from ~MB to ~25 row
slice (~10s of KB) and removes the all-client parse cost from every
refresh.

* perf(settings): use /inbounds/options for LDAP tag picker

The General settings tab only needs each inbound's tag/protocol/port to
fill a dropdown but was calling /panel/api/inbounds/list which ships the
full settings JSON with every embedded client. Switched it to /options
and added Tag to the projection. On a panel with thousands of clients
this drops the General-tab load payload from megabytes to a tiny
per-inbound row each.

* perf(clients): de-duplicate options + paged list fetches

Two issues caused each clients-page load to fire its requests twice:

1. setQuery in the hook took whatever object the consumer passed and
   stored it as-is. The consumer (ClientsPage) constructs a new object
   literal in an effect, so even when nothing actually changed the ref
   was new — the hook's useEffect saw a new query and re-fetched.
   Wrapped setQuery with a shallow value compare so identical params
   are a no-op.

2. The picker /inbounds/options fetch was bundled into refresh() with a
   length==0 guard, but the two back-to-back refreshes both saw an
   empty inbounds array (the first hadn't resolved yet) so both fired
   the request. Moved the options fetch into its own one-shot effect.

* perf(inbounds): share nodes list with form modal instead of refetching

InboundsPage and InboundFormModal both called useNodes() — each
instance maintains its own state and fires its own /panel/api/nodes/list
fetch on mount. Since the modal is always rendered (open or not), every
page load hit the endpoint twice.

Threaded nodes from the page through an availableNodes prop on the form
modal so they share one fetch.

* docs(api): register /clients/list/paged endpoint

TestAPIRoutesDocumented was failing because the new paginated clients
endpoint added in this branch wasn't listed in endpoints.js.
This commit is contained in:
Sanaei
2026-05-23 17:43:43 +02:00
committed by GitHub
parent 9c60ed7ea8
commit c5b71041d3
11 changed files with 700 additions and 124 deletions

View File

@@ -54,12 +54,65 @@ interface SubSettings {
subJsonEnable: boolean; 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() { export function useClients() {
const [clients, setClients] = useState<ClientRecord[]>([]); const [clients, setClients] = useState<ClientRecord[]>([]);
const [total, setTotal] = useState(0);
const [filtered, setFiltered] = useState(0);
const [summary, setSummary] = useState<ClientsSummary>({
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
});
const [inbounds, setInbounds] = useState<InboundOption[]>([]); const [inbounds, setInbounds] = useState<InboundOption[]>([]);
const [onlines, setOnlines] = useState<string[]>([]); const [onlines, setOnlines] = useState<string[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(false); const [fetched, setFetched] = useState(false);
const [query, setQueryState] = useState<ClientQueryParams>(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<SubSettings>({ const [subSettings, setSubSettings] = useState<SubSettings>({
enable: false, subURI: '', subJsonURI: '', subJsonEnable: false, enable: false, subURI: '', subJsonURI: '', subJsonEnable: false,
}); });
@@ -70,22 +123,35 @@ export function useClients() {
const [pageSize, setPageSize] = useState(0); const [pageSize, setPageSize] = useState(0);
const clientsRef = useRef<ClientRecord[]>([]); const clientsRef = useRef<ClientRecord[]>([]);
const queryRef = useRef<ClientQueryParams>(query);
const invalidateTimerRef = useRef<number | null>(null); const invalidateTimerRef = useRef<number | null>(null);
useEffect(() => { clientsRef.current = clients; }, [clients]); 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); setLoading(true);
try { try {
const [clientsMsg, inboundsMsg] = await Promise.all([ const params = override ?? queryRef.current;
HttpUtil.get('/panel/api/clients/list') as Promise<ApiMsg<ClientRecord[]>>, const qs = buildQS(params);
HttpUtil.get('/panel/api/inbounds/options') as Promise<ApiMsg<InboundOption[]>>, const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`) as ApiMsg<ClientPageResponse>;
]); if (msg?.success && msg.obj) {
if (clientsMsg?.success) { setClients(Array.isArray(msg.obj.items) ? msg.obj.items : []);
setClients(Array.isArray(clientsMsg.obj) ? clientsMsg.obj : []); setTotal(msg.obj.total ?? 0);
} setFiltered(msg.obj.filtered ?? 0);
if (inboundsMsg?.success) { if (msg.obj.summary) setSummary(msg.obj.summary);
setInbounds(Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : []);
} }
setFetched(true); setFetched(true);
} finally { } 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<InboundOption[]>;
if (cancelled) return;
if (msg?.success) setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
})();
return () => { cancelled = true; };
}, []);
const fetchSubSettings = useCallback(async () => { const fetchSubSettings = useCallback(async () => {
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg<Record<string, unknown>>; const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg<Record<string, unknown>>;
if (!msg?.success) return; if (!msg?.success) return;
@@ -110,6 +188,17 @@ export function useClients() {
setPageSize((s.pageSize as number) ?? 0); 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 create = useCallback(async (payload: unknown) => {
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as ApiMsg; const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as ApiMsg;
if (msg?.success) await refresh(); if (msg?.success) await refresh();
@@ -258,13 +347,18 @@ export function useClients() {
}, [refresh]); }, [refresh]);
useEffect(() => { useEffect(() => {
Promise.all([refresh(query), fetchSubSettings()]);
Promise.all([refresh(), fetchSubSettings()]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, fetchSubSettings]);
}, [refresh, fetchSubSettings]);
return { return {
clients, clients,
total,
filtered,
summary,
hydrate,
query,
setQuery,
inbounds, inbounds,
onlines, onlines,
loading, loading,

View File

@@ -80,6 +80,13 @@ export const sections = [
response: 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}', '{\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', method: 'GET',
path: '/panel/api/inbounds/options', path: '/panel/api/inbounds/options',
@@ -386,6 +393,22 @@ export const sections = [
response: 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}', '{\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', method: 'GET',
path: '/panel/api/clients/get/:email', path: '/panel/api/clients/get/:email',

View File

@@ -49,7 +49,7 @@ import { useDatepicker } from '@/hooks/useDatepicker';
import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import AppSidebar from '@/components/AppSidebar'; import AppSidebar from '@/components/AppSidebar';
import CustomStatistic from '@/components/CustomStatistic'; import CustomStatistic from '@/components/CustomStatistic';
import { IntlUtil, ObjectUtil, SizeFormatter } from '@/utils'; import { IntlUtil, SizeFormatter } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus'; import { setMessageInstance } from '@/utils/messageBus';
import ClientFormModal from './ClientFormModal'; import ClientFormModal from './ClientFormModal';
import ClientInfoModal from './ClientInfoModal'; import ClientInfoModal from './ClientInfoModal';
@@ -96,11 +96,15 @@ export default function ClientsPage() {
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const { const {
clients, inbounds, onlines, loading, fetched, subSettings, clients, filtered,
summary: serverSummary,
setQuery,
inbounds, onlines, loading, fetched, subSettings,
ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize, ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
create, update, remove, removeMany, bulkAdjust, attach, detach, create, update, remove, removeMany, bulkAdjust, attach, detach,
resetTraffic, resetAllTraffics, delDepleted, setEnable, resetTraffic, resetAllTraffics, delDepleted, setEnable,
applyTrafficEvent, applyClientStatsEvent, applyInvalidate, applyTrafficEvent, applyClientStatsEvent, applyInvalidate,
hydrate,
} = useClients(); } = useClients();
useWebSocket({ useWebSocket({
@@ -131,7 +135,10 @@ export default function ClientsPage() {
const [sortColumn, setSortColumn] = useState<string | null>(null); const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null); const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null);
const [currentPage, setCurrentPage] = useState(1); 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(() => { useEffect(() => {
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
@@ -139,6 +146,29 @@ export default function ClientsPage() {
})); }));
}, [enableFilter, searchKey, filterBy, protocolFilter]); }, [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(() => { useEffect(() => {
if (pageSize > 0) { if (pageSize > 0) {
@@ -192,81 +222,18 @@ export default function ClientsPage() {
} }
} }
function clientMatchesProtocol(row: ClientRecord, protocol?: string) { // The list page renders rows the server already sorted, filtered, and
if (!protocol) return true; // paginated. Local filtering is gone — keep the variable name so the rest
const ids = Array.isArray(row.inboundIds) ? row.inboundIds : []; // of the file (table dataSource, mobile cards, select-all) doesn't need
for (const id of ids) { // a rename.
const ib = inboundsById[id]; const filteredClients = clients;
if (ib && ib.protocol === protocol) return true;
}
return false;
}
const filteredClients = useMemo(() => { // Server-computed counts that stay stable as the user paginates/filters.
let rows = clients || []; const summary = serverSummary;
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]);
const summary = useMemo(() => { // Sort is server-side now; the page already arrives in the requested
const rows = clients || []; // order, so we just hand it through.
const deactive: string[] = []; const sortedClients = filteredClients;
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<string, (a: ClientRecord, b: ClientRecord) => 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]);
function trafficLabel(row: ClientRecord) { function trafficLabel(row: ClientRecord) {
const t0 = row.traffic; const t0 = row.traffic;
@@ -341,10 +308,15 @@ export default function ClientsPage() {
setFormOpen(true); setFormOpen(true);
} }
function onEdit(row: ClientRecord) { async function onEdit(row: ClientRecord) {
setFormMode('edit'); setFormMode('edit');
setEditingClient({ ...row }); // Paged list omits per-client secrets to keep the row payload tiny;
setEditingAttachedIds(Array.isArray(row.inboundIds) ? [...row.inboundIds] : []); // 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); setFormOpen(true);
} }
@@ -379,13 +351,15 @@ export default function ClientsPage() {
}); });
} }
function onShowInfo(row: ClientRecord) { async function onShowInfo(row: ClientRecord) {
setInfoClient(row); const full = await hydrate(row.email);
setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
setInfoOpen(true); setInfoOpen(true);
} }
function onShowQr(row: ClientRecord) { async function onShowQr(row: ClientRecord) {
setQrClient(row); const full = await hydrate(row.email);
setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
setQrOpen(true); setQrOpen(true);
} }
@@ -595,10 +569,11 @@ export default function ClientsPage() {
const tablePagination = { const tablePagination = {
current: currentPage, current: currentPage,
pageSize: tablePageSize, pageSize: tablePageSize,
total: sortedClients.length, total: filtered,
showSizeChanger: sortedClients.length > 10, showSizeChanger: filtered > 10,
pageSizeOptions: ['10', '20', '50', '100'], pageSizeOptions: ['10', '25', '50', '100', '200'],
hideOnSinglePage: sortedClients.length <= tablePageSize, hideOnSinglePage: filtered <= tablePageSize,
showTotal: (n: number) => `${n}`,
}; };
const rowSelection = { const rowSelection = {

View File

@@ -60,7 +60,7 @@ import { DBInbound } from '@/models/dbinbound.js';
import FinalMaskForm from '@/components/FinalMaskForm'; import FinalMaskForm from '@/components/FinalMaskForm';
import DateTimePicker from '@/components/DateTimePicker'; import DateTimePicker from '@/components/DateTimePicker';
import JsonEditor from '@/components/JsonEditor'; import JsonEditor from '@/components/JsonEditor';
import { useNodes, type NodeRecord } from '@/hooks/useNodes'; import type { NodeRecord } from '@/hooks/useNodes';
import './InboundFormModal.css'; import './InboundFormModal.css';
const { TextArea } = Input; const { TextArea } = Input;
@@ -73,6 +73,7 @@ interface InboundFormModalProps {
mode: 'add' | 'edit'; mode: 'add' | 'edit';
dbInbound: any; dbInbound: any;
dbInbounds: any[]; dbInbounds: any[];
availableNodes?: NodeRecord[];
} }
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly']; const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
@@ -156,10 +157,10 @@ export default function InboundFormModal({
mode, mode,
dbInbound, dbInbound,
dbInbounds, dbInbounds,
availableNodes,
}: InboundFormModalProps) { }: InboundFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const { nodes: availableNodes } = useNodes();
const selectableNodes = useMemo( const selectableNodes = useMemo(
() => (availableNodes || []).filter((n: NodeRecord) => n.enable), () => (availableNodes || []).filter((n: NodeRecord) => n.enable),
[availableNodes], [availableNodes],

View File

@@ -72,6 +72,7 @@ export default function InboundsPage() {
ipLimitEnable, ipLimitEnable,
remarkModel, remarkModel,
refresh, refresh,
hydrateInbound,
fetchDefaultSettings, fetchDefaultSettings,
applyTrafficEvent, applyTrafficEvent,
applyClientStatsEvent, applyClientStatsEvent,
@@ -259,18 +260,24 @@ export default function InboundsPage() {
}); });
}, [subSettings, openText]); }, [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[] = []; const out: string[] = [];
for (const ib of dbInbounds as any[]) { for (const ib of hydrated) {
const projected = checkFallback(ib); const projected = checkFallback(ib);
out.push(projected.genInboundLinks(remarkModel, hostOverrideFor(ib))); out.push(projected.genInboundLinks(remarkModel, hostOverrideFor(ib)));
} }
openText({ title: 'Export all inbound links', content: out.join('\r\n'), fileName: 'All-Inbounds' }); 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[] = []; const out: string[] = [];
for (const ib of dbInbounds as any[]) { for (const ib of hydrated) {
const inbound = ib.toInbound(); const inbound = ib.toInbound();
const clients = inbound?.clients || []; const clients = inbound?.clients || [];
for (const c of 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' }); 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(() => { const importInbound = useCallback(() => {
openPrompt({ openPrompt({
@@ -395,42 +402,51 @@ export default function InboundsPage() {
} }
}, [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi]); }, [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) { switch (key) {
case 'edit': case 'edit':
openEdit(dbInbound); openEdit(target);
break; break;
case 'showInfo': case 'showInfo':
setInfoDbInbound(checkFallback(dbInbound)); setInfoDbInbound(checkFallback(target));
setInfoClientIndex(findClientIndex(dbInbound, null)); setInfoClientIndex(findClientIndex(target, null));
setInfoOpen(true); setInfoOpen(true);
break; break;
case 'qrcode': case 'qrcode':
setQrDbInbound(checkFallback(dbInbound)); setQrDbInbound(checkFallback(target));
setQrOpen(true); setQrOpen(true);
break; break;
case 'export': case 'export':
exportInboundLinks(dbInbound); exportInboundLinks(target);
break; break;
case 'subs': case 'subs':
exportInboundSubs(dbInbound); exportInboundSubs(target);
break; break;
case 'clipboard': case 'clipboard':
exportInboundClipboard(dbInbound); exportInboundClipboard(target);
break; break;
case 'delete': case 'delete':
confirmDelete(dbInbound); confirmDelete(target);
break; break;
case 'resetTraffic': case 'resetTraffic':
confirmResetTraffic(dbInbound); confirmResetTraffic(target);
break; break;
case 'clone': case 'clone':
confirmClone(dbInbound); confirmClone(target);
break; break;
default: default:
messageApi.info(`Action "${key}" — coming in a later 5f subphase`); 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 basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
const requestUri = typeof window !== 'undefined' ? window.location.pathname : ''; const requestUri = typeof window !== 'undefined' ? window.location.pathname : '';
@@ -508,6 +524,7 @@ export default function InboundsPage() {
mode={formMode} mode={formMode}
dbInbound={formDbInbound} dbInbound={formDbInbound}
dbInbounds={dbInbounds as any[]} dbInbounds={dbInbounds as any[]}
availableNodes={nodesList}
/> />
<InboundInfoModal <InboundInfoModal
open={infoOpen} open={infoOpen}

View File

@@ -206,7 +206,7 @@ export function useInbounds() {
if (refreshingRef.current) return; if (refreshingRef.current) return;
refreshingRef.current = true; refreshingRef.current = true;
try { try {
const msg = await HttpUtil.get('/panel/api/inbounds/list'); const msg = await HttpUtil.get('/panel/api/inbounds/list/slim');
if (!msg?.success) return; if (!msg?.success) return;
await fetchLastOnlineMap(); await fetchLastOnlineMap();
await fetchOnlineUsers(); await fetchOnlineUsers();
@@ -216,6 +216,26 @@ export function useInbounds() {
} }
}, [fetchLastOnlineMap, fetchOnlineUsers, setInbounds]); }, [fetchLastOnlineMap, fetchOnlineUsers, setInbounds]);
// hydrateInbound fetches the full inbound (including settings.clients with
// uuid/password/flow/etc.) and swaps it into the cached list. Use this
// before opening edit / info / qr / export / clone flows — refresh() loads
// the slim list which doesn't carry per-client secrets.
const hydrateInbound = useCallback(async (id: number) => {
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( const applyTrafficEvent = useCallback(
(payload: unknown) => { (payload: unknown) => {
if (!payload || typeof payload !== 'object') return; if (!payload || typeof payload !== 'object') return;
@@ -340,6 +360,7 @@ export function useInbounds() {
ipLimitEnable, ipLimitEnable,
pageSize, pageSize,
refresh, refresh,
hydrateInbound,
fetchDefaultSettings, fetchDefaultSettings,
applyTrafficEvent, applyTrafficEvent,
applyClientStatsEvent, applyClientStatsEvent,

View File

@@ -38,7 +38,9 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (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; tag: string; protocol: string; port: number;
}[]>; }[]>;
if (cancelled) return; if (cancelled) return;

View File

@@ -20,6 +20,7 @@ type ClientController struct {
clientService service.ClientService clientService service.ClientService
inboundService service.InboundService inboundService service.InboundService
xrayService service.XrayService xrayService service.XrayService
settingService service.SettingService
} }
func NewClientController(g *gin.RouterGroup) *ClientController { func NewClientController(g *gin.RouterGroup) *ClientController {
@@ -30,6 +31,7 @@ func NewClientController(g *gin.RouterGroup) *ClientController {
func (a *ClientController) initRouter(g *gin.RouterGroup) { func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list) g.GET("/list", a.list)
g.GET("/list/paged", a.listPaged)
g.GET("/get/:email", a.get) g.GET("/get/:email", a.get)
g.GET("/traffic/:email", a.getTrafficByEmail) g.GET("/traffic/:email", a.getTrafficByEmail)
g.GET("/subLinks/:subId", a.getSubLinks) g.GET("/subLinks/:subId", a.getSubLinks)
@@ -60,6 +62,20 @@ func (a *ClientController) list(c *gin.Context) {
jsonObj(c, rows, nil) jsonObj(c, rows, nil)
} }
func (a *ClientController) listPaged(c *gin.Context) {
var params service.ClientPageParams
if err := c.ShouldBindQuery(&params); 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) { func (a *ClientController) get(c *gin.Context) {
email := c.Param("email") email := c.Param("email")
rec, err := a.clientService.GetRecordByEmail(nil, email) rec, err := a.clientService.GetRecordByEmail(nil, email)

View File

@@ -61,6 +61,7 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) {
func (a *InboundController) initRouter(g *gin.RouterGroup) { func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.getInbounds) g.GET("/list", a.getInbounds)
g.GET("/list/slim", a.getInboundsSlim)
g.GET("/options", a.getInboundOptions) g.GET("/options", a.getInboundOptions)
g.GET("/get/:id", a.getInbound) g.GET("/get/:id", a.getInbound)
g.GET("/:id/fallbacks", a.getFallbacks) g.GET("/:id/fallbacks", a.getFallbacks)
@@ -86,6 +87,18 @@ func (a *InboundController) getInbounds(c *gin.Context) {
jsonObj(c, inbounds, nil) 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 // getInboundOptions returns a lightweight projection of the user's inbounds
// (id, remark, protocol, port, tlsFlowCapable) for pickers in the clients UI. // (id, remark, protocol, port, tlsFlowCapable) for pickers in the clients UI.
// Avoids shipping per-client settings and traffic stats just to fill a dropdown. // Avoids shipping per-client settings and traffic stats just to fill a dropdown.

View File

@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -803,6 +804,351 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
return needRestart, nil 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 // BulkAdjustResult is returned by BulkAdjust to report how many clients were
// successfully updated and which were skipped (typically because the field // successfully updated and which were skipped (typically because the field
// being adjusted was unlimited for that client) or failed. // being adjusted was unlimited for that client) or failed.

View File

@@ -135,6 +135,71 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
return inbounds, nil 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 // annotateFallbackParents fills FallbackParent on each inbound that is
// the child side of a fallback rule. One DB round-trip serves the full // 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 // 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 { type InboundOption struct {
Id int `json:"id"` Id int `json:"id"`
Remark string `json:"remark"` Remark string `json:"remark"`
Tag string `json:"tag"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
Port int `json:"port"` Port int `json:"port"`
TlsFlowCapable bool `json:"tlsFlowCapable"` TlsFlowCapable bool `json:"tlsFlowCapable"`
@@ -191,12 +257,13 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
var rows []struct { var rows []struct {
Id int `gorm:"column:id"` Id int `gorm:"column:id"`
Remark string `gorm:"column:remark"` Remark string `gorm:"column:remark"`
Tag string `gorm:"column:tag"`
Protocol string `gorm:"column:protocol"` Protocol string `gorm:"column:protocol"`
Port int `gorm:"column:port"` Port int `gorm:"column:port"`
StreamSettings string `gorm:"column:stream_settings"` StreamSettings string `gorm:"column:stream_settings"`
} }
err := db.Table("inbounds"). err := db.Table("inbounds").
Select("id, remark, protocol, port, stream_settings"). Select("id, remark, tag, protocol, port, stream_settings").
Where("user_id = ?", userId). Where("user_id = ?", userId).
Order("id ASC"). Order("id ASC").
Scan(&rows).Error Scan(&rows).Error
@@ -208,6 +275,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
out = append(out, InboundOption{ out = append(out, InboundOption{
Id: r.Id, Id: r.Id,
Remark: r.Remark, Remark: r.Remark,
Tag: r.Tag,
Protocol: r.Protocol, Protocol: r.Protocol,
Port: r.Port, Port: r.Port,
TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings), TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings),