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