diff --git a/frontend/src/pages/clients/ClientsPage.vue b/frontend/src/pages/clients/ClientsPage.vue
index a03efa83..b506a0d3 100644
--- a/frontend/src/pages/clients/ClientsPage.vue
+++ b/frontend/src/pages/clients/ClientsPage.vue
@@ -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(() => [
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -490,9 +607,16 @@ const columns = computed(() => [
- {{ t('pages.clients.online')
- || 'Online'
- }}
+
+ {{ t('depleted') }}
+
+
+ {{ t('pages.clients.online') || 'Online' }}
+
+ {{ t('disabled') }}
+
+ {{ t('depletingSoon') }}
+
{{ t('pages.clients.offline') || 'Offline' }}
@@ -580,9 +704,14 @@ const columns = computed(() => [
toggleSelect(row.id, e.target.checked)" />
-
+
{{ row.email }}
+
+ {{ t('depleted') }}
+
+
+ {{ t('depletingSoon') }}
+
@@ -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;
}
+
+
diff --git a/frontend/src/pages/clients/useClients.js b/frontend/src/pages/clients/useClients.js
index bc9ae8b9..dc9e00e8 100644
--- a/frontend/src/pages/clients/useClients.js
+++ b/frontend/src/pages/clients/useClients.js
@@ -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,
};
}
diff --git a/frontend/src/pages/inbounds/InboundsPage.vue b/frontend/src/pages/inbounds/InboundsPage.vue
index 796600d0..bc42bccf 100644
--- a/frontend/src/pages/inbounds/InboundsPage.vue
+++ b/frontend/src/pages/inbounds/InboundsPage.vue
@@ -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 }) {
-
+
@@ -436,7 +435,7 @@ function onRowAction({ key, dbInbound }) {
-
+
@@ -444,55 +443,13 @@ function onRowAction({ key, dbInbound }) {
-
+
-
-
-
-
-
- {{ totals.clients }}
-
-
-
-
- {{ totals.deactive.length }}
-
-
-
-
-
- {{ totals.depleted.length }}
-
-
-
-
-
- {{ totals.expiring.length }}
-
-
-
-
-
- {{ totals.online.length }}
-
-
-
-
-
@@ -580,20 +537,3 @@ function onRowAction({ key, dbInbound }) {
}
}
-
-
diff --git a/frontend/src/pages/inbounds/useInbounds.js b/frontend/src/pages/inbounds/useInbounds.js
index d6d50424..b72c11a5 100644
--- a/frontend/src/pages/inbounds/useInbounds.js
+++ b/frontend/src/pages/inbounds/useInbounds.js
@@ -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