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.
This commit is contained in:
MHSanaei 2026-05-23 17:23:32 +02:00
parent 9c60ed7ea8
commit 6279c6d849
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
5 changed files with 141 additions and 19 deletions

View file

@ -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',

View file

@ -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 : '';

View file

@ -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,

View file

@ -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.

View file

@ -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