- {/* Tab list scrollable area, full width except for Save button */}
-
@@ -205,6 +583,7 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
onOpenFile={handleOpenFile}
onRemoveRecent={handleRemoveRecent}
onPinFile={handlePinFile}
+ onUnpinFile={handleUnpinFile}
onOpenShortcut={handleOpenShortcut}
onRemoveShortcut={handleRemoveShortcut}
onAddShortcut={handleAddShortcut}
@@ -215,12 +594,46 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
if (!tab) return null;
return (
+ {/* Error display */}
+ {tab.error && (
+
+
+
+ ⚠️
+ {tab.error}
+
+
setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, error: undefined } : t))}
+ className="text-red-400 hover:text-red-300 transition-colors"
+ >
+ ✕
+
+
+
+ )}
+ {/* Success display */}
+ {tab.success && (
+
+
+
+ ✓
+ {tab.success}
+
+
setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, success: undefined } : t))}
+ className="text-green-400 hover:text-green-300 transition-colors"
+ >
+ ✕
+
+
+
+ )}
- setTabContent(tab.id, content)}
- />
+ />
);
diff --git a/src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx b/src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx
index 0cdaac43..3a5deb04 100644
--- a/src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx
+++ b/src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx
@@ -8,148 +8,84 @@ import {
SidebarProvider
} from '@/components/ui/sidebar.tsx';
import {Separator} from '@/components/ui/separator.tsx';
-import { Plus, CornerDownLeft, Folder, File, Star, Trash2, Edit, Link2, Server, ArrowUp, MoreVertical } from 'lucide-react';
-import {Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetFooter, SheetClose} from '@/components/ui/sheet.tsx';
-import {Button} from '@/components/ui/button.tsx';
-import {Input} from '@/components/ui/input.tsx';
-import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs.tsx';
-import {Switch} from '@/components/ui/switch.tsx';
-import {SheetDescription} from '@/components/ui/sheet.tsx';
-import {Form, FormField, FormItem, FormLabel, FormControl, FormMessage} from '@/components/ui/form.tsx';
-import {zodResolver} from '@hookform/resolvers/zod';
-import {useForm, Controller} from 'react-hook-form';
-import {z} from 'zod';
-import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover.tsx';
-import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx';
+import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react';
import {ScrollArea} from '@/components/ui/scroll-area.tsx';
import { cn } from '@/lib/utils.ts';
-import axios from 'axios';
+import {Input} from '@/components/ui/input.tsx';
+import {Button} from '@/components/ui/button.tsx';
+import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx';
+import {
+ getSSHHosts,
+ listSSHFiles,
+ connectSSH,
+ getSSHStatus,
+ getConfigEditorPinned,
+ addConfigEditorPinned,
+ removeConfigEditorPinned
+} from '@/apps/SSH/ssh-axios-fixed.ts';
-function getJWT() {
- return document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
+interface SSHHost {
+ id: number;
+ name: string;
+ ip: string;
+ port: number;
+ username: string;
+ folder: string;
+ tags: string[];
+ pin: boolean;
+ authType: string;
+ password?: string;
+ key?: string;
+ keyPassword?: string;
+ keyType?: string;
+ enableTerminal: boolean;
+ enableTunnel: boolean;
+ enableConfigEditor: boolean;
+ defaultPath: string;
+ tunnelConnections: any[];
+ createdAt: string;
+ updatedAt: string;
}
-const initialSSHForm = {name: '', ip: '', port: 22, username: '', password: '', sshKey: '', isPinned: false};
-
const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
- { onSelectView, onOpenFile, tabs }: { onSelectView: (view: string) => void; onOpenFile: (file: any) => void; tabs: any[] },
+ { onSelectView, onOpenFile, tabs, onHostChange }: {
+ onSelectView: (view: string) => void;
+ onOpenFile: (file: any) => void;
+ tabs: any[];
+ onHostChange?: (host: SSHHost | null) => void;
+ },
ref
) {
- const [addSheetOpen, setAddSheetOpen] = useState(false);
- const [addSubmitting, setAddSubmitting] = useState(false);
- const [addSubmitError, setAddSubmitError] = useState
(null);
- const addSSHForm = useForm({
- defaultValues: {
- name: '',
- ip: '',
- port: 22,
- username: '',
- password: '',
- sshKey: '',
- sshKeyFile: null,
- keyPassword: '',
- keyType: 'auto',
- isPinned: false,
- defaultPath: '/',
- folder: '',
- authMethod: 'password',
- tags: [] as string[],
- tagsInput: '',
- }
- });
- React.useEffect(() => {
- if (!addSheetOpen) {
- setAddSubmitError(null);
- addSSHForm.reset();
- }
- }, [addSheetOpen]);
- const handleAddSSH = () => {
- setAddSheetOpen(true);
- };
- // Update onAddSSHSubmit to only close the modal after a successful request, and show errors otherwise
- const onAddSSHSubmit = async (values: any) => {
- console.log('onAddSSHSubmit called', values);
- setAddSubmitError(null);
- setAddSubmitting(true);
- try {
- const jwt = getJWT();
- let sshKeyContent = values.sshKey;
- if (values.sshKeyFile instanceof File) {
- sshKeyContent = await values.sshKeyFile.text();
- }
- // Always send tags as a comma string
- const tags = Array.isArray(values.tags) ? values.tags.join(',') : (values.tags || '');
- // Build payload according to backend expectations
- let payload: any = {
- name: values.name,
- folder: values.folder,
- tags,
- ip: values.ip,
- port: values.port,
- username: values.username,
- authMethod: values.authMethod,
- isPinned: values.isPinned ? 1 : 0,
- defaultPath: values.defaultPath || null,
- };
- if (values.authMethod === 'password') {
- payload.password = values.password;
- payload.sshKey = null;
- payload.keyPassword = null;
- payload.keyType = null;
- } else if (values.authMethod === 'key') {
- payload.password = null;
- payload.sshKey = sshKeyContent;
- payload.keyPassword = values.keyPassword || null;
- payload.keyType = values.keyType || null;
- }
- // Remove unused fields
- // (do not send sshKeyFile, tagsInput, etc.)
- console.log('Submitting payload to /config_editor/ssh/host:', payload);
- await axios.post(`${API_BASE_DB}/config_editor/ssh/host`, payload, {headers: {Authorization: `Bearer ${jwt}`}});
- await fetchSSH();
- setAddSheetOpen(false);
- setTimeout(() => addSSHForm.reset(), 100); // reset after closing
- } catch (err: any) {
- let errorMsg = err?.response?.data?.error || err?.message || 'Failed to add SSH connection';
- if (typeof errorMsg !== 'string') {
- errorMsg = 'An unknown error occurred. Please check the backend logs.';
- }
- setAddSubmitError(errorMsg);
- } finally {
- setAddSubmitting(false);
- }
- };
- const [sshConnections, setSSHConnections] = useState([]);
+ const [sshConnections, setSSHConnections] = useState([]);
const [loadingSSH, setLoadingSSH] = useState(false);
const [errorSSH, setErrorSSH] = useState(undefined);
const [view, setView] = useState<'servers' | 'files'>('servers');
- const [activeServer, setActiveServer] = useState(null);
+ const [activeServer, setActiveServer] = useState(null);
const [currentPath, setCurrentPath] = useState('/');
const [files, setFiles] = useState([]);
- const [sshForm, setSSHForm] = useState(initialSSHForm);
- const [editingSSH, setEditingSSH] = useState(null);
- const [sshFormError, setSSHFormError] = useState(null);
- const [sshFormLoading, setSSHFormLoading] = useState(false);
const pathInputRef = useRef(null);
- const [showEditLocal, setShowEditLocal] = useState(false);
- const [localDefaultPath, setLocalDefaultPath] = useState('/');
- const [sshPopoverOpen, setSshPopoverOpen] = useState>({});
- const [folders, setFolders] = useState([]);
- const [foldersLoading, setFoldersLoading] = useState(false);
- const [foldersError, setFoldersError] = useState(null);
- const folderInputRef = useRef(null);
- const folderDropdownRef = useRef(null);
- const [folderInput, setFolderInput] = useState('');
+
// Add search bar state
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
+ const [fileSearch, setFileSearch] = useState('');
+ const [debouncedFileSearch, setDebouncedFileSearch] = useState('');
useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
+ useEffect(() => {
+ const handler = setTimeout(() => setDebouncedFileSearch(fileSearch), 200);
+ return () => clearTimeout(handler);
+ }, [fileSearch]);
- const API_BASE_DB = 'http://localhost:8081'; // For database-backed endpoints
- const API_BASE = 'http://localhost:8084'; // For stateless file/ssh operations
+ // Add state for SSH sessionId and loading/error
+ const [sshSessionId, setSshSessionId] = useState(null);
+ const [filesLoading, setFilesLoading] = useState(false);
+ const [filesError, setFilesError] = useState(null);
+ const [connectingSSH, setConnectingSSH] = useState(false);
+ const [connectionCache, setConnectionCache] = useState>({});
+ const [fetchingFiles, setFetchingFiles] = useState(false);
useEffect(() => {
fetchSSH();
@@ -159,144 +95,323 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
setLoadingSSH(true);
setErrorSSH(undefined);
try {
- const jwt = getJWT();
- const res = await axios.get(`${API_BASE_DB}/config_editor/ssh/host`, {headers: {Authorization: `Bearer ${jwt}`}});
- setSSHConnections(res.data || []);
+ const hosts = await getSSHHosts();
+ console.log('Loaded SSH hosts:', hosts);
+ // Filter hosts to only show those with enableConfigEditor: true
+ const configEditorHosts = hosts.filter(host => host.enableConfigEditor);
+ console.log('Config Editor hosts:', configEditorHosts);
+
+ // Debug: Log the first host's credentials
+ if (configEditorHosts.length > 0) {
+ const firstHost = configEditorHosts[0];
+ console.log('First host credentials:', {
+ id: firstHost.id,
+ name: firstHost.name,
+ ip: firstHost.ip,
+ username: firstHost.username,
+ authType: firstHost.authType,
+ hasPassword: !!firstHost.password,
+ hasKey: !!firstHost.key,
+ passwordLength: firstHost.password?.length,
+ keyLength: firstHost.key?.length
+ });
+ }
+
+ setSSHConnections(configEditorHosts);
} catch (err: any) {
+ console.error('Failed to load SSH hosts:', err);
setErrorSSH('Failed to load SSH connections');
} finally {
setLoadingSSH(false);
}
}
- // Add state for SSH sessionId and loading/error
- const [sshSessionId, setSshSessionId] = useState(null);
- const [filesLoading, setFilesLoading] = useState(false);
- const [filesError, setFilesError] = useState(null);
-
// Helper to connect to SSH and set sessionId
- async function connectSSH(server: any): Promise {
- const jwt = getJWT();
- const sessionId = server.id || `${server.ip}_${server.port}_${server.username}`;
+ async function connectToSSH(server: SSHHost): Promise {
+ const sessionId = server.id.toString();
+
+ // Check if we already have a recent connection to this server
+ const cached = connectionCache[sessionId];
+ if (cached && Date.now() - cached.timestamp < 30000) { // 30 second cache
+ console.log('Using cached SSH connection for session:', sessionId);
+ setSshSessionId(cached.sessionId);
+ return cached.sessionId;
+ }
+
+ // Prevent multiple simultaneous connections
+ if (connectingSSH) {
+ console.log('SSH connection already in progress, skipping...');
+ return null;
+ }
+
+ setConnectingSSH(true);
+
try {
- await axios.post(`${API_BASE}/ssh/connect`, {
+ console.log('Attempting SSH connection:', {
sessionId,
+ ip: server.ip,
+ port: server.port,
+ username: server.username,
+ hasPassword: !!server.password,
+ hasKey: !!server.key,
+ authType: server.authType,
+ passwordLength: server.password?.length,
+ keyLength: server.key?.length
+ });
+
+ // Check if we have the necessary credentials
+ if (!server.password && !server.key) {
+ console.error('No authentication credentials available for SSH host');
+ setFilesError('No authentication credentials available for this SSH host');
+ return null;
+ }
+
+ const connectionConfig = {
ip: server.ip,
port: server.port,
username: server.username,
password: server.password,
- sshKey: server.sshKey,
+ sshKey: server.key,
keyPassword: server.keyPassword,
- }, { headers: { Authorization: `Bearer ${jwt}` } });
+ };
+
+ console.log('SSH connection config:', {
+ ...connectionConfig,
+ password: connectionConfig.password ? '[REDACTED]' : undefined,
+ sshKey: connectionConfig.sshKey ? '[REDACTED]' : undefined
+ });
+
+ await connectSSH(sessionId, connectionConfig);
+
+ console.log('SSH connection successful for session:', sessionId);
setSshSessionId(sessionId);
+
+ // Cache the successful connection
+ setConnectionCache(prev => ({
+ ...prev,
+ [sessionId]: { sessionId, timestamp: Date.now() }
+ }));
+
return sessionId;
} catch (err: any) {
+ console.error('SSH connection failed:', {
+ sessionId,
+ error: err?.response?.data?.error || err?.message,
+ status: err?.response?.status,
+ data: err?.response?.data
+ });
setFilesError(err?.response?.data?.error || 'Failed to connect to SSH');
setSshSessionId(null);
return null;
+ } finally {
+ setConnectingSSH(false);
}
}
// Modified fetchFiles to handle SSH connect if needed
async function fetchFiles() {
+ // Prevent multiple simultaneous fetches
+ if (fetchingFiles) {
+ console.log('Already fetching files, skipping...');
+ return;
+ }
+
+ setFetchingFiles(true);
setFiles([]);
setFilesLoading(true);
setFilesError(null);
+
try {
- const jwt = getJWT();
- if (activeServer?.isLocal) {
- const res = await axios.get(`${API_BASE}/files`, {
- params: { folder: currentPath },
- headers: { Authorization: `Bearer ${jwt}` },
- });
- setFiles((res.data || []).map((f: any) => ({
- ...f,
- path: currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name,
- isStarred: false,
- isSSH: false
- })));
- } else if (activeServer) {
- // Ensure SSH session is established
- let sessionId = sshSessionId;
- if (!sessionId || sessionId !== activeServer.id) {
- sessionId = await connectSSH(activeServer);
- if (!sessionId) {
- setFiles([]);
- setFilesLoading(false);
- return;
+ // Get pinned files to check against for current host
+ let pinnedFiles: any[] = [];
+ try {
+ if (activeServer) {
+ pinnedFiles = await getConfigEditorPinned(activeServer.id);
+ console.log('Fetched pinned files:', pinnedFiles);
+ }
+ } catch (err) {
+ console.error('Failed to fetch pinned files:', err);
+ }
+
+ if (activeServer && sshSessionId) {
+ console.log('Fetching files for path:', currentPath, 'sessionId:', sshSessionId);
+
+ let res: any[] = [];
+
+ // Check if SSH session is still valid
+ try {
+ const status = await getSSHStatus(sshSessionId);
+ console.log('SSH session status:', status);
+ if (!status.connected) {
+ console.log('SSH session not connected, reconnecting...');
+ const newSessionId = await connectToSSH(activeServer);
+ if (newSessionId) {
+ setSshSessionId(newSessionId);
+ // Retry with new session
+ res = await listSSHFiles(newSessionId, currentPath);
+ console.log('Retry - Raw SSH files response:', res);
+ console.log('Retry - Files count:', res?.length || 0);
+ } else {
+ throw new Error('Failed to reconnect SSH session');
+ }
+ } else {
+ res = await listSSHFiles(sshSessionId, currentPath);
+ console.log('Raw SSH files response:', res);
+ console.log('Files count:', res?.length || 0);
+ console.log('Response type:', typeof res, 'Is array:', Array.isArray(res));
+ }
+ } catch (sessionErr) {
+ console.error('SSH session check failed:', sessionErr);
+ // Try to reconnect and retry
+ const newSessionId = await connectToSSH(activeServer);
+ if (newSessionId) {
+ setSshSessionId(newSessionId);
+ res = await listSSHFiles(newSessionId, currentPath);
+ console.log('Reconnect - Raw SSH files response:', res);
+ console.log('Reconnect - Files count:', res?.length || 0);
+ } else {
+ throw sessionErr;
}
}
- const res = await axios.get(`${API_BASE}/ssh/listFiles`, {
- params: { sessionId, path: currentPath },
- headers: { Authorization: `Bearer ${jwt}` },
+
+ const processedFiles = (res || []).map((f: any) => {
+ const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name;
+ const isPinned = pinnedFiles.some(pinned => pinned.path === filePath);
+ return {
+ ...f,
+ path: filePath,
+ isPinned,
+ isSSH: true,
+ sshSessionId: sshSessionId
+ };
});
- setFiles((res.data || []).map((f: any) => ({
- ...f,
- path: currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name,
- isStarred: false,
- isSSH: true,
- sshSessionId: sessionId
- })));
+
+ console.log('Processed files with pin states:', processedFiles);
+ setFiles(processedFiles);
}
} catch (err: any) {
+ console.error('Error in fetchFiles:', err);
+ console.error('Error details:', {
+ message: err?.message,
+ response: err?.response?.data,
+ status: err?.response?.status,
+ statusText: err?.response?.statusText
+ });
setFiles([]);
- setFilesError(err?.response?.data?.error || 'Failed to list files');
+ setFilesError(err?.response?.data?.error || err?.message || 'Failed to list files');
} finally {
setFilesLoading(false);
+ setFetchingFiles(false);
}
}
- // When activeServer or currentPath changes, fetch files
+ // When activeServer, currentPath, or sshSessionId changes, fetch files
useEffect(() => {
- if (view === 'files' && activeServer) fetchFiles();
+ console.log('useEffect triggered:', { view, activeServer: !!activeServer, sshSessionId, currentPath });
+
+ // Only fetch files if we're in files view, have an active server, and a valid session
+ if (view === 'files' && activeServer && sshSessionId && !connectingSSH && !fetchingFiles) {
+ console.log('Calling fetchFiles...');
+ // Add a small delay to prevent rapid reconnections
+ const timeoutId = setTimeout(() => {
+ fetchFiles();
+ }, 100);
+ return () => clearTimeout(timeoutId);
+ }
// eslint-disable-next-line
- }, [currentPath, view, activeServer]);
+ }, [currentPath, view, activeServer, sshSessionId]);
// When switching servers, reset sessionId and errors
- function handleSelectServer(server: any) {
+ async function handleSelectServer(server: SSHHost) {
+ // Prevent multiple rapid server selections
+ if (connectingSSH) {
+ console.log('SSH connection in progress, ignoring server selection');
+ return;
+ }
+
+ // Reset all states when switching servers
+ setFetchingFiles(false);
+ setFilesLoading(false);
+ setFilesError(null);
+ setFiles([]); // Clear files immediately to show loading state
+
setActiveServer(server);
setCurrentPath(server.defaultPath || '/');
setView('files');
- setSshSessionId(server.isLocal ? null : server.id);
- setFilesError(null);
+
+ // Establish SSH connection immediately when server is selected
+ const sessionId = await connectToSSH(server);
+ if (sessionId) {
+ setSshSessionId(sessionId);
+ // Notify parent component about host change
+ if (onHostChange) {
+ onHostChange(server);
+ }
+ } else {
+ // If SSH connection fails, stay in servers view
+ setView('servers');
+ setActiveServer(null);
+ }
}
useImperativeHandle(ref, () => ({
- openFolder: (server: any, path: string) => {
+ openFolder: async (server: SSHHost, path: string) => {
+ console.log('openFolder called:', { serverId: server.id, path, currentPath, activeServerId: activeServer?.id });
+
+ // Prevent multiple simultaneous folder opens
+ if (connectingSSH || fetchingFiles) {
+ console.log('SSH connection or file fetch in progress, skipping folder open');
+ return;
+ }
+
+ // If we're already on the same server and path, just refresh files
+ if (activeServer?.id === server.id && currentPath === path) {
+ console.log('Already on same server and path, just refreshing files');
+ // Add a small delay to prevent rapid successive calls
+ setTimeout(() => fetchFiles(), 100);
+ return;
+ }
+
+ // Reset all states when opening a folder
+ setFetchingFiles(false);
+ setFilesLoading(false);
+ setFilesError(null);
+ setFiles([]);
+
setActiveServer(server);
setCurrentPath(path);
setView('files');
- setSshSessionId(server.isLocal ? null : server.id);
- setFilesError(null);
+
+ // Only establish SSH connection if we don't already have one for this server
+ if (!sshSessionId || activeServer?.id !== server.id) {
+ console.log('Establishing new SSH connection for server:', server.id);
+ const sessionId = await connectToSSH(server);
+ if (sessionId) {
+ setSshSessionId(sessionId);
+ // Only notify parent component about host change if the server actually changed
+ if (onHostChange && activeServer?.id !== server.id) {
+ onHostChange(server);
+ }
+ } else {
+ // If SSH connection fails, stay in servers view
+ setView('servers');
+ setActiveServer(null);
+ }
+ } else {
+ console.log('Using existing SSH session for server:', server.id);
+ // Only notify parent component about host change if the server actually changed
+ if (onHostChange && activeServer?.id !== server.id) {
+ onHostChange(server);
+ }
+ }
+ },
+ fetchFiles: () => {
+ if (activeServer && sshSessionId) {
+ fetchFiles();
+ }
}
}));
- // SSH Handlers
- const handleEditSSH = (conn: any) => {
- setEditingSSH(conn);
- setSSHForm({...conn});
- // setShowAddSSH(true); // No longer used
- };
- const handleDeleteSSH = async (conn: any) => {
- try {
- const jwt = getJWT();
- await axios.delete(`${API_BASE_DB}/config_editor/ssh/host/${conn.id}`, {headers: {Authorization: `Bearer ${jwt}`}});
- setSSHConnections(sshConnections.filter(s => s.id !== conn.id));
- } catch {
- }
- };
- const handlePinSSH = async (conn: any) => {
- try {
- const jwt = getJWT();
- await axios.put(`${API_BASE_DB}/config_editor/ssh/host/${conn.id}`, {
- ...conn,
- isPinned: !conn.isPinned
- }, {headers: {Authorization: `Bearer ${jwt}`}});
- setSSHConnections(sshConnections.map(s => s.id === conn.id ? {...s, isPinned: !s.isPinned} : s));
- } catch {
- }
- };
-
// Path input focus scroll
useEffect(() => {
if (pathInputRef.current) {
@@ -304,155 +419,8 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
}
}, [currentPath]);
- // Fetch folders for popover
- useEffect(() => {
- async function fetchFolders() {
- setFoldersLoading(true);
- setFoldersError(null);
- try {
- const jwt = getJWT();
- const res = await axios.get(`${API_BASE_DB}/config_editor/ssh/folders`, {headers: {Authorization: `Bearer ${jwt}`}});
- setFolders(res.data || []);
- } catch (err: any) {
- setFoldersError('Failed to load folders');
- } finally {
- setFoldersLoading(false);
- }
- }
-
- fetchFolders();
- }, [addSheetOpen]);
-
- const form = useForm({
- defaultValues: {
- name: sshForm.name || '',
- ip: sshForm.ip || '',
- port: sshForm.port || 22,
- username: sshForm.username || '',
- password: sshForm.password || '',
- sshKey: sshForm.sshKey || '',
- sshKeyFile: null,
- keyPassword: sshForm.keyPassword || '',
- keyType: sshForm.keyType || 'auto',
- isPinned: sshForm.isPinned || false,
- defaultPath: sshForm.defaultPath || '/',
- folder: sshForm.folder || '',
- authMethod: sshForm.authMethod || 'password',
- }
- });
-
- // 1. SSH edit sheet autofill (debounced to after open, longer delay)
- // Remove the useEffect that resets the form on open
- // Add a useEffect that resets the form after the sheet is closed
- const prevShowAddSSH = React.useRef(addSheetOpen);
- useEffect(() => {
- if (prevShowAddSSH.current && !addSheetOpen) {
- setTimeout(() => {
- if (editingSSH) {
- form.reset({
- name: editingSSH.name || '',
- ip: editingSSH.ip || '',
- port: editingSSH.port || 22,
- username: editingSSH.username || '',
- password: editingSSH.password || '',
- sshKey: editingSSH.sshKey || '',
- sshKeyFile: null,
- keyPassword: editingSSH.keyPassword || '',
- keyType: editingSSH.keyType || 'auto',
- isPinned: editingSSH.isPinned || false,
- defaultPath: editingSSH.defaultPath || '/',
- folder: editingSSH.folder || '',
- });
- } else {
- form.reset({
- name: '',
- ip: '',
- port: 22,
- username: '',
- password: '',
- sshKey: '',
- sshKeyFile: null,
- keyPassword: '',
- keyType: 'auto',
- isPinned: false,
- defaultPath: '/',
- folder: '',
- });
- }
- }, 100);
- }
- prevShowAddSSH.current = addSheetOpen;
- }, [addSheetOpen, editingSSH]);
-
- // 2. Local Files default path persistence
- useEffect(() => {
- async function fetchLocalDefaultPath() {
- try {
- const jwt = getJWT();
- const res = await axios.get(`${API_BASE_DB}/config_editor/local_default_path`, {headers: {Authorization: `Bearer ${jwt}`}});
- setLocalDefaultPath(res.data?.defaultPath || '/');
- } catch {
- setLocalDefaultPath('/');
- }
- }
-
- fetchLocalDefaultPath();
- }, []);
-
- async function handleSaveLocalDefaultPath(e: React.FormEvent) {
- e.preventDefault();
- try {
- const jwt = getJWT();
- await axios.post(`${API_BASE_DB}/config_editor/local_default_path`, {defaultPath: localDefaultPath}, {headers: {Authorization: `Bearer ${jwt}`}});
- setShowEditLocal(false);
- } catch {
- setShowEditLocal(false);
- }
- }
-
- const onSubmit = async (values: any) => {
- setSSHFormError(null);
- setSSHFormLoading(true);
- try {
- const jwt = getJWT();
- let sshKeyContent = values.sshKey;
- if (values.sshKeyFile instanceof File) {
- sshKeyContent = await values.sshKeyFile.text();
- }
- const payload = {
- name: values.name,
- ip: values.ip,
- port: values.port,
- username: values.username,
- password: values.password,
- sshKey: sshKeyContent,
- keyPassword: values.keyPassword,
- keyType: values.keyType,
- isPinned: values.isPinned,
- defaultPath: values.defaultPath,
- folder: values.folder,
- authMethod: values.authMethod,
- };
- if (editingSSH) {
- await axios.put(`${API_BASE_DB}/config_editor/ssh/host/${editingSSH.id}`, payload, {headers: {Authorization: `Bearer ${jwt}`}});
- } else {
- await axios.post(`${API_BASE_DB}/config_editor/ssh/host`, payload, {headers: {Authorization: `Bearer ${jwt}`}});
- }
- await fetchSSH();
- // setShowAddSSH(false); // No longer used
- } catch (err: any) {
- let errorMsg = err?.response?.data?.error || 'Failed to save SSH connection';
- if (typeof errorMsg !== 'string') {
- errorMsg = 'An unknown error occurred. Please check the backend logs.';
- }
- setSSHFormError(errorMsg);
- } finally {
- setSSHFormLoading(false);
- }
- };
-
// Group SSH connections by folder
- const sshByFolder: Record = {};
+ const sshByFolder: Record = {};
sshConnections.forEach(conn => {
const folder = conn.folder && conn.folder.trim() ? conn.folder : 'No Folder';
if (!sshByFolder[folder]) sshByFolder[folder] = [];
@@ -466,51 +434,25 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
}
// Filter hosts by search
- const filteredSshByFolder: Record = {};
+ const filteredSshByFolder: Record = {};
Object.entries(sshByFolder).forEach(([folder, hosts]) => {
filteredSshByFolder[folder] = hosts.filter(conn => {
const q = debouncedSearch.trim().toLowerCase();
if (!q) return true;
return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) ||
(conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) ||
- (conn.tags || '').toLowerCase().includes(q);
+ (conn.tags || []).join(' ').toLowerCase().includes(q);
});
});
- // Folder input logic (copy from SSHSidebar)
- const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
- React.useEffect(() => {
- function handleClickOutside(event: MouseEvent) {
- if (
- folderDropdownRef.current &&
- !folderDropdownRef.current.contains(event.target as Node) &&
- folderInputRef.current &&
- !folderInputRef.current.contains(event.target as Node)
- ) {
- setFolderDropdownOpen(false);
- }
- }
- if (folderDropdownOpen) {
- document.addEventListener('mousedown', handleClickOutside);
- } else {
- document.removeEventListener('mousedown', handleClickOutside);
- }
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
- };
- }, [folderDropdownOpen]);
-
- // Before rendering the form, define filteredFolders:
- const folderValue = addSSHForm.watch('folder');
- const filteredFolders = React.useMemo(() => {
- if (!folderValue) return folders;
- return folders.filter(f => f.toLowerCase().includes(folderValue.toLowerCase()));
- }, [folderValue, folders]);
+ // Filter files by search
+ const filteredFiles = files.filter(file => {
+ const q = debouncedFileSearch.trim().toLowerCase();
+ if (!q) return true;
+ return file.name.toLowerCase().includes(q);
+ });
// --- Render ---
- // Expect a prop: tabs: Tab[]
- // Use: props.tabs
-
return (
@@ -530,1043 +472,210 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
- {/* Add SSH button and modal here, as siblings */}
-
-
-
-
-
- Add SSH
-
-
-
-
- Add SSH
-
- Configure a new SSH connection for the config editor.
-
-
-
- {addSubmitError && (
-
{addSubmitError}
- )}
-
-
-
-
-
- {addSubmitting ? 'Adding...' : 'Add SSH'}
-
-
-
- Close
-
-
-
-
-
-
-
{/* Main black div: servers list or file/folder browser */}
-
- {view === 'servers' && (
-
- {/* SSH hosts/folders section, SSHSidebar-accurate */}
-
- {/* Search bar (full width, no border/rounded on container) */}
-
- setSearch(e.target.value)}
- placeholder="Search hosts by name, username, IP, folder, tags..."
- className="w-full h-8 text-sm bg-background border border-border rounded"
- autoComplete="off"
- />
-
-
-
-
- {/* Host list, centered, max width, no border/rounded on container */}
-
- {/* Local server */}
-
-
handleSelectServer({
- isLocal: true,
- name: 'Local Files',
- id: 'local',
- defaultPath: localDefaultPath
- })}
- style={{minWidth: 0}}
- >
-
- Local Files
-
-
- setShowEditLocal(true)}>
-
-
-
-
- {/* Accordion for folders/hosts */}
-
-
- {sortedFolders.map((folder, idx) => (
-
-
- {folder}
-
- {filteredSshByFolder[folder].map(conn => (
-
-
-
-
handleSelectServer(conn)}
- >
-
- {conn.isPinned && ★ }
- {conn.name || conn.ip}
-
-
-
-
setSshPopoverOpen(prev => ({
- ...prev,
- [conn.id]: open
- }))}>
-
-
-
-
-
-
- {
- setSshPopoverOpen(prev => ({
- ...prev,
- [conn.id]: false
- }));
- handleEditSSH(conn);
- }}
- >
- Edit
-
- {
- setSshPopoverOpen(prev => ({
- ...prev,
- [conn.id]: false
- }));
- handleDeleteSSH(conn);
- }}
- >
- Delete
-
-
-
-
-
-
-
- ))}
-
-
- {idx < sortedFolders.length - 1 && (
-
-
-
- )}
-
- ))}
-
-
-
-
+ {view === 'servers' && (
+ <>
+ {/* Search bar - outside ScrollArea so it's always visible */}
+
+ setSearch(e.target.value)}
+ placeholder="Search hosts by name, username, IP, folder, tags..."
+ className="w-full h-8 text-sm bg-[#18181b] border border-[#23232a] text-white placeholder:text-muted-foreground rounded"
+ autoComplete="off"
+ />
- )}
- {view === 'files' && activeServer && (
-
- {/* Sticky path input bar */}
-
-
{
- // If not at root, go up one directory; else, go back to servers view
- let path = currentPath;
- if (path && path !== '/' && path !== '') {
- // Remove trailing slash if present
- if (path.endsWith('/')) path = path.slice(0, -1);
- const lastSlash = path.lastIndexOf('/');
- if (lastSlash > 0) {
- setCurrentPath(path.slice(0, lastSlash));
- } else {
- setCurrentPath('/');
- }
- } else {
- setView('servers');
- }
- }}
- >
-
-
-
setCurrentPath(e.target.value)}
- className="flex-1 bg-background border border-border text-white max-w-[170px] truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
- style={{ background: '#18181b' }}
- />
-
- {/* File list with always-visible scrollbar, border at top */}
-
-
-
- {filesLoading ? (
-
Loading...
- ) : filesError ? (
-
{filesError}
- ) : files.length === 0 ? (
-
No files or folders found.
- ) : (
-
- {files.map((item: any) => {
- const isOpen = (tabs || []).some((t: any) => t.id === item.path);
- return (
-
-
!isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile(item))}
- >
- {item.type === 'directory' ?
-
:
-
}
-
{item.name}
+
+
+ {/* SSH hosts/folders section */}
+
+
+
+
+ {/* Host list */}
+
+ {/* Accordion for folders/hosts */}
+
+
+ {sortedFolders.map((folder, idx) => (
+
+
+ {folder}
+
+ {filteredSshByFolder[folder].map(conn => (
+ handleSelectServer(conn)}
+ >
+
+ {conn.pin &&
}
+
{conn.name || conn.ip}
+
+
+ ))}
+
+
+ {idx < sortedFolders.length - 1 && (
+
+
-
+ )}
+
+ ))}
+
+
+
+
+
+
+ >
+ )}
+ {view === 'files' && activeServer && (
+
+ {/* Sticky path input bar - outside ScrollArea */}
+
+
{
+ // If not at root, go up one directory; else, go back to servers view
+ let path = currentPath;
+ if (path && path !== '/' && path !== '') {
+ // Remove trailing slash if present
+ if (path.endsWith('/')) path = path.slice(0, -1);
+ const lastSlash = path.lastIndexOf('/');
+ if (lastSlash > 0) {
+ setCurrentPath(path.slice(0, lastSlash));
+ } else {
+ setCurrentPath('/');
+ }
+ } else {
+ setView('servers');
+ if (onHostChange) {
+ onHostChange(null);
+ }
+ }
+ }}
+ >
+
+
+
setCurrentPath(e.target.value)}
+ className="flex-1 bg-[#18181b] border border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]"
+ />
+
+ {/* File search bar */}
+
+ setFileSearch(e.target.value)}
+ />
+
+ {/* File list with proper scroll area - separate from topbar */}
+
+
+
+ {connectingSSH || filesLoading ? (
+
Loading...
+ ) : filesError ? (
+
{filesError}
+ ) : filteredFiles.length === 0 ? (
+
No files or folders found.
+ ) : (
+
+ {filteredFiles.map((item: any) => {
+ const isOpen = (tabs || []).some((t: any) => t.id === item.path);
+ return (
+
+
!isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile({
+ name: item.name,
+ path: item.path,
+ isSSH: item.isSSH,
+ sshSessionId: item.sshSessionId
+ }))}
+ >
+ {item.type === 'directory' ?
+ :
+ }
+ {item.name}
+
+
+ {item.type === 'file' && (
{
+ e.stopPropagation();
+ try {
+ if (item.isPinned) {
+ await removeConfigEditorPinned({
+ name: item.name,
+ path: item.path,
+ hostId: activeServer?.id,
+ isSSH: true,
+ sshSessionId: activeServer?.id.toString()
+ });
+ // Update local state without refreshing
+ setFiles(files.map(f =>
+ f.path === item.path ? { ...f, isPinned: false } : f
+ ));
+ } else {
+ await addConfigEditorPinned({
+ name: item.name,
+ path: item.path,
+ hostId: activeServer?.id,
+ isSSH: true,
+ sshSessionId: activeServer?.id.toString()
+ });
+ // Update local state without refreshing
+ setFiles(files.map(f =>
+ f.path === item.path ? { ...f, isPinned: true } : f
+ ));
+ }
+ } catch (err) {
+ console.error('Failed to pin/unpin file:', err);
+ }
+ }}
>
-
+
-
+ )}
- );
- })}
-
- )}
-
-
-
+
+ );
+ })}
+
+ )}
+
+
- )}
-
+
+ )}
- {/* Add/Edit SSH Sheet (pixel-perfect copy of SSHSidebar Add Host sheet) */}
-
-
-
- Edit Local Files
-
- Set the default path for the Local Files browser. This will be used as the starting
- directory.
-
-
-
-
-
-
-
- Save
-
-
-
- {/* Add Edit SSH modal logic (not in SidebarMenu, but as a Sheet rendered at root, open when editingSSH is set) */}
- {
- if (!open) {
- setTimeout(() => {
- setEditingSSH(null);
- form.reset();
- }, 100);
- }
- }}>
-
-
- Edit SSH
-
- Edit the SSH connection details.
-
-
-
-
-
- {sshFormLoading ? 'Saving...' : 'Save'}
-
-
-
- Close
-
-
-
-
-
);
});
diff --git a/src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx b/src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx
index 42d29232..7dc4ac5d 100644
--- a/src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx
+++ b/src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { Button } from '@/components/ui/button.tsx';
import { Card } from '@/components/ui/card.tsx';
import { Separator } from '@/components/ui/separator.tsx';
-import { Plus, Folder, File, Star, Trash2, Edit, Link2, Server } from 'lucide-react';
+import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react';
interface SSHConnection {
id: string;
@@ -88,10 +88,10 @@ export function ConfigFileSidebarViewer({
>
{conn.name || conn.ip}
- {conn.isPinned && }
+ {conn.isPinned && }
onPinSSH(conn)}>
-
+
onEditSSH(conn)}>
@@ -123,7 +123,7 @@ export function ConfigFileSidebarViewer({
onStarFile(item)}>
-
+
onDeleteFile(item)}>
diff --git a/src/apps/SSH/Config Editor/ConfigHomeView.tsx b/src/apps/SSH/Config Editor/ConfigHomeView.tsx
index 955e9b3c..0279e82a 100644
--- a/src/apps/SSH/Config Editor/ConfigHomeView.tsx
+++ b/src/apps/SSH/Config Editor/ConfigHomeView.tsx
@@ -1,17 +1,20 @@
import React from 'react';
import { Card } from '@/components/ui/card.tsx';
import { Button } from '@/components/ui/button.tsx';
-import { Star, Trash2, Folder, File, Plus } from 'lucide-react';
+import { Trash2, Folder, File, Plus, Pin } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs.tsx';
import { Input } from '@/components/ui/input.tsx';
import { useState } from 'react';
+import { cn } from '@/lib/utils.ts';
interface FileItem {
name: string;
path: string;
- isStarred?: boolean;
+ isPinned?: boolean;
type: 'file' | 'directory';
+ sshSessionId?: string;
}
+
interface ShortcutItem {
name: string;
path: string;
@@ -24,6 +27,7 @@ interface ConfigHomeViewProps {
onOpenFile: (file: FileItem) => void;
onRemoveRecent: (file: FileItem) => void;
onPinFile: (file: FileItem) => void;
+ onUnpinFile: (file: FileItem) => void;
onOpenShortcut: (shortcut: ShortcutItem) => void;
onRemoveShortcut: (shortcut: ShortcutItem) => void;
onAddShortcut: (path: string) => void;
@@ -36,69 +40,144 @@ export function ConfigHomeView({
onOpenFile,
onRemoveRecent,
onPinFile,
+ onUnpinFile,
onOpenShortcut,
onRemoveShortcut,
- onAddShortcut,
+ onAddShortcut
}: ConfigHomeViewProps) {
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
const [newShortcut, setNewShortcut] = useState('');
+
+
+
+ const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
+
+
onOpenFile(file)}
+ >
+ {file.type === 'directory' ?
+
:
+
+ }
+
+
+
+ {onPin && (
+
+
+
+ )}
+ {onRemove && (
+
+
+
+ )}
+
+
+ );
+
+ const renderShortcutCard = (shortcut: ShortcutItem) => (
+
+
onOpenShortcut(shortcut)}
+ >
+
+
+
+
+ onRemoveShortcut(shortcut)}
+ >
+
+
+
+
+ );
+
return (
-
+
setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
-
- Recent
- Pinned
- Folder Shortcuts
+
+ Recent
+ Pinned
+ Folder Shortcuts
-
-
+
+
+
{recent.length === 0 ? (
-
No recent files.
- ) : recent.map((file, index) => (
-
- onOpenFile(file)}>
- {file.type === 'directory' ? : }
-
- {file.name}
- onPinFile(file)}>
-
-
- onRemoveRecent(file)}>
-
-
-
- ))}
+
+ No recent files.
+
+ ) : recent.map((file) =>
+ renderFileCard(
+ file,
+ () => onRemoveRecent(file),
+ () => file.isPinned ? onUnpinFile(file) : onPinFile(file),
+ file.isPinned
+ )
+ )}
-
-
+
+
+
{pinned.length === 0 ? (
-
No pinned files.
- ) : pinned.map((file, index) => (
-
- onOpenFile(file)}>
- {file.type === 'directory' ? : }
-
- {file.name}
- onPinFile(file)}>
-
-
-
- ))}
+
+ No pinned files.
+
+ ) : pinned.map((file) =>
+ renderFileCard(
+ file,
+ undefined, // No remove function for pinned items
+ () => onUnpinFile(file), // Use pin function for unpinning
+ true
+ )
+ )}
-
-
+
+
+
-
+
{shortcuts.length === 0 ? (
-
No shortcuts.
- ) : shortcuts.map((shortcut, index) => (
-
- onOpenShortcut(shortcut)}>
-
-
- {shortcut.name || shortcut.path.split('/').pop()}
- onRemoveShortcut(shortcut)}>
-
-
-
- ))}
+
+ No shortcuts.
+
+ ) : shortcuts.map((shortcut) =>
+ renderShortcutCard(shortcut)
+ )}
diff --git a/src/apps/SSH/Config Editor/ConfigTabList.tsx b/src/apps/SSH/Config Editor/ConfigTabList.tsx
index b95040ba..f6c2bb4c 100644
--- a/src/apps/SSH/Config Editor/ConfigTabList.tsx
+++ b/src/apps/SSH/Config Editor/ConfigTabList.tsx
@@ -28,21 +28,27 @@ export function ConfigTabList({ tabs, activeTab, setActiveTab, closeTab, onHomeC
{tabs.map((tab, index) => {
const isActive = tab.id === activeTab;
return (
-
+
+ {/* Set Active Tab Button */}
setActiveTab(tab.id)}
variant="outline"
- className={`h-7 rounded-r-none flex items-center ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
+ className={`h-7 rounded-r-none ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
{tab.title}
+
+ {/* Close Tab Button */}
closeTab(tab.id)}
variant="outline"
- className="h-7 rounded-l-none p-0 !w-7 !h-7 flex items-center"
+ className="h-7 rounded-l-none p-0 !w-9"
>
-
+
diff --git a/src/apps/SSH/Manager/SSHManagerHostEditor.tsx b/src/apps/SSH/Manager/SSHManagerHostEditor.tsx
index 904daf19..25ccd51c 100644
--- a/src/apps/SSH/Manager/SSHManagerHostEditor.tsx
+++ b/src/apps/SSH/Manager/SSHManagerHostEditor.tsx
@@ -19,7 +19,7 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx
import React, {useEffect, useRef, useState} from "react";
import {Switch} from "@/components/ui/switch.tsx";
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
-import { createSSHHost, updateSSHHost, getSSHHosts } from '@/apps/SSH/ssh-axios';
+import { createSSHHost, updateSSHHost, getSSHHosts } from '@/apps/SSH/ssh-axios-fixed';
interface SSHHost {
id: number;
@@ -1014,7 +1014,7 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
- {editingHost ? "Update Host" : "Add Host"}
+ {editingHost ? "Update Host" : "Add Host"}
diff --git a/src/apps/SSH/Manager/SSHManagerHostViewer.tsx b/src/apps/SSH/Manager/SSHManagerHostViewer.tsx
index 0ee6659b..fe2c91bf 100644
--- a/src/apps/SSH/Manager/SSHManagerHostViewer.tsx
+++ b/src/apps/SSH/Manager/SSHManagerHostViewer.tsx
@@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Input } from "@/components/ui/input";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
-import { getSSHHosts, deleteSSHHost } from "@/apps/SSH/ssh-axios";
+import { getSSHHosts, deleteSSHHost } from "@/apps/SSH/ssh-axios-fixed";
import { Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search } from "lucide-react";
interface SSHHost {
diff --git a/src/apps/SSH/Terminal/SSHSidebar.tsx b/src/apps/SSH/Terminal/SSHSidebar.tsx
index 356f0813..b0334141 100644
--- a/src/apps/SSH/Terminal/SSHSidebar.tsx
+++ b/src/apps/SSH/Terminal/SSHSidebar.tsx
@@ -37,7 +37,7 @@ import {
} from "@/components/ui/accordion.tsx";
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { Input } from "@/components/ui/input.tsx";
-import { getSSHHosts } from "@/apps/SSH/ssh-axios";
+import { getSSHHosts } from "@/apps/SSH/ssh-axios-fixed";
interface SSHHost {
id: number;
@@ -350,7 +350,7 @@ const HostMenuItem = React.memo(function HostMenuItem({ host, onHostConnect }: {
>
{host.pin &&
-
+
}
{host.name || host.ip}
diff --git a/src/apps/SSH/Tunnel/SSHTunnel.tsx b/src/apps/SSH/Tunnel/SSHTunnel.tsx
index b3ca2386..2c0b1022 100644
--- a/src/apps/SSH/Tunnel/SSHTunnel.tsx
+++ b/src/apps/SSH/Tunnel/SSHTunnel.tsx
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from "react";
import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
-import { getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel } from "@/apps/SSH/ssh-axios";
+import { getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel } from "@/apps/SSH/ssh-axios-fixed";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
diff --git a/src/apps/SSH/ssh-axios-fixed.ts b/src/apps/SSH/ssh-axios-fixed.ts
new file mode 100644
index 00000000..d892ec15
--- /dev/null
+++ b/src/apps/SSH/ssh-axios-fixed.ts
@@ -0,0 +1,541 @@
+// SSH Host Management API functions
+import axios from 'axios';
+
+interface SSHHostData {
+ name?: string;
+ ip: string;
+ port: number;
+ username: string;
+ folder?: string;
+ tags?: string[];
+ pin?: boolean;
+ authType: 'password' | 'key';
+ password?: string;
+ key?: File | null;
+ keyPassword?: string;
+ keyType?: string;
+ enableTerminal?: boolean;
+ enableTunnel?: boolean;
+ enableConfigEditor?: boolean;
+ defaultPath?: string;
+ tunnelConnections?: any[];
+}
+
+interface SSHHost {
+ id: number;
+ name: string;
+ ip: string;
+ port: number;
+ username: string;
+ folder: string;
+ tags: string[];
+ pin: boolean;
+ authType: string;
+ password?: string;
+ key?: string;
+ keyPassword?: string;
+ keyType?: string;
+ enableTerminal: boolean;
+ enableTunnel: boolean;
+ enableConfigEditor: boolean;
+ defaultPath: string;
+ tunnelConnections: any[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+interface TunnelConfig {
+ name: string;
+ hostName: string;
+ sourceIP: string;
+ sourceSSHPort: number;
+ sourceUsername: string;
+ sourcePassword?: string;
+ sourceAuthMethod: string;
+ sourceSSHKey?: string;
+ sourceKeyPassword?: string;
+ sourceKeyType?: string;
+ endpointIP: string;
+ endpointSSHPort: number;
+ endpointUsername: string;
+ endpointPassword?: string;
+ endpointAuthMethod: string;
+ endpointSSHKey?: string;
+ endpointKeyPassword?: string;
+ endpointKeyType?: string;
+ sourcePort: number;
+ endpointPort: number;
+ maxRetries: number;
+ retryInterval: number;
+ autoStart: boolean;
+ isPinned: boolean;
+}
+
+interface TunnelStatus {
+ status: string;
+ reason?: string;
+ errorType?: string;
+ retryCount?: number;
+ maxRetries?: number;
+ nextRetryIn?: number;
+ retryExhausted?: boolean;
+}
+
+// Determine the base URL based on environment
+const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
+const baseURL = isLocalhost ? 'http://localhost:8081' : window.location.origin;
+
+// Create axios instance with base configuration for database operations (port 8081)
+const api = axios.create({
+ baseURL,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
+
+// Create config editor API instance for file operations (port 8084)
+const configEditorApi = axios.create({
+ baseURL: isLocalhost ? 'http://localhost:8084' : `${window.location.origin}/ssh`,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
+
+// Create tunnel API instance
+const tunnelApi = axios.create({
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
+
+function getCookie(name: string): string | undefined {
+ const value = `; ${document.cookie}`;
+ const parts = value.split(`; ${name}=`);
+ if (parts.length === 2) return parts.pop()?.split(';').shift();
+}
+
+// Add request interceptor to include JWT token for all API instances
+api.interceptors.request.use((config) => {
+ const token = getCookie('jwt');
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+});
+
+configEditorApi.interceptors.request.use((config) => {
+ const token = getCookie('jwt');
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+});
+
+tunnelApi.interceptors.request.use((config) => {
+ const token = getCookie('jwt');
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+});
+
+// Get all SSH hosts - FIXED: Changed from /ssh/host to /ssh/db/host
+export async function getSSHHosts(): Promise
{
+ try {
+ const response = await api.get('/ssh/db/host');
+ return response.data;
+ } catch (error) {
+ console.error('Error fetching SSH hosts:', error);
+ throw error;
+ }
+}
+
+// Create new SSH host
+export async function createSSHHost(hostData: SSHHostData): Promise {
+ try {
+ // Prepare the data according to your backend schema
+ const submitData = {
+ name: hostData.name || '',
+ ip: hostData.ip,
+ port: parseInt(hostData.port.toString()) || 22,
+ username: hostData.username,
+ folder: hostData.folder || '',
+ tags: hostData.tags || [],
+ pin: hostData.pin || false,
+ authMethod: hostData.authType,
+ password: hostData.authType === 'password' ? hostData.password : '',
+ key: hostData.authType === 'key' ? hostData.key : null,
+ keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
+ keyType: hostData.authType === 'key' ? hostData.keyType : '',
+ enableTerminal: hostData.enableTerminal !== false,
+ enableTunnel: hostData.enableTunnel !== false,
+ enableConfigEditor: hostData.enableConfigEditor !== false,
+ defaultPath: hostData.defaultPath || '/',
+ tunnelConnections: hostData.tunnelConnections || [],
+ };
+
+ if (!submitData.enableTunnel) {
+ submitData.tunnelConnections = [];
+ }
+
+ if (!submitData.enableConfigEditor) {
+ submitData.defaultPath = '';
+ }
+
+ if (hostData.authType === 'key' && hostData.key instanceof File) {
+ const formData = new FormData();
+ formData.append('key', hostData.key);
+
+ const dataWithoutFile = { ...submitData };
+ delete dataWithoutFile.key;
+ formData.append('data', JSON.stringify(dataWithoutFile));
+
+ const response = await api.post('/ssh/db/host', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ });
+
+ return response.data;
+ } else {
+ const response = await api.post('/ssh/db/host', submitData);
+ return response.data;
+ }
+ } catch (error) {
+ console.error('Error creating SSH host:', error);
+ throw error;
+ }
+}
+
+// Update existing SSH host
+export async function updateSSHHost(hostId: number, hostData: SSHHostData): Promise {
+ try {
+ const submitData = {
+ name: hostData.name || '',
+ ip: hostData.ip,
+ port: parseInt(hostData.port.toString()) || 22,
+ username: hostData.username,
+ folder: hostData.folder || '',
+ tags: hostData.tags || [],
+ pin: hostData.pin || false,
+ authMethod: hostData.authType,
+ password: hostData.authType === 'password' ? hostData.password : '',
+ key: hostData.authType === 'key' ? hostData.key : null,
+ keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
+ keyType: hostData.authType === 'key' ? hostData.keyType : '',
+ enableTerminal: hostData.enableTerminal !== false,
+ enableTunnel: hostData.enableTunnel !== false,
+ enableConfigEditor: hostData.enableConfigEditor !== false,
+ defaultPath: hostData.defaultPath || '/',
+ tunnelConnections: hostData.tunnelConnections || [],
+ };
+
+ if (!submitData.enableTunnel) {
+ submitData.tunnelConnections = [];
+ }
+ if (!submitData.enableConfigEditor) {
+ submitData.defaultPath = '';
+ }
+
+ if (hostData.authType === 'key' && hostData.key instanceof File) {
+ const formData = new FormData();
+ formData.append('key', hostData.key);
+
+ const dataWithoutFile = { ...submitData };
+ delete dataWithoutFile.key;
+ formData.append('data', JSON.stringify(dataWithoutFile));
+
+ const response = await api.put(`/ssh/db/host/${hostId}`, formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ });
+
+ return response.data;
+ } else {
+ const response = await api.put(`/ssh/db/host/${hostId}`, submitData);
+ return response.data;
+ }
+ } catch (error) {
+ console.error('Error updating SSH host:', error);
+ throw error;
+ }
+}
+
+// Delete SSH host
+export async function deleteSSHHost(hostId: number): Promise {
+ try {
+ const response = await api.delete(`/ssh/db/host/${hostId}`);
+ return response.data;
+ } catch (error) {
+ console.error('Error deleting SSH host:', error);
+ throw error;
+ }
+}
+
+// Get SSH host by ID
+export async function getSSHHostById(hostId: number): Promise {
+ try {
+ const response = await api.get(`/ssh/db/host/${hostId}`);
+ return response.data;
+ } catch (error) {
+ console.error('Error fetching SSH host:', error);
+ throw error;
+ }
+}
+
+// Tunnel-related functions
+export async function getTunnelStatuses(): Promise> {
+ try {
+ const tunnelUrl = isLocalhost ? 'http://localhost:8083/status' : `${baseURL}/ssh_tunnel/status`;
+ const response = await tunnelApi.get(tunnelUrl);
+ return response.data || {};
+ } catch (error) {
+ console.error('Error fetching tunnel statuses:', error);
+ throw error;
+ }
+}
+
+export async function getTunnelStatusByName(tunnelName: string): Promise {
+ const statuses = await getTunnelStatuses();
+ return statuses[tunnelName];
+}
+
+export async function connectTunnel(tunnelConfig: TunnelConfig): Promise {
+ try {
+ const tunnelUrl = isLocalhost ? 'http://localhost:8083/connect' : `${baseURL}/ssh_tunnel/connect`;
+ const response = await tunnelApi.post(tunnelUrl, tunnelConfig);
+ return response.data;
+ } catch (error) {
+ console.error('Error connecting tunnel:', error);
+ throw error;
+ }
+}
+
+export async function disconnectTunnel(tunnelName: string): Promise {
+ try {
+ const tunnelUrl = isLocalhost ? 'http://localhost:8083/disconnect' : `${baseURL}/ssh_tunnel/disconnect`;
+ const response = await tunnelApi.post(tunnelUrl, { tunnelName });
+ return response.data;
+ } catch (error) {
+ console.error('Error disconnecting tunnel:', error);
+ throw error;
+ }
+}
+
+export async function cancelTunnel(tunnelName: string): Promise {
+ try {
+ const tunnelUrl = isLocalhost ? 'http://localhost:8083/cancel' : `${baseURL}/ssh_tunnel/cancel`;
+ const response = await tunnelApi.post(tunnelUrl, { tunnelName });
+ return response.data;
+ } catch (error) {
+ console.error('Error canceling tunnel:', error);
+ throw error;
+ }
+}
+
+export { api, configEditorApi };
+
+// Config Editor API functions
+interface ConfigEditorFile {
+ name: string;
+ path: string;
+ type?: 'file' | 'directory';
+ isSSH?: boolean;
+ sshSessionId?: string;
+}
+
+interface ConfigEditorShortcut {
+ name: string;
+ path: string;
+}
+
+// Config Editor database functions (use port 8081 for database operations)
+export async function getConfigEditorRecent(hostId: number): Promise {
+ try {
+ console.log('Fetching recent files for host:', hostId);
+ const response = await api.get(`/ssh/config_editor/recent?hostId=${hostId}`);
+ console.log('Recent files response:', response.data);
+ return response.data || [];
+ } catch (error) {
+ console.error('Error fetching recent files:', error);
+ return [];
+ }
+}
+
+export async function addConfigEditorRecent(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise {
+ try {
+ console.log('Making request to add recent file:', file);
+ const response = await api.post('/ssh/config_editor/recent', file);
+ console.log('Add recent file response:', response.data);
+ return response.data;
+ } catch (error) {
+ console.error('Error adding recent file:', error);
+ throw error;
+ }
+}
+
+export async function removeConfigEditorRecent(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise {
+ try {
+ const response = await api.delete('/ssh/config_editor/recent', { data: file });
+ return response.data;
+ } catch (error) {
+ console.error('Error removing recent file:', error);
+ throw error;
+ }
+}
+
+export async function getConfigEditorPinned(hostId: number): Promise {
+ try {
+ const response = await api.get(`/ssh/config_editor/pinned?hostId=${hostId}`);
+ return response.data || [];
+ } catch (error) {
+ console.error('Error fetching pinned files:', error);
+ return [];
+ }
+}
+
+export async function addConfigEditorPinned(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise {
+ try {
+ const response = await api.post('/ssh/config_editor/pinned', file);
+ return response.data;
+ } catch (error) {
+ console.error('Error adding pinned file:', error);
+ throw error;
+ }
+}
+
+export async function removeConfigEditorPinned(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise {
+ try {
+ const response = await api.delete('/ssh/config_editor/pinned', { data: file });
+ return response.data;
+ } catch (error) {
+ console.error('Error removing pinned file:', error);
+ throw error;
+ }
+}
+
+export async function getConfigEditorShortcuts(hostId: number): Promise {
+ try {
+ const response = await api.get(`/ssh/config_editor/shortcuts?hostId=${hostId}`);
+ return response.data || [];
+ } catch (error) {
+ console.error('Error fetching shortcuts:', error);
+ return [];
+ }
+}
+
+export async function addConfigEditorShortcut(shortcut: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise {
+ try {
+ const response = await api.post('/ssh/config_editor/shortcuts', shortcut);
+ return response.data;
+ } catch (error) {
+ console.error('Error adding shortcut:', error);
+ throw error;
+ }
+}
+
+export async function removeConfigEditorShortcut(shortcut: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise {
+ try {
+ const response = await api.delete('/ssh/config_editor/shortcuts', { data: shortcut });
+ return response.data;
+ } catch (error) {
+ console.error('Error removing shortcut:', error);
+ throw error;
+ }
+}
+
+// SSH file operations - FIXED: Using configEditorApi for port 8084
+export async function connectSSH(sessionId: string, config: {
+ ip: string;
+ port: number;
+ username: string;
+ password?: string;
+ sshKey?: string;
+ keyPassword?: string;
+}): Promise {
+ try {
+ const response = await configEditorApi.post('/ssh/config_editor/ssh/connect', {
+ sessionId,
+ ...config
+ });
+ return response.data;
+ } catch (error) {
+ console.error('Error connecting SSH:', error);
+ throw error;
+ }
+}
+
+export async function disconnectSSH(sessionId: string): Promise {
+ try {
+ const response = await configEditorApi.post('/ssh/config_editor/ssh/disconnect', { sessionId });
+ return response.data;
+ } catch (error) {
+ console.error('Error disconnecting SSH:', error);
+ throw error;
+ }
+}
+
+export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
+ try {
+ const response = await configEditorApi.get('/ssh/config_editor/ssh/status', {
+ params: { sessionId }
+ });
+ return response.data;
+ } catch (error) {
+ console.error('Error getting SSH status:', error);
+ throw error;
+ }
+}
+
+export async function listSSHFiles(sessionId: string, path: string): Promise {
+ try {
+ const response = await configEditorApi.get('/ssh/config_editor/ssh/listFiles', {
+ params: { sessionId, path }
+ });
+ return response.data || [];
+ } catch (error) {
+ console.error('Error listing SSH files:', error);
+ throw error;
+ }
+}
+
+export async function readSSHFile(sessionId: string, path: string): Promise<{ content: string; path: string }> {
+ try {
+ const response = await configEditorApi.get('/ssh/config_editor/ssh/readFile', {
+ params: { sessionId, path }
+ });
+ return response.data;
+ } catch (error) {
+ console.error('Error reading SSH file:', error);
+ throw error;
+ }
+}
+
+export async function writeSSHFile(sessionId: string, path: string, content: string): Promise {
+ try {
+ console.log('Making writeSSHFile request:', { sessionId, path, contentLength: content.length });
+ const response = await configEditorApi.post('/ssh/config_editor/ssh/writeFile', {
+ sessionId,
+ path,
+ content
+ });
+ console.log('writeSSHFile response:', response.data);
+
+ // Check if the response indicates success
+ if (response.data && (response.data.message === 'File written successfully' || response.status === 200)) {
+ console.log('File write operation completed successfully');
+ return response.data;
+ } else {
+ throw new Error('File write operation did not return success status');
+ }
+ } catch (error) {
+ console.error('Error writing SSH file:', error);
+ console.error('Error type:', typeof error);
+ console.error('Error constructor:', error?.constructor?.name);
+ console.error('Error response:', (error as any)?.response);
+ console.error('Error response data:', (error as any)?.response?.data);
+ console.error('Error response status:', (error as any)?.response?.status);
+ throw error;
+ }
+}
\ No newline at end of file
diff --git a/src/apps/SSH/ssh-axios.ts b/src/apps/SSH/ssh-axios.ts
index b522c7f3..3743bfd7 100644
--- a/src/apps/SSH/ssh-axios.ts
+++ b/src/apps/SSH/ssh-axios.ts
@@ -83,28 +83,30 @@ interface TunnelStatus {
// Determine the base URL based on environment
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
+const baseURL = isLocalhost ? 'http://localhost:8081' : window.location.origin;
-// Create separate axios instances for different services
-const sshHostApi = axios.create({
- baseURL: isLocalhost ? 'http://localhost:8081' : window.location.origin,
- headers: {
- 'Content-Type': 'application/json',
- },
-});
-
-const tunnelApi = axios.create({
- baseURL: isLocalhost ? 'http://localhost:8083' : window.location.origin,
+// Create axios instance with base configuration for database operations (port 8081)
+const api = axios.create({
+ baseURL,
headers: {
'Content-Type': 'application/json',
},
});
+// Create config editor API instance for file operations (port 8084)
const configEditorApi = axios.create({
- baseURL: isLocalhost ? 'http://localhost:8084' : window.location.origin,
+ baseURL: isLocalhost ? 'http://localhost:8084' : `${window.location.origin}/ssh`,
headers: {
'Content-Type': 'application/json',
- }
-})
+ },
+});
+
+// Create tunnel API instance
+const tunnelApi = axios.create({
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
function getCookie(name: string): string | undefined {
const value = `; ${document.cookie}`;
@@ -112,8 +114,16 @@ function getCookie(name: string): string | undefined {
if (parts.length === 2) return parts.pop()?.split(';').shift();
}
-// Add request interceptor to include JWT token for SSH Host API
-sshHostApi.interceptors.request.use((config) => {
+// Add request interceptor to include JWT token for all API instances
+api.interceptors.request.use((config) => {
+ const token = getCookie('jwt');
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+});
+
+configEditorApi.interceptors.request.use((config) => {
const token = getCookie('jwt');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
@@ -121,7 +131,6 @@ sshHostApi.interceptors.request.use((config) => {
return config;
});
-// Add request interceptor to include JWT token for Tunnel API
tunnelApi.interceptors.request.use((config) => {
const token = getCookie('jwt');
if (token) {
@@ -130,12 +139,10 @@ tunnelApi.interceptors.request.use((config) => {
return config;
});
-// Host-related functions (use port 8081 for localhost)
-
-// Get all SSH hosts
+// Get all SSH hosts - FIXED: Changed from /ssh/host to /ssh/db/host
export async function getSSHHosts(): Promise {
try {
- const response = await sshHostApi.get('/ssh/db/host');
+ const response = await api.get('/ssh/db/host');
return response.data;
} catch (error) {
console.error('Error fetching SSH hosts:', error);
@@ -153,44 +160,37 @@ export async function createSSHHost(hostData: SSHHostData): Promise {
port: parseInt(hostData.port.toString()) || 22,
username: hostData.username,
folder: hostData.folder || '',
- tags: hostData.tags || [], // Array of strings
+ tags: hostData.tags || [],
pin: hostData.pin || false,
- authMethod: hostData.authType, // Backend expects 'authMethod'
+ authMethod: hostData.authType,
password: hostData.authType === 'password' ? hostData.password : '',
key: hostData.authType === 'key' ? hostData.key : null,
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
keyType: hostData.authType === 'key' ? hostData.keyType : '',
- enableTerminal: hostData.enableTerminal !== false, // Default to true
- enableTunnel: hostData.enableTunnel !== false, // Default to true
- enableConfigEditor: hostData.enableConfigEditor !== false, // Default to true
+ enableTerminal: hostData.enableTerminal !== false,
+ enableTunnel: hostData.enableTunnel !== false,
+ enableConfigEditor: hostData.enableConfigEditor !== false,
defaultPath: hostData.defaultPath || '/',
- tunnelConnections: hostData.tunnelConnections || [], // Array of tunnel objects
+ tunnelConnections: hostData.tunnelConnections || [],
};
- // If tunnel is disabled, clear tunnel data
if (!submitData.enableTunnel) {
submitData.tunnelConnections = [];
}
- // If config editor is disabled, clear config data
if (!submitData.enableConfigEditor) {
submitData.defaultPath = '';
}
- // Handle file upload for SSH key
if (hostData.authType === 'key' && hostData.key instanceof File) {
const formData = new FormData();
-
- // Add the file
formData.append('key', hostData.key);
- // Add all other data as JSON string
const dataWithoutFile = { ...submitData };
delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile));
- // Submit with FormData
- const response = await sshHostApi.post('/ssh/db/host', formData, {
+ const response = await api.post('/ssh/db/host', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -198,8 +198,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise {
return response.data;
} else {
- // Submit with JSON
- const response = await sshHostApi.post('/ssh/db/host', submitData);
+ const response = await api.post('/ssh/db/host', submitData);
return response.data;
}
} catch (error) {
@@ -231,7 +230,6 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
tunnelConnections: hostData.tunnelConnections || [],
};
- // Handle disabled features
if (!submitData.enableTunnel) {
submitData.tunnelConnections = [];
}
@@ -239,7 +237,6 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
submitData.defaultPath = '';
}
- // Handle file upload for SSH key
if (hostData.authType === 'key' && hostData.key instanceof File) {
const formData = new FormData();
formData.append('key', hostData.key);
@@ -248,7 +245,7 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile));
- const response = await sshHostApi.put(`/ssh/db/host/${hostId}`, formData, {
+ const response = await api.put(`/ssh/db/host/${hostId}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -256,7 +253,7 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
return response.data;
} else {
- const response = await sshHostApi.put(`/ssh/db/host/${hostId}`, submitData);
+ const response = await api.put(`/ssh/db/host/${hostId}`, submitData);
return response.data;
}
} catch (error) {
@@ -268,7 +265,7 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
// Delete SSH host
export async function deleteSSHHost(hostId: number): Promise {
try {
- const response = await sshHostApi.delete(`/ssh/db/host/${hostId}`);
+ const response = await api.delete(`/ssh/db/host/${hostId}`);
return response.data;
} catch (error) {
console.error('Error deleting SSH host:', error);
@@ -279,7 +276,7 @@ export async function deleteSSHHost(hostId: number): Promise {
// Get SSH host by ID
export async function getSSHHostById(hostId: number): Promise {
try {
- const response = await sshHostApi.get(`/ssh/db/host/${hostId}`);
+ const response = await api.get(`/ssh/db/host/${hostId}`);
return response.data;
} catch (error) {
console.error('Error fetching SSH host:', error);
@@ -287,12 +284,11 @@ export async function getSSHHostById(hostId: number): Promise {
}
}
-// Tunnel-related functions (use port 8083 for localhost)
-
-// Get all tunnel statuses (per-tunnel)
+// Tunnel-related functions
export async function getTunnelStatuses(): Promise> {
try {
- const response = await tunnelApi.get('/ssh/tunnel/status');
+ const tunnelUrl = isLocalhost ? 'http://localhost:8083/status' : `${baseURL}/ssh_tunnel/status`;
+ const response = await tunnelApi.get(tunnelUrl);
return response.data || {};
} catch (error) {
console.error('Error fetching tunnel statuses:', error);
@@ -300,16 +296,15 @@ export async function getTunnelStatuses(): Promise>
}
}
-// Get status for a specific tunnel by tunnel name
export async function getTunnelStatusByName(tunnelName: string): Promise {
const statuses = await getTunnelStatuses();
return statuses[tunnelName];
}
-// Connect tunnel (per-tunnel)
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise {
try {
- const response = await tunnelApi.post('/ssh/tunnel/connect', tunnelConfig);
+ const tunnelUrl = isLocalhost ? 'http://localhost:8083/connect' : `${baseURL}/ssh_tunnel/connect`;
+ const response = await tunnelApi.post(tunnelUrl, tunnelConfig);
return response.data;
} catch (error) {
console.error('Error connecting tunnel:', error);
@@ -317,10 +312,10 @@ export async function connectTunnel(tunnelConfig: TunnelConfig): Promise {
}
}
-// Disconnect tunnel (per-tunnel)
export async function disconnectTunnel(tunnelName: string): Promise {
try {
- const response = await tunnelApi.post('/ssh/tunnel/disconnect', { tunnelName });
+ const tunnelUrl = isLocalhost ? 'http://localhost:8083/disconnect' : `${baseURL}/ssh_tunnel/disconnect`;
+ const response = await tunnelApi.post(tunnelUrl, { tunnelName });
return response.data;
} catch (error) {
console.error('Error disconnecting tunnel:', error);
@@ -330,7 +325,8 @@ export async function disconnectTunnel(tunnelName: string): Promise {
export async function cancelTunnel(tunnelName: string): Promise {
try {
- const response = await tunnelApi.post('/ssh/tunnel/cancel', { tunnelName });
+ const tunnelUrl = isLocalhost ? 'http://localhost:8083/cancel' : `${baseURL}/ssh_tunnel/cancel`;
+ const response = await tunnelApi.post(tunnelUrl, { tunnelName });
return response.data;
} catch (error) {
console.error('Error canceling tunnel:', error);
@@ -338,6 +334,184 @@ export async function cancelTunnel(tunnelName: string): Promise {
}
}
-// Config-related functions (use port 8084 for localhost)
+export { api, configEditorApi };
-export { sshHostApi, tunnelApi, configEditorApi };
\ No newline at end of file
+// Config Editor API functions
+interface ConfigEditorFile {
+ name: string;
+ path: string;
+ type?: 'file' | 'directory';
+ isSSH?: boolean;
+ sshSessionId?: string;
+}
+
+interface ConfigEditorShortcut {
+ name: string;
+ path: string;
+}
+
+// Config Editor database functions (use port 8081 for database operations)
+export async function getConfigEditorRecent(hostId: number): Promise {
+ try {
+ const response = await api.get(`/ssh/config_editor/recent?hostId=${hostId}`);
+ return response.data || [];
+ } catch (error) {
+ console.error('Error fetching recent files:', error);
+ return [];
+ }
+}
+
+export async function addConfigEditorRecent(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise {
+ try {
+ const response = await api.post('/ssh/config_editor/recent', file);
+ return response.data;
+ } catch (error) {
+ console.error('Error adding recent file:', error);
+ }
+}
+
+export async function removeConfigEditorRecent(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise {
+ try {
+ const response = await api.delete('/ssh/config_editor/recent', { data: file });
+ return response.data;
+ } catch (error) {
+ console.error('Error removing recent file:', error);
+ }
+}
+
+export async function getConfigEditorPinned(hostId: number): Promise {
+ try {
+ const response = await api.get(`/ssh/config_editor/pinned?hostId=${hostId}`);
+ return response.data || [];
+ } catch (error) {
+ console.error('Error fetching pinned files:', error);
+ return [];
+ }
+}
+
+export async function addConfigEditorPinned(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise {
+ try {
+ const response = await api.post('/ssh/config_editor/pinned', file);
+ return response.data;
+ } catch (error) {
+ console.error('Error adding pinned file:', error);
+ }
+}
+
+export async function removeConfigEditorPinned(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise {
+ try {
+ const response = await api.delete('/ssh/config_editor/pinned', { data: file });
+ return response.data;
+ } catch (error) {
+ console.error('Error removing pinned file:', error);
+ }
+}
+
+export async function getConfigEditorShortcuts(hostId: number): Promise {
+ try {
+ const response = await api.get(`/ssh/config_editor/shortcuts?hostId=${hostId}`);
+ return response.data || [];
+ } catch (error) {
+ console.error('Error fetching shortcuts:', error);
+ return [];
+ }
+}
+
+export async function addConfigEditorShortcut(shortcut: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise {
+ try {
+ const response = await api.post('/ssh/config_editor/shortcuts', shortcut);
+ return response.data;
+ } catch (error) {
+ console.error('Error adding shortcut:', error);
+ }
+}
+
+export async function removeConfigEditorShortcut(shortcut: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise {
+ try {
+ const response = await api.delete('/ssh/config_editor/shortcuts', { data: shortcut });
+ return response.data;
+ } catch (error) {
+ console.error('Error removing shortcut:', error);
+ }
+}
+
+// SSH file operations - FIXED: Using configEditorApi for port 8084
+export async function connectSSH(sessionId: string, config: {
+ ip: string;
+ port: number;
+ username: string;
+ password?: string;
+ sshKey?: string;
+ keyPassword?: string;
+}): Promise {
+ try {
+ const response = await configEditorApi.post('/ssh/config_editor/ssh/connect', {
+ sessionId,
+ ...config
+ });
+ return response.data;
+ } catch (error) {
+ console.error('Error connecting SSH:', error);
+ throw error;
+ }
+}
+
+export async function disconnectSSH(sessionId: string): Promise {
+ try {
+ const response = await configEditorApi.post('/ssh/config_editor/ssh/disconnect', { sessionId });
+ return response.data;
+ } catch (error) {
+ console.error('Error disconnecting SSH:', error);
+ throw error;
+ }
+}
+
+export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
+ try {
+ const response = await configEditorApi.get('/ssh/config_editor/ssh/status', {
+ params: { sessionId }
+ });
+ return response.data;
+ } catch (error) {
+ console.error('Error getting SSH status:', error);
+ throw error;
+ }
+}
+
+export async function listSSHFiles(sessionId: string, path: string): Promise {
+ try {
+ const response = await configEditorApi.get('/ssh/config_editor/ssh/listFiles', {
+ params: { sessionId, path }
+ });
+ return response.data || [];
+ } catch (error) {
+ console.error('Error listing SSH files:', error);
+ throw error;
+ }
+}
+
+export async function readSSHFile(sessionId: string, path: string): Promise<{ content: string; path: string }> {
+ try {
+ const response = await configEditorApi.get('/ssh/config_editor/ssh/readFile', {
+ params: { sessionId, path }
+ });
+ return response.data;
+ } catch (error) {
+ console.error('Error reading SSH file:', error);
+ throw error;
+ }
+}
+
+export async function writeSSHFile(sessionId: string, path: string, content: string): Promise {
+ try {
+ const response = await configEditorApi.post('/ssh/config_editor/ssh/writeFile', {
+ sessionId,
+ path,
+ content
+ });
+ return response.data;
+ } catch (error) {
+ console.error('Error writing SSH file:', error);
+ throw error;
+ }
+}
\ No newline at end of file
diff --git a/src/backend/config_editor/config_editor.ts b/src/backend/config_editor/config_editor.ts
index fc374256..4e272e3d 100644
--- a/src/backend/config_editor/config_editor.ts
+++ b/src/backend/config_editor/config_editor.ts
@@ -1,7 +1,5 @@
import express from 'express';
import cors from 'cors';
-import fs from 'fs';
-import path from 'path';
import { Client as SSHClient } from 'ssh2';
import chalk from "chalk";
@@ -40,83 +38,6 @@ const logger = {
}
};
-// --- Local File Operations ---
-function normalizeFilePath(inputPath: string): string {
- if (!inputPath || typeof inputPath !== 'string') throw new Error('Invalid path');
- let normalizedPath = inputPath.replace(/\\/g, '/');
- const windowsAbsPath = /^[a-zA-Z]:\//;
- if (windowsAbsPath.test(normalizedPath)) return path.resolve(normalizedPath);
- if (normalizedPath.startsWith('/')) return path.resolve(normalizedPath);
- return path.resolve(process.cwd(), normalizedPath);
-}
-function isDirectory(p: string): boolean {
- try { return fs.statSync(p).isDirectory(); } catch { return false; }
-}
-
-app.get('/files', (req, res) => {
- try {
- const folderParam = req.query.folder as string || '';
- const folderPath = normalizeFilePath(folderParam);
- if (!fs.existsSync(folderPath) || !isDirectory(folderPath)) {
- logger.error('Directory not found:', folderPath);
- return res.status(404).json({ error: 'Directory not found' });
- }
- fs.readdir(folderPath, { withFileTypes: true }, (err, files) => {
- if (err) {
- logger.error('Error reading directory:', err);
- return res.status(500).json({ error: err.message });
- }
- const result = files.map(f => ({ name: f.name, type: f.isDirectory() ? 'directory' : 'file' }));
- res.json(result);
- });
- } catch (err: any) {
- logger.error('Error in /files endpoint:', err);
- res.status(400).json({ error: err.message });
- }
-});
-
-app.get('/file', (req, res) => {
- try {
- const folderParam = req.query.folder as string || '';
- const fileName = req.query.name as string;
- if (!fileName) return res.status(400).json({ error: 'Missing "name" parameter' });
- const folderPath = normalizeFilePath(folderParam);
- const filePath = path.join(folderPath, fileName);
- if (!fs.existsSync(filePath)) {
- logger.error(`File not found: ${filePath}`);
- return res.status(404).json({ error: 'File not found' });
- }
- if (isDirectory(filePath)) {
- logger.error(`Path is a directory: ${filePath}`);
- return res.status(400).json({ error: 'Path is a directory' });
- }
- const content = fs.readFileSync(filePath, 'utf8');
- res.setHeader('Content-Type', 'text/plain');
- res.send(content);
- } catch (err: any) {
- logger.error('Error in /file GET endpoint:', err);
- res.status(500).json({ error: err.message });
- }
-});
-
-app.post('/file', (req, res) => {
- try {
- const folderParam = req.query.folder as string || '';
- const fileName = req.query.name as string;
- const content = req.body.content;
- if (!fileName) return res.status(400).json({ error: 'Missing "name" parameter' });
- if (content === undefined) return res.status(400).json({ error: 'Missing "content" in request body' });
- const folderPath = normalizeFilePath(folderParam);
- const filePath = path.join(folderPath, fileName);
- if (!fs.existsSync(folderPath)) fs.mkdirSync(folderPath, { recursive: true });
- fs.writeFileSync(filePath, content, 'utf8');
- res.json({ message: 'File written successfully' });
- } catch (err: any) {
- logger.error('Error in /file POST endpoint:', err);
- res.status(500).json({ error: err.message });
- }
-});
-
// --- SSH Operations (per-session, in-memory, with cleanup) ---
interface SSHSession {
client: SSHClient;
@@ -136,6 +57,7 @@ function cleanupSession(sessionId: string) {
logger.info(`Cleaned up SSH session: ${sessionId}`);
}
}
+
function scheduleSessionCleanup(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
@@ -144,68 +66,130 @@ function scheduleSessionCleanup(sessionId: string) {
}
}
-app.post('/ssh/connect', (req, res) => {
+app.post('/ssh/config_editor/ssh/connect', (req, res) => {
const { sessionId, ip, port, username, password, sshKey, keyPassword } = req.body;
if (!sessionId || !ip || !username || !port) {
logger.warn('Missing SSH connection parameters');
return res.status(400).json({ error: 'Missing SSH connection parameters' });
}
+
+ logger.info(`Attempting SSH connection: ${ip}:${port} as ${username} (session: ${sessionId})`);
+ logger.info(`Auth method: ${sshKey ? 'SSH Key' : password ? 'Password' : 'None'}`);
+ logger.info(`Request body keys: ${Object.keys(req.body).join(', ')}`);
+ logger.info(`Password present: ${!!password}, Key present: ${!!sshKey}`);
+
if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId);
const client = new SSHClient();
const config: any = {
- host: ip, port: port || 22, username,
- readyTimeout: 20000, keepaliveInterval: 10000, keepaliveCountMax: 3,
+ host: ip,
+ port: port || 22,
+ username,
+ readyTimeout: 20000,
+ keepaliveInterval: 10000,
+ keepaliveCountMax: 3,
};
- if (sshKey) { config.privateKey = sshKey; if (keyPassword) config.passphrase = keyPassword; }
- else if (password) config.password = password;
- else { logger.warn('No password or key provided'); return res.status(400).json({ error: 'Either password or SSH key must be provided' }); }
+
+ if (sshKey && sshKey.trim()) {
+ config.privateKey = sshKey;
+ if (keyPassword) config.passphrase = keyPassword;
+ logger.info('Using SSH key authentication');
+ }
+ else if (password && password.trim()) {
+ config.password = password;
+ logger.info('Using password authentication');
+ }
+ else {
+ logger.warn('No password or key provided');
+ return res.status(400).json({ error: 'Either password or SSH key must be provided' });
+ }
+
+ // Create a response promise to handle async connection
+ let responseSent = false;
+
client.on('ready', () => {
+ if (responseSent) return;
+ responseSent = true;
sshSessions[sessionId] = { client, isConnected: true, lastActive: Date.now() };
scheduleSessionCleanup(sessionId);
logger.info(`SSH connected: ${ip}:${port} as ${username} (session: ${sessionId})`);
res.json({ status: 'success', message: 'SSH connection established' });
});
+
client.on('error', (err) => {
- logger.error('SSH connection error:', err.message);
+ if (responseSent) return;
+ responseSent = true;
+ logger.error(`SSH connection error for session ${sessionId}:`, err.message);
+ logger.error(`Connection details: ${ip}:${port} as ${username}`);
res.status(500).json({ status: 'error', message: err.message });
});
+
client.on('close', () => {
+ logger.info(`SSH connection closed for session ${sessionId}`);
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
cleanupSession(sessionId);
});
+
client.connect(config);
});
-app.post('/ssh/disconnect', (req, res) => {
+app.post('/ssh/config_editor/ssh/disconnect', (req, res) => {
const { sessionId } = req.body;
cleanupSession(sessionId);
res.json({ status: 'success', message: 'SSH connection disconnected' });
});
-app.get('/ssh/status', (req, res) => {
+app.get('/ssh/config_editor/ssh/status', (req, res) => {
const sessionId = req.query.sessionId as string;
const isConnected = !!sshSessions[sessionId]?.isConnected;
res.json({ status: 'success', connected: isConnected });
});
-app.get('/ssh/listFiles', (req, res) => {
+app.get('/ssh/config_editor/ssh/listFiles', (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
- const { path: sshPath = '/' } = req.query;
+ const sshPath = decodeURIComponent((req.query.path as string) || '/');
+
+ if (!sessionId) {
+ logger.warn('Session ID is required for listFiles');
+ return res.status(400).json({ error: 'Session ID is required' });
+ }
+
if (!sshConn?.isConnected) {
logger.warn(`SSH connection not established for session: ${sessionId}`);
return res.status(400).json({ error: 'SSH connection not established' });
}
+
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
- sshConn.client.exec(`ls -la "${sshPath}"`, (err, stream) => {
- if (err) { logger.error('SSH listFiles error:', err); return res.status(500).json({ error: err.message }); }
+
+ // Escape the path properly for shell command
+ const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
+ sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
+ if (err) {
+ logger.error('SSH listFiles error:', err);
+ return res.status(500).json({ error: err.message });
+ }
+
let data = '';
- stream.on('data', (chunk: Buffer) => { data += chunk.toString(); });
- stream.stderr.on('data', (_chunk: Buffer) => { /* ignore for now */ });
- stream.on('close', () => {
+ let errorData = '';
+
+ stream.on('data', (chunk: Buffer) => {
+ data += chunk.toString();
+ });
+
+ stream.stderr.on('data', (chunk: Buffer) => {
+ errorData += chunk.toString();
+ });
+
+ stream.on('close', (code) => {
+ if (code !== 0) {
+ logger.error(`SSH listFiles command failed with code ${code}: ${errorData}`);
+ return res.status(500).json({ error: `Command failed: ${errorData}` });
+ }
+
const lines = data.split('\n').filter(line => line.trim());
const files = [];
+
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
@@ -214,63 +198,249 @@ app.get('/ssh/listFiles', (req, res) => {
const name = parts.slice(8).join(' ');
const isDirectory = permissions.startsWith('d');
const isLink = permissions.startsWith('l');
- files.push({ name, type: isDirectory ? 'directory' : (isLink ? 'link' : 'file') });
+
+ // Skip . and .. directories
+ if (name === '.' || name === '..') continue;
+
+ files.push({
+ name,
+ type: isDirectory ? 'directory' : (isLink ? 'link' : 'file')
+ });
}
}
+
res.json(files);
});
});
});
-app.get('/ssh/readFile', (req, res) => {
+app.get('/ssh/config_editor/ssh/readFile', (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
- const { path: filePath } = req.query;
+ const filePath = decodeURIComponent(req.query.path as string);
+
+ if (!sessionId) {
+ logger.warn('Session ID is required for readFile');
+ return res.status(400).json({ error: 'Session ID is required' });
+ }
+
if (!sshConn?.isConnected) {
logger.warn(`SSH connection not established for session: ${sessionId}`);
return res.status(400).json({ error: 'SSH connection not established' });
}
+
if (!filePath) {
logger.warn('File path is required for readFile');
return res.status(400).json({ error: 'File path is required' });
}
+
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
- sshConn.client.exec(`cat "${filePath}"`, (err, stream) => {
- if (err) { logger.error('SSH readFile error:', err); return res.status(500).json({ error: err.message }); }
+
+ // Escape the file path properly
+ const escapedPath = filePath.replace(/'/g, "'\"'\"'");
+ sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
+ if (err) {
+ logger.error('SSH readFile error:', err);
+ return res.status(500).json({ error: err.message });
+ }
+
let data = '';
- stream.on('data', (chunk: Buffer) => { data += chunk.toString(); });
- stream.stderr.on('data', (_chunk: Buffer) => { /* ignore for now */ });
- stream.on('close', () => {
+ let errorData = '';
+
+ stream.on('data', (chunk: Buffer) => {
+ data += chunk.toString();
+ });
+
+ stream.stderr.on('data', (chunk: Buffer) => {
+ errorData += chunk.toString();
+ });
+
+ stream.on('close', (code) => {
+ if (code !== 0) {
+ logger.error(`SSH readFile command failed with code ${code}: ${errorData}`);
+ return res.status(500).json({ error: `Command failed: ${errorData}` });
+ }
+
res.json({ content: data, path: filePath });
});
});
});
-app.post('/ssh/writeFile', (req, res) => {
+app.post('/ssh/config_editor/ssh/writeFile', (req, res) => {
const { sessionId, path: filePath, content } = req.body;
const sshConn = sshSessions[sessionId];
+
+ if (!sessionId) {
+ logger.warn('Session ID is required for writeFile');
+ return res.status(400).json({ error: 'Session ID is required' });
+ }
+
if (!sshConn?.isConnected) {
logger.warn(`SSH connection not established for session: ${sessionId}`);
return res.status(400).json({ error: 'SSH connection not established' });
}
+
+ logger.info(`SSH connection status for session ${sessionId}: connected=${sshConn.isConnected}, lastActive=${new Date(sshConn.lastActive).toISOString()}`);
+
if (!filePath) {
logger.warn('File path is required for writeFile');
return res.status(400).json({ error: 'File path is required' });
}
+
if (content === undefined) {
logger.warn('File content is required for writeFile');
return res.status(400).json({ error: 'File content is required' });
}
+
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
- // Write to a temp file, then move
+
+ // Write to a temp file, then move - properly escape paths and content
const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
- const safeContent = content.replace(/'/g, "'\"'\"'");
- sshConn.client.exec(`echo '${safeContent}' > "${tempFile}" && mv "${tempFile}" "${filePath}"`, (err, stream) => {
- if (err) { logger.error('SSH writeFile error:', err); return res.status(500).json({ error: err.message }); }
- stream.on('close', () => {
- res.json({ message: 'File written successfully', path: filePath });
+ const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
+ const escapedFilePath = filePath.replace(/'/g, "'\"'\"'");
+
+ // Use base64 encoding to safely transfer content
+ const base64Content = Buffer.from(content, 'utf8').toString('base64');
+
+ logger.info(`Starting writeFile operation: session=${sessionId}, path=${filePath}, contentLength=${content.length}, base64Length=${base64Content.length}`);
+
+ // Add timeout to prevent hanging
+ const commandTimeout = setTimeout(() => {
+ logger.error(`SSH writeFile command timed out for session: ${sessionId}`);
+ if (!res.headersSent) {
+ res.status(500).json({ error: 'SSH command timed out' });
+ }
+ }, 15000); // 15 second timeout
+
+ // First check file permissions and ownership
+ const checkCommand = `ls -la '${escapedFilePath}' 2>/dev/null || echo "File does not exist"`;
+ logger.info(`Checking file details: ${filePath}`);
+
+ sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
+ if (checkErr) {
+ logger.error('File check failed:', checkErr);
+ return res.status(500).json({ error: `File check failed: ${checkErr.message}` });
+ }
+
+ let checkResult = '';
+ checkStream.on('data', (chunk: Buffer) => {
+ checkResult += chunk.toString();
+ });
+
+ checkStream.on('close', (checkCode) => {
+ logger.info(`File check result: ${checkResult.trim()}`);
+
+ // Use a simpler approach: write base64 to temp file, decode and write to target, then clean up
+ // Add explicit exit to ensure the command completes
+ const writeCommand = `echo '${base64Content}' > '${escapedTempFile}' && base64 -d '${escapedTempFile}' > '${escapedFilePath}' && rm -f '${escapedTempFile}' && echo "SUCCESS" && exit 0`;
+
+ logger.info(`Executing write command for: ${filePath}`);
+
+ sshConn.client.exec(writeCommand, (err, stream) => {
+ if (err) {
+ clearTimeout(commandTimeout);
+ logger.error('SSH writeFile error:', err);
+ if (!res.headersSent) {
+ return res.status(500).json({ error: err.message });
+ }
+ return;
+ }
+
+ let outputData = '';
+ let errorData = '';
+
+ stream.on('data', (chunk: Buffer) => {
+ outputData += chunk.toString();
+ logger.debug(`SSH writeFile stdout: ${chunk.toString()}`);
+ });
+
+ stream.stderr.on('data', (chunk: Buffer) => {
+ errorData += chunk.toString();
+ logger.debug(`SSH writeFile stderr: ${chunk.toString()}`);
+
+ // Check for permission denied and fail fast
+ if (chunk.toString().includes('Permission denied')) {
+ clearTimeout(commandTimeout);
+ logger.error(`Permission denied writing to file: ${filePath}`);
+ if (!res.headersSent) {
+ return res.status(403).json({
+ error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.`
+ });
+ }
+ return;
+ }
+ });
+
+ stream.on('close', (code) => {
+ logger.info(`SSH writeFile command completed with code: ${code}, output: "${outputData.trim()}", error: "${errorData.trim()}"`);
+ clearTimeout(commandTimeout);
+
+ // Check if we got the success message
+ if (outputData.includes('SUCCESS')) {
+ // Verify the file was actually written by checking its size
+ const verifyCommand = `ls -la '${escapedFilePath}' 2>/dev/null | awk '{print $5}'`;
+ logger.info(`Verifying file was written: ${filePath}`);
+
+ sshConn.client.exec(verifyCommand, (verifyErr, verifyStream) => {
+ if (verifyErr) {
+ logger.warn('File verification failed, but assuming success:');
+ if (!res.headersSent) {
+ res.json({ message: 'File written successfully', path: filePath });
+ }
+ return;
+ }
+
+ let verifyResult = '';
+ verifyStream.on('data', (chunk: Buffer) => {
+ verifyResult += chunk.toString();
+ });
+
+ verifyStream.on('close', (verifyCode) => {
+ const fileSize = Number(verifyResult.trim());
+ logger.info(`File verification result: size=${fileSize} bytes`);
+
+ if (fileSize > 0) {
+ logger.info(`File written successfully: ${filePath} (${fileSize} bytes)`);
+ if (!res.headersSent) {
+ res.json({ message: 'File written successfully', path: filePath });
+ }
+ } else {
+ logger.error(`File appears to be empty after write: ${filePath}`);
+ if (!res.headersSent) {
+ res.status(500).json({ error: 'File write operation may have failed - file appears empty' });
+ }
+ }
+ });
+ });
+ return;
+ }
+
+ if (code !== 0) {
+ logger.error(`SSH writeFile command failed with code ${code}: ${errorData}`);
+ if (!res.headersSent) {
+ return res.status(500).json({ error: `Command failed: ${errorData}` });
+ }
+ return;
+ }
+
+ // If code is 0 but no SUCCESS message, assume it worked anyway
+ // This handles cases where the echo "SUCCESS" didn't work but the file write did
+ logger.info(`File written successfully (code 0, no SUCCESS message): ${filePath}`);
+ if (!res.headersSent) {
+ res.json({ message: 'File written successfully', path: filePath });
+ }
+ });
+
+ stream.on('error', (streamErr) => {
+ clearTimeout(commandTimeout);
+ logger.error('SSH writeFile stream error:', streamErr);
+ if (!res.headersSent) {
+ res.status(500).json({ error: `Stream error: ${streamErr.message}` });
+ }
+ });
+ });
});
});
});
diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts
index 9f739349..a0998029 100644
--- a/src/backend/database/db/index.ts
+++ b/src/backend/database/db/index.ts
@@ -78,6 +78,39 @@ CREATE TABLE IF NOT EXISTS ssh_data (
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
);
+
+CREATE TABLE IF NOT EXISTS config_editor_recent (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ host_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ path TEXT NOT NULL,
+ last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(user_id) REFERENCES users(id),
+ FOREIGN KEY(host_id) REFERENCES ssh_data(id)
+);
+
+CREATE TABLE IF NOT EXISTS config_editor_pinned (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ host_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ path TEXT NOT NULL,
+ pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(user_id) REFERENCES users(id),
+ FOREIGN KEY(host_id) REFERENCES ssh_data(id)
+);
+
+CREATE TABLE IF NOT EXISTS config_editor_shortcuts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ host_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ path TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(user_id) REFERENCES users(id),
+ FOREIGN KEY(host_id) REFERENCES ssh_data(id)
+);
`);
// Function to safely add a column if it doesn't exist
@@ -120,6 +153,11 @@ const migrateSchema = () => {
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
+ // Add missing columns to config_editor tables
+ addColumnIfNotExists('config_editor_recent', 'host_id', 'INTEGER NOT NULL');
+ addColumnIfNotExists('config_editor_pinned', 'host_id', 'INTEGER NOT NULL');
+ addColumnIfNotExists('config_editor_shortcuts', 'host_id', 'INTEGER NOT NULL');
+
logger.success('Schema migration completed');
};
diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts
index 26b74367..1a510151 100644
--- a/src/backend/database/db/schema.ts
+++ b/src/backend/database/db/schema.ts
@@ -35,4 +35,31 @@ export const sshData = sqliteTable('ssh_data', {
defaultPath: text('default_path'), // Default path for SSH connection
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
+});
+
+export const configEditorRecent = sqliteTable('config_editor_recent', {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ userId: text('user_id').notNull().references(() => users.id),
+ hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID
+ name: text('name').notNull(), // File name
+ path: text('path').notNull(), // File path
+ lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
+});
+
+export const configEditorPinned = sqliteTable('config_editor_pinned', {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ userId: text('user_id').notNull().references(() => users.id),
+ hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID
+ name: text('name').notNull(), // File name
+ path: text('path').notNull(), // File path
+ pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
+});
+
+export const configEditorShortcuts = sqliteTable('config_editor_shortcuts', {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ userId: text('user_id').notNull().references(() => users.id),
+ hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID
+ name: text('name').notNull(), // Folder name
+ path: text('path').notNull(), // Folder path
+ createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
\ No newline at end of file
diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts
index bfee9e31..579e3f85 100644
--- a/src/backend/database/routes/ssh.ts
+++ b/src/backend/database/routes/ssh.ts
@@ -1,7 +1,7 @@
import express from 'express';
import { db } from '../db/index.js';
-import { sshData } from '../db/schema.js';
-import { eq, and } from 'drizzle-orm';
+import { sshData, configEditorRecent, configEditorPinned, configEditorShortcuts } from '../db/schema.js';
+import { eq, and, desc } from 'drizzle-orm';
import chalk from 'chalk';
import jwt from 'jsonwebtoken';
import multer from 'multer';
@@ -384,4 +384,312 @@ router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Respons
}
});
+// Config Editor Database Routes
+
+// Route: Get recent files (requires JWT)
+// GET /ssh/config_editor/recent
+router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+ const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
+
+ if (!isNonEmptyString(userId)) {
+ logger.warn('Invalid userId for recent files fetch');
+ return res.status(400).json({ error: 'Invalid userId' });
+ }
+
+ if (!hostId) {
+ logger.warn('Host ID is required for recent files fetch');
+ return res.status(400).json({ error: 'Host ID is required' });
+ }
+
+ try {
+ const recentFiles = await db
+ .select()
+ .from(configEditorRecent)
+ .where(and(
+ eq(configEditorRecent.userId, userId),
+ eq(configEditorRecent.hostId, hostId)
+ ))
+ .orderBy(desc(configEditorRecent.lastOpened));
+ res.json(recentFiles);
+ } catch (err) {
+ logger.error('Failed to fetch recent files', err);
+ res.status(500).json({ error: 'Failed to fetch recent files' });
+ }
+});
+
+// Route: Add file to recent (requires JWT)
+// POST /ssh/config_editor/recent
+router.post('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+ const { name, path, hostId } = req.body;
+ if (!isNonEmptyString(userId) || !name || !path || !hostId) {
+ logger.warn('Invalid request for adding recent file');
+ return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
+ }
+ try {
+ // Check if file already exists in recent for this host
+ const conditions = [
+ eq(configEditorRecent.userId, userId),
+ eq(configEditorRecent.path, path),
+ eq(configEditorRecent.hostId, hostId)
+ ];
+
+ const existing = await db
+ .select()
+ .from(configEditorRecent)
+ .where(and(...conditions));
+
+ if (existing.length > 0) {
+ // Update lastOpened timestamp
+ await db
+ .update(configEditorRecent)
+ .set({ lastOpened: new Date().toISOString() })
+ .where(and(...conditions));
+ } else {
+ // Add new recent file
+ await db.insert(configEditorRecent).values({
+ userId,
+ hostId,
+ name,
+ path,
+ lastOpened: new Date().toISOString()
+ });
+ }
+ res.json({ message: 'File added to recent' });
+ } catch (err) {
+ logger.error('Failed to add recent file', err);
+ res.status(500).json({ error: 'Failed to add recent file' });
+ }
+});
+
+// Route: Remove file from recent (requires JWT)
+// DELETE /ssh/config_editor/recent
+router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+ const { name, path, hostId } = req.body;
+ if (!isNonEmptyString(userId) || !name || !path || !hostId) {
+ logger.warn('Invalid request for removing recent file');
+ return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
+ }
+ try {
+ logger.info(`Removing recent file: ${name} at ${path} for user ${userId} and host ${hostId}`);
+
+ const conditions = [
+ eq(configEditorRecent.userId, userId),
+ eq(configEditorRecent.path, path),
+ eq(configEditorRecent.hostId, hostId)
+ ];
+
+ const result = await db
+ .delete(configEditorRecent)
+ .where(and(...conditions));
+ logger.info(`Recent file removed successfully`);
+ res.json({ message: 'File removed from recent' });
+ } catch (err) {
+ logger.error('Failed to remove recent file', err);
+ res.status(500).json({ error: 'Failed to remove recent file' });
+ }
+});
+
+// Route: Get pinned files (requires JWT)
+// GET /ssh/config_editor/pinned
+router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+ const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
+
+ if (!isNonEmptyString(userId)) {
+ logger.warn('Invalid userId for pinned files fetch');
+ return res.status(400).json({ error: 'Invalid userId' });
+ }
+
+ if (!hostId) {
+ logger.warn('Host ID is required for pinned files fetch');
+ return res.status(400).json({ error: 'Host ID is required' });
+ }
+
+ try {
+ const pinnedFiles = await db
+ .select()
+ .from(configEditorPinned)
+ .where(and(
+ eq(configEditorPinned.userId, userId),
+ eq(configEditorPinned.hostId, hostId)
+ ))
+ .orderBy(configEditorPinned.pinnedAt);
+ res.json(pinnedFiles);
+ } catch (err) {
+ logger.error('Failed to fetch pinned files', err);
+ res.status(500).json({ error: 'Failed to fetch pinned files' });
+ }
+});
+
+// Route: Add file to pinned (requires JWT)
+// POST /ssh/config_editor/pinned
+router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+ const { name, path, hostId } = req.body;
+ if (!isNonEmptyString(userId) || !name || !path || !hostId) {
+ logger.warn('Invalid request for adding pinned file');
+ return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
+ }
+ try {
+ // Check if file already exists in pinned for this host
+ const conditions = [
+ eq(configEditorPinned.userId, userId),
+ eq(configEditorPinned.path, path),
+ eq(configEditorPinned.hostId, hostId)
+ ];
+
+ const existing = await db
+ .select()
+ .from(configEditorPinned)
+ .where(and(...conditions));
+
+ if (existing.length === 0) {
+ // Add new pinned file
+ await db.insert(configEditorPinned).values({
+ userId,
+ hostId,
+ name,
+ path,
+ pinnedAt: new Date().toISOString()
+ });
+ }
+ res.json({ message: 'File pinned successfully' });
+ } catch (err) {
+ logger.error('Failed to pin file', err);
+ res.status(500).json({ error: 'Failed to pin file' });
+ }
+});
+
+// Route: Remove file from pinned (requires JWT)
+// DELETE /ssh/config_editor/pinned
+router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+ const { name, path, hostId } = req.body;
+ if (!isNonEmptyString(userId) || !name || !path || !hostId) {
+ logger.warn('Invalid request for removing pinned file');
+ return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
+ }
+ try {
+ logger.info(`Removing pinned file: ${name} at ${path} for user ${userId} and host ${hostId}`);
+
+ const conditions = [
+ eq(configEditorPinned.userId, userId),
+ eq(configEditorPinned.path, path),
+ eq(configEditorPinned.hostId, hostId)
+ ];
+
+ const result = await db
+ .delete(configEditorPinned)
+ .where(and(...conditions));
+ logger.info(`Pinned file removed successfully`);
+ res.json({ message: 'File unpinned successfully' });
+ } catch (err) {
+ logger.error('Failed to unpin file', err);
+ res.status(500).json({ error: 'Failed to unpin file' });
+ }
+});
+
+// Route: Get folder shortcuts (requires JWT)
+// GET /ssh/config_editor/shortcuts
+router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+ const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
+
+ if (!isNonEmptyString(userId)) {
+ logger.warn('Invalid userId for shortcuts fetch');
+ return res.status(400).json({ error: 'Invalid userId' });
+ }
+
+ if (!hostId) {
+ logger.warn('Host ID is required for shortcuts fetch');
+ return res.status(400).json({ error: 'Host ID is required' });
+ }
+
+ try {
+ const shortcuts = await db
+ .select()
+ .from(configEditorShortcuts)
+ .where(and(
+ eq(configEditorShortcuts.userId, userId),
+ eq(configEditorShortcuts.hostId, hostId)
+ ))
+ .orderBy(configEditorShortcuts.createdAt);
+ res.json(shortcuts);
+ } catch (err) {
+ logger.error('Failed to fetch shortcuts', err);
+ res.status(500).json({ error: 'Failed to fetch shortcuts' });
+ }
+});
+
+// Route: Add folder shortcut (requires JWT)
+// POST /ssh/config_editor/shortcuts
+router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+ const { name, path, hostId } = req.body;
+ if (!isNonEmptyString(userId) || !name || !path || !hostId) {
+ logger.warn('Invalid request for adding shortcut');
+ return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
+ }
+ try {
+ // Check if shortcut already exists for this host
+ const conditions = [
+ eq(configEditorShortcuts.userId, userId),
+ eq(configEditorShortcuts.path, path),
+ eq(configEditorShortcuts.hostId, hostId)
+ ];
+
+ const existing = await db
+ .select()
+ .from(configEditorShortcuts)
+ .where(and(...conditions));
+
+ if (existing.length === 0) {
+ // Add new shortcut
+ await db.insert(configEditorShortcuts).values({
+ userId,
+ hostId,
+ name,
+ path,
+ createdAt: new Date().toISOString()
+ });
+ }
+ res.json({ message: 'Shortcut added successfully' });
+ } catch (err) {
+ logger.error('Failed to add shortcut', err);
+ res.status(500).json({ error: 'Failed to add shortcut' });
+ }
+});
+
+// Route: Remove folder shortcut (requires JWT)
+// DELETE /ssh/config_editor/shortcuts
+router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+ const { name, path, hostId } = req.body;
+ if (!isNonEmptyString(userId) || !name || !path || !hostId) {
+ logger.warn('Invalid request for removing shortcut');
+ return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
+ }
+ try {
+ logger.info(`Removing shortcut: ${name} at ${path} for user ${userId} and host ${hostId}`);
+
+ const conditions = [
+ eq(configEditorShortcuts.userId, userId),
+ eq(configEditorShortcuts.path, path),
+ eq(configEditorShortcuts.hostId, hostId)
+ ];
+
+ const result = await db
+ .delete(configEditorShortcuts)
+ .where(and(...conditions));
+ logger.info(`Shortcut removed successfully`);
+ res.json({ message: 'Shortcut removed successfully' });
+ } catch (err) {
+ logger.error('Failed to remove shortcut', err);
+ res.status(500).json({ error: 'Failed to remove shortcut' });
+ }
+});
+
export default router;
\ No newline at end of file