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.
This commit is contained in:
Sanaei
2026-06-08 23:47:59 +02:00
parent b24b8524b6
commit 1fa51cf0f2
16 changed files with 57 additions and 27 deletions
+13 -5
View File
@@ -41,7 +41,7 @@ import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { usePageTitle } from '@/hooks/usePageTitle'; import { usePageTitle } from '@/hooks/usePageTitle';
import { useClients } from '@/hooks/useClients'; import { useClients } from '@/hooks/useClients';
import { HttpUtil } from '@/utils'; import { HttpUtil, SizeFormatter } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus'; import { setMessageInstance } from '@/utils/messageBus';
import AppSidebar from '@/layouts/AppSidebar'; import AppSidebar from '@/layouts/AppSidebar';
import { LazyMount } from '@/components/utility'; import { LazyMount } from '@/components/utility';
@@ -161,8 +161,8 @@ export default function GroupsPage() {
() => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0), () => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0),
[groups], [groups],
); );
const emptyGroups = useMemo( const totalTraffic = useMemo(
() => groups.filter((g) => (g.clientCount || 0) === 0).length, () => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
[groups], [groups],
); );
@@ -417,6 +417,13 @@ export default function GroupsPage() {
width: 180, width: 180,
render: (count: number) => <span>{count || 0}</span>, 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(() => { const pageClass = useMemo(() => {
@@ -465,8 +472,9 @@ export default function GroupsPage() {
</Col> </Col>
<Col xs={12} sm={8} md={6}> <Col xs={12} sm={8} md={6}>
<Statistic <Statistic
title={t('pages.groups.emptyGroups')} title={t('pages.groups.totalTraffic')}
value={String(emptyGroups)} value={SizeFormatter.sizeFormat(totalTraffic)}
prefix={<RetweetOutlined />}
/> />
</Col> </Col>
</Row> </Row>
+1
View File
@@ -126,6 +126,7 @@ export const ActiveInboundsByNodeSchema = z
export const GroupSummarySchema = z.object({ export const GroupSummarySchema = z.object({
name: z.string(), name: z.string(),
clientCount: z.number(), clientCount: z.number(),
trafficUsed: z.number().nullable().transform((v) => v ?? 0),
}); });
export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []); export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []);
+17 -9
View File
@@ -1714,15 +1714,19 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
type GroupSummary struct { type GroupSummary struct {
Name string `json:"name"` Name string `json:"name"`
ClientCount int `json:"clientCount"` ClientCount int `json:"clientCount"`
TrafficUsed int64 `json:"trafficUsed"`
} }
func (s *ClientService) ListGroups() ([]GroupSummary, error) { func (s *ClientService) ListGroups() ([]GroupSummary, error) {
db := database.GetDB() 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 var derived []GroupSummary
if err := db.Model(&model.ClientRecord{}). if err := db.Table("clients AS c").
Select("group_name AS name, COUNT(*) AS client_count"). Select("c.group_name AS name, COUNT(*) AS client_count, COALESCE(SUM(ct.up + ct.down), 0) AS traffic_used").
Where("group_name <> ''"). Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
Group("group_name"). Where("c.group_name <> ''").
Group("c.group_name").
Scan(&derived).Error; err != nil { Scan(&derived).Error; err != nil {
return nil, err return nil, err
} }
@@ -1730,16 +1734,20 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
if err := db.Find(&stored).Error; err != nil { if err := db.Find(&stored).Error; err != nil {
return nil, err 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 { for _, g := range stored {
merged[g.Name] = 0 merged[g.Name] = groupAgg{}
} }
for _, g := range derived { 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)) out := make([]GroupSummary, 0, len(merged))
for name, count := range merged { for name, agg := range merged {
out = append(out, GroupSummary{Name: name, ClientCount: count}) out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: agg.traffic})
} }
sort.Slice(out, func(i, j int) bool { sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
+2 -1
View File
@@ -812,7 +812,8 @@
"clientCount": "عملاء في المجموعة", "clientCount": "عملاء في المجموعة",
"totalGroups": "إجمالي المجموعات", "totalGroups": "إجمالي المجموعات",
"totalGroupedClients": "العملاء بمجموعة", "totalGroupedClients": "العملاء بمجموعة",
"emptyGroups": "مجموعات فارغة", "trafficUsed": "حركة المرور المستخدمة",
"totalTraffic": "إجمالي حركة المرور",
"addGroup": "إضافة مجموعة", "addGroup": "إضافة مجموعة",
"createSuccess": "تم إنشاء المجموعة «{name}».", "createSuccess": "تم إنشاء المجموعة «{name}».",
"rename": "إعادة تسمية", "rename": "إعادة تسمية",
+2 -1
View File
@@ -813,7 +813,8 @@
"clientCount": "Clients in group", "clientCount": "Clients in group",
"totalGroups": "Total groups", "totalGroups": "Total groups",
"totalGroupedClients": "Clients with a group", "totalGroupedClients": "Clients with a group",
"emptyGroups": "Empty groups", "trafficUsed": "Traffic used",
"totalTraffic": "Total traffic",
"addGroup": "Add Group", "addGroup": "Add Group",
"createSuccess": "Group \"{name}\" created.", "createSuccess": "Group \"{name}\" created.",
"rename": "Rename", "rename": "Rename",
+2 -1
View File
@@ -812,7 +812,8 @@
"clientCount": "Clientes en el grupo", "clientCount": "Clientes en el grupo",
"totalGroups": "Total de grupos", "totalGroups": "Total de grupos",
"totalGroupedClients": "Clientes con grupo", "totalGroupedClients": "Clientes con grupo",
"emptyGroups": "Grupos vacíos", "trafficUsed": "Tráfico usado",
"totalTraffic": "Tráfico total",
"addGroup": "Añadir grupo", "addGroup": "Añadir grupo",
"createSuccess": "Grupo «{name}» creado.", "createSuccess": "Grupo «{name}» creado.",
"rename": "Renombrar", "rename": "Renombrar",
+2 -1
View File
@@ -812,7 +812,8 @@
"clientCount": "کاربران در گروه", "clientCount": "کاربران در گروه",
"totalGroups": "تعداد گروه‌ها", "totalGroups": "تعداد گروه‌ها",
"totalGroupedClients": "کاربران دارای گروه", "totalGroupedClients": "کاربران دارای گروه",
"emptyGroups": "گروه‌های خالی", "trafficUsed": "ترافیک مصرف‌شده",
"totalTraffic": "مجموع ترافیک",
"addGroup": "افزودن گروه", "addGroup": "افزودن گروه",
"createSuccess": "گروه «{name}» ایجاد شد.", "createSuccess": "گروه «{name}» ایجاد شد.",
"rename": "تغییر نام", "rename": "تغییر نام",
+2 -1
View File
@@ -812,7 +812,8 @@
"clientCount": "Klien di grup", "clientCount": "Klien di grup",
"totalGroups": "Total grup", "totalGroups": "Total grup",
"totalGroupedClients": "Klien dengan grup", "totalGroupedClients": "Klien dengan grup",
"emptyGroups": "Grup kosong", "trafficUsed": "Trafik terpakai",
"totalTraffic": "Total trafik",
"addGroup": "Tambah grup", "addGroup": "Tambah grup",
"createSuccess": "Grup «{name}» dibuat.", "createSuccess": "Grup «{name}» dibuat.",
"rename": "Ubah nama", "rename": "Ubah nama",
+2 -1
View File
@@ -812,7 +812,8 @@
"clientCount": "グループ内のクライアント", "clientCount": "グループ内のクライアント",
"totalGroups": "グループ合計", "totalGroups": "グループ合計",
"totalGroupedClients": "グループのあるクライアント", "totalGroupedClients": "グループのあるクライアント",
"emptyGroups": "空のグループ", "trafficUsed": "使用済みトラフィック",
"totalTraffic": "合計トラフィック",
"addGroup": "グループ追加", "addGroup": "グループ追加",
"createSuccess": "グループ「{name}」を作成しました。", "createSuccess": "グループ「{name}」を作成しました。",
"rename": "名前変更", "rename": "名前変更",
+2 -1
View File
@@ -812,7 +812,8 @@
"clientCount": "Clientes no grupo", "clientCount": "Clientes no grupo",
"totalGroups": "Total de grupos", "totalGroups": "Total de grupos",
"totalGroupedClients": "Clientes com grupo", "totalGroupedClients": "Clientes com grupo",
"emptyGroups": "Grupos vazios", "trafficUsed": "Tráfego usado",
"totalTraffic": "Tráfego total",
"addGroup": "Adicionar grupo", "addGroup": "Adicionar grupo",
"createSuccess": "Grupo «{name}» criado.", "createSuccess": "Grupo «{name}» criado.",
"rename": "Renomear", "rename": "Renomear",
+2 -1
View File
@@ -812,7 +812,8 @@
"clientCount": "Клиентов в группе", "clientCount": "Клиентов в группе",
"totalGroups": "Всего групп", "totalGroups": "Всего групп",
"totalGroupedClients": "Клиенты с группой", "totalGroupedClients": "Клиенты с группой",
"emptyGroups": "Пустые группы", "trafficUsed": "Использованный трафик",
"totalTraffic": "Общий трафик",
"addGroup": "Добавить группу", "addGroup": "Добавить группу",
"createSuccess": "Группа «{name}» создана.", "createSuccess": "Группа «{name}» создана.",
"rename": "Переименовать", "rename": "Переименовать",
+2 -1
View File
@@ -812,7 +812,8 @@
"clientCount": "Gruptaki kullanıcılar", "clientCount": "Gruptaki kullanıcılar",
"totalGroups": "Toplam grup", "totalGroups": "Toplam grup",
"totalGroupedClients": "Grubu olan kullanıcılar", "totalGroupedClients": "Grubu olan kullanıcılar",
"emptyGroups": "Boş gruplar", "trafficUsed": "Kullanılan trafik",
"totalTraffic": "Toplam trafik",
"addGroup": "Grup ekle", "addGroup": "Grup ekle",
"createSuccess": "«{name}» grubu oluşturuldu.", "createSuccess": "«{name}» grubu oluşturuldu.",
"rename": "Yeniden adlandır", "rename": "Yeniden adlandır",
+2 -1
View File
@@ -812,7 +812,8 @@
"clientCount": "Клієнтів у групі", "clientCount": "Клієнтів у групі",
"totalGroups": "Всього груп", "totalGroups": "Всього груп",
"totalGroupedClients": "Клієнти з групою", "totalGroupedClients": "Клієнти з групою",
"emptyGroups": "Порожні групи", "trafficUsed": "Використаний трафік",
"totalTraffic": "Загальний трафік",
"addGroup": "Додати групу", "addGroup": "Додати групу",
"createSuccess": "Групу «{name}» створено.", "createSuccess": "Групу «{name}» створено.",
"rename": "Перейменувати", "rename": "Перейменувати",
+2 -1
View File
@@ -812,7 +812,8 @@
"clientCount": "Client trong nhóm", "clientCount": "Client trong nhóm",
"totalGroups": "Tổng số nhóm", "totalGroups": "Tổng số nhóm",
"totalGroupedClients": "Client có 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", "addGroup": "Thêm nhóm",
"createSuccess": "Đã tạo nhóm «{name}».", "createSuccess": "Đã tạo nhóm «{name}».",
"rename": "Đổi tên", "rename": "Đổi tên",
+2 -1
View File
@@ -812,7 +812,8 @@
"clientCount": "分组中的客户端", "clientCount": "分组中的客户端",
"totalGroups": "分组总数", "totalGroups": "分组总数",
"totalGroupedClients": "有分组的客户端", "totalGroupedClients": "有分组的客户端",
"emptyGroups": "空分组", "trafficUsed": "已用流量",
"totalTraffic": "总流量",
"addGroup": "添加分组", "addGroup": "添加分组",
"createSuccess": "已创建分组 “{name}”。", "createSuccess": "已创建分组 “{name}”。",
"rename": "重命名", "rename": "重命名",
+2 -1
View File
@@ -812,7 +812,8 @@
"clientCount": "群組中的客戶端", "clientCount": "群組中的客戶端",
"totalGroups": "群組總數", "totalGroups": "群組總數",
"totalGroupedClients": "有群組的客戶端", "totalGroupedClients": "有群組的客戶端",
"emptyGroups": "空群組", "trafficUsed": "已用流量",
"totalTraffic": "總流量",
"addGroup": "新增群組", "addGroup": "新增群組",
"createSuccess": "已建立群組「{name}」。", "createSuccess": "已建立群組「{name}」。",
"rename": "重新命名", "rename": "重新命名",