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:
MHSanaei
2026-06-27 16:33:36 +02:00
parent d1c0d77023
commit 9b8a0c9b17
20 changed files with 281 additions and 41 deletions
+45 -6
View File
@@ -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 == "" {