yggdrasil/pages/admin/system/
mod.rs1mod backup;
9mod db_status;
10mod export;
11mod server_status;
12mod sql_console;
13
14use std::future::Future;
15use std::time::Duration;
16
17use backup::BackupTab;
18use db_status::DbStatusTab;
19use export::ExportTab;
20use leptos::prelude::*;
21use leptos_router::{lazy_route, LazyRoute};
22use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
23use server_status::ServerStatusTab;
24use sql_console::SqlConsoleTab;
25
26use crate::api::error::ApiError;
27use crate::components::admin_layout::AdminPageHead;
28use crate::components::forms::{FormSelect, FORM_SELECT_COMPACT_CLASS};
29use crate::components::ui::{
30 button_class, ButtonSize, ButtonVariant, Icon, IconName, SegmentedControl, SegmentedItem,
31};
32
33pub struct SystemRoute;
35
36#[lazy_route]
37impl LazyRoute for SystemRoute {
38 fn data() -> Self {
39 Self
40 }
41
42 fn view(_this: Self) -> AnyView {
43 view! { <System/> }.into_any()
44 }
45}
46
47#[derive(Clone, Copy, PartialEq, Debug)]
49enum SystemTab {
50 ServerStatus,
52 DbStatus,
54 Backup,
56 SqlConsole,
58 Export,
60}
61
62impl SystemTab {
63 fn as_str(&self) -> &'static str {
65 match self {
66 SystemTab::ServerStatus => "server_status",
67 SystemTab::DbStatus => "db_status",
68 SystemTab::Backup => "backup",
69 SystemTab::SqlConsole => "sql_console",
70 SystemTab::Export => "export",
71 }
72 }
73
74 fn from_str(s: &str) -> Result<SystemTab, &'static str> {
76 match s {
77 "server_status" => Ok(SystemTab::ServerStatus),
78 "db_status" => Ok(SystemTab::DbStatus),
79 "backup" => Ok(SystemTab::Backup),
80 "sql_console" => Ok(SystemTab::SqlConsole),
81 "export" => Ok(SystemTab::Export),
82 _ => Err("unknown tab key"),
83 }
84 }
85}
86
87const REFRESH_OPTIONS: [(Option<u32>, &str); 5] = [
89 (None, "手动刷新"),
90 (Some(1_000), "每 1 秒"),
91 (Some(2_000), "每 2 秒"),
92 (Some(5_000), "每 5 秒"),
93 (Some(10_000), "每 10 秒"),
94];
95
96const URL_COMPONENT: &AsciiSet = &NON_ALPHANUMERIC
99 .remove(b'-')
100 .remove(b'_')
101 .remove(b'.')
102 .remove(b'~');
103
104fn urlencode(s: &str) -> String {
105 utf8_percent_encode(s, URL_COMPONENT).to_string()
106}
107
108fn use_auto_refresh(refresh_ms: Signal<Option<u32>>, reload: Callback<()>) {
111 Effect::new(move |_| {
112 let Some(ms) = refresh_ms.get() else {
113 return;
114 };
115 if ms == 0 {
116 return;
117 }
118 let Ok(handle) =
119 set_interval_with_handle(move || reload.run(()), Duration::from_millis(u64::from(ms)))
120 else {
121 return;
122 };
123 on_cleanup(move || handle.clear());
124 });
125}
126
127#[derive(Clone, Copy)]
129struct PolledStatus<T: Send + Sync + 'static> {
130 status: RwSignal<Option<T>>,
131 loading: RwSignal<bool>,
132 failed: RwSignal<bool>,
133 refresh_ms: RwSignal<Option<u32>>,
134 reload: Callback<()>,
135}
136
137fn use_polled_status<T, F, Fut>(fetch: F, initial_refresh: Option<u32>) -> PolledStatus<T>
143where
144 T: Send + Sync + 'static,
145 F: Fn() -> Fut + Send + Sync + 'static,
146 Fut: Future<Output = Result<T, ApiError>> + 'static,
147{
148 let status = RwSignal::new(None::<T>);
149 let loading = RwSignal::new(true);
150 let failed = RwSignal::new(false);
151 let refresh_ms = RwSignal::new(initial_refresh);
152 let sequence = StoredValue::new((0u64, 0u64));
154 let reload = Callback::new(move |()| {
155 let Some(current) = sequence.try_update_value(|(issued, _)| {
156 *issued += 1;
157 *issued
158 }) else {
159 return;
160 };
161 let _ = loading.try_set(true);
162 let request = fetch();
163 leptos::task::spawn_local(async move {
164 let result = request.await;
165 let Some((fresh, latest)) = sequence.try_update_value(|(issued, applied)| {
166 let fresh = current > *applied;
167 if fresh {
168 *applied = current;
169 }
170 (fresh, current == *issued)
171 }) else {
172 return;
173 };
174 if fresh {
175 match result {
176 Ok(value) => {
177 let _ = status.try_set(Some(value));
178 let _ = failed.try_set(false);
179 }
180 Err(_) => {
181 let _ = failed.try_set(true);
182 }
183 }
184 }
185 if latest {
186 let _ = loading.try_set(false);
187 }
188 });
189 });
190 Effect::new(move |_| reload.run(()));
191 use_auto_refresh(refresh_ms.into(), reload);
192 PolledStatus {
193 status,
194 loading,
195 failed,
196 refresh_ms,
197 reload,
198 }
199}
200
201#[component]
203fn RefreshControl(
204 refresh_ms: RwSignal<Option<u32>>,
205 #[prop(into)] loading: Signal<bool>,
206 #[prop(into)] on_refresh: Callback<()>,
207) -> impl IntoView {
208 view! {
209 <div class="sys-refresh">
210 <span
211 class="sys-live"
212 data-on=move || refresh_ms.get().is_some().then_some("true")
213 aria-hidden="true"
214 ></span>
215 <FormSelect
216 value=refresh_ms
217 options=REFRESH_OPTIONS.to_vec()
218 trigger_class=FORM_SELECT_COMPACT_CLASS
219 aria_label="自动刷新"
220 onchange={move |v: Option<u32>| refresh_ms.set(v)}
221 />
222 <button
223 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
224 type="button"
225 disabled=move || loading.get()
226 on:click=move |_| on_refresh.run(())
227 >
228 <Icon name=IconName::RefreshCw size=14/>
229 {move || if loading.get() { "刷新中…" } else { "立即刷新" }}
230 </button>
231 </div>
232 }
233}
234
235#[component]
239fn Metric(
240 label: &'static str,
241 #[prop(into)] value: Signal<String>,
242 #[prop(optional, into)] meta: Signal<String>,
243 #[prop(optional)] mono: bool,
244 #[prop(optional)] small: bool,
245 #[prop(optional)] children: Option<Children>,
246) -> impl IntoView {
247 view! {
248 <div class="admin-panel sys-metric">
249 <span class="sys-metric-label">{label}</span>
250 <span
251 class="sys-metric-value"
252 data-mono=mono.then_some("true")
253 data-small=small.then_some("true")
254 title=move || value.get()
255 >
256 {move || value.get()}
257 </span>
258 {children.map(|children| children())}
259 {move || {
260 let meta = meta.get();
261 (!meta.is_empty()).then(|| view! { <span class="sys-metric-meta">{meta}</span> })
262 }}
263 </div>
264 }
265}
266
267#[component]
271fn Meter(#[prop(into)] pct: Signal<f64>, #[prop(optional)] calm: bool) -> impl IntoView {
272 let pct = Memo::new(move |_| pct.get().clamp(0.0, 100.0));
273 view! {
274 <span
275 class="sys-meter"
276 data-warn=move || (!calm && pct.get() >= 80.0).then_some("true")
277 aria-hidden="true"
278 >
279 <span style=move || format!("width: {:.1}%", pct.get())></span>
280 </span>
281 }
282}
283
284#[component]
286pub fn System() -> impl IntoView {
287 let active_tab = RwSignal::new(SystemTab::ServerStatus);
288 let items = vec![
289 SegmentedItem::new("server_status".to_string(), "服务器"),
290 SegmentedItem::new("db_status".to_string(), "数据库"),
291 SegmentedItem::new("backup".to_string(), "备份"),
292 SegmentedItem::new("sql_console".to_string(), "SQL 控制台"),
293 SegmentedItem::new("export".to_string(), "导出"),
294 ];
295
296 view! {
297 <div class="admin-system">
298 <AdminPageHead title="系统">
299 "服务器与数据库状态、备份恢复、SQL 控制台与数据导出。"
300 </AdminPageHead>
301 <SegmentedControl
302 class="sys-tabs"
303 aria_label="系统分区"
304 items=items
305 value=Signal::derive(move || active_tab.get().as_str().to_string())
306 on_change=move |v: String| {
307 active_tab.set(SystemTab::from_str(&v).unwrap_or(SystemTab::ServerStatus));
308 }
309 />
310 {move || {
312 let tab = active_tab.get();
313 view! {
314 <div class="sys-tab">
315 {match tab {
316 SystemTab::ServerStatus => view! { <ServerStatusTab/> }.into_any(),
317 SystemTab::DbStatus => view! { <DbStatusTab/> }.into_any(),
318 SystemTab::Backup => view! { <BackupTab/> }.into_any(),
319 SystemTab::SqlConsole => view! { <SqlConsoleTab/> }.into_any(),
320 SystemTab::Export => view! { <ExportTab/> }.into_any(),
321 }}
322 </div>
323 }
324 }}
325 </div>
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::{urlencode, SystemTab};
332
333 #[test]
334 fn as_str_roundtrips_all_variants() {
335 for tab in [
337 SystemTab::DbStatus,
338 SystemTab::ServerStatus,
339 SystemTab::SqlConsole,
340 SystemTab::Export,
341 SystemTab::Backup,
342 ] {
343 let s = tab.as_str();
344 assert_eq!(
345 SystemTab::from_str(s),
346 Ok(tab),
347 "roundtrip failed for {tab:?}"
348 );
349 }
350 }
351
352 #[test]
353 fn as_str_returns_stable_keys() {
354 assert_eq!(SystemTab::DbStatus.as_str(), "db_status");
356 assert_eq!(SystemTab::ServerStatus.as_str(), "server_status");
357 assert_eq!(SystemTab::SqlConsole.as_str(), "sql_console");
358 assert_eq!(SystemTab::Export.as_str(), "export");
359 assert_eq!(SystemTab::Backup.as_str(), "backup");
360 }
361
362 #[test]
363 fn from_str_rejects_unknown_and_empty() {
364 assert!(SystemTab::from_str("nonsense").is_err());
365 assert!(SystemTab::from_str("").is_err());
366 assert!(SystemTab::from_str("DbStatus").is_err());
368 }
369
370 #[test]
371 fn urlencode_keeps_unreserved_and_escapes_the_rest() {
372 assert_eq!(urlencode("auto_2026-01.sql~"), "auto_2026-01.sql~");
373 assert_eq!(urlencode("a b&c=中"), "a%20b%26c%3D%E4%B8%AD");
374 }
375}