feat: Add SSH remote execution for multi-host Claude sessions
- Backend: SSH execution via spawn() with -T flag for JSON streaming - Backend: PATH setup for non-login shells on remote hosts - Backend: History loading via SSH (tail -n 2000 for large files) - Frontend: Host selector UI with colored buttons in Sidebar - Frontend: Auto-select first project when host changes - Frontend: Pass host parameter to history and session APIs - Docker: Install openssh-client, mount SSH keys Enables running Claude sessions on remote hosts via SSH while viewing them through the web UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -101,6 +101,7 @@ function App() {
|
||||
} = useClaudeSession();
|
||||
|
||||
const [selectedProject, setSelectedProject] = useState('/projects/claude-web-ui');
|
||||
const [selectedHost, setSelectedHost] = useState('local');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [resumeSession, setResumeSession] = useState(true);
|
||||
|
||||
@@ -114,7 +115,7 @@ function App() {
|
||||
}, [setMessages]);
|
||||
|
||||
const handleStartSession = () => {
|
||||
startSession(selectedProject, resumeSession);
|
||||
startSession(selectedProject, resumeSession, selectedHost);
|
||||
};
|
||||
|
||||
// Handle slash commands
|
||||
@@ -167,6 +168,8 @@ function App() {
|
||||
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||
selectedProject={selectedProject}
|
||||
onSelectProject={setSelectedProject}
|
||||
selectedHost={selectedHost}
|
||||
onSelectHost={setSelectedHost}
|
||||
sessionActive={sessionActive}
|
||||
onStartSession={handleStartSession}
|
||||
onStopSession={stopSession}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Play, Square, Trash2, FolderOpen, ChevronRight, Settings } from 'lucide-react';
|
||||
import { Play, Square, Trash2, FolderOpen, ChevronRight, Settings, Server } from 'lucide-react';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
|
||||
|
||||
@@ -8,6 +8,8 @@ export function Sidebar({
|
||||
onToggle,
|
||||
selectedProject,
|
||||
onSelectProject,
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
sessionActive,
|
||||
onStartSession,
|
||||
onStopSession,
|
||||
@@ -15,16 +17,39 @@ export function Sidebar({
|
||||
resumeSession,
|
||||
onToggleResume
|
||||
}) {
|
||||
const [hosts, setHosts] = useState([]);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [customPath, setCustomPath] = useState('');
|
||||
|
||||
// Fetch hosts on mount
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/api/projects`)
|
||||
fetch(`${API_URL}/api/hosts`)
|
||||
.then(res => res.json())
|
||||
.then(data => setProjects(data.projects || data))
|
||||
.then(data => {
|
||||
setHosts(data.hosts || []);
|
||||
if (!selectedHost && data.defaultHost) {
|
||||
onSelectHost(data.defaultHost);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Fetch projects when host changes
|
||||
useEffect(() => {
|
||||
if (!selectedHost) return;
|
||||
fetch(`${API_URL}/api/projects?host=${selectedHost}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const projectList = data.projects || data;
|
||||
setProjects(projectList);
|
||||
// Auto-select first project when host changes
|
||||
if (projectList.length > 0) {
|
||||
onSelectProject(projectList[0].path);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [selectedHost, onSelectProject]);
|
||||
|
||||
const handleCustomPath = () => {
|
||||
if (customPath.trim()) {
|
||||
onSelectProject(customPath.trim());
|
||||
@@ -49,6 +74,36 @@ export function Sidebar({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{/* Host Selection */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
|
||||
Host
|
||||
</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{hosts.map((host) => (
|
||||
<button
|
||||
key={host.id}
|
||||
onClick={() => onSelectHost(host.id)}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||
transition-colors border
|
||||
${selectedHost === host.id
|
||||
? 'border-orange-500/50 text-white'
|
||||
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: selectedHost === host.id ? `${host.color}30` : 'transparent'
|
||||
}}
|
||||
>
|
||||
<Server className="w-3.5 h-3.5" style={{ color: host.color }} />
|
||||
<span>{host.name}</span>
|
||||
{!host.isLocal && <span className="text-xs text-dark-500">(SSH)</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Selection */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
|
||||
|
||||
@@ -203,14 +203,15 @@ export function useClaudeSession() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadHistory = useCallback(async (project) => {
|
||||
const loadHistory = useCallback(async (project, host = null) => {
|
||||
try {
|
||||
const encodedProject = encodeURIComponent(project);
|
||||
const response = await fetch(`${API_URL}/api/history/${encodedProject}`);
|
||||
const hostParam = host ? `?host=${host}` : '';
|
||||
const response = await fetch(`${API_URL}/api/history/${encodedProject}${hostParam}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
console.log(`Loaded ${data.messages.length} messages from history`);
|
||||
console.log(`Loaded ${data.messages.length} messages from history (source: ${data.source || 'local'})`);
|
||||
setMessages(data.messages);
|
||||
return data.sessionId;
|
||||
}
|
||||
@@ -221,7 +222,7 @@ export function useClaudeSession() {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const startSession = useCallback(async (project = '/projects', resume = true) => {
|
||||
const startSession = useCallback(async (project = '/projects', resume = true, host = null) => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||
setError('Not connected');
|
||||
return;
|
||||
@@ -229,13 +230,14 @@ export function useClaudeSession() {
|
||||
|
||||
// Load history before starting session if resuming
|
||||
if (resume) {
|
||||
await loadHistory(project);
|
||||
await loadHistory(project, host);
|
||||
}
|
||||
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'start_session',
|
||||
project,
|
||||
resume
|
||||
resume,
|
||||
host
|
||||
}));
|
||||
}, [loadHistory]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user