mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
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:
parent
9c60ed7ea8
commit
6279c6d849
5 changed files with 141 additions and 19 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 : '';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue