feat(flow): 新增流式执行模式与SSE支持
新增流式执行模式,通过SSE实时推送节点执行事件与日志 重构HTTP执行器与中间件,提取通用HTTP客户端组件 优化前端测试面板,支持流式模式切换与实时日志展示 更新依赖版本并修复密码哈希的随机数生成器问题 修复前端节点类型映射问题,确保Code节点表单可用
This commit is contained in:
178
backend/src/middlewares/http_client.rs
Normal file
178
backend/src/middlewares/http_client.rs
Normal file
@ -0,0 +1,178 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use reqwest::Certificate;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HttpClientOptions {
|
||||
pub timeout_ms: Option<u64>,
|
||||
pub insecure: bool,
|
||||
pub ca_pem: Option<String>,
|
||||
pub http1_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HttpRequest {
|
||||
pub method: String,
|
||||
pub url: String,
|
||||
pub headers: Option<HashMap<String, String>>, // header values are strings
|
||||
pub query: Option<Map<String, Value>>, // query values will be stringified
|
||||
pub body: Option<Value>, // json body
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HttpResponse {
|
||||
pub status: u16,
|
||||
pub headers: Map<String, Value>,
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
pub async fn execute_http(req: HttpRequest, opts: HttpClientOptions) -> Result<HttpResponse> {
|
||||
// Build client with options
|
||||
let mut builder = reqwest::Client::builder();
|
||||
if let Some(ms) = opts.timeout_ms {
|
||||
builder = builder.timeout(Duration::from_millis(ms));
|
||||
}
|
||||
if opts.insecure {
|
||||
builder = builder.danger_accept_invalid_certs(true);
|
||||
}
|
||||
if opts.http1_only {
|
||||
builder = builder.http1_only();
|
||||
}
|
||||
if let Some(pem) = opts.ca_pem {
|
||||
if let Ok(cert) = Certificate::from_pem(pem.as_bytes()) {
|
||||
builder = builder.add_root_certificate(cert);
|
||||
}
|
||||
}
|
||||
let client = builder.build()?;
|
||||
|
||||
// Build request
|
||||
let mut rb = client.request(req.method.parse()?, req.url);
|
||||
|
||||
// Also set per-request timeout to ensure it takes effect in all cases
|
||||
if let Some(ms) = opts.timeout_ms {
|
||||
rb = rb.timeout(Duration::from_millis(ms));
|
||||
}
|
||||
|
||||
if let Some(hs) = req.headers {
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
let mut map = HeaderMap::new();
|
||||
for (k, v) in hs {
|
||||
if let (Ok(name), Ok(value)) = (HeaderName::from_bytes(k.as_bytes()), HeaderValue::from_str(&v)) {
|
||||
map.insert(name, value);
|
||||
}
|
||||
}
|
||||
rb = rb.headers(map);
|
||||
}
|
||||
|
||||
if let Some(qs) = req.query {
|
||||
let mut pairs: Vec<(String, String)> = Vec::new();
|
||||
for (k, v) in qs {
|
||||
let s = match v {
|
||||
Value::String(s) => s,
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
pairs.push((k, s));
|
||||
}
|
||||
rb = rb.query(&pairs);
|
||||
}
|
||||
|
||||
if let Some(b) = req.body {
|
||||
rb = rb.json(&b);
|
||||
}
|
||||
|
||||
let resp = rb.send().await?;
|
||||
let status = resp.status().as_u16();
|
||||
let headers_out: Map<String, Value> = resp
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), Value::String(v.to_str().unwrap_or("").to_string())))
|
||||
.collect();
|
||||
|
||||
let text = resp.text().await?;
|
||||
let parsed_body: Value = serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text));
|
||||
|
||||
Ok(HttpResponse {
|
||||
status,
|
||||
headers: headers_out,
|
||||
body: parsed_body,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_success() {
|
||||
let server = MockServer::start().await;
|
||||
let body = serde_json::json!({"ok": true});
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/hello"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(body.clone()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let req = HttpRequest {
|
||||
method: "GET".into(),
|
||||
url: format!("{}/hello", server.uri()),
|
||||
..Default::default()
|
||||
};
|
||||
let opts = HttpClientOptions::default();
|
||||
let resp = execute_http(req, opts).await.unwrap();
|
||||
assert_eq!(resp.status, 200);
|
||||
assert_eq!(resp.body, body);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_post_json() {
|
||||
let server = MockServer::start().await;
|
||||
let input = serde_json::json!({"name": "udmin"});
|
||||
Mock::given(method("POST")).and(path("/echo"))
|
||||
.respond_with(|req: &wiremock::Request| {
|
||||
// Echo back the request body as JSON
|
||||
let body = serde_json::from_slice::<Value>(&req.body).unwrap_or(Value::Null);
|
||||
ResponseTemplate::new(201).set_body_json(body)
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let req = HttpRequest {
|
||||
method: "POST".into(),
|
||||
url: format!("{}/echo", server.uri()),
|
||||
body: Some(input.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let opts = HttpClientOptions::default();
|
||||
let resp = execute_http(req, opts).await.unwrap();
|
||||
assert_eq!(resp.status, 201);
|
||||
assert_eq!(resp.body, input);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeout() {
|
||||
let server = MockServer::start().await;
|
||||
// Delay longer than our timeout
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/slow"))
|
||||
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_millis(200)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let req = HttpRequest { method: "GET".into(), url: format!("{}/slow", server.uri()), ..Default::default() };
|
||||
let opts = HttpClientOptions { timeout_ms: Some(50), ..Default::default() };
|
||||
let err = execute_http(req, opts).await.unwrap_err();
|
||||
// Try to verify it's a timeout error from reqwest
|
||||
let is_timeout = err
|
||||
.downcast_ref::<reqwest::Error>()
|
||||
.map(|e| e.is_timeout())
|
||||
.unwrap_or(false);
|
||||
assert!(is_timeout, "expected timeout error, got: {err}");
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,4 @@
|
||||
pub mod jwt;
|
||||
pub mod logging;
|
||||
pub mod logging;
|
||||
pub mod sse;
|
||||
pub mod http_client;
|
||||
75
backend/src/middlewares/sse.rs
Normal file
75
backend/src/middlewares/sse.rs
Normal file
@ -0,0 +1,75 @@
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use futures::Stream;
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
use tokio_stream::{wrappers::ReceiverStream, StreamExt as _};
|
||||
|
||||
// 引入后端流式事件类型
|
||||
use crate::flow::context::StreamEvent;
|
||||
|
||||
// 新增:日志与时间戳
|
||||
use tracing::info;
|
||||
use chrono::Utc;
|
||||
|
||||
/// 将 mpsc::Receiver<T> 包装为 SSE 响应,其中 T 需实现 Serialize
|
||||
/// - 自动序列化为 JSON 文本并写入 data: 行
|
||||
/// - 附带 keep-alive,避免长连接超时
|
||||
pub fn from_mpsc<T>(rx: tokio::sync::mpsc::Receiver<T>) -> Sse<impl Stream<Item = Result<Event, Infallible>>>
|
||||
where
|
||||
T: serde::Serialize + Send + 'static,
|
||||
{
|
||||
let stream = ReceiverStream::new(rx).map(|evt| {
|
||||
let payload = serde_json::to_string(&evt).unwrap_or_else(|_| "{}".to_string());
|
||||
// 关键日志:每次将事件映射为 SSE 帧时记录时间点(代表即将写入响应流)
|
||||
info!(target: "udmin.sse", ts = %Utc::now().to_rfc3339(), payload_len = payload.len(), "sse send");
|
||||
Ok::<Event, Infallible>(Event::default().data(payload))
|
||||
});
|
||||
|
||||
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(10)).text("keep-alive"))
|
||||
}
|
||||
|
||||
/// 统一发送:节点事件
|
||||
pub async fn emit_node(
|
||||
tx: &tokio::sync::mpsc::Sender<StreamEvent>,
|
||||
node_id: impl Into<String>,
|
||||
logs: Vec<String>,
|
||||
ctx: serde_json::Value,
|
||||
) {
|
||||
let nid = node_id.into();
|
||||
// 日志:事件入队时间
|
||||
info!(target: "udmin.sse", kind = "node", node_id = %nid, logs_len = logs.len(), ts = %Utc::now().to_rfc3339(), "enqueue event");
|
||||
let _ = tx
|
||||
.send(StreamEvent::Node {
|
||||
node_id: nid,
|
||||
logs,
|
||||
ctx,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 统一发送:完成事件
|
||||
pub async fn emit_done(
|
||||
tx: &tokio::sync::mpsc::Sender<StreamEvent>,
|
||||
ok: bool,
|
||||
ctx: serde_json::Value,
|
||||
logs: Vec<String>,
|
||||
) {
|
||||
info!(target: "udmin.sse", kind = "done", ok = ok, logs_len = logs.len(), ts = %Utc::now().to_rfc3339(), "enqueue event");
|
||||
let _ = tx
|
||||
.send(StreamEvent::Done { ok, ctx, logs })
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 统一发送:错误事件
|
||||
pub async fn emit_error(
|
||||
tx: &tokio::sync::mpsc::Sender<StreamEvent>,
|
||||
message: impl Into<String>,
|
||||
) {
|
||||
let msg = message.into();
|
||||
info!(target: "udmin.sse", kind = "error", message = %msg, ts = %Utc::now().to_rfc3339(), "enqueue event");
|
||||
let _ = tx
|
||||
.send(StreamEvent::Error {
|
||||
message: msg,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Reference in New Issue
Block a user