From 55e7fea15e423a4c6238f8d285b3f4b9da0f87b0 Mon Sep 17 00:00:00 2001 From: zhixian Date: Wed, 18 Feb 2026 21:23:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20add=20SSH=20frontend=20=E2=80=94=20type?= =?UTF-8?q?s,=20API,=20Tab=20Bar,=20App=20integration,=20remote=20Home?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 40 ++- src/components/InstanceTabBar.tsx | 261 ++++++++++++++++++++ src/lib/api.ts | 36 ++- src/lib/instance-context.tsx | 15 ++ src/lib/types.ts | 22 ++ src/pages/Home.tsx | 387 ++++++++++++++++-------------- 6 files changed, 582 insertions(+), 179 deletions(-) create mode 100644 src/components/InstanceTabBar.tsx create mode 100644 src/lib/instance-context.tsx diff --git a/src/App.tsx b/src/App.tsx index 2299c8b..e1b33c1 100644 --- a/src/App.tsx +++ b/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([]); const [chatOpen, setChatOpen] = useState(false); + // SSH remote instance state + const [activeInstance, setActiveInstance] = useState("local"); + const [sshHosts, setSshHosts] = useState([]); + const [connectionStatus, setConnectionStatus] = useState>({}); + + 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 ( <> -
+
+ +
)} +
{/* Chat toggle -- top-right corner */} {!chatOpen && ( @@ -291,6 +325,7 @@ export function App() { )}
+
{/* Chat Panel -- inline, pushes main content */} {chatOpen && ( @@ -311,6 +346,7 @@ export function App() {
)} +
{/* Apply Changes Dialog */} diff --git a/src/components/InstanceTabBar.tsx b/src/components/InstanceTabBar.tsx new file mode 100644 index 0000000..2f3914b --- /dev/null +++ b/src/components/InstanceTabBar.tsx @@ -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; + onSelect: (id: string) => void; + onHostsChange: () => void; +} + +const emptyHost: Omit = { + 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(null); + const [form, setForm] = useState>(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 ; + }; + + return ( + <> +
+ {/* Local tab */} + + + {/* Remote tabs */} + {hosts.map((host) => ( +
+ + +
+ ))} + + {/* Add button */} + +
+ + {/* Add/Edit Dialog */} + + + + + {editingHost ? "Edit Remote Instance" : "Add Remote Instance"} + + +
+
+ + setForm((f) => ({ ...f, label: e.target.value }))} + placeholder="My Server" + /> +
+
+ + setForm((f) => ({ ...f, host: e.target.value }))} + placeholder="192.168.1.100" + /> +
+
+ + + setForm((f) => ({ ...f, port: parseInt(e.target.value, 10) || 22 })) + } + /> +
+
+ + setForm((f) => ({ ...f, username: e.target.value }))} + placeholder="root" + /> +
+
+ + +
+ {form.authMethod === "key" && ( +
+ + setForm((f) => ({ ...f, keyPath: e.target.value }))} + placeholder="~/.ssh/id_rsa" + /> +
+ )} +
+ + + + +
+
+ + ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 6906149..4bb1168 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 => @@ -106,4 +106,38 @@ export const api = { invoke("discard_config_changes", {}), applyPendingChanges: (): Promise => invoke("apply_pending_changes", {}), + + // SSH host management + listSshHosts: (): Promise => + invoke("list_ssh_hosts", {}), + upsertSshHost: (host: SshHost): Promise => + invoke("upsert_ssh_host", { host }), + deleteSshHost: (hostId: string): Promise => + invoke("delete_ssh_host", { hostId }), + + // SSH connection + sshConnect: (hostId: string): Promise => + invoke("ssh_connect", { hostId }), + sshDisconnect: (hostId: string): Promise => + invoke("ssh_disconnect", { hostId }), + sshStatus: (hostId: string): Promise => + invoke("ssh_status", { hostId }), + + // SSH primitives + sshExec: (hostId: string, command: string): Promise => + invoke("ssh_exec", { hostId, command }), + sftpReadFile: (hostId: string, path: string): Promise => + invoke("sftp_read_file", { hostId, path }), + sftpWriteFile: (hostId: string, path: string, content: string): Promise => + invoke("sftp_write_file", { hostId, path, content }), + sftpListDir: (hostId: string, path: string): Promise => + invoke("sftp_list_dir", { hostId, path }), + sftpRemoveFile: (hostId: string, path: string): Promise => + invoke("sftp_remove_file", { hostId, path }), + + // Remote business commands + remoteReadRawConfig: (hostId: string): Promise => + invoke("remote_read_raw_config", { hostId }), + remoteGetSystemStatus: (hostId: string): Promise> => + invoke("remote_get_system_status", { hostId }), }; diff --git a/src/lib/instance-context.tsx b/src/lib/instance-context.tsx new file mode 100644 index 0000000..c432b43 --- /dev/null +++ b/src/lib/instance-context.tsx @@ -0,0 +1,15 @@ +import { createContext, useContext } from "react"; + +interface InstanceContextValue { + instanceId: string; + isRemote: boolean; +} + +export const InstanceContext = createContext({ + instanceId: "local", + isRemote: false, +}); + +export function useInstance() { + return useContext(InstanceContext); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 3890ac8..1ec57f8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 04885d4..9559314 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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(null); const [version, setVersion] = useState(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({ )} - Default Model -
- {status ? ( - - ) : ( - ... - )} -
+ {!isRemote && ( + <> + Default Model +
+ {status ? ( + + ) : ( + ... + )} +
+ + )} @@ -332,136 +359,144 @@ export function Home({ )} {/* Recommended Recipes */} -

Recommended Recipes

- {recipes.length === 0 ? ( -

No recipes available.

- ) : ( -
- {recipes.map((recipe) => ( - onCook?.(recipe.id)} - compact - /> - ))} -
+ {!isRemote && ( + <> +

Recommended Recipes

+ {recipes.length === 0 ? ( +

No recipes available.

+ ) : ( +
+ {recipes.map((recipe) => ( + onCook?.(recipe.id)} + compact + /> + ))} +
+ )} + )} {/* Backups */} -
-

Backups

- -
- {backupMessage && ( -

{backupMessage}

- )} - {backups === null ? ( -
- - -
- ) : backups.length === 0 ? ( -

No backups available.

- ) : ( -
- {backups.map((backup) => ( - - -
-
{backup.name}
-
- {formatTime(backup.createdAt)} — {formatBytes(backup.sizeBytes)} -
-
-
- - - - +
+ {backupMessage && ( +

{backupMessage}

+ )} + {backups === null ? ( +
+ + +
+ ) : backups.length === 0 ? ( +

No backups available.

+ ) : ( +
+ {backups.map((backup) => ( + + +
+
{backup.name}
+
+ {formatTime(backup.createdAt)} — {formatBytes(backup.sizeBytes)} +
+
+
+ - - - - Restore from backup? - - This will restore config and workspace files from backup "{backup.name}". Current files will be overwritten. - - - - Cancel - { - api.restoreFromBackup(backup.name) - .then((msg) => setBackupMessage(msg)) - .catch((e) => setBackupMessage(`Restore failed: ${e}`)); - }} - > - Restore - - - - - - - - - - - Delete backup? - - This will permanently delete backup "{backup.name}". This action cannot be undone. - - - - Cancel - { - api.deleteBackup(backup.name) - .then(() => { - setBackupMessage(`Deleted backup "${backup.name}"`); - refreshBackups(); - }) - .catch((e) => setBackupMessage(`Delete failed: ${e}`)); - }} - > - Delete - - - - -
-
-
- ))} -
+ + + + + + + Restore from backup? + + This will restore config and workspace files from backup "{backup.name}". Current files will be overwritten. + + + + Cancel + { + api.restoreFromBackup(backup.name) + .then((msg) => setBackupMessage(msg)) + .catch((e) => setBackupMessage(`Restore failed: ${e}`)); + }} + > + Restore + + + + + + + + + + + Delete backup? + + This will permanently delete backup "{backup.name}". This action cannot be undone. + + + + Cancel + { + api.deleteBackup(backup.name) + .then(() => { + setBackupMessage(`Deleted backup "${backup.name}"`); + refreshBackups(); + }) + .catch((e) => setBackupMessage(`Delete failed: ${e}`)); + }} + > + Delete + + + + +
+ + + ))} + + )} + )} {/* Create Agent Dialog */}