use axum::body::Bytes;
use axum::extract::{Extension, State};
use axum::response::{IntoResponse, Response};
use axum::Json;
use console_auth::{ApiError, NodePrincipal};
use console_store::{NewNodeEvent, NewNodeMetrics, NewPipelineMetrics};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use utoipa::ToSchema;
use crate::api::ErrorResponse;
use crate::state::AppState;
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct RegisterBody {
pub node_key: Option<String>,
pub api_url: Option<String>,
pub capacity_json: Option<String>,
pub bootstrap_token: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct NodeCreated {
pub id: i64,
pub node_key: String,
pub name: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct RegisterResponse {
pub node: NodeCreated,
#[serde(rename = "bootstrapToken")]
pub bootstrap_token: String,
}
#[derive(Debug, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct NodeEcho {
pub node_key: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct HeartbeatResponse {
pub node: NodeEcho,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct HeartbeatBody {
pub node_key: Option<String>,
pub name: Option<String>,
pub api_url: Option<String>,
pub engine_version: Option<String>,
pub capacity_json: Option<String>,
pub pipeline_states: Option<Vec<Value>>,
pub events: Option<Vec<Value>>,
pub resources: Option<Value>,
}
#[utoipa::path(
post,
path = "/api/nodes/register",
request_body = RegisterBody,
responses(
(status = 200, body = RegisterResponse, description = "node registered; echo nodeKey is authoritative; bootstrapToken is the new per-node token, echoed exactly once"),
(status = 401, body = ErrorResponse, description = "bootstrap token invalid/revoked"),
)
)]
pub async fn register(
State(state): State<AppState>,
Extension(principal): Extension<NodePrincipal>,
body: Bytes,
) -> Response {
let body: RegisterBody = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => return ApiError::bad_request(format!("非法注册请求: {e}")).into_response(),
};
let token = console_auth::generate_token(console_auth::BOOTSTRAP_TOKEN_PREFIX);
let token_hash = console_auth::token_hash(&token);
let store = state.store();
let result = store.upsert_self_registered(
&principal.node_key,
body.api_url.as_deref(),
body.capacity_json.as_deref(),
&token_hash,
);
let (id, name) = match result {
Ok(v) => v,
Err(e) => return ApiError::internal(&e).into_response(),
};
let _ = store.insert_audit(&console_store::AuditEntry {
actor: format!("node:{}", principal.node_key),
action: "NODE_REGISTER".into(),
entity_type: "node".into(),
entity_id: Some(principal.node_key.clone()),
detail_json: None,
});
Json(RegisterResponse {
node: NodeCreated {
id,
node_key: principal.node_key,
name,
},
bootstrap_token: token,
})
.into_response()
}
#[utoipa::path(
post,
path = "/api/nodes/heartbeat",
request_body = HeartbeatBody,
responses(
(status = 200, body = HeartbeatResponse, description = "events acknowledged"),
(status = 401, body = ErrorResponse, description = "node token invalid or node unknown"),
)
)]
pub async fn heartbeat(
State(state): State<AppState>,
Extension(principal): Extension<NodePrincipal>,
body: Bytes,
) -> Response {
let body: HeartbeatBody = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => return ApiError::bad_request(format!("非法心跳请求: {e}")).into_response(),
};
let store = state.store();
let node_key = principal.node_key.clone();
let applied = store.apply_heartbeat(
&node_key,
body.name.as_deref(),
body.api_url.as_deref(),
body.engine_version.as_deref(),
body.capacity_json.as_deref(),
);
match applied {
Ok(true) => {}
Ok(false) => return ApiError::not_found("节点不存在").into_response(),
Err(e) => return ApiError::internal(&e).into_response(),
}
let events: Vec<NewNodeEvent> = body
.events
.unwrap_or_default()
.iter()
.filter_map(|v| parse_event(&node_key, v))
.collect();
let node = store.node_by_key(&node_key).ok().flatten();
if let Some(node) = &node {
let rows: Vec<NewPipelineMetrics> = body
.pipeline_states
.unwrap_or_default()
.iter()
.filter_map(parse_pipeline_metrics)
.collect();
if let Err(e) = store.insert_pipeline_metrics(node.id, &rows) {
return ApiError::internal(&e).into_response();
}
if let Some(resources) = &body.resources {
if let Some(m) = parse_resources(resources) {
if let Err(e) = store.insert_node_metrics(node.id, &m) {
return ApiError::internal(&e).into_response();
}
}
}
}
if let Err(e) = store.insert_node_events(&events) {
return ApiError::internal(&e).into_response();
}
Json(HeartbeatResponse {
node: NodeEcho { node_key },
})
.into_response()
}
fn parse_event(node_key: &str, v: &Value) -> Option<NewNodeEvent> {
let level = v.get("level")?.as_str()?.to_string();
let kind = v.get("kind")?.as_str()?.to_string();
let message = v
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("")
.to_string();
let pipeline_id = v.get("pipelineId").and_then(json_int);
let ts_unix = v.get("ts").and_then(json_int);
Some(NewNodeEvent {
node_key: node_key.to_string(),
pipeline_id,
level,
kind,
message,
ts_unix,
})
}
fn parse_pipeline_metrics(v: &Value) -> Option<NewPipelineMetrics> {
let pipeline_id = v.get("id").and_then(json_int)?;
let status = v.get("state").and_then(|s| s.as_str()).map(str::to_string);
let totals = v.get("totals")?;
Some(NewPipelineMetrics {
pipeline_id,
status,
consumed: totals.get("in").and_then(json_int)?,
flushed: totals.get("out").and_then(json_int)?,
flushes: totals.get("flushes").and_then(json_int)?,
})
}
fn parse_resources(v: &Value) -> Option<NewNodeMetrics> {
if !v.is_object() {
return None;
}
Some(NewNodeMetrics {
rss_bytes: v.get("rssBytes").and_then(json_int),
mem_total_bytes: v.get("memTotalBytes").and_then(json_int),
cpu_pct: v.get("cpuPercent").and_then(|x| x.as_f64()),
rx_bps: v.get("rxBps").and_then(|x| x.as_f64()),
tx_bps: v.get("txBps").and_then(|x| x.as_f64()),
})
}
fn json_int(v: &Value) -> Option<i64> {
v.as_i64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
}