feat(flow): 新增流式执行模式与SSE支持

新增流式执行模式,通过SSE实时推送节点执行事件与日志
重构HTTP执行器与中间件,提取通用HTTP客户端组件
优化前端测试面板,支持流式模式切换与实时日志展示
更新依赖版本并修复密码哈希的随机数生成器问题
修复前端节点类型映射问题,确保Code节点表单可用
This commit is contained in:
2025-09-21 01:48:24 +08:00
parent 296f0ae9f6
commit dd7857940f
24 changed files with 1695 additions and 885 deletions

View 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}");
}
}

View File

@ -1,2 +1,4 @@
pub mod jwt;
pub mod logging;
pub mod logging;
pub mod sse;
pub mod http_client;

View 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;
}