[easytier-uptime] support tag in node list (#1487)

This commit is contained in:
Sijie.Sun
2025-10-18 23:19:53 +08:00
committed by GitHub
parent cc8f35787e
commit f10b45a67c
25 changed files with 902 additions and 73 deletions
Generated
+39
View File
@@ -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"
+17
View File
@@ -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))
+18 -10
View File
@@ -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 {
+2 -12
View File
@@ -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),
]
} }
} }
+2 -2
View File
@@ -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 {
+17 -11
View File
@@ -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) => {
+1 -1
View File
@@ -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);