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:
MHSanaei 2026-05-21 21:34:46 +02:00
parent 56c9c0719f
commit 22e88ec4eb
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
23 changed files with 1910 additions and 1192 deletions

View file

@ -8,6 +8,6 @@
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/nodes.js"></script>
<script type="module" src="/src/entries/nodes.tsx"></script>
</body>
</html>

View 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);
}

View 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} />;
}

View 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);
}

View 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>
);
}

View file

@ -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');
});

View 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>,
);
}
});

View 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 };
}

View 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,
};
}

View 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
}, []);
}

View 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%;
}

View 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>
);
}

View file

@ -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>

View 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;
}

View 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>
);
}

View file

@ -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>

View 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;
}

View 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>
);
}

View file

@ -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>

View 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;
}
}

View 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>
);
}

View file

@ -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>

View file

@ -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,
};
}