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:
40
src/App.tsx
40
src/App.tsx
@@ -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 */}
|
||||
|
||||
261
src/components/InstanceTabBar.tsx
Normal file
261
src/components/InstanceTabBar.tsx
Normal 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);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
15
src/lib/instance-context.tsx
Normal file
15
src/lib/instance-context.tsx
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user