mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-09 05:44:33 +00:00
fix(online): scope per-inbound online to inbounds that carried traffic
Multi-inbound clients showed online on every inbound they were attached to. Xray's user-level traffic stat aggregates across all inbounds a client belongs to, so the email signal alone can't say which inbound was used. Pair it with the inbound-level traffic signal under the same 20s grace and gate the per-inbound rollup on it: a client only shows online on inbounds that actually moved bytes this window. Remote nodes report no per-inbound activity and stay ungated (no regression). Adds GetActiveInboundsByNode, the activeInbounds WS field and POST /panel/api/clients/activeInbounds. Fixes #4859
This commit is contained in:
@@ -22,6 +22,7 @@ export const keys = {
|
||||
all: () => ['clients', 'all'] as const,
|
||||
onlines: () => ['clients', 'onlines'] as const,
|
||||
onlinesByNode: () => ['clients', 'onlinesByNode'] as const,
|
||||
activeInbounds: () => ['clients', 'activeInbounds'] as const,
|
||||
lastOnline: () => ['clients', 'lastOnline'] as const,
|
||||
groups: () => ['clients', 'groups'] as const,
|
||||
},
|
||||
|
||||
@@ -675,6 +675,12 @@ export const sections: readonly Section[] = [
|
||||
summary: 'Online client emails grouped by the node that reported them. The local panel uses key "0"; each remote node uses its node id. Lets the inbounds page show online status per node instead of merging every node together.',
|
||||
response: '{\n "success": true,\n "obj": {\n "0": ["user1"],\n "3": ["user1", "user2"]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/activeInbounds',
|
||||
summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by node (local panel uses key "0"). Pairs with onlinesByNode so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.',
|
||||
response: '{\n "success": true,\n "obj": {\n "0": ["inbound-443", "inbound-8443"]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/lastOnline',
|
||||
|
||||
@@ -9,7 +9,7 @@ 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 } from '@/schemas/client';
|
||||
import { OnlinesSchema, OnlineByNodeSchema, ActiveInboundsByNodeSchema } from '@/schemas/client';
|
||||
import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
|
||||
|
||||
export interface SubSettings {
|
||||
@@ -68,6 +68,17 @@ async function fetchOnlineClientsByNode(): Promise<Record<string, string[]>> {
|
||||
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 toNodeOnlineMap(data: Record<string, string[]>): Map<number, Set<string>> {
|
||||
const map = new Map<number, Set<string>>();
|
||||
for (const [key, emails] of Object.entries(data)) {
|
||||
@@ -112,6 +123,12 @@ export function useInbounds() {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const activeInboundsQuery = useQuery({
|
||||
queryKey: keys.clients.activeInbounds(),
|
||||
queryFn: fetchActiveInboundsByNode,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const lastOnlineQuery = useQuery({
|
||||
queryKey: keys.clients.lastOnline(),
|
||||
queryFn: fetchLastOnlineMap,
|
||||
@@ -169,6 +186,13 @@ export function useInbounds() {
|
||||
// reads this so each inbound only counts clients online on its own node.
|
||||
const onlineByNodeRef = useRef<Map<number, Set<string>>>(new Map());
|
||||
|
||||
// Recently-active inbound tags keyed by node id. A node 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 node gates: a client only counts online on an inbound
|
||||
// whose tag carried traffic this window.
|
||||
const activeByNodeRef = useRef<Map<number, Set<string>>>(new Map());
|
||||
|
||||
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
|
||||
|
||||
const rollupClients = useCallback(
|
||||
@@ -185,14 +209,21 @@ export function useInbounds() {
|
||||
const comments = new Map<string, string>();
|
||||
const now = Date.now();
|
||||
|
||||
const nodeOnline = onlineByNodeRef.current.get(dbInbound.nodeId ?? 0);
|
||||
const nodeId = dbInbound.nodeId ?? 0;
|
||||
const nodeOnline = onlineByNodeRef.current.get(nodeId);
|
||||
// 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 = activeByNodeRef.current.get(nodeId);
|
||||
const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
|
||||
|
||||
if (dbInbound.enable) {
|
||||
for (const client of clients) {
|
||||
if (client.comment && client.email) comments.set(client.email, client.comment);
|
||||
if (client.enable) {
|
||||
if (client.email) active.push(client.email);
|
||||
if (client.email && nodeOnline?.has(client.email)) online.push(client.email);
|
||||
if (client.email && inboundActive && nodeOnline?.has(client.email)) online.push(client.email);
|
||||
} else if (client.email) {
|
||||
deactive.push(client.email);
|
||||
}
|
||||
@@ -280,6 +311,13 @@ export function useInbounds() {
|
||||
}
|
||||
}, [onlinesByNodeQuery.data, rebuildClientCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeInboundsQuery.data) {
|
||||
activeByNodeRef.current = toNodeOnlineMap(activeInboundsQuery.data);
|
||||
rebuildClientCount();
|
||||
}
|
||||
}, [activeInboundsQuery.data, rebuildClientCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
|
||||
}, [lastOnlineQuery.data]);
|
||||
@@ -299,6 +337,7 @@ export function useInbounds() {
|
||||
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
||||
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
|
||||
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByNode() }),
|
||||
queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
|
||||
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
|
||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
|
||||
]);
|
||||
@@ -328,7 +367,7 @@ export function useInbounds() {
|
||||
const applyTrafficEvent = useCallback(
|
||||
(payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { onlineClients?: string[]; onlineByNode?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
|
||||
const p = payload as { onlineClients?: string[]; onlineByNode?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
|
||||
if (Array.isArray(p.onlineClients)) {
|
||||
onlineClientsRef.current = p.onlineClients;
|
||||
setOnlineClients(p.onlineClients);
|
||||
@@ -336,6 +375,9 @@ export function useInbounds() {
|
||||
if (p.onlineByNode && typeof p.onlineByNode === 'object') {
|
||||
onlineByNodeRef.current = toNodeOnlineMap(p.onlineByNode);
|
||||
}
|
||||
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
|
||||
activeByNodeRef.current = toNodeOnlineMap(p.activeInbounds);
|
||||
}
|
||||
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
||||
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
||||
}
|
||||
|
||||
@@ -117,6 +117,11 @@ export const OnlineByNodeSchema = z
|
||||
.nullable()
|
||||
.transform((v) => v ?? {});
|
||||
|
||||
export const ActiveInboundsByNodeSchema = z
|
||||
.record(z.string(), nullableStringArray)
|
||||
.nullable()
|
||||
.transform((v) => v ?? {});
|
||||
|
||||
export const GroupSummarySchema = z.object({
|
||||
name: z.string(),
|
||||
clientCount: z.number(),
|
||||
|
||||
Reference in New Issue
Block a user