mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-16 09:36:07 +00:00
adc64bb804
* fix(nodes): keep cloned nodes (shared panelGuid) in separate attribution buckets
#4983 keys online/inbound attribution by panelGuid, assuming it is globally unique. Cloned node servers ship an identical panelGuid in their copied settings, so the master collapsed several physical nodes into one bucket: GetMergedNodeTrees merged their online sets under one key and every inbound on those nodes (same origin_node_guid) read that merged set, so the inbound page showed online cross-attributed and counts inflated.
Fall back to the node-unique synthNodeGuid(node.Id) whenever a node's panelGuid is shared by another of the master's direct nodes. Applied consistently at originGuidFor (origin_node_guid write), the online-tree key plus a self-key remap for nodes that report a GUID-keyed tree, effectiveNodeGuid, and recountByGuid's inbound bucketing. sharedNodeGuids computes the collision set. Online now works without node changes; making panelGuids unique restores real-GUID identity and also fixes GUID-keyed IP attribution.
* fix(nodes): extend duplicate-GUID hardening to master collisions, IP attribution, and a heartbeat warning
Builds on the node-vs-node fix: a node's GUID is now also treated as ambiguous when it equals the master's own panelGuid (a node cloned from the master), so the master's local clients and that node can't merge. Centralized as ambiguousNodeGuids(nodes, selfGuid) + effectiveNodeKey(node).
Applied the same node-unique fallback to the GUID-keyed IP attribution that #4983 added but the prior commit left collapsing: MergeClientIpsByGuid remaps a cloned node's own subtree to its node-unique key, nodeGuidNameMap resolves names by that key, and node deletion purges both keys. Added a throttled heartbeat warning so the operator is told to regenerate a duplicate panelGuid. Tests cover master-collision, effectiveNodeKey, and the IP remap.
* fix(node-sync): log the client-IP-attribution 404 once per node, not every cycle
Old-build nodes lack panel/api/clients/clientIpsByGuid and answer 404 on every IP-sync cycle (~10s), which floods the debug log now that the IP phase actually runs. Note the missing endpoint once per node (re-armed if the node later recovers or is upgraded) and keep logging genuine fetch errors.
* fix(nodes): remap a cloned node's own-panelGuid origin so the inbound page shows online
These nodes report their OWN inbounds with their own panelGuid as OriginNodeGuid, so originGuidFor returned the shared GUID verbatim and never remapped it. origin_node_guid stayed the shared GUID while online was keyed under the node-unique key, so the inbound page (which reads the stored origin_node_guid) looked up an empty bucket and showed everyone offline — even though the Nodes page (which derives the key live) was correct. Treat an origin equal to the node's own panelGuid as the node's own inbound and resolve it through selfKey; keep only a genuinely different (descendant) origin across hops.
* fix(node-sync): don't delete a node's central inbounds when its snapshot is empty
The central-inbound sweep deletes any central inbound whose tag is absent from the node's snapshot, with no guard for an empty snapshot. A node mid-restart or with a transient DB error (e.g. Postgres 57P01) can return an empty inbound list with success=true, which wiped all of that node's central inbounds and their clients (and reset traffic history on re-create) — observed on the Germany node: 0 clients but still 44 online (online survives because it comes from the snapshot's online tree, not the central inbound). Skip the sweep entirely when the snapshot reports zero inbounds; a real per-inbound deletion still sweeps via a non-empty snapshot that omits one tag.
* fix(email): stay silent when SMTP notifications are disabled
The event subscriber is registered unconditionally and only checked the per-event list (smtpEnabledEvents, default login.attempt,cpu.high) — not the smtpEnable master toggle. Login events are always published, so a panel with smtpEnable=false still attempted a send on every login and logged 'email subscriber: send failed: smtp host not configured'. Gate HandleEvent on GetSmtpEnable() so a disabled-SMTP panel does nothing, matching the comment where the subscriber is registered.
* fix(nodes): count only expired/exhausted as 'ended', not disabled clients
The per-node depleted (ended) count folded disabled clients in with expired/exhausted (expired || exhausted || !Enable), so the Nodes page 'ended' chip was inflated and inconsistent with the inbound page, where disabled and depleted are separate buckets. Count only expired/exhausted in both GetAll and recountByGuid so 'ended' means the same thing on both pages.
* feat(nodes): show live speed for node-hosted inbounds
Inbound speed is computed on the dashboard from a 'traffics' delta feed, which only the local Xray poll produced — so node-hosted inbounds showed no speed. The node sync now diffs successive per-inbound cumulative totals (it polls @5s, same as the local poll) and broadcasts the byte deltas as a separate 'nodeTraffics' field, keyed by the central tag the dashboard already matches. The frontend applies 'traffics' to local inbounds and 'nodeTraffics' to node inbounds within their own scope, so the two 5s polls don't clobber each other and idle inbounds still clear. Deltas clamp to 0 on a reset; a node that fails to sync keeps a stale total so its delta is 0 (no phantom speed).
* fix(nodes): normalize node-inbound speed by elapsed time to avoid recovery spikes
Adversarial review found that a node's cumulative inbound counter keeps climbing while the master can't reach it, so the first delta after a gap (node outage, skipped poll, slow node) spans more than one 5s window but was still divided by the dashboard's fixed 5s — rendering an impossible one-tick speed spike on recovery (and a 2x over-report after a skipped poll). Now each delta is normalized to the fixed window using the real elapsed time since the inbound's counter last changed, so a backlog shows the true average rate over the gap. The change timestamp advances only on actual movement, so idle stretches average correctly when traffic resumes; resets rebaseline. Also moves the maybePushGlobals doc comment back onto its function.
* fix(inbounds): keep last speed across page navigation instead of blanking
Speed is delta-derived, so it can't be recomputed until the first poll after mount. The websocket subscription and speed state are page-scoped (useWebSocket lives in InboundsPage), so leaving to another page and returning blanked the Speed column for up to one 5s poll. Cache the last speed map across mounts (module scope, 15s recency guard) and seed the state from it, so returning shows the last throughput immediately and the next poll refreshes it. Applies to both local and node-hosted inbound speed.
* fix(inbounds): rebalance table column widths so it fills width without gaps
Inbound list columns had small fixed widths summing far below the table's
full width, so AntD spread the leftover space evenly into wide empty gaps.
Widen the content-heavy columns (protocol, clients, traffic, node) so the
slack lands there, keep the small ones (id, port, enable) tight, and make
scroll.x track the visible columns' total so the table never collapses
below content and adapts when conditional columns are hidden.
* feat(nodes): show active/disabled client counts on the nodes page like inbounds
The nodes page only showed total/online/ended, and (since ended now excludes disabled) disabled clients were invisible there. Compute per-node active and disabled counts — in both GetAll and recountByGuid, with the same depleted-wins-over-disabled precedence the inbound page uses so the buckets stay mutually exclusive — and render total/active/disabled/ended/online chips matching the inbound page (table column + mobile stats modal).
* fix(nodes): count active/disabled/ended by client email, not stale inbound_id
The per-node client breakdown filtered client_traffics by inbound_id, but that column goes stale after an inbound is delete+recreated (e.g. the Germany node), so almost every traffic row pointed at a dead inbound id and the counts collapsed — active showed ~5 instead of ~1100. Classify each node client via client_inbounds -> clients joined to client_traffics by EMAIL (the reliable key), deduped per node/guid, in both GetAll and recountByGuid. Now active/disabled/ended on the nodes page match the inbound page. Added a regression test that proves matching works with a deliberately stale inbound_id.
* style(nodes): widen Clients column so the count chips fit one tidy line
After adding the active/disabled chips, the 5 chips (total/active/disabled/ended/online) no longer fit the 160px Clients column and wrapped to two lines. Widen it to 220 and drop the Space wrap so they render on a single line like the inbound page, and zero the total tag's margin for even spacing. Same principle as 79ff283 (give the content column enough width).
* style(nodes): tighten Clients chip spacing to match the inbound page
AntD's default tag side-padding (~8px) put a wide gap between the count chips. Apply the inbound page's compact padding ('0 2px') + client-count-tag (tabular-nums) to each chip and narrow the column to 180 so the numbers sit close together like the inbound list instead of floating apart.
568 lines
23 KiB
TypeScript
568 lines
23 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 { 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';
|
|
|
|
export interface SubSettings {
|
|
enable: boolean;
|
|
subTitle: string;
|
|
subURI: string;
|
|
subJsonURI: string;
|
|
subJsonEnable: boolean;
|
|
// 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>;
|
|
|
|
// Server-side traffic polling interval in seconds. XrayTrafficJob broadcasts
|
|
// deltas accumulated over this window, so dividing by it yields bytes/sec.
|
|
const TRAFFIC_POLL_INTERVAL_S = 5;
|
|
|
|
// 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,
|
|
];
|
|
|
|
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' | 'jalalian') || 'gregorian';
|
|
|
|
const subSettings: SubSettings = useMemo(() => ({
|
|
enable: !!defaults.subEnable,
|
|
subTitle: defaults.subTitle || '',
|
|
subURI: defaults.subURI || '',
|
|
subJsonURI: defaults.subJsonURI || '',
|
|
subJsonEnable: !!defaults.subJsonEnable,
|
|
publicHost: defaults.subDomain || defaults.webDomain || '',
|
|
}), [defaults.subEnable, defaults.subTitle, defaults.subURI, defaults.subJsonURI, defaults.subJsonEnable, defaults.subDomain, defaults.webDomain]);
|
|
|
|
useEffect(() => {
|
|
if (defaults.datepicker) setDatepicker(datepicker);
|
|
}, [datepicker, defaults.datepicker]);
|
|
|
|
const expireDiffRef = useRef(expireDiff);
|
|
expireDiffRef.current = expireDiff;
|
|
const trafficDiffRef = useRef(trafficDiff);
|
|
trafficDiffRef.current = trafficDiff;
|
|
|
|
// dbInbounds mirrors the slim query data wrapped as DBInbound instances, but
|
|
// stays mutable so the WS-driven applyClientStatsEvent / applyTrafficEvent
|
|
// can merge per-row updates without invalidating the entire query.
|
|
const [dbInbounds, setDbInbounds] = useState<DBInboundInstance[]>([]);
|
|
const dbInboundsRef = useRef<DBInboundInstance[]>([]);
|
|
dbInboundsRef.current = dbInbounds;
|
|
|
|
const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
|
|
const [statsVersion, setStatsVersion] = useState(0);
|
|
|
|
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[]>([]);
|
|
const onlineClientsRef = useRef<string[]>([]);
|
|
onlineClientsRef.current = onlineClients;
|
|
|
|
// 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 onlineByGuidRef = useRef<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 activeByGuidRef = useRef<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 = onlineByGuidRef.current.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 = activeByGuidRef.current.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;
|
|
// Depleted wins over disabled (same priority as computeClientsSummary):
|
|
// the auto-disable job also flips client.enable off in settings when a
|
|
// client ends, so checking enable first would file every ended client
|
|
// under "Disabled".
|
|
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 < expireDiffRef.current) ||
|
|
(stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiffRef.current);
|
|
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,
|
|
};
|
|
},
|
|
[],
|
|
);
|
|
|
|
const rebuildClientCount = useCallback(() => {
|
|
const counts: Record<number, ClientRollup> = {};
|
|
for (const dbInbound of dbInboundsRef.current) {
|
|
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 });
|
|
}
|
|
setClientCount(counts);
|
|
}, [rollupClients]);
|
|
|
|
// Seed dbInbounds + clientCount from the slim query. Runs on first fetch and
|
|
// again every time the query refetches (e.g. invalidate from WS bridge).
|
|
useEffect(() => {
|
|
if (!slimQuery.data) return;
|
|
const next: DBInboundInstance[] = [];
|
|
const counts: Record<number, ClientRollup> = {};
|
|
for (const row of slimQuery.data as { protocol: string; id: number }[]) {
|
|
const dbInbound = new DBInbound(row) as DBInboundInstance;
|
|
next.push(dbInbound);
|
|
if (TRACKED_PROTOCOLS.includes(row.protocol)) {
|
|
const settings = coerceInboundJsonField(dbInbound.settings) as {
|
|
method?: string;
|
|
clients?: Array<{ email?: string; enable?: boolean; comment?: string }>;
|
|
};
|
|
if (row.protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol: row.protocol, settings })) continue;
|
|
counts[row.id] = rollupClients(dbInbound, { clients: settings.clients });
|
|
}
|
|
}
|
|
dbInboundsRef.current = next;
|
|
setDbInbounds(next);
|
|
setClientCount(counts);
|
|
}, [slimQuery.data, rollupClients]);
|
|
|
|
useEffect(() => {
|
|
if (onlinesQuery.data) {
|
|
onlineClientsRef.current = onlinesQuery.data;
|
|
setOnlineClients(onlinesQuery.data);
|
|
}
|
|
}, [onlinesQuery.data]);
|
|
|
|
useEffect(() => {
|
|
if (onlinesByGuidQuery.data) {
|
|
onlineByGuidRef.current = toGuidOnlineMap(onlinesByGuidQuery.data);
|
|
rebuildClientCount();
|
|
}
|
|
}, [onlinesByGuidQuery.data, rebuildClientCount]);
|
|
|
|
useEffect(() => {
|
|
if (activeInboundsQuery.data) {
|
|
activeByGuidRef.current = toGuidOnlineMap(activeInboundsQuery.data);
|
|
rebuildClientCount();
|
|
}
|
|
}, [activeInboundsQuery.data, rebuildClientCount]);
|
|
|
|
useEffect(() => {
|
|
if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
|
|
}, [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. The xray config
|
|
// response carries inboundTags for the routing-rule tag picker, so it
|
|
// needs invalidating too or that list stays stale until a hard refresh.
|
|
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.invalidateQueries({ queryKey: keys.xray.config() }),
|
|
]);
|
|
}, [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;
|
|
});
|
|
rebuildClientCount();
|
|
return dbInbound;
|
|
}, [rebuildClientCount]);
|
|
|
|
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)) {
|
|
onlineClientsRef.current = p.onlineClients;
|
|
setOnlineClients(p.onlineClients);
|
|
}
|
|
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
|
|
onlineByGuidRef.current = toGuidOnlineMap(p.onlineByGuid);
|
|
}
|
|
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
|
|
activeByGuidRef.current = 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);
|
|
rebuildClientCount();
|
|
},
|
|
[rebuildClientCount],
|
|
);
|
|
|
|
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 }[];
|
|
};
|
|
let touched = false;
|
|
|
|
if (Array.isArray(p.inbounds) && p.inbounds.length > 0) {
|
|
const byId = new Map<number, { id: number; up?: number; down?: number; total?: number; enable?: boolean }>();
|
|
for (const row of p.inbounds) {
|
|
if (row && row.id != null) byId.set(row.id, row);
|
|
}
|
|
for (const ib of dbInboundsRef.current) {
|
|
const upd = byId.get((ib as unknown as { id: number }).id);
|
|
if (!upd) continue;
|
|
const ibRec = ib as unknown as { up: number; down: number; total: number; enable: boolean };
|
|
if (typeof upd.up === 'number') ibRec.up = upd.up;
|
|
if (typeof upd.down === 'number') ibRec.down = upd.down;
|
|
if (typeof upd.total === 'number') ibRec.total = upd.total;
|
|
if (typeof upd.enable === 'boolean') ibRec.enable = upd.enable;
|
|
touched = true;
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(p.clients) && p.clients.length > 0) {
|
|
const byEmail = new Map<string, { email: string; up?: number; down?: number; total?: number; expiryTime?: number; enable?: boolean }>();
|
|
for (const row of p.clients) {
|
|
if (row && row.email) byEmail.set(row.email, row);
|
|
}
|
|
for (const ib of dbInboundsRef.current) {
|
|
const stats = (ib as unknown as { clientStats: { email: string; up: number; down: number; total: number; expiryTime: number; enable: boolean }[] }).clientStats;
|
|
if (!Array.isArray(stats)) continue;
|
|
for (let i = 0; i < stats.length; i++) {
|
|
const stat = stats[i];
|
|
const upd = byEmail.get(stat.email);
|
|
if (!upd) continue;
|
|
if (typeof upd.up === 'number') stat.up = upd.up;
|
|
if (typeof upd.down === 'number') stat.down = upd.down;
|
|
if (typeof upd.total === 'number') stat.total = upd.total;
|
|
if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
|
|
if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
|
|
touched = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (touched) {
|
|
setStatsVersion((v) => v + 1);
|
|
setDbInbounds((prev) => {
|
|
const next = [...prev];
|
|
dbInboundsRef.current = next;
|
|
return next;
|
|
});
|
|
rebuildClientCount();
|
|
}
|
|
},
|
|
[rebuildClientCount],
|
|
);
|
|
|
|
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,
|
|
statsVersion,
|
|
totals,
|
|
expireDiff,
|
|
trafficDiff,
|
|
subSettings,
|
|
datepicker,
|
|
tgBotEnable,
|
|
ipLimitEnable,
|
|
pageSize,
|
|
refresh,
|
|
hydrateInbound,
|
|
applyTrafficEvent,
|
|
applyClientStatsEvent,
|
|
};
|
|
}
|