[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

39
Cargo.lock generated
View File

@@ -653,6 +653,31 @@ dependencies = [
"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]]
name = "axum-login"
version = "0.16.0"
@@ -2293,6 +2318,7 @@ dependencies = [
"anyhow",
"async-trait",
"axum 0.8.4",
"axum-extra",
"chrono",
"clap",
"dashmap",
@@ -7521,6 +7547,19 @@ dependencies = [
"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]]
name = "serde_json"
version = "1.0.125"

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

View File

@@ -15,6 +15,7 @@ uuid = { version = "1.0", features = ["v4", "serde"] }
# Axum web framework
axum = { version = "0.8.4", features = ["macros"] }
axum-extra = { version = "0.10", features = ["query"] }
tower-http = { version = "0.6", features = ["cors", "compression-full"] }
tower = "0.5"

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { healthApi } from './api'
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(() => {
checkHealth()
// 定期检查健康状态
@@ -89,8 +103,8 @@ onMounted(() => {
<h1 class="app-title">EasyTier Uptime</h1>
</div>
<el-menu :default-active="route.name" mode="horizontal" class="nav-menu"
@select="(key) => router.push(menuItems.find(item => item.name === key)?.path || '/')">
<el-menu :default-active="activeMenuIndex" mode="horizontal" class="nav-menu"
@select="handleMenuSelect">
<el-menu-item v-for="item in menuItems" :key="item.name" :index="item.name">
<el-icon>
<component :is="item.icon" />

View File

@@ -6,6 +6,18 @@ const api = axios.create({
timeout: 10000,
headers: {
'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
export const nodeApi = {
// 获取节点列表
async getNodes(params = {}) {
const response = await api.get('/api/nodes', { params })
// 获取节点列表(支持传入 AbortController.signal 用于取消)
async getNodes(params = {}, options = {}) {
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
},
@@ -149,6 +167,28 @@ export const adminApi = {
async updateNode(id, data) {
const response = await api.put(`/api/admin/nodes/${id}`, 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
}
}

View File

@@ -85,6 +85,15 @@
<div class="form-tip">详细描述有助于用户选择合适的节点</div>
</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">
<div class="contact-section">
@@ -238,6 +247,7 @@ const props = defineProps({
wechat: '',
qq_number: '',
mail: '',
tags: [],
agreed: false
})
},
@@ -264,6 +274,11 @@ const props = defineProps({
showCancel: {
type: Boolean,
default: false
},
// 新增:是否显示标签管理
showTags: {
type: Boolean,
default: false
}
})
@@ -353,6 +368,38 @@ const rules = {
},
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 = () => {
return {
const data = {
name: form.name || 'Test Node',
host: form.host,
port: form.port,
@@ -376,6 +423,11 @@ const buildDataFromForm = () => {
qq_number: form.qq_number || 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) {
formRef.value.resetFields()
}
// 重置标签
if (props.showTags) {
form.tags = []
}
testResult.value = null
emit('reset')
}

View File

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

View File

@@ -196,6 +196,17 @@
<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">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
@@ -228,8 +239,8 @@
<!-- 编辑节点对话框 -->
<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"
:show-connection-test="false" :show-agreement="false" :show-cancel="true" @submit="handleUpdateNode"
@cancel="editDialogVisible = false" @reset="resetEditForm" />
:show-connection-test="false" :show-agreement="false" :show-cancel="true" :show-tags="true"
@submit="handleUpdateNode" @cancel="editDialogVisible = false" @reset="resetEditForm" />
</el-dialog>
</div>
</template>
@@ -240,6 +251,7 @@ import dayjs from 'dayjs'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Clock, DataAnalysis, CircleCheck, Loading } from '@element-plus/icons-vue'
import NodeForm from '../components/NodeForm.vue'
import { getTagStyle } from '../utils/tagColor'
export default {
name: 'AdminDashboard',
@@ -270,7 +282,8 @@ export default {
protocol: 'tcp',
version: '',
max_connections: 100,
description: ''
description: '',
tags: []
},
editingNodeId: null,
updating: false
@@ -302,6 +315,7 @@ export default {
}
},
methods: {
getTagStyle,
async loadNodes() {
try {
this.loading = true
@@ -379,13 +393,47 @@ export default {
},
editNode(node) {
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
},
async handleUpdateNode(formData) {
try {
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('节点更新成功')
this.editDialogVisible = false
await this.loadNodes()
@@ -576,4 +624,8 @@ export default {
.text-secondary {
color: #909399;
}
.tag-chip {
margin-right: 4px;
}
</style>

View File

@@ -56,7 +56,7 @@
<!-- 搜索和筛选 -->
<el-card class="filter-card">
<el-row :gutter="20">
<el-row :gutter="26">
<el-col :span="8">
<el-input v-model="searchText" placeholder="搜索节点名称、主机地址或描述" prefix-icon="Search" clearable
@input="handleSearch" />
@@ -77,14 +77,16 @@
<el-option label="WSS" value="wss" />
</el-select>
</el-col>
<!-- 新增标签多选筛选 -->
<el-col :span="4">
<el-button type="primary" @click="refreshData" :loading="loading">
<el-icon>
<Refresh />
</el-icon>
刷新
</el-button>
<el-select v-model="selectedTags" multiple collapse-tags collapse-tags-tooltip filterable clearable
placeholder="按标签筛选(可多选)" @change="handleFilter">
<el-option v-for="tag in allTags" :key="tag" :label="tag" :value="tag">
<span class="tag-option" :style="getTagStyle(tag)">{{ tag }}</span>
</el-option>
</el-select>
</el-col>
<el-col :span="4">
<el-button type="success" @click="$router.push('/submit')">
<el-icon>
@@ -97,17 +99,24 @@
</el-card>
<!-- 节点列表 -->
<el-card class="nodes-card">
<el-card ref="nodesCardRef" class="nodes-card">
<template #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'">
{{ loading ? '加载中...' : `${pagination.total} 个节点` }}
</el-tag>
</div>
</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">
<template #default="{ row }">
@@ -151,7 +160,7 @@
<template #default="{ row }">
<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>
}}</el-tag>
<span v-else class="text-muted" style="font-size: 11px;">未知</span>
<el-tag :type="row.allow_relay ? 'success' : 'info'" size="small"
style="font-size: 9px; padding: 1px 3px;">
@@ -176,6 +185,18 @@
<span class="description">{{ row.description || '暂无描述' }}</span>
</template>
</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">
<template #default="{ row }">
@@ -223,6 +244,16 @@
<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="描述" :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>
<!-- 健康状态统计 -->
@@ -261,7 +292,7 @@
</template>
<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 { nodeApi } from '../api'
import dayjs from 'dayjs'
@@ -276,6 +307,7 @@ import {
Refresh,
Plus
} from '@element-plus/icons-vue'
import { getTagStyle } from '../utils/tagColor'
// 响应式数据
const loading = ref(false)
@@ -283,11 +315,18 @@ const nodes = ref([])
const searchText = ref('')
const statusFilter = ref('')
const protocolFilter = ref('')
const selectedTags = ref([])
const allTags = ref([])
const detailDialogVisible = ref(false)
const selectedNode = ref(null)
const healthStats = ref(null)
const expandedRows = ref([])
const apiUrl = ref(window.location.href)
const tableRef = ref(null)
const nodesCardRef = ref(null)
// 请求取消控制(避免重复请求覆盖)
let fetchController = null
// 分页数据
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) => {
try {
if (with_loading) {
@@ -328,13 +378,26 @@ const fetchNodes = async (with_loading = true) => {
if (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) {
nodes.value = response.data.items
pagination.total = response.data.total
}
} catch (error) {
if (error.name === 'CanceledError' || error.name === 'AbortError') {
// 被取消的旧请求,忽略
return
}
console.error('获取节点列表失败:', error)
ElMessage.error('获取节点列表失败')
} finally {
@@ -345,6 +408,7 @@ const fetchNodes = async (with_loading = true) => {
}
const refreshData = () => {
pagination.page = 1
fetchNodes()
}
@@ -408,12 +472,69 @@ const copyAddress = (address) => {
// 生命周期
onMounted(() => {
fetchTags()
fetchNodes()
// 设置定时刷新
setInterval(() => {
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>
@@ -570,4 +691,28 @@ onMounted(() => {
background-color: #fafafa;
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>

View File

@@ -18,11 +18,11 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
target: 'http://localhost:11030',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:8080',
target: 'http://localhost:11030',
changeOrigin: true,
}
}

View File

@@ -1,6 +1,6 @@
use std::ops::{Div, Mul};
use axum::extract::{Path, Query, State};
use axum::extract::{Path, State};
use axum::Json;
use sea_orm::{
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::{operations::*, Db};
use crate::health_checker_manager::HealthCheckerManager;
use axum_extra::extract::Query;
use std::sync::Arc;
#[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 nodes = query
.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 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 {
if let Some(mut health_record) = app_state
@@ -99,7 +136,6 @@ pub async fn get_nodes(
// remove sensitive information
node_responses.iter_mut().for_each(|node| {
tracing::info!("node: {:?}", node);
node.network_name = None;
node.network_secret = None;
@@ -161,7 +197,10 @@ pub async fn get_node(
.await?
.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(
@@ -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 nodes = query
@@ -334,7 +406,14 @@ pub async fn admin_get_nodes(
.all(app_state.db.orm_db())
.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;
@@ -366,7 +445,10 @@ pub async fn admin_approve_node(
.exec(app_state.db.orm_db())
.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(
@@ -432,7 +514,15 @@ pub async fn admin_update_node(
.exec(app_state.db.orm_db())
.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(
@@ -454,7 +544,10 @@ pub async fn admin_revoke_approval(
.exec(app_state.db.orm_db())
.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(
@@ -505,3 +598,10 @@ fn verify_admin_token(headers: &HeaderMap) -> ApiResult<()> {
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)))
}

View File

@@ -162,6 +162,9 @@ pub struct UpdateNodeRequest {
#[validate(email)]
pub mail: Option<String>,
// 标签字段(仅管理员可用)
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -198,6 +201,7 @@ pub struct NodeResponse {
pub qq_number: Option<String>,
pub wechat: Option<String>,
pub mail: Option<String>,
pub tags: Vec<String>,
}
impl From<entity::shared_nodes::Model> for NodeResponse {
@@ -247,6 +251,7 @@ impl From<entity::shared_nodes::Model> for NodeResponse {
} else {
Some(node.mail)
},
tags: Vec::new(),
}
}
}
@@ -281,6 +286,8 @@ pub struct NodeFilterParams {
pub is_active: Option<bool>,
pub protocol: Option<String>,
pub search: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -313,4 +320,6 @@ pub struct AdminNodeFilterParams {
pub is_approved: Option<bool>,
pub protocol: Option<String>,
pub search: Option<String>,
pub tag: Option<String>,
pub tags: Option<Vec<String>>,
}

View File

@@ -6,7 +6,7 @@ use tower_http::cors::CorsLayer;
use super::handlers::AppState;
use super::handlers::{
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,
};
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("/health", get(health_check))
.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/nodes/{id}/health", get(get_node_health))
.route("/api/nodes/{id}/health/stats", get(get_node_health_stats))

View File

@@ -2,6 +2,8 @@ use std::env;
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use easytier::common::config::{ConsoleLoggerConfig, FileLoggerConfig, LoggingConfig};
#[derive(Debug, Clone)]
pub struct AppConfig {
pub server: ServerConfig,
@@ -32,12 +34,6 @@ pub struct HealthCheckConfig {
pub max_retries: u32,
}
#[derive(Debug, Clone)]
pub struct LoggingConfig {
pub level: String,
pub rust_log: String,
}
#[derive(Debug, Clone)]
pub struct CorsConfig {
pub allowed_origins: Vec<String>,
@@ -100,8 +96,14 @@ impl AppConfig {
};
let logging_config = LoggingConfig {
level: env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string()),
rust_log: env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
file_logger: Some(FileLoggerConfig {
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 {
@@ -161,8 +163,14 @@ impl AppConfig {
max_retries: 3,
},
logging: LoggingConfig {
level: "info".to_string(),
rust_log: "info".to_string(),
file_logger: Some(FileLoggerConfig {
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 {
allowed_origins: vec![

View File

@@ -3,4 +3,5 @@
pub mod prelude;
pub mod health_records;
pub mod node_tags;
pub mod shared_nodes;

View File

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

View File

@@ -1,4 +1,5 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
pub use super::health_records::Entity as HealthRecords;
pub use super::node_tags::Entity as NodeTags;
pub use super::shared_nodes::Entity as SharedNodes;

View File

@@ -33,6 +33,9 @@ pub struct Model {
pub enum Relation {
#[sea_orm(has_many = "super::health_records::Entity")]
HealthRecords,
// add relation to node_tags
#[sea_orm(has_many = "super::node_tags::Entity")]
NodeTags,
}
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 {}

View File

@@ -4,6 +4,7 @@ use crate::db::Db;
use crate::db::HealthStats;
use crate::db::HealthStatus;
use sea_orm::*;
use std::collections::{HashMap, HashSet};
/// 节点管理操作
pub struct NodeOperations;
@@ -229,6 +230,128 @@ impl HealthOperations {
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)]
mod tests {

View File

@@ -11,6 +11,7 @@ use api::routes::create_routes;
use clap::Parser;
use config::AppConfig;
use db::{operations::NodeOperations, Db};
use easytier::utils::init_logger;
use health_checker::HealthChecker;
use health_checker_manager::HealthCheckerManager;
use std::env;
@@ -36,18 +37,7 @@ async fn main() -> anyhow::Result<()> {
let config = AppConfig::default();
// 初始化日志
tracing_subscriber::fmt()
.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 _ = init_logger(&config.logging, false);
// 解析命令行参数
let args = Args::parse();

View File

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

View File

@@ -1,12 +1,16 @@
use sea_orm_migration::prelude::*;
mod m20250101_000001_create_tables;
mod m20250101_000002_create_node_tags;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
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),
]
}
}

View File

@@ -321,9 +321,9 @@ pub struct ConsoleLoggerConfig {
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, derive_builder::Builder)]
pub struct LoggingConfig {
#[builder(setter(into, strip_option), default = None)]
file_logger: Option<FileLoggerConfig>,
pub file_logger: Option<FileLoggerConfig>,
#[builder(setter(into, strip_option), default = None)]
console_logger: Option<ConsoleLoggerConfig>,
pub console_logger: Option<ConsoleLoggerConfig>,
}
impl LoggingConfigLoader for &LoggingConfig {

View File

@@ -16,7 +16,7 @@ use tokio::{
use crate::{
common::{dns::socket_addrs, join_joinset_background, PeerId},
peers::peer_conn::PeerConnId,
peers::{peer_conn::PeerConnId, peer_map::PeerMap},
proto::{
api::instance::{
Connector, ConnectorManageRpc, ConnectorStatus, ListConnectorRequest,
@@ -194,16 +194,22 @@ impl ManualConnectorManager {
tracing::warn!("peer manager is gone, exit");
break;
};
for x in pm.get_peer_map().get_alive_conns().iter().map(|x| {
x.tunnel
.clone()
.unwrap_or_default()
.remote_addr
.unwrap_or_default()
.to_string()
}) {
data.alive_conn_urls.insert(x);
}
let fill_alive_urls_with_peer_map = |peer_map: &PeerMap| {
for x in peer_map.get_alive_conns().iter().map(|x| {
x.tunnel
.clone()
.unwrap_or_default()
.remote_addr
.unwrap_or_default()
.to_string()
}) {
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;
}
Err(RecvError::Closed) => {

View File

@@ -2306,7 +2306,7 @@ impl RouteSessionManager {
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: {:?}",
from_peer_id, is_initiator, peer_infos, conn_bitmap, service_impl.synced_route_info, session, service_impl.route_table);