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:
sibirka 2026-05-06 20:44:45 +03:00
parent c718e7ca5b
commit 982d0c786b
6 changed files with 135 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -221,6 +221,8 @@
[pages.inbounds] [pages.inbounds]
"allTimeTraffic" = "Общий трафик" "allTimeTraffic" = "Общий трафик"
"speed" = "Скорость"
"dailyTraffic" = "За сутки"
"allTimeTrafficUsage" = "Общее использование за все время" "allTimeTrafficUsage" = "Общее использование за все время"
"title" = "Подключения" "title" = "Подключения"
"totalDownUp" = "Отправлено/получено" "totalDownUp" = "Отправлено/получено"

View file

@ -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"`
} }