mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
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.
This commit is contained in:
parent
6279c6d849
commit
b3db26c4d8
4 changed files with 507 additions and 97 deletions
|
|
@ -54,12 +54,48 @@ 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, setQuery] = useState<ClientQueryParams>(DEFAULT_QUERY);
|
||||||
const [subSettings, setSubSettings] = useState<SubSettings>({
|
const [subSettings, setSubSettings] = useState<SubSettings>({
|
||||||
enable: false, subURI: '', subJsonURI: '', subJsonEnable: false,
|
enable: false, subURI: '', subJsonURI: '', subJsonEnable: false,
|
||||||
});
|
});
|
||||||
|
|
@ -70,19 +106,40 @@ 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 params = override ?? queryRef.current;
|
||||||
|
const qs = buildQS(params);
|
||||||
const [clientsMsg, inboundsMsg] = await Promise.all([
|
const [clientsMsg, inboundsMsg] = await Promise.all([
|
||||||
HttpUtil.get('/panel/api/clients/list') as Promise<ApiMsg<ClientRecord[]>>,
|
HttpUtil.get(`/panel/api/clients/list/paged?${qs}`) as Promise<ApiMsg<ClientPageResponse>>,
|
||||||
HttpUtil.get('/panel/api/inbounds/options') as Promise<ApiMsg<InboundOption[]>>,
|
inbounds.length === 0
|
||||||
|
? HttpUtil.get('/panel/api/inbounds/options') as Promise<ApiMsg<InboundOption[]>>
|
||||||
|
: Promise.resolve(null as ApiMsg<InboundOption[]> | null),
|
||||||
]);
|
]);
|
||||||
if (clientsMsg?.success) {
|
if (clientsMsg?.success && clientsMsg.obj) {
|
||||||
setClients(Array.isArray(clientsMsg.obj) ? clientsMsg.obj : []);
|
setClients(Array.isArray(clientsMsg.obj.items) ? clientsMsg.obj.items : []);
|
||||||
|
setTotal(clientsMsg.obj.total ?? 0);
|
||||||
|
setFiltered(clientsMsg.obj.filtered ?? 0);
|
||||||
|
if (clientsMsg.obj.summary) setSummary(clientsMsg.obj.summary);
|
||||||
}
|
}
|
||||||
if (inboundsMsg?.success) {
|
if (inboundsMsg?.success) {
|
||||||
setInbounds(Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : []);
|
setInbounds(Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : []);
|
||||||
|
|
@ -91,7 +148,7 @@ export function useClients() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [inbounds.length]);
|
||||||
|
|
||||||
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>>;
|
||||||
|
|
@ -110,6 +167,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 +326,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,
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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(¶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) {
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue