From 22e88ec4eb19b2181378a0a5e2de0bd29ee32505 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 21 May 2026 21:34:46 +0200 Subject: [PATCH] refactor(frontend): port nodes to react+ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of the planned vue->react migration. The nodes entry brings in the largest shared-infrastructure batch so far — every authenticated react page from here on can lean on these. New shared pieces (live alongside their .vue counterparts during coexistence): * hooks/useMediaQuery.ts — useState + resize listener * hooks/useWebSocket.ts — wraps WebSocketClient, subscribes on mount and unsubscribes on unmount. The underlying client is a single module-level instance so multiple components on the same page share one socket. * hooks/useNodes.ts — node list state + CRUD + probe/test, including the totals memo (online/offline/avgLatency) used by the summary card. applyNodesEvent is the entry point for the heartbeat-pushed list. * components/CustomStatistic.tsx — thin Statistic wrapper, prefix + suffix slots become props. * components/Sparkline.tsx — the SVG line chart with measured-width axis scaling, gradient fill, tooltip overlay, and per-instance gradient id from React.useId. ResizeObserver lifecycle is in useEffect; the math is unchanged. Pages: * NodesPage — wires hooks + WebSocket together, renders summary card + NodeList, hosts the form modal. Uses Modal.useModal() for the delete confirm so the dialog inherits ConfigProvider theming. * NodeList — desktop renders a Table with expandable history rows; mobile flips to a vertical card list whose actions live in a bottom-right Dropdown. The IP-blur eye toggle persists across both. * NodeFormModal — controlled form (useState object, single setForm per change). The reset-on-open effect computes the next state once and applies it with eslint-disable to satisfy the new react-hooks/set-state-in-effect rule on a legitimate pattern. * NodeHistoryPanel — polls /panel/api/nodes/history/{id}/{metric}/ {bucket} every 15s, renders cpu+mem sparklines side-by-side. --- frontend/nodes.html | 2 +- frontend/src/components/CustomStatistic.css | 11 + frontend/src/components/CustomStatistic.tsx | 14 + frontend/src/components/Sparkline.css | 30 ++ frontend/src/components/Sparkline.tsx | 316 +++++++++++ frontend/src/entries/nodes.js | 21 - frontend/src/entries/nodes.tsx | 28 + frontend/src/hooks/useMediaQuery.ts | 15 + frontend/src/hooks/useNodes.ts | 177 +++++++ frontend/src/hooks/useWebSocket.ts | 32 ++ frontend/src/pages/nodes/NodeFormModal.css | 22 + frontend/src/pages/nodes/NodeFormModal.tsx | 296 +++++++++++ frontend/src/pages/nodes/NodeFormModal.vue | 209 -------- frontend/src/pages/nodes/NodeHistoryPanel.css | 20 + frontend/src/pages/nodes/NodeHistoryPanel.tsx | 125 +++++ frontend/src/pages/nodes/NodeHistoryPanel.vue | 116 ---- frontend/src/pages/nodes/NodeList.css | 145 +++++ frontend/src/pages/nodes/NodeList.tsx | 446 ++++++++++++++++ frontend/src/pages/nodes/NodeList.vue | 499 ------------------ frontend/src/pages/nodes/NodesPage.css | 49 ++ frontend/src/pages/nodes/NodesPage.tsx | 183 +++++++ frontend/src/pages/nodes/NodesPage.vue | 216 -------- frontend/src/pages/nodes/useNodes.js | 130 ----- 23 files changed, 1910 insertions(+), 1192 deletions(-) create mode 100644 frontend/src/components/CustomStatistic.css create mode 100644 frontend/src/components/CustomStatistic.tsx create mode 100644 frontend/src/components/Sparkline.css create mode 100644 frontend/src/components/Sparkline.tsx delete mode 100644 frontend/src/entries/nodes.js create mode 100644 frontend/src/entries/nodes.tsx create mode 100644 frontend/src/hooks/useMediaQuery.ts create mode 100644 frontend/src/hooks/useNodes.ts create mode 100644 frontend/src/hooks/useWebSocket.ts create mode 100644 frontend/src/pages/nodes/NodeFormModal.css create mode 100644 frontend/src/pages/nodes/NodeFormModal.tsx delete mode 100644 frontend/src/pages/nodes/NodeFormModal.vue create mode 100644 frontend/src/pages/nodes/NodeHistoryPanel.css create mode 100644 frontend/src/pages/nodes/NodeHistoryPanel.tsx delete mode 100644 frontend/src/pages/nodes/NodeHistoryPanel.vue create mode 100644 frontend/src/pages/nodes/NodeList.css create mode 100644 frontend/src/pages/nodes/NodeList.tsx delete mode 100644 frontend/src/pages/nodes/NodeList.vue create mode 100644 frontend/src/pages/nodes/NodesPage.css create mode 100644 frontend/src/pages/nodes/NodesPage.tsx delete mode 100644 frontend/src/pages/nodes/NodesPage.vue delete mode 100644 frontend/src/pages/nodes/useNodes.js diff --git a/frontend/nodes.html b/frontend/nodes.html index fec96dbc..908ae240 100644 --- a/frontend/nodes.html +++ b/frontend/nodes.html @@ -8,6 +8,6 @@
- + diff --git a/frontend/src/components/CustomStatistic.css b/frontend/src/components/CustomStatistic.css new file mode 100644 index 00000000..c3203bc2 --- /dev/null +++ b/frontend/src/components/CustomStatistic.css @@ -0,0 +1,11 @@ +.ant-statistic-content { + font-size: 16px; +} + +body.dark .ant-statistic-content { + color: var(--dark-color-text-primary); +} + +body.dark .ant-statistic-title { + color: rgba(255, 255, 255, 0.55); +} diff --git a/frontend/src/components/CustomStatistic.tsx b/frontend/src/components/CustomStatistic.tsx new file mode 100644 index 00000000..6089637f --- /dev/null +++ b/frontend/src/components/CustomStatistic.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; +import { Statistic } from 'antd'; +import './CustomStatistic.css'; + +interface CustomStatisticProps { + title?: string; + value?: string | number; + prefix?: ReactNode; + suffix?: ReactNode; +} + +export default function CustomStatistic({ title = '', value = '', prefix, suffix }: CustomStatisticProps) { + return ; +} diff --git a/frontend/src/components/Sparkline.css b/frontend/src/components/Sparkline.css new file mode 100644 index 00000000..598924b3 --- /dev/null +++ b/frontend/src/components/Sparkline.css @@ -0,0 +1,30 @@ +.sparkline-svg { + display: block; + width: 100%; +} + +.sparkline-svg .cpu-grid-y-text, +.sparkline-svg .cpu-grid-x-text { + fill: rgba(0, 0, 0, 0.65); +} + +.sparkline-svg .cpu-grid-text { + fill: rgba(0, 0, 0, 0.88); +} + +body.dark .sparkline-svg .cpu-grid-y-text, +body.dark .sparkline-svg .cpu-grid-x-text { + fill: rgba(255, 255, 255, 0.85); +} + +body.dark .sparkline-svg .cpu-grid-text { + fill: rgba(255, 255, 255, 0.95); +} + +body.dark .sparkline-svg .cpu-grid-line { + stroke: rgba(255, 255, 255, 0.12); +} + +body.dark .sparkline-svg .cpu-grid-h-line { + stroke: rgba(255, 255, 255, 0.35); +} diff --git a/frontend/src/components/Sparkline.tsx b/frontend/src/components/Sparkline.tsx new file mode 100644 index 00000000..4f0d8616 --- /dev/null +++ b/frontend/src/components/Sparkline.tsx @@ -0,0 +1,316 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import type { MouseEvent } from 'react'; +import './Sparkline.css'; + +interface SparklineProps { + data: number[]; + labels?: (string | number)[]; + vbWidth?: number; + height?: number; + stroke?: string; + strokeWidth?: number; + maxPoints?: number; + showGrid?: boolean; + gridColor?: string; + fillOpacity?: number; + showMarker?: boolean; + markerRadius?: number; + showAxes?: boolean; + yTickStep?: number; + tickCountX?: number; + paddingLeft?: number; + paddingRight?: number; + paddingTop?: number; + paddingBottom?: number; + showTooltip?: boolean; + valueMin?: number; + valueMax?: number | null; + yFormatter?: (v: number) => string; + tooltipFormatter?: ((v: number) => string) | null; +} + +export default function Sparkline({ + data, + labels = [], + vbWidth = 320, + height = 80, + stroke = '#008771', + strokeWidth = 2, + maxPoints = 120, + showGrid = true, + gridColor = 'rgba(0,0,0,0.1)', + fillOpacity = 0.15, + showMarker = true, + markerRadius = 2.8, + showAxes = false, + yTickStep = 25, + tickCountX = 4, + paddingLeft = 56, + paddingRight = 6, + paddingTop = 6, + paddingBottom = 20, + showTooltip = false, + valueMin = 0, + valueMax = 100, + yFormatter = (v: number) => `${Math.round(v)}%`, + tooltipFormatter = null, +}: SparklineProps) { + const svgRef = useRef(null); + const [measuredWidth, setMeasuredWidth] = useState(0); + const [hoverIdx, setHoverIdx] = useState(-1); + + const reactId = useId(); + const gradId = `spkGrad-${reactId.replace(/[^a-zA-Z0-9]/g, '')}`; + + useEffect(() => { + const el = svgRef.current; + if (!el) return; + const measure = () => { + const w = el.getBoundingClientRect?.().width || 0; + if (w > 0) setMeasuredWidth(Math.round(w)); + }; + measure(); + if (typeof ResizeObserver !== 'undefined') { + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + } + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + }, []); + + const effectiveVbWidth = measuredWidth > 0 ? measuredWidth : vbWidth; + const drawWidth = Math.max(1, effectiveVbWidth - paddingLeft - paddingRight); + const drawHeight = Math.max(1, height - paddingTop - paddingBottom); + const nPoints = Math.min(data.length, maxPoints); + + const dataSlice = useMemo( + () => (nPoints === 0 ? [] : data.slice(data.length - nPoints)), + [data, nPoints], + ); + + const labelsSlice = useMemo(() => { + if (!labels?.length || nPoints === 0) return [] as (string | number)[]; + const start = Math.max(0, labels.length - nPoints); + return labels.slice(start); + }, [labels, nPoints]); + + const yDomain = useMemo(() => { + const min = valueMin; + if (valueMax != null) return { min, max: valueMax }; + let max = min; + for (const v of dataSlice) { + const n = Number(v); + if (Number.isFinite(n) && n > max) max = n; + } + if (max <= min) max = min + 1; + return { min, max: max * 1.1 }; + }, [dataSlice, valueMin, valueMax]); + + const project = useCallback( + (v: number) => { + const { min, max } = yDomain; + const span = max - min; + if (span <= 0) return paddingTop + drawHeight; + const clipped = Math.max(min, Math.min(max, Number(v) || 0)); + const ratio = (clipped - min) / span; + return Math.round(paddingTop + (drawHeight - ratio * drawHeight)); + }, + [yDomain, paddingTop, drawHeight], + ); + + const pointsArr = useMemo<[number, number][]>(() => { + if (nPoints === 0) return []; + const w = drawWidth; + const dx = nPoints > 1 ? w / (nPoints - 1) : 0; + return dataSlice.map((v, i) => { + const x = Math.round(paddingLeft + i * dx); + return [x, project(v)]; + }); + }, [dataSlice, nPoints, drawWidth, paddingLeft, project]); + + const pointsStr = useMemo(() => pointsArr.map((p) => `${p[0]},${p[1]}`).join(' '), [pointsArr]); + + const areaPath = useMemo(() => { + if (pointsArr.length === 0) return ''; + const first = pointsArr[0]; + const last = pointsArr[pointsArr.length - 1]; + const baseY = paddingTop + drawHeight; + const line = pointsStr.replace(/ /g, ' L '); + return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`; + }, [pointsArr, pointsStr, paddingTop, drawHeight]); + + const gridLines = useMemo(() => { + if (!showGrid) return []; + const h = drawHeight; + const w = drawWidth; + return [0, 0.25, 0.5, 0.75, 1].map((r) => { + const y = Math.round(paddingTop + h * r); + return { x1: paddingLeft, y1: y, x2: paddingLeft + w, y2: y }; + }); + }, [showGrid, drawHeight, drawWidth, paddingTop, paddingLeft]); + + const lastPoint = pointsArr.length === 0 ? null : pointsArr[pointsArr.length - 1]; + + const yTicks = useMemo(() => { + if (!showAxes) return []; + const { min, max } = yDomain; + const out: { y: number; label: string }[] = []; + if (valueMax === 100 && valueMin === 0 && yTickStep > 0) { + for (let p = min; p <= max; p += yTickStep) { + out.push({ y: project(p), label: yFormatter(p) }); + } + return out; + } + const ticks = 5; + for (let i = 0; i < ticks; i++) { + const v = min + ((max - min) * i) / (ticks - 1); + out.push({ y: project(v), label: yFormatter(v) }); + } + return out; + }, [showAxes, yDomain, valueMax, valueMin, yTickStep, project, yFormatter]); + + const xTicks = useMemo(() => { + if (!showAxes) return []; + if (nPoints === 0) return []; + const m = Math.max(2, tickCountX); + const w = drawWidth; + const dx = nPoints > 1 ? w / (nPoints - 1) : 0; + const out: { x: number; label: string }[] = []; + for (let i = 0; i < m; i++) { + const idx = Math.round((i * (nPoints - 1)) / (m - 1)); + const label = labelsSlice[idx] != null ? String(labelsSlice[idx]) : String(idx); + const x = Math.round(paddingLeft + idx * dx); + out.push({ x, label }); + } + return out; + }, [showAxes, labelsSlice, nPoints, tickCountX, drawWidth, paddingLeft]); + + const onMouseMove = useCallback( + (evt: MouseEvent) => { + if (!showTooltip || pointsArr.length === 0) return; + const rect = evt.currentTarget.getBoundingClientRect(); + const px = evt.clientX - rect.left; + const x = (px / rect.width) * effectiveVbWidth; + const dx = nPoints > 1 ? drawWidth / (nPoints - 1) : 0; + const idx = Math.max(0, Math.min(nPoints - 1, Math.round((x - paddingLeft) / (dx || 1)))); + setHoverIdx(idx); + }, + [showTooltip, pointsArr.length, effectiveVbWidth, nPoints, drawWidth, paddingLeft], + ); + + const onMouseLeave = useCallback(() => setHoverIdx(-1), []); + + const hoverText = useMemo(() => { + const idx = hoverIdx; + if (idx < 0 || idx >= dataSlice.length) return ''; + const raw = Number(dataSlice[idx] || 0); + const fmt = tooltipFormatter || yFormatter; + const val = fmt(Number.isFinite(raw) ? raw : 0); + const lab = labelsSlice[idx] != null ? labelsSlice[idx] : ''; + return `${val}${lab ? ' • ' + lab : ''}`; + }, [hoverIdx, dataSlice, labelsSlice, tooltipFormatter, yFormatter]); + + return ( + + + + + + + + + {showGrid && ( + + {gridLines.map((g, i) => ( + + ))} + + )} + + {showAxes && ( + + {yTicks.map((tk, i) => ( + + {tk.label} + + ))} + {xTicks.map((tk, i) => ( + + {tk.label} + + ))} + + )} + + {areaPath && } + + {showMarker && lastPoint && ( + + )} + + {showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx] && ( + + + + + {hoverText} + + + )} + + ); +} diff --git a/frontend/src/entries/nodes.js b/frontend/src/entries/nodes.js deleted file mode 100644 index 92e60a15..00000000 --- a/frontend/src/entries/nodes.js +++ /dev/null @@ -1,21 +0,0 @@ -import { createApp } from 'vue'; -import Antd, { message } from 'ant-design-vue'; -import 'ant-design-vue/dist/reset.css'; - -import { setupAxios } from '@/api/axios-init.js'; -import '@/composables/useTheme.js'; -import { i18n, readyI18n } from '@/i18n/index.js'; -import { applyDocumentTitle } from '@/utils'; -import NodesPage from '@/pages/nodes/NodesPage.vue'; - -setupAxios(); -applyDocumentTitle(); - -const messageContainer = document.getElementById('message'); -if (messageContainer) { - message.config({ getContainer: () => messageContainer }); -} - -readyI18n().then(() => { - createApp(NodesPage).use(Antd).use(i18n).mount('#app'); -}); diff --git a/frontend/src/entries/nodes.tsx b/frontend/src/entries/nodes.tsx new file mode 100644 index 00000000..75761eba --- /dev/null +++ b/frontend/src/entries/nodes.tsx @@ -0,0 +1,28 @@ +import { createRoot } from 'react-dom/client'; +import { message } from 'antd'; +import 'antd/dist/reset.css'; + +import { setupAxios } from '@/api/axios-init.js'; +import { applyDocumentTitle } from '@/utils'; +import { readyI18n } from '@/i18n/react'; +import { ThemeProvider } from '@/hooks/useTheme'; +import NodesPage from '@/pages/nodes/NodesPage'; + +setupAxios(); +applyDocumentTitle(); + +const messageContainer = document.getElementById('message'); +if (messageContainer) { + message.config({ getContainer: () => messageContainer }); +} + +readyI18n().then(() => { + const root = document.getElementById('app'); + if (root) { + createRoot(root).render( + + + , + ); + } +}); diff --git a/frontend/src/hooks/useMediaQuery.ts b/frontend/src/hooks/useMediaQuery.ts new file mode 100644 index 00000000..3d9b846e --- /dev/null +++ b/frontend/src/hooks/useMediaQuery.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react'; + +const MOBILE_BREAKPOINT_PX = 768; + +export function useMediaQuery(breakpoint: number = MOBILE_BREAKPOINT_PX) { + const [isMobile, setIsMobile] = useState(() => window.innerWidth <= breakpoint); + + useEffect(() => { + const onResize = () => setIsMobile(window.innerWidth <= breakpoint); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [breakpoint]); + + return { isMobile }; +} diff --git a/frontend/src/hooks/useNodes.ts b/frontend/src/hooks/useNodes.ts new file mode 100644 index 00000000..ef61c3e8 --- /dev/null +++ b/frontend/src/hooks/useNodes.ts @@ -0,0 +1,177 @@ +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(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + refresh(); + }, [refresh]); + + return { + nodes, + loading, + fetched, + totals, + refresh, + applyNodesEvent, + create, + update, + remove, + setEnable, + testConnection, + probe, + }; +} diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts new file mode 100644 index 00000000..02ddd0be --- /dev/null +++ b/frontend/src/hooks/useWebSocket.ts @@ -0,0 +1,32 @@ +import { useEffect } from 'react'; +import { WebSocketClient } from '@/api/websocket.js'; + +type Handler = (payload: unknown) => void; + +interface SharedClient { + connect(): void; + on(event: string, fn: Handler): void; + off(event: string, fn: Handler): void; +} + +let sharedClient: SharedClient | null = null; + +function getSharedClient(): SharedClient { + if (sharedClient) return sharedClient; + const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || ''; + sharedClient = new WebSocketClient(basePath) as SharedClient; + return sharedClient; +} + +export function useWebSocket(handlers: Record) { + useEffect(() => { + const client = getSharedClient(); + const entries = Object.entries(handlers); + for (const [event, fn] of entries) client.on(event, fn); + client.connect(); + return () => { + for (const [event, fn] of entries) client.off(event, fn); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} diff --git a/frontend/src/pages/nodes/NodeFormModal.css b/frontend/src/pages/nodes/NodeFormModal.css new file mode 100644 index 00000000..dba8bb42 --- /dev/null +++ b/frontend/src/pages/nodes/NodeFormModal.css @@ -0,0 +1,22 @@ +.test-row .hint { + font-size: 12px; + opacity: 0.6; + margin-top: 4px; +} + +.ant-form .hint { + font-size: 12px; + opacity: 0.6; + margin-top: 4px; +} + +.test-row { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +} + +.test-result { + width: 100%; +} diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx new file mode 100644 index 00000000..49b2e8be --- /dev/null +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -0,0 +1,296 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Alert, + Col, + Form, + Input, + InputNumber, + Modal, + Row, + Select, + Switch, + message, +} from 'antd'; +import type { NodeRecord } from '@/hooks/useNodes'; +import './NodeFormModal.css'; + +type Mode = 'add' | 'edit'; + +interface ApiMsg { + success?: boolean; + msg?: string; + obj?: T; +} + +interface NodeFormModalProps { + open: boolean; + mode: Mode; + node: NodeRecord | null; + testConnection: (payload: Partial) => Promise>; + save: (payload: Partial) => Promise; + onOpenChange: (open: boolean) => void; +} + +interface FormState { + id: number; + name: string; + remark: string; + scheme: 'http' | 'https'; + address: string; + port: number; + basePath: string; + apiToken: string; + enable: boolean; + allowPrivateAddress: boolean; +} + +function defaultForm(): FormState { + return { + id: 0, + name: '', + remark: '', + scheme: 'https', + address: '', + port: 2053, + basePath: '/', + apiToken: '', + enable: true, + allowPrivateAddress: false, + }; +} + +export default function NodeFormModal({ + open, + mode, + node, + testConnection, + save, + onOpenChange, +}: NodeFormModalProps) { + const { t } = useTranslation(); + + const [form, setForm] = useState(defaultForm); + const [submitting, setSubmitting] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ + status: string; + latencyMs?: number; + xrayVersion?: string; + error?: string; + } | null>(null); + + useEffect(() => { + if (!open) return; + const base = defaultForm(); + const next: FormState = mode === 'edit' && node + ? { + ...base, + ...(node as unknown as Partial), + id: node.id, + scheme: (node.scheme as 'http' | 'https') || base.scheme, + } + : base; + /* eslint-disable react-hooks/set-state-in-effect */ + setForm(next); + setTestResult(null); + /* eslint-enable react-hooks/set-state-in-effect */ + }, [open, mode, node]); + + const title = useMemo( + () => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')), + [mode, t], + ); + + function buildPayload(): Partial { + return { + id: form.id || 0, + name: form.name?.trim() || '', + remark: form.remark?.trim() || '', + scheme: form.scheme || 'https', + address: form.address?.trim() || '', + port: Number(form.port) || 0, + basePath: form.basePath?.trim() || '/', + apiToken: form.apiToken?.trim() || '', + enable: !!form.enable, + allowPrivateAddress: !!form.allowPrivateAddress, + }; + } + + function update(key: K, value: FormState[K]) { + setForm((prev) => ({ ...prev, [key]: value })); + } + + async function onTest() { + setTesting(true); + setTestResult(null); + try { + const payload = buildPayload(); + if (!payload.address || !payload.port) { + message.error(t('pages.nodes.toasts.fillRequired')); + return; + } + const msg = await testConnection(payload); + if (msg?.success && msg.obj) { + setTestResult(msg.obj); + } else { + setTestResult({ status: 'offline', error: msg?.msg || 'unknown error' }); + } + } finally { + setTesting(false); + } + } + + async function onSave() { + const payload = buildPayload(); + if (!payload.name || !payload.address || !payload.port) { + message.error(t('pages.nodes.toasts.fillRequired')); + return; + } + setSubmitting(true); + try { + const msg = await save(payload); + if (msg?.success) { + onOpenChange(false); + } + } finally { + setSubmitting(false); + } + } + + function close() { + if (!submitting) onOpenChange(false); + } + + return ( + +
+ + + + update('name', e.target.value)} + /> + + + + + update('remark', e.target.value)} /> + + + + + + + + update('address', e.target.value)} + /> + + + + + update('port', Number(v) || 0)} + /> + + + + + + + + update('basePath', e.target.value)} + /> + + + + + update('enable', v)} /> + + + + + + update('allowPrivateAddress', v)} + /> +
{t('pages.nodes.allowPrivateAddressHint')}
+
+ + + update('apiToken', e.target.value)} + /> +
{t('pages.nodes.apiTokenHint')}
+
+ +
+ + {testResult && ( +
+ {testResult.status === 'online' ? ( + + ) : ( + + )} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/nodes/NodeFormModal.vue b/frontend/src/pages/nodes/NodeFormModal.vue deleted file mode 100644 index 9cc5ea2c..00000000 --- a/frontend/src/pages/nodes/NodeFormModal.vue +++ /dev/null @@ -1,209 +0,0 @@ - - - - - diff --git a/frontend/src/pages/nodes/NodeHistoryPanel.css b/frontend/src/pages/nodes/NodeHistoryPanel.css new file mode 100644 index 00000000..664d1eb5 --- /dev/null +++ b/frontend/src/pages/nodes/NodeHistoryPanel.css @@ -0,0 +1,20 @@ +.node-history-panel { + padding: 8px 0; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +@media (max-width: 768px) { + .node-history-panel { + grid-template-columns: 1fr; + gap: 12px; + } +} + +.node-history-panel .series-title { + font-size: 12px; + font-weight: 500; + opacity: 0.75; + margin-bottom: 4px; +} diff --git a/frontend/src/pages/nodes/NodeHistoryPanel.tsx b/frontend/src/pages/nodes/NodeHistoryPanel.tsx new file mode 100644 index 00000000..a4454d55 --- /dev/null +++ b/frontend/src/pages/nodes/NodeHistoryPanel.tsx @@ -0,0 +1,125 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { HttpUtil } from '@/utils'; +import Sparkline from '@/components/Sparkline'; +import './NodeHistoryPanel.css'; + +interface NodeRef { + id: number; +} + +interface NodeHistoryPanelProps { + node: NodeRef; + bucket?: number; +} + +interface SeriesPoint { + t: number; + v: number; +} + +interface ApiMsg { + success?: boolean; + obj?: T; +} + +const REFRESH_MS = 15000; + +export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanelProps) { + const { t } = useTranslation(); + const [cpuPoints, setCpuPoints] = useState([]); + const [cpuLabels, setCpuLabels] = useState([]); + const [memPoints, setMemPoints] = useState([]); + const [memLabels, setMemLabels] = useState([]); + + const lastNodeId = useRef(node.id); + + useEffect(() => { + let cancelled = false; + + const bucketLabel = (unixSec: number) => { + const d = new Date(unixSec * 1000); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + if (bucket >= 60) return `${hh}:${mm}`; + const ss = String(d.getSeconds()).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; + }; + + const fetchSeries = async (metric: 'cpu' | 'mem') => { + try { + const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`; + const msg = await HttpUtil.get(url) as ApiMsg; + if (msg?.success && Array.isArray(msg.obj)) { + const vals: number[] = []; + const labs: string[] = []; + for (const p of msg.obj) { + labs.push(bucketLabel(p.t)); + vals.push(Math.max(0, Math.min(100, Number(p.v) || 0))); + } + return { vals, labs }; + } + } catch (e) { + console.error('node history fetch failed', metric, e); + } + return { vals: [] as number[], labs: [] as string[] }; + }; + + const refresh = async () => { + const [cpu, mem] = await Promise.all([fetchSeries('cpu'), fetchSeries('mem')]); + if (cancelled) return; + setCpuPoints(cpu.vals); + setCpuLabels(cpu.labs); + setMemPoints(mem.vals); + setMemLabels(mem.labs); + }; + + refresh(); + const timer = window.setInterval(refresh, REFRESH_MS); + lastNodeId.current = node.id; + + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [node.id, bucket]); + + return ( +
+
+
{t('pages.nodes.cpu')}
+ +
+
+
{t('pages.nodes.mem')}
+ +
+
+ ); +} diff --git a/frontend/src/pages/nodes/NodeHistoryPanel.vue b/frontend/src/pages/nodes/NodeHistoryPanel.vue deleted file mode 100644 index ec7b49ac..00000000 --- a/frontend/src/pages/nodes/NodeHistoryPanel.vue +++ /dev/null @@ -1,116 +0,0 @@ - - - - - diff --git a/frontend/src/pages/nodes/NodeList.css b/frontend/src/pages/nodes/NodeList.css new file mode 100644 index 00000000..c6d7f4ca --- /dev/null +++ b/frontend/src/pages/nodes/NodeList.css @@ -0,0 +1,145 @@ +.toolbar { + margin-bottom: 12px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.name-cell { + display: flex; + flex-direction: column; +} + +.name-cell .name { + font-weight: 500; +} + +.name-cell .remark { + font-size: 12px; + opacity: 0.65; +} + +.address-header { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ip-toggle-icon { + cursor: pointer; + font-size: 14px; + opacity: 0.7; +} + +.ip-toggle-icon:hover { + opacity: 1; +} + +.address-hidden { + filter: blur(5px); + transition: filter 0.2s ease; +} + +.address-visible { + filter: none; +} + +.node-cards { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 4px; +} + +.node-card { + border: 1px solid rgba(128, 128, 128, 0.2); + border-radius: 10px; + padding: 12px; + background: rgba(255, 255, 255, 0.02); + display: flex; + flex-direction: column; + gap: 8px; +} + +body.dark .node-card { + background: rgba(255, 255, 255, 0.03); + border-color: rgba(255, 255, 255, 0.1); +} + +.card-head { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; +} + +.card-expand { + font-size: 12px; + opacity: 0.6; + transition: transform 150ms ease; + flex-shrink: 0; +} + +.card-expand.is-expanded { + transform: rotate(90deg); +} + +.node-name { + font-weight: 600; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.row-action-trigger { + font-size: 20px; + cursor: pointer; +} + +.card-stats { + display: flex; + flex-direction: column; + gap: 6px; +} + +.stat-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.stat-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.6; + min-width: 96px; + flex-shrink: 0; +} + +.card-stats .ant-tag { + margin: 0; +} + +.card-history { + margin-top: 4px; + padding-top: 8px; + border-top: 1px solid rgba(128, 128, 128, 0.15); +} + +.card-empty { + text-align: center; + opacity: 0.4; + padding: 20px 0; +} diff --git a/frontend/src/pages/nodes/NodeList.tsx b/frontend/src/pages/nodes/NodeList.tsx new file mode 100644 index 00000000..7a16f082 --- /dev/null +++ b/frontend/src/pages/nodes/NodeList.tsx @@ -0,0 +1,446 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Badge, + Button, + Card, + Dropdown, + Modal, + Space, + Switch, + Table, + Tag, + Tooltip, +} from 'antd'; +import type { BadgeProps } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + DeleteOutlined, + EditOutlined, + ExclamationCircleOutlined, + EyeInvisibleOutlined, + EyeOutlined, + InfoCircleOutlined, + MoreOutlined, + PlusOutlined, + RightOutlined, + ThunderboltOutlined, +} from '@ant-design/icons'; + +import NodeHistoryPanel from './NodeHistoryPanel'; +import type { NodeRecord } from '@/hooks/useNodes'; +import './NodeList.css'; + +interface NodeListProps { + nodes: NodeRecord[]; + loading?: boolean; + isMobile?: boolean; + onAdd: () => void; + onEdit: (node: NodeRecord) => void; + onDelete: (node: NodeRecord) => void; + onProbe: (node: NodeRecord) => void; + onToggleEnable: (node: NodeRecord, next: boolean) => void; +} + +interface NodeRow extends NodeRecord { + url: string; + key: number; +} + +function badgeStatus(status?: string): BadgeProps['status'] { + switch (status) { + case 'online': return 'success'; + case 'offline': return 'error'; + default: return 'default'; + } +} + +function formatPct(p?: number): string { + if (typeof p !== 'number' || Number.isNaN(p)) return '-'; + return `${p.toFixed(1)}%`; +} + +function formatUptime(secs?: number): string { + if (!secs) return '-'; + const days = Math.floor(secs / 86400); + const hours = Math.floor((secs % 86400) / 3600); + if (days > 0) return `${days}d ${hours}h`; + const mins = Math.floor((secs % 3600) / 60); + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +function useRelativeTime() { + const { t } = useTranslation(); + return (unixSeconds?: number) => { + if (!unixSeconds) return t('pages.nodes.never'); + const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds)); + if (diffSec < 5) return t('pages.nodes.justNow'); + if (diffSec < 60) return `${diffSec}s`; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`; + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`; + return `${Math.floor(diffSec / 86400)}d`; + }; +} + +export default function NodeList({ + nodes, + loading = false, + isMobile = false, + onAdd, + onEdit, + onDelete, + onProbe, + onToggleEnable, +}: NodeListProps) { + const { t } = useTranslation(); + const relativeTime = useRelativeTime(); + + const [showAddress, setShowAddress] = useState(false); + const [statsNode, setStatsNode] = useState(null); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const dataSource = useMemo( + () => nodes.map((n) => ({ + ...n, + url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`, + key: n.id, + })), + [nodes], + ); + + function toggleExpanded(id: number) { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + } + + const columns = useMemo>(() => [ + { + title: t('pages.nodes.name'), + dataIndex: 'name', + ellipsis: true, + render: (_value, record) => ( +
+ {record.name} + {record.remark && {record.remark}} +
+ ), + }, + { + title: ( + + {t('pages.nodes.address')} + + {showAddress ? ( + setShowAddress(false)} /> + ) : ( + setShowAddress(true)} /> + )} + + + ), + dataIndex: 'url', + ellipsis: true, + render: (_value, record) => ( + + {record.url} + + ), + }, + { + title: t('pages.nodes.status'), + dataIndex: 'status', + align: 'center', + render: (_value, record) => ( + + + {t(`pages.nodes.statusValues.${record.status || 'unknown'}`)} + {record.lastError && ( + + + + )} + + ), + }, + { + title: t('pages.nodes.cpu'), + dataIndex: 'cpuPct', + align: 'center', + width: 90, + render: (_value, record) => formatPct(record.cpuPct), + }, + { + title: t('pages.nodes.mem'), + dataIndex: 'memPct', + align: 'center', + width: 90, + render: (_value, record) => formatPct(record.memPct), + }, + { + title: t('pages.nodes.xrayVersion'), + dataIndex: 'xrayVersion', + align: 'center', + render: (_value, record) => record.xrayVersion || '-', + }, + { + title: t('pages.nodes.panelVersion') || 'Panel version', + dataIndex: 'panelVersion', + align: 'center', + render: (_value, record) => record.panelVersion || '-', + }, + { + title: t('pages.nodes.uptime'), + dataIndex: 'uptimeSecs', + align: 'center', + render: (_value, record) => formatUptime(record.uptimeSecs), + }, + { + title: t('clients'), + align: 'center', + width: 160, + render: (_value, record) => ( + + {record.clientCount || 0} + {record.onlineCount ? ( + {record.onlineCount} {t('online')} + ) : null} + {record.depletedCount ? ( + {record.depletedCount} {t('depleted')} + ) : null} + + ), + }, + { + title: t('pages.nodes.latency'), + dataIndex: 'latencyMs', + align: 'center', + width: 100, + render: (_value, record) => + record.latencyMs && record.latencyMs > 0 ? `${record.latencyMs} ms` : '-', + }, + { + title: t('pages.nodes.lastHeartbeat'), + dataIndex: 'lastHeartbeat', + align: 'center', + width: 120, + render: (_value, record) => relativeTime(record.lastHeartbeat), + }, + { + title: t('pages.nodes.enable'), + dataIndex: 'enable', + align: 'center', + width: 80, + render: (_value, record) => ( + onToggleEnable(record, v)} + /> + ), + }, + { + title: t('pages.nodes.actions'), + align: 'center', + width: 160, + fixed: 'right', + render: (_value, record) => ( + + + + + + {isMobile ? ( + <> +
+ {dataSource.length === 0 ? ( +
+ ) : ( + dataSource.map((record) => ( +
+
toggleExpanded(record.id)}> + + + {record.name} +
e.stopPropagation()}> + + setStatsNode(record)} + /> + + onToggleEnable(record, v)} + /> + {t('pages.nodes.probe')}, + onClick: () => onProbe(record), + }, + { + key: 'edit', + label: <> {t('edit')}, + onClick: () => onEdit(record), + }, + { + key: 'delete', + danger: true, + label: <> {t('delete')}, + onClick: () => onDelete(record), + }, + ], + }} + > + + +
+
+ + {expandedIds.has(record.id) && ( +
+ +
+ )} +
+ )) + )} +
+ + setStatsNode(null)} + > + {statsNode && ( +
+ {statsNode.remark && ( +
+ {t('pages.nodes.name')} + {statsNode.remark} +
+ )} +
+ {t('pages.nodes.address')} + + {statsNode.url} + + + {showAddress ? ( + setShowAddress(false)} /> + ) : ( + setShowAddress(true)} /> + )} + +
+
+ {t('pages.nodes.status')} + + {t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`)} + {statsNode.lastError && ( + + + + )} +
+
+ {t('pages.nodes.cpu')} + {formatPct(statsNode.cpuPct)} +
+
+ {t('pages.nodes.mem')} + {formatPct(statsNode.memPct)} +
+
+ {t('pages.nodes.xrayVersion')} + {statsNode.xrayVersion || '-'} +
+
+ {t('pages.nodes.panelVersion') || 'Panel version'} + {statsNode.panelVersion || '-'} +
+
+ {t('pages.nodes.uptime')} + {formatUptime(statsNode.uptimeSecs)} +
+
+ {t('pages.nodes.latency')} + + {statsNode.latencyMs && statsNode.latencyMs > 0 ? `${statsNode.latencyMs} ms` : '-'} + +
+
+ {t('clients')} + {statsNode.clientCount || 0} + {statsNode.onlineCount ? ( + {statsNode.onlineCount} {t('online')} + ) : null} + {statsNode.depletedCount ? ( + {statsNode.depletedCount} {t('depleted')} + ) : null} +
+
+ {t('pages.nodes.lastHeartbeat')} + {relativeTime(statsNode.lastHeartbeat)} +
+
+ )} +
+ + ) : ( + + dataSource={dataSource} + columns={columns} + pagination={false} + loading={loading} + scroll={{ x: 'max-content' }} + size="middle" + rowKey="id" + expandable={{ + expandedRowRender: (record) => , + }} + /> + )} + + ); +} diff --git a/frontend/src/pages/nodes/NodeList.vue b/frontend/src/pages/nodes/NodeList.vue deleted file mode 100644 index 434aa80b..00000000 --- a/frontend/src/pages/nodes/NodeList.vue +++ /dev/null @@ -1,499 +0,0 @@ - - - - - diff --git a/frontend/src/pages/nodes/NodesPage.css b/frontend/src/pages/nodes/NodesPage.css new file mode 100644 index 00000000..ca31a3c4 --- /dev/null +++ b/frontend/src/pages/nodes/NodesPage.css @@ -0,0 +1,49 @@ +.nodes-page { + --bg-page: #e6e8ec; + --bg-card: #ffffff; + min-height: 100vh; + background: var(--bg-page); +} + +.nodes-page.is-dark { + --bg-page: #1e1e1e; + --bg-card: #252526; +} + +.nodes-page.is-dark.is-ultra { + --bg-page: #050505; + --bg-card: #0c0e12; +} + +.nodes-page .ant-layout, +.nodes-page .ant-layout-content { + background: transparent; +} + +.nodes-page .content-shell { + background: transparent; +} + +.nodes-page .content-area { + padding: 24px; +} + +@media (max-width: 768px) { + .nodes-page .content-area { + padding: 8px; + } +} + +.nodes-page .loading-spacer { + min-height: calc(100vh - 120px); +} + +.nodes-page .summary-card { + padding: 16px; +} + +@media (max-width: 768px) { + .nodes-page .summary-card { + padding: 8px; + } +} diff --git a/frontend/src/pages/nodes/NodesPage.tsx b/frontend/src/pages/nodes/NodesPage.tsx new file mode 100644 index 00000000..5d1866b7 --- /dev/null +++ b/frontend/src/pages/nodes/NodesPage.tsx @@ -0,0 +1,183 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, message } from 'antd'; +import { + CheckCircleOutlined, + CloseCircleOutlined, + CloudServerOutlined, + ThunderboltOutlined, +} from '@ant-design/icons'; + +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 AppSidebar from '@/components/AppSidebar'; +import CustomStatistic from '@/components/CustomStatistic'; +import NodeList from './NodeList'; +import NodeFormModal from './NodeFormModal'; +import './NodesPage.css'; + +const basePath = window.X_UI_BASE_PATH || ''; +const requestUri = window.location.pathname; + +export default function NodesPage() { + const { t } = useTranslation(); + const { isDark, isUltra, antdThemeConfig } = useTheme(); + const { isMobile } = useMediaQuery(); + const [modal, modalContextHolder] = Modal.useModal(); + + const { + nodes, + loading, + fetched, + totals, + applyNodesEvent, + create, + update, + remove, + setEnable, + testConnection, + probe, + } = useNodes(); + + useWebSocket({ nodes: applyNodesEvent }); + + const [formOpen, setFormOpen] = useState(false); + const [formMode, setFormMode] = useState<'add' | 'edit'>('add'); + const [formNode, setFormNode] = useState(null); + + const onAdd = useCallback(() => { + setFormMode('add'); + setFormNode(null); + setFormOpen(true); + }, []); + + const onEdit = useCallback((node: NodeRecord) => { + setFormMode('edit'); + setFormNode({ ...node }); + setFormOpen(true); + }, []); + + const onSave = useCallback(async (payload: Partial) => { + if (formMode === 'edit' && formNode?.id) { + return update(formNode.id, payload); + } + return create(payload); + }, [formMode, formNode, update, create]); + + const onDelete = useCallback((node: NodeRecord) => { + modal.confirm({ + title: t('pages.nodes.deleteConfirmTitle', { name: node.name }), + content: t('pages.nodes.deleteConfirmContent'), + okText: t('delete'), + okType: 'danger', + cancelText: t('cancel'), + onOk: async () => { + const msg = await remove(node.id); + if (msg?.success) message.success(t('pages.nodes.toasts.deleted')); + }, + }); + }, [modal, t, remove]); + + const onProbe = useCallback(async (node: NodeRecord) => { + const msg = await probe(node.id); + if (msg?.success && msg.obj) { + if (msg.obj.status === 'online') { + message.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs })); + } else { + message.error(msg.obj.error || t('pages.nodes.toasts.probeFailed')); + } + } + }, [probe, t]); + + const onToggleEnable = useCallback(async (node: NodeRecord, next: boolean) => { + await setEnable(node.id, next); + }, [setEnable]); + + const pageClass = useMemo(() => { + const classes = ['nodes-page']; + if (isDark) classes.push('is-dark'); + if (isUltra) classes.push('is-ultra'); + return classes.join(' '); + }, [isDark, isUltra]); + + return ( + + {modalContextHolder} + + + + + + + {!fetched ? ( +
+ ) : ( + + + + + + } + /> + + + } + /> + + + } + /> + + + 0 ? `${totals.avgLatency} ms` : '-'} + prefix={} + /> + + + + + + + + + + )} + + + + + + + + ); +} diff --git a/frontend/src/pages/nodes/NodesPage.vue b/frontend/src/pages/nodes/NodesPage.vue deleted file mode 100644 index 2ca85fec..00000000 --- a/frontend/src/pages/nodes/NodesPage.vue +++ /dev/null @@ -1,216 +0,0 @@ - - - - - diff --git a/frontend/src/pages/nodes/useNodes.js b/frontend/src/pages/nodes/useNodes.js deleted file mode 100644 index 1282a94e..00000000 --- a/frontend/src/pages/nodes/useNodes.js +++ /dev/null @@ -1,130 +0,0 @@ -// Loads the node list and runs CRUD/probe actions against the -// /panel/api/nodes/* endpoints. Live updates arrive over WebSocket -// (pushed by NodeHeartbeatJob every 10s) so we don't poll. - -import { computed, onMounted, ref, shallowRef } from 'vue'; -import { HttpUtil } from '@/utils'; - -export function useNodes() { - const nodes = shallowRef([]); - const loading = ref(false); - const fetched = ref(false); - - async function refresh() { - loading.value = true; - try { - const msg = await HttpUtil.get('/panel/api/nodes/list'); - if (msg?.success) { - nodes.value = Array.isArray(msg.obj) ? msg.obj : []; - } - fetched.value = true; - } finally { - loading.value = false; - } - } - - // Replaces the local list with the snapshot pushed by the heartbeat job. - // shallowRef means a fresh assignment is enough to retrigger reactivity; - // we always assign a new array so Vue notices. - function applyNodesEvent(payload) { - if (Array.isArray(payload)) { - nodes.value = payload; - if (!fetched.value) fetched.value = true; - } - } - - async function create(payload) { - const msg = await HttpUtil.post('/panel/api/nodes/add', payload); - if (msg?.success) await refresh(); - return msg; - } - - async function update(id, payload) { - const msg = await HttpUtil.post(`/panel/api/nodes/update/${id}`, payload); - if (msg?.success) await refresh(); - return msg; - } - - async function remove(id) { - const msg = await HttpUtil.post(`/panel/api/nodes/del/${id}`); - if (msg?.success) await refresh(); - return msg; - } - - async function setEnable(id, enable) { - const msg = await HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }); - if (msg?.success) await refresh(); - return msg; - } - - // testConnection probes a transient (unsaved) node config so the form - // can validate before save. Returns the ProbeResultUI shape from Go. - async function testConnection(payload) { - const msg = await HttpUtil.post('/panel/api/nodes/test', payload); - return msg; - } - - // probe forces an immediate heartbeat against an already-saved node. - async function probe(id) { - const msg = await HttpUtil.post(`/panel/api/nodes/probe/${id}`); - if (msg?.success) await refresh(); - return msg; - } - - const totals = computed(() => { - const list = nodes.value; - 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 list) { - 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 > 0) { - latencySum += n.latencyMs; - latencyCount += 1; - } - } else if (n.status === 'offline') { - offline += 1; - } - } - return { - total: list.length, - online, - offline, - avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0, - inbounds, - clients, - onlineClients, - depleted, - }; - }); - - // Initial fetch — WebSocket takes over after the first heartbeat tick - // (~10s) but the page should populate immediately on mount. - onMounted(refresh); - - return { - nodes, - loading, - fetched, - totals, - refresh, - applyNodesEvent, - create, - update, - remove, - setEnable, - testConnection, - probe, - }; -}