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
+3 -1
View File
@@ -709,6 +709,7 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
Name string Name string
TunnelID int64 TunnelID int64
TunnelName string TunnelName string
TrafficRatio float64
RemoteAddr string RemoteAddr string
Strategy string Strategy string
InFlow int64 InFlow int64
@@ -721,7 +722,7 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
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,6 +739,7 @@ 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,
"tunnelTrafficRatio": row.TrafficRatio,
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort), "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,
@@ -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">