feat: 实现RPC节点管理功能
- 后端功能: - 添加RPC节点配置管理(增删改查、优先级调整) - 实现节点健康检查和自动故障转移 - 修复Retrofit baseUrl处理问题(使用拦截器动态替换URL) - 添加RPC节点可用性验证(创建前验证) - 默认节点作为兜底,不返回给前端,始终排在最后 - API健康检查使用动态获取的可用节点 - 前端功能: - 添加RPC节点设置页面 - 支持添加、删除、检查节点 - 支持调整节点优先级 - 完整的多语言支持(中文简体/繁体、英文) - 添加菜单项多语言key - 数据库: - 添加RPC节点配置表迁移脚本
This commit is contained in:
@@ -32,6 +32,7 @@ import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders'
|
||||
import FilteredOrdersList from './pages/FilteredOrdersList'
|
||||
import SystemSettings from './pages/SystemSettings'
|
||||
import ApiHealthStatus from './pages/ApiHealthStatus'
|
||||
import RpcNodeSettings from './pages/RpcNodeSettings'
|
||||
import Announcements from './pages/Announcements'
|
||||
import { wsManager } from './services/websocket'
|
||||
import type { OrderPushMessage } from './types'
|
||||
@@ -263,7 +264,7 @@ function App() {
|
||||
<Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} />
|
||||
<Route path="/announcements" element={<ProtectedRoute><Announcements /></ProtectedRoute>} />
|
||||
<Route path="/system-settings" element={<ProtectedRoute><SystemSettings /></ProtectedRoute>} />
|
||||
<Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} />
|
||||
<Route path="/system-settings/rpc-nodes" element={<ProtectedRoute><RpcNodeSettings /></ProtectedRoute>} /> <Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} />
|
||||
|
||||
{/* 默认重定向到登录页 */}
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal, Form, Input, Select, message, Space } from 'antd'
|
||||
import { LinkOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import type { RpcNodeAddRequest } from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface AddRpcNodeModalProps {
|
||||
visible: boolean
|
||||
onCancel: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const { Option } = Select
|
||||
|
||||
const AddRpcNodeModal: React.FC<AddRpcNodeModalProps> = ({ visible, onCancel, onSuccess }) => {
|
||||
const { t } = useTranslation()
|
||||
const [form] = Form.useForm()
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>('CUSTOM')
|
||||
const [validating, setValidating] = useState(false)
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'ALCHEMY', label: t('rpcNodeSettings.providerAlchemy'), url: 'https://dashboard.alchemy.com/' },
|
||||
{ value: 'INFURA', label: t('rpcNodeSettings.providerInfura'), url: 'https://infura.io/' },
|
||||
{ value: 'QUICKNODE', label: t('rpcNodeSettings.providerQuickNode'), url: 'https://www.quicknode.com/' },
|
||||
{ value: 'CHAINSTACK', label: t('rpcNodeSettings.providerChainstack'), url: 'https://chainstack.com/' },
|
||||
{ value: 'GETBLOCK', label: t('rpcNodeSettings.providerGetBlock'), url: 'https://getblock.io/' },
|
||||
{ value: 'CUSTOM', label: t('rpcNodeSettings.customNode'), url: '' }
|
||||
]
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setValidating(true)
|
||||
|
||||
const request: RpcNodeAddRequest = {
|
||||
providerType: values.providerType,
|
||||
name: values.name,
|
||||
apiKey: values.apiKey,
|
||||
httpUrl: values.httpUrl,
|
||||
wsUrl: values.wsUrl
|
||||
}
|
||||
|
||||
// 先验证节点
|
||||
const validateResponse = await apiService.rpcNodes.validate(request)
|
||||
|
||||
if (validateResponse.data.code === 0 && validateResponse.data.data) {
|
||||
const result = validateResponse.data.data
|
||||
|
||||
if (!result.valid) {
|
||||
message.error(`${t('rpcNodeSettings.validateFailed')} ${result.message}`)
|
||||
setValidating(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证通过,添加节点
|
||||
const addResponse = await apiService.rpcNodes.add(request)
|
||||
|
||||
if (addResponse.data.code === 0) {
|
||||
message.success(t('rpcNodeSettings.addSuccess'))
|
||||
form.resetFields()
|
||||
setSelectedProvider('CUSTOM')
|
||||
onSuccess()
|
||||
} else {
|
||||
message.error(addResponse.data.msg || t('rpcNodeSettings.addFailed'))
|
||||
}
|
||||
} else {
|
||||
message.error(validateResponse.data.msg || t('rpcNodeSettings.validateError'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!error.errorFields) {
|
||||
message.error(error.message || t('rpcNodeSettings.operationFailed'))
|
||||
}
|
||||
} finally {
|
||||
setValidating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields()
|
||||
setSelectedProvider('CUSTOM')
|
||||
onCancel()
|
||||
}
|
||||
|
||||
const currentProvider = providerOptions.find(p => p.value === selectedProvider)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('rpcNodeSettings.addNodeTitle')}
|
||||
open={visible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
width={600}
|
||||
confirmLoading={validating}
|
||||
okText={t('rpcNodeSettings.validateAndAdd')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ providerType: 'CUSTOM' }}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('rpcNodeSettings.providerTypeLabel')}
|
||||
name="providerType"
|
||||
rules={[{ required: true, message: t('rpcNodeSettings.providerTypeRequired') }]}
|
||||
>
|
||||
<Select onChange={setSelectedProvider}>
|
||||
{providerOptions.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('rpcNodeSettings.nodeNameLabel')}
|
||||
name="name"
|
||||
rules={[{ required: true, message: t('rpcNodeSettings.nodeNameRequired') }]}
|
||||
>
|
||||
<Input placeholder={t('rpcNodeSettings.nodeNamePlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
{selectedProvider !== 'CUSTOM' && (
|
||||
<Form.Item
|
||||
label={
|
||||
<Space>
|
||||
<span>{t('rpcNodeSettings.apiKeyLabel')}</span>
|
||||
{currentProvider?.url && (
|
||||
<a href={currentProvider.url} target="_blank" rel="noopener noreferrer">
|
||||
<LinkOutlined /> {t('rpcNodeSettings.getApiKey')}
|
||||
</a>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
name="apiKey"
|
||||
rules={[{ required: true, message: t('rpcNodeSettings.apiKeyRequired') }]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={t('rpcNodeSettings.apiKeyPlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{selectedProvider === 'CUSTOM' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('rpcNodeSettings.httpUrlLabel')}
|
||||
name="httpUrl"
|
||||
rules={[
|
||||
{ required: true, message: t('rpcNodeSettings.httpUrlRequired') },
|
||||
{ type: 'url', message: t('rpcNodeSettings.httpUrlInvalid') }
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('rpcNodeSettings.httpUrlPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('rpcNodeSettings.wsUrlLabel')}
|
||||
name="wsUrl"
|
||||
rules={[{ type: 'url', message: t('rpcNodeSettings.wsUrlInvalid') }]}
|
||||
>
|
||||
<Input placeholder={t('rpcNodeSettings.wsUrlPlaceholder')} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddRpcNodeModal
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
TwitterOutlined,
|
||||
CheckCircleOutlined,
|
||||
SendOutlined,
|
||||
NotificationOutlined
|
||||
ApiOutlined, NotificationOutlined
|
||||
} from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -131,6 +131,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
icon: <SettingOutlined />,
|
||||
label: t('menu.systemOverview') || '通用设置'
|
||||
},
|
||||
{
|
||||
key: '/system-settings/rpc-nodes',
|
||||
icon: <ApiOutlined />,
|
||||
label: t('menu.rpcNodes') || 'RPC节点管理'
|
||||
},
|
||||
{
|
||||
key: '/system-settings/api-health',
|
||||
icon: <CheckCircleOutlined />,
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
"systemSettings": "System",
|
||||
"systemOverview": "Overview",
|
||||
"language": "Language",
|
||||
"rpcNodes": "RPC Nodes",
|
||||
"apiHealth": "API Health",
|
||||
"builderApiKey": "Builder API Key",
|
||||
"proxy": "Proxy",
|
||||
@@ -1065,5 +1066,62 @@
|
||||
"averagePnl": "Average PnL",
|
||||
"maxPnl": "Max PnL",
|
||||
"minPnl": "Min PnL"
|
||||
},
|
||||
"rpcNodeSettings": {
|
||||
"title": "Polygon RPC Node Configuration",
|
||||
"fetchFailed": "Failed to fetch node list",
|
||||
"checkHealthSuccess": "Health check completed",
|
||||
"checkHealthFailed": "Health check failed",
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"adjustPrioritySuccess": "Priority adjusted successfully",
|
||||
"adjustPriorityFailed": "Failed to adjust priority",
|
||||
"priority": "Priority",
|
||||
"providerType": "Provider",
|
||||
"name": "Name",
|
||||
"status": "Status",
|
||||
"statusHealthy": "Available",
|
||||
"statusUnhealthy": "Unavailable",
|
||||
"statusUnknown": "Unknown",
|
||||
"responseTime": "Response Time",
|
||||
"actions": "Actions",
|
||||
"check": "Check",
|
||||
"checkSuccess": "Check completed",
|
||||
"checkFailed": "Check failed",
|
||||
"deleteConfirm": "Are you sure you want to delete this node?",
|
||||
"deleteConfirmOk": "Confirm",
|
||||
"deleteConfirmCancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"batchCheck": "Batch Check",
|
||||
"addNode": "Add Node",
|
||||
"addNodeTitle": "Add RPC Node",
|
||||
"validateAndAdd": "Validate and Add",
|
||||
"providerTypeLabel": "Provider Type",
|
||||
"providerTypeRequired": "Please select provider type",
|
||||
"nodeNameLabel": "Node Name",
|
||||
"nodeNameRequired": "Please enter node name",
|
||||
"nodeNamePlaceholder": "e.g.: My Alchemy Node",
|
||||
"apiKeyLabel": "API Key",
|
||||
"getApiKey": "Get API Key",
|
||||
"apiKeyRequired": "Please enter API Key",
|
||||
"apiKeyPlaceholder": "Enter your API Key",
|
||||
"httpUrlLabel": "HTTP RPC URL",
|
||||
"httpUrlRequired": "Please enter HTTP RPC URL",
|
||||
"httpUrlInvalid": "Please enter a valid URL",
|
||||
"httpUrlPlaceholder": "https://polygon-rpc.com",
|
||||
"wsUrlLabel": "WebSocket URL (Optional)",
|
||||
"wsUrlInvalid": "Please enter a valid URL",
|
||||
"wsUrlPlaceholder": "wss://polygon-rpc.com",
|
||||
"validateFailed": "Node validation failed:",
|
||||
"addSuccess": "Added successfully",
|
||||
"addFailed": "Failed to add",
|
||||
"validateError": "Validation failed",
|
||||
"operationFailed": "Operation failed",
|
||||
"customNode": "Custom Node",
|
||||
"providerAlchemy": "Alchemy",
|
||||
"providerInfura": "Infura",
|
||||
"providerQuickNode": "QuickNode",
|
||||
"providerChainstack": "Chainstack",
|
||||
"providerGetBlock": "GetBlock"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
"systemSettings": "系统管理",
|
||||
"systemOverview": "概览",
|
||||
"language": "语言",
|
||||
"rpcNodes": "RPC节点管理",
|
||||
"apiHealth": "API健康",
|
||||
"builderApiKey": "Builder API Key",
|
||||
"proxy": "代理",
|
||||
@@ -1065,5 +1066,62 @@
|
||||
"averagePnl": "平均盈亏",
|
||||
"maxPnl": "最大盈亏",
|
||||
"minPnl": "最小盈亏"
|
||||
},
|
||||
"rpcNodeSettings": {
|
||||
"title": "Polygon RPC 节点配置",
|
||||
"fetchFailed": "获取节点列表失败",
|
||||
"checkHealthSuccess": "健康检查完成",
|
||||
"checkHealthFailed": "健康检查失败",
|
||||
"deleteSuccess": "删除成功",
|
||||
"deleteFailed": "删除失败",
|
||||
"adjustPrioritySuccess": "调整成功",
|
||||
"adjustPriorityFailed": "调整失败",
|
||||
"priority": "优先级",
|
||||
"providerType": "服务商",
|
||||
"name": "名称",
|
||||
"status": "状态",
|
||||
"statusHealthy": "可用",
|
||||
"statusUnhealthy": "不可用",
|
||||
"statusUnknown": "未知",
|
||||
"responseTime": "响应时间",
|
||||
"actions": "操作",
|
||||
"check": "检查",
|
||||
"checkSuccess": "检查完成",
|
||||
"checkFailed": "检查失败",
|
||||
"deleteConfirm": "确定删除此节点吗?",
|
||||
"deleteConfirmOk": "确定",
|
||||
"deleteConfirmCancel": "取消",
|
||||
"delete": "删除",
|
||||
"batchCheck": "批量检查",
|
||||
"addNode": "添加节点",
|
||||
"addNodeTitle": "添加 RPC 节点",
|
||||
"validateAndAdd": "验证并添加",
|
||||
"providerTypeLabel": "服务商类型",
|
||||
"providerTypeRequired": "请选择服务商类型",
|
||||
"nodeNameLabel": "节点名称",
|
||||
"nodeNameRequired": "请输入节点名称",
|
||||
"nodeNamePlaceholder": "例如: My Alchemy Node",
|
||||
"apiKeyLabel": "API Key",
|
||||
"getApiKey": "获取 API Key",
|
||||
"apiKeyRequired": "请输入 API Key",
|
||||
"apiKeyPlaceholder": "输入您的 API Key",
|
||||
"httpUrlLabel": "HTTP RPC URL",
|
||||
"httpUrlRequired": "请输入 HTTP RPC URL",
|
||||
"httpUrlInvalid": "请输入有效的 URL",
|
||||
"httpUrlPlaceholder": "https://polygon-rpc.com",
|
||||
"wsUrlLabel": "WebSocket URL (可选)",
|
||||
"wsUrlInvalid": "请输入有效的 URL",
|
||||
"wsUrlPlaceholder": "wss://polygon-rpc.com",
|
||||
"validateFailed": "节点验证失败:",
|
||||
"addSuccess": "添加成功",
|
||||
"addFailed": "添加失败",
|
||||
"validateError": "验证失败",
|
||||
"operationFailed": "操作失败",
|
||||
"customNode": "自定义节点",
|
||||
"providerAlchemy": "Alchemy",
|
||||
"providerInfura": "Infura",
|
||||
"providerQuickNode": "QuickNode",
|
||||
"providerChainstack": "Chainstack",
|
||||
"providerGetBlock": "GetBlock"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
"systemSettings": "系統管理",
|
||||
"systemOverview": "概覽",
|
||||
"language": "語言",
|
||||
"rpcNodes": "RPC節點管理",
|
||||
"apiHealth": "API健康",
|
||||
"builderApiKey": "Builder API Key",
|
||||
"proxy": "代理",
|
||||
@@ -1065,5 +1066,62 @@
|
||||
"averagePnl": "平均盈虧",
|
||||
"maxPnl": "最大盈虧",
|
||||
"minPnl": "最小盈虧"
|
||||
},
|
||||
"rpcNodeSettings": {
|
||||
"title": "Polygon RPC 節點配置",
|
||||
"fetchFailed": "獲取節點列表失敗",
|
||||
"checkHealthSuccess": "健康檢查完成",
|
||||
"checkHealthFailed": "健康檢查失敗",
|
||||
"deleteSuccess": "刪除成功",
|
||||
"deleteFailed": "刪除失敗",
|
||||
"adjustPrioritySuccess": "調整成功",
|
||||
"adjustPriorityFailed": "調整失敗",
|
||||
"priority": "優先級",
|
||||
"providerType": "服務商",
|
||||
"name": "名稱",
|
||||
"status": "狀態",
|
||||
"statusHealthy": "可用",
|
||||
"statusUnhealthy": "不可用",
|
||||
"statusUnknown": "未知",
|
||||
"responseTime": "響應時間",
|
||||
"actions": "操作",
|
||||
"check": "檢查",
|
||||
"checkSuccess": "檢查完成",
|
||||
"checkFailed": "檢查失敗",
|
||||
"deleteConfirm": "確定刪除此節點嗎?",
|
||||
"deleteConfirmOk": "確定",
|
||||
"deleteConfirmCancel": "取消",
|
||||
"delete": "刪除",
|
||||
"batchCheck": "批量檢查",
|
||||
"addNode": "添加節點",
|
||||
"addNodeTitle": "添加 RPC 節點",
|
||||
"validateAndAdd": "驗證並添加",
|
||||
"providerTypeLabel": "服務商類型",
|
||||
"providerTypeRequired": "請選擇服務商類型",
|
||||
"nodeNameLabel": "節點名稱",
|
||||
"nodeNameRequired": "請輸入節點名稱",
|
||||
"nodeNamePlaceholder": "例如: My Alchemy Node",
|
||||
"apiKeyLabel": "API Key",
|
||||
"getApiKey": "獲取 API Key",
|
||||
"apiKeyRequired": "請輸入 API Key",
|
||||
"apiKeyPlaceholder": "輸入您的 API Key",
|
||||
"httpUrlLabel": "HTTP RPC URL",
|
||||
"httpUrlRequired": "請輸入 HTTP RPC URL",
|
||||
"httpUrlInvalid": "請輸入有效的 URL",
|
||||
"httpUrlPlaceholder": "https://polygon-rpc.com",
|
||||
"wsUrlLabel": "WebSocket URL (可選)",
|
||||
"wsUrlInvalid": "請輸入有效的 URL",
|
||||
"wsUrlPlaceholder": "wss://polygon-rpc.com",
|
||||
"validateFailed": "節點驗證失敗:",
|
||||
"addSuccess": "添加成功",
|
||||
"addFailed": "添加失敗",
|
||||
"validateError": "驗證失敗",
|
||||
"operationFailed": "操作失敗",
|
||||
"customNode": "自定義節點",
|
||||
"providerAlchemy": "Alchemy",
|
||||
"providerInfura": "Infura",
|
||||
"providerQuickNode": "QuickNode",
|
||||
"providerChainstack": "Chainstack",
|
||||
"providerGetBlock": "GetBlock"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, Table, Button, Space, Badge, message, Popconfirm, Tag } from 'antd'
|
||||
import { UpOutlined, DownOutlined, DeleteOutlined, ReloadOutlined, PlusOutlined, ApiOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import type { RpcNodeConfig } from '../types'
|
||||
import AddRpcNodeModal from '../components/AddRpcNodeModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const RpcNodeSettings: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [nodes, setNodes] = useState<RpcNodeConfig[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes()
|
||||
}, [])
|
||||
|
||||
const fetchNodes = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.rpcNodes.list()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setNodes(response.data.data)
|
||||
} else {
|
||||
message.error(response.data.msg || t('rpcNodeSettings.fetchFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('rpcNodeSettings.fetchFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckAllHealth = async () => {
|
||||
setChecking(true)
|
||||
try {
|
||||
const response = await apiService.rpcNodes.checkHealth({})
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('rpcNodeSettings.checkHealthSuccess'))
|
||||
fetchNodes()
|
||||
} else {
|
||||
message.error(response.data.msg || t('rpcNodeSettings.checkHealthFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('rpcNodeSettings.checkHealthFailed'))
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
const response = await apiService.rpcNodes.delete({ id })
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('rpcNodeSettings.deleteSuccess'))
|
||||
fetchNodes()
|
||||
} else {
|
||||
message.error(response.data.msg || t('rpcNodeSettings.deleteFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('rpcNodeSettings.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleMovePriority = async (id: number, direction: 'up' | 'down') => {
|
||||
const index = nodes.findIndex(n => n.id === id)
|
||||
if (index === -1) return
|
||||
|
||||
if (direction === 'up' && index === 0) return
|
||||
if (direction === 'down' && index === nodes.length - 1) return
|
||||
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1
|
||||
const newPriority = nodes[targetIndex].priority
|
||||
|
||||
try {
|
||||
const response = await apiService.rpcNodes.updatePriority({ id, priority: newPriority })
|
||||
if (response.data.code === 0) {
|
||||
// 同时更新另一个节点的优先级
|
||||
await apiService.rpcNodes.updatePriority({
|
||||
id: nodes[targetIndex].id,
|
||||
priority: nodes[index].priority
|
||||
})
|
||||
message.success(t('rpcNodeSettings.adjustPrioritySuccess'))
|
||||
fetchNodes()
|
||||
} else {
|
||||
message.error(response.data.msg || t('rpcNodeSettings.adjustPriorityFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('rpcNodeSettings.adjustPriorityFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('rpcNodeSettings.priority'),
|
||||
dataIndex: 'priority',
|
||||
width: 120,
|
||||
render: (_: any, record: RpcNodeConfig, index: number) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UpOutlined />}
|
||||
onClick={() => handleMovePriority(record.id, 'up')}
|
||||
disabled={index === 0}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownOutlined />}
|
||||
onClick={() => handleMovePriority(record.id, 'down')}
|
||||
disabled={index === nodes.length - 1}
|
||||
/>
|
||||
<span>{index + 1}</span>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.providerType'),
|
||||
dataIndex: 'providerType',
|
||||
width: 120,
|
||||
render: (type: string) => <Tag color="blue">{type}</Tag>
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.name'),
|
||||
dataIndex: 'name',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.status'),
|
||||
dataIndex: 'lastCheckStatus',
|
||||
width: 100,
|
||||
render: (status: string | undefined) => {
|
||||
const statusMap = {
|
||||
HEALTHY: { status: 'success' as const, text: t('rpcNodeSettings.statusHealthy') },
|
||||
UNHEALTHY: { status: 'error' as const, text: t('rpcNodeSettings.statusUnhealthy') },
|
||||
UNKNOWN: { status: 'default' as const, text: t('rpcNodeSettings.statusUnknown') }
|
||||
}
|
||||
const config = statusMap[status as keyof typeof statusMap] || statusMap.UNKNOWN
|
||||
return <Badge status={config.status} text={config.text} />
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.responseTime'),
|
||||
dataIndex: 'responseTimeMs',
|
||||
width: 100,
|
||||
render: (time: number | undefined) => time ? `${time}ms` : '-'
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.actions'),
|
||||
key: 'action',
|
||||
width: 150,
|
||||
render: (_: any, record: RpcNodeConfig) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await apiService.rpcNodes.checkHealth({ id: record.id })
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('rpcNodeSettings.checkSuccess'))
|
||||
fetchNodes()
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(t('rpcNodeSettings.checkFailed'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('rpcNodeSettings.check')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('rpcNodeSettings.deleteConfirm')}
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText={t('rpcNodeSettings.deleteConfirmOk')}
|
||||
cancelText={t('rpcNodeSettings.deleteConfirmCancel')}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
{t('rpcNodeSettings.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<ApiOutlined />
|
||||
<span>{t('rpcNodeSettings.title')}</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleCheckAllHealth}
|
||||
loading={checking}
|
||||
>
|
||||
{t('rpcNodeSettings.batchCheck')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setModalVisible(true)}
|
||||
>
|
||||
{t('rpcNodeSettings.addNode')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
dataSource={nodes}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<AddRpcNodeModal
|
||||
visible={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onSuccess={() => {
|
||||
setModalVisible(false)
|
||||
fetchNodes()
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default RpcNodeSettings
|
||||
@@ -887,6 +887,7 @@ const SystemSettings: React.FC = () => {
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -618,6 +618,33 @@ export const apiService = {
|
||||
/**
|
||||
* 公告 API
|
||||
*/
|
||||
/**
|
||||
* RPC 节点配置 API
|
||||
*/
|
||||
rpcNodes: {
|
||||
list: () =>
|
||||
apiClient.post<ApiResponse<import('../types').RpcNodeConfig[]>>('/system/rpc-nodes/list', {}),
|
||||
|
||||
add: (data: import('../types').RpcNodeAddRequest) =>
|
||||
apiClient.post<ApiResponse<import('../types').RpcNodeConfig>>('/system/rpc-nodes/add', data),
|
||||
|
||||
update: (data: import('../types').RpcNodeUpdateRequest) =>
|
||||
apiClient.post<ApiResponse<import('../types').RpcNodeConfig>>('/system/rpc-nodes/update', data),
|
||||
|
||||
delete: (data: { id: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/system/rpc-nodes/delete', data),
|
||||
|
||||
updatePriority: (data: { id: number; priority: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/system/rpc-nodes/update-priority', data),
|
||||
|
||||
checkHealth: (data: { id?: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/system/rpc-nodes/check-health', data),
|
||||
|
||||
validate: (data: import('../types').RpcNodeAddRequest) =>
|
||||
apiClient.post<ApiResponse<{ valid: boolean; message: string; responseTimeMs?: number }>>('/system/rpc-nodes/validate', data)
|
||||
},
|
||||
|
||||
|
||||
announcements: {
|
||||
/**
|
||||
* 获取公告列表(最近10条)
|
||||
|
||||
@@ -806,3 +806,54 @@ export interface NotificationConfigUpdateRequest {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* RPC 节点配置类型
|
||||
*/
|
||||
export interface RpcNodeConfig {
|
||||
id: number
|
||||
providerType: 'ALCHEMY' | 'INFURA' | 'QUICKNODE' | 'CHAINSTACK' | 'GETBLOCK' | 'CUSTOM' | 'PUBLIC'
|
||||
name: string
|
||||
httpUrl: string
|
||||
wsUrl?: string
|
||||
apiKeyMasked?: string // 脱敏后的 API Key
|
||||
enabled: boolean
|
||||
priority: number
|
||||
lastCheckTime?: number
|
||||
lastCheckStatus?: 'HEALTHY' | 'UNHEALTHY' | 'UNKNOWN'
|
||||
responseTimeMs?: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 RPC 节点请求
|
||||
*/
|
||||
export interface RpcNodeAddRequest {
|
||||
providerType: string
|
||||
name: string
|
||||
apiKey?: string // 主流服务商需要
|
||||
httpUrl?: string // CUSTOM 需要
|
||||
wsUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 RPC 节点请求
|
||||
*/
|
||||
export interface RpcNodeUpdateRequest {
|
||||
id: number
|
||||
name?: string
|
||||
enabled?: boolean
|
||||
priority?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点健康检查结果
|
||||
*/
|
||||
export interface NodeCheckResult {
|
||||
status: 'HEALTHY' | 'UNHEALTHY' | 'UNKNOWN'
|
||||
message: string
|
||||
checkTime: number
|
||||
responseTimeMs?: number
|
||||
blockNumber?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user