feat: add remote backup support (create, list, restore, delete)
Remote instances now get the same backup experience as local: - 4 new SSH-based Rust commands for remote backup operations - UpgradeDialog always runs backup step for both local and remote - Home page Backups section visible and functional for remote instances - Show button hidden for remote (no local path to open) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -4017,6 +4017,148 @@ pub fn delete_backup(backup_name: String) -> Result<bool, String> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Remote Backup / Restore (via SSH) ----
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn remote_backup_before_upgrade(
|
||||||
|
pool: State<'_, SshConnectionPool>,
|
||||||
|
host_id: String,
|
||||||
|
) -> Result<BackupInfo, String> {
|
||||||
|
let now_secs = unix_timestamp_secs();
|
||||||
|
let now_dt = chrono::DateTime::<chrono::Utc>::from_timestamp(now_secs as i64, 0);
|
||||||
|
let name = now_dt
|
||||||
|
.map(|dt| dt.format("%Y-%m-%d_%H%M%S").to_string())
|
||||||
|
.unwrap_or_else(|| format!("{now_secs}"));
|
||||||
|
|
||||||
|
let cmd = format!(
|
||||||
|
concat!(
|
||||||
|
"set -e; ",
|
||||||
|
"BDIR=\"$HOME/.openclaw/.clawpal/backups/{name}\"; ",
|
||||||
|
"mkdir -p \"$BDIR\"; ",
|
||||||
|
"cp \"$HOME/.openclaw/openclaw.json\" \"$BDIR/\" 2>/dev/null || true; ",
|
||||||
|
"cp -r \"$HOME/.openclaw/agents\" \"$BDIR/\" 2>/dev/null || true; ",
|
||||||
|
"cp -r \"$HOME/.openclaw/memory\" \"$BDIR/\" 2>/dev/null || true; ",
|
||||||
|
"du -sk \"$BDIR\" 2>/dev/null | awk '{{print $1 * 1024}}' || echo 0"
|
||||||
|
),
|
||||||
|
name = name
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = pool.exec_login(&host_id, &cmd).await?;
|
||||||
|
if result.exit_code != 0 {
|
||||||
|
return Err(format!("Remote backup failed (exit {}): {}", result.exit_code, result.stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
let size_bytes: u64 = result.stdout.trim().lines().last()
|
||||||
|
.and_then(|l| l.trim().parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(BackupInfo {
|
||||||
|
name,
|
||||||
|
path: String::new(),
|
||||||
|
created_at: format_timestamp_from_unix(now_secs),
|
||||||
|
size_bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn remote_list_backups(
|
||||||
|
pool: State<'_, SshConnectionPool>,
|
||||||
|
host_id: String,
|
||||||
|
) -> Result<Vec<BackupInfo>, String> {
|
||||||
|
// List backup directory names
|
||||||
|
let list_result = pool
|
||||||
|
.exec_login(&host_id, "ls -1d \"$HOME/.openclaw/.clawpal/backups\"/*/ 2>/dev/null || true")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let dirs: Vec<String> = list_result
|
||||||
|
.stdout
|
||||||
|
.lines()
|
||||||
|
.filter(|l| !l.trim().is_empty())
|
||||||
|
.map(|l| l.trim().trim_end_matches('/').to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if dirs.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a single command to get sizes for all backup dirs (du -sk is POSIX portable)
|
||||||
|
let du_parts: Vec<String> = dirs
|
||||||
|
.iter()
|
||||||
|
.map(|d| format!("du -sk '{}' 2>/dev/null || echo '0\t{}'", d, d))
|
||||||
|
.collect();
|
||||||
|
let du_cmd = du_parts.join("; ");
|
||||||
|
let du_result = pool.exec_login(&host_id, &du_cmd).await?;
|
||||||
|
|
||||||
|
let mut size_map = std::collections::HashMap::new();
|
||||||
|
for line in du_result.stdout.lines() {
|
||||||
|
let parts: Vec<&str> = line.splitn(2, '\t').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
let size_kb: u64 = parts[0].trim().parse().unwrap_or(0);
|
||||||
|
let path = parts[1].trim().trim_end_matches('/');
|
||||||
|
size_map.insert(path.to_string(), size_kb * 1024);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut backups: Vec<BackupInfo> = dirs
|
||||||
|
.iter()
|
||||||
|
.map(|d| {
|
||||||
|
let name = d.rsplit('/').next().unwrap_or(d).to_string();
|
||||||
|
let size_bytes = size_map.get(d.trim_end_matches('/')).copied().unwrap_or(0);
|
||||||
|
BackupInfo {
|
||||||
|
name: name.clone(),
|
||||||
|
path: String::new(),
|
||||||
|
created_at: name.clone(), // Name is the timestamp
|
||||||
|
size_bytes,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
backups.sort_by(|a, b| b.name.cmp(&a.name));
|
||||||
|
Ok(backups)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn remote_restore_from_backup(
|
||||||
|
pool: State<'_, SshConnectionPool>,
|
||||||
|
host_id: String,
|
||||||
|
backup_name: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let cmd = format!(
|
||||||
|
concat!(
|
||||||
|
"set -e; ",
|
||||||
|
"BDIR=\"$HOME/.openclaw/.clawpal/backups/{name}\"; ",
|
||||||
|
"[ -d \"$BDIR\" ] || {{ echo 'Backup not found'; exit 1; }}; ",
|
||||||
|
"cp \"$BDIR/openclaw.json\" \"$HOME/.openclaw/openclaw.json\" 2>/dev/null || true; ",
|
||||||
|
"[ -d \"$BDIR/agents\" ] && cp -r \"$BDIR/agents\" \"$HOME/.openclaw/\" 2>/dev/null || true; ",
|
||||||
|
"[ -d \"$BDIR/memory\" ] && cp -r \"$BDIR/memory\" \"$HOME/.openclaw/\" 2>/dev/null || true; ",
|
||||||
|
"echo 'Restored from backup '\"'\"'{name}'\"'\"''"
|
||||||
|
),
|
||||||
|
name = backup_name
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = pool.exec_login(&host_id, &cmd).await?;
|
||||||
|
if result.exit_code != 0 {
|
||||||
|
return Err(format!("Remote restore failed: {}", result.stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(format!("Restored from backup '{}'", backup_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn remote_delete_backup(
|
||||||
|
pool: State<'_, SshConnectionPool>,
|
||||||
|
host_id: String,
|
||||||
|
backup_name: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let cmd = format!(
|
||||||
|
"BDIR=\"$HOME/.openclaw/.clawpal/backups/{name}\"; [ -d \"$BDIR\" ] && rm -rf \"$BDIR\" && echo 'deleted' || echo 'not_found'",
|
||||||
|
name = backup_name
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = pool.exec_login(&host_id, &cmd).await?;
|
||||||
|
Ok(result.stdout.trim() == "deleted")
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_model_provider_base_url(cfg: &Value, provider: &str) -> Option<String> {
|
fn resolve_model_provider_base_url(cfg: &Value, provider: &str) -> Option<String> {
|
||||||
let provider = provider.trim();
|
let provider = provider.trim();
|
||||||
if provider.is_empty() {
|
if provider.is_empty() {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ use crate::commands::{
|
|||||||
remote_chat_via_openclaw, remote_check_openclaw_update,
|
remote_chat_via_openclaw, remote_check_openclaw_update,
|
||||||
remote_save_config_baseline, remote_check_config_dirty, remote_discard_config_changes, remote_apply_pending_changes,
|
remote_save_config_baseline, remote_check_config_dirty, remote_discard_config_changes, remote_apply_pending_changes,
|
||||||
run_openclaw_upgrade, remote_run_openclaw_upgrade,
|
run_openclaw_upgrade, remote_run_openclaw_upgrade,
|
||||||
|
remote_backup_before_upgrade, remote_list_backups, remote_restore_from_backup, remote_delete_backup,
|
||||||
RemoteConfigBaselines,
|
RemoteConfigBaselines,
|
||||||
};
|
};
|
||||||
use crate::ssh::SshConnectionPool;
|
use crate::ssh::SshConnectionPool;
|
||||||
@@ -149,6 +150,10 @@ pub fn run() {
|
|||||||
remote_apply_pending_changes,
|
remote_apply_pending_changes,
|
||||||
run_openclaw_upgrade,
|
run_openclaw_upgrade,
|
||||||
remote_run_openclaw_upgrade,
|
remote_run_openclaw_upgrade,
|
||||||
|
remote_backup_before_upgrade,
|
||||||
|
remote_list_backups,
|
||||||
|
remote_restore_from_backup,
|
||||||
|
remote_delete_backup,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("failed to run app");
|
.expect("failed to run app");
|
||||||
|
|||||||
@@ -48,20 +48,17 @@ export function UpgradeDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startUpgrade = async () => {
|
const startUpgrade = async () => {
|
||||||
if (isRemote) {
|
setStep("backup");
|
||||||
setStep("upgrading");
|
runBackup();
|
||||||
runUpgrade();
|
|
||||||
} else {
|
|
||||||
setStep("backup");
|
|
||||||
runBackup();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const runBackup = async () => {
|
const runBackup = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const info = await api.backupBeforeUpgrade();
|
const info = isRemote
|
||||||
|
? await api.remoteBackupBeforeUpgrade(instanceId)
|
||||||
|
: await api.backupBeforeUpgrade();
|
||||||
setBackupName(info.name);
|
setBackupName(info.name);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setStep("upgrading");
|
setStep("upgrading");
|
||||||
@@ -112,9 +109,7 @@ export function UpgradeDialog({
|
|||||||
<code className="font-medium text-primary">{latestVersion}</code>
|
<code className="font-medium text-primary">{latestVersion}</code>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{isRemote
|
This will back up your config and upgrade OpenClaw{isRemote ? " on the remote instance" : ""}.
|
||||||
? "This will upgrade OpenClaw on the remote instance."
|
|
||||||
: "This will back up your config and upgrade OpenClaw."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -201,6 +201,16 @@ export const api = {
|
|||||||
remoteApplyPendingChanges: (hostId: string): Promise<boolean> =>
|
remoteApplyPendingChanges: (hostId: string): Promise<boolean> =>
|
||||||
invoke("remote_apply_pending_changes", { hostId }),
|
invoke("remote_apply_pending_changes", { hostId }),
|
||||||
|
|
||||||
|
// Remote backup
|
||||||
|
remoteBackupBeforeUpgrade: (hostId: string): Promise<BackupInfo> =>
|
||||||
|
invoke("remote_backup_before_upgrade", { hostId }),
|
||||||
|
remoteListBackups: (hostId: string): Promise<BackupInfo[]> =>
|
||||||
|
invoke("remote_list_backups", { hostId }),
|
||||||
|
remoteRestoreFromBackup: (hostId: string, backupName: string): Promise<string> =>
|
||||||
|
invoke("remote_restore_from_backup", { hostId, backupName }),
|
||||||
|
remoteDeleteBackup: (hostId: string, backupName: string): Promise<boolean> =>
|
||||||
|
invoke("remote_delete_backup", { hostId, backupName }),
|
||||||
|
|
||||||
// Upgrade
|
// Upgrade
|
||||||
runOpenclawUpgrade: (): Promise<string> =>
|
runOpenclawUpgrade: (): Promise<string> =>
|
||||||
invoke("run_openclaw_upgrade", {}),
|
invoke("run_openclaw_upgrade", {}),
|
||||||
|
|||||||
@@ -139,11 +139,15 @@ export function Home({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshBackups = () => {
|
const refreshBackups = () => {
|
||||||
if (isRemote) { setBackups([]); return; }
|
if (isRemote) {
|
||||||
api.listBackups().then(setBackups).catch((e) => console.error("Failed to load backups:", e));
|
if (!isConnected) return;
|
||||||
|
api.remoteListBackups(instanceId).then(setBackups).catch((e) => console.error("Failed to load remote backups:", e));
|
||||||
|
} else {
|
||||||
|
api.listBackups().then(setBackups).catch((e) => console.error("Failed to load backups:", e));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
useEffect(refreshBackups, [isRemote]);
|
useEffect(refreshBackups, [isRemote, isConnected, instanceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRemote) {
|
if (isRemote) {
|
||||||
@@ -477,123 +481,130 @@ export function Home({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Backups */}
|
{/* Backups */}
|
||||||
{!isRemote && (
|
<div className="flex items-center justify-between mt-6 mb-3">
|
||||||
<>
|
<h3 className="text-lg font-semibold">Backups</h3>
|
||||||
<div className="flex items-center justify-between mt-6 mb-3">
|
<Button
|
||||||
<h3 className="text-lg font-semibold">Backups</h3>
|
size="sm"
|
||||||
<Button
|
variant="outline"
|
||||||
size="sm"
|
disabled={backingUp}
|
||||||
variant="outline"
|
onClick={() => {
|
||||||
disabled={backingUp}
|
setBackingUp(true);
|
||||||
onClick={() => {
|
setBackupMessage("");
|
||||||
setBackingUp(true);
|
const backupPromise = isRemote
|
||||||
setBackupMessage("");
|
? api.remoteBackupBeforeUpgrade(instanceId)
|
||||||
api.backupBeforeUpgrade()
|
: api.backupBeforeUpgrade();
|
||||||
.then((info) => {
|
backupPromise
|
||||||
setBackupMessage(`Created backup: ${info.name}`);
|
.then((info) => {
|
||||||
refreshBackups();
|
setBackupMessage(`Created backup: ${info.name}`);
|
||||||
})
|
refreshBackups();
|
||||||
.catch((e) => setBackupMessage(`Backup failed: ${e}`))
|
})
|
||||||
.finally(() => setBackingUp(false));
|
.catch((e) => setBackupMessage(`Backup failed: ${e}`))
|
||||||
}}
|
.finally(() => setBackingUp(false));
|
||||||
>
|
}}
|
||||||
{backingUp ? "Creating..." : "Create Backup"}
|
>
|
||||||
</Button>
|
{backingUp ? "Creating..." : "Create Backup"}
|
||||||
</div>
|
</Button>
|
||||||
{backupMessage && (
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mb-2">{backupMessage}</p>
|
{backupMessage && (
|
||||||
)}
|
<p className="text-sm text-muted-foreground mb-2">{backupMessage}</p>
|
||||||
{backups === null ? (
|
)}
|
||||||
<div className="space-y-2">
|
{backups === null ? (
|
||||||
<Skeleton className="h-16 w-full" />
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-16 w-full" />
|
<Skeleton className="h-16 w-full" />
|
||||||
</div>
|
<Skeleton className="h-16 w-full" />
|
||||||
) : backups.length === 0 ? (
|
</div>
|
||||||
<p className="text-muted-foreground text-sm">No backups available.</p>
|
) : backups.length === 0 ? (
|
||||||
) : (
|
<p className="text-muted-foreground text-sm">No backups available.</p>
|
||||||
<div className="space-y-2">
|
) : (
|
||||||
{backups.map((backup) => (
|
<div className="space-y-2">
|
||||||
<Card key={backup.name}>
|
{backups.map((backup) => (
|
||||||
<CardContent className="flex items-center justify-between">
|
<Card key={backup.name}>
|
||||||
<div>
|
<CardContent className="flex items-center justify-between">
|
||||||
<div className="font-medium text-sm">{backup.name}</div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="font-medium text-sm">{backup.name}</div>
|
||||||
{formatTime(backup.createdAt)} — {formatBytes(backup.sizeBytes)}
|
<div className="text-xs text-muted-foreground">
|
||||||
</div>
|
{formatTime(backup.createdAt)} — {formatBytes(backup.sizeBytes)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
</div>
|
||||||
<Button
|
<div className="flex gap-1.5">
|
||||||
size="sm"
|
{!isRemote && (
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={() => api.openUrl(backup.path)}
|
size="sm"
|
||||||
>
|
variant="outline"
|
||||||
Show
|
onClick={() => api.openUrl(backup.path)}
|
||||||
|
>
|
||||||
|
Show
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
Restore
|
||||||
</Button>
|
</Button>
|
||||||
<AlertDialog>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogContent>
|
||||||
<Button size="sm" variant="outline">
|
<AlertDialogHeader>
|
||||||
Restore
|
<AlertDialogTitle>Restore from backup?</AlertDialogTitle>
|
||||||
</Button>
|
<AlertDialogDescription>
|
||||||
</AlertDialogTrigger>
|
This will restore config and workspace files from backup "{backup.name}". Current files will be overwritten.
|
||||||
<AlertDialogContent>
|
</AlertDialogDescription>
|
||||||
<AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogTitle>Restore from backup?</AlertDialogTitle>
|
<AlertDialogFooter>
|
||||||
<AlertDialogDescription>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
This will restore config and workspace files from backup "{backup.name}". Current files will be overwritten.
|
<AlertDialogAction
|
||||||
</AlertDialogDescription>
|
onClick={() => {
|
||||||
</AlertDialogHeader>
|
const restorePromise = isRemote
|
||||||
<AlertDialogFooter>
|
? api.remoteRestoreFromBackup(instanceId, backup.name)
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
: api.restoreFromBackup(backup.name);
|
||||||
<AlertDialogAction
|
restorePromise
|
||||||
onClick={() => {
|
.then((msg) => setBackupMessage(msg))
|
||||||
api.restoreFromBackup(backup.name)
|
.catch((e) => setBackupMessage(`Restore failed: ${e}`));
|
||||||
.then((msg) => setBackupMessage(msg))
|
}}
|
||||||
.catch((e) => setBackupMessage(`Restore failed: ${e}`));
|
>
|
||||||
}}
|
Restore
|
||||||
>
|
</AlertDialogAction>
|
||||||
Restore
|
</AlertDialogFooter>
|
||||||
</AlertDialogAction>
|
</AlertDialogContent>
|
||||||
</AlertDialogFooter>
|
</AlertDialog>
|
||||||
</AlertDialogContent>
|
<AlertDialog>
|
||||||
</AlertDialog>
|
<AlertDialogTrigger asChild>
|
||||||
<AlertDialog>
|
<Button size="sm" variant="destructive">
|
||||||
<AlertDialogTrigger asChild>
|
Delete
|
||||||
<Button size="sm" variant="destructive">
|
</Button>
|
||||||
Delete
|
</AlertDialogTrigger>
|
||||||
</Button>
|
<AlertDialogContent>
|
||||||
</AlertDialogTrigger>
|
<AlertDialogHeader>
|
||||||
<AlertDialogContent>
|
<AlertDialogTitle>Delete backup?</AlertDialogTitle>
|
||||||
<AlertDialogHeader>
|
<AlertDialogDescription>
|
||||||
<AlertDialogTitle>Delete backup?</AlertDialogTitle>
|
This will permanently delete backup "{backup.name}". This action cannot be undone.
|
||||||
<AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
This will permanently delete backup "{backup.name}". This action cannot be undone.
|
</AlertDialogHeader>
|
||||||
</AlertDialogDescription>
|
<AlertDialogFooter>
|
||||||
</AlertDialogHeader>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogFooter>
|
<AlertDialogAction
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
<AlertDialogAction
|
onClick={() => {
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
const deletePromise = isRemote
|
||||||
onClick={() => {
|
? api.remoteDeleteBackup(instanceId, backup.name)
|
||||||
api.deleteBackup(backup.name)
|
: api.deleteBackup(backup.name);
|
||||||
.then(() => {
|
deletePromise
|
||||||
setBackupMessage(`Deleted backup "${backup.name}"`);
|
.then(() => {
|
||||||
refreshBackups();
|
setBackupMessage(`Deleted backup "${backup.name}"`);
|
||||||
})
|
refreshBackups();
|
||||||
.catch((e) => setBackupMessage(`Delete failed: ${e}`));
|
})
|
||||||
}}
|
.catch((e) => setBackupMessage(`Delete failed: ${e}`));
|
||||||
>
|
}}
|
||||||
Delete
|
>
|
||||||
</AlertDialogAction>
|
Delete
|
||||||
</AlertDialogFooter>
|
</AlertDialogAction>
|
||||||
</AlertDialogContent>
|
</AlertDialogFooter>
|
||||||
</AlertDialog>
|
</AlertDialogContent>
|
||||||
</div>
|
</AlertDialog>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
))}
|
</Card>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create Agent Dialog */}
|
{/* Create Agent Dialog */}
|
||||||
|
|||||||
Reference in New Issue
Block a user