diff --git a/plans/027-pr-292-node-page-merge-conflict-resolution.md b/plans/027-pr-292-node-page-merge-conflict-resolution.md new file mode 100644 index 0000000..27c9341 --- /dev/null +++ b/plans/027-pr-292-node-page-merge-conflict-resolution.md @@ -0,0 +1,5 @@ +# PR 292 Node Page Merge Conflict Resolution Plan + +- [x] Review the conflicted node page and identify all overlapping feature areas from main and PR #292 +- [x] Merge tab split, per-tab search, remote usage cards, expiry filters, and renewal indicators into `vite-frontend/src/pages/node.tsx` +- [x] Build `vite-frontend` and fix any integration issues from the merged result diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 1d4f3a7..5c1f2cc 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import toast from "react-hot-toast"; + import { DndContext, KeyboardSensor, @@ -51,6 +52,7 @@ import { batchUpgradeNodes, getNodeReleases, rollbackNode, + getPeerRemoteUsageList, type ReleaseChannel, } from "@/api"; import { PageEmptyState, PageLoadingState } from "@/components/page-state"; @@ -130,6 +132,35 @@ interface NodeForm { socks: number; // 0 关 1 开 } +type NodeTab = "local" | "remote"; + +interface RemoteUsageBinding { + bindingId: number; + tunnelId: number; + tunnelName: string; + chainType: number; + hopInx: number; + allocatedPort: number; + resourceKey: string; + remoteBindingId: string; + updatedTime: number; +} + +interface RemoteUsageNode { + nodeId: number; + nodeName: string; + remoteUrl: string; + shareId: number; + portRangeStart: number; + portRangeEnd: number; + maxBandwidth: number; + currentFlow: number; + usedPorts: number[]; + bindings: RemoteUsageBinding[]; + activeBindingNum: number; + syncError?: string; +} + const EXPIRING_SOON_DAYS = 7; type NodeExpiryState = "permanent" | "healthy" | "expiringSoon" | "expired"; @@ -259,10 +290,21 @@ export default function NodePage() { const [nodeList, setNodeList] = useState([]); const [nodeOrder, setNodeOrder] = useState([]); const [loading, setLoading] = useState(false); - const [searchKeyword, setSearchKeyword] = useLocalStorageState( - "node-search-keyword", + const [localSearchKeyword, setLocalSearchKeyword] = useLocalStorageState( + "node-search-keyword-local", "", ); + const [remoteSearchKeyword, setRemoteSearchKeyword] = useLocalStorageState( + "node-search-keyword-remote", + "", + ); + const [activeTab, setActiveTab] = useLocalStorageState( + "node-active-tab", + "local", + ); + const [remoteUsageMap, setRemoteUsageMap] = useState< + Record + >({}); const [nodeFilterMode, setNodeFilterMode, resetNodeFilterMode] = useLocalStorageState("node-expiry-filter-mode", "all"); const [isSearchVisible, setIsSearchVisible] = useState(false); @@ -354,6 +396,25 @@ export default function NodePage() { onNodeOffline: handleNodeOffline, }); + const loadRemoteUsage = useCallback(async () => { + try { + const res = await getPeerRemoteUsageList(); + + if (res.code === 0 && Array.isArray(res.data)) { + const nextMap: Record = {}; + + (res.data as unknown as RemoteUsageNode[]).forEach((item) => { + if (!item || typeof item.nodeId !== "number") return; + nextMap[item.nodeId] = item; + }); + + setRemoteUsageMap(nextMap); + } + } catch { + // ignore remote usage errors in node page + } + }, []); + // 加载节点列表 const loadNodes = useCallback(async (options?: { silent?: boolean }) => { const silent = options?.silent ?? false; @@ -497,7 +558,12 @@ export default function NodePage() { useEffect(() => { loadNodes(); - }, [loadNodes]); + loadRemoteUsage(); + }, [loadNodes, loadRemoteUsage]); + + useEffect(() => { + setSelectedIds(new Set()); + }, [activeTab]); useEffect(() => { if (!usingPollingFallback) { @@ -556,6 +622,32 @@ export default function NodePage() { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; }; + const formatFlow = (bytes: number): string => { + if (!Number.isFinite(bytes) || bytes <= 0) { + return "0 B"; + } + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + }; + + const formatChainType = (chainType: number, hopInx: number) => { + if (chainType === 1) { + return "入口节点"; + } + if (chainType === 2) { + return `中继跳点 #${hopInx}`; + } + if (chainType === 3) { + return "出口节点"; + } + + return "未知链路"; + }; + // 获取进度条颜色 const getProgressColor = ( value: number, @@ -911,17 +1003,30 @@ export default function NodePage() { ); } } else if (upgradeTarget === "batch") { + const selectedLocalIds = Array.from(selectedIds).filter((id) => { + const matchedNode = nodeList.find((node) => node.id === id); + + return matchedNode?.isRemote !== 1; + }); + + if (selectedLocalIds.length === 0) { + toast.error("请选择本地节点进行升级"); + setUpgradeModalOpen(false); + + return; + } + setBatchUpgradeLoading(true); setUpgradeModalOpen(false); try { const res = await batchUpgradeNodes( - Array.from(selectedIds), + selectedLocalIds, version, releaseChannel, ); if (res.code === 0) { - toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`); + toast.success(`批量升级命令已发送到 ${selectedLocalIds.length} 个节点`); } else { toast.error(res.msg || "批量升级失败"); } @@ -1061,12 +1166,26 @@ export default function NodePage() { if (isNaN(activeId) || isNaN(overId)) return; - const oldIndex = nodeOrder.indexOf(activeId); - const newIndex = nodeOrder.indexOf(overId); + const displayNodeIds = displayNodes.map((node) => node.id); + const oldIndex = displayNodeIds.indexOf(activeId); + const newIndex = displayNodeIds.indexOf(overId); if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return; - const newOrder = arrayMove(nodeOrder, oldIndex, newIndex); + const reorderedDisplayIds = arrayMove(displayNodeIds, oldIndex, newIndex); + const displayIdSet = new Set(displayNodeIds); + let reorderedDisplayIndex = 0; + + const newOrder = nodeOrder.map((id) => { + if (!displayIdSet.has(id)) { + return id; + } + + const nextId = reorderedDisplayIds[reorderedDisplayIndex]; + reorderedDisplayIndex += 1; + + return nextId; + }); setNodeOrder(newOrder); @@ -1119,7 +1238,7 @@ export default function NodePage() { }; const selectAll = () => { - setSelectedIds(new Set(sortedNodes.map((n) => n.id))); + setSelectedIds(new Set(displayNodes.map((n) => n.id))); }; const deselectAll = () => { @@ -1169,6 +1288,10 @@ export default function NodePage() { const nodeExpiryStats = useMemo(() => { return nodeList.reduce( (acc, node) => { + if (node.isRemote === 1) { + return acc; + } + const meta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle); if (meta.state === "expired") acc.expired += 1; @@ -1187,23 +1310,86 @@ export default function NodePage() { const sortedNodes = useMemo((): Node[] => { if (!nodeList || nodeList.length === 0) return []; - let filteredNodes = nodeList; + const sortedByDb = [...nodeList].sort((a, b) => { + const expiryDiff = + getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight - + getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight; - if (searchKeyword.trim()) { - const lowerKeyword = searchKeyword.toLowerCase(); + if (expiryDiff !== 0) { + return expiryDiff; + } + const aInx = a.inx ?? 0; + const bInx = b.inx ?? 0; - filteredNodes = filteredNodes.filter( - (n) => - (n.name && n.name.toLowerCase().includes(lowerKeyword)) || - (n.remark && n.remark.toLowerCase().includes(lowerKeyword)) || - (n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) || - (n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) || - (n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)), - ); + return aInx - bInx; + }); + + // 如果数据库中没有排序信息,则使用本地存储的顺序 + if ( + nodeOrder && + nodeOrder.length > 0 && + sortedByDb.every((n) => n.inx === undefined || n.inx === 0) + ) { + const nodeMap = new Map(nodeList.map((n) => [n.id, n] as const)); + const localSorted: Node[] = []; + + nodeOrder.forEach((id) => { + const node = nodeMap.get(id); + + if (node) localSorted.push(node); + }); + + nodeList.forEach((node) => { + if (!nodeOrder.includes(node.id)) { + localSorted.push(node); + } + }); + + return localSorted; } - if (nodeFilterMode !== "all") { - filteredNodes = filteredNodes.filter((node) => { + return sortedByDb; + }, [nodeList, nodeOrder]); + + const filterNodesByKeyword = useCallback((nodes: Node[], keyword: string) => { + const normalizedKeyword = keyword.trim().toLowerCase(); + + if (!normalizedKeyword) { + return nodes; + } + + return nodes.filter( + (node) => + (node.name && node.name.toLowerCase().includes(normalizedKeyword)) || + (node.remark && node.remark.toLowerCase().includes(normalizedKeyword)) || + (node.serverIp && + node.serverIp.toLowerCase().includes(normalizedKeyword)) || + (node.serverIpV4 && + node.serverIpV4.toLowerCase().includes(normalizedKeyword)) || + (node.serverIpV6 && + node.serverIpV6.toLowerCase().includes(normalizedKeyword)), + ); + }, []); + + const localNodes = useMemo( + () => sortedNodes.filter((node) => node.isRemote !== 1), + [sortedNodes], + ); + + const remoteNodes = useMemo( + () => sortedNodes.filter((node) => node.isRemote === 1), + [sortedNodes], + ); + + const filteredLocalNodes = useMemo( + () => { + const keywordFiltered = filterNodesByKeyword(localNodes, localSearchKeyword); + + if (nodeFilterMode === "all") { + return keywordFiltered; + } + + return keywordFiltered.filter((node) => { const expiryMeta = getNodeExpiryMeta( node.expiryTime, node.renewalCycle, @@ -1220,71 +1406,88 @@ export default function NodePage() { return true; } }); - } + }, + [filterNodesByKeyword, localNodes, localSearchKeyword, nodeFilterMode], + ); - const sortedByDb = [...filteredNodes].sort((a, b) => { - const expiryDiff = - getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight - - getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight; + const filteredRemoteNodes = useMemo( + () => filterNodesByKeyword(remoteNodes, remoteSearchKeyword), + [filterNodesByKeyword, remoteNodes, remoteSearchKeyword], + ); - if (expiryDiff !== 0) { - return expiryDiff; - } + const currentSearchKeyword = + activeTab === "remote" ? remoteSearchKeyword : localSearchKeyword; - const aInx = a.inx ?? 0; - const bInx = b.inx ?? 0; + const setCurrentSearchKeyword = + activeTab === "remote" ? setRemoteSearchKeyword : setLocalSearchKeyword; - return aInx - bInx; - }); + const displayNodes = useMemo( + () => (activeTab === "remote" ? filteredRemoteNodes : filteredLocalNodes), + [activeTab, filteredLocalNodes, filteredRemoteNodes], + ); - // 如果数据库中没有排序信息,则使用本地存储的顺序 - if ( - nodeOrder && - nodeOrder.length > 0 && - sortedByDb.every((n) => n.inx === undefined || n.inx === 0) - ) { - const nodeMap = new Map(filteredNodes.map((n) => [n.id, n] as const)); - const localSorted: Node[] = []; - - nodeOrder.forEach((id) => { - const node = nodeMap.get(id); - - if (node) localSorted.push(node); - }); - - filteredNodes.forEach((node) => { - if (!nodeOrder.includes(node.id)) { - localSorted.push(node); - } - }); - - return localSorted; - } - - return sortedByDb; - }, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]); + const canBatchUpgrade = activeTab === "local"; + const canUseExpiryFilter = activeTab === "local"; + const hasKeywordSearch = currentSearchKeyword.trim().length > 0; + const hasActiveFilters = nodeFilterMode !== "all"; + const isDisplayFiltered = hasKeywordSearch || (canUseExpiryFilter && hasActiveFilters); const sortableNodeIds = useMemo( - () => sortedNodes.map((n) => n.id), - [sortedNodes], + () => displayNodes.map((n) => n.id), + [displayNodes], ); return ( -
-
- setIsSearchVisible(false)} - onOpen={() => setIsSearchVisible(true)} - /> +
+
+ +
-
-
+
+
+ setIsSearchVisible(false)} + onOpen={() => setIsSearchVisible(true)} + /> +
+ +
{selectMode ? ( <> @@ -1308,7 +1511,7 @@ export default function NodePage() { @@ -1415,6 +1623,19 @@ export default function NodePage() { className="h-64" message="暂无节点配置,点击上方按钮开始创建" /> + ) : displayNodes.length === 0 ? ( + ) : (
- {sortedNodes.map((node) => { + {displayNodes.map((node) => { const isRemoteNode = node.isRemote === 1; + const remoteUsage = isRemoteNode ? remoteUsageMap[node.id] : null; const expiryMeta = getNodeExpiryMeta( node.expiryTime, node.renewalCycle, @@ -1592,6 +1814,117 @@ export default function NodePage() { )}
+ {isRemoteNode && ( +
+ {remoteUsage ? ( + <> +
+
+ 远程地址 + + {remoteUsage.remoteUrl || node.remoteUrl || "-"} + +
+
+ 共享ID + #{remoteUsage.shareId} +
+
+ 流量 + + {formatFlow(remoteUsage.currentFlow)} + +
+
+ 带宽上限 + + {remoteUsage.maxBandwidth > 0 + ? formatSpeed(remoteUsage.maxBandwidth) + : "不限"} + +
+
+ +
+
+ 占用端口 + + {remoteUsage.usedPorts.length}/{Math.max( + remoteUsage.portRangeEnd - + remoteUsage.portRangeStart + + 1, + 0, + )} + +
+
+ {remoteUsage.usedPorts.length > 0 ? ( +
+ {remoteUsage.usedPorts.map((port) => ( + + {port} + + ))} +
+ ) : ( +
暂无占用端口
+ )} +
+
+ +
+
+ 绑定明细 + + {remoteUsage.activeBindingNum} + +
+
+ {remoteUsage.bindings.length > 0 ? ( + remoteUsage.bindings.map((binding) => ( +
+
+ + {binding.tunnelName} + + + #{binding.tunnelId} + +
+
+ {formatChainType(binding.chainType, binding.hopInx)} + 端口 {binding.allocatedPort} +
+
+ )) + ) : ( +
暂无绑定明细
+ )} +
+
+ + ) : ( +
+ 暂未获取到远程占用数据 +
+ )} +
+ )} + {!isRemoteNode && ( <> {/* 系统监控 */}