Started config editor, migrated to one ssh manager for adding hosts.

This commit is contained in:
LukeGus
2025-07-26 15:42:15 -05:00
parent 608111c37b
commit 2e62dee798
36 changed files with 3064 additions and 1240 deletions

View File

@@ -0,0 +1,213 @@
import React, { useState, useEffect } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { loadLanguage } from '@uiw/codemirror-extensions-langs';
import { hyperLink } from '@uiw/codemirror-extensions-hyper-link';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView } from '@codemirror/view';
interface ConfigCodeEditorProps {
content: string;
fileNameOld: string;
onContentChange: (value: string) => void;
}
export function ConfigCodeEditor({content, fileNameOld, onContentChange}: ConfigCodeEditorProps) {
const fileName = "test.js"
function getLanguageName(filename: string): string {
const ext = filename.slice(filename.lastIndexOf('.') + 1).toLowerCase();
switch (ext) {
case 'ng': return 'angular';
case 'apl': return 'apl';
case 'asc': return 'asciiArmor';
case 'ast': return 'asterisk';
case 'bf': return 'brainfuck';
case 'c': return 'c';
case 'ceylon': return 'ceylon';
case 'clj': return 'clojure';
case 'cmake': return 'cmake';
case 'cob':
case 'cbl': return 'cobol';
case 'coffee': return 'coffeescript';
case 'lisp': return 'commonLisp';
case 'cpp':
case 'cc':
case 'cxx': return 'cpp';
case 'cr': return 'crystal';
case 'cs': return 'csharp';
case 'css': return 'css';
case 'cypher': return 'cypher';
case 'd': return 'd';
case 'dart': return 'dart';
case 'diff':
case 'patch': return 'diff';
case 'dockerfile': return 'dockerfile';
case 'dtd': return 'dtd';
case 'dylan': return 'dylan';
case 'ebnf': return 'ebnf';
case 'ecl': return 'ecl';
case 'eiffel': return 'eiffel';
case 'elm': return 'elm';
case 'erl': return 'erlang';
case 'factor': return 'factor';
case 'fcl': return 'fcl';
case 'fs': return 'forth';
case 'f90':
case 'for': return 'fortran';
case 's': return 'gas';
case 'feature': return 'gherkin';
case 'go': return 'go';
case 'groovy': return 'groovy';
case 'hs': return 'haskell';
case 'hx': return 'haxe';
case 'html':
case 'htm': return 'html';
case 'http': return 'http';
case 'idl': return 'idl';
case 'java': return 'java';
case 'js':
case 'mjs':
case 'cjs': return 'javascript';
case 'jinja2':
case 'j2': return 'jinja2';
case 'json': return 'json';
case 'jsx': return 'jsx';
case 'jl': return 'julia';
case 'kt':
case 'kts': return 'kotlin';
case 'less': return 'less';
case 'lezer': return 'lezer';
case 'liquid': return 'liquid';
case 'litcoffee': return 'livescript';
case 'lua': return 'lua';
case 'md': return 'markdown';
case 'nb':
case 'mat': return 'mathematica';
case 'mbox': return 'mbox';
case 'mmd': return 'mermaid';
case 'mrc': return 'mirc';
case 'moo': return 'modelica';
case 'mscgen': return 'mscgen';
case 'm': return 'mumps';
case 'sql': return 'mysql';
case 'nc': return 'nesC';
case 'nginx': return 'nginx';
case 'nix': return 'nix';
case 'nsi': return 'nsis';
case 'nt': return 'ntriples';
case 'mm': return 'objectiveCpp';
case 'octave': return 'octave';
case 'oz': return 'oz';
case 'pas': return 'pascal';
case 'pl':
case 'pm': return 'perl';
case 'pgsql': return 'pgsql';
case 'php': return 'php';
case 'pig': return 'pig';
case 'ps1': return 'powershell';
case 'properties': return 'properties';
case 'proto': return 'protobuf';
case 'pp': return 'puppet';
case 'py': return 'python';
case 'q': return 'q';
case 'r': return 'r';
case 'rb': return 'ruby';
case 'rs': return 'rust';
case 'sas': return 'sas';
case 'sass':
case 'scss': return 'sass';
case 'scala': return 'scala';
case 'scm': return 'scheme';
case 'shader': return 'shader';
case 'sh':
case 'bash': return 'shell';
case 'siv': return 'sieve';
case 'st': return 'smalltalk';
case 'sol': return 'solidity';
case 'solr': return 'solr';
case 'rq': return 'sparql';
case 'xlsx':
case 'ods':
case 'csv': return 'spreadsheet';
case 'nut': return 'squirrel';
case 'tex': return 'stex';
case 'styl': return 'stylus';
case 'svelte': return 'svelte';
case 'swift': return 'swift';
case 'tcl': return 'tcl';
case 'textile': return 'textile';
case 'tiddlywiki': return 'tiddlyWiki';
case 'tiki': return 'tiki';
case 'toml': return 'toml';
case 'troff': return 'troff';
case 'tsx': return 'tsx';
case 'ttcn': return 'ttcn';
case 'ttl':
case 'turtle': return 'turtle';
case 'ts': return 'typescript';
case 'vb': return 'vb';
case 'vbs': return 'vbscript';
case 'vm': return 'velocity';
case 'v': return 'verilog';
case 'vhd':
case 'vhdl': return 'vhdl';
case 'vue': return 'vue';
case 'wat': return 'wast';
case 'webidl': return 'webIDL';
case 'xq':
case 'xquery': return 'xQuery';
case 'xml': return 'xml';
case 'yacas': return 'yacas';
case 'yaml':
case 'yml': return 'yaml';
case 'z80': return 'z80';
default: return 'js';
}
}
useEffect(() => {
document.body.style.overflowX = 'hidden';
return () => {
document.body.style.overflowX = '';
};
}, []);
return (
<div style={{ width: '100%', height: '100%', position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div
style={{
width: '100%',
height: '100%',
overflow: 'auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
className="config-codemirror-scroll-wrapper"
>
<CodeMirror
value={content}
extensions={[
loadLanguage(getLanguageName(fileName)),
hyperLink,
oneDark,
EditorView.theme({
'&': {
backgroundColor: '#09090b !important',
},
'.cm-gutters': {
backgroundColor: '#18181b !important',
},
})
]}
onChange={(value: any) => onContentChange(value)}
theme={undefined}
height="100%"
basicSetup={{ lineNumbers: true }}
style={{ minHeight: '100%', minWidth: '100%', flex: 1 }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,232 @@
import React, { useState, useEffect, useRef } from "react";
import { ConfigEditorSidebar } from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx";
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];
}
interface Tab {
id: string | number;
title: string;
fileName: string;
content: string;
isSSH?: boolean;
sshSessionId?: string;
filePath?: string;
loading?: boolean;
error?: string;
dirty?: boolean;
}
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 sidebarRef = useRef<any>(null);
// Fetch home data
useEffect(() => {
fetchHomeData();
}, []);
async function fetchHomeData() {
setLoadingHome(true);
setErrorHome(undefined);
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}` } }),
]);
setRecent(recentRes.data || []);
setPinned(pinnedRes.data || []);
setShortcuts(shortcutsRes.data || []);
} catch (err: any) {
setErrorHome('Failed to load home data');
} finally {
setLoadingHome(false);
}
}
// Home view actions
const handleOpenFile = async (file: any) => {
const tabId = file.path;
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 }]);
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));
// Mark as recent
await axios.post(`${API_BASE_DB}/config_editor/recent`, { name: file.name, path: file.path }, { headers: { Authorization: `Bearer ${jwt}` } });
fetchHomeData();
} catch (err: any) {
setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, loading: false, error: err?.message || 'Failed to load file' } : t));
}
}
setActiveTab(tabId);
};
const handleRemoveRecent = async (file: any) => {
// Implement backend delete if needed
setRecent(recent.filter(f => f.path !== file.path));
};
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();
};
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;
}
// Use the sidebar's openFolder method
if ((window as any).configSidebarRef && (window as any).configSidebarRef.openFolder) {
(window as any).configSidebarRef.openFolder(server, shortcut.path);
}
};
// 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 });
fetchHomeData();
} catch {}
};
const handleRemoveShortcut = async (shortcut: any) => {
try {
const jwt = getJWT();
await axios.post(`${API_BASE_DB}/config_editor/shortcuts/delete`, { path: shortcut.path });
fetchHomeData();
} catch {}
};
// Tab actions
const closeTab = (tabId: string | number) => {
const idx = tabs.findIndex(t => t.id === tabId);
const newTabs = tabs.filter(t => t.id !== tabId);
setTabs(newTabs);
if (activeTab === tabId) {
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}` } });
fetchHomeData();
} catch (err) {
setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, error: 'Failed to save file' } : t));
}
};
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} />
</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="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')}
/>
</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>
);
})()}
</div>
</div>
<div style={{ position: 'absolute', top: 44, left: 256, right: 0, bottom: 0, overflow: 'hidden', zIndex: 10, background: '#101014', display: 'flex', flexDirection: 'column' }}>
{activeTab === 'home' ? (
<ConfigHomeView
recent={recent}
pinned={pinned}
shortcuts={shortcuts}
onOpenFile={handleOpenFile}
onRemoveRecent={handleRemoveRecent}
onPinFile={handlePinFile}
onOpenShortcut={handleOpenShortcut}
onRemoveShortcut={handleRemoveShortcut}
onAddShortcut={handleAddShortcut}
/>
) : (
(() => {
const tab = tabs.find(t => t.id === activeTab);
if (!tab) return null;
return (
<div className="flex flex-col h-full" style={{ flex: 1, minHeight: 0 }}>
<div className="flex-1 min-h-0">
<ConfigCodeEditor
content={tab.content}
fileNameOld={tab.fileName}
onContentChange={content => setTabContent(tab.id, content)}
/>
</div>
</div>
);
})()
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
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';
interface SSHConnection {
id: string;
name: string;
ip: string;
port: number;
username: string;
isPinned?: boolean;
}
interface FileItem {
name: string;
type: 'file' | 'directory' | 'link';
path: string;
isStarred?: boolean;
}
interface ConfigFileSidebarViewerProps {
sshConnections: SSHConnection[];
onAddSSH: () => void;
onConnectSSH: (conn: SSHConnection) => void;
onEditSSH: (conn: SSHConnection) => void;
onDeleteSSH: (conn: SSHConnection) => void;
onPinSSH: (conn: SSHConnection) => void;
currentPath: string;
files: FileItem[];
onOpenFile: (file: FileItem) => void;
onOpenFolder: (folder: FileItem) => void;
onStarFile: (file: FileItem) => void;
onDeleteFile: (file: FileItem) => void;
isLoading?: boolean;
error?: string;
isSSHMode: boolean;
onSwitchToLocal: () => void;
onSwitchToSSH: (conn: SSHConnection) => void;
currentSSH?: SSHConnection;
}
export function ConfigFileSidebarViewer({
sshConnections,
onAddSSH,
onConnectSSH,
onEditSSH,
onDeleteSSH,
onPinSSH,
currentPath,
files,
onOpenFile,
onOpenFolder,
onStarFile,
onDeleteFile,
isLoading,
error,
isSSHMode,
onSwitchToLocal,
onSwitchToSSH,
currentSSH,
}: ConfigFileSidebarViewerProps) {
return (
<div className="flex flex-col h-full">
{/* SSH Connections */}
<div className="p-2 bg-[#18181b] border-b border-[#23232a]">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground font-semibold">SSH Connections</span>
<Button size="icon" variant="outline" onClick={onAddSSH} className="ml-2 h-7 w-7">
<Plus className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-col gap-1">
<Button
variant={!isSSHMode ? 'secondary' : 'ghost'}
className="w-full justify-start text-left px-2 py-1.5 rounded"
onClick={onSwitchToLocal}
>
<Server className="w-4 h-4 mr-2" /> Local Files
</Button>
{sshConnections.map((conn) => (
<div key={conn.id} className="flex items-center gap-1 group">
<Button
variant={isSSHMode && currentSSH?.id === conn.id ? 'secondary' : 'ghost'}
className="flex-1 justify-start text-left px-2 py-1.5 rounded"
onClick={() => onSwitchToSSH(conn)}
>
<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" />}
</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'}`} />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}>
<Edit className="w-4 h-4" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteSSH(conn)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
))}
</div>
</div>
{/* File/Folder Viewer */}
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground font-semibold">{isSSHMode ? 'SSH Path' : 'Local Path'}</span>
<span className="text-xs text-white truncate">{currentPath}</span>
</div>
{isLoading ? (
<div className="text-xs text-muted-foreground">Loading...</div>
) : error ? (
<div className="text-xs text-red-500">{error}</div>
) : (
<div className="flex flex-col gap-1">
{files.map((item) => (
<Card key={item.path} className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border border-[#23232a] rounded">
<div className="flex items-center gap-2 flex-1 cursor-pointer" onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400" /> : <File className="w-4 h-4 text-muted-foreground" />}
<span className="text-sm text-white truncate">{item.name}</span>
</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'}`} />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteFile(item)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</Card>
))}
{files.length === 0 && <div className="text-xs text-muted-foreground">No files or folders found.</div>}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs.tsx';
import { Input } from '@/components/ui/input.tsx';
import { useState } from 'react';
interface FileItem {
name: string;
path: string;
isStarred?: boolean;
type: 'file' | 'directory';
}
interface ShortcutItem {
name: string;
path: string;
}
interface ConfigHomeViewProps {
recent: FileItem[];
pinned: FileItem[];
shortcuts: ShortcutItem[];
onOpenFile: (file: FileItem) => void;
onRemoveRecent: (file: FileItem) => void;
onPinFile: (file: FileItem) => void;
onOpenShortcut: (shortcut: ShortcutItem) => void;
onRemoveShortcut: (shortcut: ShortcutItem) => void;
onAddShortcut: (path: string) => void;
}
export function ConfigHomeView({
recent,
pinned,
shortcuts,
onOpenFile,
onRemoveRecent,
onPinFile,
onOpenShortcut,
onRemoveShortcut,
onAddShortcut,
}: ConfigHomeViewProps) {
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
const [newShortcut, setNewShortcut] = useState('');
return (
<div className="p-6 flex flex-col gap-8 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>
<TabsContent value="recent">
<div className="flex flex-wrap 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>
</TabsContent>
<TabsContent value="pinned">
<div className="flex flex-wrap 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>
</TabsContent>
<TabsContent value="shortcuts">
<div className="flex items-center gap-2 mb-0">
<Input
placeholder="Enter folder path"
value={newShortcut}
onChange={e => setNewShortcut(e.target.value)}
className="flex-1"
/>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
onClick={() => {
if (newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
setNewShortcut('');
}
}}
>
<Plus className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap 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>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { Button } from '@/components/ui/button.tsx';
import { X, Home } from 'lucide-react';
interface ConfigTab {
id: string | number;
title: string;
}
interface ConfigTabListProps {
tabs: ConfigTab[];
activeTab: string | number;
setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void;
onHomeClick: () => void;
}
export function ConfigTabList({ tabs, activeTab, setActiveTab, closeTab, onHomeClick }: ConfigTabListProps) {
return (
<div className="inline-flex items-center h-full px-[0.5rem] overflow-x-auto">
<Button
onClick={onHomeClick}
variant="outline"
className={`h-7 mr-[0.5rem] rounded-md flex items-center ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
<Home className="w-4 h-4" />
</Button>
{tabs.map((tab, index) => {
const isActive = tab.id === activeTab;
return (
<div key={tab.id} className={index < tabs.length - 1 ? 'mr-[0.5rem]' : ''}>
<div className="inline-flex rounded-md shadow-sm" role="group">
<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' : ''}`}
>
{tab.title}
</Button>
<Button
onClick={() => closeTab(tab.id)}
variant="outline"
className="h-7 rounded-l-none p-0 !w-7 !h-7 flex items-center"
>
<X className="!w-4 !h-4" strokeWidth={2.5} />
</Button>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,8 @@
import React from "react";
import { ConfigTabList } from "./ConfigTabList.tsx";
export function ConfigTopbar(props: any): React.ReactElement {
return (
<ConfigTabList {...props} />
)
}

View File

@@ -0,0 +1,92 @@
import React, { useState } from "react";
import {SSHManagerSidebar} from "@/apps/SSH/Manager/SSHManagerSidebar.tsx";
import {SSHManagerHostViewer} from "@/apps/SSH/Manager/SSHManagerHostViewer.tsx"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import {SSHManagerHostEditor} from "@/apps/SSH/Manager/SSHManagerHostEditor.tsx";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
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 SSHManager({ onSelectView }: ConfigEditorProps): React.ReactElement {
const [activeTab, setActiveTab] = useState("host_viewer");
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
const handleEditHost = (host: SSHHost) => {
setEditingHost(host);
setActiveTab("add_host");
};
const handleFormSubmit = () => {
setEditingHost(null);
setActiveTab("host_viewer");
};
const handleTabChange = (value: string) => {
setActiveTab(value);
// Reset editingHost when switching to host_viewer
if (value === "host_viewer") {
setEditingHost(null);
}
};
return (
<div>
<SSHManagerSidebar
onSelectView={onSelectView}
/>
<div className="flex w-screen h-screen overflow-hidden">
<div className="w-[256px]" />
<div className="flex-1 bg-[#18181b] m-[35px] text-white p-4 rounded-md w-[1200px] border h-[calc(100vh-70px)] flex flex-col min-h-0">
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex-1 flex flex-col h-full min-h-0">
<TabsList>
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
<TabsTrigger value="add_host">
{editingHost ? "Edit Host" : "Add Host"}
</TabsTrigger>
</TabsList>
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 mt-1 mb-1" />
<SSHManagerHostViewer onEditHost={handleEditHost}/>
</TabsContent>
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 mt-1 mb-1" />
<div className="flex flex-col h-full min-h-0">
<SSHManagerHostEditor
editingHost={editingHost}
onFormSubmit={handleFormSubmit}
/>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,320 @@
import React, { useState, useEffect, useMemo } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
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 { Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search } from "lucide-react";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface SSHManagerHostViewerProps {
onEditHost?: (host: SSHHost) => void;
}
export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps) {
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
fetchHosts();
}, []);
const fetchHosts = async () => {
try {
setLoading(true);
const data = await getSSHHosts();
setHosts(data);
setError(null);
} catch (err) {
console.error('Failed to fetch hosts:', err);
setError('Failed to load hosts');
} finally {
setLoading(false);
}
};
const handleDelete = async (hostId: number, hostName: string) => {
if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) {
try {
await deleteSSHHost(hostId);
await fetchHosts(); // Refresh the list
} catch (err) {
console.error('Failed to delete host:', err);
alert('Failed to delete host');
}
}
};
const handleEdit = (host: SSHHost) => {
if (onEditHost) {
onEditHost(host);
}
};
// Filter and sort hosts
const filteredAndSortedHosts = useMemo(() => {
let filtered = hosts;
// Apply search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = hosts.filter(host => {
const searchableText = [
host.name || '',
host.username,
host.ip,
host.folder || '',
...(host.tags || []),
host.authType,
host.defaultPath || ''
].join(' ').toLowerCase();
return searchableText.includes(query);
});
}
// Sort: pinned first, then alphabetical by name/username
return filtered.sort((a, b) => {
// First, sort by pin status (pinned hosts first)
if (a.pin && !b.pin) return -1;
if (!a.pin && b.pin) return 1;
// Then sort alphabetically by name or username
const aName = a.name || a.username;
const bName = b.name || b.username;
return aName.localeCompare(bName);
});
}, [hosts, searchQuery]);
// Group hosts by folder
const hostsByFolder = useMemo(() => {
const grouped: { [key: string]: SSHHost[] } = {};
filteredAndSortedHosts.forEach(host => {
const folder = host.folder || 'Uncategorized';
if (!grouped[folder]) {
grouped[folder] = [];
}
grouped[folder].push(host);
});
// Sort folders to ensure "Uncategorized" is always first
const sortedFolders = Object.keys(grouped).sort((a, b) => {
if (a === 'Uncategorized') return -1;
if (b === 'Uncategorized') return 1;
return a.localeCompare(b);
});
// Create a new object with sorted folders
const sortedGrouped: { [key: string]: SSHHost[] } = {};
sortedFolders.forEach(folder => {
sortedGrouped[folder] = grouped[folder];
});
return sortedGrouped;
}, [filteredAndSortedHosts]);
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading hosts...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">{error}</p>
<Button onClick={fetchHosts} variant="outline">
Retry
</Button>
</div>
</div>
);
}
if (hosts.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">No SSH Hosts</h3>
<p className="text-muted-foreground mb-4">
You haven't added any SSH hosts yet. Click "Add Host" to get started.
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-2">
<div>
<h2 className="text-xl font-semibold">SSH Hosts</h2>
<p className="text-muted-foreground">
{filteredAndSortedHosts.length} hosts
</p>
</div>
<Button onClick={fetchHosts} variant="outline" size="sm">
Refresh
</Button>
</div>
{/* Search Bar */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search hosts by name, username, IP, folder, tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-2 pb-20">
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
<div key={folder} className="border rounded-md">
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}>
<AccordionItem value={folder} className="border-none">
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
<div className="flex items-center gap-2">
<Folder className="h-4 w-4" />
<span className="font-medium">{folder}</span>
<Badge variant="secondary" className="text-xs">
{folderHosts.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="p-2">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{folderHosts.map((host) => (
<div
key={host.id}
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
onClick={() => handleEdit(host)}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
{host.pin && <Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />}
<h3 className="font-medium truncate text-sm">
{host.name || `${host.username}@${host.ip}`}
</h3>
</div>
<p className="text-xs text-muted-foreground truncate">
{host.ip}:{host.port}
</p>
<p className="text-xs text-muted-foreground truncate">
{host.username}
</p>
</div>
<div className="flex gap-1 flex-shrink-0 ml-1">
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleEdit(host);
}}
className="h-5 w-5 p-0"
>
<Edit className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDelete(host.id, host.name || `${host.username}@${host.ip}`);
}}
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
<div className="mt-2 space-y-1">
{/* Tags */}
{host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{host.tags.slice(0, 6).map((tag, index) => (
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
<Tag className="h-2 w-2 mr-0.5" />
{tag}
</Badge>
))}
{host.tags.length > 6 && (
<Badge variant="outline" className="text-xs px-1 py-0">
+{host.tags.length - 6}
</Badge>
)}
</div>
)}
{/* Features */}
<div className="flex flex-wrap gap-1">
{host.enableTerminal && (
<Badge variant="outline" className="text-xs px-1 py-0">
<Terminal className="h-2 w-2 mr-0.5" />
Terminal
</Badge>
)}
{host.enableTunnel && (
<Badge variant="outline" className="text-xs px-1 py-0">
<Network className="h-2 w-2 mr-0.5" />
Tunnel
{host.tunnelConnections && host.tunnelConnections.length > 0 && (
<span className="ml-0.5">({host.tunnelConnections.length})</span>
)}
</Badge>
)}
{host.enableConfigEditor && (
<Badge variant="outline" className="text-xs px-1 py-0">
<FileEdit className="h-2 w-2 mr-0.5" />
Config
</Badge>
)}
</div>
</div>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
))}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import React from 'react';
import {
CornerDownLeft
} from "lucide-react"
import {
Button
} from "@/components/ui/button.tsx"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
interface SidebarProps {
onSelectView: (view: string) => void;
}
export function SSHManagerSidebar({ onSelectView }: SidebarProps): React.ReactElement {
return (
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / SSH Manager
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent className="flex flex-col flex-grow">
<SidebarMenu>
{/* Sidebar Items */}
<SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")} variant="outline">
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1" />
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
)
}

View File

@@ -1,8 +1,8 @@
import React, { useState, useRef, useEffect } from "react";
import { SSHSidebar } from "@/apps/SSH/SSHSidebar.tsx";
import { SSHSidebar } from "@/apps/SSH/Terminal/SSHSidebar.tsx";
import { SSHTerminal } from "./SSHTerminal.tsx";
import { SSHTopbar } from "@/apps/SSH/SSHTopbar.tsx";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import { SSHTopbar } from "@/apps/SSH/Terminal/SSHTopbar.tsx";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable.tsx';
import * as ResizablePrimitive from "react-resizable-panels";
interface ConfigEditorProps {

View File

@@ -52,15 +52,13 @@ import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { ScrollArea } from "@/components/ui/scroll-area";
} from "@/components/ui/accordion.tsx";
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import Icon from "../../../public/icon.svg";
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select";
} from "@/components/ui/popover.tsx";
interface SidebarProps {
onSelectView: (view: string) => void;
@@ -637,8 +635,7 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa
<SidebarContent className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
<img src={Icon} alt="Icon" className="w-6 h-6" />
- Termix / SSH
Termix / Terminal
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent className="flex flex-col flex-grow h-full overflow-hidden">

View File

@@ -1,4 +1,4 @@
import {SSHTabList} from "@/apps/SSH/SSHTabList.tsx";
import {SSHTabList} from "@/apps/SSH/Terminal/SSHTabList.tsx";
import React from "react";
interface TerminalTab {

View File

@@ -0,0 +1,225 @@
import React, { useState, useEffect, useCallback } from "react";
import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
import axios from "axios";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
interface SSHTunnel {
id: number;
name: string;
folder: string;
sourcePort: number;
endpointPort: number;
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;
maxRetries: number;
retryInterval: number;
connectionState: string;
autoStart: boolean;
isPinned: boolean;
}
export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
const [tunnels, setTunnels] = useState<SSHTunnel[]>([]);
const [tunnelsLoading, setTunnelsLoading] = useState(false);
const [tunnelsError, setTunnelsError] = useState<string | null>(null);
const [tunnelStatusMap, setTunnelStatusMap] = useState<Record<string, any>>({});
const sidebarRef = React.useRef<any>(null);
const fetchTunnels = useCallback(async () => {
setTunnelsLoading(true);
setTunnelsError(null);
try {
const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
const res = await axios.get(
(window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + '/ssh_tunnel/tunnel',
{ headers: { Authorization: `Bearer ${jwt}` } }
);
const tunnelData = res.data || [];
setTunnels(tunnelData.map((tunnel: any) => ({
id: tunnel.id,
name: tunnel.name,
folder: tunnel.folder || '',
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort,
sourceIP: tunnel.sourceIP,
sourceSSHPort: tunnel.sourceSSHPort,
sourceUsername: tunnel.sourceUsername || '',
sourcePassword: tunnel.sourcePassword || '',
sourceAuthMethod: tunnel.sourceAuthMethod || 'password',
sourceSSHKey: tunnel.sourceSSHKey || '',
sourceKeyPassword: tunnel.sourceKeyPassword || '',
sourceKeyType: tunnel.sourceKeyType || '',
endpointIP: tunnel.endpointIP,
endpointSSHPort: tunnel.endpointSSHPort,
endpointUsername: tunnel.endpointUsername || '',
endpointPassword: tunnel.endpointPassword || '',
endpointAuthMethod: tunnel.endpointAuthMethod || 'password',
endpointSSHKey: tunnel.endpointSSHKey || '',
endpointKeyPassword: tunnel.endpointKeyPassword || '',
endpointKeyType: tunnel.endpointKeyType || '',
maxRetries: tunnel.maxRetries || 3,
retryInterval: tunnel.retryInterval || 5000,
connectionState: tunnel.connectionState || 'DISCONNECTED',
autoStart: tunnel.autoStart || false,
isPinned: tunnel.isPinned || false
})));
} catch (err: any) {
setTunnelsError('Failed to load tunnels');
} finally {
setTunnelsLoading(false);
}
}, []);
// Poll backend for tunnel statuses
const fetchTunnelStatuses = useCallback(async () => {
try {
const res = await axios.get('http://localhost:8083/status');
setTunnelStatusMap(res.data || {});
} catch (err) {
// Optionally handle error
}
}, []);
useEffect(() => {
fetchTunnels();
const interval = setInterval(fetchTunnels, 10000);
return () => clearInterval(interval);
}, [fetchTunnels]);
useEffect(() => {
fetchTunnelStatuses();
const interval = setInterval(fetchTunnelStatuses, 500);
return () => clearInterval(interval);
}, [fetchTunnelStatuses]);
// Merge backend status into tunnels
const tunnelsWithStatus = tunnels.map(tunnel => {
const status = tunnelStatusMap[tunnel.name] || {};
return {
...tunnel,
connectionState: status.status ? status.status.toUpperCase() : tunnel.connectionState,
statusReason: status.reason || '',
statusErrorType: status.errorType || '',
statusManualDisconnect: status.manualDisconnect || false,
statusRetryCount: status.retryCount,
statusMaxRetries: status.maxRetries,
statusNextRetryIn: status.nextRetryIn,
statusRetryExhausted: status.retryExhausted,
};
});
const handleConnect = async (tunnelId: string) => {
// Immediately set to CONNECTING for instant UI feedback
setTunnels(prev => prev.map(t =>
t.id.toString() === tunnelId
? { ...t, connectionState: "CONNECTING" }
: t
));
const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
if (!tunnel) return;
try {
await axios.post('http://localhost:8083/connect', {
...tunnel,
name: tunnel.name
});
// No need to update state here; polling will update real status
} catch (err) {
// Optionally handle error
}
};
const handleDisconnect = async (tunnelId: string) => {
// Immediately set to DISCONNECTING for instant UI feedback
setTunnels(prev => prev.map(t =>
t.id.toString() === tunnelId
? { ...t, connectionState: "DISCONNECTING" }
: t
));
const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
if (!tunnel) return;
try {
await axios.post('http://localhost:8083/disconnect', {
tunnelName: tunnel.name
});
// No need to update state here; polling will update real status
} catch (err) {
// Optionally handle error
}
};
const handleDeleteTunnel = async (tunnelId: string) => {
try {
const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
await axios.delete(
(window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + `/ssh_tunnel/tunnel/${tunnelId}`,
{ headers: { Authorization: `Bearer ${jwt}` } }
);
fetchTunnels();
} catch (err: any) {
console.error('Failed to delete tunnel:', err);
}
};
const handleEditTunnel = async (tunnelId: string, data: any) => {
try {
const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
await axios.put(
(window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + `/ssh_tunnel/tunnel/${tunnelId}`,
data,
{ headers: { Authorization: `Bearer ${jwt}` } }
);
fetchTunnels();
} catch (err: any) {
console.error('Failed to edit tunnel:', err);
}
};
const handleEditTunnelClick = (tunnelId: string) => {
// Find the tunnel data and pass it to the sidebar
const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
if (tunnel && sidebarRef.current) {
// Call the sidebar's openEditSheet function
sidebarRef.current.openEditSheet(tunnel);
}
};
return (
<div className="flex h-screen w-full">
<div className="w-64 flex-shrink-0">
<SSHTunnelSidebar
ref={sidebarRef}
onSelectView={onSelectView}
onTunnelAdded={fetchTunnels}
onEditTunnel={handleEditTunnelClick}
/>
</div>
<div className="flex-1 overflow-auto">
<SSHTunnelViewer
tunnels={tunnelsWithStatus}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onDeleteTunnel={handleDeleteTunnel}
onEditTunnel={handleEditTunnelClick}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,186 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Loader2, Edit, Trash2 } from "lucide-react";
const CONNECTION_STATES = {
DISCONNECTED: "disconnected",
CONNECTING: "connecting",
CONNECTED: "connected",
VERIFYING: "verifying",
FAILED: "failed",
UNSTABLE: "unstable",
RETRYING: "retrying",
DISCONNECTING: "disconnecting"
};
interface SSHTunnelObjectProps {
hostConfig: any;
onConnect?: () => void;
onDisconnect?: () => void;
onDelete?: () => void;
onEdit?: () => void;
connectionState?: keyof typeof CONNECTION_STATES;
isPinned?: boolean;
}
export function SSHTunnelObject({
hostConfig = {},
onConnect,
onDisconnect,
onDelete,
onEdit,
connectionState = "DISCONNECTED",
isPinned = false
}: SSHTunnelObjectProps): React.ReactElement {
const getStatusColor = (state: keyof typeof CONNECTION_STATES) => {
switch (state) {
case "CONNECTED":
return "bg-green-500";
case "CONNECTING":
case "VERIFYING":
case "RETRYING":
return "bg-yellow-500";
case "FAILED":
return "bg-red-500";
case "UNSTABLE":
return "bg-orange-500";
default:
return "bg-gray-500";
}
};
const getStatusText = (state: keyof typeof CONNECTION_STATES) => {
switch (state) {
case "CONNECTED":
return "Connected";
case "CONNECTING":
return "Connecting";
case "VERIFYING":
return "Verifying";
case "FAILED":
return "Failed";
case "UNSTABLE":
return "Unstable";
case "RETRYING":
return "Retrying";
default:
return "Disconnected";
}
};
const isConnected = connectionState === "CONNECTED";
const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING"].includes(connectionState);
const isDisconnecting = connectionState === "DISCONNECTING";
return (
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
{/* Hover overlay buttons */}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10 flex gap-1">
<Button
size="sm"
variant="secondary"
className="h-8 w-8 p-0 bg-black/50 hover:bg-black/70 border-0"
onClick={onEdit}
>
<Edit className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="destructive"
className="h-8 w-8 p-0 bg-red-500/50 hover:bg-red-500/70 border-0"
onClick={onDelete}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<div className="p-2">
<div className="flex items-center justify-between gap-1 mb-1">
<div className="text-lg font-semibold text-card-foreground flex-1 min-w-0">
<span className="break-words">
{isPinned && <span className="text-yellow-400 mr-1 flex-shrink-0"></span>}
{hostConfig.name || "My SSH Tunnel"}
</span>
</div>
<div className="w-px h-4 bg-border mx-1"></div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className={`w-2 h-2 rounded-full ${getStatusColor(connectionState)}`} />
<span className="text-sm text-muted-foreground whitespace-nowrap">
{getStatusText(connectionState)}
</span>
</div>
</div>
<Separator className="mb-1" />
<div className="space-y-1 mb-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground flex-shrink-0 mr-2">Source:</span>
<span className="text-card-foreground font-mono text-right break-all">
{hostConfig.source || "localhost:22"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground flex-shrink-0 mr-2">Endpoint:</span>
<span className="text-card-foreground font-mono text-right break-all">
{hostConfig.endpoint || "test:224"}
</span>
</div>
</div>
<Separator className="my-1" />
{/* Error/Status Reason */}
{((connectionState === "FAILED" || connectionState === "UNSTABLE") && hostConfig.statusReason) && (
<div className="mb-2 text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">
{hostConfig.statusReason}
{typeof hostConfig.statusReason === 'string' && hostConfig.statusReason.includes('Max retries exhausted') && (
<>
<br />
<span>
Check your Docker logs for the error reason, join the <a href="https://discord.com/invite/jVQGdvHDrf" target="_blank" rel="noopener noreferrer" className="underline text-blue-400">Discord</a> or create a <a href="https://github.com/LukeGus/Termix/issues/new" target="_blank" rel="noopener noreferrer" className="underline text-blue-400">GitHub issue</a> for help.
</span>
</>
)}
</div>
)}
<div className="flex gap-2 mt-2">
<Button
onClick={onConnect}
disabled={isConnected || isConnecting || isDisconnecting}
className="flex-1"
variant={isConnected ? "secondary" : "default"}
>
{isConnecting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Connecting...
</>
) : isConnected ? (
"Connected"
) : (
"Connect"
)}
</Button>
<Button
onClick={onDisconnect}
disabled={!isConnected || isDisconnecting || isConnecting}
variant="outline"
className="flex-1"
>
{isDisconnecting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Disconnecting...
</>
) : (
"Disconnect"
)}
</Button>
</div>
</div>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,137 @@
import React from "react";
import { SSHTunnelObject } from "./SSHTunnelObject.tsx";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion.tsx";
import { Separator } from "@/components/ui/separator.tsx";
interface SSHTunnelViewerProps {
tunnels: Array<{
id: number;
name: string;
folder: string;
sourcePort: number;
endpointPort: number;
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;
maxRetries: number;
retryInterval: number;
connectionState?: string;
autoStart: boolean;
isPinned: boolean;
}>;
onConnect?: (tunnelId: string) => void;
onDisconnect?: (tunnelId: string) => void;
onDeleteTunnel?: (tunnelId: string) => void;
onEditTunnel?: (tunnelId: string) => void;
}
export function SSHTunnelViewer({
tunnels = [],
onConnect,
onDisconnect,
onDeleteTunnel,
onEditTunnel
}: SSHTunnelViewerProps): React.ReactElement {
const handleConnect = (tunnelId: string) => {
onConnect?.(tunnelId);
};
const handleDisconnect = (tunnelId: string) => {
onDisconnect?.(tunnelId);
};
// Group tunnels by folder and sort
const tunnelsByFolder = React.useMemo(() => {
const map: Record<string, typeof tunnels> = {};
tunnels.forEach(tunnel => {
const folder = tunnel.folder && tunnel.folder.trim() ? tunnel.folder : 'No Folder';
if (!map[folder]) map[folder] = [];
map[folder].push(tunnel);
});
return map;
}, [tunnels]);
const sortedFolders = React.useMemo(() => {
const folders = Object.keys(tunnelsByFolder);
folders.sort((a, b) => {
if (a === 'No Folder') return -1;
if (b === 'No Folder') return 1;
return a.localeCompare(b);
});
return folders;
}, [tunnelsByFolder]);
const getSortedTunnels = (arr: typeof tunnels) => {
const pinned = arr.filter(t => t.isPinned).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const rest = arr.filter(t => !t.isPinned).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
return [...pinned, ...rest];
};
return (
<div className="w-full p-6" style={{ width: 'calc(100vw - 256px)', maxWidth: 'none' }}>
<div className="w-full min-w-0" style={{ width: '100%', maxWidth: 'none' }}>
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-foreground mb-2">
SSH Tunnels
</h1>
<p className="text-muted-foreground">
Manage your SSH tunnel connections
</p>
</div>
{/* Accordion Layout */}
{tunnels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<h3 className="text-lg font-semibold text-foreground mb-2">
No SSH Tunnels
</h3>
<p className="text-muted-foreground max-w-md">
Create your first SSH tunnel to get started. Use the sidebar to add a new tunnel configuration.
</p>
</div>
) : (
<Accordion type="multiple" className="w-full" defaultValue={sortedFolders}>
{sortedFolders.map((folder, idx) => (
<AccordionItem value={folder} key={`folder-${folder}`} className={idx === 0 ? "mt-0" : "mt-2"}>
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2" style={{marginTop: idx === 0 ? 0 : undefined}}>
{folder}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
<div className="grid grid-cols-4 gap-6 w-full">
{getSortedTunnels(tunnelsByFolder[folder]).map((tunnel, tunnelIndex) => (
<div key={tunnel.id} className="w-full">
<SSHTunnelObject
hostConfig={tunnel}
connectionState={tunnel.connectionState as any}
isPinned={tunnel.isPinned}
onConnect={() => handleConnect(tunnel.id.toString())}
onDisconnect={() => handleDisconnect(tunnel.id.toString())}
onDelete={() => onDeleteTunnel?.(tunnel.id.toString())}
onEdit={() => onEditTunnel?.(tunnel.id.toString())}
/>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</div>
</div>
);
}

229
src/apps/SSH/ssh-axios.ts Normal file
View File

@@ -0,0 +1,229 @@
// 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;
}
// 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
const api = axios.create({
baseURL,
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
api.interceptors.request.use((config) => {
const token = getCookie('jwt'); // Adjust based on your token storage
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Get all SSH hosts
export async function getSSHHosts(): Promise<SSHHost[]> {
try {
const response = await api.get('/ssh/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 || [], // Array of strings
pin: hostData.pin || false,
authMethod: hostData.authType, // Backend expects 'authMethod'
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
defaultPath: hostData.defaultPath || '/',
tunnelConnections: hostData.tunnelConnections || [], // Array of tunnel objects
};
// 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 api.post('/ssh/host', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
} else {
// Submit with JSON
const response = await api.post('/ssh/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 || [],
};
// Handle disabled features
if (!submitData.enableTunnel) {
submitData.tunnelConnections = [];
}
if (!submitData.enableConfigEditor) {
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);
const dataWithoutFile = { ...submitData };
delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile));
const response = await api.put(`/ssh/host/${hostId}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
} else {
const response = await api.put(`/ssh/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/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/host/${hostId}`);
return response.data;
} catch (error) {
console.error('Error fetching SSH host:', error);
throw error;
}
}
export { api };