mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-26 15:55:02 +03:00
2551a673c3
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.
342 lines
12 KiB
JavaScript
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,
|
|
};
|
|
}
|