yggdrasil/pages/admin/system/
backup.rs1use dioxus::prelude::*;
4
5use crate::components::ui::{LoadingButton, BTN_OUTLINE, BTN_TEXT_AMBER, BTN_TEXT_RED};
6
7use super::format_bytes;
8
9#[allow(non_snake_case)]
11#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
12pub(super) fn BackupTab() -> Element {
13 use crate::api::database::backup::BackupInfo;
14 #[cfg(target_arch = "wasm32")]
15 use crate::api::database::backup::{
16 create_backup, delete_backup, list_backups, restore_backup,
17 };
18 use crate::api::database::tasks::TaskProgress;
19 #[cfg(target_arch = "wasm32")]
20 use crate::api::database::tasks::{get_task_progress, TaskStatus};
21 use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS};
22
23 let backups = use_signal(Vec::<BackupInfo>::new);
26 let mut loading = use_signal(|| false);
27 let mut error = use_signal(|| Option::<String>::None);
28 let active_task_id: Signal<Option<String>> = use_signal(|| None);
30 let mut active_progress = use_signal(|| Option::<TaskProgress>::None);
31 let mut busy = use_signal(|| false);
32
33 let mut refresh_list = move || {
35 loading.set(true);
36 #[cfg(target_arch = "wasm32")]
37 {
38 let mut backups = backups;
39 let mut error = error;
40 spawn(async move {
41 match list_backups().await {
42 Ok(list) => {
43 backups.set(list);
44 error.set(None);
45 }
46 Err(e) => error.set(Some(e.to_string())),
47 }
48 loading.set(false);
49 });
50 }
51 #[cfg(not(target_arch = "wasm32"))]
52 {
53 loading.set(false);
54 }
55 };
56
57 use_effect(move || {
58 refresh_list();
59 });
60
61 use_future(move || {
68 let mut active_task_id = active_task_id;
69 let mut active_progress = active_progress;
70 let mut backups_f = backups;
71 let mut busy_f = busy;
72 async move {
73 #[cfg(target_arch = "wasm32")]
74 {
75 loop {
76 let tid = match active_task_id() {
77 Some(t) => t,
78 None => {
79 crate::utils::time::sleep_ms(200).await;
81 continue;
82 }
83 };
84 loop {
86 crate::utils::time::sleep_ms(1500).await;
87 match get_task_progress(tid.clone()).await {
88 Ok(p) => {
89 let done =
90 p.status == TaskStatus::Done || p.status == TaskStatus::Failed;
91 active_progress.set(Some(p));
92 if done {
93 if let Ok(list) = list_backups().await {
95 backups_f.set(list);
96 }
97 active_task_id.set(None);
98 busy_f.set(false);
99 break;
100 }
101 }
102 Err(_) => {
103 active_task_id.set(None);
104 busy_f.set(false);
105 break;
106 }
107 }
108 }
109 }
111 }
112 #[cfg(not(target_arch = "wasm32"))]
113 {
114 let _ = (active_task_id, active_progress, backups_f, busy_f);
115 }
116 }
117 });
118
119 let current_backups = backups.read().clone();
120 let current_error = error.read().clone();
121 let current_progress = active_progress.read().clone();
122 let is_busy = busy();
123 let backup_rows: Vec<(String, String, String, String)> = current_backups
126 .iter()
127 .map(|b| {
128 (
129 b.filename.clone(),
130 b.mode.clone(),
131 format_bytes(b.size_bytes as i64),
132 format!("/api/database/backups/{}", urlencode_dl(&b.filename)),
133 )
134 })
135 .collect();
136
137 rsx! {
138 div { class: "space-y-4",
139 div { class: "flex items-center gap-3",
141 LoadingButton {
142 label: "创建备份".to_string(),
143 loading: is_busy,
144 variant: "sm",
145 onclick: move |_| {
146 #[cfg(target_arch = "wasm32")]
147 {
148 busy.set(true);
149 active_progress.set(None);
150 let mut active_task_id = active_task_id;
151 spawn(async move {
152 match create_backup().await {
153 Ok(id) => active_task_id.set(Some(id)),
154 Err(e) => {
155 error.set(Some(e.to_string()));
156 busy.set(false);
157 }
158 }
159 });
160 }
161 },
162 }
163 button {
164 class: "{BTN_OUTLINE}",
165 disabled: loading() || is_busy,
166 onclick: move |_| refresh_list(),
167 "刷新列表"
168 }
169 }
170
171 if let Some(p) = current_progress {
173 div { class: "{ADMIN_CARD_CLASS} p-4",
174 div { class: "flex items-center justify-between mb-2",
175 span { class: "text-sm font-medium text-paper-primary", "{p.stage}" }
176 span { class: "text-sm text-paper-secondary", "{p.percent}%" }
177 }
178 div { class: "w-full bg-paper-entry rounded-full h-2 overflow-hidden",
179 div {
180 class: "bg-paper-accent h-full transition-all",
181 style: "width: {p.percent}%",
182 }
183 }
184 if let Some(detail) = p.detail {
185 p { class: "text-xs text-paper-secondary mt-2", "{detail}" }
186 }
187 if let Some(err) = p.error {
188 p { class: "text-xs text-red-600 dark:text-red-400 mt-2",
189 "错误:{err}"
190 }
191 }
192 }
193 }
194
195 if let Some(err) = current_error {
197 div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-sm text-red-700 dark:text-red-300",
198 "{err}"
199 }
200 }
201
202 if !current_backups.is_empty() {
204 div { class: "{ADMIN_TABLE_CLASS}",
205 div { class: "overflow-x-auto",
206 table { class: "w-full text-sm",
207 thead {
208 tr { class: "border-b border-paper-border text-left text-paper-secondary",
209 th { class: "px-4 py-2 font-medium", "文件名" }
210 th { class: "px-4 py-2 font-medium", "模式" }
211 th { class: "px-4 py-2 font-medium text-right",
212 "大小"
213 }
214 th { class: "px-4 py-2 font-medium text-right",
215 "操作"
216 }
217 }
218 }
219 tbody {
220 for (fname, mode, size_str, dl_url) in backup_rows.iter() {
221 BackupRow {
222 key: "{fname}",
223 filename: fname.clone(),
224 mode: mode.clone(),
225 size_str: size_str.clone(),
226 dl_url: dl_url.clone(),
227 busy: is_busy,
228 on_restore: move |f: String| {
233 #[cfg(target_arch = "wasm32")]
234 {
235 let mut busy = busy;
236 let mut active_progress = active_progress;
237 let mut active_task_id = active_task_id;
238 let mut error = error;
239 spawn(async move {
240 busy.set(true);
241 active_progress.set(None);
242 match restore_backup(f, true).await {
243 Ok(id) => active_task_id.set(Some(id)),
244 Err(e) => {
245 error.set(Some(e.to_string()));
246 busy.set(false);
247 }
248 }
249 });
250 }
251 },
252 on_delete: move |fname_del: String| {
255 #[cfg(target_arch = "wasm32")]
256 {
257 let mut backups = backups;
258 spawn(async move {
259 let _ = delete_backup(fname_del).await;
260 if let Ok(list) = list_backups().await {
261 backups.set(list);
262 }
263 });
264 }
265 },
266 }
267 }
268 }
269 }
270 }
271 }
272 } else if !loading() {
273 div { class: "text-paper-secondary text-sm py-4", "暂无备份文件" }
274 }
275
276 p { class: "text-xs text-paper-secondary",
277 "备份优先用 pg_dump(含 schema),不可用时回退纯 SQL(仅数据)。"
278 "恢复仅接受本系统生成的备份,且会覆盖现有数据。"
279 }
280 }
281 }
282}
283fn urlencode_dl(s: &str) -> String {
286 #[cfg(target_arch = "wasm32")]
287 {
288 let mut out = String::with_capacity(s.len());
289 for b in s.bytes() {
290 match b {
291 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
292 out.push(b as char);
293 }
294 b' ' => out.push('+'),
295 _ => out.push_str(&format!("%{:02X}", b)),
296 }
297 }
298 out
299 }
300 #[cfg(not(target_arch = "wasm32"))]
301 {
302 s.to_string()
303 }
304}
305#[derive(Props, Clone, PartialEq)]
311struct BackupRowProps {
312 filename: String,
313 mode: String,
314 size_str: String,
315 dl_url: String,
316 busy: bool,
317 on_restore: Callback<String>,
318 on_delete: Callback<String>,
319}
320
321#[component]
322#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
323fn BackupRow(props: BackupRowProps) -> Element {
324 use crate::components::ui::Popover;
325 use crate::components::ui::BTN_DANGER_OUTLINE;
326
327 let on_restore = props.on_restore;
329 let on_delete = props.on_delete;
330 let fname_for_restore = props.filename.clone();
331 let fname_for_delete = props.filename.clone();
332
333 let mut open_action = use_signal(|| Option::<String>::None);
336 let mut anchor_x = use_signal(|| 0i32);
338 let mut anchor_y = use_signal(|| 0i32);
339
340 rsx! {
341 tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
342 td { class: "px-4 py-2 font-mono text-xs text-paper-primary", "{props.filename}" }
343 td { class: "px-4 py-2 text-paper-secondary", "{props.mode}" }
344 td { class: "px-4 py-2 text-right text-paper-secondary", "{props.size_str}" }
345 td { class: "px-4 py-2 text-right whitespace-nowrap",
346 a {
347 class: "text-xs text-paper-accent hover:underline mr-3",
348 href: "{props.dl_url}",
349 download: "",
350 "下载"
351 }
352 button {
353 class: "{BTN_TEXT_AMBER} mr-3 disabled:opacity-50",
354 disabled: props.busy,
355 onclick: move |e| {
357 let c = e.client_coordinates();
358 anchor_x.set(c.x as i32);
359 anchor_y.set(c.y as i32);
360 open_action.set(Some("restore".to_string()));
361 },
362 "恢复"
363 }
364 button {
365 class: "{BTN_TEXT_RED} disabled:opacity-50",
366 disabled: props.busy,
367 onclick: move |e| {
368 let c = e.client_coordinates();
369 anchor_x.set(c.x as i32);
370 anchor_y.set(c.y as i32);
371 open_action.set(Some("delete".to_string()));
372 },
373 "删除"
374 }
375 }
376
377 Popover {
379 open: open_action().as_deref() == Some("restore"),
380 anchor_x: anchor_x(),
381 anchor_y: anchor_y(),
382 placement: "bottom",
383 on_close: move |_| open_action.set(None),
384 div { class: "w-64 space-y-3",
385 p { class: "text-sm text-paper-primary leading-relaxed",
386 "恢复将覆盖现有数据,确认恢复 "
387 span { class: "font-mono text-xs break-all", "{props.filename}" }
388 "?"
389 }
390 p { class: "text-xs text-paper-secondary",
391 "仅本系统生成的备份可恢复。"
392 }
393 div { class: "flex justify-end gap-2 pt-1",
394 button {
395 class: "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer",
396 onclick: move |_| open_action.set(None),
397 "取消"
398 }
399 button {
400 class: "{BTN_DANGER_OUTLINE}",
401 onclick: move |_| {
402 open_action.set(None);
403 on_restore.call(fname_for_restore.clone());
404 },
405 "确认恢复"
406 }
407 }
408 }
409 }
410
411 Popover {
413 open: open_action().as_deref() == Some("delete"),
414 anchor_x: anchor_x(),
415 anchor_y: anchor_y(),
416 placement: "bottom",
417 on_close: move |_| open_action.set(None),
418 div { class: "w-64 space-y-3",
419 p { class: "text-sm text-paper-primary",
420 "确认删除 "
421 span { class: "font-mono text-xs break-all", "{props.filename}" }
422 "?"
423 }
424 div { class: "flex justify-end gap-2 pt-1",
425 button {
426 class: "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer",
427 onclick: move |_| open_action.set(None),
428 "取消"
429 }
430 button {
431 class: "{BTN_DANGER_OUTLINE}",
432 onclick: move |_| {
433 open_action.set(None);
434 on_delete.call(fname_for_delete.clone());
435 },
436 "确认删除"
437 }
438 }
439 }
440 }
441 }
442 }
443}