- ProbeTasksModal: self-loading/self-saving surface (B3 probe-meta pattern), tasks grouped by region label (ungrouped last), editor with name/region/target/interval/auto-join + agent-node multi select snapshotting baseNodeIds for the base-diff save; delete with confirm. Toolbar entry on NodeList (RadarChart icon), wired in NodesPage; NodeList call sites in two component tests updated for the new required prop. - i18n pages.nodes.probeTasks.* (25 keys) in all 13 locales: zh-CN translated, en-US English, others English placeholder per repo convention; insertion verified pure-additive (351 insertions, 0 deletions) and the i18n dead-key failures are byte-identical to the B3 baseline. - tsc clean; oxlint 0/0 (baseline parity); oxfmt clean on touched files (3 pre-existing drift files untouched).
936 lines
32 KiB
TypeScript
936 lines
32 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Dropdown,
|
|
Modal,
|
|
Popover,
|
|
Space,
|
|
Switch,
|
|
Table,
|
|
Tag,
|
|
Tooltip,
|
|
} from 'antd';
|
|
import type { BadgeProps } from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import {
|
|
ApartmentOutlined,
|
|
ClusterOutlined,
|
|
CloudDownloadOutlined,
|
|
DeleteOutlined,
|
|
EditOutlined,
|
|
ExclamationCircleOutlined,
|
|
EyeInvisibleOutlined,
|
|
EyeOutlined,
|
|
InfoCircleOutlined,
|
|
MoreOutlined,
|
|
PlusOutlined,
|
|
RadarChartOutlined,
|
|
RightOutlined,
|
|
SafetyCertificateOutlined,
|
|
TeamOutlined,
|
|
ThunderboltOutlined,
|
|
} from '@ant-design/icons';
|
|
|
|
import NodeHistoryPanel from './NodeHistoryPanel';
|
|
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
|
import { isPanelUpdateAvailable } from '@/lib/panel-version';
|
|
import { activateOnKey } from '@/utils/a11y';
|
|
import './NodeList.css';
|
|
|
|
interface NodeListProps {
|
|
nodes: NodeRecord[];
|
|
loading?: boolean;
|
|
isMobile?: boolean;
|
|
latestVersion?: string;
|
|
selectedIds: number[];
|
|
onSelectionChange: (ids: number[]) => void;
|
|
onAdd: () => void;
|
|
onMtls: () => void;
|
|
onProbeTasks: () => void;
|
|
onEdit: (node: NodeRecord) => void;
|
|
onDelete: (node: NodeRecord) => void;
|
|
onProbe: (node: NodeRecord) => void;
|
|
onToggleEnable: (node: NodeRecord, next: boolean) => void;
|
|
onUpdateNode: (node: NodeRecord) => void;
|
|
onUpdateSelected: () => void;
|
|
}
|
|
|
|
function isUpdateEligible(n: NodeRecord): boolean {
|
|
return !!n.enable && n.status === 'online';
|
|
}
|
|
|
|
// One category chip of the nodes table's clients cell. When the backend
|
|
// sent the category's email list, hovering pops the list over (same
|
|
// pattern as the inbounds page); without a list it keeps the plain
|
|
// category tooltip. Chip styling is identical either way.
|
|
function CategoryChip({
|
|
title,
|
|
emails,
|
|
color,
|
|
count,
|
|
}: {
|
|
title: string;
|
|
emails?: string[];
|
|
color?: string;
|
|
count: number;
|
|
}) {
|
|
const tag = (
|
|
<Tag color={color} className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
|
|
{count}
|
|
</Tag>
|
|
);
|
|
if (emails && emails.length > 0) {
|
|
return (
|
|
<Popover
|
|
title={title}
|
|
content={
|
|
<div className="client-email-list">
|
|
{emails.map((e) => (
|
|
<div key={e}>{e}</div>
|
|
))}
|
|
</div>
|
|
}
|
|
>
|
|
{tag}
|
|
</Popover>
|
|
);
|
|
}
|
|
return <Tooltip title={title}>{tag}</Tooltip>;
|
|
}
|
|
|
|
interface NodeRow extends NodeRecord {
|
|
url: string;
|
|
key: string | number;
|
|
}
|
|
|
|
function badgeStatus(status?: string): BadgeProps['status'] {
|
|
switch (status) {
|
|
case 'online':
|
|
return 'success';
|
|
case 'offline':
|
|
return 'error';
|
|
default:
|
|
return 'default';
|
|
}
|
|
}
|
|
|
|
interface HealthProps {
|
|
status?: string;
|
|
xrayState?: string;
|
|
xrayError?: string;
|
|
}
|
|
|
|
// Purple: the node's panel API is reachable (status=online) but its Xray core
|
|
// has failed or been stopped. Distinct from a normal offline/unknown node.
|
|
const XRAY_ERROR_COLOR = '#722ED1';
|
|
|
|
// True when the panel is online but Xray itself reports error/stop.
|
|
function hasXrayProblem(status?: string, xrayState?: string): boolean {
|
|
if (status !== 'online') return false;
|
|
const xs = (xrayState || '').toLowerCase().trim();
|
|
return xs === 'error' || xs === 'stop';
|
|
}
|
|
|
|
// Tooltip text + icon color for the status cell. A real probe error (lastError)
|
|
// is a warning and takes precedence; otherwise an Xray-core problem shows purple.
|
|
function statusIssue(record: Pick<NodeRecord, 'status' | 'xrayState' | 'xrayError' | 'lastError'>) {
|
|
const tip =
|
|
record.lastError ||
|
|
(hasXrayProblem(record.status, record.xrayState) ? record.xrayError : '') ||
|
|
'';
|
|
const iconColor =
|
|
!record.lastError && hasXrayProblem(record.status, record.xrayState)
|
|
? XRAY_ERROR_COLOR
|
|
: 'var(--ant-color-warning)';
|
|
return { tip, iconColor };
|
|
}
|
|
|
|
function StatusDot({ status, xrayState }: HealthProps) {
|
|
if (status === 'online') {
|
|
return hasXrayProblem(status, xrayState) ? (
|
|
<span className="xray-error-dot" />
|
|
) : (
|
|
<span className="online-dot" />
|
|
);
|
|
}
|
|
return <Badge status={badgeStatus(status)} />;
|
|
}
|
|
|
|
function StatusLabel({ status, xrayState }: HealthProps) {
|
|
const { t } = useTranslation();
|
|
if (status === 'online') {
|
|
const xs = (xrayState || '').toLowerCase().trim();
|
|
if (xs === 'error' || xs === 'stop') {
|
|
const detail =
|
|
xs === 'error'
|
|
? t('pages.nodes.statusValues.xrayError')
|
|
: t('pages.nodes.statusValues.xrayStopped');
|
|
return (
|
|
<span style={{ color: XRAY_ERROR_COLOR }}>
|
|
{t('pages.nodes.statusValues.online')} ({detail})
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span style={{ color: 'var(--ant-color-success)' }}>
|
|
{t('pages.nodes.statusValues.online')}
|
|
</span>
|
|
);
|
|
}
|
|
return <span>{t(`pages.nodes.statusValues.${status || 'unknown'}`)}</span>;
|
|
}
|
|
|
|
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,
|
|
latestVersion = '',
|
|
selectedIds,
|
|
onSelectionChange,
|
|
onAdd,
|
|
onMtls,
|
|
onProbeTasks,
|
|
onEdit,
|
|
onDelete,
|
|
onProbe,
|
|
onToggleEnable,
|
|
onUpdateNode,
|
|
onUpdateSelected,
|
|
}: 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());
|
|
|
|
// Map a node GUID to its display name so a transitive sub-node can show which
|
|
// parent it is reached through (#4983).
|
|
const nameByGuid = useMemo(() => {
|
|
const m = new Map<string, string>();
|
|
for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
|
|
return m;
|
|
}, [nodes]);
|
|
|
|
// Order direct nodes first, each immediately followed by its transitive
|
|
// sub-nodes, so the table reads as a parent -> child tree without colliding
|
|
// with the per-row history expander (transitive nodes carry id 0).
|
|
const dataSource = useMemo<NodeRow[]>(() => {
|
|
const toRow = (n: NodeRecord): NodeRow => ({
|
|
...n,
|
|
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
|
|
key: n.transitive ? `t-${n.guid || ''}` : n.id,
|
|
});
|
|
const childrenByParent = new Map<string, NodeRecord[]>();
|
|
for (const n of nodes) {
|
|
if (n.transitive && n.parentGuid) {
|
|
const arr = childrenByParent.get(n.parentGuid) || [];
|
|
arr.push(n);
|
|
childrenByParent.set(n.parentGuid, arr);
|
|
}
|
|
}
|
|
const ordered: NodeRow[] = [];
|
|
const added = new Set<string>();
|
|
const push = (n: NodeRecord) => {
|
|
const row = toRow(n);
|
|
ordered.push(row);
|
|
added.add(String(row.key));
|
|
};
|
|
for (const n of nodes) {
|
|
if (n.transitive) continue;
|
|
push(n);
|
|
if (n.guid) for (const child of childrenByParent.get(n.guid) || []) push(child);
|
|
}
|
|
// Transitive nodes whose parent isn't in the list still get shown.
|
|
for (const n of nodes) {
|
|
if (n.transitive && !added.has(`t-${n.guid || ''}`)) push(n);
|
|
}
|
|
return ordered;
|
|
}, [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.actions'),
|
|
align: 'center',
|
|
width: 150,
|
|
render: (_value, record) =>
|
|
record.transitive ? (
|
|
<Tooltip
|
|
title={t('pages.nodes.subNodeTip', {
|
|
parent: record.parentGuid ? nameByGuid.get(record.parentGuid) || '-' : '-',
|
|
})}
|
|
>
|
|
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>
|
|
{t('pages.nodes.subNode')}
|
|
</Tag>
|
|
</Tooltip>
|
|
) : (
|
|
<Space>
|
|
<Tooltip title={t('pages.nodes.probe')}>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
style={{ fontSize: 16 }}
|
|
icon={<ThunderboltOutlined />}
|
|
aria-label={t('pages.nodes.probe')}
|
|
onClick={() => onProbe(record)}
|
|
/>
|
|
</Tooltip>
|
|
{isUpdateEligible(record) && (
|
|
<Tooltip
|
|
title={t(
|
|
record.nodeType === 'agent'
|
|
? 'pages.nodes.updateAgent'
|
|
: 'pages.nodes.updatePanel',
|
|
)}
|
|
>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
style={{ fontSize: 16 }}
|
|
icon={<CloudDownloadOutlined />}
|
|
aria-label={t(
|
|
record.nodeType === 'agent'
|
|
? 'pages.nodes.updateAgent'
|
|
: 'pages.nodes.updatePanel',
|
|
)}
|
|
onClick={() => onUpdateNode(record)}
|
|
/>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip title={t('edit')}>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
style={{ fontSize: 16 }}
|
|
icon={<EditOutlined />}
|
|
aria-label={t('edit')}
|
|
onClick={() => onEdit(record)}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip title={t('delete')}>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
danger
|
|
style={{ fontSize: 16 }}
|
|
icon={<DeleteOutlined />}
|
|
aria-label={t('delete')}
|
|
onClick={() => onDelete(record)}
|
|
/>
|
|
</Tooltip>
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
title: t('pages.nodes.enable'),
|
|
dataIndex: 'enable',
|
|
align: 'center',
|
|
width: 64,
|
|
render: (_value, record) =>
|
|
record.transitive ? (
|
|
<span style={{ opacity: 0.4 }}>—</span>
|
|
) : (
|
|
<Switch
|
|
checked={!!record.enable}
|
|
size="small"
|
|
onChange={(v) => onToggleEnable(record, v)}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
title: t('pages.nodes.name'),
|
|
dataIndex: 'name',
|
|
ellipsis: true,
|
|
render: (_value, record) => (
|
|
<div
|
|
className="name-cell"
|
|
style={record.transitive ? { paddingInlineStart: 20 } : undefined}
|
|
>
|
|
<span className="name">
|
|
{record.transitive && (
|
|
<ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} />
|
|
)}
|
|
{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"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={t('pages.index.toggleIpVisibility')}
|
|
onClick={() => setShowAddress(false)}
|
|
onKeyDown={activateOnKey(() => setShowAddress(false))}
|
|
/>
|
|
) : (
|
|
<EyeInvisibleOutlined
|
|
className="ip-toggle-icon"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={t('pages.index.toggleIpVisibility')}
|
|
onClick={() => setShowAddress(true)}
|
|
onKeyDown={activateOnKey(() => setShowAddress(true))}
|
|
/>
|
|
)}
|
|
</Tooltip>
|
|
</span>
|
|
),
|
|
dataIndex: 'url',
|
|
width: 330,
|
|
ellipsis: true,
|
|
render: (_value, record) => {
|
|
if (record.nodeType === 'agent') {
|
|
// Agent nodes have no panel to link to: show the host IPs the
|
|
// agent reports with its heartbeat as plain text on one line
|
|
// (P13, layout per item 21).
|
|
const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
|
|
return (
|
|
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
|
|
{ips.length > 0
|
|
? ips.map((ip, i) => (
|
|
<span key={ip}>
|
|
{i > 0 && <span style={{ opacity: 0.45 }}> · </span>}
|
|
{ip}
|
|
</span>
|
|
))
|
|
: '-'}
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<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) => {
|
|
const { tip, iconColor } = statusIssue(record);
|
|
return (
|
|
<Space size={4}>
|
|
<StatusDot status={record.status} xrayState={record.xrayState} />
|
|
<StatusLabel status={record.status} xrayState={record.xrayState} />
|
|
{tip && (
|
|
<Tooltip title={tip}>
|
|
<ExclamationCircleOutlined style={{ color: iconColor }} />
|
|
</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) => {
|
|
// Agent rows carry the A-series display version ("A1.0.0"), not
|
|
// an upstream panel version: the update badge must never light
|
|
// up for them (the version comparator falls back to string
|
|
// inequality for unparseable versions, which would always
|
|
// differ) (P13, item 18).
|
|
const canUpdate =
|
|
record.nodeType !== 'agent' &&
|
|
isUpdateEligible(record) &&
|
|
isPanelUpdateAvailable(latestVersion, record.panelVersion || '');
|
|
return (
|
|
<Space size={4}>
|
|
<span>{record.panelVersion || '-'}</span>
|
|
{canUpdate && (
|
|
<Tooltip title={`${t('pages.nodes.updateAvailable')}: ${latestVersion}`}>
|
|
<Tag
|
|
color="orange"
|
|
style={{ margin: 0, cursor: 'pointer' }}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => onUpdateNode(record)}
|
|
onKeyDown={activateOnKey(() => onUpdateNode(record))}
|
|
>
|
|
{t('pages.nodes.updateAvailable')}
|
|
</Tag>
|
|
</Tooltip>
|
|
)}
|
|
</Space>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: t('pages.nodes.uptime'),
|
|
dataIndex: 'uptimeSecs',
|
|
align: 'center',
|
|
render: (_value, record) => formatUptime(record.uptimeSecs),
|
|
},
|
|
{
|
|
title: t('clients'),
|
|
align: 'center',
|
|
width: 150,
|
|
render: (_value, record) => (
|
|
<Space size={2}>
|
|
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
|
|
<TeamOutlined /> {record.clientCount || 0}
|
|
</Tag>
|
|
{record.activeCount ? (
|
|
<CategoryChip
|
|
title={t('subscription.active')}
|
|
emails={record.activeEmails}
|
|
color="green"
|
|
count={record.activeCount}
|
|
/>
|
|
) : null}
|
|
{record.disabledCount ? (
|
|
<CategoryChip
|
|
title={t('disabled')}
|
|
emails={record.disabledEmails}
|
|
count={record.disabledCount}
|
|
/>
|
|
) : null}
|
|
{record.depletedCount ? (
|
|
<CategoryChip
|
|
title={t('depleted')}
|
|
emails={record.depletedEmails}
|
|
color="red"
|
|
count={record.depletedCount}
|
|
/>
|
|
) : null}
|
|
{record.onlineCount ? (
|
|
<CategoryChip
|
|
title={t('online')}
|
|
emails={record.onlineEmails}
|
|
color="blue"
|
|
count={record.onlineCount}
|
|
/>
|
|
) : null}
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
title: t('pages.nodes.latency'),
|
|
dataIndex: 'latencyMs',
|
|
align: 'center',
|
|
width: 84,
|
|
render: (_value, record) =>
|
|
record.latencyMs && record.latencyMs > 0 ? `${record.latencyMs} ms` : '-',
|
|
},
|
|
{
|
|
title: t('pages.nodes.lastHeartbeat'),
|
|
dataIndex: 'lastHeartbeat',
|
|
align: 'center',
|
|
width: 96,
|
|
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
|
},
|
|
],
|
|
[
|
|
t,
|
|
showAddress,
|
|
relativeTime,
|
|
latestVersion,
|
|
onToggleEnable,
|
|
onProbe,
|
|
onEdit,
|
|
onDelete,
|
|
onUpdateNode,
|
|
nameByGuid,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<Card size="small" hoverable>
|
|
<div className="toolbar">
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
|
{t('pages.nodes.addNode')}
|
|
</Button>
|
|
<Button icon={<SafetyCertificateOutlined />} onClick={onMtls}>
|
|
{t('pages.nodes.mtls.title')}
|
|
</Button>
|
|
<Button icon={<RadarChartOutlined />} onClick={onProbeTasks}>
|
|
{t('pages.nodes.probeTasks.title')}
|
|
</Button>
|
|
{selectedIds.length > 0 && (
|
|
<Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
|
|
{t('pages.nodes.updateSelected', { count: selectedIds.length })}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{isMobile ? (
|
|
<>
|
|
<div className="node-cards">
|
|
{dataSource.length === 0 ? (
|
|
<div className="card-empty">
|
|
<ClusterOutlined style={{ fontSize: 28, opacity: 0.5 }} />
|
|
<div>{t('noData')}</div>
|
|
</div>
|
|
) : (
|
|
dataSource.map((record) =>
|
|
record.transitive ? (
|
|
<div
|
|
key={String(record.key)}
|
|
className="node-card"
|
|
style={{ paddingInlineStart: 16, opacity: 0.85 }}
|
|
>
|
|
<div className="card-head">
|
|
<ApartmentOutlined style={{ opacity: 0.6 }} />
|
|
<StatusDot status={record.status} xrayState={record.xrayState} />
|
|
<span className="node-name">{record.name}</span>
|
|
<div className="card-actions">
|
|
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>
|
|
{t('pages.nodes.subNode')}
|
|
</Tag>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div key={record.id} className="node-card">
|
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- mouse click-to-expand mirrors the keyboard-accessible chevron disclosure button */}
|
|
<div
|
|
className="card-head"
|
|
onClick={(e) => {
|
|
if (!(e.target as HTMLElement).closest('.card-actions'))
|
|
toggleExpanded(record.id);
|
|
}}
|
|
>
|
|
<RightOutlined
|
|
className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-expanded={expandedIds.has(record.id)}
|
|
aria-label={record.name}
|
|
onKeyDown={activateOnKey(() => toggleExpanded(record.id))}
|
|
/>
|
|
<StatusDot status={record.status} xrayState={record.xrayState} />
|
|
<span className="node-name">{record.name}</span>
|
|
<div className="card-actions">
|
|
<Tooltip title={t('info')}>
|
|
<InfoCircleOutlined
|
|
className="row-action-trigger"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={t('info')}
|
|
onClick={() => setStatsNode(record)}
|
|
onKeyDown={activateOnKey(() => 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),
|
|
},
|
|
...(isUpdateEligible(record)
|
|
? [
|
|
{
|
|
key: 'update',
|
|
label: (
|
|
<>
|
|
<CloudDownloadOutlined />{' '}
|
|
{t(
|
|
record.nodeType === 'agent'
|
|
? 'pages.nodes.updateAgent'
|
|
: 'pages.nodes.updatePanel',
|
|
)}
|
|
</>
|
|
),
|
|
onClick: () => onUpdateNode(record),
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
key: 'edit',
|
|
label: (
|
|
<>
|
|
<EditOutlined /> {t('edit')}
|
|
</>
|
|
),
|
|
onClick: () => onEdit(record),
|
|
},
|
|
{
|
|
key: 'delete',
|
|
danger: true,
|
|
label: (
|
|
<>
|
|
<DeleteOutlined /> {t('delete')}
|
|
</>
|
|
),
|
|
onClick: () => onDelete(record),
|
|
},
|
|
],
|
|
}}
|
|
>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="row-action-trigger"
|
|
icon={<MoreOutlined />}
|
|
aria-label={t('more')}
|
|
/>
|
|
</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"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={t('pages.index.toggleIpVisibility')}
|
|
onClick={() => setShowAddress(false)}
|
|
onKeyDown={activateOnKey(() => setShowAddress(false))}
|
|
/>
|
|
) : (
|
|
<EyeInvisibleOutlined
|
|
className="ip-toggle-icon"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={t('pages.index.toggleIpVisibility')}
|
|
onClick={() => setShowAddress(true)}
|
|
onKeyDown={activateOnKey(() => setShowAddress(true))}
|
|
/>
|
|
)}
|
|
</Tooltip>
|
|
</div>
|
|
<div className="stat-row">
|
|
<span className="stat-label">{t('pages.nodes.status')}</span>
|
|
<StatusDot status={statsNode.status} xrayState={statsNode.xrayState} />
|
|
<StatusLabel status={statsNode.status} xrayState={statsNode.xrayState} />
|
|
{(() => {
|
|
const { tip, iconColor } = statusIssue(statsNode);
|
|
return tip ? (
|
|
<Tooltip title={tip}>
|
|
<ExclamationCircleOutlined style={{ color: iconColor }} />
|
|
</Tooltip>
|
|
) : null;
|
|
})()}
|
|
</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>
|
|
<TeamOutlined /> {statsNode.clientCount || 0}
|
|
</Tag>
|
|
{statsNode.activeCount ? (
|
|
<Tag color="green">
|
|
{statsNode.activeCount} {t('subscription.active')}
|
|
</Tag>
|
|
) : null}
|
|
{statsNode.disabledCount ? (
|
|
<Tag>
|
|
{statsNode.disabledCount} {t('disabled')}
|
|
</Tag>
|
|
) : null}
|
|
{statsNode.depletedCount ? (
|
|
<Tag color="red">
|
|
{statsNode.depletedCount} {t('depleted')}
|
|
</Tag>
|
|
) : null}
|
|
{statsNode.onlineCount ? (
|
|
<Tag color="blue">
|
|
{statsNode.onlineCount} {t('online')}
|
|
</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="key"
|
|
rowSelection={
|
|
dataSource.length > 1
|
|
? {
|
|
selectedRowKeys: selectedIds,
|
|
onChange: (keys) =>
|
|
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
|
getCheckboxProps: (record) => ({
|
|
disabled: !!record.transitive || !isUpdateEligible(record),
|
|
}),
|
|
}
|
|
: undefined
|
|
}
|
|
locale={{
|
|
emptyText: (
|
|
<div className="card-empty">
|
|
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
|
<div>{t('noData')}</div>
|
|
</div>
|
|
),
|
|
}}
|
|
expandable={{
|
|
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
|
rowExpandable: (record) => !record.transitive,
|
|
}}
|
|
/>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|