Finalized ssh config editor
This commit is contained in:
@@ -213,7 +213,7 @@ export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername,
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => window.open('https://www.paypal.com/paypalme/LukeGustafson803', '_blank')}
|
||||
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
|
||||
>
|
||||
Fund
|
||||
</Button>
|
||||
|
||||
@@ -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
|
||||
<CodeMirror
|
||||
value={content}
|
||||
extensions={[
|
||||
loadLanguage(getLanguageName(fileName)),
|
||||
loadLanguage(getLanguageName(fileName || 'untitled.txt') as any) || [],
|
||||
hyperLink,
|
||||
oneDark,
|
||||
EditorView.theme({
|
||||
|
||||
@@ -3,14 +3,24 @@ import { ConfigEditorSidebar } from "@/apps/SSH/Config Editor/ConfigEditorSideba
|
||||
import { ConfigTabList } from "@/apps/SSH/Config Editor/ConfigTabList.tsx";
|
||||
import { ConfigHomeView } from "@/apps/SSH/Config Editor/ConfigHomeView.tsx";
|
||||
import { ConfigCodeEditor } from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx";
|
||||
import axios from 'axios';
|
||||
import { Button } from '@/components/ui/button.tsx';
|
||||
import { ConfigTopbar } from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
|
||||
import { cn } from '@/lib/utils.ts';
|
||||
|
||||
function getJWT() {
|
||||
return document.cookie.split('; ').find(row => 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<Tab[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<string | number>('home');
|
||||
const [recent, setRecent] = useState<any[]>([]);
|
||||
const [pinned, setPinned] = useState<any[]>([]);
|
||||
const [shortcuts, setShortcuts] = useState<any[]>([]);
|
||||
const [loadingHome, setLoadingHome] = useState(false);
|
||||
const [errorHome, setErrorHome] = useState<string | undefined>(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<SSHHost | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const sidebarRef = useRef<any>(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 (
|
||||
<div style={{ position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20 }}>
|
||||
<ConfigEditorSidebar
|
||||
onSelectView={onSelectView}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
ref={sidebarRef}
|
||||
onHostChange={handleHostChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ position: 'absolute', top: 0, left: 256, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#09090b' }}>
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Connect to a Server</h2>
|
||||
<p className="text-muted-foreground">Select a server from the sidebar to start editing files</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20 }}>
|
||||
<ConfigEditorSidebar onSelectView={onSelectView} onOpenFile={handleOpenFile} tabs={tabs} ref={sidebarRef} />
|
||||
<ConfigEditorSidebar
|
||||
onSelectView={onSelectView}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
ref={sidebarRef}
|
||||
onHostChange={handleHostChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ position: 'absolute', top: 0, left: 256, right: 0, height: 44, zIndex: 30 }}>
|
||||
<div className="flex items-center w-full bg-[#18181b] border-b border-[#222224] h-11 relative" style={{ height: 44 }}>
|
||||
{/* Tab list scrollable area, full width except for Save button */}
|
||||
<div className="flex-1 min-w-0 h-full flex items-center pr-0">
|
||||
<div className="flex items-center w-full bg-[#18181b] border-b border-[#222224] h-11 relative px-4" style={{ height: 44 }}>
|
||||
{/* Tab list scrollable area */}
|
||||
<div className="flex-1 min-w-0 h-full flex items-center">
|
||||
<div className="h-9 w-full bg-[#09090b] border border-[#23232a] rounded-md flex items-center overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent" style={{ minWidth: 0 }}>
|
||||
<ConfigTopbar
|
||||
tabs={tabs.map(t => ({ 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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Save button only for file tabs, stationary at right */}
|
||||
{activeTab !== 'home' && (() => {
|
||||
const tab = tabs.find(t => t.id === activeTab);
|
||||
if (!tab) return null;
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'ml-2 mr-2 px-3 py-1.5 border rounded-md text-base font-medium transition-colors',
|
||||
'border-[#2d2d30] text-white bg-transparent hover:bg-[#23232a] active:bg-[#23232a] focus:bg-[#23232a]',
|
||||
!tab.dirty ? 'opacity-60 cursor-not-allowed' : 'hover:border-[#2d2d30]'
|
||||
)}
|
||||
disabled={!tab.dirty}
|
||||
onClick={() => handleSave(tab)}
|
||||
type="button"
|
||||
style={{ height: 36, alignSelf: 'center' }}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
{/* Save button - always visible */}
|
||||
<Button
|
||||
className={cn(
|
||||
'ml-4 px-4 py-1.5 border rounded-md text-sm font-medium transition-colors',
|
||||
'border-[#2d2d30] text-white bg-transparent hover:bg-[#23232a] active:bg-[#23232a] focus:bg-[#23232a]',
|
||||
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : 'hover:border-[#2d2d30]'
|
||||
)}
|
||||
disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving}
|
||||
onClick={() => {
|
||||
const tab = tabs.find(t => t.id === activeTab);
|
||||
if (tab && !isSaving) handleSave(tab);
|
||||
}}
|
||||
type="button"
|
||||
style={{ height: 36, alignSelf: 'center' }}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ position: 'absolute', top: 44, left: 256, right: 0, bottom: 0, overflow: 'hidden', zIndex: 10, background: '#101014', display: 'flex', flexDirection: 'column' }}>
|
||||
@@ -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 (
|
||||
<div className="flex flex-col h-full" style={{ flex: 1, minHeight: 0 }}>
|
||||
{/* Error display */}
|
||||
{tab.error && (
|
||||
<div className="bg-red-900/20 border border-red-500/30 text-red-300 px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-red-400">⚠️</span>
|
||||
<span>{tab.error}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, error: undefined } : t))}
|
||||
className="text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Success display */}
|
||||
{tab.success && (
|
||||
<div className="bg-green-900/20 border border-green-500/30 text-green-300 px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-400">✓</span>
|
||||
<span>{tab.success}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, success: undefined } : t))}
|
||||
className="text-green-400 hover:text-green-300 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ConfigCodeEditor
|
||||
<ConfigCodeEditor
|
||||
content={tab.content}
|
||||
fileNameOld={tab.fileName}
|
||||
fileName={tab.fileName}
|
||||
onContentChange={content => setTabContent(tab.id, content)}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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({
|
||||
>
|
||||
<Link2 className="w-4 h-4 mr-2" />
|
||||
{conn.name || conn.ip}
|
||||
{conn.isPinned && <Star className="w-3 h-3 ml-1 text-yellow-400" />}
|
||||
{conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400" />}
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}>
|
||||
<Star className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`} />
|
||||
<Pin className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`} />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}>
|
||||
<Edit className="w-4 h-4" />
|
||||
@@ -123,7 +123,7 @@ export function ConfigFileSidebarViewer({
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onStarFile(item)}>
|
||||
<Star className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`} />
|
||||
<Pin className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`} />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteFile(item)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card.tsx';
|
||||
import { Button } from '@/components/ui/button.tsx';
|
||||
import { Star, Trash2, Folder, File, Plus } from 'lucide-react';
|
||||
import { Trash2, Folder, File, Plus, Pin } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs.tsx';
|
||||
import { Input } from '@/components/ui/input.tsx';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils.ts';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
path: string;
|
||||
isStarred?: boolean;
|
||||
isPinned?: boolean;
|
||||
type: 'file' | 'directory';
|
||||
sshSessionId?: string;
|
||||
}
|
||||
|
||||
interface ShortcutItem {
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -24,6 +27,7 @@ interface ConfigHomeViewProps {
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onRemoveRecent: (file: FileItem) => void;
|
||||
onPinFile: (file: FileItem) => void;
|
||||
onUnpinFile: (file: FileItem) => void;
|
||||
onOpenShortcut: (shortcut: ShortcutItem) => void;
|
||||
onRemoveShortcut: (shortcut: ShortcutItem) => void;
|
||||
onAddShortcut: (path: string) => void;
|
||||
@@ -36,69 +40,144 @@ export function ConfigHomeView({
|
||||
onOpenFile,
|
||||
onRemoveRecent,
|
||||
onPinFile,
|
||||
onUnpinFile,
|
||||
onOpenShortcut,
|
||||
onRemoveShortcut,
|
||||
onAddShortcut,
|
||||
onAddShortcut
|
||||
}: ConfigHomeViewProps) {
|
||||
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
||||
const [newShortcut, setNewShortcut] = useState('');
|
||||
|
||||
|
||||
|
||||
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
|
||||
<div key={file.path} className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenFile(file)}
|
||||
>
|
||||
{file.type === 'directory' ?
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" /> :
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{onPin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={onPin}
|
||||
>
|
||||
<Pin className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`} />
|
||||
</Button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderShortcutCard = (shortcut: ShortcutItem) => (
|
||||
<div key={shortcut.path} className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenShortcut(shortcut)}
|
||||
>
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{shortcut.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={() => onRemoveShortcut(shortcut)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 flex flex-col gap-8 h-full bg-[#09090b]">
|
||||
<div className="p-4 flex flex-col gap-4 h-full bg-[#09090b]">
|
||||
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
|
||||
<TabsList className="mb-1">
|
||||
<TabsTrigger value="recent">Recent</TabsTrigger>
|
||||
<TabsTrigger value="pinned">Pinned</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts">Folder Shortcuts</TabsTrigger>
|
||||
<TabsList className="mb-4 bg-[#18181b] border border-[#23232a]">
|
||||
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">Recent</TabsTrigger>
|
||||
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder Shortcuts</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="recent">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{recent.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">No recent files.</span>
|
||||
) : recent.map((file, index) => (
|
||||
<Card key={`${file.path}-${index}`} className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded">
|
||||
<Button variant="ghost" className="p-0 h-7 w-7" onClick={() => onOpenFile(file)}>
|
||||
{file.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400" /> : <File className="w-4 h-4 text-muted-foreground" />}
|
||||
</Button>
|
||||
<span className="text-sm text-white truncate max-w-[120px]">{file.name}</span>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinFile(file)}>
|
||||
<Star className={`w-4 h-4 ${file.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`} />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onRemoveRecent(file)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No recent files.</span>
|
||||
</div>
|
||||
) : recent.map((file) =>
|
||||
renderFileCard(
|
||||
file,
|
||||
() => onRemoveRecent(file),
|
||||
() => file.isPinned ? onUnpinFile(file) : onPinFile(file),
|
||||
file.isPinned
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="pinned">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
|
||||
<TabsContent value="pinned" className="mt-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{pinned.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">No pinned files.</span>
|
||||
) : pinned.map((file, index) => (
|
||||
<Card key={`${file.path}-${index}`} className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded">
|
||||
<Button variant="ghost" className="p-0 h-7 w-7" onClick={() => onOpenFile(file)}>
|
||||
{file.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400" /> : <File className="w-4 h-4 text-muted-foreground" />}
|
||||
</Button>
|
||||
<span className="text-sm text-white truncate max-w-[120px]">{file.name}</span>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinFile(file)}>
|
||||
<Star className={`w-4 h-4 ${file.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`} />
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No pinned files.</span>
|
||||
</div>
|
||||
) : pinned.map((file) =>
|
||||
renderFileCard(
|
||||
file,
|
||||
undefined, // No remove function for pinned items
|
||||
() => onUnpinFile(file), // Use pin function for unpinning
|
||||
true
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="shortcuts">
|
||||
<div className="flex items-center gap-2 mb-0">
|
||||
|
||||
<TabsContent value="shortcuts" className="mt-0">
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border border-[#23232a] rounded-lg">
|
||||
<Input
|
||||
placeholder="Enter folder path"
|
||||
value={newShortcut}
|
||||
onChange={e => 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('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 w-7"
|
||||
className="h-8 px-2 bg-[#23232a] border-[#434345] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={() => {
|
||||
if (newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
@@ -106,23 +185,18 @@ export function ConfigHomeView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{shortcuts.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground mt-4">No shortcuts.</span>
|
||||
) : shortcuts.map((shortcut, index) => (
|
||||
<Card key={`${shortcut.path}-${index}`} className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded">
|
||||
<Button variant="ghost" className="p-0 h-7 w-7" onClick={() => onOpenShortcut(shortcut)}>
|
||||
<Folder className="w-4 h-4 text-blue-400" />
|
||||
</Button>
|
||||
<span className="text-sm text-white truncate max-w-[120px]">{shortcut.name || shortcut.path.split('/').pop()}</span>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onRemoveShortcut(shortcut)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
<div className="flex items-center justify-center py-4 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No shortcuts.</span>
|
||||
</div>
|
||||
) : shortcuts.map((shortcut) =>
|
||||
renderShortcutCard(shortcut)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -28,21 +28,27 @@ export function ConfigTabList({ tabs, activeTab, setActiveTab, closeTab, onHomeC
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
return (
|
||||
<div key={tab.id} className={index < tabs.length - 1 ? 'mr-[0.5rem]' : ''}>
|
||||
<div
|
||||
key={tab.id}
|
||||
className={index < tabs.length - 1 ? "mr-[0.5rem]" : ""}
|
||||
>
|
||||
<div className="inline-flex rounded-md shadow-sm" role="group">
|
||||
{/* Set Active Tab Button */}
|
||||
<Button
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
variant="outline"
|
||||
className={`h-7 rounded-r-none flex items-center ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||
className={`h-7 rounded-r-none ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||
>
|
||||
{tab.title}
|
||||
</Button>
|
||||
|
||||
{/* Close Tab Button */}
|
||||
<Button
|
||||
onClick={() => closeTab(tab.id)}
|
||||
variant="outline"
|
||||
className="h-7 rounded-l-none p-0 !w-7 !h-7 flex items-center"
|
||||
className="h-7 rounded-l-none p-0 !w-9"
|
||||
>
|
||||
<X className="!w-4 !h-4" strokeWidth={2.5} />
|
||||
<X className="!w-5 !h-5" strokeWidth={2.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
</ScrollArea>
|
||||
<footer className="shrink-0 w-full">
|
||||
<Separator className="p-0.25 mt-1 mb-3"/>
|
||||
<Button type="submit">{editingHost ? "Update Host" : "Add Host"}</Button>
|
||||
<Button type="submit" variant="outline">{editingHost ? "Update Host" : "Add Host"}</Button>
|
||||
</footer>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }: {
|
||||
>
|
||||
<div className="flex items-center w-full">
|
||||
{host.pin &&
|
||||
<Pin className="h-3.5 mr-1 w-3.5 mt-0.5 text-yellow-500 flex-shrink-0" />
|
||||
<Pin className="h-4.5 mr-1 w-4.5 mt-0.5 text-yellow-500 flex-shrink-0" />
|
||||
}
|
||||
<span className="font-medium truncate">{host.name || host.ip}</span>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
541
src/apps/SSH/ssh-axios-fixed.ts
Normal file
541
src/apps/SSH/ssh-axios-fixed.ts
Normal file
@@ -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<SSHHost[]> {
|
||||
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<SSHHost> {
|
||||
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<SSHHost> {
|
||||
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<any> {
|
||||
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<SSHHost> {
|
||||
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<Record<string, TunnelStatus>> {
|
||||
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<TunnelStatus | undefined> {
|
||||
const statuses = await getTunnelStatuses();
|
||||
return statuses[tunnelName];
|
||||
}
|
||||
|
||||
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<ConfigEditorFile[]> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<ConfigEditorFile[]> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<ConfigEditorShortcut[]> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<any[]> {
|
||||
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<any> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<SSHHost[]> {
|
||||
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<SSHHost> {
|
||||
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<SSHHost> {
|
||||
|
||||
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<any> {
|
||||
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<any> {
|
||||
// Get SSH host by ID
|
||||
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
||||
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<SSHHost> {
|
||||
}
|
||||
}
|
||||
|
||||
// Tunnel-related functions (use port 8083 for localhost)
|
||||
|
||||
// Get all tunnel statuses (per-tunnel)
|
||||
// Tunnel-related functions
|
||||
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
|
||||
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<Record<string, TunnelStatus>>
|
||||
}
|
||||
}
|
||||
|
||||
// Get status for a specific tunnel by tunnel name
|
||||
export async function getTunnelStatusByName(tunnelName: string): Promise<TunnelStatus | undefined> {
|
||||
const statuses = await getTunnelStatuses();
|
||||
return statuses[tunnelName];
|
||||
}
|
||||
|
||||
// Connect tunnel (per-tunnel)
|
||||
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
|
||||
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<any> {
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect tunnel (per-tunnel)
|
||||
export async function disconnectTunnel(tunnelName: string): Promise<any> {
|
||||
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<any> {
|
||||
|
||||
export async function cancelTunnel(tunnelName: string): Promise<any> {
|
||||
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<any> {
|
||||
}
|
||||
}
|
||||
|
||||
// Config-related functions (use port 8084 for localhost)
|
||||
export { api, configEditorApi };
|
||||
|
||||
export { sshHostApi, tunnelApi, 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<ConfigEditorFile[]> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<ConfigEditorFile[]> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<ConfigEditorShortcut[]> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<any[]> {
|
||||
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<any> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
@@ -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`),
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user