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:
MHSanaei 2026-05-17 13:38:58 +02:00
parent 9db91cda37
commit 750bd93681
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
4 changed files with 232 additions and 100 deletions

View file

@ -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>

View file

@ -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,
};
}

View file

@ -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>

View file

@ -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