mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
refactor(frontend): port nodes to react+ts
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.
This commit is contained in:
parent
56c9c0719f
commit
22e88ec4eb
23 changed files with 1910 additions and 1192 deletions
|
|
@ -8,6 +8,6 @@
|
|||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/nodes.js"></script>
|
||||
<script type="module" src="/src/entries/nodes.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
11
frontend/src/components/CustomStatistic.css
Normal file
11
frontend/src/components/CustomStatistic.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
14
frontend/src/components/CustomStatistic.tsx
Normal file
14
frontend/src/components/CustomStatistic.tsx
Normal file
|
|
@ -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 <Statistic title={title} value={value} prefix={prefix} suffix={suffix} />;
|
||||
}
|
||||
30
frontend/src/components/Sparkline.css
Normal file
30
frontend/src/components/Sparkline.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
316
frontend/src/components/Sparkline.tsx
Normal file
316
frontend/src/components/Sparkline.tsx
Normal file
|
|
@ -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<SVGSVGElement | null>(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<SVGSVGElement>) => {
|
||||
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 (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
height={height}
|
||||
viewBox={`0 0 ${effectiveVbWidth} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className="sparkline-svg"
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity={fillOpacity} />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{showGrid && (
|
||||
<g>
|
||||
{gridLines.map((g, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={g.x1}
|
||||
y1={g.y1}
|
||||
x2={g.x2}
|
||||
y2={g.y2}
|
||||
stroke={gridColor}
|
||||
strokeWidth={1}
|
||||
className="cpu-grid-line"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{showAxes && (
|
||||
<g>
|
||||
{yTicks.map((tk, i) => (
|
||||
<text
|
||||
key={`y${i}`}
|
||||
className="cpu-grid-y-text"
|
||||
x={Math.max(0, paddingLeft - 4)}
|
||||
y={tk.y + 4}
|
||||
textAnchor="end"
|
||||
fontSize={10}
|
||||
>
|
||||
{tk.label}
|
||||
</text>
|
||||
))}
|
||||
{xTicks.map((tk, i) => (
|
||||
<text
|
||||
key={`x${i}`}
|
||||
className="cpu-grid-x-text"
|
||||
x={tk.x}
|
||||
y={paddingTop + drawHeight + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={10}
|
||||
>
|
||||
{tk.label}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{areaPath && <path d={areaPath} fill={`url(#${gradId})`} stroke="none" />}
|
||||
<polyline
|
||||
points={pointsStr}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{showMarker && lastPoint && (
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius} fill={stroke} />
|
||||
)}
|
||||
|
||||
{showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx] && (
|
||||
<g>
|
||||
<line
|
||||
className="cpu-grid-h-line"
|
||||
x1={pointsArr[hoverIdx][0]}
|
||||
x2={pointsArr[hoverIdx][0]}
|
||||
y1={paddingTop}
|
||||
y2={paddingTop + drawHeight}
|
||||
stroke="rgba(0,0,0,0.2)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<circle cx={pointsArr[hoverIdx][0]} cy={pointsArr[hoverIdx][1]} r={3.5} fill={stroke} />
|
||||
<text
|
||||
className="cpu-grid-text"
|
||||
x={pointsArr[hoverIdx][0]}
|
||||
y={paddingTop + 12}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
>
|
||||
{hoverText}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
28
frontend/src/entries/nodes.tsx
Normal file
28
frontend/src/entries/nodes.tsx
Normal file
|
|
@ -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(
|
||||
<ThemeProvider>
|
||||
<NodesPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
15
frontend/src/hooks/useMediaQuery.ts
Normal file
15
frontend/src/hooks/useMediaQuery.ts
Normal file
|
|
@ -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<boolean>(() => window.innerWidth <= breakpoint);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsMobile(window.innerWidth <= breakpoint);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [breakpoint]);
|
||||
|
||||
return { isMobile };
|
||||
}
|
||||
177
frontend/src/hooks/useNodes.ts
Normal file
177
frontend/src/hooks/useNodes.ts
Normal file
|
|
@ -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<T = unknown> {
|
||||
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<NodeRecord[]>([]);
|
||||
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<NodeRecord[]>;
|
||||
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<NodeRecord>) => {
|
||||
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<NodeRecord>) => {
|
||||
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<NodeRecord>) => {
|
||||
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<NodeTotals>(() => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
32
frontend/src/hooks/useWebSocket.ts
Normal file
32
frontend/src/hooks/useWebSocket.ts
Normal file
|
|
@ -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<string, Handler>) {
|
||||
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
|
||||
}, []);
|
||||
}
|
||||
22
frontend/src/pages/nodes/NodeFormModal.css
Normal file
22
frontend/src/pages/nodes/NodeFormModal.css
Normal file
|
|
@ -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%;
|
||||
}
|
||||
296
frontend/src/pages/nodes/NodeFormModal.tsx
Normal file
296
frontend/src/pages/nodes/NodeFormModal.tsx
Normal file
|
|
@ -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<T = unknown> {
|
||||
success?: boolean;
|
||||
msg?: string;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
interface NodeFormModalProps {
|
||||
open: boolean;
|
||||
mode: Mode;
|
||||
node: NodeRecord | null;
|
||||
testConnection: (payload: Partial<NodeRecord>) => Promise<ApiMsg<{
|
||||
status: string;
|
||||
latencyMs?: number;
|
||||
xrayVersion?: string;
|
||||
error?: string;
|
||||
}>>;
|
||||
save: (payload: Partial<NodeRecord>) => Promise<ApiMsg>;
|
||||
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<FormState>(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<FormState>),
|
||||
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<NodeRecord> {
|
||||
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<K extends keyof FormState>(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 (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
confirmLoading={submitting}
|
||||
okText={t('save')}
|
||||
cancelText={t('cancel')}
|
||||
maskClosable={false}
|
||||
width="640px"
|
||||
onOk={onSave}
|
||||
onCancel={close}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.nodes.name')} required>
|
||||
<Input
|
||||
value={form.name}
|
||||
placeholder={t('pages.nodes.namePlaceholder')}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.nodes.remark')}>
|
||||
<Input value={form.remark} onChange={(e) => update('remark', e.target.value)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item label={t('pages.nodes.scheme')}>
|
||||
<Select
|
||||
value={form.scheme}
|
||||
onChange={(v) => update('scheme', v)}
|
||||
options={[
|
||||
{ value: 'https', label: 'https' },
|
||||
{ value: 'http', label: 'http' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.nodes.address')} required>
|
||||
<Input
|
||||
value={form.address}
|
||||
placeholder={t('pages.nodes.addressPlaceholder')}
|
||||
onChange={(e) => update('address', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item label={t('pages.nodes.port')} required>
|
||||
<InputNumber
|
||||
value={form.port}
|
||||
min={1}
|
||||
max={65535}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => update('port', Number(v) || 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.nodes.basePath')}>
|
||||
<Input
|
||||
value={form.basePath}
|
||||
placeholder="/"
|
||||
onChange={(e) => update('basePath', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.nodes.enable')}>
|
||||
<Switch checked={form.enable} onChange={(v) => update('enable', v)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label={t('pages.nodes.allowPrivateAddress')}>
|
||||
<Switch
|
||||
checked={form.allowPrivateAddress}
|
||||
onChange={(v) => update('allowPrivateAddress', v)}
|
||||
/>
|
||||
<div className="hint">{t('pages.nodes.allowPrivateAddressHint')}</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.nodes.apiToken')} required>
|
||||
<Input.Password
|
||||
value={form.apiToken}
|
||||
placeholder={t('pages.nodes.apiTokenPlaceholder')}
|
||||
onChange={(e) => update('apiToken', e.target.value)}
|
||||
/>
|
||||
<div className="hint">{t('pages.nodes.apiTokenHint')}</div>
|
||||
</Form.Item>
|
||||
|
||||
<div className="test-row">
|
||||
<button type="button" disabled={testing} className="ant-btn ant-btn-default" onClick={onTest}>
|
||||
{t('pages.nodes.testConnection')}
|
||||
</button>
|
||||
{testResult && (
|
||||
<div className="test-result">
|
||||
{testResult.status === 'online' ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
message={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
|
||||
description={testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('pages.nodes.connectionFailed')}
|
||||
description={testResult.error}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
mode: { type: String, default: 'add' }, // 'add' | 'edit'
|
||||
node: { type: Object, default: null },
|
||||
testConnection: { type: Function, required: true },
|
||||
save: { type: Function, required: true }, // (payload) => Promise<msg>
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Default form shape — used for "add" mode and to reset between
|
||||
// edits. Sane defaults: HTTPS, port 2053, base path '/', enabled.
|
||||
function defaultForm() {
|
||||
return {
|
||||
id: 0,
|
||||
name: '',
|
||||
remark: '',
|
||||
scheme: 'https',
|
||||
address: '',
|
||||
port: 2053,
|
||||
basePath: '/',
|
||||
apiToken: '',
|
||||
enable: true,
|
||||
allowPrivateAddress: false,
|
||||
};
|
||||
}
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
const submitting = ref(false);
|
||||
const testing = ref(false);
|
||||
const testResult = ref(null); // { status, latencyMs, xrayVersion, error }
|
||||
// Reset the form whenever the modal is opened. In edit mode we copy
|
||||
// the existing node into the form fields; in add mode we wipe back
|
||||
// to defaults so a previous edit doesn't leak through.
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
Object.assign(form, defaultForm());
|
||||
testResult.value = null;
|
||||
if (props.mode === 'edit' && props.node) {
|
||||
Object.assign(form, props.node);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const title = computed(() =>
|
||||
props.mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode'),
|
||||
);
|
||||
|
||||
function close() {
|
||||
if (!submitting.value) emit('update:open', false);
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
testing.value = true;
|
||||
testResult.value = null;
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
if (!payload.address || !payload.port) {
|
||||
message.error(t('pages.nodes.toasts.fillRequired'));
|
||||
return;
|
||||
}
|
||||
const msg = await props.testConnection(payload);
|
||||
if (msg?.success) {
|
||||
testResult.value = msg.obj;
|
||||
} else {
|
||||
testResult.value = { status: 'offline', error: msg?.msg || 'unknown error' };
|
||||
}
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
const payload = buildPayload();
|
||||
if (!payload.name || !payload.address || !payload.port) {
|
||||
message.error(t('pages.nodes.toasts.fillRequired'));
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const msg = await props.save(payload);
|
||||
if (msg?.success) {
|
||||
emit('update:open', false);
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :confirm-loading="submitting" :ok-text="t('save')" :cancel-text="t('cancel')"
|
||||
:mask-closable="false" width="640px" @ok="onSave" @cancel="close">
|
||||
<a-form layout="vertical" :model="form">
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.nodes.name')" required>
|
||||
<a-input v-model:value="form.name" :placeholder="t('pages.nodes.namePlaceholder')" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.nodes.remark')">
|
||||
<a-input v-model:value="form.remark" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-form-item :label="t('pages.nodes.scheme')">
|
||||
<a-select v-model:value="form.scheme">
|
||||
<a-select-option value="https">https</a-select-option>
|
||||
<a-select-option value="http">http</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.nodes.address')" required>
|
||||
<a-input v-model:value="form.address" :placeholder="t('pages.nodes.addressPlaceholder')" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-form-item :label="t('pages.nodes.port')" required>
|
||||
<a-input-number v-model:value="form.port" :min="1" :max="65535" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.nodes.basePath')">
|
||||
<a-input v-model:value="form.basePath" placeholder="/" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="t('pages.nodes.enable')">
|
||||
<a-switch v-model:checked="form.enable" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item :label="t('pages.nodes.allowPrivateAddress')">
|
||||
<a-switch v-model:checked="form.allowPrivateAddress" />
|
||||
<div class="hint">{{ t('pages.nodes.allowPrivateAddressHint') }}</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('pages.nodes.apiToken')" required>
|
||||
<a-input-password v-model:value="form.apiToken" :placeholder="t('pages.nodes.apiTokenPlaceholder')" />
|
||||
<div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>
|
||||
</a-form-item>
|
||||
|
||||
<div class="test-row">
|
||||
<a-button :loading="testing" @click="onTest">
|
||||
{{ t('pages.nodes.testConnection') }}
|
||||
</a-button>
|
||||
<div v-if="testResult" class="test-result">
|
||||
<a-alert v-if="testResult.status === 'online'" type="success" show-icon
|
||||
:message="t('pages.nodes.connectionOk', { ms: testResult.latencyMs })"
|
||||
:description="testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined" />
|
||||
<a-alert v-else type="error" show-icon :message="t('pages.nodes.connectionFailed')"
|
||||
:description="testResult.error" />
|
||||
</div>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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%;
|
||||
}
|
||||
</style>
|
||||
20
frontend/src/pages/nodes/NodeHistoryPanel.css
Normal file
20
frontend/src/pages/nodes/NodeHistoryPanel.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
125
frontend/src/pages/nodes/NodeHistoryPanel.tsx
Normal file
125
frontend/src/pages/nodes/NodeHistoryPanel.tsx
Normal file
|
|
@ -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<T = unknown> {
|
||||
success?: boolean;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
const REFRESH_MS = 15000;
|
||||
|
||||
export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [cpuPoints, setCpuPoints] = useState<number[]>([]);
|
||||
const [cpuLabels, setCpuLabels] = useState<string[]>([]);
|
||||
const [memPoints, setMemPoints] = useState<number[]>([]);
|
||||
const [memLabels, setMemLabels] = useState<string[]>([]);
|
||||
|
||||
const lastNodeId = useRef<number>(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<SeriesPoint[]>;
|
||||
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 (
|
||||
<div className="node-history-panel">
|
||||
<div className="series">
|
||||
<div className="series-title">{t('pages.nodes.cpu')}</div>
|
||||
<Sparkline
|
||||
data={cpuPoints}
|
||||
labels={cpuLabels}
|
||||
vbWidth={640}
|
||||
height={120}
|
||||
stroke="#008771"
|
||||
showGrid
|
||||
showAxes
|
||||
tickCountX={4}
|
||||
maxPoints={cpuPoints.length || 1}
|
||||
fillOpacity={0.18}
|
||||
markerRadius={2.6}
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
<div className="series">
|
||||
<div className="series-title">{t('pages.nodes.mem')}</div>
|
||||
<Sparkline
|
||||
data={memPoints}
|
||||
labels={memLabels}
|
||||
vbWidth={640}
|
||||
height={120}
|
||||
stroke="#7c4dff"
|
||||
showGrid
|
||||
showAxes
|
||||
tickCountX={4}
|
||||
maxPoints={memPoints.length || 1}
|
||||
fillOpacity={0.18}
|
||||
markerRadius={2.6}
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import Sparkline from '@/components/Sparkline.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, required: true },
|
||||
// Bucket size in seconds — matches the SystemHistoryModal selector.
|
||||
bucket: { type: Number, default: 30 },
|
||||
});
|
||||
|
||||
// Two parallel series so the panel renders CPU and Mem side-by-side
|
||||
// in a single fetch round-trip per refresh.
|
||||
const cpuPoints = ref([]);
|
||||
const cpuLabels = ref([]);
|
||||
const memPoints = ref([]);
|
||||
const memLabels = ref([]);
|
||||
|
||||
const REFRESH_MS = 15000;
|
||||
let timer = null;
|
||||
|
||||
function bucketLabel(unixSec) {
|
||||
const d = new Date(unixSec * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
if (props.bucket >= 60) return `${hh}:${mm}`;
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
async function fetchSeries(metric) {
|
||||
try {
|
||||
const url = `/panel/api/nodes/history/${props.node.id}/${metric}/${props.bucket}`;
|
||||
const msg = await HttpUtil.get(url);
|
||||
if (msg?.success && Array.isArray(msg.obj)) {
|
||||
const vals = [];
|
||||
const labs = [];
|
||||
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: [], labs: [] };
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const [cpu, mem] = await Promise.all([fetchSeries('cpu'), fetchSeries('mem')]);
|
||||
cpuPoints.value = cpu.vals;
|
||||
cpuLabels.value = cpu.labs;
|
||||
memPoints.value = mem.vals;
|
||||
memLabels.value = mem.labs;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
timer = window.setInterval(refresh, REFRESH_MS);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer != null) window.clearInterval(timer);
|
||||
});
|
||||
|
||||
// If the parent table re-emits a node row with a different id (rare —
|
||||
// happens when the list is sorted or filtered while the panel is open),
|
||||
// reset and re-fetch.
|
||||
watch(() => props.node?.id, (a, b) => {
|
||||
if (a !== b) refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="node-history-panel">
|
||||
<div class="series">
|
||||
<div class="series-title">{{ t('pages.nodes.cpu') }}</div>
|
||||
<Sparkline :data="cpuPoints" :labels="cpuLabels" :vb-width="640" :height="120" stroke="#008771" :show-grid="true"
|
||||
:show-axes="true" :tick-count-x="4" :max-points="cpuPoints.length || 1" :fill-opacity="0.18"
|
||||
:marker-radius="2.6" :show-tooltip="true" />
|
||||
</div>
|
||||
<div class="series">
|
||||
<div class="series-title">{{ t('pages.nodes.mem') }}</div>
|
||||
<Sparkline :data="memPoints" :labels="memLabels" :vb-width="640" :height="120" stroke="#7c4dff" :show-grid="true"
|
||||
:show-axes="true" :tick-count-x="4" :max-points="memPoints.length || 1" :fill-opacity="0.18"
|
||||
:marker-radius="2.6" :show-tooltip="true" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.series-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
opacity: 0.75;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
145
frontend/src/pages/nodes/NodeList.css
Normal file
145
frontend/src/pages/nodes/NodeList.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
446
frontend/src/pages/nodes/NodeList.tsx
Normal file
446
frontend/src/pages/nodes/NodeList.tsx
Normal file
|
|
@ -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<NodeRow | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const dataSource = useMemo<NodeRow[]>(
|
||||
() => 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<ColumnsType<NodeRow>>(() => [
|
||||
{
|
||||
title: t('pages.nodes.name'),
|
||||
dataIndex: 'name',
|
||||
ellipsis: true,
|
||||
render: (_value, record) => (
|
||||
<div className="name-cell">
|
||||
<span className="name">{record.name}</span>
|
||||
{record.remark && <span className="remark">{record.remark}</span>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<span className="address-header">
|
||||
{t('pages.nodes.address')}
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{showAddress ? (
|
||||
<EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
|
||||
) : (
|
||||
<EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
|
||||
)}
|
||||
</Tooltip>
|
||||
</span>
|
||||
),
|
||||
dataIndex: 'url',
|
||||
ellipsis: true,
|
||||
render: (_value, record) => (
|
||||
<a
|
||||
href={record.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={showAddress ? 'address-visible' : 'address-hidden'}
|
||||
>
|
||||
{record.url}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.status'),
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
render: (_value, record) => (
|
||||
<Space size={4}>
|
||||
<Badge status={badgeStatus(record.status)} />
|
||||
<span>{t(`pages.nodes.statusValues.${record.status || 'unknown'}`)}</span>
|
||||
{record.lastError && (
|
||||
<Tooltip title={record.lastError}>
|
||||
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<Space size={4}>
|
||||
<Tag color="green">{record.clientCount || 0}</Tag>
|
||||
{record.onlineCount ? (
|
||||
<Tag color="blue">{record.onlineCount} {t('online')}</Tag>
|
||||
) : null}
|
||||
{record.depletedCount ? (
|
||||
<Tag color="red">{record.depletedCount} {t('depleted')}</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.actions'),
|
||||
align: 'center',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
render: (_value, record) => (
|
||||
<Space>
|
||||
<Tooltip title={t('pages.nodes.probe')}>
|
||||
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('edit')}>
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [t, showAddress, relativeTime, onToggleEnable, onProbe, onEdit, onDelete]);
|
||||
|
||||
return (
|
||||
<Card size="small" hoverable>
|
||||
<div className="toolbar">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||
{t('pages.nodes.addNode')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
<>
|
||||
<div className="node-cards">
|
||||
{dataSource.length === 0 ? (
|
||||
<div className="card-empty">—</div>
|
||||
) : (
|
||||
dataSource.map((record) => (
|
||||
<div key={record.id} className="node-card">
|
||||
<div className="card-head" onClick={() => toggleExpanded(record.id)}>
|
||||
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
|
||||
<Badge status={badgeStatus(record.status)} />
|
||||
<span className="node-name">{record.name}</span>
|
||||
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<Tooltip title={t('info')}>
|
||||
<InfoCircleOutlined
|
||||
className="row-action-trigger"
|
||||
onClick={() => setStatsNode(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
checked={!!record.enable}
|
||||
size="small"
|
||||
onChange={(v) => onToggleEnable(record, v)}
|
||||
/>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'probe',
|
||||
label: <><ThunderboltOutlined /> {t('pages.nodes.probe')}</>,
|
||||
onClick: () => onProbe(record),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: <><EditOutlined /> {t('edit')}</>,
|
||||
onClick: () => onEdit(record),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
danger: true,
|
||||
label: <><DeleteOutlined /> {t('delete')}</>,
|
||||
onClick: () => onDelete(record),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<MoreOutlined className="row-action-trigger" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedIds.has(record.id) && (
|
||||
<div className="card-history">
|
||||
<NodeHistoryPanel node={record} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={!!statsNode}
|
||||
footer={null}
|
||||
width={360}
|
||||
centered
|
||||
title={statsNode?.name || ''}
|
||||
onCancel={() => setStatsNode(null)}
|
||||
>
|
||||
{statsNode && (
|
||||
<div className="card-stats">
|
||||
{statsNode.remark && (
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.name')}</span>
|
||||
<span>{statsNode.remark}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.address')}</span>
|
||||
<a
|
||||
href={statsNode.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={showAddress ? 'address-visible' : 'address-hidden'}
|
||||
>
|
||||
{statsNode.url}
|
||||
</a>
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{showAddress ? (
|
||||
<EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
|
||||
) : (
|
||||
<EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.status')}</span>
|
||||
<Badge status={badgeStatus(statsNode.status)} />
|
||||
<span>{t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`)}</span>
|
||||
{statsNode.lastError && (
|
||||
<Tooltip title={statsNode.lastError}>
|
||||
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.cpu')}</span>
|
||||
<Tag>{formatPct(statsNode.cpuPct)}</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.mem')}</span>
|
||||
<Tag>{formatPct(statsNode.memPct)}</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.xrayVersion')}</span>
|
||||
<Tag>{statsNode.xrayVersion || '-'}</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.panelVersion') || 'Panel version'}</span>
|
||||
<Tag>{statsNode.panelVersion || '-'}</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.uptime')}</span>
|
||||
<Tag>{formatUptime(statsNode.uptimeSecs)}</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.latency')}</span>
|
||||
<Tag>
|
||||
{statsNode.latencyMs && statsNode.latencyMs > 0 ? `${statsNode.latencyMs} ms` : '-'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('clients')}</span>
|
||||
<Tag color="green">{statsNode.clientCount || 0}</Tag>
|
||||
{statsNode.onlineCount ? (
|
||||
<Tag color="blue">{statsNode.onlineCount} {t('online')}</Tag>
|
||||
) : null}
|
||||
{statsNode.depletedCount ? (
|
||||
<Tag color="red">{statsNode.depletedCount} {t('depleted')}</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.lastHeartbeat')}</span>
|
||||
<Tag>{relativeTime(statsNode.lastHeartbeat)}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
) : (
|
||||
<Table<NodeRow>
|
||||
dataSource={dataSource}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="middle"
|
||||
rowKey="id"
|
||||
expandable={{
|
||||
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,499 +0,0 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ThunderboltOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
InfoCircleOutlined,
|
||||
MoreOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import NodeHistoryPanel from './NodeHistoryPanel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
nodes: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'add',
|
||||
'edit',
|
||||
'delete',
|
||||
'probe',
|
||||
'toggle-enable',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dataSource = computed(() =>
|
||||
props.nodes.map((n) => ({
|
||||
...n,
|
||||
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
|
||||
key: n.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const showAddress = ref(false);
|
||||
|
||||
function statusColor(status) {
|
||||
switch (status) {
|
||||
case 'online': return 'green';
|
||||
case 'offline': return 'red';
|
||||
default: return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
// Relative-time formatter — keeps the column compact and avoids
|
||||
// pulling dayjs just for this single use.
|
||||
function relativeTime(unixSeconds) {
|
||||
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`;
|
||||
}
|
||||
|
||||
function formatUptime(secs) {
|
||||
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 formatPct(p) {
|
||||
if (typeof p !== 'number' || isNaN(p)) return '-';
|
||||
return `${p.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
const statsNode = ref(null);
|
||||
function openStats(node) {
|
||||
statsNode.value = node;
|
||||
}
|
||||
function closeStats() {
|
||||
statsNode.value = null;
|
||||
}
|
||||
|
||||
const expandedIds = ref(new Set());
|
||||
function toggleExpanded(id) {
|
||||
const next = new Set(expandedIds.value);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
expandedIds.value = next;
|
||||
}
|
||||
function isExpanded(id) {
|
||||
return expandedIds.value.has(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card size="small" hoverable>
|
||||
<div class="toolbar">
|
||||
<a-button type="primary" @click="emit('add')">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
{{ t('pages.nodes.addNode') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- ====================== Mobile: card list ======================= -->
|
||||
<div v-if="isMobile" class="node-cards">
|
||||
<div v-if="dataSource.length === 0" class="card-empty">—</div>
|
||||
|
||||
<div v-for="record in dataSource" :key="record.id" class="node-card">
|
||||
<div class="card-head" @click="toggleExpanded(record.id)">
|
||||
<RightOutlined class="card-expand" :class="{ 'is-expanded': isExpanded(record.id) }" />
|
||||
<a-badge
|
||||
:status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
|
||||
<span class="node-name">{{ record.name }}</span>
|
||||
<div class="card-actions" @click.stop>
|
||||
<a-tooltip :title="t('info')">
|
||||
<InfoCircleOutlined class="row-action-trigger" @click="openStats(record)" />
|
||||
</a-tooltip>
|
||||
<a-switch :checked="record.enable" size="small" @change="(v) => emit('toggle-enable', record, v)" />
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<MoreOutlined class="row-action-trigger" @click.prevent />
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="probe" @click="emit('probe', record)">
|
||||
<ThunderboltOutlined /> {{ t('pages.nodes.probe') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="edit" @click="emit('edit', record)">
|
||||
<EditOutlined /> {{ t('edit') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delete" class="danger-item" @click="emit('delete', record)">
|
||||
<DeleteOutlined /> {{ t('delete') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isExpanded(record.id)" class="card-history">
|
||||
<NodeHistoryPanel :node="record" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-modal v-if="isMobile" :open="!!statsNode" :footer="null" :width="360" centered
|
||||
:title="statsNode ? statsNode.name : ''" @cancel="closeStats">
|
||||
<div v-if="statsNode" class="card-stats">
|
||||
<div v-if="statsNode.remark" class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.name') }}</span>
|
||||
<span>{{ statsNode.remark }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.address') }}</span>
|
||||
<a :href="statsNode.url" target="_blank" rel="noopener noreferrer"
|
||||
:class="showAddress ? 'address-visible' : 'address-hidden'">{{ statsNode.url }}</a>
|
||||
<a-tooltip :title="t('pages.index.toggleIpVisibility')">
|
||||
<component :is="showAddress ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
|
||||
@click="showAddress = !showAddress" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.status') }}</span>
|
||||
<a-badge
|
||||
:status="statusColor(statsNode.status) === 'green' ? 'success' : (statusColor(statsNode.status) === 'red' ? 'error' : 'default')" />
|
||||
<span>{{ t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`) }}</span>
|
||||
<a-tooltip v-if="statsNode.lastError" :title="statsNode.lastError">
|
||||
<ExclamationCircleOutlined style="color: #faad14" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.cpu') }}</span>
|
||||
<a-tag>{{ formatPct(statsNode.cpuPct) }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.mem') }}</span>
|
||||
<a-tag>{{ formatPct(statsNode.memPct) }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.xrayVersion') }}</span>
|
||||
<a-tag>{{ statsNode.xrayVersion || '-' }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.panelVersion') || 'Panel version' }}</span>
|
||||
<a-tag>{{ statsNode.panelVersion || '-' }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.uptime') }}</span>
|
||||
<a-tag>{{ formatUptime(statsNode.uptimeSecs) }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.latency') }}</span>
|
||||
<a-tag>
|
||||
<template v-if="statsNode.latencyMs > 0">{{ statsNode.latencyMs }} ms</template>
|
||||
<template v-else>-</template>
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('clients') }}</span>
|
||||
<a-tag color="green">{{ statsNode.clientCount || 0 }}</a-tag>
|
||||
<a-tag v-if="statsNode.onlineCount" color="blue">
|
||||
{{ statsNode.onlineCount }} {{ t('online') }}
|
||||
</a-tag>
|
||||
<a-tag v-if="statsNode.depletedCount" color="red">
|
||||
{{ statsNode.depletedCount }} {{ t('depleted') }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.nodes.lastHeartbeat') }}</span>
|
||||
<a-tag>{{ relativeTime(statsNode.lastHeartbeat) }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- ====================== Desktop: a-table ======================== -->
|
||||
<a-table v-else :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }"
|
||||
size="middle" row-key="id">
|
||||
<template #expandedRowRender="{ record }">
|
||||
<NodeHistoryPanel :node="record" />
|
||||
</template>
|
||||
<a-table-column :title="t('pages.nodes.name')" data-index="name" :ellipsis="true">
|
||||
<template #default="{ record }">
|
||||
<div class="name-cell">
|
||||
<span class="name">{{ record.name }}</span>
|
||||
<span v-if="record.remark" class="remark">{{ record.remark }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column data-index="url" :ellipsis="true">
|
||||
<template #title>
|
||||
<span class="address-header">
|
||||
{{ t('pages.nodes.address') }}
|
||||
<a-tooltip :title="t('pages.index.toggleIpVisibility')">
|
||||
<component :is="showAddress ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
|
||||
@click="showAddress = !showAddress" />
|
||||
</a-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<template #default="{ record }">
|
||||
<a :href="record.url" target="_blank" rel="noopener noreferrer"
|
||||
:class="showAddress ? 'address-visible' : 'address-hidden'">{{ record.url }}</a>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.status')" data-index="status" align="center">
|
||||
<template #default="{ record }">
|
||||
<a-space :size="4">
|
||||
<a-badge
|
||||
:status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
|
||||
<span>{{ t(`pages.nodes.statusValues.${record.status || 'unknown'}`) }}</span>
|
||||
<a-tooltip v-if="record.lastError" :title="record.lastError">
|
||||
<ExclamationCircleOutlined style="color: #faad14" />
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.cpu')" data-index="cpuPct" align="center" :width="90">
|
||||
<template #default="{ record }">{{ formatPct(record.cpuPct) }}</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.mem')" data-index="memPct" align="center" :width="90">
|
||||
<template #default="{ record }">{{ formatPct(record.memPct) }}</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.xrayVersion')" data-index="xrayVersion" align="center">
|
||||
<template #default="{ record }">
|
||||
{{ record.xrayVersion || '-' }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.panelVersion') || 'Panel version'" data-index="panelVersion" align="center">
|
||||
<template #default="{ record }">
|
||||
{{ record.panelVersion || '-' }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.uptime')" data-index="uptimeSecs" align="center">
|
||||
<template #default="{ record }">{{ formatUptime(record.uptimeSecs) }}</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('clients')" align="center" :width="160">
|
||||
<template #default="{ record }">
|
||||
<a-space :size="4">
|
||||
<a-tag color="green">{{ record.clientCount || 0 }}</a-tag>
|
||||
<a-tag v-if="record.onlineCount" color="blue">
|
||||
{{ record.onlineCount }} {{ t('online') }}
|
||||
</a-tag>
|
||||
<a-tag v-if="record.depletedCount" color="red">
|
||||
{{ record.depletedCount }} {{ t('depleted') }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.latency')" data-index="latencyMs" align="center" :width="100">
|
||||
<template #default="{ record }">
|
||||
<span v-if="record.latencyMs > 0">{{ record.latencyMs }} ms</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.lastHeartbeat')" data-index="lastHeartbeat" align="center" :width="120">
|
||||
<template #default="{ record }">{{ relativeTime(record.lastHeartbeat) }}</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.enable')" data-index="enable" align="center" :width="80">
|
||||
<template #default="{ record }">
|
||||
<a-switch :checked="record.enable" size="small" @change="(v) => emit('toggle-enable', record, v)" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.actions')" align="center" :width="160" fixed="right">
|
||||
<template #default="{ record }">
|
||||
<a-space>
|
||||
<a-tooltip :title="t('pages.nodes.probe')">
|
||||
<a-button type="text" size="small" @click="emit('probe', record)">
|
||||
<template #icon>
|
||||
<ThunderboltOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('edit')">
|
||||
<a-button type="text" size="small" @click="emit('edit', record)">
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('delete')">
|
||||
<a-button type="text" size="small" danger @click="emit('delete', record)">
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
:global(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 :deep(.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;
|
||||
}
|
||||
|
||||
.danger-item {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
</style>
|
||||
49
frontend/src/pages/nodes/NodesPage.css
Normal file
49
frontend/src/pages/nodes/NodesPage.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
183
frontend/src/pages/nodes/NodesPage.tsx
Normal file
183
frontend/src/pages/nodes/NodesPage.tsx
Normal file
|
|
@ -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<NodeRecord | null>(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<NodeRecord>) => {
|
||||
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 (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
{modalContextHolder}
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} tip="Loading…" size="large">
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : (
|
||||
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
|
||||
<Col span={24}>
|
||||
<Card size="small" hoverable className="summary-card">
|
||||
<Row gutter={[16, isMobile ? 16 : 12]}>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<CustomStatistic
|
||||
title={t('pages.nodes.totalNodes')}
|
||||
value={String(totals.total)}
|
||||
prefix={<CloudServerOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<CustomStatistic
|
||||
title={t('pages.nodes.onlineNodes')}
|
||||
value={String(totals.online)}
|
||||
prefix={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<CustomStatistic
|
||||
title={t('pages.nodes.offlineNodes')}
|
||||
value={String(totals.offline)}
|
||||
prefix={<CloseCircleOutlined style={{ color: '#ff4d4f' }} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<CustomStatistic
|
||||
title={t('pages.nodes.avgLatency')}
|
||||
value={totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'}
|
||||
prefix={<ThunderboltOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<NodeList
|
||||
nodes={nodes}
|
||||
loading={loading}
|
||||
isMobile={isMobile}
|
||||
onAdd={onAdd}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onProbe={onProbe}
|
||||
onToggleEnable={onToggleEnable}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Spin>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
|
||||
<NodeFormModal
|
||||
open={formOpen}
|
||||
mode={formMode}
|
||||
node={formNode}
|
||||
testConnection={testConnection}
|
||||
save={onSave}
|
||||
onOpenChange={setFormOpen}
|
||||
/>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import CustomStatistic from '@/components/CustomStatistic.vue';
|
||||
import NodeList from './NodeList.vue';
|
||||
import NodeFormModal from './NodeFormModal.vue';
|
||||
import { useNodes } from './useNodes.js';
|
||||
import { useWebSocket } from '@/composables/useWebSocket.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const {
|
||||
nodes,
|
||||
loading,
|
||||
fetched,
|
||||
totals,
|
||||
applyNodesEvent,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setEnable,
|
||||
testConnection,
|
||||
probe,
|
||||
} = useNodes();
|
||||
|
||||
// Live updates — NodeHeartbeatJob pushes the fresh list every 10s.
|
||||
useWebSocket({ nodes: applyNodesEvent });
|
||||
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
// === Form modal state =================================================
|
||||
const formOpen = ref(false);
|
||||
const formMode = ref('add');
|
||||
const formNode = ref(null);
|
||||
|
||||
function onAdd() {
|
||||
formMode.value = 'add';
|
||||
formNode.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function onEdit(node) {
|
||||
formMode.value = 'edit';
|
||||
formNode.value = { ...node };
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
// Save callback the modal hands its payload to. We hide the create vs.
|
||||
// update branching here so the modal stays mode-agnostic.
|
||||
async function onSave(payload) {
|
||||
if (formMode.value === 'edit' && formNode.value?.id) {
|
||||
return update(formNode.value.id, payload);
|
||||
}
|
||||
return create(payload);
|
||||
}
|
||||
|
||||
function onDelete(node) {
|
||||
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'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onProbe(node) {
|
||||
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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleEnable(node, next) {
|
||||
await setEnable(node.id, next);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="nodes-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||
|
||||
<a-layout class="content-shell">
|
||||
<a-layout-content id="content-layout" class="content-area">
|
||||
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
|
||||
<div v-if="!fetched" class="loading-spacer" />
|
||||
|
||||
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
|
||||
<!-- Summary statistics card -->
|
||||
<a-col :span="24">
|
||||
<a-card size="small" hoverable class="summary-card">
|
||||
<a-row :gutter="[16, isMobile ? 16 : 12]">
|
||||
<a-col :xs="12" :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.totalNodes')" :value="String(totals.total)">
|
||||
<template #prefix>
|
||||
<CloudServerOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.onlineNodes')" :value="String(totals.online)">
|
||||
<template #prefix>
|
||||
<CheckCircleOutlined style="color: #52c41a" />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.offlineNodes')" :value="String(totals.offline)">
|
||||
<template #prefix>
|
||||
<CloseCircleOutlined style="color: #ff4d4f" />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :md="6">
|
||||
<CustomStatistic :title="t('pages.nodes.avgLatency')"
|
||||
:value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'">
|
||||
<template #prefix>
|
||||
<ThunderboltOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- Node table -->
|
||||
<a-col :span="24">
|
||||
<NodeList :nodes="nodes" :loading="loading" :is-mobile="isMobile" @add="onAdd" @edit="onEdit"
|
||||
@delete="onDelete" @probe="onProbe" @toggle-enable="onToggleEnable" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<NodeFormModal v-model:open="formOpen" :mode="formMode" :node="formNode" :test-connection="testConnection"
|
||||
:save="onSave" />
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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 :deep(.ant-layout),
|
||||
.nodes-page :deep(.ant-layout-content) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spacer {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.summary-card {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue