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

@@ -1,24 +1,34 @@
import { HomepageSidebar } from "@/apps/Homepage/HomepageSidebar.tsx";
import React, { useState } from "react";
import { HomepageAuth } from "@/apps/Homepage/HomepageAuth.tsx";
import {HomepageSidebar} from "@/apps/Homepage/HomepageSidebar.tsx";
import React, {useEffect, useState} from "react";
import {HomepageAuth} from "@/apps/Homepage/HomepageAuth.tsx";
interface HomepageProps {
onSelectView: (view: string) => void;
}
export function Homepage({ onSelectView }: HomepageProps): React.ReactElement {
export function Homepage({onSelectView}: HomepageProps): React.ReactElement {
const [loggedIn, setLoggedIn] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [username, setUsername] = useState<string | null>(null);
return (
<div className="flex min-h-screen">
<HomepageSidebar onSelectView={onSelectView} disabled={!loggedIn} isAdmin={isAdmin} username={loggedIn ? username : null} />
<div className="flex-1 bg-background" />
<HomepageSidebar
onSelectView={onSelectView}
disabled={!loggedIn}
isAdmin={isAdmin}
username={loggedIn ? username : null}
/>
<div className="flex-1 bg-background"/>
<div
className="fixed inset-y-0 right-0 flex justify-center items-center z-50"
style={{ left: 256 }}
style={{left: 256}}
>
<HomepageAuth setLoggedIn={setLoggedIn} setIsAdmin={setIsAdmin} setUsername={setUsername} />
<HomepageAuth
setLoggedIn={setLoggedIn}
setIsAdmin={setIsAdmin}
setUsername={setUsername}
/>
</div>
</div>
);

View File

@@ -58,8 +58,6 @@ export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername,
}
setDbError(null);
}).catch(() => {
setFirstUser(true);
setTab("signup");
setDbError("Could not connect to the database. Please try again later.");
});
}, []);

View File

@@ -3,7 +3,7 @@ import {
Computer,
Server,
File,
Hammer, ChevronUp, User2
Hammer, ChevronUp, User2, HardDrive
} from "lucide-react";
import {
@@ -16,16 +16,30 @@ import {
SidebarMenuButton,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import Icon from "/public/icon.svg";
import {
Separator,
} from "@/components/ui/separator.tsx"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, SheetClose } from "@/components/ui/sheet";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
SheetClose
} from "@/components/ui/sheet";
import {Checkbox} from "@/components/ui/checkbox.tsx";
import axios from "axios";
import {Button} from "@/components/ui/button.tsx";
import {Homepage} from "@/apps/Homepage/Homepage.tsx";
import {SSHManager} from "@/apps/SSH/Manager/SSHManager.tsx";
import {SSH} from "@/apps/SSH/Terminal/SSH.tsx";
import {SSHTunnel} from "@/apps/SSH/Tunnel/SSHTunnel.tsx";
import {ConfigEditor} from "@/apps/SSH/Config Editor/ConfigEditor.tsx";
import {Tools} from "@/apps/Tools/Tools.tsx";
interface SidebarProps {
onSelectView: (view: string) => void;
@@ -40,22 +54,22 @@ function handleLogout() {
}
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
const apiBase =
typeof window !== "undefined" && window.location.hostname === "localhost"
? "http://localhost:8081/users"
: "/users";
typeof window !== "undefined" && window.location.hostname === "localhost"
? "http://localhost:8081/users"
: "/users";
const API = axios.create({
baseURL: apiBase,
baseURL: apiBase,
});
export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: SidebarProps): React.ReactElement {
export function HomepageSidebar({onSelectView, getView, disabled, isAdmin, username}: SidebarProps): React.ReactElement {
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
@@ -72,8 +86,8 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S
try {
await API.patch(
"/registration-allowed",
{ allowed: checked },
{ headers: { Authorization: `Bearer ${jwt}` } }
{allowed: checked},
{headers: {Authorization: `Bearer ${jwt}`}}
);
setAllowRegistration(checked);
} catch (e) {
@@ -81,103 +95,120 @@ export function HomepageSidebar({ onSelectView, disabled, isAdmin, username }: S
setRegLoading(false);
}
};
return (
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
<img src={Icon} alt="Icon" className="w-6 h-6" />
- Termix
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem key={"SSH"}>
<SidebarMenuButton onClick={() => onSelectView("ssh")} disabled={disabled}>
<Computer />
<span>SSH</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"SSH Tunnel"}>
<SidebarMenuButton onClick={() => onSelectView("ssh_tunnel")} disabled={disabled}>
<Server />
<span>SSH Tunnel</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"Config Editor"}>
<SidebarMenuButton onClick={() => onSelectView("config_editor")} disabled={disabled}>
<File />
<span>Config Editor</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"Tools"}>
<SidebarMenuButton onClick={() => onSelectView("tools")} disabled={disabled}>
<Hammer />
<span>Tools</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="data-[state=open]:opacity-90 w-full"
style={{ width: '100%' }}
disabled={disabled}
<div>
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem key={"SSH Manager"}>
<SidebarMenuButton onClick={() => onSelectView("ssh_manager")} disabled={disabled}>
<HardDrive/>
<span>SSH Manager</span>
</SidebarMenuButton>
</SidebarMenuItem>
<div className="ml-5">
<SidebarMenuItem key={"Terminal"}>
<SidebarMenuButton onClick={() => onSelectView("terminal")} disabled={disabled}>
<Computer/>
<span>Terminal</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"Tunnel"}>
<SidebarMenuButton onClick={() => onSelectView("tunnel")}
disabled={disabled}>
<Server/>
<span>Tunnel</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem key={"Config Editor"}>
<SidebarMenuButton onClick={() => onSelectView("config_editor")}
disabled={disabled}>
<File/>
<span>Config Editor</span>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
<SidebarMenuItem key={"Tools"}>
<SidebarMenuButton onClick={() => onSelectView("tools")} disabled={disabled}>
<Hammer/>
<span>Tools</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="data-[state=open]:opacity-90 w-full"
style={{width: '100%'}}
disabled={disabled}
>
<User2/> {username ? username : 'Signed out'}
<ChevronUp className="ml-auto"/>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={6}
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
>
<User2 /> {username ? username : 'Signed out'}
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={6}
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
>
{isAdmin && (
<DropdownMenuItem className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none" onSelect={() => setAdminSheetOpen(true)}>
<span>Admin Settings</span>
{isAdmin && (
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onSelect={() => setAdminSheetOpen(true)}>
<span>Admin Settings</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onSelect={handleLogout}>
<span>Sign out</span>
</DropdownMenuItem>
)}
<DropdownMenuItem className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none" onSelect={handleLogout}>
<span>Sign out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
{/* Admin Settings Sheet (always rendered, only openable if isAdmin) */}
{isAdmin && (
<Sheet open={adminSheetOpen} onOpenChange={setAdminSheetOpen}>
<SheetContent side="left" className="w-[320px]">
<SheetHeader>
<SheetTitle>Admin Settings</SheetTitle>
</SheetHeader>
<div className="pt-1 pb-4 px-4 flex flex-col gap-4">
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle} disabled={regLoading} />
Allow new account registration
</label>
</div>
<SheetFooter className="px-4 pt-1 pb-4">
<Separator className="p-0.25 mt-2 mb-2" />
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)}
</Sidebar>
</SidebarProvider>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
{/* Admin Settings Sheet (always rendered, only openable if isAdmin) */}
{isAdmin && (
<Sheet open={adminSheetOpen} onOpenChange={setAdminSheetOpen}>
<SheetContent side="left" className="w-[320px]">
<SheetHeader>
<SheetTitle>Admin Settings</SheetTitle>
</SheetHeader>
<div className="pt-1 pb-4 px-4 flex flex-col gap-4">
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
disabled={regLoading}/>
Allow new account registration
</label>
</div>
<SheetFooter className="px-4 pt-1 pb-4">
<Separator className="p-0.25 mt-2 mb-2"/>
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)}
</Sidebar>
</SidebarProvider>
</div>
)
}

View File

@@ -1,12 +1,12 @@
import React, { useState, useEffect, useRef } from "react";
import { ConfigEditorSidebar } from "@/apps/Config Editor/ConfigEditorSidebar";
import { ConfigTabList } from "@/apps/Config Editor/ConfigTabList";
import { ConfigHomeView } from "@/apps/Config Editor/ConfigHomeView";
import { ConfigCodeEditor } from "@/apps/Config Editor/ConfigCodeEditor";
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';
import { ConfigTopbar } from "@/apps/Config Editor/ConfigTopbar";
import { cn } from '@/lib/utils';
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];
@@ -216,11 +216,11 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
return (
<div className="flex flex-col h-full" style={{ flex: 1, minHeight: 0 }}>
<div className="flex-1 min-h-0">
<ConfigCodeEditor
<ConfigCodeEditor
content={tab.content}
fileNameOld={tab.fileName}
onContentChange={content => setTabContent(tab.id, content)}
/>
/>
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
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 {

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
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';
import { Input } from '@/components/ui/input';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs.tsx';
import { Input } from '@/components/ui/input.tsx';
import { useState } from 'react';
interface FileItem {

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Button } from '@/components/ui/button.tsx';
import { X, Home } from 'lucide-react';
interface ConfigTab {

View File

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

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

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from "react";
import { SSHTunnelSidebar } from "@/apps/SSH Tunnel/SSHTunnelSidebar.tsx";
import { SSHTunnelViewer } from "@/apps/SSH Tunnel/SSHTunnelViewer.tsx";
import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
import axios from "axios";
interface ConfigEditorProps {

View File

@@ -1,7 +1,7 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
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 = {

View File

@@ -48,8 +48,7 @@ import { Input } from "@/components/ui/input.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import axios from "axios";
import Icon from "../../../public/icon.svg";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
interface SidebarProps {
onSelectView: (view: string) => void;
@@ -478,8 +477,7 @@ export const SSHTunnelSidebar = React.forwardRef<{ openEditSheet: (tunnel: any)
<SidebarContent>
<SidebarGroup>
<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 Tunnel
Termix / Tunnel
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent className="flex flex-col flex-grow">

View File

@@ -1,7 +1,7 @@
import React from "react";
import { SSHTunnelObject } from "./SSHTunnelObject";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
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<{

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 };

View File

@@ -21,7 +21,6 @@ import {
import {
Separator,
} from "@/components/ui/separator.tsx"
import Icon from "../../../public/icon.svg";
interface SidebarProps {
onSelectView: (view: string) => void;
@@ -34,8 +33,7 @@ export function TemplateSidebar({ onSelectView }: SidebarProps): React.ReactElem
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
<img src={Icon} alt="Icon" className="w-6 h-6" />
- Termix / Template
Termix / Template
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent className="flex flex-col flex-grow">

View File

@@ -21,7 +21,6 @@ import {
import {
Separator,
} from "@/components/ui/separator.tsx"
import Icon from "../../../public/icon.svg";
interface SidebarProps {
onSelectView: (view: string) => void;
@@ -34,8 +33,7 @@ export function ToolsSidebar({ onSelectView }: SidebarProps): React.ReactElement
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
<img src={Icon} alt="Icon" className="w-6 h-6" />
- Termix / Tools
Termix / Tools
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarGroupContent className="flex flex-col flex-grow">