v1.6.0 #221
21
package-lock.json
generated
21
package-lock.json
generated
@@ -7,7 +7,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"hasInstallScript": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
@@ -5152,26 +5151,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/concat-map": {
|
|
||||||
"version": "0.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
|
||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/compare-version": {
|
|
||||||
"version": "0.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz",
|
|
||||||
"integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ export function LeftSidebar({
|
|||||||
<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"
|
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"
|
||||||
onClick={handleLogout}>
|
onClick={handleLogout}>
|
||||||
|
|
||||||
<span>{t('common.logout')}</span>
|
<span>{t('common.logout')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@@ -59,13 +59,9 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
setSelectedTabIds([]);
|
setSelectedTabIds([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (selectedTabIds.length === 0) return;
|
if (selectedTabIds.length === 0) return;
|
||||||
|
|
||||||
const value = e.currentTarget.value;
|
|
||||||
let commandToSend = '';
|
let commandToSend = '';
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
|||||||
16
src/ui/Mobile/Apps/Navigation/BottomNavbar.tsx
Normal file
16
src/ui/Mobile/Apps/Navigation/BottomNavbar.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {Menu} from "lucide-react";
|
||||||
|
|
||||||
|
interface MenuProps {
|
||||||
|
onSidebarOpenClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomNavbar({onSidebarOpenClick}: MenuProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[80px] bg-[#18181BFF] flex flex-col justify-center">
|
||||||
|
<Button className="w-[40px] h-[40px] ml-2" variant="outline" onClick={onSidebarOpenClick}>
|
||||||
|
<Menu />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
src/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx
Normal file
80
src/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React, {useState} from "react";
|
||||||
|
import {CardTitle} from "@/components/ui/card.tsx";
|
||||||
|
import {ChevronDown, Folder} from "lucide-react";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
|
import {Host} from "@/ui/Mobile/Apps/Navigation/Hosts/Host.tsx";
|
||||||
|
|
||||||
|
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;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderCardProps {
|
||||||
|
folderName: string,
|
||||||
|
hosts: SSHHost[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FolderCard({folderName, hosts}: FolderCardProps): React.ReactElement {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
|
const toggleExpanded = () => {
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden"
|
||||||
|
style={{padding: '0', margin: '0'}}>
|
||||||
|
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
|
||||||
|
<div className="flex gap-2 pr-10">
|
||||||
|
<div className="flex-shrink-0 flex items-center">
|
||||||
|
<Folder size={16} strokeWidth={3}/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="mb-0 leading-tight break-words text-md">{folderName}</CardTitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-[28px] h-[28px] absolute right-4 top-1/2 -translate-y-1/2 flex-shrink-0"
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
>
|
||||||
|
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? '' : 'rotate-180'}`}/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="flex flex-col p-2 gap-y-3">
|
||||||
|
{hosts.map((host, index) => (
|
||||||
|
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
|
||||||
|
<Host host={host}/>
|
||||||
|
|
||||||
|
{index < hosts.length - 1 && (
|
||||||
|
<div className="relative -mx-2">
|
||||||
|
<Separator className="p-0.25 absolute inset-x-0"/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
src/ui/Mobile/Apps/Navigation/Hosts/Host.tsx
Normal file
107
src/ui/Mobile/Apps/Navigation/Hosts/Host.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React, {useEffect, useState} from "react";
|
||||||
|
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||||
|
import {Server, Terminal} from "lucide-react";
|
||||||
|
import {getServerStatusById} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
|
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;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostProps {
|
||||||
|
host: SSHHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Host({host}: HostProps): React.ReactElement {
|
||||||
|
const [serverStatus, setServerStatus] = useState<'online' | 'offline' | 'degraded'>('degraded');
|
||||||
|
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||||
|
const hasTags = tags.length > 0;
|
||||||
|
|
||||||
|
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let intervalId: number | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getServerStatusById(host.id);
|
||||||
|
if (!cancelled) {
|
||||||
|
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setServerStatus('offline');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStatus();
|
||||||
|
|
||||||
|
intervalId = window.setInterval(fetchStatus, 10000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (intervalId) window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [host.id]);
|
||||||
|
|
||||||
|
const handleTerminalClick = () => {
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerClick = () => {
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||||
|
<StatusIndicator/>
|
||||||
|
</Status>
|
||||||
|
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||||
|
{host.name || host.ip}
|
||||||
|
</p>
|
||||||
|
<ButtonGroup className="flex-shrink-0">
|
||||||
|
{host.enableTerminal && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="!px-2 border-1 w-[60px] border-[#303032]"
|
||||||
|
onClick={handleTerminalClick}
|
||||||
|
>
|
||||||
|
<Terminal/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
{hasTags && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||||
|
{tags.map((tag: string) => (
|
||||||
|
<div key={tag} className="bg-[#18181b] border-1 border-[#303032] pl-2 pr-2 rounded-[10px]">
|
||||||
|
<p className="text-sm">{tag}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx
Normal file
63
src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import {Sidebar, SidebarContent, SidebarGroupLabel, SidebarHeader, SidebarProvider} from "@/components/ui/sidebar.tsx";
|
||||||
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
import {Menu} from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
|
import {FolderCard} from "@/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx";
|
||||||
|
import {Host} from "@/ui/Mobile/Apps/Navigation/Hosts/Host.tsx";
|
||||||
|
|
||||||
|
interface LeftSidebarProps {
|
||||||
|
isSidebarOpen: boolean;
|
||||||
|
setIsSidebarOpen: (type: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeftSidebar({ isSidebarOpen, setIsSidebarOpen }: LeftSidebarProps) {
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<SidebarProvider open={isSidebarOpen}>
|
||||||
|
<Sidebar>
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarGroupLabel className="text-lg font-bold text-white">
|
||||||
|
Termix
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
|
className="w-[28px] h-[28px] absolute right-5"
|
||||||
|
>
|
||||||
|
<Menu className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
</SidebarHeader>
|
||||||
|
<Separator/>
|
||||||
|
<SidebarContent className="px-2 py-2">
|
||||||
|
<FolderCard
|
||||||
|
folderName="Folder"
|
||||||
|
hosts={[{
|
||||||
|
id: 1,
|
||||||
|
name: "My Server",
|
||||||
|
ip: "192.168.1.100",
|
||||||
|
port: 22,
|
||||||
|
username: "admin",
|
||||||
|
folder: "/home/admin",
|
||||||
|
tags: ["production", "backend"],
|
||||||
|
pin: true,
|
||||||
|
authType: "password",
|
||||||
|
password: "securePassword123",
|
||||||
|
key: undefined,
|
||||||
|
keyPassword: undefined,
|
||||||
|
keyType: undefined,
|
||||||
|
enableTerminal: true,
|
||||||
|
enableTunnel: false,
|
||||||
|
enableFileManager: true,
|
||||||
|
defaultPath: "/home/admin/projects",
|
||||||
|
tunnelConnections: [],
|
||||||
|
createdAt: "2025-09-05T12:00:00Z",
|
||||||
|
updatedAt: "2025-09-05T12:00:00Z"
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
</SidebarContent>
|
||||||
|
</Sidebar>
|
||||||
|
</SidebarProvider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import {useRef, FC} from "react";
|
import React, {useRef, FC} from "react";
|
||||||
import {Terminal} from "@/ui/Mobile/Apps/Terminal/Terminal.tsx";
|
import {Terminal} from "@/ui/Mobile/Apps/Terminal/Terminal.tsx";
|
||||||
import {TerminalKeyboard} from "@/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx";
|
import {TerminalKeyboard} from "@/ui/Mobile/Apps/Terminal/TerminalKeyboard.tsx";
|
||||||
|
import {BottomNavbar} from "@/ui/Mobile/Apps/Navigation/BottomNavbar.tsx";
|
||||||
|
import {LeftSidebar} from "@/ui/Mobile/Apps/Navigation/LeftSidebar.tsx";
|
||||||
|
|
||||||
export const MobileApp: FC = () => {
|
export const MobileApp: FC = () => {
|
||||||
const terminalRef = useRef<any>(null);
|
const terminalRef = useRef<any>(null);
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = React.useState(false);
|
||||||
|
|
||||||
function handleKeyboardInput(input: string) {
|
function handleKeyboardInput(input: string) {
|
||||||
if (!terminalRef.current?.sendInput) return;
|
if (!terminalRef.current?.sendInput) return;
|
||||||
@@ -12,7 +15,7 @@ export const MobileApp: FC = () => {
|
|||||||
"{backspace}": "\x7f",
|
"{backspace}": "\x7f",
|
||||||
"{space}": " ",
|
"{space}": " ",
|
||||||
"{tab}": "\t",
|
"{tab}": "\t",
|
||||||
"{enter}": "\r",
|
"{enter}": "",
|
||||||
"{escape}": "\x1b",
|
"{escape}": "\x1b",
|
||||||
"{arrowUp}": "\x1b[A",
|
"{arrowUp}": "\x1b[A",
|
||||||
"{arrowDown}": "\x1b[B",
|
"{arrowDown}": "\x1b[B",
|
||||||
@@ -22,7 +25,7 @@ export const MobileApp: FC = () => {
|
|||||||
"{home}": "\x1b[H",
|
"{home}": "\x1b[H",
|
||||||
"{end}": "\x1b[F",
|
"{end}": "\x1b[F",
|
||||||
"{pageUp}": "\x1b[5~",
|
"{pageUp}": "\x1b[5~",
|
||||||
"{pageDown}": "\x1b[6~",
|
"{pageDown}": "\x1b[6~"
|
||||||
};
|
};
|
||||||
|
|
||||||
if (input in keyMap) {
|
if (input in keyMap) {
|
||||||
@@ -33,7 +36,7 @@ export const MobileApp: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen flex flex-col bg-[#09090b] overflow-y-hidden overflow-x-hidden">
|
<div className="h-screen w-screen flex flex-col bg-[#09090b] overflow-y-hidden overflow-x-hidden relative">
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<Terminal
|
<Terminal
|
||||||
ref={terminalRef}
|
ref={terminalRef}
|
||||||
@@ -49,9 +52,25 @@ export const MobileApp: FC = () => {
|
|||||||
<TerminalKeyboard
|
<TerminalKeyboard
|
||||||
onSendInput={handleKeyboardInput}
|
onSendInput={handleKeyboardInput}
|
||||||
/>
|
/>
|
||||||
<div className="w-full h-[80px] bg-[#18181BFF]">
|
<BottomNavbar
|
||||||
|
onSidebarOpenClick={() => setIsSidebarOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/30 backdrop-blur-sm z-10"
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute top-0 left-0 h-full z-20 pointer-events-none">
|
||||||
|
<div onClick={(e) => { e.stopPropagation(); }} className="pointer-events-auto">
|
||||||
|
<LeftSidebar
|
||||||
|
isSidebarOpen={isSidebarOpen}
|
||||||
|
setIsSidebarOpen={setIsSidebarOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user