mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
feat(clients): live WebSocket updates + Ended status surfacing
ClientsPage now subscribes to traffic / client_stats / invalidate WebSocket events instead of polling /onlines every 10s. Per-row traffic counters refresh in place, online state stays current, and list-level mutations elsewhere trigger a refresh. The client roll-up summary moves from InboundsPage to ClientsPage where it belongs, restructured into six labeled stat tiles (Total / Online / Ended / Expiring / Disabled / Active) with email popovers on the ones with issues. Auto-disabled clients (traffic exhausted or expiry passed) now classify as 'depleted' even though clients.enable=false, so they show up under the Ended filter and render a red Ended tag instead of looking indistinguishable from an operator-disabled row. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
9db91cda37
commit
750bd93681
4 changed files with 232 additions and 100 deletions
|
|
@ -15,11 +15,14 @@ import {
|
|||
UsergroupAddOutlined,
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
import { useWebSocket } from '@/composables/useWebSocket.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import CustomStatistic from '@/components/CustomStatistic.vue';
|
||||
import { ObjectUtil, SizeFormatter, IntlUtil } from '@/utils';
|
||||
import { useClients } from './useClients.js';
|
||||
import ClientFormModal from './ClientFormModal.vue';
|
||||
|
|
@ -48,8 +51,17 @@ const {
|
|||
resetAllTraffics,
|
||||
delDepleted,
|
||||
setEnable,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
} = useClients();
|
||||
|
||||
useWebSocket({
|
||||
traffic: applyTrafficEvent,
|
||||
client_stats: applyClientStatsEvent,
|
||||
invalidate: applyInvalidate,
|
||||
});
|
||||
|
||||
const togglingId = ref(null);
|
||||
|
||||
async function onToggleEnable(row, next) {
|
||||
|
|
@ -214,7 +226,6 @@ function inboundLabel(id) {
|
|||
|
||||
function clientBucket(row) {
|
||||
if (!row) return null;
|
||||
if (!row.enable) return 'deactive';
|
||||
const traffic = row.traffic || {};
|
||||
const used = (traffic.up || 0) + (traffic.down || 0);
|
||||
const total = row.totalGB || 0;
|
||||
|
|
@ -222,12 +233,23 @@ function clientBucket(row) {
|
|||
const expired = row.expiryTime > 0 && row.expiryTime <= now;
|
||||
const exhausted = total > 0 && used >= total;
|
||||
if (expired || exhausted) return 'depleted';
|
||||
if (!row.enable) return 'deactive';
|
||||
const nearExpiry = row.expiryTime > 0 && row.expiryTime - now < (expireDiff.value || 0);
|
||||
const nearLimit = total > 0 && total - used < (trafficDiff.value || 0);
|
||||
if (nearExpiry || nearLimit) return 'expiring';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function bucketTagColor(bucket) {
|
||||
switch (bucket) {
|
||||
case 'depleted': return 'red';
|
||||
case 'expiring': return 'orange';
|
||||
case 'deactive': return 'default';
|
||||
case 'active': return 'green';
|
||||
default: return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
function clientMatchesProtocol(row, protocol) {
|
||||
if (!protocol) return true;
|
||||
const ids = Array.isArray(row.inboundIds) ? row.inboundIds : [];
|
||||
|
|
@ -255,6 +277,24 @@ const filteredClients = computed(() => {
|
|||
return rows;
|
||||
});
|
||||
|
||||
const summary = computed(() => {
|
||||
const rows = clients.value || [];
|
||||
const deactive = [];
|
||||
const depleted = [];
|
||||
const expiring = [];
|
||||
const online = [];
|
||||
let active = 0;
|
||||
for (const row of rows) {
|
||||
const bucket = clientBucket(row);
|
||||
if (bucket === 'deactive') deactive.push(row.email);
|
||||
else if (bucket === 'depleted') depleted.push(row.email);
|
||||
else if (bucket === 'expiring') expiring.push(row.email);
|
||||
else if (bucket === 'active') active++;
|
||||
if (row.enable && isOnline(row.email)) online.push(row.email);
|
||||
}
|
||||
return { total: rows.length, active, deactive, depleted, expiring, online };
|
||||
});
|
||||
|
||||
function onAdd() {
|
||||
formMode.value = 'add';
|
||||
editingClient.value = null;
|
||||
|
|
@ -412,6 +452,83 @@ const columns = computed(() => [
|
|||
<div v-if="!fetched" class="loading-spacer" />
|
||||
|
||||
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
|
||||
<a-col :span="24">
|
||||
<a-card size="small" hoverable class="summary-card">
|
||||
<a-row :gutter="[16, 12]">
|
||||
<a-col :xs="12" :sm="8" :md="4">
|
||||
<CustomStatistic :title="t('clients')" :value="String(summary.total)">
|
||||
<template #prefix>
|
||||
<TeamOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="4">
|
||||
<a-popover :title="t('online')" :open="summary.online.length ? undefined : false">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in summary.online" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<CustomStatistic :title="t('online')" :value="String(summary.online.length)">
|
||||
<template #prefix>
|
||||
<span class="dot dot-blue" />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-popover>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="4">
|
||||
<a-popover :title="t('depleted')" :open="summary.depleted.length ? undefined : false">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in summary.depleted" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<CustomStatistic :title="t('depleted')" :value="String(summary.depleted.length)">
|
||||
<template #prefix>
|
||||
<span class="dot dot-red" />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-popover>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="4">
|
||||
<a-popover :title="t('depletingSoon')" :open="summary.expiring.length ? undefined : false">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in summary.expiring" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<CustomStatistic :title="t('depletingSoon')" :value="String(summary.expiring.length)">
|
||||
<template #prefix>
|
||||
<span class="dot dot-orange" />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-popover>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="4">
|
||||
<a-popover :title="t('disabled')" :open="summary.deactive.length ? undefined : false">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in summary.deactive" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<CustomStatistic :title="t('disabled')" :value="String(summary.deactive.length)">
|
||||
<template #prefix>
|
||||
<span class="dot dot-gray" />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-popover>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="4">
|
||||
<CustomStatistic :title="t('subscription.active')" :value="String(summary.active)">
|
||||
<template #prefix>
|
||||
<span class="dot dot-green" />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-card size="small">
|
||||
<template #title>
|
||||
|
|
@ -490,9 +607,16 @@ const columns = computed(() => [
|
|||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'online'">
|
||||
<a-tag v-if="record.enable && isOnline(record.email)" color="green">{{ t('pages.clients.online')
|
||||
|| 'Online'
|
||||
}}</a-tag>
|
||||
<a-tag v-if="clientBucket(record) === 'depleted'" color="red">
|
||||
{{ t('depleted') }}
|
||||
</a-tag>
|
||||
<a-tag v-else-if="record.enable && isOnline(record.email)" color="green">
|
||||
{{ t('pages.clients.online') || 'Online' }}
|
||||
</a-tag>
|
||||
<a-tag v-else-if="!record.enable">{{ t('disabled') }}</a-tag>
|
||||
<a-tag v-else-if="clientBucket(record) === 'expiring'" color="orange">
|
||||
{{ t('depletingSoon') }}
|
||||
</a-tag>
|
||||
<a-tag v-else>{{ t('pages.clients.offline') || 'Offline' }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'inboundIds'">
|
||||
|
|
@ -580,9 +704,14 @@ const columns = computed(() => [
|
|||
<div class="card-head">
|
||||
<a-checkbox :checked="isSelected(row.id)"
|
||||
@change="(e) => toggleSelect(row.id, e.target.checked)" />
|
||||
<a-badge
|
||||
:color="row.enable && isOnline(row.email) ? 'green' : (row.enable ? 'default' : 'red')" />
|
||||
<a-badge :color="bucketTagColor(clientBucket(row))" />
|
||||
<span class="tag-name">{{ row.email }}</span>
|
||||
<a-tag v-if="clientBucket(row) === 'depleted'" color="red" class="status-tag">
|
||||
{{ t('depleted') }}
|
||||
</a-tag>
|
||||
<a-tag v-else-if="clientBucket(row) === 'expiring'" color="orange" class="status-tag">
|
||||
{{ t('depletingSoon') }}
|
||||
</a-tag>
|
||||
<div class="card-actions" @click.stop>
|
||||
<a-tooltip :title="t('pages.clients.moreInformation') || 'Info'">
|
||||
<InfoCircleOutlined class="row-action-trigger" @click="onShowInfo(row)" />
|
||||
|
|
@ -689,6 +818,38 @@ const columns = computed(() => [
|
|||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.summary-card {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dot-green { background: #52c41a; }
|
||||
.dot-blue { background: #1677ff; }
|
||||
.dot-red { background: #ff4d4f; }
|
||||
.dot-orange { background: #fa8c16; }
|
||||
.dot-gray { background: rgba(128, 128, 128, 0.6); }
|
||||
|
||||
.status-tag {
|
||||
margin: 0 0 0 4px;
|
||||
font-size: 11px;
|
||||
padding: 0 6px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.card-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -807,3 +968,20 @@ const columns = computed(() => [
|
|||
color: #ff4d4f;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* AD-Vue popovers teleport their content to <body>, so scoped styles
|
||||
don't reach them — this block has to be unscoped. */
|
||||
.client-email-list {
|
||||
max-height: 280px;
|
||||
min-width: 160px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.client-email-list > div {
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
||||
import { onMounted, ref, shallowRef } from 'vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||
const ONLINES_POLL_MS = 10000;
|
||||
|
||||
export function useClients() {
|
||||
const clients = shallowRef([]);
|
||||
|
|
@ -14,7 +13,6 @@ export function useClients() {
|
|||
const ipLimitEnable = ref(false);
|
||||
const expireDiff = ref(0);
|
||||
const trafficDiff = ref(0);
|
||||
let onlinesTimer = null;
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
|
|
@ -50,13 +48,6 @@ export function useClients() {
|
|||
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
|
||||
}
|
||||
|
||||
async function refreshOnlines() {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/onlines');
|
||||
if (msg?.success) {
|
||||
onlines.value = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
}
|
||||
}
|
||||
|
||||
async function create(payload) {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS);
|
||||
if (msg?.success) await refresh();
|
||||
|
|
@ -127,14 +118,48 @@ export function useClients() {
|
|||
return update(client.id, payload);
|
||||
}
|
||||
|
||||
function applyTrafficEvent(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
if (Array.isArray(payload.onlineClients)) {
|
||||
onlines.value = payload.onlineClients;
|
||||
}
|
||||
}
|
||||
|
||||
function applyClientStatsEvent(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
if (!Array.isArray(payload.clients) || payload.clients.length === 0) return;
|
||||
const byEmail = new Map();
|
||||
for (const row of payload.clients) {
|
||||
if (row && row.email) byEmail.set(row.email, row);
|
||||
}
|
||||
let touched = false;
|
||||
const next = clients.value || [];
|
||||
for (let i = 0; i < next.length; i++) {
|
||||
const row = next[i];
|
||||
const upd = byEmail.get(row?.email);
|
||||
if (!upd) continue;
|
||||
const merged = { ...(row.traffic || {}) };
|
||||
if (typeof upd.up === 'number') merged.up = upd.up;
|
||||
if (typeof upd.down === 'number') merged.down = upd.down;
|
||||
if (typeof upd.total === 'number') merged.total = upd.total;
|
||||
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
|
||||
if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
|
||||
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
|
||||
next[i] = { ...row, traffic: merged };
|
||||
touched = true;
|
||||
}
|
||||
if (touched) clients.value = [...next];
|
||||
}
|
||||
|
||||
function applyInvalidate(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
if (payload.type === 'inbounds' || payload.type === 'clients') {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([refresh(), fetchSubSettings()]);
|
||||
refreshOnlines();
|
||||
onlinesTimer = setInterval(refreshOnlines, ONLINES_POLL_MS);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (onlinesTimer) clearInterval(onlinesTimer);
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -148,7 +173,6 @@ export function useClients() {
|
|||
expireDiff,
|
||||
trafficDiff,
|
||||
refresh,
|
||||
refreshOnlines,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
|
|
@ -158,5 +182,8 @@ export function useClients() {
|
|||
resetAllTraffics,
|
||||
delDepleted,
|
||||
setEnable,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
SwapOutlined,
|
||||
PieChartOutlined,
|
||||
BarsOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||
|
|
@ -428,7 +427,7 @@ function onRowAction({ key, dbInbound }) {
|
|||
<a-col :span="24">
|
||||
<a-card size="small" hoverable class="summary-card">
|
||||
<a-row :gutter="[16, 12]">
|
||||
<a-col :xs="12" :sm="12" :md="5">
|
||||
<a-col :xs="12" :sm="12" :md="8">
|
||||
<CustomStatistic :title="t('pages.inbounds.totalDownUp')"
|
||||
:value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
|
||||
<template #prefix>
|
||||
|
|
@ -436,7 +435,7 @@ function onRowAction({ key, dbInbound }) {
|
|||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :md="5">
|
||||
<a-col :xs="12" :sm="12" :md="8">
|
||||
<CustomStatistic :title="t('pages.inbounds.totalUsage')"
|
||||
:value="SizeFormatter.sizeFormat(totals.up + totals.down)">
|
||||
<template #prefix>
|
||||
|
|
@ -444,55 +443,13 @@ function onRowAction({ key, dbInbound }) {
|
|||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :md="5">
|
||||
<a-col :xs="24" :sm="24" :md="8">
|
||||
<CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
|
||||
<template #prefix>
|
||||
<BarsOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="24" :md="4">
|
||||
<CustomStatistic :title="t('clients')" value=" ">
|
||||
<template #prefix>
|
||||
<a-space direction="horizontal">
|
||||
<TeamOutlined />
|
||||
<a-tag color="green">{{ totals.clients }}</a-tag>
|
||||
<a-popover v-if="totals.deactive.length" :title="t('disabled')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.deactive" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag>{{ totals.deactive.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="totals.depleted.length" :title="t('depleted')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.depleted" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="red">{{ totals.depleted.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="totals.expiring.length" :title="t('depletingSoon')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.expiring" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="orange">{{ totals.expiring.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="totals.online.length" :title="t('online')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.online" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="blue">{{ totals.online.length }}</a-tag>
|
||||
</a-popover>
|
||||
</a-space>
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
|
@ -580,20 +537,3 @@ function onRowAction({ key, dbInbound }) {
|
|||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* AD-Vue popovers teleport their content to <body>, so scoped styles
|
||||
don't reach them — this block has to be unscoped. */
|
||||
.client-email-list {
|
||||
max-height: 280px;
|
||||
min-width: 160px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.client-email-list > div {
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -284,24 +284,11 @@ export function useInbounds() {
|
|||
const totals = computed(() => {
|
||||
let up = 0;
|
||||
let down = 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;
|
||||
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, clients, deactive, depleted, expiring, online };
|
||||
return { up, down };
|
||||
});
|
||||
|
||||
// ObjectUtil reference is wired at module load — keeping a no-op import
|
||||
|
|
|
|||
Loading…
Reference in a new issue