feat(ui): create CodeRunner Dioxus UI component with async polling
- runner.rs: CodeRunner 组件(源码展示 + Run 按钮 + 阶段/输出/错误区) spawn 提交 StartExec 后用 sleep_ms(WASM 友好) 轮询 GetExecResult,MAX_POLLS 兜底 mut 信号加 cfg_attr 放行 server 的 unused_mut(AGENTS.md 约定) 提前克隆 source/language 给 move 闭包,避免 rsx! 二次借用 (E0382) - execute.rs: 修正双目标可见性——server fn 模块不能 cfg-gate 共享类型 use 双目标可见,server-only 辅助 use 单独 gate(对齐 posts 模块约定) - mod.rs: execute 不再 gate,languages/progress 仍 gate
This commit is contained in:
parent
576803302d
commit
6b875e6ba5
@ -6,31 +6,46 @@
|
||||
//!
|
||||
//! 错误脱敏:匿名可见错误(不支持的语言、超限、限流)返回中文消息;系统内部
|
||||
//! 异常(容器拉起失败等)记服务端日志,对前端返回统一「系统暂时不可用」。
|
||||
//!
|
||||
//! 双目标可见性:本模块**不** cfg-gate(server function 需对 WASM 可见以便客户端
|
||||
//! 调用),但所有 server-only 的 `use` 与全局静态量都单独 gate,使 WASM 侧仅保留
|
||||
//! 函数签名(body 被 server 宏剥离)。与 posts 模块的约定一致。
|
||||
|
||||
// 与 posts / settings 模块一致:Dioxus `#[server]` 宏触发 deprecated/unit 提示,按项目惯例放行。
|
||||
#![allow(clippy::unused_unit, deprecated)]
|
||||
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use dioxus::prelude::*;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
// 共享数据类型在函数签名中出现,双目标可见,不 gate。
|
||||
use crate::api::code_runner::{ExecRequest, ExecResult, ExecStatus, ExecTask};
|
||||
|
||||
// server-only 辅助模块与依赖:仅在 server function body(被宏剥离到 WASM 之外)内使用。
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::code_runner::languages::{is_supported_lang, LANGUAGES};
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::code_runner::progress::{
|
||||
gc_old_tasks, insert_task, update_task_result, update_task_stage, EXEC_TASKS,
|
||||
};
|
||||
use crate::api::code_runner::{ExecRequest, ExecResult, ExecStatus, ExecTask};
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::rate_limit::{check_code_exec_limit, get_client_ip};
|
||||
#[cfg(feature = "server")]
|
||||
use crate::infra::docker::run_in_container;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::infra::runner_config::{clamp_limits, RUNNER_CONFIG};
|
||||
#[cfg(feature = "server")]
|
||||
use std::sync::{Arc, LazyLock};
|
||||
#[cfg(feature = "server")]
|
||||
use std::time::Duration;
|
||||
#[cfg(feature = "server")]
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// 并发容器控制信号量,限制同时在跑的容器数量(`CODE_RUNNER_MAX_CONCURRENT`)。
|
||||
#[cfg(feature = "server")]
|
||||
pub static RUNNER_SEMAPHORE: LazyLock<Arc<Semaphore>> =
|
||||
LazyLock::new(|| Arc::new(Semaphore::new(RUNNER_CONFIG.max_concurrent)));
|
||||
|
||||
/// 从 FullstackContext 提取客户端 IP(无上下文时退回 "unknown")。
|
||||
#[cfg(feature = "server")]
|
||||
fn client_ip() -> String {
|
||||
match dioxus::fullstack::FullstackContext::current() {
|
||||
Some(ctx) => {
|
||||
@ -107,8 +122,7 @@ pub async fn start_exec(req: ExecRequest) -> Result<String, ServerFnError> {
|
||||
.unwrap_or_else(|| lang_def.default_limits.clone());
|
||||
let final_limits = clamp_limits(base_limits, lang_def.allow_network);
|
||||
|
||||
let start_time = Utc::now();
|
||||
|
||||
let start_time = chrono::Utc::now();
|
||||
let res = run_in_container(
|
||||
&lang_def.image,
|
||||
&lang_def.run_cmd,
|
||||
@ -117,8 +131,7 @@ pub async fn start_exec(req: ExecRequest) -> Result<String, ServerFnError> {
|
||||
final_limits,
|
||||
)
|
||||
.await;
|
||||
|
||||
let duration_ms = (Utc::now() - start_time)
|
||||
let duration_ms = (chrono::Utc::now() - start_time)
|
||||
.num_milliseconds()
|
||||
.max(0) as u64;
|
||||
|
||||
@ -155,7 +168,7 @@ pub async fn start_exec(req: ExecRequest) -> Result<String, ServerFnError> {
|
||||
};
|
||||
let exec_res = ExecResult {
|
||||
status: status.clone(),
|
||||
stdout: "".to_string(),
|
||||
stdout: String::new(),
|
||||
stderr: if is_timeout {
|
||||
"执行超时".to_string()
|
||||
} else {
|
||||
|
||||
@ -71,10 +71,10 @@ pub struct ExecTask {
|
||||
pub result: Option<ExecResult>,
|
||||
}
|
||||
|
||||
// 子模块在后续任务中实现:
|
||||
#[cfg(feature = "server")]
|
||||
pub mod execute;
|
||||
// execute.rs 含 server function,需对双目标可见(不能 cfg-gate);其 server-only
|
||||
// 依赖在文件内单独 gate。languages / progress 是纯 server 辅助,整体 gate。
|
||||
#[cfg(feature = "server")]
|
||||
pub mod languages;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod progress;
|
||||
pub mod execute;
|
||||
|
||||
10
src/components/code_runner/mod.rs
Normal file
10
src/components/code_runner/mod.rs
Normal file
@ -0,0 +1,10 @@
|
||||
//! 代码运行器(Code Runner)组件。
|
||||
//!
|
||||
//! 提供可运行代码块的交互 UI:显示源码、运行按钮、运行阶段、输出区。
|
||||
//! 点击 Run 时调用 `StartExec` server function 提交,轮询 `GetExecResult` 直到终态。
|
||||
//!
|
||||
//! CodeMirror 编辑器实例由调用方(阅读器扫描 / 后台试运行)在 WASM 端按容器 id
|
||||
//! 挂载;本组件只负责布局、状态机与轮询,与编辑器桥接保持解耦。
|
||||
|
||||
pub mod runner;
|
||||
pub use runner::CodeRunner;
|
||||
191
src/components/code_runner/runner.rs
Normal file
191
src/components/code_runner/runner.rs
Normal file
@ -0,0 +1,191 @@
|
||||
//! CodeRunner 组件实现:源码 + 运行按钮 + 轮询 + 输出区。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api::code_runner::execute::{get_exec_result, start_exec};
|
||||
use crate::api::code_runner::{ExecRequest, ExecStatus};
|
||||
use crate::infra::runner_config::ResourceLimits;
|
||||
use crate::utils::time::sleep_ms;
|
||||
|
||||
/// 轮询间隔(毫秒)。
|
||||
const POLL_INTERVAL_MS: u32 = 500;
|
||||
/// 轮询最大次数兜底,避免任务卡在 Running 状态时无限轮询。
|
||||
const MAX_POLLS: u32 = 240; // 500ms * 240 = 120s 上限
|
||||
|
||||
/// 代码运行器组件。
|
||||
///
|
||||
/// Props:
|
||||
/// - `source`:初始源码(用于展示与提交)。
|
||||
/// - `language`:语言标识(python / node 等)。
|
||||
/// - `overrides`:可选资源限制覆盖。
|
||||
///
|
||||
/// `mut` 信号仅在 WASM 的 spawn 闭包内被 `.set()`,server 构建会触发 unused_mut,
|
||||
/// 故按项目惯例加 `cfg_attr` 放行(参见 AGENTS.md「mut bindings needed only on WASM」)。
|
||||
#[component]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
|
||||
pub fn CodeRunner(
|
||||
source: String,
|
||||
language: String,
|
||||
overrides: Option<ResourceLimits>,
|
||||
) -> Element {
|
||||
let mut running = use_signal(|| false);
|
||||
let mut stage = use_signal(|| String::new());
|
||||
let mut output = use_signal(|| String::new());
|
||||
let mut exit_info = use_signal(|| String::new());
|
||||
let mut error_msg = use_signal(|| String::new());
|
||||
|
||||
// 为每个实例生成稳定的容器 id(CodeMirror 容器,由调用方在 WASM 端挂载)。
|
||||
// use_id 在 Dioxus 0.7 提供 scope 内稳定的 id;退回 use_hook 保证只算一次。
|
||||
let container_id = use_hook(|| {
|
||||
format!(
|
||||
"code-runner-{}",
|
||||
// 简单用一次性随机后缀;非安全场景,避免额外依赖。
|
||||
now_pseudo_unique()
|
||||
)
|
||||
});
|
||||
|
||||
// 提前克隆一份给 run 闭包使用,避免 move 闭包抢走 rsx! 仍要读的 source/language。
|
||||
let run_source = source.clone();
|
||||
let run_language = language.clone();
|
||||
let run_overrides = overrides.clone();
|
||||
|
||||
let run_code = move |_| {
|
||||
if running() {
|
||||
return;
|
||||
}
|
||||
running.set(true);
|
||||
stage.set("提交中...".to_string());
|
||||
output.set(String::new());
|
||||
exit_info.set(String::new());
|
||||
error_msg.set(String::new());
|
||||
|
||||
let req = ExecRequest {
|
||||
language: run_language.clone(),
|
||||
source: run_source.clone(),
|
||||
overrides: run_overrides.clone(),
|
||||
};
|
||||
|
||||
spawn(async move {
|
||||
match start_exec(req).await {
|
||||
Ok(task_id) => {
|
||||
let mut polls = 0u32;
|
||||
loop {
|
||||
polls += 1;
|
||||
sleep_ms(POLL_INTERVAL_MS).await;
|
||||
match get_exec_result(task_id.clone()).await {
|
||||
Ok(task) => {
|
||||
stage.set(task.stage.clone());
|
||||
let terminal = task.status != ExecStatus::Queued
|
||||
&& task.status != ExecStatus::Running;
|
||||
if terminal {
|
||||
running.set(false);
|
||||
if let Some(res) = task.result {
|
||||
let out = format!(
|
||||
"Stdout:\n{}\nStderr:\n{}",
|
||||
res.stdout, res.stderr
|
||||
);
|
||||
output.set(out);
|
||||
exit_info.set(format!(
|
||||
"耗时: {}ms · 状态: {}",
|
||||
res.duration_ms,
|
||||
status_label(&res.status)
|
||||
));
|
||||
if res.status == ExecStatus::Success {
|
||||
error_msg.set(String::new());
|
||||
} else {
|
||||
error_msg
|
||||
.set(status_label(&res.status));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
if polls >= MAX_POLLS {
|
||||
running.set(false);
|
||||
stage.set("查询超时".to_string());
|
||||
error_msg.set("轮询超时,请重试".to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
running.set(false);
|
||||
stage.set("结果获取异常".to_string());
|
||||
error_msg.set("结果获取异常".to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
running.set(false);
|
||||
let msg = e.to_string();
|
||||
stage.set(msg.clone());
|
||||
error_msg.set(msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "code-runner-container border border-base-300 rounded-xl overflow-hidden my-4 bg-base-100",
|
||||
div { class: "flex justify-between items-center bg-base-200 px-3 py-1 text-sm border-b border-base-300",
|
||||
span { class: "font-mono font-bold text-base-content/80", "{language}" }
|
||||
button {
|
||||
class: "btn btn-xs btn-primary rounded-lg",
|
||||
disabled: running(),
|
||||
onclick: run_code,
|
||||
if running() {
|
||||
span { class: "loading loading-spinner loading-xs mr-1" }
|
||||
"{stage()}"
|
||||
} else {
|
||||
"Run"
|
||||
}
|
||||
}
|
||||
}
|
||||
// CodeMirror 容器:调用方(阅读器扫描 / 后台试运行)在 WASM 端按此 id 挂载编辑器。
|
||||
// 服务端渲染时仅展示源码文本,避免在 SSR 阶段拉起 JS 编辑器。
|
||||
div {
|
||||
id: "{container_id}",
|
||||
class: "code-runner-editor min-h-[100px] font-mono text-sm",
|
||||
// 源码以 data 属性承载,供挂载脚本读取初始化(避免 SSR 时把源码当 HTML 解析)。
|
||||
"data-source": "{source}",
|
||||
}
|
||||
if !output().is_empty() {
|
||||
div { class: "output-area bg-neutral text-neutral-content p-3 rounded-lg m-2 font-mono text-xs whitespace-pre-wrap break-all",
|
||||
div { class: "text-gray-400 border-b border-gray-600 pb-1 mb-1 flex justify-between items-center",
|
||||
span { "Console Output" }
|
||||
span { class: "text-gray-500", "{exit_info()}" }
|
||||
}
|
||||
{output()}
|
||||
}
|
||||
}
|
||||
if !error_msg().is_empty() {
|
||||
div { class: "px-3 pb-2 text-xs text-error",
|
||||
{error_msg()}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 ExecStatus 映射成中文标签。
|
||||
fn status_label(status: &ExecStatus) -> String {
|
||||
match status {
|
||||
ExecStatus::Queued => "排队中".to_string(),
|
||||
ExecStatus::Running => "运行中".to_string(),
|
||||
ExecStatus::Success => "成功".to_string(),
|
||||
ExecStatus::Timeout => "超时".to_string(),
|
||||
ExecStatus::OomKilled => "内存超限".to_string(),
|
||||
ExecStatus::Error => "运行错误".to_string(),
|
||||
ExecStatus::Failed => "系统失败".to_string(),
|
||||
ExecStatus::RateLimited => "请求过频".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成一个伪唯一后缀(基于时间戳 + 计数器),用于容器 id。
|
||||
/// 非安全用途,仅避免同页多实例 id 冲突。
|
||||
fn now_pseudo_unique() -> String {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("{}-{}", crate::utils::time::now_millis(), n)
|
||||
}
|
||||
@ -9,6 +9,8 @@
|
||||
pub mod admin_layout;
|
||||
/// 后台页面骨架屏组件。
|
||||
pub mod admin_skeleton;
|
||||
/// 代码运行器组件(可运行代码块 UI)。
|
||||
pub mod code_runner;
|
||||
/// 评论相关组件。
|
||||
pub mod comments;
|
||||
/// 空状态组件(无数据时的插画配图 + 文案)。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user