diff --git a/frontend/src/api/queries/useNodeMutations.ts b/frontend/src/api/queries/useNodeMutations.ts new file mode 100644 index 00000000..2b9f707e --- /dev/null +++ b/frontend/src/api/queries/useNodeMutations.ts @@ -0,0 +1,63 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { HttpUtil } from '@/utils'; +import { keys } from '@/api/queryKeys'; +import type { NodeRecord } from '@/api/queries/useNodesQuery'; + +interface ApiMsg { + success?: boolean; + msg?: string; + obj?: T; +} + +export interface ProbeResult { + status: string; + latencyMs?: number; + xrayVersion?: string; + error?: string; +} + +export function useNodeMutations() { + const queryClient = useQueryClient(); + const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() }); + + const createMut = useMutation({ + mutationFn: (payload: Partial) => + HttpUtil.post('/panel/api/nodes/add', payload) as Promise, + onSuccess: (msg) => { if (msg?.success) invalidate(); }, + }); + + const updateMut = useMutation({ + mutationFn: ({ id, payload }: { id: number; payload: Partial }) => + HttpUtil.post(`/panel/api/nodes/update/${id}`, payload) as Promise, + onSuccess: (msg) => { if (msg?.success) invalidate(); }, + }); + + const removeMut = useMutation({ + mutationFn: (id: number) => + HttpUtil.post(`/panel/api/nodes/del/${id}`) as Promise, + onSuccess: (msg) => { if (msg?.success) invalidate(); }, + }); + + const setEnableMut = useMutation({ + mutationFn: ({ id, enable }: { id: number; enable: boolean }) => + HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }) as Promise, + onSuccess: (msg) => { if (msg?.success) invalidate(); }, + }); + + const probeMut = useMutation({ + mutationFn: (id: number) => + HttpUtil.post(`/panel/api/nodes/probe/${id}`) as Promise>, + onSuccess: (msg) => { if (msg?.success) invalidate(); }, + }); + + return { + create: (payload: Partial) => createMut.mutateAsync(payload), + update: (id: number, payload: Partial) => updateMut.mutateAsync({ id, payload }), + remove: (id: number) => removeMut.mutateAsync(id), + setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }), + probe: (id: number) => probeMut.mutateAsync(id), + testConnection: (payload: Partial) => + HttpUtil.post('/panel/api/nodes/test', payload) as Promise>, + }; +} diff --git a/frontend/src/api/queries/useNodesQuery.ts b/frontend/src/api/queries/useNodesQuery.ts new file mode 100644 index 00000000..5c7c6b07 --- /dev/null +++ b/frontend/src/api/queries/useNodesQuery.ts @@ -0,0 +1,108 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; + +import { HttpUtil } from '@/utils'; +import { keys } from '@/api/queryKeys'; + +export interface NodeRecord { + id: number; + name?: string; + remark?: string; + scheme?: string; + address?: string; + port?: number; + basePath?: string; + apiToken?: string; + enable?: boolean; + status?: 'online' | 'offline' | string; + latencyMs?: number; + cpuPct?: number; + memPct?: number; + xrayVersion?: string; + panelVersion?: string; + uptimeSecs?: number; + inboundCount?: number; + clientCount?: number; + onlineCount?: number; + depletedCount?: number; + lastHeartbeat?: number; + lastError?: string; + allowPrivateAddress?: boolean; + [key: string]: unknown; +} + +export interface NodeTotals { + total: number; + online: number; + offline: number; + avgLatency: number; + inbounds: number; + clients: number; + onlineClients: number; + depleted: number; +} + +interface ApiMsg { + success?: boolean; + msg?: string; + obj?: T; +} + +async function fetchNodes(): Promise { + const msg = await HttpUtil.get('/panel/api/nodes/list', undefined, { silent: true }) as ApiMsg; + if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch nodes'); + return Array.isArray(msg.obj) ? msg.obj : []; +} + +export function useNodesQuery() { + const query = useQuery({ + queryKey: keys.nodes.list(), + queryFn: fetchNodes, + }); + + const nodes = useMemo(() => query.data ?? [], [query.data]); + + const totals = useMemo(() => { + let online = 0; + let offline = 0; + let latencySum = 0; + let latencyCount = 0; + let inbounds = 0; + let clients = 0; + let onlineClients = 0; + let depleted = 0; + for (const n of nodes) { + inbounds += n.inboundCount || 0; + clients += n.clientCount || 0; + onlineClients += n.onlineCount || 0; + depleted += n.depletedCount || 0; + if (!n.enable) continue; + if (n.status === 'online') { + online += 1; + if (n.latencyMs && n.latencyMs > 0) { + latencySum += n.latencyMs; + latencyCount += 1; + } + } else if (n.status === 'offline') { + offline += 1; + } + } + return { + total: nodes.length, + online, + offline, + avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0, + inbounds, + clients, + onlineClients, + depleted, + }; + }, [nodes]); + + return { + nodes, + totals, + loading: query.isFetching, + fetched: query.data !== undefined, + }; +} diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index c606e53e..3d4a852d 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -2,4 +2,8 @@ export const keys = { server: { status: () => ['server', 'status'] as const, }, + nodes: { + root: () => ['nodes'] as const, + list: () => ['nodes', 'list'] as const, + }, } as const; diff --git a/frontend/src/api/websocketBridge.ts b/frontend/src/api/websocketBridge.ts index 8a43eab1..db738d59 100644 --- a/frontend/src/api/websocketBridge.ts +++ b/frontend/src/api/websocketBridge.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { WebSocketClient } from '@/api/websocket.js'; +import { keys } from '@/api/queryKeys'; type Handler = (payload: unknown) => void; @@ -46,13 +47,20 @@ export function useWebSocketBridge() { queryClient.setQueryData(['xray', 'outboundsTraffic'], payload); }; + const onNodes: Handler = (payload) => { + if (!Array.isArray(payload)) return; + queryClient.setQueryData(keys.nodes.list(), payload); + }; + client.on('invalidate', onInvalidate); client.on('outbounds', onOutbounds); + client.on('nodes', onNodes); client.connect(); return () => { client.off('invalidate', onInvalidate); client.off('outbounds', onOutbounds); + client.off('nodes', onNodes); if (invalidateTimer != null) { clearTimeout(invalidateTimer); invalidateTimer = null; diff --git a/frontend/src/hooks/useNodes.ts b/frontend/src/hooks/useNodes.ts deleted file mode 100644 index 3f316263..00000000 --- a/frontend/src/hooks/useNodes.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { HttpUtil } from '@/utils'; - -export interface NodeRecord { - id: number; - name?: string; - remark?: string; - scheme?: string; - address?: string; - port?: number; - basePath?: string; - apiToken?: string; - enable?: boolean; - status?: 'online' | 'offline' | string; - latencyMs?: number; - cpuPct?: number; - memPct?: number; - xrayVersion?: string; - panelVersion?: string; - uptimeSecs?: number; - inboundCount?: number; - clientCount?: number; - onlineCount?: number; - depletedCount?: number; - lastHeartbeat?: number; - lastError?: string; - allowPrivateAddress?: boolean; - [key: string]: unknown; -} - -interface ApiMsg { - success?: boolean; - msg?: string; - obj?: T; -} - -interface NodeTotals { - total: number; - online: number; - offline: number; - avgLatency: number; - inbounds: number; - clients: number; - onlineClients: number; - depleted: number; -} - -export function useNodes() { - const [nodes, setNodes] = useState([]); - const [loading, setLoading] = useState(false); - const [fetched, setFetched] = useState(false); - const fetchedRef = useRef(false); - - const refresh = useCallback(async () => { - setLoading(true); - try { - const msg = await HttpUtil.get('/panel/api/nodes/list') as ApiMsg; - if (msg?.success) { - setNodes(Array.isArray(msg.obj) ? msg.obj : []); - } - fetchedRef.current = true; - setFetched(true); - } finally { - setLoading(false); - } - }, []); - - const applyNodesEvent = useCallback((payload: unknown) => { - if (Array.isArray(payload)) { - setNodes(payload as NodeRecord[]); - if (!fetchedRef.current) { - fetchedRef.current = true; - setFetched(true); - } - } - }, []); - - const create = useCallback(async (payload: Partial) => { - const msg = await HttpUtil.post('/panel/api/nodes/add', payload) as ApiMsg; - if (msg?.success) await refresh(); - return msg; - }, [refresh]); - - const update = useCallback(async (id: number, payload: Partial) => { - const msg = await HttpUtil.post(`/panel/api/nodes/update/${id}`, payload) as ApiMsg; - if (msg?.success) await refresh(); - return msg; - }, [refresh]); - - const remove = useCallback(async (id: number) => { - const msg = await HttpUtil.post(`/panel/api/nodes/del/${id}`) as ApiMsg; - if (msg?.success) await refresh(); - return msg; - }, [refresh]); - - const setEnable = useCallback(async (id: number, enable: boolean) => { - const msg = await HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }) as ApiMsg; - if (msg?.success) await refresh(); - return msg; - }, [refresh]); - - const testConnection = useCallback(async (payload: Partial) => { - return await HttpUtil.post('/panel/api/nodes/test', payload) as ApiMsg<{ - status: string; - latencyMs?: number; - xrayVersion?: string; - error?: string; - }>; - }, []); - - const probe = useCallback(async (id: number) => { - const msg = await HttpUtil.post(`/panel/api/nodes/probe/${id}`) as ApiMsg<{ - status: string; - latencyMs?: number; - error?: string; - }>; - if (msg?.success) await refresh(); - return msg; - }, [refresh]); - - const totals = useMemo(() => { - let online = 0; - let offline = 0; - let latencySum = 0; - let latencyCount = 0; - let inbounds = 0; - let clients = 0; - let onlineClients = 0; - let depleted = 0; - for (const n of nodes) { - inbounds += n.inboundCount || 0; - clients += n.clientCount || 0; - onlineClients += n.onlineCount || 0; - depleted += n.depletedCount || 0; - if (!n.enable) continue; - if (n.status === 'online') { - online += 1; - if (n.latencyMs && n.latencyMs > 0) { - latencySum += n.latencyMs; - latencyCount += 1; - } - } else if (n.status === 'offline') { - offline += 1; - } - } - return { - total: nodes.length, - online, - offline, - avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0, - inbounds, - clients, - onlineClients, - depleted, - }; - }, [nodes]); - - useEffect(() => { - - refresh(); - }, [refresh]); - - return { - nodes, - loading, - fetched, - totals, - refresh, - applyNodesEvent, - create, - update, - remove, - setEnable, - testConnection, - probe, - }; -} diff --git a/frontend/src/pages/inbounds/InboundFormModal.tsx b/frontend/src/pages/inbounds/InboundFormModal.tsx index 6c7213ec..1e0a9b39 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/InboundFormModal.tsx @@ -60,7 +60,7 @@ import { DBInbound } from '@/models/dbinbound.js'; import FinalMaskForm from '@/components/FinalMaskForm'; import DateTimePicker from '@/components/DateTimePicker'; import JsonEditor from '@/components/JsonEditor'; -import type { NodeRecord } from '@/hooks/useNodes'; +import type { NodeRecord } from '@/api/queries/useNodesQuery'; import './InboundFormModal.css'; const { TextArea } = Input; diff --git a/frontend/src/pages/inbounds/InboundList.tsx b/frontend/src/pages/inbounds/InboundList.tsx index b2f9c75f..c73db4d3 100644 --- a/frontend/src/pages/inbounds/InboundList.tsx +++ b/frontend/src/pages/inbounds/InboundList.tsx @@ -33,7 +33,7 @@ import { import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils'; import InfinityIcon from '@/components/InfinityIcon'; import { useDatepicker } from '@/hooks/useDatepicker'; -import type { NodeRecord } from '@/hooks/useNodes'; +import type { NodeRecord } from '@/api/queries/useNodesQuery'; import './InboundList.css'; type ProtocolFlags = { diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index bcf5d960..95e994c7 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -25,7 +25,7 @@ import { coerceInboundJsonField } from '@/models/dbinbound.js'; import { useTheme } from '@/hooks/useTheme'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useWebSocket } from '@/hooks/useWebSocket'; -import { useNodes } from '@/hooks/useNodes'; +import { useNodesQuery } from '@/api/queries/useNodesQuery'; import AppSidebar from '@/components/AppSidebar'; import CustomStatistic from '@/components/CustomStatistic'; const TextModal = lazy(() => import('@/components/TextModal')); @@ -85,9 +85,9 @@ export default function InboundsPage() { const [messageApi, messageContextHolder] = message.useMessage(); useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); - const { nodes: nodesList } = useNodes(); + const { nodes: nodesList } = useNodesQuery(); const nodesById = useMemo(() => { - const map = new Map['nodes'][number]>(); + const map = new Map['nodes'][number]>(); for (const n of nodesList || []) map.set(n.id, n); return map; }, [nodesList]); diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx index 795fec76..93dc3aba 100644 --- a/frontend/src/pages/nodes/NodeFormModal.tsx +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -13,7 +13,7 @@ import { Switch, message, } from 'antd'; -import type { NodeRecord } from '@/hooks/useNodes'; +import type { NodeRecord } from '@/api/queries/useNodesQuery'; import './NodeFormModal.css'; type Mode = 'add' | 'edit'; diff --git a/frontend/src/pages/nodes/NodeList.tsx b/frontend/src/pages/nodes/NodeList.tsx index 3cfaee99..8179a979 100644 --- a/frontend/src/pages/nodes/NodeList.tsx +++ b/frontend/src/pages/nodes/NodeList.tsx @@ -28,7 +28,7 @@ import { } from '@ant-design/icons'; import NodeHistoryPanel from './NodeHistoryPanel'; -import type { NodeRecord } from '@/hooks/useNodes'; +import type { NodeRecord } from '@/api/queries/useNodesQuery'; import './NodeList.css'; interface NodeListProps { diff --git a/frontend/src/pages/nodes/NodesPage.tsx b/frontend/src/pages/nodes/NodesPage.tsx index 080273e9..2f5b7a79 100644 --- a/frontend/src/pages/nodes/NodesPage.tsx +++ b/frontend/src/pages/nodes/NodesPage.tsx @@ -10,9 +10,9 @@ import { import { useTheme } from '@/hooks/useTheme'; import { useMediaQuery } from '@/hooks/useMediaQuery'; -import { useNodes } from '@/hooks/useNodes'; -import type { NodeRecord } from '@/hooks/useNodes'; -import { useWebSocket } from '@/hooks/useWebSocket'; +import { useNodesQuery } from '@/api/queries/useNodesQuery'; +import type { NodeRecord } from '@/api/queries/useNodesQuery'; +import { useNodeMutations } from '@/api/queries/useNodeMutations'; import AppSidebar from '@/components/AppSidebar'; import CustomStatistic from '@/components/CustomStatistic'; import NodeList from './NodeList'; @@ -29,21 +29,8 @@ export default function NodesPage() { const [messageApi, messageContextHolder] = message.useMessage(); useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); - const { - nodes, - loading, - fetched, - totals, - applyNodesEvent, - create, - update, - remove, - setEnable, - testConnection, - probe, - } = useNodes(); - - useWebSocket({ nodes: applyNodesEvent }); + const { nodes, loading, fetched, totals } = useNodesQuery(); + const { create, update, remove, setEnable, testConnection, probe } = useNodeMutations(); const [formOpen, setFormOpen] = useState(false); const [formMode, setFormMode] = useState<'add' | 'edit'>('add');