[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
@@ -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)))
}
@@ -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>>,
}
@@ -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))
+18 -10
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![
@@ -3,4 +3,5 @@
pub mod prelude;
pub mod health_records;
pub mod node_tags;
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
pub use super::health_records::Entity as HealthRecords;
pub use super::node_tags::Entity as NodeTags;
pub use super::shared_nodes::Entity as SharedNodes;
@@ -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 {}
@@ -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 {
+2 -12
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();
@@ -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::*;
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),
]
}
}