feat: add SSH frontend — types, API, Tab Bar, App integration, remote Home

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zhixian
2026-02-18 21:23:50 +09:00
parent 4da9e2bd03
commit 55e7fea15e
6 changed files with 582 additions and 179 deletions

View File

@@ -8,6 +8,8 @@ import { Doctor } from "./pages/Doctor";
import { Channels } from "./pages/Channels";
import { Chat } from "./components/Chat";
import { DiffViewer } from "./components/DiffViewer";
import { InstanceTabBar } from "./components/InstanceTabBar";
import { InstanceContext } from "./lib/instance-context";
import { api } from "./lib/api";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -29,7 +31,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
import type { DiscordGuildChannel } from "./lib/types";
import type { DiscordGuildChannel, SshHost } from "./lib/types";
type Route = "home" | "recipes" | "cook" | "history" | "channels" | "doctor" | "settings";
@@ -48,6 +50,29 @@ export function App() {
const [discordGuildChannels, setDiscordGuildChannels] = useState<DiscordGuildChannel[]>([]);
const [chatOpen, setChatOpen] = useState(false);
// SSH remote instance state
const [activeInstance, setActiveInstance] = useState("local");
const [sshHosts, setSshHosts] = useState<SshHost[]>([]);
const [connectionStatus, setConnectionStatus] = useState<Record<string, "connected" | "disconnected" | "error">>({});
const refreshHosts = useCallback(() => {
api.listSshHosts().then(setSshHosts).catch((e) => console.error("Failed to load SSH hosts:", e));
}, []);
useEffect(() => {
refreshHosts();
}, [refreshHosts]);
const handleInstanceSelect = useCallback((id: string) => {
setActiveInstance(id);
if (id !== "local") {
setConnectionStatus((prev) => ({ ...prev, [id]: prev[id] || "disconnected" }));
api.sshConnect(id)
.then(() => setConnectionStatus((prev) => ({ ...prev, [id]: "connected" })))
.catch(() => setConnectionStatus((prev) => ({ ...prev, [id]: "error" })));
}
}, []);
// Config dirty state
const [dirty, setDirty] = useState(false);
const [showApplyDialog, setShowApplyDialog] = useState(false);
@@ -145,7 +170,15 @@ export function App() {
return (
<>
<div className="flex h-screen">
<div className="flex flex-col h-screen">
<InstanceTabBar
hosts={sshHosts}
activeId={activeInstance}
connectionStatus={connectionStatus}
onSelect={handleInstanceSelect}
onHostsChange={refreshHosts}
/>
<div className="flex flex-1 overflow-hidden">
<aside className="w-[200px] min-w-[200px] bg-muted border-r border-border flex flex-col py-4">
<h1 className="px-4 text-lg font-bold mb-4">ClawPal</h1>
<nav className="flex flex-col gap-1 px-2 flex-1">
@@ -235,6 +268,7 @@ export function App() {
</div>
)}
</aside>
<InstanceContext.Provider value={{ instanceId: activeInstance, isRemote: activeInstance !== "local" }}>
<main className="flex-1 overflow-y-auto p-4 relative">
{/* Chat toggle -- top-right corner */}
{!chatOpen && (
@@ -291,6 +325,7 @@ export function App() {
<Settings key={configVersion} onDataChange={bumpConfigVersion} />
)}
</main>
</InstanceContext.Provider>
{/* Chat Panel -- inline, pushes main content */}
{chatOpen && (
@@ -311,6 +346,7 @@ export function App() {
</div>
</aside>
)}
</div>
</div>
{/* Apply Changes Dialog */}

View File

@@ -0,0 +1,261 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { SshHost } from "@/lib/types";
interface InstanceTabBarProps {
hosts: SshHost[];
activeId: string; // "local" or host.id
connectionStatus: Record<string, "connected" | "disconnected" | "error">;
onSelect: (id: string) => void;
onHostsChange: () => void;
}
const emptyHost: Omit<SshHost, "id"> = {
label: "",
host: "",
port: 22,
username: "",
authMethod: "ssh_config",
keyPath: undefined,
};
export function InstanceTabBar({
hosts,
activeId,
connectionStatus,
onSelect,
onHostsChange,
}: InstanceTabBarProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingHost, setEditingHost] = useState<SshHost | null>(null);
const [form, setForm] = useState<Omit<SshHost, "id">>(emptyHost);
const [saving, setSaving] = useState(false);
const openAddDialog = () => {
setEditingHost(null);
setForm({ ...emptyHost });
setDialogOpen(true);
};
const openEditDialog = (host: SshHost) => {
setEditingHost(host);
setForm({
label: host.label,
host: host.host,
port: host.port,
username: host.username,
authMethod: host.authMethod,
keyPath: host.keyPath,
});
setDialogOpen(true);
};
const handleSave = () => {
const host: SshHost = {
id: editingHost?.id ?? crypto.randomUUID(),
...form,
};
setSaving(true);
api
.upsertSshHost(host)
.then(() => {
onHostsChange();
setDialogOpen(false);
})
.catch((e) => console.error("Failed to save SSH host:", e))
.finally(() => setSaving(false));
};
const handleDelete = (hostId: string) => {
api
.deleteSshHost(hostId)
.then(() => {
onHostsChange();
if (activeId === hostId) onSelect("local");
})
.catch((e) => console.error("Failed to delete SSH host:", e));
};
const statusDot = (status: "connected" | "disconnected" | "error" | undefined) => {
const color =
status === "connected"
? "bg-green-500"
: status === "error"
? "bg-red-500"
: "bg-gray-400";
return <span className={cn("inline-block w-2 h-2 rounded-full shrink-0", color)} />;
};
return (
<>
<div className="flex items-center gap-0.5 px-2 py-1.5 bg-muted border-b border-border overflow-x-auto shrink-0">
{/* Local tab */}
<button
className={cn(
"flex items-center gap-1.5 px-3 py-1 rounded text-sm whitespace-nowrap transition-colors",
activeId === "local"
? "bg-background shadow-sm font-medium"
: "hover:bg-background/50"
)}
onClick={() => onSelect("local")}
>
{statusDot("connected")}
Local
</button>
{/* Remote tabs */}
{hosts.map((host) => (
<div
key={host.id}
className="relative group flex items-center"
>
<button
className={cn(
"flex items-center gap-1.5 px-3 py-1 rounded text-sm whitespace-nowrap transition-colors",
activeId === host.id
? "bg-background shadow-sm font-medium"
: "hover:bg-background/50"
)}
onClick={() => onSelect(host.id)}
onContextMenu={(e) => {
e.preventDefault();
openEditDialog(host);
}}
>
{statusDot(connectionStatus[host.id])}
{host.label || host.host}
</button>
<button
className="absolute -top-0.5 -right-0.5 hidden group-hover:flex items-center justify-center w-4 h-4 rounded-full bg-muted-foreground/20 hover:bg-destructive hover:text-white text-[10px] leading-none"
onClick={(e) => {
e.stopPropagation();
handleDelete(host.id);
}}
>
&times;
</button>
</div>
))}
{/* Add button */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={openAddDialog}
>
+
</Button>
</div>
{/* Add/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingHost ? "Edit Remote Instance" : "Add Remote Instance"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ssh-label">Label</Label>
<Input
id="ssh-label"
value={form.label}
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
placeholder="My Server"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ssh-host">Host</Label>
<Input
id="ssh-host"
value={form.host}
onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))}
placeholder="192.168.1.100"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ssh-port">Port</Label>
<Input
id="ssh-port"
type="number"
value={form.port}
onChange={(e) =>
setForm((f) => ({ ...f, port: parseInt(e.target.value, 10) || 22 }))
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ssh-username">Username</Label>
<Input
id="ssh-username"
value={form.username}
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
placeholder="root"
/>
</div>
<div className="space-y-1.5">
<Label>Auth Method</Label>
<Select
value={form.authMethod}
onValueChange={(val) =>
setForm((f) => ({
...f,
authMethod: val as "key" | "ssh_config",
keyPath: val === "ssh_config" ? undefined : f.keyPath,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ssh_config">SSH Config / Agent</SelectItem>
<SelectItem value="key">Private Key</SelectItem>
</SelectContent>
</Select>
</div>
{form.authMethod === "key" && (
<div className="space-y-1.5">
<Label htmlFor="ssh-keypath">Key Path</Label>
<Input
id="ssh-keypath"
value={form.keyPath || ""}
onChange={(e) => setForm((f) => ({ ...f, keyPath: e.target.value }))}
placeholder="~/.ssh/id_rsa"
/>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving || !form.host || !form.username}>
{saving ? "Saving..." : editingHost ? "Update" : "Add"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { AgentOverview, AgentSessionAnalysis, ApplyResult, BackupInfo, ChannelNode, ConfigDirtyState, DiscordGuildChannel, HistoryItem, ModelCatalogProvider, ModelProfile, PreviewResult, ProviderAuthSuggestion, Recipe, ResolvedApiKey, StatusLight, SystemStatus, DoctorReport, MemoryFile, SessionFile } from "./types";
import type { AgentOverview, AgentSessionAnalysis, ApplyResult, BackupInfo, ChannelNode, ConfigDirtyState, DiscordGuildChannel, HistoryItem, ModelCatalogProvider, ModelProfile, PreviewResult, ProviderAuthSuggestion, Recipe, ResolvedApiKey, StatusLight, SystemStatus, DoctorReport, MemoryFile, SessionFile, SshHost, SshExecResult, SftpEntry } from "./types";
export const api = {
getSystemStatus: (): Promise<SystemStatus> =>
@@ -106,4 +106,38 @@ export const api = {
invoke("discard_config_changes", {}),
applyPendingChanges: (): Promise<boolean> =>
invoke("apply_pending_changes", {}),
// SSH host management
listSshHosts: (): Promise<SshHost[]> =>
invoke("list_ssh_hosts", {}),
upsertSshHost: (host: SshHost): Promise<SshHost> =>
invoke("upsert_ssh_host", { host }),
deleteSshHost: (hostId: string): Promise<boolean> =>
invoke("delete_ssh_host", { hostId }),
// SSH connection
sshConnect: (hostId: string): Promise<boolean> =>
invoke("ssh_connect", { hostId }),
sshDisconnect: (hostId: string): Promise<boolean> =>
invoke("ssh_disconnect", { hostId }),
sshStatus: (hostId: string): Promise<string> =>
invoke("ssh_status", { hostId }),
// SSH primitives
sshExec: (hostId: string, command: string): Promise<SshExecResult> =>
invoke("ssh_exec", { hostId, command }),
sftpReadFile: (hostId: string, path: string): Promise<string> =>
invoke("sftp_read_file", { hostId, path }),
sftpWriteFile: (hostId: string, path: string, content: string): Promise<boolean> =>
invoke("sftp_write_file", { hostId, path, content }),
sftpListDir: (hostId: string, path: string): Promise<SftpEntry[]> =>
invoke("sftp_list_dir", { hostId, path }),
sftpRemoveFile: (hostId: string, path: string): Promise<boolean> =>
invoke("sftp_remove_file", { hostId, path }),
// Remote business commands
remoteReadRawConfig: (hostId: string): Promise<unknown> =>
invoke("remote_read_raw_config", { hostId }),
remoteGetSystemStatus: (hostId: string): Promise<Record<string, unknown>> =>
invoke("remote_get_system_status", { hostId }),
};

View File

@@ -0,0 +1,15 @@
import { createContext, useContext } from "react";
interface InstanceContextValue {
instanceId: string;
isRemote: boolean;
}
export const InstanceContext = createContext<InstanceContextValue>({
instanceId: "local",
isRemote: false,
});
export function useInstance() {
return useContext(InstanceContext);
}

View File

@@ -242,3 +242,25 @@ export interface BackupInfo {
createdAt: string;
sizeBytes: number;
}
export interface SshHost {
id: string;
label: string;
host: string;
port: number;
username: string;
authMethod: "key" | "ssh_config";
keyPath?: string;
}
export interface SshExecResult {
stdout: string;
stderr: string;
exitCode: number;
}
export interface SftpEntry {
name: string;
isDir: boolean;
size: number;
}

View File

@@ -26,6 +26,7 @@ import { RecipeCard } from "@/components/RecipeCard";
import { Skeleton } from "@/components/ui/skeleton";
import type { StatusLight, AgentOverview, Recipe, BackupInfo, ModelProfile } from "../lib/types";
import { formatTime, formatBytes } from "@/lib/utils";
import { useInstance } from "@/lib/instance-context";
interface AgentGroup {
identity: string;
@@ -57,6 +58,7 @@ export function Home({
onCook?: (recipeId: string, source?: string) => void;
showToast?: (message: string, type?: "success" | "error") => void;
}) {
const { instanceId, isRemote } = useInstance();
const [status, setStatus] = useState<StatusLight | null>(null);
const [version, setVersion] = useState<string | null>(null);
const [updateInfo, setUpdateInfo] = useState<{ available: boolean; latest?: string } | null>(null);
@@ -76,18 +78,29 @@ export function Home({
const retriesRef = useRef(0);
const fetchStatus = useCallback(() => {
api.getStatusLight().then((s) => {
setStatus(s);
if (s.healthy) {
if (isRemote) {
api.remoteGetSystemStatus(instanceId).then((s) => {
const healthy = s.healthy as boolean ?? false;
const activeAgents = s.activeAgents as number ?? 0;
const globalDefaultModel = s.globalDefaultModel as string | undefined;
setStatus({ healthy, activeAgents, globalDefaultModel });
setStatusSettled(true);
retriesRef.current = 0;
} else if (retriesRef.current < 5) {
retriesRef.current++;
} else {
setStatusSettled(true);
}
}).catch((e) => console.error("Failed to fetch status:", e));
}, []);
if (s.openclawVersion) setVersion(s.openclawVersion as string);
}).catch((e) => console.error("Failed to fetch remote status:", e));
} else {
api.getStatusLight().then((s) => {
setStatus(s);
if (s.healthy) {
setStatusSettled(true);
retriesRef.current = 0;
} else if (retriesRef.current < 5) {
retriesRef.current++;
} else {
setStatusSettled(true);
}
}).catch((e) => console.error("Failed to fetch status:", e));
}
}, [isRemote, instanceId]);
useEffect(() => {
fetchStatus();
@@ -97,8 +110,13 @@ export function Home({
}, [fetchStatus, statusSettled]);
const refreshAgents = useCallback(() => {
if (isRemote) {
// Remote agents not yet fully supported; show empty list
setAgents([]);
return;
}
api.listAgentsOverview().then(setAgents).catch((e) => console.error("Failed to load agents:", e));
}, []);
}, [isRemote]);
useEffect(() => {
refreshAgents();
@@ -108,17 +126,21 @@ export function Home({
}, [refreshAgents]);
useEffect(() => {
if (isRemote) { setRecipes([]); return; }
api.listRecipes().then((r) => setRecipes(r.slice(0, 4))).catch((e) => console.error("Failed to load recipes:", e));
}, []);
}, [isRemote]);
const refreshBackups = () => {
if (isRemote) { setBackups([]); return; }
api.listBackups().then(setBackups).catch((e) => console.error("Failed to load backups:", e));
};
useEffect(refreshBackups, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(refreshBackups, [isRemote]);
useEffect(() => {
if (isRemote) { setModelProfiles([]); return; }
api.listModelProfiles().then((p) => setModelProfiles(p.filter((m) => m.enabled))).catch((e) => console.error("Failed to load model profiles:", e));
}, []);
}, [isRemote]);
// Match current global model value to a profile ID
const currentModelProfileId = useMemo(() => {
@@ -136,8 +158,9 @@ export function Home({
const agentGroups = useMemo(() => groupAgents(agents || []), [agents]);
// Heavy call: version + update check, deferred
// Heavy call: version + update check, deferred (local only; remote version set in fetchStatus)
useEffect(() => {
if (isRemote) return;
const timer = setTimeout(() => {
api.getSystemStatus().then((s) => {
setVersion(s.openclawVersion);
@@ -150,7 +173,7 @@ export function Home({
}).catch((e) => console.error("Failed to fetch system status:", e));
}, 100);
return () => clearTimeout(timer);
}, []);
}, [isRemote]);
const handleDeleteAgent = (agentId: string) => {
api.deleteAgent(agentId)
@@ -218,39 +241,43 @@ export function Home({
)}
</div>
<span className="text-sm text-muted-foreground">Default Model</span>
<div className="max-w-xs">
{status ? (
<Select
value={currentModelProfileId || "__none__"}
onValueChange={(val) => {
setSavingModel(true);
api.setGlobalModel(val === "__none__" ? null : val)
.then(() => api.getStatusLight())
.then(setStatus)
.catch((e) => showToast?.(String(e), "error"))
.finally(() => setSavingModel(false));
}}
disabled={savingModel}
>
<SelectTrigger size="sm" className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
<span className="text-muted-foreground">not set</span>
</SelectItem>
{modelProfiles.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.provider}/{p.model}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="text-sm">...</span>
)}
</div>
{!isRemote && (
<>
<span className="text-sm text-muted-foreground">Default Model</span>
<div className="max-w-xs">
{status ? (
<Select
value={currentModelProfileId || "__none__"}
onValueChange={(val) => {
setSavingModel(true);
api.setGlobalModel(val === "__none__" ? null : val)
.then(() => api.getStatusLight())
.then(setStatus)
.catch((e) => showToast?.(String(e), "error"))
.finally(() => setSavingModel(false));
}}
disabled={savingModel}
>
<SelectTrigger size="sm" className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
<span className="text-muted-foreground">not set</span>
</SelectItem>
{modelProfiles.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.provider}/{p.model}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="text-sm">...</span>
)}
</div>
</>
)}
</CardContent>
</Card>
@@ -332,136 +359,144 @@ export function Home({
)}
{/* Recommended Recipes */}
<h3 className="text-lg font-semibold mt-6 mb-3">Recommended Recipes</h3>
{recipes.length === 0 ? (
<p className="text-muted-foreground">No recipes available.</p>
) : (
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3">
{recipes.map((recipe) => (
<RecipeCard
key={recipe.id}
recipe={recipe}
onCook={() => onCook?.(recipe.id)}
compact
/>
))}
</div>
{!isRemote && (
<>
<h3 className="text-lg font-semibold mt-6 mb-3">Recommended Recipes</h3>
{recipes.length === 0 ? (
<p className="text-muted-foreground">No recipes available.</p>
) : (
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3">
{recipes.map((recipe) => (
<RecipeCard
key={recipe.id}
recipe={recipe}
onCook={() => onCook?.(recipe.id)}
compact
/>
))}
</div>
)}
</>
)}
{/* Backups */}
<div className="flex items-center justify-between mt-6 mb-3">
<h3 className="text-lg font-semibold">Backups</h3>
<Button
size="sm"
variant="outline"
disabled={backingUp}
onClick={() => {
setBackingUp(true);
setBackupMessage("");
api.backupBeforeUpgrade()
.then((info) => {
setBackupMessage(`Created backup: ${info.name}`);
refreshBackups();
})
.catch((e) => setBackupMessage(`Backup failed: ${e}`))
.finally(() => setBackingUp(false));
}}
>
{backingUp ? "Creating..." : "Create Backup"}
</Button>
</div>
{backupMessage && (
<p className="text-sm text-muted-foreground mb-2">{backupMessage}</p>
)}
{backups === null ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : backups.length === 0 ? (
<p className="text-muted-foreground text-sm">No backups available.</p>
) : (
<div className="space-y-2">
{backups.map((backup) => (
<Card key={backup.name}>
<CardContent className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">{backup.name}</div>
<div className="text-xs text-muted-foreground">
{formatTime(backup.createdAt)} {formatBytes(backup.sizeBytes)}
</div>
</div>
<div className="flex gap-1.5">
<Button
size="sm"
variant="outline"
onClick={() => api.openUrl(backup.path)}
>
Show
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline">
Restore
{!isRemote && (
<>
<div className="flex items-center justify-between mt-6 mb-3">
<h3 className="text-lg font-semibold">Backups</h3>
<Button
size="sm"
variant="outline"
disabled={backingUp}
onClick={() => {
setBackingUp(true);
setBackupMessage("");
api.backupBeforeUpgrade()
.then((info) => {
setBackupMessage(`Created backup: ${info.name}`);
refreshBackups();
})
.catch((e) => setBackupMessage(`Backup failed: ${e}`))
.finally(() => setBackingUp(false));
}}
>
{backingUp ? "Creating..." : "Create Backup"}
</Button>
</div>
{backupMessage && (
<p className="text-sm text-muted-foreground mb-2">{backupMessage}</p>
)}
{backups === null ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : backups.length === 0 ? (
<p className="text-muted-foreground text-sm">No backups available.</p>
) : (
<div className="space-y-2">
{backups.map((backup) => (
<Card key={backup.name}>
<CardContent className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">{backup.name}</div>
<div className="text-xs text-muted-foreground">
{formatTime(backup.createdAt)} {formatBytes(backup.sizeBytes)}
</div>
</div>
<div className="flex gap-1.5">
<Button
size="sm"
variant="outline"
onClick={() => api.openUrl(backup.path)}
>
Show
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore from backup?</AlertDialogTitle>
<AlertDialogDescription>
This will restore config and workspace files from backup "{backup.name}". Current files will be overwritten.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
api.restoreFromBackup(backup.name)
.then((msg) => setBackupMessage(msg))
.catch((e) => setBackupMessage(`Restore failed: ${e}`));
}}
>
Restore
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive">
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete backup?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete backup "{backup.name}". This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
api.deleteBackup(backup.name)
.then(() => {
setBackupMessage(`Deleted backup "${backup.name}"`);
refreshBackups();
})
.catch((e) => setBackupMessage(`Delete failed: ${e}`));
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
))}
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline">
Restore
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore from backup?</AlertDialogTitle>
<AlertDialogDescription>
This will restore config and workspace files from backup "{backup.name}". Current files will be overwritten.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
api.restoreFromBackup(backup.name)
.then((msg) => setBackupMessage(msg))
.catch((e) => setBackupMessage(`Restore failed: ${e}`));
}}
>
Restore
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive">
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete backup?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete backup "{backup.name}". This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
api.deleteBackup(backup.name)
.then(() => {
setBackupMessage(`Deleted backup "${backup.name}"`);
refreshBackups();
})
.catch((e) => setBackupMessage(`Delete failed: ${e}`));
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
)}
{/* Create Agent Dialog */}