feat(groups): show upload/download breakdown in group traffic

Add per-group up/down to GroupSummary (backend + schema), surface them
as Upload/Download columns in the groups table, and fold upload/download
into the Total traffic summary card. Rename the group "Clients in group"
column to just "Clients" across all locales.
This commit is contained in:
MHSanaei
2026-06-12 15:30:30 +02:00
parent 7c698c4bcf
commit 1c5cb84492
16 changed files with 79 additions and 19 deletions
+31 -3
View File
@@ -22,6 +22,8 @@ import {
} from 'antd'; } from 'antd';
import type { MenuProps, TableColumnsType } from 'antd'; import type { MenuProps, TableColumnsType } from 'antd';
import { import {
ArrowDownOutlined,
ArrowUpOutlined,
ClockCircleOutlined, ClockCircleOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
@@ -165,6 +167,14 @@ export default function GroupsPage() {
() => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0), () => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
[groups], [groups],
); );
const totalUpload = useMemo(
() => groups.reduce((acc, g) => acc + (g.up || 0), 0),
[groups],
);
const totalDownload = useMemo(
() => groups.reduce((acc, g) => acc + (g.down || 0), 0),
[groups],
);
function openCreate() { function openCreate() {
setCreateName(''); setCreateName('');
@@ -417,6 +427,20 @@ 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.upload'),
dataIndex: 'up',
key: 'up',
width: 140,
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
},
{
title: t('pages.groups.download'),
dataIndex: 'down',
key: 'down',
width: 140,
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
},
{ {
title: t('pages.groups.trafficUsed'), title: t('pages.groups.trafficUsed'),
dataIndex: 'trafficUsed', dataIndex: 'trafficUsed',
@@ -456,26 +480,30 @@ export default function GroupsPage() {
<Col span={24}> <Col span={24}>
<Card size="small" hoverable className="summary-card"> <Card size="small" hoverable className="summary-card">
<Row gutter={[16, isMobile ? 16 : 12]}> <Row gutter={[16, isMobile ? 16 : 12]}>
<Col xs={12} sm={8} md={6}> <Col xs={12} sm={8}>
<Statistic <Statistic
title={t('pages.groups.totalGroups')} title={t('pages.groups.totalGroups')}
value={String(totalGroups)} value={String(totalGroups)}
prefix={<TagsOutlined />} prefix={<TagsOutlined />}
/> />
</Col> </Col>
<Col xs={12} sm={8} md={6}> <Col xs={12} sm={8}>
<Statistic <Statistic
title={t('pages.groups.totalGroupedClients')} title={t('pages.groups.totalGroupedClients')}
value={String(totalClients)} value={String(totalClients)}
prefix={<TeamOutlined />} prefix={<TeamOutlined />}
/> />
</Col> </Col>
<Col xs={12} sm={8} md={6}> <Col xs={24} sm={8}>
<Statistic <Statistic
title={t('pages.groups.totalTraffic')} title={t('pages.groups.totalTraffic')}
value={SizeFormatter.sizeFormat(totalTraffic)} value={SizeFormatter.sizeFormat(totalTraffic)}
prefix={<RetweetOutlined />} prefix={<RetweetOutlined />}
/> />
<Space size={16} style={{ marginTop: 4, color: 'var(--ant-color-text-secondary)', fontSize: 13 }}>
<span><ArrowUpOutlined /> {SizeFormatter.sizeFormat(totalUpload)}</span>
<span><ArrowDownOutlined /> {SizeFormatter.sizeFormat(totalDownload)}</span>
</Space>
</Col> </Col>
</Row> </Row>
</Card> </Card>
+2
View File
@@ -129,6 +129,8 @@ 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), trafficUsed: z.number().nullable().transform((v) => v ?? 0),
up: z.number().nullable().transform((v) => v ?? 0),
down: 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 ?? []);
+7 -3
View File
@@ -14,6 +14,8 @@ type GroupSummary struct {
Name string `json:"name"` Name string `json:"name"`
ClientCount int `json:"clientCount"` ClientCount int `json:"clientCount"`
TrafficUsed int64 `json:"trafficUsed"` TrafficUsed int64 `json:"trafficUsed"`
Up int64 `json:"up"`
Down int64 `json:"down"`
} }
func (s *ClientService) ListGroups() ([]GroupSummary, error) { func (s *ClientService) ListGroups() ([]GroupSummary, error) {
@@ -22,7 +24,7 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
// never double-counts a client's traffic. // never double-counts a client's traffic.
var derived []GroupSummary var derived []GroupSummary
if err := db.Table("clients AS c"). 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"). Select("c.group_name AS name, COUNT(*) AS client_count, COALESCE(SUM(ct.up + ct.down), 0) AS traffic_used, 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"). Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
Where("c.group_name <> ''"). Where("c.group_name <> ''").
Group("c.group_name"). Group("c.group_name").
@@ -36,17 +38,19 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
type groupAgg struct { type groupAgg struct {
count int count int
traffic int64 traffic int64
up int64
down int64
} }
merged := make(map[string]groupAgg, len(derived)+len(stored)) merged := make(map[string]groupAgg, len(derived)+len(stored))
for _, g := range stored { for _, g := range stored {
merged[g.Name] = groupAgg{} merged[g.Name] = groupAgg{}
} }
for _, g := range derived { for _, g := range derived {
merged[g.Name] = groupAgg{count: g.ClientCount, traffic: g.TrafficUsed} merged[g.Name] = groupAgg{count: g.ClientCount, traffic: g.TrafficUsed, up: g.Up, down: g.Down}
} }
out := make([]GroupSummary, 0, len(merged)) out := make([]GroupSummary, 0, len(merged))
for name, agg := range merged { for name, agg := range merged {
out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: agg.traffic}) out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: agg.traffic, Up: agg.up, Down: agg.down})
} }
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)
+3 -1
View File
@@ -812,10 +812,12 @@
"groups": { "groups": {
"title": "المجموعات", "title": "المجموعات",
"name": "الاسم", "name": "الاسم",
"clientCount": "عملاء في المجموعة", "clientCount": "العملاء",
"totalGroups": "إجمالي المجموعات", "totalGroups": "إجمالي المجموعات",
"totalGroupedClients": "العملاء بمجموعة", "totalGroupedClients": "العملاء بمجموعة",
"trafficUsed": "حركة المرور المستخدمة", "trafficUsed": "حركة المرور المستخدمة",
"upload": "رفع",
"download": "تنزيل",
"totalTraffic": "إجمالي حركة المرور", "totalTraffic": "إجمالي حركة المرور",
"addGroup": "إضافة مجموعة", "addGroup": "إضافة مجموعة",
"createSuccess": "تم إنشاء المجموعة «{name}».", "createSuccess": "تم إنشاء المجموعة «{name}».",
+3 -1
View File
@@ -813,10 +813,12 @@
"groups": { "groups": {
"title": "Groups", "title": "Groups",
"name": "Name", "name": "Name",
"clientCount": "Clients in group", "clientCount": "Clients",
"totalGroups": "Total groups", "totalGroups": "Total groups",
"totalGroupedClients": "Clients with a group", "totalGroupedClients": "Clients with a group",
"trafficUsed": "Traffic used", "trafficUsed": "Traffic used",
"upload": "Upload",
"download": "Download",
"totalTraffic": "Total traffic", "totalTraffic": "Total traffic",
"addGroup": "Add Group", "addGroup": "Add Group",
"createSuccess": "Group \"{name}\" created.", "createSuccess": "Group \"{name}\" created.",
+3 -1
View File
@@ -812,10 +812,12 @@
"groups": { "groups": {
"title": "Grupos", "title": "Grupos",
"name": "Nombre", "name": "Nombre",
"clientCount": "Clientes en el grupo", "clientCount": "Clientes",
"totalGroups": "Total de grupos", "totalGroups": "Total de grupos",
"totalGroupedClients": "Clientes con grupo", "totalGroupedClients": "Clientes con grupo",
"trafficUsed": "Tráfico usado", "trafficUsed": "Tráfico usado",
"upload": "Subida",
"download": "Bajada",
"totalTraffic": "Tráfico total", "totalTraffic": "Tráfico total",
"addGroup": "Añadir grupo", "addGroup": "Añadir grupo",
"createSuccess": "Grupo «{name}» creado.", "createSuccess": "Grupo «{name}» creado.",
+3 -1
View File
@@ -812,10 +812,12 @@
"groups": { "groups": {
"title": "گروه‌ها", "title": "گروه‌ها",
"name": "نام", "name": "نام",
"clientCount": "کاربران در گروه", "clientCount": "کاربران",
"totalGroups": "تعداد گروه‌ها", "totalGroups": "تعداد گروه‌ها",
"totalGroupedClients": "کاربران دارای گروه", "totalGroupedClients": "کاربران دارای گروه",
"trafficUsed": "ترافیک مصرف‌شده", "trafficUsed": "ترافیک مصرف‌شده",
"upload": "آپلود",
"download": "دانلود",
"totalTraffic": "مجموع ترافیک", "totalTraffic": "مجموع ترافیک",
"addGroup": "افزودن گروه", "addGroup": "افزودن گروه",
"createSuccess": "گروه «{name}» ایجاد شد.", "createSuccess": "گروه «{name}» ایجاد شد.",
+3 -1
View File
@@ -812,10 +812,12 @@
"groups": { "groups": {
"title": "Grup", "title": "Grup",
"name": "Nama", "name": "Nama",
"clientCount": "Klien di grup", "clientCount": "Klien",
"totalGroups": "Total grup", "totalGroups": "Total grup",
"totalGroupedClients": "Klien dengan grup", "totalGroupedClients": "Klien dengan grup",
"trafficUsed": "Trafik terpakai", "trafficUsed": "Trafik terpakai",
"upload": "Unggah",
"download": "Unduh",
"totalTraffic": "Total trafik", "totalTraffic": "Total trafik",
"addGroup": "Tambah grup", "addGroup": "Tambah grup",
"createSuccess": "Grup «{name}» dibuat.", "createSuccess": "Grup «{name}» dibuat.",
+3 -1
View File
@@ -812,10 +812,12 @@
"groups": { "groups": {
"title": "グループ", "title": "グループ",
"name": "名前", "name": "名前",
"clientCount": "グループ内のクライアント", "clientCount": "クライアント",
"totalGroups": "グループ合計", "totalGroups": "グループ合計",
"totalGroupedClients": "グループのあるクライアント", "totalGroupedClients": "グループのあるクライアント",
"trafficUsed": "使用済みトラフィック", "trafficUsed": "使用済みトラフィック",
"upload": "アップロード",
"download": "ダウンロード",
"totalTraffic": "合計トラフィック", "totalTraffic": "合計トラフィック",
"addGroup": "グループ追加", "addGroup": "グループ追加",
"createSuccess": "グループ「{name}」を作成しました。", "createSuccess": "グループ「{name}」を作成しました。",
+3 -1
View File
@@ -812,10 +812,12 @@
"groups": { "groups": {
"title": "Grupos", "title": "Grupos",
"name": "Nome", "name": "Nome",
"clientCount": "Clientes no grupo", "clientCount": "Clientes",
"totalGroups": "Total de grupos", "totalGroups": "Total de grupos",
"totalGroupedClients": "Clientes com grupo", "totalGroupedClients": "Clientes com grupo",
"trafficUsed": "Tráfego usado", "trafficUsed": "Tráfego usado",
"upload": "Envio",
"download": "Recebimento",
"totalTraffic": "Tráfego total", "totalTraffic": "Tráfego total",
"addGroup": "Adicionar grupo", "addGroup": "Adicionar grupo",
"createSuccess": "Grupo «{name}» criado.", "createSuccess": "Grupo «{name}» criado.",
+3 -1
View File
@@ -812,10 +812,12 @@
"groups": { "groups": {
"title": "Группы", "title": "Группы",
"name": "Имя", "name": "Имя",
"clientCount": "Клиентов в группе", "clientCount": "Клиенты",
"totalGroups": "Всего групп", "totalGroups": "Всего групп",
"totalGroupedClients": "Клиенты с группой", "totalGroupedClients": "Клиенты с группой",
"trafficUsed": "Использованный трафик", "trafficUsed": "Использованный трафик",
"upload": "Отправлено",
"download": "Получено",
"totalTraffic": "Общий трафик", "totalTraffic": "Общий трафик",
"addGroup": "Добавить группу", "addGroup": "Добавить группу",
"createSuccess": "Группа «{name}» создана.", "createSuccess": "Группа «{name}» создана.",
+3 -1
View File
@@ -813,10 +813,12 @@
"groups": { "groups": {
"title": "Gruplar", "title": "Gruplar",
"name": "İsim", "name": "İsim",
"clientCount": "Gruptaki kullanıcılar", "clientCount": "Kullanıcılar",
"totalGroups": "Toplam grup", "totalGroups": "Toplam grup",
"totalGroupedClients": "Grubu olan kullanıcılar", "totalGroupedClients": "Grubu olan kullanıcılar",
"trafficUsed": "Kullanılan trafik", "trafficUsed": "Kullanılan trafik",
"upload": "Yükleme",
"download": "İndirme",
"totalTraffic": "Toplam trafik", "totalTraffic": "Toplam trafik",
"addGroup": "Grup ekle", "addGroup": "Grup ekle",
"createSuccess": "«{name}» grubu oluşturuldu.", "createSuccess": "«{name}» grubu oluşturuldu.",
+3 -1
View File
@@ -812,10 +812,12 @@
"groups": { "groups": {
"title": "Групи", "title": "Групи",
"name": "Назва", "name": "Назва",
"clientCount": "Клієнтів у групі", "clientCount": "Клієнти",
"totalGroups": "Всього груп", "totalGroups": "Всього груп",
"totalGroupedClients": "Клієнти з групою", "totalGroupedClients": "Клієнти з групою",
"trafficUsed": "Використаний трафік", "trafficUsed": "Використаний трафік",
"upload": "Вивантаження",
"download": "Завантаження",
"totalTraffic": "Загальний трафік", "totalTraffic": "Загальний трафік",
"addGroup": "Додати групу", "addGroup": "Додати групу",
"createSuccess": "Групу «{name}» створено.", "createSuccess": "Групу «{name}» створено.",
+3 -1
View File
@@ -812,10 +812,12 @@
"groups": { "groups": {
"title": "Nhóm", "title": "Nhóm",
"name": "Tên", "name": "Tên",
"clientCount": "Client trong nhóm", "clientCount": "Client",
"totalGroups": "Tổng số nhóm", "totalGroups": "Tổng số nhóm",
"totalGroupedClients": "Client có nhóm", "totalGroupedClients": "Client có nhóm",
"trafficUsed": "Lưu lượng đã dùng", "trafficUsed": "Lưu lượng đã dùng",
"upload": "Tải lên",
"download": "Tải xuống",
"totalTraffic": "Tổng lưu lượ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}».",
+3 -1
View File
@@ -812,10 +812,12 @@
"groups": { "groups": {
"title": "分组", "title": "分组",
"name": "名称", "name": "名称",
"clientCount": "分组中的客户端", "clientCount": "客户端",
"totalGroups": "分组总数", "totalGroups": "分组总数",
"totalGroupedClients": "有分组的客户端", "totalGroupedClients": "有分组的客户端",
"trafficUsed": "已用流量", "trafficUsed": "已用流量",
"upload": "上传",
"download": "下载",
"totalTraffic": "总流量", "totalTraffic": "总流量",
"addGroup": "添加分组", "addGroup": "添加分组",
"createSuccess": "已创建分组 “{name}”。", "createSuccess": "已创建分组 “{name}”。",
+3 -1
View File
@@ -812,10 +812,12 @@
"groups": { "groups": {
"title": "群組", "title": "群組",
"name": "名稱", "name": "名稱",
"clientCount": "群組中的客戶端", "clientCount": "客戶端",
"totalGroups": "群組總數", "totalGroups": "群組總數",
"totalGroupedClients": "有群組的客戶端", "totalGroupedClients": "有群組的客戶端",
"trafficUsed": "已用流量", "trafficUsed": "已用流量",
"upload": "上傳",
"download": "下載",
"totalTraffic": "總流量", "totalTraffic": "總流量",
"addGroup": "新增群組", "addGroup": "新增群組",
"createSuccess": "已建立群組「{name}」。", "createSuccess": "已建立群組「{name}」。",