Files
3x-ui/frontend/src/pages/inbounds/useInbounds.js
T
MHSanaei 2551a673c3 fix(inbounds): refresh client rows live over websocket
Two bugs combined to leave per-client traffic / remained / all-time
columns stuck at stale numbers while only the inbound-level row and
the online badge refreshed:

1. Backend (xray + node sync traffic jobs) only included the per-client
   array in the client_stats broadcast when activeEmails / touched
   was non-empty. Cycles with no client deltas — or any node sync that
   failed to fetch a snapshot — shipped only the inbound summary, so
   the frontend had nothing to merge for clients. Replaced both code
   paths with a single GetAllClientTraffics() snapshot per cycle; the
   broadcast now always carries the full client list.

2. Frontend mutated dbInbound.clientStats[i] in place. DBInbound is a
   plain class instance (not wrapped in reactive()), so Vue could not
   see the field-level changes and ClientRowTable's statsMap computed
   stayed cached forever. Added a statsVersion tick bumped on every
   merge and read inside statsMap so the computed re-evaluates and the
   template pulls fresh up/down/allTime/expiryTime each push.

Removed the now-dead emailSet helper from node_traffic_sync_job and
the activeEmails filter from xray_traffic_job.
2026-05-14 01:31:49 +02:00

342 lines
12 KiB
JavaScript

// Loads the inbound list + sidecar data the page needs (online users,
// last-online-map, default settings) and computes the per-inbound client
// roll-ups the legacy panel surfaces in the popovers.
//
// Live-update model: initial GET on mount, then the WebSocket delta path
// keeps the table fresh — the page subscribes to the server's `traffic`,
// `client_stats`, and `invalidate` events and merges them into local
// refs in-place. The manual refresh button is kept as a fallback.
import { computed, ref, shallowRef } from 'vue';
import { HttpUtil, ObjectUtil } from '@/utils';
import { DBInbound } from '@/models/dbinbound.js';
import { Protocols } from '@/models/inbound.js';
import { setDatepicker } from '@/composables/useDatepicker.js';
export function useInbounds() {
const fetched = ref(false);
const refreshing = ref(false);
// shallowRef because each refresh swaps the array; per-row reactivity is
// unnecessary at the page level (modals work on copies).
const dbInbounds = shallowRef([]);
const clientCount = ref({});
const onlineClients = ref([]);
const lastOnlineMap = ref({});
// Bumps on every client_stats merge so the per-inbound ClientRowTable
// child can re-render. DBInbound is a plain class instance, not reactive,
// so the in-place mutations on its clientStats array are invisible to
// Vue's tracking unless something else (this tick) signals the change.
const statsVersion = ref(0);
// Default-settings sidecar fields the table needs for color/expiry math.
const expireDiff = ref(0);
const trafficDiff = ref(0);
const subSettings = ref({
enable: false,
subTitle: '',
subURI: '',
subJsonURI: '',
subJsonEnable: false,
});
const remarkModel = ref('-ieo');
const datepicker = ref('gregorian');
const tgBotEnable = ref(false);
const ipLimitEnable = ref(false);
const pageSize = ref(0);
function isClientOnline(email) {
return onlineClients.value.includes(email);
}
// Roll-up of {clients, active, deactive, depleted, expiring, online,
// comments} for a single inbound. Mirrors getClientCounts in the legacy
// template. Skipped for protocols that don't have multi-user clients
// (HTTP, MIXED, WireGuard) since their settings have no client list.
function rollupClients(dbInbound, inbound) {
const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
const clients = inbound?.clients || [];
const active = [];
const deactive = [];
const depleted = [];
const expiring = [];
const online = [];
const comments = new Map();
const now = Date.now();
if (dbInbound.enable) {
for (const client of clients) {
if (client.comment) comments.set(client.email, client.comment);
if (client.enable) {
active.push(client.email);
if (isClientOnline(client.email)) online.push(client.email);
} else {
deactive.push(client.email);
}
}
for (const stats of clientStats) {
const exhausted = stats.total > 0 && stats.up + stats.down >= stats.total;
const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
if (expired || exhausted) {
depleted.push(stats.email);
} else {
const expiringSoon =
(stats.expiryTime > 0 && stats.expiryTime - now < expireDiff.value) ||
(stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiff.value);
if (expiringSoon) expiring.push(stats.email);
}
}
} else {
for (const client of clients) deactive.push(client.email);
}
return {
clients: clients.length,
active,
deactive,
depleted,
expiring,
online,
comments,
};
}
function setInbounds(rows) {
const next = [];
const counts = {};
for (const row of rows) {
const dbInbound = new DBInbound(row);
const parsed = dbInbound.toInbound();
next.push(dbInbound);
const tracked = [
Protocols.VMESS,
Protocols.VLESS,
Protocols.TROJAN,
Protocols.SHADOWSOCKS,
Protocols.HYSTERIA,
];
if (tracked.includes(row.protocol)) {
if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
counts[row.id] = rollupClients(dbInbound, parsed);
}
}
dbInbounds.value = next;
clientCount.value = counts;
fetched.value = true;
}
async function fetchOnlineUsers() {
const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
if (msg?.success) onlineClients.value = msg.obj || [];
}
async function fetchLastOnlineMap() {
const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
if (msg?.success && msg.obj) lastOnlineMap.value = msg.obj;
}
async function fetchDefaultSettings() {
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
if (!msg?.success) return;
const s = msg.obj || {};
expireDiff.value = (s.expireDiff ?? 0) * 86400000;
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
tgBotEnable.value = !!s.tgBotEnable;
subSettings.value = {
enable: !!s.subEnable,
subTitle: s.subTitle || '',
subURI: s.subURI || '',
subJsonURI: s.subJsonURI || '',
subJsonEnable: !!s.subJsonEnable,
};
pageSize.value = s.pageSize ?? 0;
remarkModel.value = s.remarkModel || '-ieo';
datepicker.value = s.datepicker || 'gregorian';
// Mirror into the global composable so date-pickers in modals can
// pick the right calendar without re-fetching the settings.
setDatepicker(datepicker.value);
ipLimitEnable.value = !!s.ipLimitEnable;
}
// ============ WebSocket live-update merge ===========================
// The xray traffic job and the node traffic sync job each broadcast
// a `traffic` payload every ~10s. We merge it into onlineClients +
// lastOnlineMap; per-inbound counters arrive in the parallel
// client_stats event below.
function applyTrafficEvent(payload) {
if (!payload || typeof payload !== 'object') return;
if (Array.isArray(payload.onlineClients)) {
onlineClients.value = payload.onlineClients;
}
if (payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
// Merge so a subsequent payload that drops a quiet client doesn't
// wipe their last-seen timestamp.
lastOnlineMap.value = { ...lastOnlineMap.value, ...payload.lastOnlineMap };
}
// Recompute per-inbound rollups so the "online" badges in the
// expand-row table flip without waiting for a full refresh.
rebuildClientCount();
}
// The client_stats payload carries absolute traffic counters for every
// client + per-inbound totals (full snapshot, not deltas). Both are
// overwritten in place.
function applyClientStatsEvent(payload) {
if (!payload || typeof payload !== 'object') return;
let touched = false;
if (Array.isArray(payload.inbounds) && payload.inbounds.length > 0) {
const byId = new Map();
for (const row of payload.inbounds) {
if (row && row.id != null) byId.set(row.id, row);
}
for (const ib of dbInbounds.value) {
const upd = byId.get(ib.id);
if (!upd) continue;
if (typeof upd.up === 'number') ib.up = upd.up;
if (typeof upd.down === 'number') ib.down = upd.down;
if (typeof upd.allTime === 'number') ib.allTime = upd.allTime;
if (typeof upd.total === 'number') ib.total = upd.total;
if (typeof upd.enable === 'boolean') ib.enable = upd.enable;
touched = true;
}
}
if (Array.isArray(payload.clients) && payload.clients.length > 0) {
const byEmail = new Map();
for (const row of payload.clients) {
if (row && row.email) byEmail.set(row.email, row);
}
for (const ib of dbInbounds.value) {
if (!Array.isArray(ib.clientStats)) continue;
for (let i = 0; i < ib.clientStats.length; i++) {
const stat = ib.clientStats[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.allTime === 'number') stat.allTime = upd.allTime;
if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
touched = true;
}
}
}
if (touched) {
statsVersion.value++;
dbInbounds.value = [...dbInbounds.value];
rebuildClientCount();
}
}
// The hub may decide a payload is too large to push directly and emit
// an `invalidate` event with the affected dataType instead. For the
// inbounds page that means "the inbound list changed elsewhere — go
// re-fetch via REST".
function applyInvalidate(payload) {
if (!payload || typeof payload !== 'object') return;
if (payload.type === 'inbounds') {
refresh();
}
}
function applyInboundsEvent(payload) {
if (!Array.isArray(payload)) return;
setInbounds(payload);
}
// Recompute the per-inbound roll-up after any in-place mutation.
// Cheap because rollupClients only iterates a single inbound's
// clients + clientStats arrays.
function rebuildClientCount() {
const counts = {};
const tracked = [
Protocols.VMESS,
Protocols.VLESS,
Protocols.TROJAN,
Protocols.SHADOWSOCKS,
Protocols.HYSTERIA,
];
for (const dbInbound of dbInbounds.value) {
const parsed = dbInbound.toInbound();
if (!tracked.includes(dbInbound.protocol)) continue;
if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
counts[dbInbound.id] = rollupClients(dbInbound, parsed);
}
clientCount.value = counts;
}
async function refresh() {
refreshing.value = true;
try {
const msg = await HttpUtil.get('/panel/api/inbounds/list');
if (!msg?.success) return;
await fetchLastOnlineMap();
await fetchOnlineUsers();
setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
} finally {
// Match legacy: keep the spinning-icon state visible briefly so
// a fast network doesn't make the button feel like it didn't fire.
setTimeout(() => { refreshing.value = false; }, 500);
}
}
// Aggregate totals shown in the dashboard summary card. allTime falls
// back to up+down when the per-inbound counter isn't populated yet.
const totals = computed(() => {
let up = 0;
let down = 0;
let allTime = 0;
let clients = 0;
const deactive = [];
const depleted = [];
const expiring = [];
const online = [];
for (const ib of dbInbounds.value) {
up += ib.up || 0;
down += ib.down || 0;
allTime += ib.allTime || (ib.up + ib.down) || 0;
const c = clientCount.value[ib.id];
if (c) {
clients += c.clients;
deactive.push(...c.deactive);
depleted.push(...c.depleted);
expiring.push(...c.expiring);
online.push(...c.online);
}
}
return { up, down, allTime, clients, deactive, depleted, expiring, online };
});
// ObjectUtil reference is wired at module load — keeping a no-op import
// here so the linter doesn't drop it; the legacy search uses it.
void ObjectUtil;
return {
fetched,
refreshing,
dbInbounds,
clientCount,
onlineClients,
lastOnlineMap,
statsVersion,
totals,
expireDiff,
trafficDiff,
subSettings,
remarkModel,
datepicker,
tgBotEnable,
ipLimitEnable,
pageSize,
refresh,
fetchDefaultSettings,
applyTrafficEvent,
applyClientStatsEvent,
applyInvalidate,
applyInboundsEvent,
};
}