Skip to main content

yggdrasil/pages/admin/system/
mod.rs

1//! 后台「系统」页面:服务器、数据库、备份、SQL 控制台、导出五个 tab。
2//!
3//! tab 状态用 `RwSignal`(不深链 / 不走路由)。各 tab 拆为独立子模块、状态互不
4//! 共享,切换时整段重建,旧 tab 的轮询与定时器随 owner 销毁而停止。本文件另提供各
5//! tab 共用的指标卡片、进度条、刷新控件与「定时刷新的状态」钩子。样式见
6//! styles/pages/admin-system.css。
7
8mod 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
33/// 路由:`/admin/system`。数据全部在客户端读取,没有 SSR 数据。
34pub 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/// 系统页的五个 tab。
48#[derive(Clone, Copy, PartialEq, Debug)]
49enum SystemTab {
50    /// 服务器状态(应用内 + 主机层 CPU / 内存 / 磁盘、缓存命中率)。
51    ServerStatus,
52    /// 数据库运行状态(表、索引、连接、迁移版本)。
53    DbStatus,
54    /// 备份与恢复(pg_dump + 任务进度)。
55    Backup,
56    /// SQL 控制台(全读写 + 护栏)。
57    SqlConsole,
58    /// 数据导出(SQL / CSV 流式下载)。
59    Export,
60}
61
62impl SystemTab {
63    /// 变体 → 稳定字符串 key(与基于 String 的 `SegmentedControl` 桥接)。
64    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    /// 字符串 key → 变体。未知 / 空串返回 Err(调用方回退到默认 tab)。
75    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
87/// 自动刷新间隔可选项(毫秒;None = 手动)。
88const 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
96/// URL 编码:只保留 `A-Za-z0-9-_.~`,其余按 UTF-8 字节写成 `%XX`。
97/// 导出参数与备份下载链接共用;两端编码一致,SSR 与水合产出同一份 href。
98const 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
108/// 按 `refresh_ms` 定时调用 `reload`。间隔变化时清掉旧定时器再建新的(不会叠加多个
109/// 轮询);effect 重跑与 owner 销毁都会清除定时器。effect 只在浏览器执行。
110fn 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/// 定时刷新的状态:数据、加载中、失败标记、刷新间隔与重新加载回调。
128#[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
137/// 挂载时读取一次,之后按 `refresh_ms` 定时刷新。
138///
139/// 每次请求领取递增的序号:只应用比已应用结果更新的响应,迟到的旧响应被丢弃
140/// (短间隔下较慢的响应也能落地,不会被后续请求饿死);`loading` 只在最新一次
141/// 请求结束时复位。组件销毁后迟到的响应经 `try_*` 静默丢弃。
142fn 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    // (已发出的最新序号, 已应用的最新序号)
153    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/// 刷新控件:自动刷新间隔与「立即刷新」。自动刷新时圆点呼吸。
202#[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/// 指标卡片:标签、数值、补充说明,可带进度条等子元素。
236///
237/// `value` / `meta` 可传字符串或信号;`meta` 为空时不渲染说明行。
238#[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/// 占用条。超过 80% 时转为陶土色提醒;`calm` 的不提醒(命中率、占比)。
268///
269/// `pct` 可传数值或信号:数值变化时节点原地更新,宽度走 CSS 过渡。
270#[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/// 系统页入口组件。
285#[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            // 切 tab 时整段重建:真正卸载旧 tab,轮询与状态不残留。
311            {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        // 每个变体经 as_str -> from_str 必须还原为自身。
336        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        // 字符串 key 必须稳定(改 key 会破坏 URL/调试/未来持久化),锁定之。
355        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        // 大小写敏感:不接受大写变体,避免歧义。
367        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}