[easytier-uptime] support tag in node list (#1487)
This commit is contained in:
Generated
+39
@@ -653,6 +653,31 @@ dependencies = [
|
|||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-extra"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d"
|
||||||
|
dependencies = [
|
||||||
|
"axum 0.8.4",
|
||||||
|
"axum-core 0.5.2",
|
||||||
|
"bytes",
|
||||||
|
"form_urlencoded",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustversion",
|
||||||
|
"serde",
|
||||||
|
"serde_html_form",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"tower 0.5.2",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum-login"
|
name = "axum-login"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -2293,6 +2318,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum 0.8.4",
|
"axum 0.8.4",
|
||||||
|
"axum-extra",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
@@ -7521,6 +7547,19 @@ dependencies = [
|
|||||||
"syn 2.0.87",
|
"syn 2.0.87",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_html_form"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"indexmap 2.7.1",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.125"
|
version = "1.0.125"
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Development Environment Configuration
|
||||||
|
SERVER_HOST=127.0.0.1
|
||||||
|
SERVER_PORT=8080
|
||||||
|
DATABASE_PATH=uptime.db
|
||||||
|
DATABASE_MAX_CONNECTIONS=5
|
||||||
|
HEALTH_CHECK_INTERVAL=60
|
||||||
|
HEALTH_CHECK_TIMEOUT=15
|
||||||
|
HEALTH_CHECK_RETRIES=2
|
||||||
|
RUST_LOG=debug
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
|
||||||
|
CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS
|
||||||
|
CORS_ALLOWED_HEADERS=content-type,authorization
|
||||||
|
NODE_ENV=development
|
||||||
|
API_BASE_URL=/api
|
||||||
|
ENABLE_COMPRESSION=true
|
||||||
|
ENABLE_CORS=true
|
||||||
@@ -15,6 +15,7 @@ uuid = { version = "1.0", features = ["v4", "serde"] }
|
|||||||
|
|
||||||
# Axum web framework
|
# Axum web framework
|
||||||
axum = { version = "0.8.4", features = ["macros"] }
|
axum = { version = "0.8.4", features = ["macros"] }
|
||||||
|
axum-extra = { version = "0.10", features = ["query"] }
|
||||||
tower-http = { version = "0.6", features = ["cors", "compression-full"] }
|
tower-http = { version = "0.6", features = ["cors", "compression-full"] }
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { healthApi } from './api'
|
import { healthApi } from './api'
|
||||||
import {
|
import {
|
||||||
@@ -70,6 +70,20 @@ const menuItems = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 根据当前路由计算默认激活的菜单项
|
||||||
|
const activeMenuIndex = computed(() => {
|
||||||
|
const p = route.path
|
||||||
|
if (p.startsWith('/submit')) return 'submit'
|
||||||
|
return 'dashboard'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理菜单选择,避免返回 Promise 导致异步补丁问题
|
||||||
|
const handleMenuSelect = (key) => {
|
||||||
|
const item = menuItems.find((i) => i.name === key)
|
||||||
|
if (item && item.path) {
|
||||||
|
router.push(item.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
checkHealth()
|
checkHealth()
|
||||||
// 定期检查健康状态
|
// 定期检查健康状态
|
||||||
@@ -89,8 +103,8 @@ onMounted(() => {
|
|||||||
<h1 class="app-title">EasyTier Uptime</h1>
|
<h1 class="app-title">EasyTier Uptime</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-menu :default-active="route.name" mode="horizontal" class="nav-menu"
|
<el-menu :default-active="activeMenuIndex" mode="horizontal" class="nav-menu"
|
||||||
@select="(key) => router.push(menuItems.find(item => item.name === key)?.path || '/')">
|
@select="handleMenuSelect">
|
||||||
<el-menu-item v-for="item in menuItems" :key="item.name" :index="item.name">
|
<el-menu-item v-for="item in menuItems" :key="item.name" :index="item.name">
|
||||||
<el-icon>
|
<el-icon>
|
||||||
<component :is="item.icon" />
|
<component :is="item.icon" />
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ const api = axios.create({
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
// 保证数组参数使用 repeated keys 风格序列化:tags=a&tags=b
|
||||||
|
paramsSerializer: params => {
|
||||||
|
const usp = new URLSearchParams()
|
||||||
|
Object.entries(params || {}).forEach(([key, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(v => usp.append(key, v))
|
||||||
|
} else if (value !== undefined && value !== null && value !== '') {
|
||||||
|
usp.append(key, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return usp.toString()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -50,9 +62,15 @@ api.interceptors.response.use(
|
|||||||
|
|
||||||
// 节点相关API
|
// 节点相关API
|
||||||
export const nodeApi = {
|
export const nodeApi = {
|
||||||
// 获取节点列表
|
// 获取节点列表(支持传入 AbortController.signal 用于取消)
|
||||||
async getNodes(params = {}) {
|
async getNodes(params = {}, options = {}) {
|
||||||
const response = await api.get('/api/nodes', { params })
|
const response = await api.get('/api/nodes', { params, signal: options.signal })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取所有标签
|
||||||
|
async getAllTags() {
|
||||||
|
const response = await api.get('/api/tags')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -149,6 +167,28 @@ export const adminApi = {
|
|||||||
async updateNode(id, data) {
|
async updateNode(id, data) {
|
||||||
const response = await api.put(`/api/admin/nodes/${id}`, data)
|
const response = await api.put(`/api/admin/nodes/${id}`, data)
|
||||||
return response.data
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 兼容方法:获取所有节点(参数转换)
|
||||||
|
async getAllNodes(params = {}) {
|
||||||
|
const mapped = {
|
||||||
|
page: params.page,
|
||||||
|
per_page: params.page_size ?? params.per_page,
|
||||||
|
is_approved: params.approved ?? params.is_approved,
|
||||||
|
is_active: params.online ?? params.is_active,
|
||||||
|
protocol: params.protocol,
|
||||||
|
search: params.search,
|
||||||
|
tag: params.tag
|
||||||
|
}
|
||||||
|
// 移除未定义的字段
|
||||||
|
Object.keys(mapped).forEach(k => {
|
||||||
|
if (mapped[k] === undefined || mapped[k] === null || mapped[k] === '') {
|
||||||
|
delete mapped[k]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 直接复用现有接口
|
||||||
|
const response = await api.get('/api/admin/nodes', { params: mapped })
|
||||||
|
return response.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,15 @@
|
|||||||
<div class="form-tip">详细描述有助于用户选择合适的节点</div>
|
<div class="form-tip">详细描述有助于用户选择合适的节点</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 新增:标签管理(仅在管理员编辑时显示) -->
|
||||||
|
<el-form-item v-if="props.showTags" label="标签" prop="tags">
|
||||||
|
<el-select v-model="form.tags" multiple filterable allow-create default-first-option :multiple-limit="10"
|
||||||
|
placeholder="输入后按回车添加,如:北京、联通、IPv6、高带宽">
|
||||||
|
<el-option v-for="opt in (form.tags || [])" :key="opt" :label="opt" :value="opt" />
|
||||||
|
</el-select>
|
||||||
|
<div class="form-tip">用于分类与检索,建议 1-6 个标签,每个不超过 32 字符</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<!-- 联系方式 -->
|
<!-- 联系方式 -->
|
||||||
<el-form-item label="联系方式" prop="contact_info">
|
<el-form-item label="联系方式" prop="contact_info">
|
||||||
<div class="contact-section">
|
<div class="contact-section">
|
||||||
@@ -238,6 +247,7 @@ const props = defineProps({
|
|||||||
wechat: '',
|
wechat: '',
|
||||||
qq_number: '',
|
qq_number: '',
|
||||||
mail: '',
|
mail: '',
|
||||||
|
tags: [],
|
||||||
agreed: false
|
agreed: false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -264,6 +274,11 @@ const props = defineProps({
|
|||||||
showCancel: {
|
showCancel: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
// 新增:是否显示标签管理
|
||||||
|
showTags: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -353,6 +368,38 @@ const rules = {
|
|||||||
},
|
},
|
||||||
trigger: 'change'
|
trigger: 'change'
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
// 新增:标签规则(仅在显示标签管理时生效)
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
validator: (rule, value, callback) => {
|
||||||
|
if (!props.showTags) {
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Array.isArray(form.tags)) {
|
||||||
|
callback(new Error('标签格式错误'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.tags.length > 10) {
|
||||||
|
callback(new Error('最多添加 10 个标签'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const t of form.tags) {
|
||||||
|
const s = (t || '').trim()
|
||||||
|
if (s.length === 0) {
|
||||||
|
callback(new Error('标签不能为空'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (s.length > 32) {
|
||||||
|
callback(new Error('每个标签不超过 32 字符'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,7 +409,7 @@ const canTest = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const buildDataFromForm = () => {
|
const buildDataFromForm = () => {
|
||||||
return {
|
const data = {
|
||||||
name: form.name || 'Test Node',
|
name: form.name || 'Test Node',
|
||||||
host: form.host,
|
host: form.host,
|
||||||
port: form.port,
|
port: form.port,
|
||||||
@@ -376,6 +423,11 @@ const buildDataFromForm = () => {
|
|||||||
qq_number: form.qq_number || null,
|
qq_number: form.qq_number || null,
|
||||||
mail: form.mail || null
|
mail: form.mail || null
|
||||||
}
|
}
|
||||||
|
// 仅在管理员编辑时附带标签
|
||||||
|
if (props.showTags) {
|
||||||
|
data.tags = Array.isArray(form.tags) ? form.tags : []
|
||||||
|
}
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试连接
|
// 测试连接
|
||||||
@@ -441,6 +493,10 @@ const resetFields = () => {
|
|||||||
if (formRef.value) {
|
if (formRef.value) {
|
||||||
formRef.value.resetFields()
|
formRef.value.resetFields()
|
||||||
}
|
}
|
||||||
|
// 重置标签
|
||||||
|
if (props.showTags) {
|
||||||
|
form.tags = []
|
||||||
|
}
|
||||||
testResult.value = null
|
testResult.value = null
|
||||||
emit('reset')
|
emit('reset')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Deterministic tag color generator (pure frontend)
|
||||||
|
// Same tag => same color; different tags => different colors
|
||||||
|
|
||||||
|
function stringHash(str) {
|
||||||
|
const s = String(str)
|
||||||
|
let hash = 5381
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
hash = (hash * 33) ^ s.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return hash >>> 0 // ensure positive
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToRgb(h, s, l) {
|
||||||
|
// h,s,l in [0,1]
|
||||||
|
let r, g, b
|
||||||
|
|
||||||
|
if (s === 0) {
|
||||||
|
r = g = b = l // achromatic
|
||||||
|
} else {
|
||||||
|
const hue2rgb = (p, q, t) => {
|
||||||
|
if (t < 0) t += 1
|
||||||
|
if (t > 1) t -= 1
|
||||||
|
if (t < 1 / 6) return p + (q - p) * 6 * t
|
||||||
|
if (t < 1 / 2) return q
|
||||||
|
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||||
|
const p = 2 * l - q
|
||||||
|
r = hue2rgb(p, q, h + 1 / 3)
|
||||||
|
g = hue2rgb(p, q, h)
|
||||||
|
b = hue2rgb(p, q, h - 1 / 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHex(r, g, b) {
|
||||||
|
const toHex = (v) => v.toString(16).padStart(2, '0')
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTagStyle(tag) {
|
||||||
|
const hash = stringHash(tag)
|
||||||
|
const hue = hash % 360 // 0-359
|
||||||
|
const saturation = 65 // percentage
|
||||||
|
const lightness = 47 // percentage
|
||||||
|
|
||||||
|
const rgb = hslToRgb(hue / 360, saturation / 100, lightness / 100)
|
||||||
|
const hex = rgbToHex(rgb[0], rgb[1], rgb[2])
|
||||||
|
|
||||||
|
// Perceived brightness for text color selection
|
||||||
|
const brightness = rgb[0] * 0.299 + rgb[1] * 0.587 + rgb[2] * 0.114
|
||||||
|
const textColor = brightness > 160 ? '#1f1f1f' : '#ffffff'
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: hex,
|
||||||
|
borderColor: hex,
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,6 +196,17 @@
|
|||||||
|
|
||||||
<el-table-column prop="description" label="描述" min-width="150" show-overflow-tooltip />
|
<el-table-column prop="description" label="描述" min-width="150" show-overflow-tooltip />
|
||||||
|
|
||||||
|
<el-table-column prop="tags" label="标签" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="tags-list">
|
||||||
|
<el-tag v-for="(tag, idx) in row.tags" :key="tag + idx" size="small" class="tag-chip" :style="getTagStyle(tag)">
|
||||||
|
{{ tag }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="!row.tags || row.tags.length === 0" class="text-muted">无</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="created_at" label="创建时间" width="160">
|
<el-table-column prop="created_at" label="创建时间" width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ formatDate(row.created_at) }}
|
{{ formatDate(row.created_at) }}
|
||||||
@@ -228,8 +239,8 @@
|
|||||||
<!-- 编辑节点对话框 -->
|
<!-- 编辑节点对话框 -->
|
||||||
<el-dialog v-model="editDialogVisible" title="编辑节点" width="800px" destroy-on-close>
|
<el-dialog v-model="editDialogVisible" title="编辑节点" width="800px" destroy-on-close>
|
||||||
<NodeForm v-if="editDialogVisible" v-model="editForm" :submitting="updating" submit-text="更新节点" submit-icon="Edit"
|
<NodeForm v-if="editDialogVisible" v-model="editForm" :submitting="updating" submit-text="更新节点" submit-icon="Edit"
|
||||||
:show-connection-test="false" :show-agreement="false" :show-cancel="true" @submit="handleUpdateNode"
|
:show-connection-test="false" :show-agreement="false" :show-cancel="true" :show-tags="true"
|
||||||
@cancel="editDialogVisible = false" @reset="resetEditForm" />
|
@submit="handleUpdateNode" @cancel="editDialogVisible = false" @reset="resetEditForm" />
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -240,6 +251,7 @@ import dayjs from 'dayjs'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Check, Clock, DataAnalysis, CircleCheck, Loading } from '@element-plus/icons-vue'
|
import { Check, Clock, DataAnalysis, CircleCheck, Loading } from '@element-plus/icons-vue'
|
||||||
import NodeForm from '../components/NodeForm.vue'
|
import NodeForm from '../components/NodeForm.vue'
|
||||||
|
import { getTagStyle } from '../utils/tagColor'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AdminDashboard',
|
name: 'AdminDashboard',
|
||||||
@@ -270,7 +282,8 @@ export default {
|
|||||||
protocol: 'tcp',
|
protocol: 'tcp',
|
||||||
version: '',
|
version: '',
|
||||||
max_connections: 100,
|
max_connections: 100,
|
||||||
description: ''
|
description: '',
|
||||||
|
tags: []
|
||||||
},
|
},
|
||||||
editingNodeId: null,
|
editingNodeId: null,
|
||||||
updating: false
|
updating: false
|
||||||
@@ -302,6 +315,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
getTagStyle,
|
||||||
async loadNodes() {
|
async loadNodes() {
|
||||||
try {
|
try {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
@@ -379,13 +393,47 @@ export default {
|
|||||||
},
|
},
|
||||||
editNode(node) {
|
editNode(node) {
|
||||||
this.editingNodeId = node.id
|
this.editingNodeId = node.id
|
||||||
this.editForm = node
|
// 只取需要的字段,并复制 tags 数组以避免引用问题
|
||||||
|
this.editForm = {
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
host: node.host,
|
||||||
|
port: node.port,
|
||||||
|
protocol: node.protocol,
|
||||||
|
version: node.version,
|
||||||
|
max_connections: node.max_connections,
|
||||||
|
description: node.description || '',
|
||||||
|
allow_relay: node.allow_relay,
|
||||||
|
network_name: node.network_name,
|
||||||
|
network_secret: node.network_secret,
|
||||||
|
wechat: node.wechat,
|
||||||
|
qq_number: node.qq_number,
|
||||||
|
mail: node.mail,
|
||||||
|
tags: Array.isArray(node.tags) ? [...node.tags] : []
|
||||||
|
}
|
||||||
this.editDialogVisible = true
|
this.editDialogVisible = true
|
||||||
},
|
},
|
||||||
async handleUpdateNode(formData) {
|
async handleUpdateNode(formData) {
|
||||||
try {
|
try {
|
||||||
this.updating = true
|
this.updating = true
|
||||||
await adminApi.updateNode(this.editingNodeId, formData)
|
// 确保提交包含 tags 字段(为空数组也传)
|
||||||
|
const payload = {
|
||||||
|
name: formData.name,
|
||||||
|
host: formData.host,
|
||||||
|
port: formData.port,
|
||||||
|
protocol: formData.protocol,
|
||||||
|
version: formData.version,
|
||||||
|
max_connections: formData.max_connections,
|
||||||
|
description: formData.description,
|
||||||
|
allow_relay: formData.allow_relay,
|
||||||
|
network_name: formData.network_name,
|
||||||
|
network_secret: formData.network_secret,
|
||||||
|
wechat: formData.wechat,
|
||||||
|
qq_number: formData.qq_number,
|
||||||
|
mail: formData.mail,
|
||||||
|
tags: Array.isArray(formData.tags) ? formData.tags : []
|
||||||
|
}
|
||||||
|
await adminApi.updateNode(this.editingNodeId, payload)
|
||||||
ElMessage.success('节点更新成功')
|
ElMessage.success('节点更新成功')
|
||||||
this.editDialogVisible = false
|
this.editDialogVisible = false
|
||||||
await this.loadNodes()
|
await this.loadNodes()
|
||||||
@@ -576,4 +624,8 @@ export default {
|
|||||||
.text-secondary {
|
.text-secondary {
|
||||||
color: #909399;
|
color: #909399;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
|
|
||||||
<!-- 搜索和筛选 -->
|
<!-- 搜索和筛选 -->
|
||||||
<el-card class="filter-card">
|
<el-card class="filter-card">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="26">
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-input v-model="searchText" placeholder="搜索节点名称、主机地址或描述" prefix-icon="Search" clearable
|
<el-input v-model="searchText" placeholder="搜索节点名称、主机地址或描述" prefix-icon="Search" clearable
|
||||||
@input="handleSearch" />
|
@input="handleSearch" />
|
||||||
@@ -77,14 +77,16 @@
|
|||||||
<el-option label="WSS" value="wss" />
|
<el-option label="WSS" value="wss" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
<!-- 新增:标签多选筛选 -->
|
||||||
<el-col :span="4">
|
<el-col :span="4">
|
||||||
<el-button type="primary" @click="refreshData" :loading="loading">
|
<el-select v-model="selectedTags" multiple collapse-tags collapse-tags-tooltip filterable clearable
|
||||||
<el-icon>
|
placeholder="按标签筛选(可多选)" @change="handleFilter">
|
||||||
<Refresh />
|
<el-option v-for="tag in allTags" :key="tag" :label="tag" :value="tag">
|
||||||
</el-icon>
|
<span class="tag-option" :style="getTagStyle(tag)">{{ tag }}</span>
|
||||||
刷新
|
</el-option>
|
||||||
</el-button>
|
</el-select>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|
||||||
<el-col :span="4">
|
<el-col :span="4">
|
||||||
<el-button type="success" @click="$router.push('/submit')">
|
<el-button type="success" @click="$router.push('/submit')">
|
||||||
<el-icon>
|
<el-icon>
|
||||||
@@ -97,17 +99,24 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 节点列表 -->
|
<!-- 节点列表 -->
|
||||||
<el-card class="nodes-card">
|
<el-card ref="nodesCardRef" class="nodes-card">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span>节点列表</span>
|
<span>
|
||||||
|
节点列表
|
||||||
|
<el-button type="text" :loading="loading" @click="refreshData" style="margin-left: 8px;">
|
||||||
|
<el-icon>
|
||||||
|
<Refresh />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
<el-tag :type="loading ? 'info' : 'success'">
|
<el-tag :type="loading ? 'info' : 'success'">
|
||||||
{{ loading ? '加载中...' : `共 ${pagination.total} 个节点` }}
|
{{ loading ? '加载中...' : `共 ${pagination.total} 个节点` }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-table :data="nodes" v-loading="loading" stripe style="width: 100%" row-key="id">
|
<el-table ref="tableRef" :data="nodes" v-loading="loading" stripe style="width: 100%" row-key="id">
|
||||||
<!-- 展开列 -->
|
<!-- 展开列 -->
|
||||||
<el-table-column type="expand" width="50">
|
<el-table-column type="expand" width="50">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -151,7 +160,7 @@
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div style="display: flex; flex-direction: column; gap: 1px; align-items: flex-start;">
|
<div style="display: flex; flex-direction: column; gap: 1px; align-items: flex-start;">
|
||||||
<el-tag v-if="row.version" size="small" style="font-size: 11px; padding: 1px 4px;">{{ row.version
|
<el-tag v-if="row.version" size="small" style="font-size: 11px; padding: 1px 4px;">{{ row.version
|
||||||
}}</el-tag>
|
}}</el-tag>
|
||||||
<span v-else class="text-muted" style="font-size: 11px;">未知</span>
|
<span v-else class="text-muted" style="font-size: 11px;">未知</span>
|
||||||
<el-tag :type="row.allow_relay ? 'success' : 'info'" size="small"
|
<el-tag :type="row.allow_relay ? 'success' : 'info'" size="small"
|
||||||
style="font-size: 9px; padding: 1px 3px;">
|
style="font-size: 9px; padding: 1px 3px;">
|
||||||
@@ -176,6 +185,18 @@
|
|||||||
<span class="description">{{ row.description || '暂无描述' }}</span>
|
<span class="description">{{ row.description || '暂无描述' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<!-- 新增:标签展示 -->
|
||||||
|
<el-table-column label="标签" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="tags-list">
|
||||||
|
<el-tag v-for="(tag, idx) in row.tags" :key="tag + idx" size="small" class="tag-chip"
|
||||||
|
:style="getTagStyle(tag)" style="margin: 2px 6px 2px 0;">
|
||||||
|
{{ tag }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="!row.tags || row.tags.length === 0" class="text-muted">无</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -223,6 +244,16 @@
|
|||||||
<el-descriptions-item label="创建时间">{{ formatDate(selectedNode.created_at) }}</el-descriptions-item>
|
<el-descriptions-item label="创建时间">{{ formatDate(selectedNode.created_at) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="更新时间">{{ formatDate(selectedNode.updated_at) }}</el-descriptions-item>
|
<el-descriptions-item label="更新时间">{{ formatDate(selectedNode.updated_at) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="描述" :span="2">{{ selectedNode.description || '暂无描述' }}</el-descriptions-item>
|
<el-descriptions-item label="描述" :span="2">{{ selectedNode.description || '暂无描述' }}</el-descriptions-item>
|
||||||
|
<!-- 新增:标签 -->
|
||||||
|
<el-descriptions-item label="标签" :span="2">
|
||||||
|
<div class="tags-list">
|
||||||
|
<el-tag v-for="(tag, idx) in selectedNode.tags" :key="tag + idx" size="small" class="tag-chip"
|
||||||
|
style="margin: 2px 6px 2px 0;">
|
||||||
|
{{ tag }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="!selectedNode.tags || selectedNode.tags.length === 0" class="text-muted">无</span>
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<!-- 健康状态统计 -->
|
<!-- 健康状态统计 -->
|
||||||
@@ -261,7 +292,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, computed } from 'vue'
|
import { ref, reactive, onMounted, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { nodeApi } from '../api'
|
import { nodeApi } from '../api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@@ -276,6 +307,7 @@ import {
|
|||||||
Refresh,
|
Refresh,
|
||||||
Plus
|
Plus
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
|
import { getTagStyle } from '../utils/tagColor'
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -283,11 +315,18 @@ const nodes = ref([])
|
|||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const statusFilter = ref('')
|
const statusFilter = ref('')
|
||||||
const protocolFilter = ref('')
|
const protocolFilter = ref('')
|
||||||
|
const selectedTags = ref([])
|
||||||
|
const allTags = ref([])
|
||||||
const detailDialogVisible = ref(false)
|
const detailDialogVisible = ref(false)
|
||||||
const selectedNode = ref(null)
|
const selectedNode = ref(null)
|
||||||
const healthStats = ref(null)
|
const healthStats = ref(null)
|
||||||
const expandedRows = ref([])
|
const expandedRows = ref([])
|
||||||
const apiUrl = ref(window.location.href)
|
const apiUrl = ref(window.location.href)
|
||||||
|
const tableRef = ref(null)
|
||||||
|
const nodesCardRef = ref(null)
|
||||||
|
|
||||||
|
// 请求取消控制(避免重复请求覆盖)
|
||||||
|
let fetchController = null
|
||||||
|
|
||||||
// 分页数据
|
// 分页数据
|
||||||
const pagination = reactive({
|
const pagination = reactive({
|
||||||
@@ -309,6 +348,17 @@ const averageUptime = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
|
const fetchTags = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await nodeApi.getAllTags()
|
||||||
|
if (resp.success && Array.isArray(resp.data)) {
|
||||||
|
allTags.value = resp.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取标签列表失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchNodes = async (with_loading = true) => {
|
const fetchNodes = async (with_loading = true) => {
|
||||||
try {
|
try {
|
||||||
if (with_loading) {
|
if (with_loading) {
|
||||||
@@ -328,13 +378,26 @@ const fetchNodes = async (with_loading = true) => {
|
|||||||
if (protocolFilter.value) {
|
if (protocolFilter.value) {
|
||||||
params.protocol = protocolFilter.value
|
params.protocol = protocolFilter.value
|
||||||
}
|
}
|
||||||
|
if (selectedTags.value && selectedTags.value.length > 0) {
|
||||||
|
params.tags = selectedTags.value
|
||||||
|
}
|
||||||
|
|
||||||
const response = await nodeApi.getNodes(params)
|
// 取消上一请求,创建新的请求控制器
|
||||||
|
if (fetchController) {
|
||||||
|
try { fetchController.abort() } catch (_) { }
|
||||||
|
}
|
||||||
|
fetchController = new AbortController()
|
||||||
|
|
||||||
|
const response = await nodeApi.getNodes(params, { signal: fetchController.signal })
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
nodes.value = response.data.items
|
nodes.value = response.data.items
|
||||||
pagination.total = response.data.total
|
pagination.total = response.data.total
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.name === 'CanceledError' || error.name === 'AbortError') {
|
||||||
|
// 被取消的旧请求,忽略
|
||||||
|
return
|
||||||
|
}
|
||||||
console.error('获取节点列表失败:', error)
|
console.error('获取节点列表失败:', error)
|
||||||
ElMessage.error('获取节点列表失败')
|
ElMessage.error('获取节点列表失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -345,6 +408,7 @@ const fetchNodes = async (with_loading = true) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
|
pagination.page = 1
|
||||||
fetchNodes()
|
fetchNodes()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,12 +472,69 @@ const copyAddress = (address) => {
|
|||||||
|
|
||||||
// 生命周期
|
// 生命周期
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
fetchTags()
|
||||||
fetchNodes()
|
fetchNodes()
|
||||||
|
|
||||||
// 设置定时刷新
|
// 设置定时刷新
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
fetchNodes(false)
|
fetchNodes(false)
|
||||||
}, 3000) // 每30秒刷新一次
|
}, 30000) // 每30秒刷新一次
|
||||||
|
})
|
||||||
|
|
||||||
|
// 智能滚动处理:纵向滚动时页面整体滚动,横向滚动时表格内部滚动
|
||||||
|
let wheelHandler = null
|
||||||
|
let wheelTargets = []
|
||||||
|
|
||||||
|
const detachWheelHandlers = () => {
|
||||||
|
if (wheelTargets && wheelTargets.length) {
|
||||||
|
wheelTargets.forEach((el) => {
|
||||||
|
try { el.removeEventListener('wheel', wheelHandler, { capture: true }) } catch (_) { }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wheelTargets = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachWheelHandler = () => {
|
||||||
|
const tableEl = tableRef.value?.$el
|
||||||
|
const body = tableEl ? tableEl.querySelector('.el-table__body-wrapper') : null
|
||||||
|
if (!body) return
|
||||||
|
|
||||||
|
detachWheelHandlers()
|
||||||
|
const wrap = body.querySelector('.el-scrollbar__wrap') || body
|
||||||
|
|
||||||
|
wheelHandler = (e) => {
|
||||||
|
const deltaX = e.deltaX
|
||||||
|
const deltaY = e.deltaY
|
||||||
|
|
||||||
|
// 如果是横向滚动(Shift + 滚轮 或 触摸板横向滑动)
|
||||||
|
if (Math.abs(deltaX) > Math.abs(deltaY) || e.shiftKey) {
|
||||||
|
// 允许表格内部横向滚动,不阻止默认行为
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是纵向滚动,阻止表格内部滚动,让页面整体滚动
|
||||||
|
if (deltaY) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const scroller = document.scrollingElement || document.documentElement
|
||||||
|
scroller.scrollTop += deltaY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.addEventListener('wheel', wheelHandler, { passive: false, capture: true })
|
||||||
|
wheelTargets.push(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(attachWheelHandler)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(nodes, () => {
|
||||||
|
nextTick(attachWheelHandler)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
detachWheelHandlers()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -570,4 +691,28 @@ onMounted(() => {
|
|||||||
background-color: #fafafa;
|
background-color: #fafafa;
|
||||||
border-top: 1px solid #ebeef5;
|
border-top: 1px solid #ebeef5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-option {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__body-wrapper) {
|
||||||
|
overflow-x: auto !important;
|
||||||
|
overflow-y: hidden !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-card__body) {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__body-wrapper .el-scrollbar__wrap) {
|
||||||
|
overflow-x: auto !important;
|
||||||
|
overflow-y: hidden !important;
|
||||||
|
height: auto !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:11030',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/health': {
|
'/health': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:11030',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::ops::{Div, Mul};
|
use std::ops::{Div, Mul};
|
||||||
|
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ColumnTrait, Condition, EntityTrait, IntoActiveModel, ModelTrait, Order, PaginatorTrait,
|
ColumnTrait, Condition, EntityTrait, IntoActiveModel, ModelTrait, Order, PaginatorTrait,
|
||||||
@@ -16,6 +16,7 @@ use crate::api::{
|
|||||||
use crate::db::entity::{self, health_records, shared_nodes};
|
use crate::db::entity::{self, health_records, shared_nodes};
|
||||||
use crate::db::{operations::*, Db};
|
use crate::db::{operations::*, Db};
|
||||||
use crate::health_checker_manager::HealthCheckerManager;
|
use crate::health_checker_manager::HealthCheckerManager;
|
||||||
|
use axum_extra::extract::Query;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -60,6 +61,35 @@ pub async fn get_nodes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标签过滤(支持单标签与多标签 OR)
|
||||||
|
let mut filtered_ids: Option<Vec<i32>> = None;
|
||||||
|
if !filters.tags.is_empty() {
|
||||||
|
let ids_any =
|
||||||
|
NodeOperations::filter_node_ids_by_tags_any(&app_state.db, &filters.tags).await?;
|
||||||
|
filtered_ids = match filtered_ids {
|
||||||
|
Some(mut existing) => {
|
||||||
|
// 合并去重
|
||||||
|
existing.extend(ids_any);
|
||||||
|
existing.sort();
|
||||||
|
existing.dedup();
|
||||||
|
Some(existing)
|
||||||
|
}
|
||||||
|
None => Some(ids_any),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(ids) = filtered_ids {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(Json(ApiResponse::success(PaginatedResponse {
|
||||||
|
items: vec![],
|
||||||
|
total: 0,
|
||||||
|
page,
|
||||||
|
per_page,
|
||||||
|
total_pages: 0,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
query = query.filter(entity::shared_nodes::Column::Id.is_in(ids));
|
||||||
|
}
|
||||||
|
|
||||||
let total = query.clone().count(app_state.db.orm_db()).await?;
|
let total = query.clone().count(app_state.db.orm_db()).await?;
|
||||||
let nodes = query
|
let nodes = query
|
||||||
.order_by_asc(entity::shared_nodes::Column::Id)
|
.order_by_asc(entity::shared_nodes::Column::Id)
|
||||||
@@ -71,6 +101,13 @@ pub async fn get_nodes(
|
|||||||
let mut node_responses: Vec<NodeResponse> = nodes.into_iter().map(NodeResponse::from).collect();
|
let mut node_responses: Vec<NodeResponse> = nodes.into_iter().map(NodeResponse::from).collect();
|
||||||
let total_pages = total.div_ceil(per_page as u64);
|
let total_pages = total.div_ceil(per_page as u64);
|
||||||
|
|
||||||
|
// 补充标签
|
||||||
|
let ids: Vec<i32> = node_responses.iter().map(|n| n.id).collect();
|
||||||
|
let tags_map = NodeOperations::get_nodes_tags_map(&app_state.db, &ids).await?;
|
||||||
|
for n in &mut node_responses {
|
||||||
|
n.tags = tags_map.get(&n.id).cloned().unwrap_or_default();
|
||||||
|
}
|
||||||
|
|
||||||
// 为每个节点添加健康状态信息
|
// 为每个节点添加健康状态信息
|
||||||
for node_response in &mut node_responses {
|
for node_response in &mut node_responses {
|
||||||
if let Some(mut health_record) = app_state
|
if let Some(mut health_record) = app_state
|
||||||
@@ -99,7 +136,6 @@ pub async fn get_nodes(
|
|||||||
|
|
||||||
// remove sensitive information
|
// remove sensitive information
|
||||||
node_responses.iter_mut().for_each(|node| {
|
node_responses.iter_mut().for_each(|node| {
|
||||||
tracing::info!("node: {:?}", node);
|
|
||||||
node.network_name = None;
|
node.network_name = None;
|
||||||
node.network_secret = None;
|
node.network_secret = None;
|
||||||
|
|
||||||
@@ -161,7 +197,10 @@ pub async fn get_node(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiError::NotFound(format!("Node with id {} not found", id)))?;
|
.ok_or_else(|| ApiError::NotFound(format!("Node with id {} not found", id)))?;
|
||||||
|
|
||||||
Ok(Json(ApiResponse::success(NodeResponse::from(node))))
|
let mut resp = NodeResponse::from(node);
|
||||||
|
resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(resp)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_node_health(
|
pub async fn get_node_health(
|
||||||
@@ -325,6 +364,39 @@ pub async fn admin_get_nodes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标签过滤(支持单标签与多标签 OR)
|
||||||
|
let mut filtered_ids: Option<Vec<i32>> = None;
|
||||||
|
if let Some(tag) = filters.tag {
|
||||||
|
let ids = NodeOperations::filter_node_ids_by_tag(&app_state.db, &tag).await?;
|
||||||
|
filtered_ids = Some(ids);
|
||||||
|
}
|
||||||
|
if let Some(tags) = filters.tags {
|
||||||
|
if !tags.is_empty() {
|
||||||
|
let ids_any = NodeOperations::filter_node_ids_by_tags_any(&app_state.db, &tags).await?;
|
||||||
|
filtered_ids = match filtered_ids {
|
||||||
|
Some(mut existing) => {
|
||||||
|
existing.extend(ids_any);
|
||||||
|
existing.sort();
|
||||||
|
existing.dedup();
|
||||||
|
Some(existing)
|
||||||
|
}
|
||||||
|
None => Some(ids_any),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ids) = filtered_ids {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(Json(ApiResponse::success(PaginatedResponse {
|
||||||
|
items: vec![],
|
||||||
|
total: 0,
|
||||||
|
page,
|
||||||
|
per_page,
|
||||||
|
total_pages: 0,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
query = query.filter(entity::shared_nodes::Column::Id.is_in(ids));
|
||||||
|
}
|
||||||
|
|
||||||
let total = query.clone().count(app_state.db.orm_db()).await?;
|
let total = query.clone().count(app_state.db.orm_db()).await?;
|
||||||
|
|
||||||
let nodes = query
|
let nodes = query
|
||||||
@@ -334,7 +406,14 @@ pub async fn admin_get_nodes(
|
|||||||
.all(app_state.db.orm_db())
|
.all(app_state.db.orm_db())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let node_responses: Vec<NodeResponse> = nodes.into_iter().map(NodeResponse::from).collect();
|
let mut node_responses: Vec<NodeResponse> = nodes.into_iter().map(NodeResponse::from).collect();
|
||||||
|
|
||||||
|
// 补充标签
|
||||||
|
let ids: Vec<i32> = node_responses.iter().map(|n| n.id).collect();
|
||||||
|
let tags_map = NodeOperations::get_nodes_tags_map(&app_state.db, &ids).await?;
|
||||||
|
for n in &mut node_responses {
|
||||||
|
n.tags = tags_map.get(&n.id).cloned().unwrap_or_default();
|
||||||
|
}
|
||||||
|
|
||||||
let total_pages = (total as f64 / per_page as f64).ceil() as u32;
|
let total_pages = (total as f64 / per_page as f64).ceil() as u32;
|
||||||
|
|
||||||
@@ -366,7 +445,10 @@ pub async fn admin_approve_node(
|
|||||||
.exec(app_state.db.orm_db())
|
.exec(app_state.db.orm_db())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(ApiResponse::success(NodeResponse::from(updated_node))))
|
let mut resp = NodeResponse::from(updated_node);
|
||||||
|
resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(resp)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn admin_update_node(
|
pub async fn admin_update_node(
|
||||||
@@ -432,7 +514,15 @@ pub async fn admin_update_node(
|
|||||||
.exec(app_state.db.orm_db())
|
.exec(app_state.db.orm_db())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(ApiResponse::success(NodeResponse::from(updated_node))))
|
// 更新标签
|
||||||
|
if let Some(tags) = request.tags {
|
||||||
|
NodeOperations::set_node_tags(&app_state.db, updated_node.id, tags).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut resp = NodeResponse::from(updated_node);
|
||||||
|
resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(resp)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn admin_revoke_approval(
|
pub async fn admin_revoke_approval(
|
||||||
@@ -454,7 +544,10 @@ pub async fn admin_revoke_approval(
|
|||||||
.exec(app_state.db.orm_db())
|
.exec(app_state.db.orm_db())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(ApiResponse::success(NodeResponse::from(updated_node))))
|
let mut resp = NodeResponse::from(updated_node);
|
||||||
|
resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::success(resp)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn admin_delete_node(
|
pub async fn admin_delete_node(
|
||||||
@@ -505,3 +598,10 @@ fn verify_admin_token(headers: &HeaderMap) -> ApiResult<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_tags(
|
||||||
|
State(app_state): State<AppState>,
|
||||||
|
) -> ApiResult<Json<ApiResponse<Vec<String>>>> {
|
||||||
|
let tags = NodeOperations::get_all_tags(&app_state.db).await?;
|
||||||
|
Ok(Json(ApiResponse::success(tags)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -162,6 +162,9 @@ pub struct UpdateNodeRequest {
|
|||||||
|
|
||||||
#[validate(email)]
|
#[validate(email)]
|
||||||
pub mail: Option<String>,
|
pub mail: Option<String>,
|
||||||
|
|
||||||
|
// 标签字段(仅管理员可用)
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -198,6 +201,7 @@ pub struct NodeResponse {
|
|||||||
pub qq_number: Option<String>,
|
pub qq_number: Option<String>,
|
||||||
pub wechat: Option<String>,
|
pub wechat: Option<String>,
|
||||||
pub mail: Option<String>,
|
pub mail: Option<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<entity::shared_nodes::Model> for NodeResponse {
|
impl From<entity::shared_nodes::Model> for NodeResponse {
|
||||||
@@ -247,6 +251,7 @@ impl From<entity::shared_nodes::Model> for NodeResponse {
|
|||||||
} else {
|
} else {
|
||||||
Some(node.mail)
|
Some(node.mail)
|
||||||
},
|
},
|
||||||
|
tags: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,6 +286,8 @@ pub struct NodeFilterParams {
|
|||||||
pub is_active: Option<bool>,
|
pub is_active: Option<bool>,
|
||||||
pub protocol: Option<String>,
|
pub protocol: Option<String>,
|
||||||
pub search: Option<String>,
|
pub search: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -313,4 +320,6 @@ pub struct AdminNodeFilterParams {
|
|||||||
pub is_approved: Option<bool>,
|
pub is_approved: Option<bool>,
|
||||||
pub protocol: Option<String>,
|
pub protocol: Option<String>,
|
||||||
pub search: Option<String>,
|
pub search: Option<String>,
|
||||||
|
pub tag: Option<String>,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use tower_http::cors::CorsLayer;
|
|||||||
use super::handlers::AppState;
|
use super::handlers::AppState;
|
||||||
use super::handlers::{
|
use super::handlers::{
|
||||||
admin_approve_node, admin_delete_node, admin_get_nodes, admin_login, admin_revoke_approval,
|
admin_approve_node, admin_delete_node, admin_get_nodes, admin_login, admin_revoke_approval,
|
||||||
admin_update_node, admin_verify_token, create_node, get_node, get_node_health,
|
admin_update_node, admin_verify_token, create_node, get_all_tags, get_node, get_node_health,
|
||||||
get_node_health_stats, get_nodes, health_check,
|
get_node_health_stats, get_nodes, health_check,
|
||||||
};
|
};
|
||||||
use crate::api::{get_node_connect_url, test_connection};
|
use crate::api::{get_node_connect_url, test_connection};
|
||||||
@@ -38,6 +38,7 @@ pub fn create_routes() -> Router<AppState> {
|
|||||||
.route("/node/{id}", get(get_node_connect_url))
|
.route("/node/{id}", get(get_node_connect_url))
|
||||||
.route("/health", get(health_check))
|
.route("/health", get(health_check))
|
||||||
.route("/api/nodes", get(get_nodes).post(create_node))
|
.route("/api/nodes", get(get_nodes).post(create_node))
|
||||||
|
.route("/api/tags", get(get_all_tags))
|
||||||
.route("/api/test_connection", post(test_connection))
|
.route("/api/test_connection", post(test_connection))
|
||||||
.route("/api/nodes/{id}/health", get(get_node_health))
|
.route("/api/nodes/{id}/health", get(get_node_health))
|
||||||
.route("/api/nodes/{id}/health/stats", get(get_node_health_stats))
|
.route("/api/nodes/{id}/health/stats", get(get_node_health_stats))
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ use std::env;
|
|||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use easytier::common::config::{ConsoleLoggerConfig, FileLoggerConfig, LoggingConfig};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
@@ -32,12 +34,6 @@ pub struct HealthCheckConfig {
|
|||||||
pub max_retries: u32,
|
pub max_retries: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct LoggingConfig {
|
|
||||||
pub level: String,
|
|
||||||
pub rust_log: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CorsConfig {
|
pub struct CorsConfig {
|
||||||
pub allowed_origins: Vec<String>,
|
pub allowed_origins: Vec<String>,
|
||||||
@@ -100,8 +96,14 @@ impl AppConfig {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let logging_config = LoggingConfig {
|
let logging_config = LoggingConfig {
|
||||||
level: env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string()),
|
file_logger: Some(FileLoggerConfig {
|
||||||
rust_log: env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
|
level: Some(env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string())),
|
||||||
|
file: Some("easytier-uptime.log".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
console_logger: Some(ConsoleLoggerConfig {
|
||||||
|
level: Some(env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string())),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
let cors_config = CorsConfig {
|
let cors_config = CorsConfig {
|
||||||
@@ -161,8 +163,14 @@ impl AppConfig {
|
|||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
},
|
},
|
||||||
logging: LoggingConfig {
|
logging: LoggingConfig {
|
||||||
level: "info".to_string(),
|
file_logger: Some(FileLoggerConfig {
|
||||||
rust_log: "info".to_string(),
|
level: Some("info".to_string()),
|
||||||
|
file: Some("easytier-uptime.log".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
console_logger: Some(ConsoleLoggerConfig {
|
||||||
|
level: Some("info".to_string()),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
cors: CorsConfig {
|
cors: CorsConfig {
|
||||||
allowed_origins: vec![
|
allowed_origins: vec![
|
||||||
|
|||||||
@@ -3,4 +3,5 @@
|
|||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
|
|
||||||
pub mod health_records;
|
pub mod health_records;
|
||||||
|
pub mod node_tags;
|
||||||
pub mod shared_nodes;
|
pub mod shared_nodes;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//! `SeaORM` Entity for node tags
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "node_tags")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub node_id: i32,
|
||||||
|
pub tag: String,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::shared_nodes::Entity",
|
||||||
|
from = "Column::NodeId",
|
||||||
|
to = "super::shared_nodes::Column::Id"
|
||||||
|
)]
|
||||||
|
SharedNodes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::shared_nodes::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::SharedNodes.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
|
||||||
|
|
||||||
pub use super::health_records::Entity as HealthRecords;
|
pub use super::health_records::Entity as HealthRecords;
|
||||||
|
pub use super::node_tags::Entity as NodeTags;
|
||||||
pub use super::shared_nodes::Entity as SharedNodes;
|
pub use super::shared_nodes::Entity as SharedNodes;
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ pub struct Model {
|
|||||||
pub enum Relation {
|
pub enum Relation {
|
||||||
#[sea_orm(has_many = "super::health_records::Entity")]
|
#[sea_orm(has_many = "super::health_records::Entity")]
|
||||||
HealthRecords,
|
HealthRecords,
|
||||||
|
// add relation to node_tags
|
||||||
|
#[sea_orm(has_many = "super::node_tags::Entity")]
|
||||||
|
NodeTags,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Related<super::health_records::Entity> for Entity {
|
impl Related<super::health_records::Entity> for Entity {
|
||||||
@@ -41,4 +44,10 @@ impl Related<super::health_records::Entity> for Entity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Related<super::node_tags::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::NodeTags.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use crate::db::Db;
|
|||||||
use crate::db::HealthStats;
|
use crate::db::HealthStats;
|
||||||
use crate::db::HealthStatus;
|
use crate::db::HealthStatus;
|
||||||
use sea_orm::*;
|
use sea_orm::*;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
/// 节点管理操作
|
/// 节点管理操作
|
||||||
pub struct NodeOperations;
|
pub struct NodeOperations;
|
||||||
@@ -229,6 +230,128 @@ impl HealthOperations {
|
|||||||
Ok(result.rows_affected)
|
Ok(result.rows_affected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
impl NodeOperations {
|
||||||
|
/// 获取节点的全部标签
|
||||||
|
pub async fn get_node_tags(db: &Db, node_id: i32) -> Result<Vec<String>, DbErr> {
|
||||||
|
let tags = node_tags::Entity::find()
|
||||||
|
.filter(node_tags::Column::NodeId.eq(node_id))
|
||||||
|
.all(db.orm_db())
|
||||||
|
.await?;
|
||||||
|
Ok(tags.into_iter().map(|m| m.tag).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量获取节点的标签映射
|
||||||
|
pub async fn get_nodes_tags_map(
|
||||||
|
db: &Db,
|
||||||
|
node_ids: &[i32],
|
||||||
|
) -> Result<HashMap<i32, Vec<String>>, DbErr> {
|
||||||
|
if node_ids.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
let tags = node_tags::Entity::find()
|
||||||
|
.filter(node_tags::Column::NodeId.is_in(node_ids.to_vec()))
|
||||||
|
.order_by_asc(node_tags::Column::NodeId)
|
||||||
|
.all(db.orm_db())
|
||||||
|
.await?;
|
||||||
|
let mut map: HashMap<i32, Vec<String>> = HashMap::new();
|
||||||
|
for t in tags {
|
||||||
|
map.entry(t.node_id).or_default().push(t.tag);
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用标签过滤节点(返回节点ID)
|
||||||
|
pub async fn filter_node_ids_by_tag(db: &Db, tag: &str) -> Result<Vec<i32>, DbErr> {
|
||||||
|
let tagged = node_tags::Entity::find()
|
||||||
|
.filter(node_tags::Column::Tag.eq(tag))
|
||||||
|
.all(db.orm_db())
|
||||||
|
.await?;
|
||||||
|
Ok(tagged.into_iter().map(|m| m.node_id).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置节点标签(替换为给定集合)
|
||||||
|
pub async fn set_node_tags(db: &Db, node_id: i32, tags: Vec<String>) -> Result<(), DbErr> {
|
||||||
|
// 去重与清理空白
|
||||||
|
let mut set: HashSet<String> = HashSet::new();
|
||||||
|
for tag in tags.into_iter() {
|
||||||
|
let trimmed = tag.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
set.insert(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取出当前标签
|
||||||
|
let existing = node_tags::Entity::find()
|
||||||
|
.filter(node_tags::Column::NodeId.eq(node_id))
|
||||||
|
.all(db.orm_db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let existing_set: HashSet<String> = existing.iter().map(|m| m.tag.clone()).collect();
|
||||||
|
|
||||||
|
// 需要删除的
|
||||||
|
let to_delete: Vec<i32> = existing
|
||||||
|
.iter()
|
||||||
|
.filter(|m| !set.contains(&m.tag))
|
||||||
|
.map(|m| m.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 需要新增的
|
||||||
|
let to_insert: Vec<String> = set
|
||||||
|
.into_iter()
|
||||||
|
.filter(|t| !existing_set.contains(t))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 执行删除
|
||||||
|
if !to_delete.is_empty() {
|
||||||
|
node_tags::Entity::delete_many()
|
||||||
|
.filter(node_tags::Column::Id.is_in(to_delete))
|
||||||
|
.exec(db.orm_db())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行新增
|
||||||
|
for t in to_insert {
|
||||||
|
let now = chrono::Utc::now().fixed_offset();
|
||||||
|
let am = node_tags::ActiveModel {
|
||||||
|
id: NotSet,
|
||||||
|
node_id: Set(node_id),
|
||||||
|
tag: Set(t),
|
||||||
|
created_at: Set(now),
|
||||||
|
};
|
||||||
|
node_tags::Entity::insert(am).exec(db.orm_db()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:获取所有唯一标签(按字母排序)
|
||||||
|
pub async fn get_all_tags(db: &Db) -> Result<Vec<String>, DbErr> {
|
||||||
|
let rows = node_tags::Entity::find().all(db.orm_db()).await?;
|
||||||
|
let mut set: HashSet<String> = HashSet::new();
|
||||||
|
for r in rows {
|
||||||
|
set.insert(r.tag);
|
||||||
|
}
|
||||||
|
let mut list: Vec<String> = set.into_iter().collect();
|
||||||
|
list.sort();
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:使用多标签(OR 语义)过滤节点,返回匹配的节点ID
|
||||||
|
pub async fn filter_node_ids_by_tags_any(db: &Db, tags: &[String]) -> Result<Vec<i32>, DbErr> {
|
||||||
|
if tags.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let tagged = node_tags::Entity::find()
|
||||||
|
.filter(node_tags::Column::Tag.is_in(tags.to_vec()))
|
||||||
|
.all(db.orm_db())
|
||||||
|
.await?;
|
||||||
|
let mut set: HashSet<i32> = HashSet::new();
|
||||||
|
for m in tagged {
|
||||||
|
set.insert(m.node_id);
|
||||||
|
}
|
||||||
|
Ok(set.into_iter().collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use api::routes::create_routes;
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use config::AppConfig;
|
use config::AppConfig;
|
||||||
use db::{operations::NodeOperations, Db};
|
use db::{operations::NodeOperations, Db};
|
||||||
|
use easytier::utils::init_logger;
|
||||||
use health_checker::HealthChecker;
|
use health_checker::HealthChecker;
|
||||||
use health_checker_manager::HealthCheckerManager;
|
use health_checker_manager::HealthCheckerManager;
|
||||||
use std::env;
|
use std::env;
|
||||||
@@ -36,18 +37,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let config = AppConfig::default();
|
let config = AppConfig::default();
|
||||||
|
|
||||||
// 初始化日志
|
// 初始化日志
|
||||||
tracing_subscriber::fmt()
|
let _ = init_logger(&config.logging, false);
|
||||||
.with_max_level(match config.logging.level.as_str() {
|
|
||||||
"debug" => tracing::Level::DEBUG,
|
|
||||||
"info" => tracing::Level::INFO,
|
|
||||||
"warn" => tracing::Level::WARN,
|
|
||||||
"error" => tracing::Level::ERROR,
|
|
||||||
_ => tracing::Level::INFO,
|
|
||||||
})
|
|
||||||
.with_target(false)
|
|
||||||
.with_thread_ids(true)
|
|
||||||
.with_env_filter(EnvFilter::new("easytier_uptime"))
|
|
||||||
.init();
|
|
||||||
|
|
||||||
// 解析命令行参数
|
// 解析命令行参数
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
use sea_orm_migration::{prelude::*, schema::*};
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum NodeTags {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
NodeId,
|
||||||
|
Tag,
|
||||||
|
CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum SharedNodes {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// 创建 node_tags 表
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(NodeTags::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(pk_auto(NodeTags::Id).not_null())
|
||||||
|
.col(integer(NodeTags::NodeId).not_null())
|
||||||
|
.col(string(NodeTags::Tag).not_null())
|
||||||
|
.col(
|
||||||
|
timestamp_with_time_zone(NodeTags::CreatedAt)
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_node_tags_node")
|
||||||
|
.from(NodeTags::Table, NodeTags::NodeId)
|
||||||
|
.to(SharedNodes::Table, SharedNodes::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade)
|
||||||
|
.on_update(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 索引:NodeId
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_node_tags_node")
|
||||||
|
.table(NodeTags::Table)
|
||||||
|
.col(NodeTags::NodeId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 索引:Tag
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_node_tags_tag")
|
||||||
|
.table(NodeTags::Table)
|
||||||
|
.col(NodeTags::Tag)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 唯一索引:每个节点的标签唯一
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("uniq_node_tag_per_node")
|
||||||
|
.table(NodeTags::Table)
|
||||||
|
.col(NodeTags::NodeId)
|
||||||
|
.col(NodeTags::Tag)
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// 先删除索引
|
||||||
|
manager
|
||||||
|
.drop_index(
|
||||||
|
Index::drop()
|
||||||
|
.name("idx_node_tags_node")
|
||||||
|
.table(NodeTags::Table)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.drop_index(
|
||||||
|
Index::drop()
|
||||||
|
.name("idx_node_tags_tag")
|
||||||
|
.table(NodeTags::Table)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.drop_index(
|
||||||
|
Index::drop()
|
||||||
|
.name("uniq_node_tag_per_node")
|
||||||
|
.table(NodeTags::Table)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(NodeTags::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
use sea_orm_migration::prelude::*;
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
mod m20250101_000001_create_tables;
|
mod m20250101_000001_create_tables;
|
||||||
|
mod m20250101_000002_create_node_tags;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl MigratorTrait for Migrator {
|
impl MigratorTrait for Migrator {
|
||||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||||
vec![Box::new(m20250101_000001_create_tables::Migration)]
|
vec![
|
||||||
|
Box::new(m20250101_000001_create_tables::Migration),
|
||||||
|
Box::new(m20250101_000002_create_node_tags::Migration),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,9 +321,9 @@ pub struct ConsoleLoggerConfig {
|
|||||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, derive_builder::Builder)]
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, derive_builder::Builder)]
|
||||||
pub struct LoggingConfig {
|
pub struct LoggingConfig {
|
||||||
#[builder(setter(into, strip_option), default = None)]
|
#[builder(setter(into, strip_option), default = None)]
|
||||||
file_logger: Option<FileLoggerConfig>,
|
pub file_logger: Option<FileLoggerConfig>,
|
||||||
#[builder(setter(into, strip_option), default = None)]
|
#[builder(setter(into, strip_option), default = None)]
|
||||||
console_logger: Option<ConsoleLoggerConfig>,
|
pub console_logger: Option<ConsoleLoggerConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LoggingConfigLoader for &LoggingConfig {
|
impl LoggingConfigLoader for &LoggingConfig {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use tokio::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
common::{dns::socket_addrs, join_joinset_background, PeerId},
|
common::{dns::socket_addrs, join_joinset_background, PeerId},
|
||||||
peers::peer_conn::PeerConnId,
|
peers::{peer_conn::PeerConnId, peer_map::PeerMap},
|
||||||
proto::{
|
proto::{
|
||||||
api::instance::{
|
api::instance::{
|
||||||
Connector, ConnectorManageRpc, ConnectorStatus, ListConnectorRequest,
|
Connector, ConnectorManageRpc, ConnectorStatus, ListConnectorRequest,
|
||||||
@@ -194,16 +194,22 @@ impl ManualConnectorManager {
|
|||||||
tracing::warn!("peer manager is gone, exit");
|
tracing::warn!("peer manager is gone, exit");
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
for x in pm.get_peer_map().get_alive_conns().iter().map(|x| {
|
let fill_alive_urls_with_peer_map = |peer_map: &PeerMap| {
|
||||||
x.tunnel
|
for x in peer_map.get_alive_conns().iter().map(|x| {
|
||||||
.clone()
|
x.tunnel
|
||||||
.unwrap_or_default()
|
.clone()
|
||||||
.remote_addr
|
.unwrap_or_default()
|
||||||
.unwrap_or_default()
|
.remote_addr
|
||||||
.to_string()
|
.unwrap_or_default()
|
||||||
}) {
|
.to_string()
|
||||||
data.alive_conn_urls.insert(x);
|
}) {
|
||||||
}
|
data.alive_conn_urls.insert(x);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fill_alive_urls_with_peer_map(&pm.get_peer_map());
|
||||||
|
fill_alive_urls_with_peer_map(&pm.get_foreign_network_client().get_peer_map());
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Err(RecvError::Closed) => {
|
Err(RecvError::Closed) => {
|
||||||
|
|||||||
@@ -2306,7 +2306,7 @@ impl RouteSessionManager {
|
|||||||
service_impl.update_foreign_network_owner_map();
|
service_impl.update_foreign_network_owner_map();
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
"handling sync_route_info rpc: from_peer_id: {:?}, is_initiator: {:?}, peer_infos: {:?}, conn_bitmap: {:?}, synced_route_info: {:?} session: {:?}, new_route_table: {:?}",
|
"handling sync_route_info rpc: from_peer_id: {:?}, is_initiator: {:?}, peer_infos: {:?}, conn_bitmap: {:?}, synced_route_info: {:?} session: {:?}, new_route_table: {:?}",
|
||||||
from_peer_id, is_initiator, peer_infos, conn_bitmap, service_impl.synced_route_info, session, service_impl.route_table);
|
from_peer_id, is_initiator, peer_infos, conn_bitmap, service_impl.synced_route_info, session, service_impl.route_table);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user