mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
feat: add per-client real-time speed & 24h daily traffic
- Added DailyUp/Down/LastDailyReset to ClientTraffic model - Lazy daily reset at 00:00 local time (no cron) - Non-sortable Speed column using dashboard delta logic - i18n keys added to all locale files
This commit is contained in:
parent
c718e7ca5b
commit
982d0c786b
6 changed files with 135 additions and 0 deletions
|
|
@ -54,6 +54,14 @@
|
||||||
</template>
|
</template>
|
||||||
</a-popover>
|
</a-popover>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- SpeedRenderer: Displays real-time client throughput calculated via delta between WebSocket updates.
|
||||||
|
Calculated client-side to reduce server load and avoid constant re-fetching of stats. -->
|
||||||
|
<template slot="speed" slot-scope="text, client">
|
||||||
|
<a-tag color="cyan" style="cursor: default;">
|
||||||
|
<span v-if="client.email">[[ formatClientSpeed(record, client.email) ]]</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
<template slot="client" slot-scope="text, client">
|
<template slot="client" slot-scope="text, client">
|
||||||
<a-space direction="horizontal" :size="2">
|
<a-space direction="horizontal" :size="2">
|
||||||
<a-tooltip>
|
<a-tooltip>
|
||||||
|
|
@ -115,6 +123,21 @@
|
||||||
<template slot="allTime" slot-scope="text, client">
|
<template slot="allTime" slot-scope="text, client">
|
||||||
<a-tag>[[ SizeFormatter.sizeFormat(getAllTimeClient(record, client.email)) ]]</a-tag>
|
<a-tag>[[ SizeFormatter.sizeFormat(getAllTimeClient(record, client.email)) ]]</a-tag>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- DailyTrafficRenderer: Shows cumulative traffic for the current 24h cycle.
|
||||||
|
Includes a popover for granular Up/Down breakdown. -->
|
||||||
|
<template slot="dailyTraffic" slot-scope="text, client">
|
||||||
|
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
<template slot="content" v-if="client.email">
|
||||||
|
<table cellpadding="2" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td>↑[[ SizeFormatter.sizeFormat(getDailyUpStats(record, client.email)) ]]</td>
|
||||||
|
<td>↓[[ SizeFormatter.sizeFormat(getDailyDownStats(record, client.email)) ]]</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
<a-tag>[[ SizeFormatter.sizeFormat(getDailySumStats(record, client.email)) ]]</a-tag>
|
||||||
|
</a-popover>
|
||||||
|
</template>
|
||||||
<template slot="expiryTime" slot-scope="text, client, index">
|
<template slot="expiryTime" slot-scope="text, client, index">
|
||||||
<template v-if="client.expiryTime !=0 && client.reset >0">
|
<template v-if="client.expiryTime !=0 && client.reset >0">
|
||||||
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
|
||||||
|
|
|
||||||
|
|
@ -992,8 +992,12 @@
|
||||||
{ title: '{{ i18n "pages.inbounds.operate" }}', width: 140, scopedSlots: { customRender: 'actions' } },
|
{ title: '{{ i18n "pages.inbounds.operate" }}', width: 140, scopedSlots: { customRender: 'actions' } },
|
||||||
{ title: '{{ i18n "pages.inbounds.enable" }}', width: 60, scopedSlots: { customRender: 'enable' } },
|
{ title: '{{ i18n "pages.inbounds.enable" }}', width: 60, scopedSlots: { customRender: 'enable' } },
|
||||||
{ title: '{{ i18n "online" }}', width: 80, scopedSlots: { customRender: 'online' } },
|
{ title: '{{ i18n "online" }}', width: 80, scopedSlots: { customRender: 'online' } },
|
||||||
|
// Speed column is intentionally non-sortable to prevent UI jitter caused by volatile real-time values.
|
||||||
|
{ title: '{{ i18n "pages.inbounds.speed" }}', width: 90, align: 'center', scopedSlots: { customRender: 'speed' } },
|
||||||
{ title: '{{ i18n "pages.inbounds.client" }}', width: 160, scopedSlots: { customRender: 'client' } },
|
{ title: '{{ i18n "pages.inbounds.client" }}', width: 160, scopedSlots: { customRender: 'client' } },
|
||||||
{ title: '{{ i18n "pages.inbounds.traffic" }}', width: 200, align: 'center', scopedSlots: { customRender: 'traffic' } },
|
{ title: '{{ i18n "pages.inbounds.traffic" }}', width: 200, align: 'center', scopedSlots: { customRender: 'traffic' } },
|
||||||
|
// DynamicMetricsColumns: Adds Speed and Daily Traffic visualization to the table.
|
||||||
|
{ title: '{{ i18n "pages.inbounds.dailyTraffic" }}', width: 100, align: 'center', scopedSlots: { customRender: 'dailyTraffic' } },
|
||||||
{ title: '{{ i18n "pages.inbounds.allTimeTraffic" }}', width: 110, align: 'center', scopedSlots: { customRender: 'allTime' } },
|
{ title: '{{ i18n "pages.inbounds.allTimeTraffic" }}', width: 110, align: 'center', scopedSlots: { customRender: 'allTime' } },
|
||||||
{ title: '{{ i18n "pages.inbounds.expireDate" }}', width: 180, align: 'center', scopedSlots: { customRender: 'expiryTime' } },
|
{ title: '{{ i18n "pages.inbounds.expireDate" }}', width: 180, align: 'center', scopedSlots: { customRender: 'expiryTime' } },
|
||||||
];
|
];
|
||||||
|
|
@ -1064,6 +1068,10 @@
|
||||||
showAlert: false,
|
showAlert: false,
|
||||||
ipLimitEnable: false,
|
ipLimitEnable: false,
|
||||||
pageSize: 0,
|
pageSize: 0,
|
||||||
|
// clientSpeedCache stores the previous traffic snapshot (total bytes, timestamp)
|
||||||
|
// for each client. Used to calculate instantaneous speed on the client-side
|
||||||
|
// without additional API requests.
|
||||||
|
clientSpeedCache: {},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
loading(spinning = true) {
|
loading(spinning = true) {
|
||||||
|
|
@ -1857,6 +1865,88 @@
|
||||||
const allTime = clientStats.allTime || 0;
|
const allTime = clientStats.allTime || 0;
|
||||||
return allTime > current ? allTime : current;
|
return allTime > current ? allTime : current;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// DailyTrafficGetters & SpeedCalculator: Exposes 24h traffic counters (Up/Down/Sum)
|
||||||
|
// and calculates real-time client throughput. Speed computation uses a delta-caching
|
||||||
|
// strategy (1s interval) to prevent UI jitter during high-frequency WebSocket updates.
|
||||||
|
getDailyUpStats(dbInbound, email) {
|
||||||
|
if (!email || email.length == 0) return 0;
|
||||||
|
let clientStats = this.getClientStats(dbInbound, email);
|
||||||
|
return clientStats ? (clientStats.dailyUp || 0) : 0;
|
||||||
|
},
|
||||||
|
getDailyDownStats(dbInbound, email) {
|
||||||
|
if (!email || email.length == 0) return 0;
|
||||||
|
let clientStats = this.getClientStats(dbInbound, email);
|
||||||
|
return clientStats ? (clientStats.dailyDown || 0) : 0;
|
||||||
|
},
|
||||||
|
getDailySumStats(dbInbound, email) {
|
||||||
|
if (!email || email.length == 0) return 0;
|
||||||
|
let clientStats = this.getClientStats(dbInbound, email);
|
||||||
|
if (!clientStats) return 0;
|
||||||
|
return (clientStats.dailyUp || 0) + (clientStats.dailyDown || 0);
|
||||||
|
},
|
||||||
|
// getClientSpeed returns the current speed (bytes/sec) for a client
|
||||||
|
// Calculated from the delta between last known stats and current stats
|
||||||
|
// Uses same logic as dashboard total speed calculation
|
||||||
|
formatClientSpeed(dbInbound, email) {
|
||||||
|
const speed = this.getClientSpeed(dbInbound, email);
|
||||||
|
if (speed > 0) {
|
||||||
|
return SizeFormatter.sizeFormat(speed) + '/s';
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
},
|
||||||
|
getClientSpeed(dbInbound, email) {
|
||||||
|
if (!email || !email.length) return 0;
|
||||||
|
if (!dbInbound || !dbInbound.id) return 0;
|
||||||
|
|
||||||
|
const clientStats = this.getClientStats(dbInbound, email);
|
||||||
|
if (!clientStats) return 0;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const currentUp = clientStats.up || 0;
|
||||||
|
const currentDown = clientStats.down || 0;
|
||||||
|
const currentTotal = currentUp + currentDown;
|
||||||
|
const key = `${dbInbound.id}:${email}`;
|
||||||
|
|
||||||
|
// Initialize cache entry if not exists
|
||||||
|
if (!this.clientSpeedCache[key]) {
|
||||||
|
this.$set(this.clientSpeedCache, key, {
|
||||||
|
up: currentUp,
|
||||||
|
down: currentDown,
|
||||||
|
total: currentTotal,
|
||||||
|
time: now,
|
||||||
|
speed: 0
|
||||||
|
});
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = this.clientSpeedCache[key];
|
||||||
|
const timeDiff = (now - cache.time) / 1000;
|
||||||
|
|
||||||
|
// Only recalculate speed if enough time passed (1 second for stability)
|
||||||
|
if (timeDiff >= 1.0) {
|
||||||
|
const upDelta = currentUp - cache.up;
|
||||||
|
const downDelta = currentDown - cache.down;
|
||||||
|
const totalDelta = upDelta + downDelta;
|
||||||
|
|
||||||
|
// If traffic increased (positive delta), calculate speed
|
||||||
|
if (totalDelta >= 0 && timeDiff > 0) {
|
||||||
|
cache.speed = totalDelta / timeDiff;
|
||||||
|
} else {
|
||||||
|
// Traffic reset or counter overflow - keep last known speed
|
||||||
|
cache.speed = cache.speed || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache with current values
|
||||||
|
cache.up = currentUp;
|
||||||
|
cache.down = currentDown;
|
||||||
|
cache.total = currentTotal;
|
||||||
|
cache.time = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache.speed || 0;
|
||||||
|
},
|
||||||
|
|
||||||
getRemStats(dbInbound, email) {
|
getRemStats(dbInbound, email) {
|
||||||
if (!email || email.length == 0) return 0;
|
if (!email || email.length == 0) return 0;
|
||||||
let clientStats = this.getClientStats(dbInbound, email);
|
let clientStats = this.getClientStats(dbInbound, email);
|
||||||
|
|
|
||||||
|
|
@ -1408,6 +1408,9 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
|
// Calculate start of today in local timezone (Unix milliseconds)
|
||||||
|
nowTime := time.Now()
|
||||||
|
today := time.Date(nowTime.Year(), nowTime.Month(), nowTime.Day(), 0, 0, 0, 0, nowTime.Location()).UnixMilli()
|
||||||
for dbTraffic_index := range dbClientTraffics {
|
for dbTraffic_index := range dbClientTraffics {
|
||||||
t, ok := trafficByEmail[dbClientTraffics[dbTraffic_index].Email]
|
t, ok := trafficByEmail[dbClientTraffics[dbTraffic_index].Email]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -1416,6 +1419,15 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
||||||
dbClientTraffics[dbTraffic_index].Up += t.Up
|
dbClientTraffics[dbTraffic_index].Up += t.Up
|
||||||
dbClientTraffics[dbTraffic_index].Down += t.Down
|
dbClientTraffics[dbTraffic_index].Down += t.Down
|
||||||
dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down
|
dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down
|
||||||
|
// Check if daily reset is needed (lazy reset at first traffic after 00:00)
|
||||||
|
if dbClientTraffics[dbTraffic_index].LastDailyReset < today {
|
||||||
|
dbClientTraffics[dbTraffic_index].DailyUp = 0
|
||||||
|
dbClientTraffics[dbTraffic_index].DailyDown = 0
|
||||||
|
dbClientTraffics[dbTraffic_index].LastDailyReset = today
|
||||||
|
}
|
||||||
|
// Add current traffic to daily counters
|
||||||
|
dbClientTraffics[dbTraffic_index].DailyUp += t.Up
|
||||||
|
dbClientTraffics[dbTraffic_index].DailyDown += t.Down
|
||||||
if t.Up+t.Down > 0 {
|
if t.Up+t.Down > 0 {
|
||||||
onlineClients = append(onlineClients, t.Email)
|
onlineClients = append(onlineClients, t.Email)
|
||||||
dbClientTraffics[dbTraffic_index].LastOnline = now
|
dbClientTraffics[dbTraffic_index].LastOnline = now
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,8 @@
|
||||||
|
|
||||||
[pages.inbounds]
|
[pages.inbounds]
|
||||||
"allTimeTraffic" = "All-time Traffic"
|
"allTimeTraffic" = "All-time Traffic"
|
||||||
|
"speed" = "Speed"
|
||||||
|
"dailyTraffic" = "24h Traffic"
|
||||||
"allTimeTrafficUsage" = "All Time Total Usage"
|
"allTimeTrafficUsage" = "All Time Total Usage"
|
||||||
"title" = "Inbounds"
|
"title" = "Inbounds"
|
||||||
"totalDownUp" = "Total Sent/Received"
|
"totalDownUp" = "Total Sent/Received"
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,8 @@
|
||||||
|
|
||||||
[pages.inbounds]
|
[pages.inbounds]
|
||||||
"allTimeTraffic" = "Общий трафик"
|
"allTimeTraffic" = "Общий трафик"
|
||||||
|
"speed" = "Скорость"
|
||||||
|
"dailyTraffic" = "За сутки"
|
||||||
"allTimeTrafficUsage" = "Общее использование за все время"
|
"allTimeTrafficUsage" = "Общее использование за все время"
|
||||||
"title" = "Подключения"
|
"title" = "Подключения"
|
||||||
"totalDownUp" = "Отправлено/получено"
|
"totalDownUp" = "Отправлено/получено"
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,10 @@ type ClientTraffic struct {
|
||||||
Total int64 `json:"total" form:"total"`
|
Total int64 `json:"total" form:"total"`
|
||||||
Reset int `json:"reset" form:"reset" gorm:"default:0"`
|
Reset int `json:"reset" form:"reset" gorm:"default:0"`
|
||||||
LastOnline int64 `json:"lastOnline" form:"lastOnline" gorm:"default:0"`
|
LastOnline int64 `json:"lastOnline" form:"lastOnline" gorm:"default:0"`
|
||||||
|
// DailyTrafficModelExtension: Adds persistent fields for 24h cycle accounting (DailyUp/Down)
|
||||||
|
// and a timezone-aware reset checkpoint. Enables "lazy reset" logic during traffic updates,
|
||||||
|
// eliminating the need for background cron jobs or scheduled tasks.
|
||||||
|
DailyUp int64 `json:"dailyUp" form:"dailyUp"`
|
||||||
|
DailyDown int64 `json:"dailyDown" form:"dailyDown"`
|
||||||
|
LastDailyReset int64 `json:"lastDailyReset" form:"lastDailyReset" gorm:"default:0"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue