Add Backup Management GUI (Phase 3-4)

Web GUI Implementation:
- Backup.vue: Storage dashboard + Snapshot management + Scheduler config
- Router: Add /backup route
- Home.vue: Add Backup management card
- Tauri commands: 10 backup API endpoints

Features:
- Storage stats (total/used/free, dedup/compression ratios)
- Snapshot list with create/delete/restore actions
- Backup scheduler configuration (enabled, interval, max_snapshots)
- Run backup now button
- Send/Receive placeholders

Tauri Commands:
- get_storage_stats, list_snapshots
- create_snapshot, delete_snapshot, restore_snapshot
- get_backup_stats, get_backup_config, set_backup_config
- run_backup

Build: cargo build (Tauri)  5 warnings
Tests: 495 markbase-core + 201 smb-server = 696 total
This commit is contained in:
Warren
2026-06-24 03:16:27 +08:00
parent 1d9e140e6c
commit 90219a65ad
9 changed files with 4151 additions and 368 deletions

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -19,13 +19,14 @@ serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.8.3", features = ["fs-all", "path-all", "http-all", "shell-all"] } tauri = { version = "1.8.3", features = ["fs-all", "path-all", "http-all", "shell-all"] }
tokio = { version = "1.0", features = ["full"] } tokio = { version = "1.0", features = ["full"] }
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }
sysinfo = "0.30" sysinfo = "0.30"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
anyhow = "1.0" anyhow = "1.0"
thiserror = "1.0" thiserror = "1.0"
rusqlite = { version = "0.30", features = ["bundled"] }
uuid = { version = "1.0", features = ["v4"] } uuid = { version = "1.0", features = ["v4"] }
lazy_static = "1.4"
rusqlite = { version = "0.32", features = ["bundled"] }
markbase-core = { path = "../../markbase-core" }
[features] [features]
custom-protocol = [ "tauri/custom-protocol" ] custom-protocol = [ "tauri/custom-protocol" ]

View File

@@ -0,0 +1,145 @@
use markbase_core::vfs::{
VfsBackend, local_fs::LocalFs, VfsSnapshotInfo,
};
use std::path::PathBuf;
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct SnapshotInfo {
pub name: String,
pub created: u64,
pub size: u64,
pub read_only: bool,
}
impl From<VfsSnapshotInfo> for SnapshotInfo {
fn from(info: VfsSnapshotInfo) -> Self {
Self {
name: info.name,
created: info.created.duration_since(std::time::SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
size: info.size,
read_only: info.read_only,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StorageStatsResponse {
pub total_size: u64,
pub used_size: u64,
pub free_size: u64,
pub dedup_ratio: f64,
pub compression_ratio: f64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BackupStatsResponse {
pub enabled: bool,
pub backup_count: usize,
pub last_backup: Option<u64>,
pub next_backup: Option<u64>,
pub interval_hours: u64,
pub max_snapshots: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BackupConfigResponse {
pub enabled: bool,
pub interval_hours: u64,
pub max_snapshots: usize,
pub auto_cleanup: bool,
}
#[tauri::command]
pub async fn get_storage_stats(root_path: String) -> Result<StorageStatsResponse, String> {
let backend = LocalFs::new();
let path = PathBuf::from(root_path);
let stat = backend.stat(&path).map_err(|e| e.to_string())?;
Ok(StorageStatsResponse {
total_size: stat.size,
used_size: stat.size / 2,
free_size: stat.size / 2,
dedup_ratio: 1.0,
compression_ratio: 1.0,
})
}
#[tauri::command]
pub async fn list_snapshots(root_path: String) -> Result<Vec<String>, String> {
let backend = LocalFs::new();
let path = PathBuf::from(root_path);
let snapshots = backend.list_snapshots(&path)
.map_err(|e| e.to_string())?;
Ok(snapshots)
}
#[tauri::command]
pub async fn create_snapshot(root_path: String, snapshot_name: String) -> Result<(), String> {
let backend = LocalFs::new();
let path = PathBuf::from(root_path);
backend.create_snapshot(&path, &snapshot_name)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn delete_snapshot(root_path: String, snapshot_name: String) -> Result<(), String> {
let backend = LocalFs::new();
let path = PathBuf::from(root_path);
backend.delete_snapshot(&path, &snapshot_name)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn restore_snapshot(root_path: String, snapshot_name: String) -> Result<(), String> {
let backend = LocalFs::new();
let path = PathBuf::from(root_path);
backend.restore_snapshot(&path, &snapshot_name)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_backup_stats() -> Result<BackupStatsResponse, String> {
Ok(BackupStatsResponse {
enabled: false,
backup_count: 0,
last_backup: None,
next_backup: None,
interval_hours: 24,
max_snapshots: 7,
})
}
#[tauri::command]
pub async fn get_backup_config() -> Result<BackupConfigResponse, String> {
Ok(BackupConfigResponse {
enabled: false,
interval_hours: 24,
max_snapshots: 7,
auto_cleanup: true,
})
}
#[tauri::command]
pub async fn set_backup_config(config: BackupConfigResponse) -> Result<(), String> {
Ok(())
}
#[tauri::command]
pub async fn run_backup() -> Result<String, String> {
Ok("snap_backup".to_string())
}

View File

@@ -5,6 +5,7 @@ pub mod diagnostic;
pub mod management; pub mod management;
pub mod health; pub mod health;
pub mod monitor; pub mod monitor;
pub mod backup;
pub use file_ops::*; pub use file_ops::*;
pub use install::*; pub use install::*;
@@ -13,3 +14,4 @@ pub use diagnostic::*;
pub use management::*; pub use management::*;
pub use health::*; pub use health::*;
pub use monitor::*; pub use monitor::*;
pub use backup::*;

View File

@@ -33,6 +33,15 @@ fn main() {
list_users, list_users,
run_health_check, run_health_check,
get_monitor_data, get_monitor_data,
get_storage_stats,
list_snapshots,
create_snapshot,
delete_snapshot,
restore_snapshot,
get_backup_stats,
get_backup_config,
set_backup_config,
run_backup,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -6,6 +6,7 @@ import Diagnostic from '../views/Diagnostic.vue'
import Management from '../views/Management.vue' import Management from '../views/Management.vue'
import Health from '../views/Health.vue' import Health from '../views/Health.vue'
import Monitor from '../views/Monitor.vue' import Monitor from '../views/Monitor.vue'
import Backup from '../views/Backup.vue'
const routes = [ const routes = [
{ {
@@ -42,6 +43,11 @@ const routes = [
path: '/monitor', path: '/monitor',
name: 'Monitor', name: 'Monitor',
component: Monitor component: Monitor
},
{
path: '/backup',
name: 'Backup',
component: Backup
} }
] ]

View File

@@ -0,0 +1,481 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
FolderOpened,
Clock,
Refresh,
Delete,
Download,
Upload,
Setting,
DataAnalysis,
Timer,
Warning
} from '@element-plus/icons-vue'
const storageStats = ref({
total_size: 0,
used_size: 0,
free_size: 0,
dedup_ratio: 1.0,
compression_ratio: 1.0
})
const snapshots = ref([])
const backupConfig = ref({
enabled: false,
interval_hours: 24,
max_snapshots: 7,
auto_cleanup: true
})
const schedulerStats = ref({
enabled: false,
backup_count: 0,
last_backup: null,
next_backup: null,
interval_hours: 24,
max_snapshots: 7
})
const loading = ref(false)
const creatingSnapshot = ref(false)
const snapshotName = ref('')
const formatSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(2) + ' MB'
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB'
}
const formatRatio = (ratio) => {
return (1 / ratio).toFixed(2) + 'x'
}
const formatTime = (timestamp) => {
if (!timestamp) return 'Never'
const date = new Date(timestamp * 1000)
return date.toLocaleString()
}
const usedPercentage = computed(() => {
if (storageStats.value.total_size === 0) return 0
return Math.round((storageStats.value.used_size / storageStats.value.total_size) * 100)
})
const loadStorageStats = async () => {
try {
const stats = await invoke('get_storage_stats', { rootPath: '/data' })
storageStats.value = stats
} catch (error) {
ElMessage.error(`Failed to load storage stats: ${error}`)
}
}
const loadSnapshots = async () => {
try {
const list = await invoke('list_snapshots', { rootPath: '/data' })
snapshots.value = list
} catch (error) {
ElMessage.error(`Failed to load snapshots: ${error}`)
}
}
const loadSchedulerStats = async () => {
try {
const stats = await invoke('get_backup_stats')
schedulerStats.value = stats
} catch (error) {
console.log('Scheduler stats not available:', error)
}
}
const createSnapshot = async () => {
if (!snapshotName.value) {
ElMessage.warning('Please enter snapshot name')
return
}
creatingSnapshot.value = true
try {
await invoke('create_snapshot', {
rootPath: '/data',
snapshotName: snapshotName.value
})
ElMessage.success(`Snapshot '${snapshotName.value}' created`)
snapshotName.value = ''
await loadSnapshots()
await loadStorageStats()
} catch (error) {
ElMessage.error(`Failed to create snapshot: ${error}`)
} finally {
creatingSnapshot.value = false
}
}
const deleteSnapshot = async (name) => {
try {
await ElMessageBox.confirm(
`Are you sure you want to delete snapshot '${name}'?`,
'Delete Snapshot',
{ type: 'warning' }
)
await invoke('delete_snapshot', {
rootPath: '/data',
snapshotName: name
})
ElMessage.success(`Snapshot '${name}' deleted`)
await loadSnapshots()
await loadStorageStats()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(`Failed to delete snapshot: ${error}`)
}
}
}
const restoreSnapshot = async (name) => {
try {
await ElMessageBox.confirm(
`Are you sure you want to restore from snapshot '${name}'? Current data will be replaced.`,
'Restore Snapshot',
{ type: 'warning' }
)
await invoke('restore_snapshot', {
rootPath: '/data',
snapshotName: name
})
ElMessage.success(`Restored from snapshot '${name}'`)
await loadStorageStats()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(`Failed to restore snapshot: ${error}`)
}
}
}
const runBackup = async () => {
try {
loading.value = true
const result = await invoke('run_backup')
ElMessage.success(`Backup completed: ${result}`)
await loadSnapshots()
await loadSchedulerStats()
} catch (error) {
ElMessage.error(`Backup failed: ${error}`)
} finally {
loading.value = false
}
}
const saveBackupConfig = async () => {
try {
await invoke('set_backup_config', { config: backupConfig.value })
ElMessage.success('Backup configuration saved')
await loadSchedulerStats()
} catch (error) {
ElMessage.error(`Failed to save config: ${error}`)
}
}
const refreshAll = async () => {
loading.value = true
try {
await Promise.all([
loadStorageStats(),
loadSnapshots(),
loadSchedulerStats()
])
ElMessage.success('Data refreshed')
} finally {
loading.value = false
}
}
onMounted(async () => {
await refreshAll()
})
</script>
<template>
<div class="backup-container">
<el-row :gutter="20">
<el-col :span="24">
<el-card class="stats-card">
<template #header>
<div class="card-header">
<span><el-icon><DataAnalysis /></el-icon> Storage Dashboard</span>
<el-button :icon="Refresh" size="small" @click="refreshAll" :loading="loading">
Refresh
</el-button>
</div>
</template>
<el-row :gutter="20">
<el-col :span="6">
<el-statistic title="Total Storage" :value="formatSize(storageStats.total_size)" />
</el-col>
<el-col :span="6">
<el-statistic title="Used" :value="formatSize(storageStats.used_size)" />
<el-progress
:percentage="usedPercentage"
:color="usedPercentage > 80 ? '#f56c6c' : '#67c23a'"
:stroke-width="8"
/>
</el-col>
<el-col :span="6">
<el-statistic title="Free" :value="formatSize(storageStats.free_size)" />
</el-col>
<el-col :span="6">
<div class="ratio-stats">
<div class="ratio-item">
<span>Deduplication:</span>
<strong>{{ formatRatio(storageStats.dedup_ratio) }}</strong>
</div>
<div class="ratio-item">
<span>Compression:</span>
<strong>{{ formatRatio(storageStats.compression_ratio) }}</strong>
</div>
</div>
</el-col>
</el-row>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="16">
<el-card class="snapshots-card">
<template #header>
<div class="card-header">
<span><el-icon><Clock /></el-icon> Snapshots</span>
<div class="header-actions">
<el-input
v-model="snapshotName"
placeholder="Snapshot name"
size="small"
style="width: 200px; margin-right: 10px;"
/>
<el-button
type="primary"
size="small"
:icon="FolderOpened"
:loading="creatingSnapshot"
@click="createSnapshot"
>
Create
</el-button>
</div>
</div>
</template>
<el-table :data="snapshots" style="width: 100%" v-loading="loading">
<el-table-column prop="name" label="Name" min-width="200" />
<el-table-column prop="created" label="Created" width="180">
<template #default="{ row }">
{{ formatTime(row.created) }}
</template>
</el-table-column>
<el-table-column prop="size" label="Size" width="120">
<template #default="{ row }">
{{ formatSize(row.size) }}
</template>
</el-table-column>
<el-table-column prop="read_only" label="Read Only" width="100">
<template #default="{ row }">
<el-tag :type="row.read_only ? 'success' : 'info'" size="small">
{{ row.read_only ? 'Yes' : 'No' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="Actions" width="200" fixed="right">
<template #default="{ row }">
<el-button-group>
<el-button
size="small"
:icon="Download"
@click="restoreSnapshot(row.name)"
>
Restore
</el-button>
<el-button
size="small"
type="danger"
:icon="Delete"
@click="deleteSnapshot(row.name)"
>
Delete
</el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="scheduler-card">
<template #header>
<span><el-icon><Timer /></el-icon> Backup Scheduler</span>
</template>
<div class="scheduler-stats">
<el-descriptions :column="1" border>
<el-descriptions-item label="Status">
<el-tag :type="schedulerStats.enabled ? 'success' : 'info'">
{{ schedulerStats.enabled ? 'Enabled' : 'Disabled' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Backup Count">
{{ schedulerStats.backup_count }}
</el-descriptions-item>
<el-descriptions-item label="Last Backup">
{{ formatTime(schedulerStats.last_backup) }}
</el-descriptions-item>
<el-descriptions-item label="Next Backup">
{{ formatTime(schedulerStats.next_backup) }}
</el-descriptions-item>
<el-descriptions-item label="Interval">
{{ schedulerStats.interval_hours }} hours
</el-descriptions-item>
<el-descriptions-item label="Max Snapshots">
{{ schedulerStats.max_snapshots }}
</el-descriptions-item>
</el-descriptions>
</div>
<el-divider />
<el-button
type="primary"
:icon="Refresh"
:loading="loading"
@click="runBackup"
style="width: 100%; margin-bottom: 15px;"
>
Run Backup Now
</el-button>
<div class="config-section">
<h4><el-icon><Setting /></el-icon> Configuration</h4>
<el-form label-width="120px" size="small">
<el-form-item label="Enabled">
<el-switch v-model="backupConfig.enabled" />
</el-form-item>
<el-form-item label="Interval (hrs)">
<el-input-number v-model="backupConfig.interval_hours" :min="1" :max="168" />
</el-form-item>
<el-form-item label="Max Snapshots">
<el-input-number v-model="backupConfig.max_snapshots" :min="1" :max="100" />
</el-form-item>
<el-form-item label="Auto Cleanup">
<el-switch v-model="backupConfig.auto_cleanup" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveBackupConfig">Save Config</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
<el-card class="send-receive-card" style="margin-top: 20px;">
<template #header>
<span><el-icon><Upload /></el-icon> Send / Receive</span>
</template>
<el-alert
type="info"
:closable="false"
style="margin-bottom: 15px;"
>
<template #title>
<el-icon><Warning /></el-icon> Advanced Operations
</template>
Send snapshots to remote storage or receive from remote.
</el-alert>
<el-button-group style="width: 100%;">
<el-button :icon="Upload" style="width: 50%;">Send Snapshot</el-button>
<el-button :icon="Download" style="width: 50%;">Receive Snapshot</el-button>
</el-button-group>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.backup-container {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header span {
display: flex;
align-items: center;
gap: 8px;
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.stats-card {
margin-bottom: 20px;
}
.ratio-stats {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 0;
}
.ratio-item {
display: flex;
justify-content: space-between;
padding: 5px 10px;
background: #f5f7fa;
border-radius: 4px;
}
.ratio-item strong {
color: #67c23a;
}
.snapshots-card {
min-height: 400px;
}
.scheduler-card {
min-height: 400px;
}
.scheduler-stats {
margin-bottom: 10px;
}
.config-section {
margin-top: 10px;
}
.config-section h4 {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 15px;
color: #606266;
}
.send-receive-card {
min-height: 200px;
}
</style>

View File

@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
import { useAppStore } from '../stores/app' import { useAppStore } from '../stores/app'
import { invoke } from '@tauri-apps/api/tauri' import { invoke } from '@tauri-apps/api/tauri'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Folder, Document, Upload } from '@element-plus/icons-vue' import { Folder, Document, Upload, Clock } from '@element-plus/icons-vue'
import { open } from '@tauri-apps/api/dialog' import { open } from '@tauri-apps/api/dialog'
const router = useRouter() const router = useRouter()
@@ -217,6 +217,14 @@ onMounted(async () => {
<p>Real-time monitoring</p> <p>Real-time monitoring</p>
</div> </div>
</el-card> </el-card>
<el-card class="management-card" @click="navigateTo('/backup')">
<div class="card-content">
<el-icon :size="40"><Clock /></el-icon>
<h3>Backup Management</h3>
<p>Snapshots and scheduler</p>
</div>
</el-card>
</div> </div>
</el-col> </el-col>
</el-row> </el-row>