mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 08:10:58 +00:00
feat(groups): reset group traffic without touching client counters
The group page shows traffic counting per group, but the only reset available zeroed every member client's up/down counters (and their quotas) via bulkResetTraffic. Group traffic is a derived sum of client traffic, so zeroing the group display previously required mutating the clients themselves. Add a display-only baseline: ClientGroup gains reset_up/reset_down columns (additive, handled by AutoMigrate). ResetGroupTraffic snapshots the group's current up/down sum into the baseline, and ListGroups now reports max(0, sum - baseline). Client counters are left untouched and no Xray restart is triggered. A new POST /panel/api/clients/groups/ resetTraffic endpoint drives it, creating the client_groups row when the group exists only as a derived label. The groups page action now calls the new endpoint; confirm/success strings updated across all 13 locales to reflect group-only semantics.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func groupByName(t *testing.T, svc *ClientService, name string) GroupSummary {
|
||||
t.Helper()
|
||||
rows, err := svc.ListGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("ListGroups: %v", err)
|
||||
}
|
||||
for _, g := range rows {
|
||||
if g.Name == name {
|
||||
return g
|
||||
}
|
||||
}
|
||||
t.Fatalf("group %q not found in %v", name, rows)
|
||||
return GroupSummary{}
|
||||
}
|
||||
|
||||
func seedGroupedClient(t *testing.T, email, group string, up, down int64) {
|
||||
t.Helper()
|
||||
if err := database.GetDB().Create(&model.ClientRecord{Email: email, Enable: true, Group: group}).Error; err != nil {
|
||||
t.Fatalf("seed client record %q: %v", email, err)
|
||||
}
|
||||
seedClientRow(t, email, 1, up, down, 0)
|
||||
}
|
||||
|
||||
func TestResetGroupTraffic_ZeroesGroupButKeepsClients(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
seedGroupedClient(t, "alice", "vip", 100, 200)
|
||||
seedGroupedClient(t, "bob", "vip", 50, 50)
|
||||
|
||||
before := groupByName(t, svc, "vip")
|
||||
if before.Up != 150 || before.Down != 250 || before.TrafficUsed != 400 || before.ClientCount != 2 {
|
||||
t.Fatalf("before reset: got %+v, want up=150 down=250 used=400 count=2", before)
|
||||
}
|
||||
|
||||
if err := svc.ResetGroupTraffic("vip"); err != nil {
|
||||
t.Fatalf("ResetGroupTraffic: %v", err)
|
||||
}
|
||||
|
||||
after := groupByName(t, svc, "vip")
|
||||
if after.Up != 0 || after.Down != 0 || after.TrafficUsed != 0 {
|
||||
t.Fatalf("after reset: got %+v, want up=0 down=0 used=0", after)
|
||||
}
|
||||
if after.ClientCount != 2 {
|
||||
t.Fatalf("after reset: client count changed to %d, want 2", after.ClientCount)
|
||||
}
|
||||
|
||||
var alice xray.ClientTraffic
|
||||
if err := database.GetDB().Where("email = ?", "alice").First(&alice).Error; err != nil {
|
||||
t.Fatalf("load alice traffic: %v", err)
|
||||
}
|
||||
if alice.Up != 100 || alice.Down != 200 {
|
||||
t.Fatalf("client counter modified by group reset: alice up=%d down=%d, want 100/200", alice.Up, alice.Down)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetGroupTraffic_NewTrafficAccumulatesAboveBaseline(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
seedGroupedClient(t, "carol", "team", 100, 100)
|
||||
if err := svc.ResetGroupTraffic("team"); err != nil {
|
||||
t.Fatalf("ResetGroupTraffic: %v", err)
|
||||
}
|
||||
if g := groupByName(t, svc, "team"); g.Up != 0 || g.Down != 0 {
|
||||
t.Fatalf("after reset: got %+v, want up=0 down=0", g)
|
||||
}
|
||||
|
||||
if err := database.GetDB().Table("client_traffics").
|
||||
Where("email = ?", "carol").
|
||||
Updates(map[string]any{"up": 130, "down": 100}).Error; err != nil {
|
||||
t.Fatalf("bump carol traffic: %v", err)
|
||||
}
|
||||
|
||||
g := groupByName(t, svc, "team")
|
||||
if g.Up != 30 || g.Down != 0 || g.TrafficUsed != 30 {
|
||||
t.Fatalf("post-bump: got %+v, want up=30 down=0 used=30", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetGroupTraffic_CreatesRowForDerivedGroup(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
seedGroupedClient(t, "dave", "adhoc", 70, 30)
|
||||
|
||||
var rows int64
|
||||
if err := database.GetDB().Model(&model.ClientGroup{}).Where("name = ?", "adhoc").Count(&rows).Error; err != nil {
|
||||
t.Fatalf("count client_groups: %v", err)
|
||||
}
|
||||
if rows != 0 {
|
||||
t.Fatalf("precondition: derived group should have no client_groups row, got %d", rows)
|
||||
}
|
||||
|
||||
if err := svc.ResetGroupTraffic("adhoc"); err != nil {
|
||||
t.Fatalf("ResetGroupTraffic: %v", err)
|
||||
}
|
||||
|
||||
var stored model.ClientGroup
|
||||
if err := database.GetDB().Where("name = ?", "adhoc").First(&stored).Error; err != nil {
|
||||
t.Fatalf("client_groups row not created: %v", err)
|
||||
}
|
||||
if stored.ResetUp != 70 || stored.ResetDown != 30 {
|
||||
t.Fatalf("baseline not snapshotted: got up=%d down=%d, want 70/30", stored.ResetUp, stored.ResetDown)
|
||||
}
|
||||
if g := groupByName(t, svc, "adhoc"); g.Up != 0 || g.Down != 0 {
|
||||
t.Fatalf("after reset: got %+v, want up=0 down=0", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetGroupTraffic_EmptyNameRejected(t *testing.T) {
|
||||
initTrafficTestDB(t)
|
||||
svc := &ClientService{}
|
||||
if err := svc.ResetGroupTraffic(" "); err == nil {
|
||||
t.Fatal("ResetGroupTraffic(blank) = nil, want error")
|
||||
}
|
||||
}
|
||||
@@ -36,21 +36,32 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
|
||||
return nil, err
|
||||
}
|
||||
type groupAgg struct {
|
||||
count int
|
||||
traffic int64
|
||||
up int64
|
||||
down int64
|
||||
count int
|
||||
up int64
|
||||
down int64
|
||||
}
|
||||
baseUp := make(map[string]int64, len(stored))
|
||||
baseDown := make(map[string]int64, len(stored))
|
||||
merged := make(map[string]groupAgg, len(derived)+len(stored))
|
||||
for _, g := range stored {
|
||||
merged[g.Name] = groupAgg{}
|
||||
baseUp[g.Name] = g.ResetUp
|
||||
baseDown[g.Name] = g.ResetDown
|
||||
}
|
||||
for _, g := range derived {
|
||||
merged[g.Name] = groupAgg{count: g.ClientCount, traffic: g.TrafficUsed, up: g.Up, down: g.Down}
|
||||
merged[g.Name] = groupAgg{count: g.ClientCount, up: g.Up, down: g.Down}
|
||||
}
|
||||
out := make([]GroupSummary, 0, len(merged))
|
||||
for name, agg := range merged {
|
||||
out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: agg.traffic, Up: agg.up, Down: agg.down})
|
||||
up := agg.up - baseUp[name]
|
||||
if up < 0 {
|
||||
up = 0
|
||||
}
|
||||
down := agg.down - baseDown[name]
|
||||
if down < 0 {
|
||||
down = 0
|
||||
}
|
||||
out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: up + down, Up: up, Down: down})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||
@@ -77,6 +88,34 @@ func (s *ClientService) EmailsByGroup(name string) ([]string, error) {
|
||||
return emails, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) ResetGroupTraffic(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return common.NewError("group name is required")
|
||||
}
|
||||
db := database.GetDB()
|
||||
var agg struct {
|
||||
Up int64
|
||||
Down int64
|
||||
}
|
||||
if err := db.Table("clients AS c").
|
||||
Select("COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down").
|
||||
Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
|
||||
Where("c.group_name = ?", name).
|
||||
Scan(&agg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.ClientGroup{}).Where("name = ?", name).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return db.Create(&model.ClientGroup{Name: name, ResetUp: agg.Up, ResetDown: agg.Down}).Error
|
||||
}
|
||||
return db.Model(&model.ClientGroup{}).Where("name = ?", name).
|
||||
Updates(map[string]any{"reset_up": agg.Up, "reset_down": agg.Down}).Error
|
||||
}
|
||||
|
||||
func (s *ClientService) CreateGroup(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
|
||||
Reference in New Issue
Block a user