fix(online): scope online status per node instead of a global union

The inbounds page and Nodes page checked each client's email against a
single deduped union of every node's online clients, so a client connected
to one node showed as online on every inbound across every node. The local
online set was also derived from the email-keyed client_traffics.last_online
column, which remote-node syncs bump too, leaking remote-only clients onto
local inbounds.

Track online clients per node: the local panel's own xray clients under key
0 (derived from live traffic-poll deltas via RefreshLocalOnline, kept in
memory and independent of the shared last_online column) and each remote
node under its id. Add GetOnlineClientsByNode plus a /clients/onlinesByNode
endpoint and onlineByNode WS field; node.go and the inbounds rollup now scope
online by node. The flat GetOnlineClients union is kept for client-centric and
total-count views (Clients page, dashboard, telegram).

Closes #4809
This commit is contained in:
MHSanaei
2026-06-02 18:33:21 +02:00
parent 6f6c7fc17a
commit 3af2da0142
12 changed files with 334 additions and 31 deletions

View File

@@ -21,6 +21,7 @@ export const keys = {
list: (params: unknown) => ['clients', 'list', params] as const,
all: () => ['clients', 'all'] as const,
onlines: () => ['clients', 'onlines'] as const,
onlinesByNode: () => ['clients', 'onlinesByNode'] as const,
lastOnline: () => ['clients', 'lastOnline'] as const,
groups: () => ['clients', 'groups'] as const,
},

View File

@@ -666,9 +666,15 @@ export const sections: readonly Section[] = [
{
method: 'POST',
path: '/panel/api/clients/onlines',
summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
summary: 'List the emails of currently connected clients (last seen within the heartbeat window), deduped across every node.',
response: '{\n "success": true,\n "obj": ["user1", "user2"]\n}',
},
{
method: 'POST',
path: '/panel/api/clients/onlinesByNode',
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/lastOnline',

View File

@@ -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 } from '@/schemas/client';
import { OnlinesSchema, OnlineByNodeSchema } from '@/schemas/client';
import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
export interface SubSettings {
@@ -54,6 +54,25 @@ async function fetchOnlineClients(): Promise<string[]> {
return Array.isArray(validated.obj) ? validated.obj : [];
}
// Online emails grouped by node id (local panel = key 0), used to scope the
// per-inbound online rollup so a client online on one node is not shown
// online on every node's inbounds.
async function fetchOnlineClientsByNode(): Promise<Record<string, string[]>> {
const msg = await HttpUtil.post('/panel/api/clients/onlinesByNode', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByNode');
const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByNode');
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)) {
if (!Array.isArray(emails)) continue;
map.set(Number(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');
@@ -83,6 +102,12 @@ export function useInbounds() {
staleTime: Infinity,
});
const onlinesByNodeQuery = useQuery({
queryKey: keys.clients.onlinesByNode(),
queryFn: fetchOnlineClientsByNode,
staleTime: Infinity,
});
const lastOnlineQuery = useQuery({
queryKey: keys.clients.lastOnline(),
queryFn: fetchLastOnlineMap,
@@ -135,6 +160,10 @@ export function useInbounds() {
const onlineClientsRef = useRef<string[]>([]);
onlineClientsRef.current = onlineClients;
// Online emails keyed by node id (local inbounds = key 0). The rollup
// reads this so each inbound only counts clients online on its own node.
const onlineByNodeRef = useRef<Map<number, Set<string>>>(new Map());
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
const rollupClients = useCallback(
@@ -151,12 +180,14 @@ export function useInbounds() {
const comments = new Map<string, string>();
const now = Date.now();
const nodeOnline = onlineByNodeRef.current.get(dbInbound.nodeId ?? 0);
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 && onlineClientsRef.current.includes(client.email)) online.push(client.email);
if (client.email && nodeOnline?.has(client.email)) online.push(client.email);
} else if (client.email) {
deactive.push(client.email);
}
@@ -237,6 +268,13 @@ export function useInbounds() {
}
}, [onlinesQuery.data]);
useEffect(() => {
if (onlinesByNodeQuery.data) {
onlineByNodeRef.current = toNodeOnlineMap(onlinesByNodeQuery.data);
rebuildClientCount();
}
}, [onlinesByNodeQuery.data, rebuildClientCount]);
useEffect(() => {
if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
}, [lastOnlineQuery.data]);
@@ -255,6 +293,7 @@ export function useInbounds() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByNode() }),
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
]);
@@ -284,11 +323,14 @@ export function useInbounds() {
const applyTrafficEvent = useCallback(
(payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { onlineClients?: string[]; lastOnlineMap?: Record<string, number> };
const p = payload as { onlineClients?: string[]; onlineByNode?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
if (Array.isArray(p.onlineClients)) {
onlineClientsRef.current = p.onlineClients;
setOnlineClients(p.onlineClients);
}
if (p.onlineByNode && typeof p.onlineByNode === 'object') {
onlineByNodeRef.current = toNodeOnlineMap(p.onlineByNode);
}
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
}

View File

@@ -112,6 +112,11 @@ export const BulkDetachResultSchema = z.object({
export const OnlinesSchema = nullableStringArray;
export const OnlineByNodeSchema = z
.record(z.string(), nullableStringArray)
.nullable()
.transform((v) => v ?? {});
export const GroupSummarySchema = z.object({
name: z.string(),
clientCount: z.number(),