fix: tunnel chain order and speed limit UI improvements (#216)

* fix(backend): use correct chain order index for tunnel nodes

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(frontend): filter '不限速' from speed limit dropdowns and preserve node order

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
sagit
2026-02-26 21:30:25 +08:00
committed by GitHub
parent 34581e0d18
commit 7d74f76e5e
4 changed files with 93 additions and 42 deletions
@@ -2806,7 +2806,7 @@ func pickNodeAddressV6(node *nodeRecord) string {
func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error { func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error {
allocated := map[int64]int{} allocated := map[int64]int{}
inNodes := asMapSlice(req["inNodeId"]) inNodes := asMapSlice(req["inNodeId"])
for _, n := range inNodes { for i, n := range inNodes {
nodeID := asInt64(n["nodeId"], 0) nodeID := asInt64(n["nodeId"], 0)
if nodeID <= 0 { if nodeID <= 0 {
continue continue
@@ -2818,13 +2818,13 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
nodeID, nodeID,
sql.NullInt64{}, sql.NullInt64{},
defaultString(asString(n["strategy"]), "round"), defaultString(asString(n["strategy"]), "round"),
0, i+1,
defaultString(asString(n["protocol"]), "tls"), defaultString(asString(n["protocol"]), "tls"),
); err != nil { ); err != nil {
return err return err
} }
} }
for _, n := range asMapSlice(req["outNodeId"]) { for i, n := range asMapSlice(req["outNodeId"]) {
nodeID := asInt64(n["nodeId"], 0) nodeID := asInt64(n["nodeId"], 0)
if nodeID <= 0 { if nodeID <= 0 {
continue continue
@@ -2844,7 +2844,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
nodeID, nodeID,
sql.NullInt64{Int64: int64(port), Valid: true}, sql.NullInt64{Int64: int64(port), Valid: true},
defaultString(asString(n["strategy"]), "round"), defaultString(asString(n["strategy"]), "round"),
0, i+1,
defaultString(asString(n["protocol"]), "tls"), defaultString(asString(n["protocol"]), "tls"),
); err != nil { ); err != nil {
return err return err
+27 -7
View File
@@ -503,10 +503,30 @@ export default function ForwardPage() {
}; };
// 表单验证 // 表单验证
const availableSpeedLimits = useMemo(() => { const noLimitSpeedLimitIds = useMemo(() => {
return speedLimits; return new Set(
speedLimits
.filter((speedLimit) => speedLimit.name.trim() === "不限速")
.map((speedLimit) => speedLimit.id),
);
}, [speedLimits]); }, [speedLimits]);
const availableSpeedLimits = useMemo(() => {
return speedLimits.filter(
(speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),
);
}, [speedLimits, noLimitSpeedLimitIds]);
const normalizeSpeedId = (speedId?: number | null): number | null => {
if (speedId === null || speedId === undefined) {
return null;
}
return noLimitSpeedLimitIds.has(speedId) ? null : speedId;
};
const selectedSpeedId = normalizeSpeedId(form.speedId);
const validateForm = (): boolean => { const validateForm = (): boolean => {
const newErrors: { [key: string]: string } = {}; const newErrors: { [key: string]: string } = {};
@@ -591,7 +611,7 @@ export default function ForwardPage() {
remoteAddr: forward.remoteAddr.split(",").join("\n"), remoteAddr: forward.remoteAddr.split(",").join("\n"),
interfaceName: forward.interfaceName || "", interfaceName: forward.interfaceName || "",
strategy: forward.strategy || "fifo", strategy: forward.strategy || "fifo",
speedId: forward.speedId ?? null, speedId: normalizeSpeedId(forward.speedId),
}); });
setErrors({}); setErrors({});
setModalOpen(true); setModalOpen(true);
@@ -671,7 +691,7 @@ export default function ForwardPage() {
inPort: form.inPort, inPort: form.inPort,
remoteAddr: processedRemoteAddr, remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : "fifo", strategy: addressCount > 1 ? form.strategy : "fifo",
speedId: form.speedId, speedId: normalizeSpeedId(form.speedId),
}; };
res = await updateForward(updateData); res = await updateForward(updateData);
@@ -683,7 +703,7 @@ export default function ForwardPage() {
inPort: form.inPort, inPort: form.inPort,
remoteAddr: processedRemoteAddr, remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : "fifo", strategy: addressCount > 1 ? form.strategy : "fifo",
speedId: form.speedId, speedId: normalizeSpeedId(form.speedId),
}; };
res = await createForward(createData); res = await createForward(createData);
@@ -2331,8 +2351,8 @@ export default function ForwardPage() {
label="限速规则" label="限速规则"
placeholder="不限速" placeholder="不限速"
selectedKeys={ selectedKeys={
form.speedId !== null && form.speedId !== undefined selectedSpeedId !== null
? [form.speedId.toString()] ? [selectedSpeedId.toString()]
: ["null"] : ["null"]
} }
variant="bordered" variant="bordered"
+29 -24
View File
@@ -366,6 +366,21 @@ export default function TunnelPage() {
return form.chainNodes || []; return form.chainNodes || [];
}; };
const mergeOrderedNodes = (
currentNodes: ChainTunnel[],
selectedNodeIds: number[],
buildDefault: (nodeId: number) => ChainTunnel,
): ChainTunnel[] => {
const selectedSet = new Set(selectedNodeIds);
const kept = currentNodes.filter((node) => selectedSet.has(node.nodeId));
const keptIds = new Set(kept.map((node) => node.nodeId));
const added = selectedNodeIds
.filter((nodeId) => !keptIds.has(nodeId))
.map((nodeId) => buildDefault(nodeId));
return [...kept, ...added];
};
// 提交表单 // 提交表单
const handleSubmit = async () => { const handleSubmit = async () => {
if (!validateForm()) return; if (!validateForm()) return;
@@ -1241,15 +1256,10 @@ export default function TunnelPage() {
const selectedIds = Array.from(keys).map((key) => const selectedIds = Array.from(keys).map((key) =>
parseInt(key as string), parseInt(key as string),
); );
const newInNodeId: ChainTunnel[] = selectedIds.map( const newInNodeId = mergeOrderedNodes(
(nodeId) => { form.inNodeId,
// 保留已有的端口配置 selectedIds,
const existing = form.inNodeId.find( (nodeId) => ({ nodeId, chainType: 1 }),
(ct) => ct.nodeId === nodeId,
);
return existing || { nodeId, chainType: 1 };
},
); );
setForm((prev) => ({ ...prev, inNodeId: newInNodeId })); setForm((prev) => ({ ...prev, inNodeId: newInNodeId }));
@@ -1659,21 +1669,16 @@ export default function TunnelPage() {
const realNodes = currentOutNodes.filter( const realNodes = currentOutNodes.filter(
(ct) => ct.nodeId !== -1, (ct) => ct.nodeId !== -1,
); );
const newOutNodeId: ChainTunnel[] = const newOutNodeId = mergeOrderedNodes(
selectedIds.map((nodeId) => { realNodes,
const existing = realNodes.find( selectedIds,
(ct) => ct.nodeId === nodeId, (nodeId) => ({
); nodeId,
chainType: 3,
return ( protocol,
existing || { strategy,
nodeId, }),
chainType: 3, );
protocol,
strategy,
}
);
});
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
+33 -7
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date"; import { parseDate } from "@internationalized/date";
@@ -219,6 +219,22 @@ export default function UserPage() {
const [speedLimits, setSpeedLimits] = useState<SpeedLimit[]>([]); const [speedLimits, setSpeedLimits] = useState<SpeedLimit[]>([]);
const [userGroups, setUserGroups] = useState<UserGroup[]>([]); const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
const noLimitSpeedLimitIds = useMemo(() => {
return new Set(
speedLimits
.filter((speedLimit) => speedLimit.name.trim() === "不限速")
.map((speedLimit) => speedLimit.id),
);
}, [speedLimits]);
const normalizeSpeedId = (speedId?: number | null): number | null => {
if (speedId === null || speedId === undefined) {
return null;
}
return noLimitSpeedLimitIds.has(speedId) ? null : speedId;
};
// 生命周期 // 生命周期
useEffect(() => { useEffect(() => {
loadUsers(); loadUsers();
@@ -432,7 +448,10 @@ export default function UserPage() {
try { try {
const tunnelsToAssign: TunnelAssignItem[] = Array.from( const tunnelsToAssign: TunnelAssignItem[] = Array.from(
batchTunnelSelections.entries(), batchTunnelSelections.entries(),
).map(([tunnelId, speedId]) => ({ tunnelId, speedId })); ).map(([tunnelId, speedId]) => ({
tunnelId,
speedId: normalizeSpeedId(speedId),
}));
const response = await batchAssignUserTunnel({ const response = await batchAssignUserTunnel({
userId: currentUser.id, userId: currentUser.id,
@@ -456,6 +475,7 @@ export default function UserPage() {
const handleEditTunnel = (userTunnel: UserTunnel) => { const handleEditTunnel = (userTunnel: UserTunnel) => {
setEditTunnelForm({ setEditTunnelForm({
...userTunnel, ...userTunnel,
speedId: normalizeSpeedId(userTunnel.speedId),
expTime: userTunnel.expTime, expTime: userTunnel.expTime,
}); });
onEditTunnelModalOpen(); onEditTunnelModalOpen();
@@ -472,7 +492,7 @@ export default function UserPage() {
num: editTunnelForm.num, num: editTunnelForm.num,
expTime: editTunnelForm.expTime, expTime: editTunnelForm.expTime,
flowResetTime: editTunnelForm.flowResetTime, flowResetTime: editTunnelForm.flowResetTime,
speedId: editTunnelForm.speedId, speedId: normalizeSpeedId(editTunnelForm.speedId),
status: editTunnelForm.status, status: editTunnelForm.status,
}); });
@@ -582,12 +602,18 @@ export default function UserPage() {
} }
}; };
const editAvailableSpeedLimits = speedLimits; const editAvailableSpeedLimits = speedLimits.filter(
(speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),
);
const getSpeedLimitsForTunnel = (_tunnelId: number) => { const getSpeedLimitsForTunnel = (_tunnelId: number) => {
return speedLimits; return speedLimits.filter(
(speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),
);
}; };
const editTunnelSelectedSpeedId = normalizeSpeedId(editTunnelForm?.speedId);
const toggleTunnelSelection = (tunnelId: number) => { const toggleTunnelSelection = (tunnelId: number) => {
setBatchTunnelSelections((prev) => { setBatchTunnelSelections((prev) => {
const newMap = new Map(prev); const newMap = new Map(prev);
@@ -1404,8 +1430,8 @@ export default function UserPage() {
<Select <Select
label="限速规则" label="限速规则"
selectedKeys={ selectedKeys={
editTunnelForm.speedId editTunnelSelectedSpeedId !== null
? [editTunnelForm.speedId.toString()] ? [editTunnelSelectedSpeedId.toString()]
: ["null"] : ["null"]
} }
onSelectionChange={(keys) => { onSelectionChange={(keys) => {