Files
3x-ui-agent/frontend/src/pages/inbounds/useInbounds.ts
T
Muse 3a02211555 merge fix/settings-trim: dead settings, upstream remnants, JSON/Clash subscriptions removed
# Conflicts:
#	frontend/src/models/setting.ts
#	frontend/src/pages/settings/GeneralTab.tsx
#	frontend/src/pages/settings/SettingsPage.tsx
#	frontend/src/pages/settings/SubscriptionBalancersTab.tsx
#	frontend/src/pages/xray/outbounds/OutboundsTab.css
#	frontend/src/schemas/setting.ts
2026-10-23 00:39:44 +00:00

592 lines
22 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { DBInbound, coerceInboundJsonField } from '@/models/dbinbound';
import type { ClientStats, DBInboundInit } from '@/models/dbinbound';
import { Protocols } from '@/schemas/primitives';
import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
import { setDatepicker } from '@/hooks/useDatepicker';
import { keys } from '@/api/queryKeys';
import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from '@/schemas/inbound';
import { OnlinesSchema, OnlineByNodeSchema, ActiveInboundsByNodeSchema } from '@/schemas/client';
import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
import type { InboundSpeedEntry } from './list/types';
import { TRAFFIC_POLL_INTERVAL_S } from '@/lib/traffic/poll-interval';
export interface SubSettings {
enable: boolean;
subTitle: string;
subURI: string;
// Configured public host (Sub Domain, else Web Domain) used as the share/QR
// link host when the panel is reached on a loopback address. Empty if neither
// is set.
publicHost: string;
}
type DBInboundInstance = InstanceType<typeof DBInbound>;
// Speed is delta-derived, so it can't be recomputed until the first poll after
// mount; navigating away and back would otherwise blank the column for up to one
// poll. Cache the last speed map across mounts (module scope) and reseed from it
// while recent, so returning to the page shows the last throughput immediately
// and the next poll refreshes it.
const SPEED_CACHE_TTL_MS = 15000;
let inboundSpeedCache: { at: number; data: Record<number, InboundSpeedEntry> } = {
at: 0,
data: {},
};
interface TrafficDelta {
Tag: string;
Up: number;
Down: number;
IsInbound?: boolean;
}
interface ClientRollup {
clients: number;
active: string[];
deactive: string[];
depleted: string[];
expiring: string[];
online: string[];
comments: Map<string, string>;
}
const TRACKED_PROTOCOLS: readonly string[] = [
Protocols.VMESS,
Protocols.VLESS,
Protocols.TROJAN,
Protocols.SHADOWSOCKS,
Protocols.HYSTERIA,
Protocols.WIREGUARD,
Protocols.MTPROTO,
Protocols.AMNEZIAWG,
];
async function fetchSlimInbounds(): Promise<unknown[]> {
const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbounds');
const validated = parseMsg(msg, SlimInboundListSchema, 'inbounds/list/slim');
return Array.isArray(validated.obj) ? validated.obj : [];
}
async function fetchOnlineClients(): Promise<string[]> {
const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
return Array.isArray(validated.obj) ? validated.obj : [];
}
// Online emails grouped by the panelGuid of the node that physically hosts each
// client, used to scope the per-inbound online rollup so a client online on one
// node is not shown online on every node's inbounds — and a client on a
// sub-node is attributed to that sub-node, not the node it syncs through (#4983).
async function fetchOnlineClientsByGuid(): Promise<Record<string, string[]>> {
const msg = await HttpUtil.post('/panel/api/clients/onlinesByGuid', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByGuid');
const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByGuid');
return validated.obj && typeof validated.obj === 'object'
? (validated.obj as Record<string, string[]>)
: {};
}
// Inbound tags that carried traffic recently, grouped by node (local = key 0).
// Pairs with the per-node online map so a client attached to several inbounds
// is only marked online on the ones that actually moved bytes — Xray's
// user-level stat can't attribute traffic to a single inbound on its own.
async function fetchActiveInboundsByNode(): Promise<Record<string, string[]>> {
const msg = await HttpUtil.post('/panel/api/clients/activeInbounds', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch activeInbounds');
const validated = parseMsg(msg, ActiveInboundsByNodeSchema, 'clients/activeInbounds');
return validated.obj && typeof validated.obj === 'object'
? (validated.obj as Record<string, string[]>)
: {};
}
function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string>> {
const map = new Map<string, Set<string>>();
for (const [key, emails] of Object.entries(data)) {
if (!Array.isArray(emails)) continue;
map.set(key, new Set(emails));
}
return map;
}
async function fetchLastOnlineMap(): Promise<Record<string, number>> {
const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
const validated = parseMsg(msg, LastOnlineMapSchema, 'clients/lastOnline');
return validated.obj && typeof validated.obj === 'object' ? validated.obj : {};
}
async function fetchDefaultSettings(): Promise<DefaultsPayload> {
const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, {
silent: true,
});
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
return validated.obj ?? {};
}
export function useInbounds() {
const queryClient = useQueryClient();
const slimQuery = useQuery({
queryKey: keys.inbounds.slim(),
queryFn: fetchSlimInbounds,
staleTime: Infinity,
});
const onlinesQuery = useQuery({
queryKey: keys.clients.onlines(),
queryFn: fetchOnlineClients,
staleTime: Infinity,
});
const onlinesByGuidQuery = useQuery({
queryKey: keys.clients.onlinesByGuid(),
queryFn: fetchOnlineClientsByGuid,
staleTime: Infinity,
});
const activeInboundsQuery = useQuery({
queryKey: keys.clients.activeInbounds(),
queryFn: fetchActiveInboundsByNode,
staleTime: Infinity,
});
const lastOnlineQuery = useQuery({
queryKey: keys.clients.lastOnline(),
queryFn: fetchLastOnlineMap,
staleTime: Infinity,
});
const defaultsQuery = useQuery({
queryKey: keys.settings.defaults(),
queryFn: fetchDefaultSettings,
staleTime: Infinity,
});
const defaults = defaultsQuery.data ?? {};
const expireDiff = (defaults.expireDiff ?? 0) * 86400000;
const trafficDiff = (defaults.trafficDiff ?? 0) * 1073741824;
const tgBotEnable = !!defaults.tgBotEnable;
const ipLimitEnable = !!defaults.ipLimitEnable;
const pageSize = defaults.pageSize ?? 0;
const datepicker = (defaults.datepicker as 'gregorian') || 'gregorian';
const subSettings: SubSettings = useMemo(
() => ({
enable: !!defaults.subEnable,
subTitle: defaults.subTitle || '',
subURI: defaults.subURI || '',
publicHost: defaults.subDomain || defaults.webDomain || '',
}),
[
defaults.subEnable,
defaults.subTitle,
defaults.subURI,
defaults.subDomain,
defaults.webDomain,
],
);
useEffect(() => {
if (defaults.datepicker) setDatepicker(datepicker);
}, [datepicker, defaults.datepicker]);
// dbInbounds mirrors the slim query data wrapped as DBInbound instances. The
// WS handlers rebuild only the rows they touch, so no refetch is needed.
const [dbInbounds, setDbInbounds] = useState<DBInboundInstance[]>([]);
const dbInboundsRef = useRef<DBInboundInstance[]>([]);
useEffect(() => {
dbInboundsRef.current = dbInbounds;
});
const [inboundSpeed, setInboundSpeed] = useState<Record<number, InboundSpeedEntry>>(() =>
Date.now() - inboundSpeedCache.at < SPEED_CACHE_TTL_MS ? inboundSpeedCache.data : {},
);
useEffect(() => {
inboundSpeedCache = { at: Date.now(), data: inboundSpeed };
}, [inboundSpeed]);
const [onlineClients, setOnlineClients] = useState<string[]>([]);
// Online emails keyed by the hosting node's panelGuid. The rollup reads this
// so each inbound only counts clients online on the node that physically
// hosts it, attributing a sub-node's clients to that sub-node (#4983).
const [onlineByGuid, setOnlineByGuid] = useState<Map<string, Set<string>>>(() => new Map());
// Recently-active inbound tags keyed by the hosting node's panelGuid. A GUID
// missing from this map means "no per-inbound activity reported" (e.g. remote
// nodes), so the rollup leaves that node's inbounds ungated and falls back to
// the email signal. A present GUID gates: a client only counts online on an
// inbound whose tag carried traffic this window.
const [activeByGuid, setActiveByGuid] = useState<Map<string, Set<string>>>(() => new Map());
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
const rollupClients = useCallback(
(
dbInbound: DBInboundInstance,
inbound: { clients?: { email?: string; enable?: boolean; comment?: string }[] },
): ClientRollup => {
const clientStats = Array.isArray((dbInbound as { clientStats?: unknown }).clientStats)
? (
dbInbound as unknown as {
clientStats: {
email: string;
total: number;
up: number;
down: number;
expiryTime: number;
}[];
}
).clientStats
: [];
const clients = inbound?.clients || [];
const active: string[] = [];
const deactive: string[] = [];
const depleted: string[] = [];
const expiring: string[] = [];
const online: string[] = [];
const comments = new Map<string, string>();
const now = Date.now();
// Attribution key: the GUID of the node that physically hosts this
// inbound. Local inbounds carry the panel's own GUID (filled server-side);
// a node-managed inbound carries its origin node's GUID, or falls back to
// the master-local synthetic id for an old-build node without one (#4983).
const guid =
dbInbound.originNodeGuid || (dbInbound.nodeId != null ? `node:${dbInbound.nodeId}` : '');
const nodeOnline = onlineByGuid.get(guid);
// A node absent from the active map reports no per-inbound activity, so
// leave its inbounds ungated. When present, only mark a client online on
// this inbound if its tag actually carried traffic — that's what stops a
// multi-inbound client lighting up every inbound it's attached to.
const activeForNode = activeByGuid.get(guid);
const inboundActive =
activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
if (dbInbound.enable) {
const statsByEmail = new Map<
string,
{ email: string; total: number; up: number; down: number; expiryTime: number }
>();
for (const stats of clientStats) {
if (stats.email) statsByEmail.set(stats.email.toLowerCase(), stats);
}
for (const client of clients) {
if (client.comment && client.email) comments.set(client.email, client.comment);
if (!client.email) continue;
const stats = statsByEmail.get(client.email.toLowerCase());
const exhausted =
stats != null && stats.total > 0 && stats.up + stats.down >= stats.total;
const expired = stats != null && stats.expiryTime > 0 && stats.expiryTime <= now;
if (expired || exhausted) {
depleted.push(client.email);
continue;
}
if (!client.enable) {
deactive.push(client.email);
continue;
}
active.push(client.email);
if (inboundActive && nodeOnline?.has(client.email)) online.push(client.email);
if (stats) {
const expiringSoon =
(stats.expiryTime > 0 && stats.expiryTime - now < expireDiff) ||
(stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiff);
if (expiringSoon) expiring.push(client.email);
}
}
} else {
for (const client of clients) {
if (client.email) deactive.push(client.email);
}
}
return {
clients: clients.length,
active,
deactive,
depleted,
expiring,
online,
comments,
};
},
[onlineByGuid, activeByGuid, expireDiff, trafficDiff],
);
// Every write to a DBInbound row also replaces the dbInbounds array, so this
// recomputes on both a refetch and a WS-merged stats update.
const clientCount = useMemo(() => {
const counts: Record<number, ClientRollup> = {};
for (const dbInbound of dbInbounds) {
const protocol = dbInbound.protocol;
if (!TRACKED_PROTOCOLS.includes(protocol)) continue;
const settings = coerceInboundJsonField(dbInbound.settings) as {
method?: string;
clients?: Array<{ email?: string; enable?: boolean; comment?: string }>;
};
if (protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol, settings })) continue;
counts[dbInbound.id] = rollupClients(dbInbound, { clients: settings.clients });
}
return counts;
}, [dbInbounds, rollupClients]);
// Adopting fetched data during render (rather than in an effect) keeps the
// list from painting one frame of the previous data after a refetch.
const [syncedSlim, setSyncedSlim] = useState<unknown>();
if (slimQuery.data && slimQuery.data !== syncedSlim) {
setSyncedSlim(slimQuery.data);
setDbInbounds(
(slimQuery.data as { protocol: string; id: number }[]).map(
(row) => new DBInbound(row) as DBInboundInstance,
),
);
}
const [syncedOnlines, setSyncedOnlines] = useState<unknown>();
if (onlinesQuery.data && onlinesQuery.data !== syncedOnlines) {
setSyncedOnlines(onlinesQuery.data);
setOnlineClients(onlinesQuery.data);
}
const [syncedOnlinesByGuid, setSyncedOnlinesByGuid] = useState<unknown>();
if (onlinesByGuidQuery.data && onlinesByGuidQuery.data !== syncedOnlinesByGuid) {
setSyncedOnlinesByGuid(onlinesByGuidQuery.data);
setOnlineByGuid(toGuidOnlineMap(onlinesByGuidQuery.data));
}
const [syncedActiveInbounds, setSyncedActiveInbounds] = useState<unknown>();
if (activeInboundsQuery.data && activeInboundsQuery.data !== syncedActiveInbounds) {
setSyncedActiveInbounds(activeInboundsQuery.data);
setActiveByGuid(toGuidOnlineMap(activeInboundsQuery.data));
}
const [syncedLastOnline, setSyncedLastOnline] = useState<unknown>();
if (lastOnlineQuery.data && lastOnlineQuery.data !== syncedLastOnline) {
setSyncedLastOnline(lastOnlineQuery.data);
setLastOnlineMap(lastOnlineQuery.data);
}
const fetched =
(slimQuery.data !== undefined || slimQuery.isError) &&
(defaultsQuery.data !== undefined || defaultsQuery.isError);
const fetchErrorSource = slimQuery.error || defaultsQuery.error;
const fetchError = fetchErrorSource ? (fetchErrorSource as Error).message : '';
const refresh = useCallback(async () => {
// Invalidate at the inbounds root so both `slim` (this page's list)
// and `options` (the Clients page's inbound picker) refetch. Without
// the options bucket, a freshly-created inbound stays invisible in
// the client add/edit modal until a full page reload.
await Promise.all([
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByGuid() }),
queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
]);
}, [queryClient]);
// 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 validated = parseMsg(msg, InboundDetailSchema, `inbounds/get/${id}`);
if (!validated.obj) return null;
const dbInbound = new DBInbound(validated.obj) as DBInboundInstance;
setDbInbounds((prev) => {
const next = prev.map((row) =>
(row as unknown as { id: number }).id === id ? dbInbound : row,
);
dbInboundsRef.current = next;
return next;
});
return dbInbound;
}, []);
const applyTrafficEvent = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as {
traffics?: TrafficDelta[];
nodeTraffics?: TrafficDelta[];
onlineClients?: string[];
onlineByGuid?: Record<string, string[]>;
activeInbounds?: Record<string, string[]>;
lastOnlineMap?: Record<string, number>;
};
if (Array.isArray(p.onlineClients)) {
setOnlineClients(p.onlineClients);
}
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
setOnlineByGuid(toGuidOnlineMap(p.onlineByGuid));
}
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
setActiveByGuid(toGuidOnlineMap(p.activeInbounds));
}
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
}
// Speed arrives from two independent 5s polls: the local Xray poll sends
// `traffics` (local inbounds) and the node sync sends `nodeTraffics` (node
// inbounds). Each replaces speed only within its own scope so the two don't
// clobber each other; an idle in-scope inbound — absent from its payload —
// clears instead of showing a stale value.
const applyTraffics = (
traffics: TrafficDelta[],
inScope: (ib: DBInboundInstance) => boolean,
) => {
const byTag = new Map<string, TrafficDelta>();
for (const tr of traffics) {
if (!tr || typeof tr.Tag !== 'string') continue;
if (tr.IsInbound === false) continue;
byTag.set(tr.Tag, tr);
}
setInboundSpeed((prev) => {
const next = { ...prev };
for (const ib of dbInboundsRef.current) {
if (!inScope(ib)) continue;
const delta = byTag.get(ib.tag);
if (delta) {
next[ib.id] = {
up: (delta.Up || 0) / TRAFFIC_POLL_INTERVAL_S,
down: (delta.Down || 0) / TRAFFIC_POLL_INTERVAL_S,
};
} else {
delete next[ib.id];
}
}
return next;
});
};
if (Array.isArray(p.traffics)) applyTraffics(p.traffics, (ib) => ib.nodeId == null);
if (Array.isArray(p.nodeTraffics)) applyTraffics(p.nodeTraffics, (ib) => ib.nodeId != null);
}, []);
const applyClientStatsEvent = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as {
inbounds?: { id: number; up?: number; down?: number; total?: number; enable?: boolean }[];
clients?: {
email: string;
up?: number;
down?: number;
total?: number;
expiryTime?: number;
enable?: boolean;
}[];
};
const byId = new Map<
number,
{ id: number; up?: number; down?: number; total?: number; enable?: boolean }
>();
if (Array.isArray(p.inbounds)) {
for (const row of p.inbounds) {
if (row && row.id != null) byId.set(row.id, row);
}
}
const byEmail = new Map<
string,
{
email: string;
up?: number;
down?: number;
total?: number;
expiryTime?: number;
enable?: boolean;
}
>();
if (Array.isArray(p.clients)) {
for (const row of p.clients) {
if (row && row.email) byEmail.set(row.email, row);
}
}
if (byId.size === 0 && byEmail.size === 0) return;
// Rows carrying an update are rebuilt rather than patched in place: the
// derived clientCount only recomputes when a row's identity changes.
let touched = false;
const next = dbInboundsRef.current.map((ib) => {
const upd = byId.get(ib.id);
const stats = Array.isArray(ib.clientStats) ? ib.clientStats : null;
let statsTouched = false;
const nextStats =
stats && byEmail.size > 0
? stats.map((stat) => {
const su = byEmail.get(stat.email);
if (!su) return stat;
statsTouched = true;
return {
...stat,
up: typeof su.up === 'number' ? su.up : stat.up,
down: typeof su.down === 'number' ? su.down : stat.down,
total: typeof su.total === 'number' ? su.total : stat.total,
expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime,
enable: typeof su.enable === 'boolean' ? su.enable : stat.enable,
} as ClientStats;
})
: null;
if (!upd && !statsTouched) return ib;
touched = true;
const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance;
if (upd) {
if (typeof upd.up === 'number') row.up = upd.up;
if (typeof upd.down === 'number') row.down = upd.down;
if (typeof upd.total === 'number') row.total = upd.total;
if (typeof upd.enable === 'boolean') row.enable = upd.enable;
}
if (statsTouched && nextStats) row.clientStats = nextStats;
return row;
});
if (!touched) return;
dbInboundsRef.current = next;
setDbInbounds(next);
}, []);
const totals = useMemo(() => {
let up = 0;
let down = 0;
for (const ib of dbInbounds) {
const rec = ib as unknown as { up?: number; down?: number };
up += rec.up || 0;
down += rec.down || 0;
}
return { up, down };
}, [dbInbounds]);
return {
fetched,
fetchError,
dbInbounds,
clientCount,
onlineClients,
lastOnlineMap,
inboundSpeed,
totals,
expireDiff,
trafficDiff,
subSettings,
datepicker,
tgBotEnable,
ipLimitEnable,
pageSize,
refresh,
hydrateInbound,
applyTrafficEvent,
applyClientStatsEvent,
};
}