Pārlūkot izejas kodu

feat(groups): show used traffic per group in groups table

Sum up+down across each group's clients via a LEFT JOIN on email in
ListGroups, expose it as trafficUsed on GroupSummary, and render it as a
new column plus a "Total traffic" summary card. Drops the unused "Empty
groups" card and its translation key.
Sanaei 7 stundas atpakaļ
vecāks
revīzija
1fa51cf0f2

+ 13 - 5
frontend/src/pages/groups/GroupsPage.tsx

@@ -41,7 +41,7 @@ import { useTheme } from '@/hooks/useTheme';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { usePageTitle } from '@/hooks/usePageTitle';
 import { useClients } from '@/hooks/useClients';
-import { HttpUtil } from '@/utils';
+import { HttpUtil, SizeFormatter } from '@/utils';
 import { setMessageInstance } from '@/utils/messageBus';
 import AppSidebar from '@/layouts/AppSidebar';
 import { LazyMount } from '@/components/utility';
@@ -161,8 +161,8 @@ export default function GroupsPage() {
     () => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0),
     [groups],
   );
-  const emptyGroups = useMemo(
-    () => groups.filter((g) => (g.clientCount || 0) === 0).length,
+  const totalTraffic = useMemo(
+    () => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
     [groups],
   );
 
@@ -417,6 +417,13 @@ export default function GroupsPage() {
       width: 180,
       render: (count: number) => <span>{count || 0}</span>,
     },
+    {
+      title: t('pages.groups.trafficUsed'),
+      dataIndex: 'trafficUsed',
+      key: 'trafficUsed',
+      width: 160,
+      render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
+    },
   ];
 
   const pageClass = useMemo(() => {
@@ -465,8 +472,9 @@ export default function GroupsPage() {
                         </Col>
                         <Col xs={12} sm={8} md={6}>
                           <Statistic
-                            title={t('pages.groups.emptyGroups')}
-                            value={String(emptyGroups)}
+                            title={t('pages.groups.totalTraffic')}
+                            value={SizeFormatter.sizeFormat(totalTraffic)}
+                            prefix={<RetweetOutlined />}
                           />
                         </Col>
                       </Row>

+ 1 - 0
frontend/src/schemas/client.ts

@@ -126,6 +126,7 @@ export const ActiveInboundsByNodeSchema = z
 export const GroupSummarySchema = z.object({
   name: z.string(),
   clientCount: z.number(),
+  trafficUsed: z.number().nullable().transform((v) => v ?? 0),
 });
 
 export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []);

+ 17 - 9
web/service/client.go

@@ -1714,15 +1714,19 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
 type GroupSummary struct {
 	Name        string `json:"name"`
 	ClientCount int    `json:"clientCount"`
+	TrafficUsed int64  `json:"trafficUsed"`
 }
 
 func (s *ClientService) ListGroups() ([]GroupSummary, error) {
 	db := database.GetDB()
+	// email is unique in both clients and client_traffics, so the LEFT JOIN
+	// never double-counts a client's traffic.
 	var derived []GroupSummary
-	if err := db.Model(&model.ClientRecord{}).
-		Select("group_name AS name, COUNT(*) AS client_count").
-		Where("group_name <> ''").
-		Group("group_name").
+	if err := db.Table("clients AS c").
+		Select("c.group_name AS name, COUNT(*) AS client_count, COALESCE(SUM(ct.up + ct.down), 0) AS traffic_used").
+		Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
+		Where("c.group_name <> ''").
+		Group("c.group_name").
 		Scan(&derived).Error; err != nil {
 		return nil, err
 	}
@@ -1730,16 +1734,20 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
 	if err := db.Find(&stored).Error; err != nil {
 		return nil, err
 	}
-	merged := make(map[string]int, len(derived)+len(stored))
+	type groupAgg struct {
+		count   int
+		traffic int64
+	}
+	merged := make(map[string]groupAgg, len(derived)+len(stored))
 	for _, g := range stored {
-		merged[g.Name] = 0
+		merged[g.Name] = groupAgg{}
 	}
 	for _, g := range derived {
-		merged[g.Name] = g.ClientCount
+		merged[g.Name] = groupAgg{count: g.ClientCount, traffic: g.TrafficUsed}
 	}
 	out := make([]GroupSummary, 0, len(merged))
-	for name, count := range merged {
-		out = append(out, GroupSummary{Name: name, ClientCount: count})
+	for name, agg := range merged {
+		out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: agg.traffic})
 	}
 	sort.Slice(out, func(i, j int) bool {
 		return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)

+ 2 - 1
web/translation/ar-EG.json

@@ -812,7 +812,8 @@
       "clientCount": "عملاء في المجموعة",
       "totalGroups": "إجمالي المجموعات",
       "totalGroupedClients": "العملاء بمجموعة",
-      "emptyGroups": "مجموعات فارغة",
+      "trafficUsed": "حركة المرور المستخدمة",
+      "totalTraffic": "إجمالي حركة المرور",
       "addGroup": "إضافة مجموعة",
       "createSuccess": "تم إنشاء المجموعة «{name}».",
       "rename": "إعادة تسمية",

+ 2 - 1
web/translation/en-US.json

@@ -813,7 +813,8 @@
       "clientCount": "Clients in group",
       "totalGroups": "Total groups",
       "totalGroupedClients": "Clients with a group",
-      "emptyGroups": "Empty groups",
+      "trafficUsed": "Traffic used",
+      "totalTraffic": "Total traffic",
       "addGroup": "Add Group",
       "createSuccess": "Group \"{name}\" created.",
       "rename": "Rename",

+ 2 - 1
web/translation/es-ES.json

@@ -812,7 +812,8 @@
       "clientCount": "Clientes en el grupo",
       "totalGroups": "Total de grupos",
       "totalGroupedClients": "Clientes con grupo",
-      "emptyGroups": "Grupos vacíos",
+      "trafficUsed": "Tráfico usado",
+      "totalTraffic": "Tráfico total",
       "addGroup": "Añadir grupo",
       "createSuccess": "Grupo «{name}» creado.",
       "rename": "Renombrar",

+ 2 - 1
web/translation/fa-IR.json

@@ -812,7 +812,8 @@
       "clientCount": "کاربران در گروه",
       "totalGroups": "تعداد گروه‌ها",
       "totalGroupedClients": "کاربران دارای گروه",
-      "emptyGroups": "گروه‌های خالی",
+      "trafficUsed": "ترافیک مصرف‌شده",
+      "totalTraffic": "مجموع ترافیک",
       "addGroup": "افزودن گروه",
       "createSuccess": "گروه «{name}» ایجاد شد.",
       "rename": "تغییر نام",

+ 2 - 1
web/translation/id-ID.json

@@ -812,7 +812,8 @@
       "clientCount": "Klien di grup",
       "totalGroups": "Total grup",
       "totalGroupedClients": "Klien dengan grup",
-      "emptyGroups": "Grup kosong",
+      "trafficUsed": "Trafik terpakai",
+      "totalTraffic": "Total trafik",
       "addGroup": "Tambah grup",
       "createSuccess": "Grup «{name}» dibuat.",
       "rename": "Ubah nama",

+ 2 - 1
web/translation/ja-JP.json

@@ -812,7 +812,8 @@
       "clientCount": "グループ内のクライアント",
       "totalGroups": "グループ合計",
       "totalGroupedClients": "グループのあるクライアント",
-      "emptyGroups": "空のグループ",
+      "trafficUsed": "使用済みトラフィック",
+      "totalTraffic": "合計トラフィック",
       "addGroup": "グループ追加",
       "createSuccess": "グループ「{name}」を作成しました。",
       "rename": "名前変更",

+ 2 - 1
web/translation/pt-BR.json

@@ -812,7 +812,8 @@
       "clientCount": "Clientes no grupo",
       "totalGroups": "Total de grupos",
       "totalGroupedClients": "Clientes com grupo",
-      "emptyGroups": "Grupos vazios",
+      "trafficUsed": "Tráfego usado",
+      "totalTraffic": "Tráfego total",
       "addGroup": "Adicionar grupo",
       "createSuccess": "Grupo «{name}» criado.",
       "rename": "Renomear",

+ 2 - 1
web/translation/ru-RU.json

@@ -812,7 +812,8 @@
       "clientCount": "Клиентов в группе",
       "totalGroups": "Всего групп",
       "totalGroupedClients": "Клиенты с группой",
-      "emptyGroups": "Пустые группы",
+      "trafficUsed": "Использованный трафик",
+      "totalTraffic": "Общий трафик",
       "addGroup": "Добавить группу",
       "createSuccess": "Группа «{name}» создана.",
       "rename": "Переименовать",

+ 2 - 1
web/translation/tr-TR.json

@@ -812,7 +812,8 @@
       "clientCount": "Gruptaki kullanıcılar",
       "totalGroups": "Toplam grup",
       "totalGroupedClients": "Grubu olan kullanıcılar",
-      "emptyGroups": "Boş gruplar",
+      "trafficUsed": "Kullanılan trafik",
+      "totalTraffic": "Toplam trafik",
       "addGroup": "Grup ekle",
       "createSuccess": "«{name}» grubu oluşturuldu.",
       "rename": "Yeniden adlandır",

+ 2 - 1
web/translation/uk-UA.json

@@ -812,7 +812,8 @@
       "clientCount": "Клієнтів у групі",
       "totalGroups": "Всього груп",
       "totalGroupedClients": "Клієнти з групою",
-      "emptyGroups": "Порожні групи",
+      "trafficUsed": "Використаний трафік",
+      "totalTraffic": "Загальний трафік",
       "addGroup": "Додати групу",
       "createSuccess": "Групу «{name}» створено.",
       "rename": "Перейменувати",

+ 2 - 1
web/translation/vi-VN.json

@@ -812,7 +812,8 @@
       "clientCount": "Client trong nhóm",
       "totalGroups": "Tổng số nhóm",
       "totalGroupedClients": "Client có nhóm",
-      "emptyGroups": "Nhóm trống",
+      "trafficUsed": "Lưu lượng đã dùng",
+      "totalTraffic": "Tổng lưu lượng",
       "addGroup": "Thêm nhóm",
       "createSuccess": "Đã tạo nhóm «{name}».",
       "rename": "Đổi tên",

+ 2 - 1
web/translation/zh-CN.json

@@ -812,7 +812,8 @@
       "clientCount": "分组中的客户端",
       "totalGroups": "分组总数",
       "totalGroupedClients": "有分组的客户端",
-      "emptyGroups": "空分组",
+      "trafficUsed": "已用流量",
+      "totalTraffic": "总流量",
       "addGroup": "添加分组",
       "createSuccess": "已创建分组 “{name}”。",
       "rename": "重命名",

+ 2 - 1
web/translation/zh-TW.json

@@ -812,7 +812,8 @@
       "clientCount": "群組中的客戶端",
       "totalGroups": "群組總數",
       "totalGroupedClients": "有群組的客戶端",
-      "emptyGroups": "空群組",
+      "trafficUsed": "已用流量",
+      "totalTraffic": "總流量",
       "addGroup": "新增群組",
       "createSuccess": "已建立群組「{name}」。",
       "rename": "重新命名",