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}
+
- {normalizeForwardTunnelName(forward.tunnelName)} + {`${normalizeForwardTunnelName(forward.tunnelName)} · ${formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}`}