Skip to main content

yggdrasil/pages/admin/
runner.rs

1//! 管理后台「代码运行器」页面(`/admin/runner`)。
2//!
3//! 作者在写作时可在此沙箱快速试运行代码(验证围栏 ` ```lang runnable ` 的预期输出),
4//! 而无需进入文章渲染后才能运行。沙箱使用与读者相同的 StartExec / GetExecResult
5//! 接口,受同一套资源钳制约束(admin 跳过速率限制,见 `start_exec`)。
6//!
7//! 左侧为语言分段与运行器,右侧为资源上限与临时覆盖。样式见
8//! styles/pages/admin-runner.css。
9
10use leptos::prelude::*;
11use leptos::task::spawn_local;
12use leptos_router::{lazy_route, LazyRoute};
13
14use crate::components::admin_layout::AdminPageHead;
15use crate::components::code_runner::CodeRunner;
16use crate::components::forms::{FormLabel, FormTextarea};
17use crate::components::ui::{SegmentedControl, SegmentedItem, StatusDot};
18use crate::infra::runner_config::ResourceLimits;
19use crate::models::settings::RunnerSettings;
20use crate::routes::Route;
21
22const OVERRIDES_PLACEHOLDER: &str = "{\"timeout_secs\": 3}";
23/// 受支持的语言集合(与 LANGUAGES 注册表 / CODE_RUNNER_LANGUAGES 对齐)。
24const SUPPORTED_LANGS: &[(&str, &str)] = &[
25    ("python", "Python"),
26    ("node", "Node.js"),
27    ("go", "Go"),
28    ("rust", "Rust"),
29    ("bun", "Bun · TS"),
30];
31/// 默认示例源码(按语言)。
32fn default_source(lang: &str) -> String {
33    match lang {
34        "python" => "print('Hello from author sandbox')\nfor i in range(3):\n    print(f'line {i}')\n".to_string(),
35        "node" => "console.log('Hello from author sandbox');\n[0,1,2].forEach(i => console.log(`line ${i}`));\n".to_string(),
36        "go" => "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello from author sandbox\")\n\tfor i := 0; i < 3; i++ {\n\t\tfmt.Printf(\"line %d\\n\", i)\n\t}\n}\n".to_string(),
37        "rust" => "fn main() {\n    println!(\"Hello from author sandbox\");\n    for i in 0..3 {\n        println!(\"line {}\", i);\n    }\n}\n".to_string(),
38        // bun 跑 TypeScript:示例用 TS 类型注解体现语言特性。
39        "bun" => "const greeting: string = 'Hello from author sandbox';\nconsole.log(greeting);\n[0, 1, 2].forEach((i: number) => console.log(`line ${i}`));\n".to_string(),
40        _ => String::new(),
41    }
42}
43
44/// 解析「临时覆盖」JSON:空串视为无覆盖,格式不对时忽略并给出提示。
45fn parse_overrides(raw: &str) -> (Option<ResourceLimits>, String) {
46    match serde_json::from_slice::<ResourceLimits>(raw.trim().as_bytes()) {
47        Ok(o) => (Some(o), String::new()),
48        Err(_) if raw.trim().is_empty() => (None, String::new()),
49        Err(_) => (None, "JSON 格式不对,已忽略".to_string()),
50    }
51}
52
53/// 代码运行器路由(无 SSR 数据,资源上限在客户端读取)。
54pub struct RunnerRoute;
55
56#[lazy_route]
57impl LazyRoute for RunnerRoute {
58    fn data() -> Self {
59        Self
60    }
61
62    fn view(_this: Self) -> AnyView {
63        view! { <Runner/> }.into_any()
64    }
65}
66
67/// 管理后台代码试运行页面。
68#[component]
69fn Runner() -> impl IntoView {
70    let lang = RwSignal::new("python".to_string());
71    // 语言切换时刷新示例源码(首次进入也有默认值)。
72    let source = RwSignal::new(default_source("python"));
73    let overrides_json = RwSignal::new(String::new());
74    let limits = RwSignal::new(None::<RunnerSettings>);
75    // 仅客户端读取资源上限(Effect 不在服务端运行)。
76    Effect::new(move |_| {
77        spawn_local(async move {
78            if let Ok(s) = crate::api::settings::get_runner_settings().await {
79                let _ = limits.try_set(Some(s));
80            }
81        });
82    });
83
84    let parsed = Memo::new(move |_| overrides_json.with(|raw| parse_overrides(raw)));
85    let override_error = Signal::derive(move || parsed.with(|(_, error)| error.clone()));
86
87    view! {
88        <div class="admin-runner">
89            <AdminPageHead
90                title="代码运行器"
91                actions=|| view! { <StatusDot label="Docker 沙箱" tone="success"/> }
92            >
93                "在沙箱里试运行代码,确认文章里可运行代码块的预期输出。"
94            </AdminPageHead>
95            <div class="runner-admin-grid">
96                <section class="runner-admin-main" aria-label="编辑与输出">
97                    <SegmentedControl
98                        class="runner-admin-langs"
99                        aria_label="语言"
100                        items={
101                            SUPPORTED_LANGS
102                                .iter()
103                                .map(|(v, l)| SegmentedItem::new(v.to_string(), *l))
104                                .collect::<Vec<_>>()
105                        }
106                        value=lang
107                        on_change=move |l: String| {
108                            if l != lang.get_untracked() {
109                                source.set(default_source(&l));
110                                lang.set(l);
111                            }
112                        }
113                    />
114                    // 切语言时按语言重建:CodeRunner 的防重复初始化守卫与非响应式
115                    // prop 让它不会就地换语言,卸载重建最直接。
116                    {move || {
117                        let language = lang.get();
118                        let overrides = parsed.with_untracked(|(o, _)| o.clone());
119                        view! {
120                            <div class="runner-admin-runner">
121                                <CodeRunner
122                                    source=source.get_untracked()
123                                    language=language
124                                    overrides=overrides
125                                    instance_id=0
126                                />
127                            </div>
128                        }
129                    }}
130                </section>
131                <aside class="admin-panel runner-admin-side" aria-label="运行配置">
132                    <h2>"资源上限"</h2>
133                    {move || match limits.get() {
134                        Some(c) => {
135                            view! {
136                                <dl>
137                                    <dt>"超时"</dt>
138                                    <dd>{format!("{} 秒", c.max_timeout_secs)}</dd>
139                                    <dt>"内存"</dt>
140                                    <dd>{format!("{} MB", c.max_memory_mb)}</dd>
141                                    <dt>"CPU"</dt>
142                                    <dd>{format!("{:.1} 核", c.max_cpu_cores)}</dd>
143                                    <dt>"网络"</dt>
144                                    <dd>{if c.allow_network { "开启" } else { "关闭" }}</dd>
145                                </dl>
146                            }
147                                .into_any()
148                        }
149                        None => view! { <p class="runner-admin-hint">"正在读取…"</p> }.into_any(),
150                    }}
151                    <div>
152                        <FormLabel
153                            label="临时覆盖(JSON,可选)"
154                            html_for="runner-overrides".to_string()
155                        />
156                        <FormTextarea
157                            id="runner-overrides"
158                            class="field field-textarea settings-mono"
159                            rows=4
160                            placeholder=OVERRIDES_PLACEHOLDER
161                            invalid=Signal::derive(move || !override_error.with(String::is_empty))
162                            value=overrides_json
163                            oninput=move |v: String| overrides_json.set(v)
164                        />
165                        {move || {
166                            if override_error.with(String::is_empty) {
167                                view! {
168                                    <p class="runner-admin-hint">
169                                        "可覆盖 cpu_cores、memory_mb、timeout_secs、output_bytes、allow_network,仍受上限约束。"
170                                    </p>
171                                }
172                                    .into_any()
173                            } else {
174                                view! {
175                                    <p class="runner-admin-hint" data-error="true" role="alert">
176                                        {move || override_error.get()}
177                                    </p>
178                                }
179                                    .into_any()
180                            }
181                        }}
182                    </div>
183                    <a class="runner-admin-link" href=Route::SiteSettingsPage {}.to_string()>
184                        "在设置里调整上限"
185                    </a>
186                </aside>
187            </div>
188        </div>
189    }
190}