feat(forward): display tunnel traffic ratio on forward page

This commit is contained in:
sagitchu
2026-03-08 20:00:26 +08:00
parent 31ef861504
commit d60655045a
5 changed files with 103 additions and 23 deletions
+18 -16
View File
@@ -703,25 +703,26 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
} }
type fwdRow struct { type fwdRow struct {
ID int64 ID int64
UserID int64 UserID int64
UserName string UserName string
Name string Name string
TunnelID int64 TunnelID int64
TunnelName string TunnelName string
RemoteAddr string TrafficRatio float64
Strategy string RemoteAddr string
InFlow int64 Strategy string
OutFlow int64 InFlow int64
CreatedTime int64 OutFlow int64
Status int CreatedTime int64
Inx int Status int
SpeedID sql.NullInt64 Inx int
SpeedID sql.NullInt64
} }
var rows []fwdRow var rows []fwdRow
err := r.db.Model(&model.Forward{}). 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"). Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
Order("forward.inx ASC, forward.id ASC"). Order("forward.inx ASC, forward.id ASC").
Find(&rows).Error Find(&rows).Error
@@ -738,7 +739,8 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
item := map[string]interface{}{ item := map[string]interface{}{
"id": row.ID, "userId": row.UserID, "userName": row.UserName, "id": row.ID, "userId": row.UserID, "userName": row.UserName,
"name": row.Name, "tunnelId": row.TunnelID, "tunnelName": row.TunnelName, "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, "remoteAddr": row.RemoteAddr, "strategy": row.Strategy,
"inFlow": row.InFlow, "outFlow": row.OutFlow, "inFlow": row.InFlow, "outFlow": row.OutFlow,
"createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx), "createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx),
@@ -31,7 +31,7 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
if err := repo.DB().Exec(` if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 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) t.Fatalf("insert tunnel: %v", err)
} }
tunnelID := mustLastInsertID(t, repo, "contract-tunnel") tunnelID := mustLastInsertID(t, repo, "contract-tunnel")
@@ -118,6 +118,13 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
if got := int64(idFloat); got != userForwardID { if got := int64(idFloat); got != userForwardID {
t.Fatalf("expected forward id %d, got %d", userForwardID, got) 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) { t.Run("forward diagnose returns structured payload", func(t *testing.T) {
@@ -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
+1
View File
@@ -47,6 +47,7 @@ export interface ForwardApiItem {
name: string; name: string;
status: number; status: number;
tunnelName?: string; tunnelName?: string;
tunnelTrafficRatio?: number;
inIp?: string; inIp?: string;
inPort?: number; inPort?: number;
remoteAddr?: string; remoteAddr?: string;
+70 -6
View File
@@ -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 { useState, useEffect, useMemo, useRef, useCallback } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@@ -104,6 +104,7 @@ interface Forward {
name: string; name: string;
tunnelId: number; tunnelId: number;
tunnelName: string; tunnelName: string;
tunnelTrafficRatio?: number;
inIp: string; inIp: string;
inPort: number; inPort: number;
remoteAddr: string; remoteAddr: string;
@@ -164,6 +165,7 @@ interface ForwardUserGroup {
interface ForwardTunnelGroup { interface ForwardTunnelGroup {
tunnelKey: string; tunnelKey: string;
tunnelName: string; tunnelName: string;
tunnelTrafficRatio?: number;
items: Forward[]; items: Forward[];
} }
@@ -245,6 +247,32 @@ const compareForwardTunnelGroupKeyAsc = (a: string, b: string): number => {
return compareForwardTunnelNameAsc(a, b); 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 => { const buildForwardGroupOrderLocalKey = (tokenUserId: number): string => {
return `${FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`; 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) => ({ return (items || []).map((forward) => ({
...forward, id: forward.id,
name: forward.name,
tunnelId: forward.tunnelId ?? 0, tunnelId: forward.tunnelId ?? 0,
tunnelName: forward.tunnelName || "", tunnelName: forward.tunnelName || "",
tunnelTrafficRatio: normalizeTunnelTrafficRatio(
forward.tunnelTrafficRatio,
),
inIp: forward.inIp || "", inIp: forward.inIp || "",
inPort: forward.inPort ?? 0, inPort: forward.inPort ?? 0,
remoteAddr: forward.remoteAddr || "", remoteAddr: forward.remoteAddr || "",
strategy: forward.strategy || "fifo", strategy: typeof forward.strategy === "string" ? forward.strategy : "fifo",
status: typeof forward.status === "number" ? forward.status : 0, status: typeof forward.status === "number" ? forward.status : 0,
inFlow: forward.inFlow ?? 0, inFlow: forward.inFlow ?? 0,
outFlow: forward.outFlow ?? 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, serviceRunning: forward.status === 1,
})); }));
}; };
@@ -2727,6 +2768,9 @@ export default function ForwardPage() {
existingGroup.tunnelMap.set(tunnelKey, { existingGroup.tunnelMap.set(tunnelKey, {
tunnelKey, tunnelKey,
tunnelName, tunnelName,
tunnelTrafficRatio: normalizeTunnelTrafficRatio(
forward.tunnelTrafficRatio,
),
items: [forward], items: [forward],
}); });
@@ -2741,6 +2785,15 @@ export default function ForwardPage() {
) { ) {
existingTunnelGroup.tunnelName = tunnelName; 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) => { const groups = Array.from(userGroupMap.values()).map((group) => {
@@ -2917,6 +2970,14 @@ export default function ForwardPage() {
</svg> </svg>
</Button> </Button>
<span className={titleClassName}>{tunnel.tunnelName}</span> <span className={titleClassName}>{tunnel.tunnelName}</span>
<Chip
className="h-5 border-none bg-secondary/15 px-1.5 text-[11px] font-semibold text-secondary-700"
color="secondary"
size="sm"
variant="flat"
>
{formatTunnelTrafficRatio(tunnel.tunnelTrafficRatio)}
</Chip>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={countClassName}>{tunnel.items.length} 条规则</span> <span className={countClassName}>{tunnel.items.length} 条规则</span>
@@ -3312,6 +3373,9 @@ export default function ForwardPage() {
<span className="font-medium text-secondary-700"> <span className="font-medium text-secondary-700">
{forward.tunnelName} {forward.tunnelName}
</span> </span>
<span className="ml-1 text-secondary-600/80">
{formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}
</span>
</Chip> </Chip>
</TableCell> </TableCell>
<TableCell className="max-w-[220px]"> <TableCell className="max-w-[220px]">
@@ -3467,7 +3531,7 @@ export default function ForwardPage() {
{forward.name} {forward.name}
</h3> </h3>
<p className="text-xs text-default-500 truncate"> <p className="text-xs text-default-500 truncate">
{normalizeForwardTunnelName(forward.tunnelName)} {`${normalizeForwardTunnelName(forward.tunnelName)} · ${formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}`}
</p> </p>
</div> </div>
<div className="flex items-center gap-1.5 ml-2"> <div className="flex items-center gap-1.5 ml-2">