diff --git a/src/apps/Homepage/HomepageAuth.tsx b/src/apps/Homepage/HomepageAuth.tsx index d534aba5..d9e26c15 100644 --- a/src/apps/Homepage/HomepageAuth.tsx +++ b/src/apps/Homepage/HomepageAuth.tsx @@ -213,7 +213,7 @@ export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername, diff --git a/src/apps/SSH/Config Editor/ConfigCodeEditor.tsx b/src/apps/SSH/Config Editor/ConfigCodeEditor.tsx index de688803..ce8d23b2 100644 --- a/src/apps/SSH/Config Editor/ConfigCodeEditor.tsx +++ b/src/apps/SSH/Config Editor/ConfigCodeEditor.tsx @@ -7,15 +7,20 @@ import { EditorView } from '@codemirror/view'; interface ConfigCodeEditorProps { content: string; - fileNameOld: string; + fileName: string; onContentChange: (value: string) => void; } -export function ConfigCodeEditor({content, fileNameOld, onContentChange}: ConfigCodeEditorProps) { - const fileName = "test.js" - +export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCodeEditorProps) { function getLanguageName(filename: string): string { - const ext = filename.slice(filename.lastIndexOf('.') + 1).toLowerCase(); + if (!filename || typeof filename !== 'string') { + return 'text'; // Default to text if no filename + } + const lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex === -1) { + return 'text'; // No extension found + } + const ext = filename.slice(lastDotIndex + 1).toLowerCase(); switch (ext) { case 'ng': return 'angular'; @@ -162,7 +167,7 @@ export function ConfigCodeEditor({content, fileNameOld, onContentChange}: Config case 'yaml': case 'yml': return 'yaml'; case 'z80': return 'z80'; - default: return 'js'; + default: return 'text'; } } @@ -189,7 +194,7 @@ export function ConfigCodeEditor({content, fileNameOld, onContentChange}: Config row.startsWith('jwt='))?.split('=')[1]; -} +import { + getConfigEditorRecent, + getConfigEditorPinned, + getConfigEditorShortcuts, + addConfigEditorRecent, + removeConfigEditorRecent, + addConfigEditorPinned, + removeConfigEditorPinned, + addConfigEditorShortcut, + removeConfigEditorShortcut, + readSSHFile, + writeSSHFile, + getSSHStatus, + connectSSH +} from '@/apps/SSH/ssh-axios-fixed.ts'; interface Tab { id: string | number; @@ -22,107 +32,304 @@ interface Tab { filePath?: string; loading?: boolean; error?: string; + success?: string; dirty?: boolean; } +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; +} + export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) => void }): React.ReactElement { const [tabs, setTabs] = useState([]); const [activeTab, setActiveTab] = useState('home'); const [recent, setRecent] = useState([]); const [pinned, setPinned] = useState([]); const [shortcuts, setShortcuts] = useState([]); - const [loadingHome, setLoadingHome] = useState(false); - const [errorHome, setErrorHome] = useState(undefined); - const API_BASE_DB = 'http://localhost:8081'; // For database-backed endpoints - const API_BASE = 'http://localhost:8084'; // For stateless file/ssh operations + const [currentHost, setCurrentHost] = useState(null); + const [isSaving, setIsSaving] = useState(false); const sidebarRef = useRef(null); - // Fetch home data + // Fetch home data when host changes useEffect(() => { - fetchHomeData(); - }, []); + if (currentHost) { + fetchHomeData(); + } else { + // Clear data when no host is selected + setRecent([]); + setPinned([]); + setShortcuts([]); + } + }, [currentHost]); + + // Refresh home data when switching to home view + useEffect(() => { + if (activeTab === 'home' && currentHost) { + fetchHomeData(); + } + }, [activeTab, currentHost]); + + + + // Periodic refresh of home data when on home view + useEffect(() => { + if (activeTab === 'home' && currentHost) { + const interval = setInterval(() => { + fetchHomeData(); + }, 2000); // Refresh every 2 seconds when on home view + + return () => clearInterval(interval); + } + }, [activeTab, currentHost]); + async function fetchHomeData() { - setLoadingHome(true); - setErrorHome(undefined); + if (!currentHost) return; + try { - const jwt = getJWT(); - const [recentRes, pinnedRes, shortcutsRes] = await Promise.all([ - axios.get(`${API_BASE_DB}/config_editor/recent`, { headers: { Authorization: `Bearer ${jwt}` } }), - axios.get(`${API_BASE_DB}/config_editor/pinned`, { headers: { Authorization: `Bearer ${jwt}` } }), - axios.get(`${API_BASE_DB}/config_editor/shortcuts`, { headers: { Authorization: `Bearer ${jwt}` } }), + console.log('Fetching home data for host:', currentHost.id); + + const homeDataPromise = Promise.all([ + getConfigEditorRecent(currentHost.id), + getConfigEditorPinned(currentHost.id), + getConfigEditorShortcuts(currentHost.id), ]); - setRecent(recentRes.data || []); - setPinned(pinnedRes.data || []); - setShortcuts(shortcutsRes.data || []); + + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Fetch home data timed out')), 15000) + ); + + const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]); + + console.log('Home data fetched successfully:', { + recentCount: recentRes?.length || 0, + pinnedCount: pinnedRes?.length || 0, + shortcutsCount: shortcutsRes?.length || 0 + }); + + // Process recent files to add isPinned property and type + const recentWithPinnedStatus = (recentRes || []).map(file => ({ + ...file, + type: 'file', // Assume all recent files are files, not directories + isPinned: (pinnedRes || []).some(pinnedFile => + pinnedFile.path === file.path && pinnedFile.name === file.name + ) + })); + + // Process pinned files to add type + const pinnedWithType = (pinnedRes || []).map(file => ({ + ...file, + type: 'file' // Assume all pinned files are files, not directories + })); + + setRecent(recentWithPinnedStatus); + setPinned(pinnedWithType); + setShortcuts((shortcutsRes || []).map(shortcut => ({ + ...shortcut, + type: 'directory' // Shortcuts are always directories + }))); } catch (err: any) { - setErrorHome('Failed to load home data'); - } finally { - setLoadingHome(false); + console.error('Failed to fetch home data:', err); } } + // Helper function for consistent error handling + const formatErrorMessage = (err: any, defaultMessage: string): string => { + if (typeof err === 'object' && err !== null && 'response' in err) { + const axiosErr = err as any; + if (axiosErr.response?.status === 403) { + return `Permission denied. ${defaultMessage}. Check the Docker logs for detailed error information.`; + } else if (axiosErr.response?.status === 500) { + const backendError = axiosErr.response?.data?.error || 'Internal server error occurred'; + return `Server Error (500): ${backendError}. Check the Docker logs for detailed error information.`; + } else if (axiosErr.response?.data?.error) { + const backendError = axiosErr.response.data.error; + return `${axiosErr.response?.status ? `Error ${axiosErr.response.status}: ` : ''}${backendError}. Check the Docker logs for detailed error information.`; + } else { + return `Request failed with status code ${axiosErr.response?.status || 'unknown'}. Check the Docker logs for detailed error information.`; + } + } else if (err instanceof Error) { + return `${err.message}. Check the Docker logs for detailed error information.`; + } else { + return `${defaultMessage}. Check the Docker logs for detailed error information.`; + } + }; + // Home view actions const handleOpenFile = async (file: any) => { const tabId = file.path; + console.log('Opening file:', { file, currentHost, tabId }); + if (!tabs.find(t => t.id === tabId)) { - setTabs([...tabs, { id: tabId, title: file.name, fileName: file.name, content: '', filePath: file.path, isSSH: file.isSSH, sshSessionId: file.sshSessionId, loading: true }]); + // Use the current host's SSH session ID instead of the stored one + const currentSshSessionId = currentHost?.id.toString(); + console.log('Using SSH session ID:', currentSshSessionId, 'for file path:', file.path); + + setTabs([...tabs, { id: tabId, title: file.name, fileName: file.name, content: '', filePath: file.path, isSSH: true, sshSessionId: currentSshSessionId, loading: true }]); try { - let content = ''; - const jwt = getJWT(); - if (file.isSSH) { - const res = await axios.get(`${API_BASE}/ssh/readFile`, { params: { sessionId: file.sshSessionId, path: file.path }, headers: { Authorization: `Bearer ${jwt}` } }); - content = res.data.content; - } else { - const folder = file.path.substring(0, file.path.lastIndexOf('/')); - const res = await axios.get(`${API_BASE}/file`, { params: { folder, name: file.name }, headers: { Authorization: `Bearer ${jwt}` } }); - content = res.data; - } - setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, content, loading: false, error: undefined } : t)); + const res = await readSSHFile(currentSshSessionId, file.path); + console.log('File read successful:', { path: file.path, contentLength: res.content?.length }); + setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, content: res.content, loading: false, error: undefined } : t)); // Mark as recent - await axios.post(`${API_BASE_DB}/config_editor/recent`, { name: file.name, path: file.path }, { headers: { Authorization: `Bearer ${jwt}` } }); + await addConfigEditorRecent({ + name: file.name, + path: file.path, + isSSH: true, + sshSessionId: currentSshSessionId, + hostId: currentHost?.id + }); + // Refresh immediately after opening file fetchHomeData(); } catch (err: any) { - setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, loading: false, error: err?.message || 'Failed to load file' } : t)); + console.error('Failed to read file:', { path: file.path, sessionId: currentSshSessionId, error: err }); + const errorMessage = formatErrorMessage(err, 'Cannot read file'); + setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, loading: false, error: errorMessage } : t)); } } setActiveTab(tabId); }; + const handleRemoveRecent = async (file: any) => { - // Implement backend delete if needed - setRecent(recent.filter(f => f.path !== file.path)); + try { + await removeConfigEditorRecent({ + name: file.name, + path: file.path, + isSSH: true, + sshSessionId: file.sshSessionId, + hostId: currentHost?.id + }); + // Refresh immediately after removing + fetchHomeData(); + } catch (err) { + console.error('Failed to remove recent file:', err); + } }; + const handlePinFile = async (file: any) => { - const jwt = getJWT(); - await axios.post(`${API_BASE_DB}/config_editor/pinned`, { name: file.name, path: file.path }, { headers: { Authorization: `Bearer ${jwt}` } }); - fetchHomeData(); + try { + await addConfigEditorPinned({ + name: file.name, + path: file.path, + isSSH: true, + sshSessionId: file.sshSessionId, + hostId: currentHost?.id + }); + // Refresh immediately after pinning + fetchHomeData(); + // Refresh sidebar files to update pin states immediately + if (sidebarRef.current && sidebarRef.current.fetchFiles) { + sidebarRef.current.fetchFiles(); + } + } catch (err) { + console.error('Failed to pin file:', err); + } }; + + const handleUnpinFile = async (file: any) => { + try { + await removeConfigEditorPinned({ + name: file.name, + path: file.path, + isSSH: true, + sshSessionId: file.sshSessionId, + hostId: currentHost?.id + }); + // Refresh immediately after unpinning + fetchHomeData(); + // Refresh sidebar files to update pin states immediately + if (sidebarRef.current && sidebarRef.current.fetchFiles) { + sidebarRef.current.fetchFiles(); + } + } catch (err) { + console.error('Failed to unpin file:', err); + } + }; + const handleOpenShortcut = async (shortcut: any) => { - // Find the server for this shortcut (local or SSH) - let server: any = { isLocal: true, name: 'Local Files', id: 'local', defaultPath: '/' }; - if (shortcut.server) { - server = shortcut.server; + console.log('Opening shortcut:', { shortcut, currentHost }); + + // Prevent multiple rapid clicks + if (sidebarRef.current?.isOpeningShortcut) { + console.log('Shortcut opening already in progress, ignoring click'); + return; } - // Use the sidebar's openFolder method - if ((window as any).configSidebarRef && (window as any).configSidebarRef.openFolder) { - (window as any).configSidebarRef.openFolder(server, shortcut.path); + + if (sidebarRef.current && sidebarRef.current.openFolder) { + try { + // Set flag to prevent multiple simultaneous opens + sidebarRef.current.isOpeningShortcut = true; + + // Normalize the path to ensure it starts with / + const normalizedPath = shortcut.path.startsWith('/') ? shortcut.path : `/${shortcut.path}`; + console.log('Normalized path:', normalizedPath); + + await sidebarRef.current.openFolder(currentHost, normalizedPath); + console.log('Shortcut opened successfully'); + } catch (err) { + console.error('Failed to open shortcut:', err); + // Could show error to user here if needed + } finally { + // Clear flag after operation completes + if (sidebarRef.current) { + sidebarRef.current.isOpeningShortcut = false; + } + } + } else { + console.error('Sidebar ref or openFolder function not available'); } }; - // Add add/remove shortcut logic + const handleAddShortcut = async (folderPath: string) => { try { - const jwt = getJWT(); - await axios.post(`${API_BASE_DB}/config_editor/shortcuts`, { name: folderPath.split('/').pop(), path: folderPath }); + const name = folderPath.split('/').pop() || folderPath; + await addConfigEditorShortcut({ + name, + path: folderPath, + isSSH: true, + sshSessionId: currentHost?.id.toString(), + hostId: currentHost?.id + }); + // Refresh immediately after adding shortcut fetchHomeData(); - } catch {} + } catch (err) { + console.error('Failed to add shortcut:', err); + } }; + const handleRemoveShortcut = async (shortcut: any) => { try { - const jwt = getJWT(); - await axios.post(`${API_BASE_DB}/config_editor/shortcuts/delete`, { path: shortcut.path }); + await removeConfigEditorShortcut({ + name: shortcut.name, + path: shortcut.path, + isSSH: true, + sshSessionId: currentHost?.id.toString(), + hostId: currentHost?.id + }); + // Refresh immediately after removing shortcut fetchHomeData(); - } catch {} + } catch (err) { + console.error('Failed to remove shortcut:', err); + } }; // Tab actions @@ -134,66 +341,237 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) => if (newTabs.length > 0) setActiveTab(newTabs[Math.max(0, idx - 1)].id); else setActiveTab('home'); } - }; - const setTabContent = (tabId: string | number, content: string) => { - setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, content, dirty: true } : t)); - }; - const handleSave = async (tab: Tab) => { - try { - const jwt = getJWT(); - if (tab.isSSH) { - await axios.post(`${API_BASE}/ssh/writeFile`, { sessionId: tab.sshSessionId, path: tab.filePath, content: tab.content }, { headers: { Authorization: `Bearer ${jwt}` } }); - } else { - await axios.post(`${API_BASE}/file?folder=${encodeURIComponent(tab.filePath?.substring(0, tab.filePath?.lastIndexOf('/')) || '')}&name=${encodeURIComponent(tab.fileName)}`, { content: tab.content }, { headers: { Authorization: `Bearer ${jwt}` } }); - } - setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, dirty: false } : t)); - // Mark as recent - await axios.post(`${API_BASE_DB}/config_editor/recent`, { name: tab.fileName, path: tab.filePath }, { headers: { Authorization: `Bearer ${jwt}` } }); + // Refresh home data when closing tabs to update recent list + if (currentHost) { fetchHomeData(); - } catch (err) { - setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, error: 'Failed to save file' } : t)); } }; + const setTabContent = (tabId: string | number, content: string) => { + setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, content, dirty: true, error: undefined, success: undefined } : t)); + }; + + const handleSave = async (tab: Tab) => { + // Prevent multiple simultaneous saves + if (isSaving) { + console.log('Save already in progress, ignoring save request'); + return; + } + + setIsSaving(true); + + try { + console.log('Saving file:', { + tabId: tab.id, + fileName: tab.fileName, + filePath: tab.filePath, + sshSessionId: tab.sshSessionId, + contentLength: tab.content?.length, + currentHost: currentHost?.id + }); + + if (!tab.sshSessionId) { + throw new Error('No SSH session ID available'); + } + + if (!tab.filePath) { + throw new Error('No file path available'); + } + + if (!currentHost?.id) { + throw new Error('No current host available'); + } + + // Check SSH connection status first with timeout + try { + const statusPromise = getSSHStatus(tab.sshSessionId); + const statusTimeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('SSH status check timed out')), 10000) + ); + + const status = await Promise.race([statusPromise, statusTimeoutPromise]); + + if (!status.connected) { + console.log('SSH session disconnected, attempting to reconnect...'); + // Try to reconnect using current host credentials with timeout + const connectPromise = connectSSH(tab.sshSessionId, { + ip: currentHost.ip, + port: currentHost.port, + username: currentHost.username, + password: currentHost.password, + sshKey: currentHost.key, + keyPassword: currentHost.keyPassword + }); + const connectTimeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('SSH reconnection timed out')), 15000) + ); + + await Promise.race([connectPromise, connectTimeoutPromise]); + console.log('SSH reconnection successful'); + } + } catch (statusErr) { + console.warn('Could not check SSH status or reconnect, proceeding with save attempt:', statusErr); + } + + // Add timeout to prevent hanging + console.log('Starting save operation with 30 second timeout...'); + const savePromise = writeSSHFile(tab.sshSessionId, tab.filePath, tab.content); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => { + console.log('Save operation timed out after 30 seconds'); + reject(new Error('Save operation timed out')); + }, 30000) + ); + + const result = await Promise.race([savePromise, timeoutPromise]); + console.log('Save operation completed successfully:', result); + setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, dirty: false, success: 'File saved successfully' } : t)); + console.log('File saved successfully - main save operation complete'); + + // Auto-hide success message after 3 seconds + setTimeout(() => { + setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, success: undefined } : t)); + }, 3000); + + // Mark as recent and refresh home data in background (non-blocking) + Promise.allSettled([ + (async () => { + try { + console.log('Adding file to recent...'); + await addConfigEditorRecent({ + name: tab.fileName, + path: tab.filePath, + isSSH: true, + sshSessionId: tab.sshSessionId, + hostId: currentHost.id + }); + console.log('File added to recent successfully'); + } catch (recentErr) { + console.warn('Failed to add file to recent:', recentErr); + } + })(), + (async () => { + try { + console.log('Refreshing home data...'); + await fetchHomeData(); + console.log('Home data refreshed successfully'); + } catch (refreshErr) { + console.warn('Failed to refresh home data:', refreshErr); + } + })() + ]).then(() => { + console.log('Background operations completed'); + }); + + console.log('File saved successfully - main operation complete, background operations started'); + } catch (err) { + console.error('Failed to save file:', err); + + let errorMessage = formatErrorMessage(err, 'Cannot save file'); + + // Check if this is a timeout error (which might mean the save actually worked) + if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) { + errorMessage = `Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.`; + } + + console.log('Final error message:', errorMessage); + + setTabs(tabs => { + const updatedTabs = tabs.map(t => t.id === tab.id ? { ...t, error: `Failed to save file: ${errorMessage}` } : t); + console.log('Updated tabs with error:', updatedTabs.find(t => t.id === tab.id)); + return updatedTabs; + }); + + // Force a re-render to ensure error is displayed + setTimeout(() => { + console.log('Forcing re-render to show error'); + setTabs(currentTabs => [...currentTabs]); + }, 100); + } finally { + console.log('Save operation completed, setting isSaving to false'); + setIsSaving(false); + console.log('isSaving state after setting to false:', false); + } + }; + + const handleHostChange = (host: SSHHost | null) => { + setCurrentHost(host); + // Close all tabs when switching hosts + setTabs([]); + setActiveTab('home'); + }; + + // Show connection message when no host is selected + if (!currentHost) { + return ( +
+
+ +
+
+
+

Connect to a Server

+

Select a server from the sidebar to start editing files

+
+
+
+ ); + } + return (
- +
-
- {/* Tab list scrollable area, full width except for Save button */} -
+
+ {/* Tab list scrollable area */} +
({ id: t.id, title: t.title }))} activeTab={activeTab} setActiveTab={setActiveTab} closeTab={closeTab} - onHomeClick={() => setActiveTab('home')} + onHomeClick={() => { + setActiveTab('home'); + // Immediately refresh home data when clicking home + if (currentHost) { + fetchHomeData(); + } + }} />
- {/* Save button only for file tabs, stationary at right */} - {activeTab !== 'home' && (() => { - const tab = tabs.find(t => t.id === activeTab); - if (!tab) return null; - return ( - - ); - })()} + {/* Save button - always visible */} +
@@ -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} +
+ +
+
+ )} + {/* Success display */} + {tab.success && ( +
+
+
+ + {tab.success} +
+ +
+
+ )}
- 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 - - Configure a new SSH connection for the config editor. - - -
- {addSubmitError && ( -
{addSubmitError}
- )} -
- - {/* Name */} - ( - - Name - - - - - - )} - /> - {/* Folder */} - ( - - Folder - - { - if (typeof field.ref === 'function') field.ref(el); - (folderInputRef as React.MutableRefObject).current = el; - }} - placeholder="e.g. Work" - autoComplete="off" - value={field.value} - onFocus={() => setFolderDropdownOpen(true)} - onChange={e => { - field.onChange(e); - setFolderDropdownOpen(true); - }} - disabled={foldersLoading} - /> - - {folderDropdownOpen && filteredFolders.length > 0 && ( -
-
- {filteredFolders.map((folder) => ( - - ))} -
-
- )} - {foldersLoading &&
Loading folders...
} - {foldersError &&
{foldersError}
} - -
- )} - /> - {/* Tags */} - ( - - Tags - - { - const value = e.target.value; - const tags = addSSHForm.watch('tags') as string[]; - if (value.endsWith(' ')) { - const tag = value.trim(); - if (tag && !tags.includes(tag)) { - addSSHForm.setValue('tags', [...tags, tag]); - } - addSSHForm.setValue('tagsInput', ''); - } else { - addSSHForm.setValue('tagsInput', value); - } - }} - /> - - {/* Tag chips */} - {(addSSHForm.watch('tags') as string[]).length > 0 && ( -
- {(addSSHForm.watch('tags') as string[]).map((tag: string) => ( - - ))} -
- )} - -
- )} - /> - {/* Connection Details */} - - ( - - IP - - - - - - )} - /> - ( - - Username - - - - - - )} - /> - ( - - Port - - field.onChange(Number(e.target.value) || 22)} - /> - - - - )} - /> - {/* Authentication */} - - ( - - - Password - SSH Key - - - ( - - Password - - - - - - )} - /> - - - ( - - SSH Private Key - -
- { - const file = e.target.files?.[0]; - field.onChange(file || null); - }} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> - -
-
-
- )} - /> - ( - - Key Password (if protected) - - - - - - )} - /> - { - const keyTypeOptions = [ - { value: 'auto', label: 'Auto-detect' }, - { value: 'ssh-rsa', label: 'RSA' }, - { value: 'ssh-ed25519', label: 'ED25519' }, - { value: 'ecdsa-sha2-nistp256', label: 'ECDSA NIST P-256' }, - { value: 'ecdsa-sha2-nistp384', label: 'ECDSA NIST P-384' }, - { value: 'ecdsa-sha2-nistp521', label: 'ECDSA NIST P-521' }, - { value: 'ssh-dss', label: 'DSA' }, - { value: 'ssh-rsa-sha2-256', label: 'RSA SHA2-256' }, - { value: 'ssh-rsa-sha2-512', label: 'RSA SHA2-512' }, - ]; - const [dropdownOpen, setDropdownOpen] = React.useState(false); - const dropdownRef = React.useRef(null); - const buttonRef = React.useRef(null); - React.useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) && - buttonRef.current && - !buttonRef.current.contains(event.target as Node) - ) { - setDropdownOpen(false); - } - } - if (dropdownOpen) { - document.addEventListener('mousedown', handleClickOutside); - } else { - document.removeEventListener('mousedown', handleClickOutside); - } - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [dropdownOpen]); - return ( - - Key Type - -
- - {dropdownOpen && ( -
-
- {keyTypeOptions.map(opt => ( - - ))} -
-
- )} -
-
- -
- ); - }} - /> -
-
- )} - /> - {/* Other */} - - ( - - Default Path - - - - - - )} - /> - ( - - -
- - Pin Connection -
-
- -
- )} - /> - - -
- - - - - - -
-
- -
{/* 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 -
-
- -
-
- {/* 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 - }))}> - - - - - - - - -
-
-
-
- ))} -
-
- {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 */} -
- - 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 => ( + + ))} + + + {idx < sortedFolders.length - 1 && ( +
+
-
+ )} + + ))} + +
+
+
+
+ + + )} + {view === 'files' && activeServer && ( +
+ {/* Sticky path input bar - outside ScrollArea */} +
+ + 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' && ( -
+ )}
- ); - })} -
- )} -
-
-
+
+ ); + })} +
+ )} +
+
- )} - +
+ )}
- {/* 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. - - -
-
- - - Default Path - - setLocalDefaultPath(e.target.value)} - placeholder="/home/user" - className="bg-[#18181b] border border-[#23232a] text-white rounded-md px-2 py-2 min-h-[40px] text-sm"/> - - -
- -
- - - -
-
- {/* 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. - - -
-
- { - 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, - folder: values.folder, - tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags, - 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, - authMethod: values.authMethod, - }; - await axios.put(`${API_BASE_DB}/config_editor/ssh/host/${editingSSH.id}`, payload, {headers: {Authorization: `Bearer ${jwt}`}}); - await fetchSSH(); - setEditingSSH(null); - setTimeout(() => form.reset(), 100); // reset after closing - } catch (err: any) { - let errorMsg = err?.response?.data?.error || err?.message || 'Failed to update SSH connection'; - if (typeof errorMsg !== 'string') { - errorMsg = 'An unknown error occurred. Please check the backend logs.'; - } - setSSHFormError(errorMsg); - } finally { - setSSHFormLoading(false); - } - })} className="space-y-4"> - ( - - Name - - - - - - )} - /> - ( - - Folder - - { - if (typeof field.ref === 'function') field.ref(el); - (folderInputRef as React.MutableRefObject).current = el; - }} - placeholder="e.g. Work" - autoComplete="off" - value={field.value} - onFocus={() => setFolderDropdownOpen(true)} - onChange={e => { - field.onChange(e); - setFolderDropdownOpen(true); - }} - disabled={foldersLoading} - /> - - {folderDropdownOpen && folders.length > 0 && ( -
-
- {folders.map(folder => ( - - ))} -
-
- )} - {foldersLoading && -
Loading folders...
} - {foldersError && -
{foldersError}
} - -
- )} - /> -

Connection Details

- -
- ( - - Username - - - - - - )} - /> - ( - - IP Address - - - - - - )} - /> - ( - - Port - - - - - - )} - /> - ( - -

Authentication

- - - - Password - SSH Key - - - ( - - Password - - - - - - )} - /> - - - { - const file = field.value as File | null; - return ( - - SSH Key - -
- { - const file = e.target.files?.[0]; - field.onChange(file || null); - }} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - /> - -
-
- -
- ); - }} - /> - ( - - Key Password (if protected) - - - - - - )} - /> - { - const keyTypeOptions = [ - {value: 'auto', label: 'Auto-detect'}, - {value: 'ssh-rsa', label: 'RSA'}, - {value: 'ssh-ed25519', label: 'ED25519'}, - {value: 'ecdsa-sha2-nistp256', label: 'ECDSA NIST P-256'}, - {value: 'ecdsa-sha2-nistp384', label: 'ECDSA NIST P-384'}, - {value: 'ecdsa-sha2-nistp521', label: 'ECDSA NIST P-521'}, - {value: 'ssh-dss', label: 'DSA'}, - {value: 'ssh-rsa-sha2-256', label: 'RSA SHA2-256'}, - {value: 'ssh-rsa-sha2-512', label: 'RSA SHA2-512'}, - ]; - const [dropdownOpen, setDropdownOpen] = React.useState(false); - const dropdownRef = React.useRef(null); - const buttonRef = React.useRef(null); - React.useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) && - buttonRef.current && - !buttonRef.current.contains(event.target as Node) - ) { - setDropdownOpen(false); - } - } - if (dropdownOpen) { - document.addEventListener('mousedown', handleClickOutside); - } else { - document.removeEventListener('mousedown', handleClickOutside); - } - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [dropdownOpen]); - return ( - - Key Type - -
- - {dropdownOpen && ( -
-
- {keyTypeOptions.map(opt => ( - - ))} -
-
- )} -
-
- -
- ); - }} - /> -
-
-
- )} - /> -

Other

- - ( - - Default Path - - - - - - )} - /> - ( - - -
- - Pin Connection -
-
- -
- )} - /> - - -
- - - - - - - - ); }); 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 && }
+ )} + {onRemove && ( + + )} +
+
+ ); + + const renderShortcutCard = (shortcut: ShortcutItem) => ( +
+
onOpenShortcut(shortcut)} + > + +
+
+ {shortcut.path} +
+
+
+
+ +
+
+ ); + 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) => ( - - - {file.name} - - - - ))} +
+ 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) => ( - - - {file.name} - - - ))} +
+ No pinned files. +
+ ) : pinned.map((file) => + renderFileCard( + file, + undefined, // No remove function for pinned items + () => onUnpinFile(file), // Use pin function for unpinning + true + ) + )}
- -
+ + +
setNewShortcut(e.target.value)} - className="flex-1" + className="flex-1 bg-[#23232a] border-[#434345] text-white placeholder:text-muted-foreground" + onKeyDown={(e) => { + if (e.key === 'Enter' && newShortcut.trim()) { + onAddShortcut(newShortcut.trim()); + setNewShortcut(''); + } + }} />
-
+
{shortcuts.length === 0 ? ( - No shortcuts. - ) : shortcuts.map((shortcut, index) => ( - - - {shortcut.name || shortcut.path.split('/').pop()} - - - ))} +
+ 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 */} + + {/* Close Tab Button */}
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
- +
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