mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 05:04:22 +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>
|
<body>
|
||||||
<div id="message"></div>
|
<div id="message"></div>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/entries/nodes.js"></script>
|
<script type="module" src="/src/entries/nodes.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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