From 6279c6d849d165814e33cbb3e2fc85c7252e0074 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 23 May 2026 17:23:32 +0200 Subject: [PATCH] perf(inbounds): slim list payload + lazy hydrate for row actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/pages/api-docs/endpoints.js | 7 +++ frontend/src/pages/inbounds/InboundsPage.tsx | 52 ++++++++++------ frontend/src/pages/inbounds/useInbounds.ts | 23 ++++++- web/controller/inbound.go | 13 ++++ web/service/inbound.go | 65 ++++++++++++++++++++ 5 files changed, 141 insertions(+), 19 deletions(-) diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js index 1fca7994..735457fa 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', diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index cde83b53..98795f91 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 : ''; diff --git a/frontend/src/pages/inbounds/useInbounds.ts b/frontend/src/pages/inbounds/useInbounds.ts index 59a12472..aacca078 100644 --- a/frontend/src/pages/inbounds/useInbounds.ts +++ b/frontend/src/pages/inbounds/useInbounds.ts @@ -206,7 +206,7 @@ export function useInbounds() { if (refreshingRef.current) return; refreshingRef.current = true; try { - const msg = await HttpUtil.get('/panel/api/inbounds/list'); + const msg = await HttpUtil.get('/panel/api/inbounds/list/slim'); if (!msg?.success) return; await fetchLastOnlineMap(); await fetchOnlineUsers(); @@ -216,6 +216,26 @@ export function useInbounds() { } }, [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( (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/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/inbound.go b/web/service/inbound.go index 0a996952..bc5b253d 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