diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index 45cca03..e0b9949 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -703,25 +703,26 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) { } type fwdRow struct { - ID int64 - UserID int64 - UserName string - Name string - TunnelID int64 - TunnelName string - RemoteAddr string - Strategy string - InFlow int64 - OutFlow int64 - CreatedTime int64 - Status int - Inx int - SpeedID sql.NullInt64 + ID int64 + UserID int64 + UserName string + Name string + TunnelID int64 + TunnelName string + TrafficRatio float64 + RemoteAddr string + Strategy string + InFlow int64 + OutFlow int64 + CreatedTime int64 + Status int + Inx int + SpeedID sql.NullInt64 } var rows []fwdRow err := r.db.Model(&model.Forward{}). - Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id"). + Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id"). Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id"). Order("forward.inx ASC, forward.id ASC"). Find(&rows).Error @@ -738,7 +739,8 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) { item := map[string]interface{}{ "id": row.ID, "userId": row.UserID, "userName": row.UserName, "name": row.Name, "tunnelId": row.TunnelID, "tunnelName": row.TunnelName, - "inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort), + "tunnelTrafficRatio": row.TrafficRatio, + "inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort), "remoteAddr": row.RemoteAddr, "strategy": row.Strategy, "inFlow": row.InFlow, "outFlow": row.OutFlow, "createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx), diff --git a/go-backend/tests/contract/forward_contract_test.go b/go-backend/tests/contract/forward_contract_test.go index 34d5199..2bf72fc 100644 --- a/go-backend/tests/contract/forward_contract_test.go +++ b/go-backend/tests/contract/forward_contract_test.go @@ -31,7 +31,7 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) { if err := repo.DB().Exec(` INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, "contract-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil { + `, "contract-tunnel", 2.5, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil { t.Fatalf("insert tunnel: %v", err) } tunnelID := mustLastInsertID(t, repo, "contract-tunnel") @@ -118,6 +118,13 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) { if got := int64(idFloat); got != userForwardID { t.Fatalf("expected forward id %d, got %d", userForwardID, got) } + ratioFloat, ok := item["tunnelTrafficRatio"].(float64) + if !ok { + t.Fatalf("expected tunnelTrafficRatio to be float64, got %T", item["tunnelTrafficRatio"]) + } + if ratioFloat != 2.5 { + t.Fatalf("expected tunnelTrafficRatio 2.5, got %v", ratioFloat) + } }) t.Run("forward diagnose returns structured payload", func(t *testing.T) { diff --git a/plans/023-forward-page-tunnel-traffic-ratio.md b/plans/023-forward-page-tunnel-traffic-ratio.md new file mode 100644 index 0000000..bbda8ce --- /dev/null +++ b/plans/023-forward-page-tunnel-traffic-ratio.md @@ -0,0 +1,6 @@ +# Forward Page Tunnel Traffic Ratio Plan + +- [x] Review `/forward/list` data flow and rule page render points for tunnel ratio support +- [x] Extend backend forward list payload with tunnel traffic ratio and cover it with a contract test +- [x] Update forward page types, mapping, grouped metadata, and visible ratio UI across list modes +- [x] Verify targeted backend tests and frontend build, then mark the plan complete diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index a15911f..9fd44e0 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -47,6 +47,7 @@ export interface ForwardApiItem { name: string; status: number; tunnelName?: string; + tunnelTrafficRatio?: number; inIp?: string; inPort?: number; remoteAddr?: string; diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index eb99d8c..aa897fd 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -1,4 +1,4 @@ -import type { SpeedLimitApiItem } from "@/api/types"; +import type { ForwardApiItem, SpeedLimitApiItem } from "@/api/types"; import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import toast from "react-hot-toast"; @@ -104,6 +104,7 @@ interface Forward { name: string; tunnelId: number; tunnelName: string; + tunnelTrafficRatio?: number; inIp: string; inPort: number; remoteAddr: string; @@ -164,6 +165,7 @@ interface ForwardUserGroup { interface ForwardTunnelGroup { tunnelKey: string; tunnelName: string; + tunnelTrafficRatio?: number; items: Forward[]; } @@ -245,6 +247,32 @@ const compareForwardTunnelGroupKeyAsc = (a: string, b: string): number => { return compareForwardTunnelNameAsc(a, b); }; +const normalizeTunnelTrafficRatio = (value: unknown): number => { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value; + } + + if (typeof value === "string") { + const parsed = Number(value); + + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + + return 1; +}; + +const formatTunnelTrafficRatio = (value?: number): string => { + const ratio = normalizeTunnelTrafficRatio(value); + + if (Number.isInteger(ratio)) { + return `${ratio}x`; + } + + return `${parseFloat(ratio.toFixed(2))}x`; +}; + const buildForwardGroupOrderLocalKey = (tokenUserId: number): string => { return `${FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`; }; @@ -504,19 +532,32 @@ const normalizeForwardItems = (items: Forward[]): Forward[] => { })); }; -const mapForwardApiItems = (items: any[]): Forward[] => { +const mapForwardApiItems = (items: ForwardApiItem[]): Forward[] => { return (items || []).map((forward) => ({ - ...forward, + id: forward.id, + name: forward.name, tunnelId: forward.tunnelId ?? 0, tunnelName: forward.tunnelName || "", + tunnelTrafficRatio: normalizeTunnelTrafficRatio( + forward.tunnelTrafficRatio, + ), inIp: forward.inIp || "", inPort: forward.inPort ?? 0, remoteAddr: forward.remoteAddr || "", - strategy: forward.strategy || "fifo", + strategy: typeof forward.strategy === "string" ? forward.strategy : "fifo", status: typeof forward.status === "number" ? forward.status : 0, inFlow: forward.inFlow ?? 0, outFlow: forward.outFlow ?? 0, - createdTime: forward.createdTime || "", + createdTime: + typeof forward.createdTime === "string" ? forward.createdTime : "", + userName: + typeof forward.userName === "string" ? forward.userName : undefined, + userId: typeof forward.userId === "number" ? forward.userId : undefined, + inx: typeof forward.inx === "number" ? forward.inx : undefined, + speedId: + typeof forward.speedId === "number" || forward.speedId === null + ? forward.speedId + : undefined, serviceRunning: forward.status === 1, })); }; @@ -2727,6 +2768,9 @@ export default function ForwardPage() { existingGroup.tunnelMap.set(tunnelKey, { tunnelKey, tunnelName, + tunnelTrafficRatio: normalizeTunnelTrafficRatio( + forward.tunnelTrafficRatio, + ), items: [forward], }); @@ -2741,6 +2785,15 @@ export default function ForwardPage() { ) { existingTunnelGroup.tunnelName = tunnelName; } + + if ( + normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) === 1 && + normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1 + ) { + existingTunnelGroup.tunnelTrafficRatio = normalizeTunnelTrafficRatio( + forward.tunnelTrafficRatio, + ); + } }); const groups = Array.from(userGroupMap.values()).map((group) => { @@ -2917,6 +2970,14 @@ export default function ForwardPage() { {tunnel.tunnelName} + + {formatTunnelTrafficRatio(tunnel.tunnelTrafficRatio)} +
{tunnel.items.length} 条规则 @@ -3312,6 +3373,9 @@ export default function ForwardPage() { {forward.tunnelName} + + {formatTunnelTrafficRatio(forward.tunnelTrafficRatio)} + @@ -3467,7 +3531,7 @@ export default function ForwardPage() { {forward.name}

- {normalizeForwardTunnelName(forward.tunnelName)} + {`${normalizeForwardTunnelName(forward.tunnelName)} · ${formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}`}