Clean up code

This commit is contained in:
LukeGus
2025-07-28 14:56:43 -05:00
parent bc4c2dc7e6
commit 30bcdd440e
37 changed files with 2428 additions and 2661 deletions

View File

@@ -2,13 +2,10 @@
FROM node:18-alpine AS deps
WORKDIR /app
# Install build dependencies for native modules
RUN apk add --no-cache python3 make g++
# Copy dependency files
COPY package*.json ./
# Install dependencies with caching
RUN npm ci --force && \
npm cache clean --force
@@ -16,30 +13,24 @@ RUN npm ci --force && \
FROM deps AS frontend-builder
WORKDIR /app
# Copy source files
COPY . .
# Build frontend
RUN npm run build
# Stage 3: Build backend TypeScript
FROM deps AS backend-builder
WORKDIR /app
# Copy source files
COPY . .
# Build backend TypeScript to JavaScript
RUN npm run build:backend
# Stage 4: Production dependencies
FROM node:18-alpine AS production-deps
WORKDIR /app
# Copy only production dependency files
COPY package*.json ./
# Install only production dependencies
RUN npm ci --only=production --ignore-scripts --force && \
npm cache clean --force
@@ -47,13 +38,10 @@ RUN npm ci --only=production --ignore-scripts --force && \
FROM node:18-alpine AS native-builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache python3 make g++
# Copy dependency files
COPY package*.json ./
# Install only the native modules we need
RUN npm ci --only=production bcryptjs better-sqlite3 --force && \
npm cache clean --force
@@ -63,35 +51,26 @@ ENV DATA_DIR=/app/data \
PORT=8080 \
NODE_ENV=production
# Install dependencies in a single layer
RUN apk add --no-cache nginx gettext su-exec && \
mkdir -p /app/data && \
chown -R node:node /app/data
# Setup nginx and frontend
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
RUN chown -R nginx:nginx /usr/share/nginx/html
# Setup backend
WORKDIR /app
# Copy production dependencies and native modules
COPY --from=production-deps /app/node_modules /app/node_modules
COPY --from=native-builder /app/node_modules/bcryptjs /app/node_modules/bcryptjs
COPY --from=native-builder /app/node_modules/better-sqlite3 /app/node_modules/better-sqlite3
# Copy compiled backend JavaScript
COPY --from=backend-builder /app/dist/backend ./dist/backend
# Copy package.json for scripts
COPY package.json ./
RUN chown -R node:node /app
VOLUME ["/app/data"]
# Expose ports
EXPOSE ${PORT} 8081 8082 8083 8084
COPY docker/entrypoint.sh /entrypoint.sh

View File

@@ -1,16 +1,14 @@
services:
termix:
#image: ghcr.io/lukegus/termix:latest
image: ghcr.io/lukegus/termix:dev-1.0-development-latest
image: ghcr.io/lukegus/termix:latest
container_name: termix
restart: unless-stopped
ports:
- "3800:8080"
- "8080:8080"
volumes:
- termix-data:/app/data
environment:
# Generate random salt here https://www.lastpass.com/features/password-generator (max 32 characters, include all characters for settings)
SALT: "2v.F7!6a!jIzmJsu|[)h61$ZMXs;,i+~"
PORT: 8080
volumes:
termix-data:

View File

@@ -4,11 +4,9 @@ set -e
export PORT=${PORT:-8080}
echo "Configuring web UI to run on port: $PORT"
# Configure nginx with the correct port
envsubst '${PORT}' < /etc/nginx/nginx.conf > /etc/nginx/nginx.conf.tmp
mv /etc/nginx/nginx.conf.tmp /etc/nginx/nginx.conf
# Setup data directory
mkdir -p /app/data
chown -R node:node /app/data
chmod 755 /app/data
@@ -16,12 +14,10 @@ chmod 755 /app/data
echo "Starting nginx..."
nginx
# Start backend services
echo "Starting backend services..."
cd /app
export NODE_ENV=production
# Start the compiled TypeScript backend
if command -v su-exec > /dev/null 2>&1; then
su-exec node node dist/backend/starter.js
else
@@ -30,5 +26,4 @@ fi
echo "All services started"
# Keep container running
tail -f /dev/null

View File

@@ -1,293 +1,302 @@
import React, { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import React, {useState, useEffect} from "react";
import {cn} from "@/lib/utils";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import {Label} from "@/components/ui/label";
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert";
import axios from "axios";
function setCookie(name: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
}
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,
});
interface HomepageAuthProps extends React.ComponentProps<"div"> {
setLoggedIn: (loggedIn: boolean) => void;
setIsAdmin: (isAdmin: boolean) => void;
setUsername: (username: string | null) => void;
setLoggedIn: (loggedIn: boolean) => void;
setIsAdmin: (isAdmin: boolean) => void;
setUsername: (username: string | null) => void;
}
export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername, ...props }: HomepageAuthProps) {
const [tab, setTab] = useState<"login" | "signup">("login");
const [localUsername, setLocalUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
const [firstUser, setFirstUser] = useState(false);
const [dbError, setDbError] = useState<string | null>(null);
const [registrationAllowed, setRegistrationAllowed] = useState(true);
useEffect(() => {
API.get("/registration-allowed").then(res => {
setRegistrationAllowed(res.data.allowed);
});
}, []);
export function HomepageAuth({className, setLoggedIn, setIsAdmin, setUsername, ...props}: HomepageAuthProps) {
const [tab, setTab] = useState<"login" | "signup">("login");
const [localUsername, setLocalUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
const [firstUser, setFirstUser] = useState(false);
const [dbError, setDbError] = useState<string | null>(null);
const [registrationAllowed, setRegistrationAllowed] = useState(true);
useEffect(() => {
API.get("/registration-allowed").then(res => {
setRegistrationAllowed(res.data.allowed);
});
}, []);
useEffect(() => {
API.get("/count").then(res => {
if (res.data.count === 0) {
setFirstUser(true);
setTab("signup");
} else {
setFirstUser(false);
}
setDbError(null);
}).catch(() => {
setDbError("Could not connect to the database. Please try again later.");
});
}, []);
useEffect(() => {
const jwt = getCookie("jwt");
if (jwt) {
setLoading(true);
Promise.all([
API.get("/me", { headers: { Authorization: `Bearer ${jwt}` } }),
API.get("/db-health")
])
.then(([meRes]) => {
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.data.is_admin);
setUsername(meRes.data.username || null);
setDbError(null);
})
.catch((err) => {
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setCookie("jwt", "", -1);
if (err?.response?.data?.error?.includes("Database")) {
setDbError("Could not connect to the database. Please try again later.");
} else {
useEffect(() => {
API.get("/count").then(res => {
if (res.data.count === 0) {
setFirstUser(true);
setTab("signup");
} else {
setFirstUser(false);
}
setDbError(null);
}
})
.finally(() => setLoading(false));
} else {
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
}).catch(() => {
setDbError("Could not connect to the database. Please try again later.");
});
}, []);
useEffect(() => {
const jwt = getCookie("jwt");
if (jwt) {
setLoading(true);
Promise.all([
API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}}),
API.get("/db-health")
])
.then(([meRes]) => {
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.data.is_admin);
setUsername(meRes.data.username || null);
setDbError(null);
})
.catch((err) => {
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setCookie("jwt", "", -1);
if (err?.response?.data?.error?.includes("Database")) {
setDbError("Could not connect to the database. Please try again later.");
} else {
setDbError(null);
}
})
.finally(() => setLoading(false));
} else {
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
}
}, [setLoggedIn, setIsAdmin, setUsername]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
let res, meRes;
if (tab === "login") {
res = await API.post("/get", {username: localUsername, password});
} else {
await API.post("/create", {username: localUsername, password});
res = await API.post("/get", {username: localUsername, password});
}
setCookie("jwt", res.data.token);
[meRes] = await Promise.all([
API.get("/me", {headers: {Authorization: `Bearer ${res.data.token}`}}),
API.get("/db-health")
]);
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.data.is_admin);
setUsername(meRes.data.username || null);
setDbError(null);
} catch (err: any) {
setError(err?.response?.data?.error || "Unknown error");
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setCookie("jwt", "", -1);
if (err?.response?.data?.error?.includes("Database")) {
setDbError("Could not connect to the database. Please try again later.");
} else {
setDbError(null);
}
} finally {
setLoading(false);
}
}
}, [setLoggedIn, setIsAdmin, setUsername]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
let res, meRes;
if (tab === "login") {
res = await API.post("/get", { username: localUsername, password });
} else {
await API.post("/create", { username: localUsername, password });
res = await API.post("/get", { username: localUsername, password });
}
setCookie("jwt", res.data.token);
[meRes] = await Promise.all([
API.get("/me", { headers: { Authorization: `Bearer ${res.data.token}` } }),
API.get("/db-health")
]);
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.data.is_admin);
setUsername(meRes.data.username || null);
setDbError(null);
} catch (err: any) {
setError(err?.response?.data?.error || "Unknown error");
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setCookie("jwt", "", -1);
if (err?.response?.data?.error?.includes("Database")) {
setDbError("Could not connect to the database. Please try again later.");
} else {
setDbError(null);
}
} finally {
setLoading(false);
}
}
const Spinner = (
<svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
);
const Spinner = (
<svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
);
return (
<div
className={cn(
"flex-1 flex justify-center items-center min-h-screen bg-background",
className
)}
{...props}
>
<div className={`w-[420px] max-w-full bg-background rounded-xl shadow-lg p-6 flex flex-col ${internalLoggedIn ? '' : 'border border-border'}`}>
{dbError && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{dbError}</AlertDescription>
</Alert>
)}
{firstUser && !dbError && !internalLoggedIn && (
<Alert variant="default" className="mb-4">
<AlertTitle>First User</AlertTitle>
<AlertDescription>
You are the first user and will be made an admin. You can view admin settings in the sidebar user dropdown.
</AlertDescription>
</Alert>
)}
{!registrationAllowed && !internalLoggedIn && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Registration Disabled</AlertTitle>
<AlertDescription>
New account registration is currently disabled by an admin. Please log in or contact an administrator.
</AlertDescription>
</Alert>
)}
{(internalLoggedIn || (loading && getCookie("jwt"))) && (
<div className="flex flex-1 justify-center items-center p-0 m-0">
<div className="flex flex-col items-center gap-4">
<Alert className="my-2">
<AlertTitle>Logged in!</AlertTitle>
<AlertDescription>
You are logged in! Use the sidebar to access all available tools. To get started, create an SSH Host in the SSH Manager tab. Once created, you can connect to that host using the other apps in the sidebar.
</AlertDescription>
</Alert>
<div className="flex flex-row items-center gap-2">
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
>
GitHub
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
>
Feedback
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
>
Discord
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
>
Fund
</Button>
</div>
</div>
</div>
)}
{(!internalLoggedIn && (!loading || !getCookie("jwt"))) && (
<>
<div className="flex gap-2 mb-6">
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "login"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
return (
<div
className={cn(
"flex-1 flex justify-center items-center min-h-screen bg-background",
className
)}
{...props}
>
<div
className={`w-[420px] max-w-full bg-background rounded-xl shadow-lg p-6 flex flex-col ${internalLoggedIn ? '' : 'border border-border'}`}>
{dbError && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{dbError}</AlertDescription>
</Alert>
)}
onClick={() => setTab("login")}
aria-selected={tab === "login"}
disabled={loading || firstUser}
>
Login
</button>
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "signup"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
{firstUser && !dbError && !internalLoggedIn && (
<Alert variant="default" className="mb-4">
<AlertTitle>First User</AlertTitle>
<AlertDescription>
You are the first user and will be made an admin. You can view admin settings in the sidebar
user dropdown.
</AlertDescription>
</Alert>
)}
{!registrationAllowed && !internalLoggedIn && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Registration Disabled</AlertTitle>
<AlertDescription>
New account registration is currently disabled by an admin. Please log in or contact an
administrator.
</AlertDescription>
</Alert>
)}
{(internalLoggedIn || (loading && getCookie("jwt"))) && (
<div className="flex flex-1 justify-center items-center p-0 m-0">
<div className="flex flex-col items-center gap-4">
<Alert className="my-2">
<AlertTitle>Logged in!</AlertTitle>
<AlertDescription>
You are logged in! Use the sidebar to access all available tools. To get started,
create an SSH Host in the SSH Manager tab. Once created, you can connect to that
host using the other apps in the sidebar.
</AlertDescription>
</Alert>
<div className="flex flex-row items-center gap-2">
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
>
GitHub
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
>
Feedback
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
>
Discord
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
>
Fund
</Button>
</div>
</div>
</div>
)}
{(!internalLoggedIn && (!loading || !getCookie("jwt"))) && (
<>
<div className="flex gap-2 mb-6">
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "login"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => setTab("login")}
aria-selected={tab === "login"}
disabled={loading || firstUser}
>
Login
</button>
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "signup"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => setTab("signup")}
aria-selected={tab === "signup"}
disabled={loading || !registrationAllowed}
>
Sign Up
</button>
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login" ? "Login to your account" : "Create a new account"}
</h2>
</div>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={e => setLocalUsername(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required className="h-11 text-base"
value={password} onChange={e => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}/>
</div>
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}>
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
</Button>
</form>
</>
)}
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
onClick={() => setTab("signup")}
aria-selected={tab === "signup"}
disabled={loading || !registrationAllowed}
>
Sign Up
</button>
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login" ? "Login to your account" : "Create a new account"}
</h2>
</div>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={e => setLocalUsername(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required className="h-11 text-base" value={password} onChange={e => setPassword(e.target.value)} disabled={loading || internalLoggedIn} />
</div>
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold" disabled={loading || internalLoggedIn}>
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
</Button>
</form>
</>
)}
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
</div>
);
</div>
);
}

View File

@@ -69,7 +69,13 @@ const API = axios.create({
baseURL: apiBase,
});
export function HomepageSidebar({onSelectView, getView, 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);
@@ -109,14 +115,16 @@ export function HomepageSidebar({onSelectView, getView, disabled, isAdmin, usern
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem key={"SSH Manager"}>
<SidebarMenuButton onClick={() => onSelectView("ssh_manager")} disabled={disabled}>
<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}>
<SidebarMenuButton onClick={() => onSelectView("terminal")}
disabled={disabled}>
<Computer/>
<span>Terminal</span>
</SidebarMenuButton>

View File

@@ -1,9 +1,9 @@
import React, { useState, useEffect } from "react";
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';
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;
@@ -14,160 +14,285 @@ interface ConfigCodeEditorProps {
export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCodeEditorProps) {
function getLanguageName(filename: string): string {
if (!filename || typeof filename !== 'string') {
return 'text'; // Default to text if no filename
return 'text';
}
const lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex === -1) {
return 'text'; // No extension found
return 'text';
}
const ext = filename.slice(lastDotIndex + 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 '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 '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 '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 '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 '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 'htm':
return 'html';
case 'http':
return 'http';
case 'idl':
return 'idl';
case 'java':
return 'java';
case 'js':
case 'mjs':
case 'cjs': return 'javascript';
case 'cjs':
return 'javascript';
case 'jinja2':
case 'j2': return 'jinja2';
case 'json': return 'json';
case 'jsx': return 'jsx';
case 'jl': return 'julia';
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 '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 '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 '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 '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 '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 '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 '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 '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 'xquery':
return 'xQuery';
case 'xml':
return 'xml';
case 'yacas':
return 'yacas';
case 'yaml':
case 'yml': return 'yaml';
case 'z80': return 'z80';
default: return 'text';
case 'yml':
return 'yaml';
case 'z80':
return 'z80';
default:
return 'text';
}
}
@@ -179,7 +304,14 @@ export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCod
}, []);
return (
<div style={{ width: '100%', height: '100%', position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
<div
style={{
width: '100%',
@@ -209,8 +341,8 @@ export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCod
onChange={(value: any) => onContentChange(value)}
theme={undefined}
height="100%"
basicSetup={{ lineNumbers: true }}
style={{ minHeight: '100%', minWidth: '100%', flex: 1 }}
basicSetup={{lineNumbers: true}}
style={{minHeight: '100%', minWidth: '100%', flex: 1}}
/>
</div>
</div>

View File

@@ -1,11 +1,11 @@
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 { Button } from '@/components/ui/button.tsx';
import { ConfigTopbar } from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
import { cn } from '@/lib/utils.ts';
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 {Button} from '@/components/ui/button.tsx';
import {ConfigTopbar} from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
import {cn} from '@/lib/utils.ts';
import {
getConfigEditorRecent,
getConfigEditorPinned,
@@ -20,7 +20,7 @@ import {
writeSSHFile,
getSSHStatus,
connectSSH
} from '@/apps/SSH/ssh-axios-fixed.ts';
} from '@/apps/SSH/ssh-axios.ts';
interface Tab {
id: string | number;
@@ -59,7 +59,7 @@ interface SSHHost {
updatedAt: string;
}
export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) => void }): React.ReactElement {
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[]>([]);
@@ -71,89 +71,71 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
const sidebarRef = useRef<any>(null);
// Fetch home data when host changes
useEffect(() => {
if (currentHost) {
fetchHomeData();
} else {
// Clear data when no host is selected
setRecent([]);
setPinned([]);
setShortcuts([]);
}
}, [currentHost]);
// Refresh home data when switching to home view
useEffect(() => {
if (activeTab === 'home' && currentHost) {
fetchHomeData();
}
}, [activeTab, currentHost]);
// Periodic refresh of home data when on home view
useEffect(() => {
if (activeTab === 'home' && currentHost) {
const interval = setInterval(() => {
fetchHomeData();
}, 2000); // Refresh every 2 seconds when on home view
}, 2000);
return () => clearInterval(interval);
}
}, [activeTab, currentHost]);
async function fetchHomeData() {
if (!currentHost) return;
try {
console.log('Fetching home data for host:', currentHost.id);
const homeDataPromise = Promise.all([
getConfigEditorRecent(currentHost.id),
getConfigEditorPinned(currentHost.id),
getConfigEditorShortcuts(currentHost.id),
]);
const timeoutPromise = new Promise((_, reject) =>
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Fetch home data timed out')), 15000)
);
const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]);
console.log('Home data fetched successfully:', {
recentCount: recentRes?.length || 0,
pinnedCount: pinnedRes?.length || 0,
shortcutsCount: shortcutsRes?.length || 0
});
// Process recent files to add isPinned property and type
const recentWithPinnedStatus = (recentRes || []).map(file => ({
...file,
type: 'file', // Assume all recent files are files, not directories
isPinned: (pinnedRes || []).some(pinnedFile =>
type: 'file',
isPinned: (pinnedRes || []).some(pinnedFile =>
pinnedFile.path === file.path && pinnedFile.name === file.name
)
}));
// Process pinned files to add type
const pinnedWithType = (pinnedRes || []).map(file => ({
...file,
type: 'file' // Assume all pinned files are files, not directories
type: 'file'
}));
setRecent(recentWithPinnedStatus);
setPinned(pinnedWithType);
setShortcuts((shortcutsRes || []).map(shortcut => ({
...shortcut,
type: 'directory' // Shortcuts are always directories
type: 'directory'
})));
} catch (err: any) {
console.error('Failed to fetch home data:', err);
}
}
// Helper function for consistent error handling
const formatErrorMessage = (err: any, defaultMessage: string): string => {
if (typeof err === 'object' && err !== null && 'response' in err) {
const axiosErr = err as any;
@@ -175,35 +157,41 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
}
};
// Home view actions
const handleOpenFile = async (file: any) => {
const tabId = file.path;
console.log('Opening file:', { file, currentHost, tabId });
if (!tabs.find(t => t.id === tabId)) {
// Use the current host's SSH session ID instead of the stored one
const currentSshSessionId = currentHost?.id.toString();
console.log('Using SSH session ID:', currentSshSessionId, 'for file path:', file.path);
setTabs([...tabs, { id: tabId, title: file.name, fileName: file.name, content: '', filePath: file.path, isSSH: true, sshSessionId: currentSshSessionId, loading: true }]);
setTabs([...tabs, {
id: tabId,
title: file.name,
fileName: file.name,
content: '',
filePath: file.path,
isSSH: true,
sshSessionId: currentSshSessionId,
loading: true
}]);
try {
const res = await readSSHFile(currentSshSessionId, file.path);
console.log('File read successful:', { path: file.path, contentLength: res.content?.length });
setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, content: res.content, loading: false, error: undefined } : t));
// Mark as recent
await addConfigEditorRecent({
name: file.name,
path: file.path,
isSSH: true,
setTabs(tabs => tabs.map(t => t.id === tabId ? {
...t,
content: res.content,
loading: false,
error: undefined
} : t));
await addConfigEditorRecent({
name: file.name,
path: file.path,
isSSH: true,
sshSessionId: currentSshSessionId,
hostId: currentHost?.id
hostId: currentHost?.id
});
// Refresh immediately after opening file
fetchHomeData();
} catch (err: any) {
console.error('Failed to read file:', { path: file.path, sessionId: currentSshSessionId, error: err });
const errorMessage = formatErrorMessage(err, 'Cannot read file');
setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, loading: false, error: errorMessage } : t));
setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false, error: errorMessage} : t));
}
}
setActiveTab(tabId);
@@ -211,128 +199,103 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
const handleRemoveRecent = async (file: any) => {
try {
await removeConfigEditorRecent({
name: file.name,
path: file.path,
isSSH: true,
await removeConfigEditorRecent({
name: file.name,
path: file.path,
isSSH: true,
sshSessionId: file.sshSessionId,
hostId: currentHost?.id
hostId: currentHost?.id
});
// Refresh immediately after removing
fetchHomeData();
} catch (err) {
console.error('Failed to remove recent file:', err);
}
};
const handlePinFile = async (file: any) => {
try {
await addConfigEditorPinned({
name: file.name,
path: file.path,
isSSH: true,
await addConfigEditorPinned({
name: file.name,
path: file.path,
isSSH: true,
sshSessionId: file.sshSessionId,
hostId: currentHost?.id
hostId: currentHost?.id
});
// Refresh immediately after pinning
fetchHomeData();
// Refresh sidebar files to update pin states immediately
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
sidebarRef.current.fetchFiles();
}
} catch (err) {
console.error('Failed to pin file:', err);
}
};
const handleUnpinFile = async (file: any) => {
try {
await removeConfigEditorPinned({
name: file.name,
path: file.path,
isSSH: true,
await removeConfigEditorPinned({
name: file.name,
path: file.path,
isSSH: true,
sshSessionId: file.sshSessionId,
hostId: currentHost?.id
hostId: currentHost?.id
});
// Refresh immediately after unpinning
fetchHomeData();
// Refresh sidebar files to update pin states immediately
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
sidebarRef.current.fetchFiles();
}
} catch (err) {
console.error('Failed to unpin file:', err);
}
};
const handleOpenShortcut = async (shortcut: any) => {
console.log('Opening shortcut:', { shortcut, currentHost });
// Prevent multiple rapid clicks
if (sidebarRef.current?.isOpeningShortcut) {
console.log('Shortcut opening already in progress, ignoring click');
return;
}
if (sidebarRef.current && sidebarRef.current.openFolder) {
try {
// Set flag to prevent multiple simultaneous opens
sidebarRef.current.isOpeningShortcut = true;
// Normalize the path to ensure it starts with /
const normalizedPath = shortcut.path.startsWith('/') ? shortcut.path : `/${shortcut.path}`;
console.log('Normalized path:', normalizedPath);
await sidebarRef.current.openFolder(currentHost, normalizedPath);
console.log('Shortcut opened successfully');
} catch (err) {
console.error('Failed to open shortcut:', err);
// Could show error to user here if needed
} finally {
// Clear flag after operation completes
if (sidebarRef.current) {
sidebarRef.current.isOpeningShortcut = false;
}
}
} else {
console.error('Sidebar ref or openFolder function not available');
}
};
const handleAddShortcut = async (folderPath: string) => {
try {
const name = folderPath.split('/').pop() || folderPath;
await addConfigEditorShortcut({
name,
path: folderPath,
isSSH: true,
await addConfigEditorShortcut({
name,
path: folderPath,
isSSH: true,
sshSessionId: currentHost?.id.toString(),
hostId: currentHost?.id
hostId: currentHost?.id
});
// Refresh immediately after adding shortcut
fetchHomeData();
} catch (err) {
console.error('Failed to add shortcut:', err);
}
};
const handleRemoveShortcut = async (shortcut: any) => {
try {
await removeConfigEditorShortcut({
name: shortcut.name,
path: shortcut.path,
isSSH: true,
await removeConfigEditorShortcut({
name: shortcut.name,
path: shortcut.path,
isSSH: true,
sshSessionId: currentHost?.id.toString(),
hostId: currentHost?.id
hostId: currentHost?.id
});
// Refresh immediately after removing shortcut
fetchHomeData();
} catch (err) {
console.error('Failed to remove shortcut:', err);
}
};
// Tab actions
const closeTab = (tabId: string | number) => {
const idx = tabs.findIndex(t => t.id === tabId);
const newTabs = tabs.filter(t => t.id !== tabId);
@@ -341,59 +304,50 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
if (newTabs.length > 0) setActiveTab(newTabs[Math.max(0, idx - 1)].id);
else setActiveTab('home');
}
// Refresh home data when closing tabs to update recent list
if (currentHost) {
fetchHomeData();
}
};
const setTabContent = (tabId: string | number, content: string) => {
setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, content, dirty: true, error: undefined, success: undefined } : t));
setTabs(tabs => tabs.map(t => t.id === tabId ? {
...t,
content,
dirty: true,
error: undefined,
success: undefined
} : t));
};
const handleSave = async (tab: Tab) => {
// Prevent multiple simultaneous saves
if (isSaving) {
console.log('Save already in progress, ignoring save request');
return;
}
setIsSaving(true);
try {
console.log('Saving file:', {
tabId: tab.id,
fileName: tab.fileName,
filePath: tab.filePath,
sshSessionId: tab.sshSessionId,
contentLength: tab.content?.length,
currentHost: currentHost?.id
});
if (!tab.sshSessionId) {
throw new Error('No SSH session ID available');
}
if (!tab.filePath) {
throw new Error('No file path available');
}
if (!currentHost?.id) {
throw new Error('No current host available');
}
// Check SSH connection status first with timeout
try {
const statusPromise = getSSHStatus(tab.sshSessionId);
const statusTimeoutPromise = new Promise((_, reject) =>
const statusTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('SSH status check timed out')), 10000)
);
const status = await Promise.race([statusPromise, statusTimeoutPromise]);
if (!status.connected) {
console.log('SSH session disconnected, attempting to reconnect...');
// Try to reconnect using current host credentials with timeout
const connectPromise = connectSSH(tab.sshSessionId, {
ip: currentHost.ip,
port: currentHost.port,
@@ -402,119 +356,107 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
sshKey: currentHost.key,
keyPassword: currentHost.keyPassword
});
const connectTimeoutPromise = new Promise((_, reject) =>
const connectTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('SSH reconnection timed out')), 15000)
);
await Promise.race([connectPromise, connectTimeoutPromise]);
console.log('SSH reconnection successful');
}
} catch (statusErr) {
console.warn('Could not check SSH status or reconnect, proceeding with save attempt:', statusErr);
}
// Add timeout to prevent hanging
console.log('Starting save operation with 30 second timeout...');
const savePromise = writeSSHFile(tab.sshSessionId, tab.filePath, tab.content);
const timeoutPromise = new Promise((_, reject) =>
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => {
console.log('Save operation timed out after 30 seconds');
reject(new Error('Save operation timed out'));
}, 30000)
);
const result = await Promise.race([savePromise, timeoutPromise]);
console.log('Save operation completed successfully:', result);
setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, dirty: false, success: 'File saved successfully' } : t));
console.log('File saved successfully - main save operation complete');
// Auto-hide success message after 3 seconds
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t,
dirty: false,
success: 'File saved successfully'
} : t));
setTimeout(() => {
setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, success: undefined } : t));
setTabs(tabs => tabs.map(t => t.id === tab.id ? {...t, success: undefined} : t));
}, 3000);
// Mark as recent and refresh home data in background (non-blocking)
Promise.allSettled([
(async () => {
try {
console.log('Adding file to recent...');
await addConfigEditorRecent({
name: tab.fileName,
path: tab.filePath,
isSSH: true,
await addConfigEditorRecent({
name: tab.fileName,
path: tab.filePath,
isSSH: true,
sshSessionId: tab.sshSessionId,
hostId: currentHost.id
hostId: currentHost.id
});
console.log('File added to recent successfully');
} catch (recentErr) {
console.warn('Failed to add file to recent:', recentErr);
}
})(),
(async () => {
try {
console.log('Refreshing home data...');
await fetchHomeData();
console.log('Home data refreshed successfully');
} catch (refreshErr) {
console.warn('Failed to refresh home data:', refreshErr);
}
})()
]).then(() => {
console.log('Background operations completed');
});
console.log('File saved successfully - main operation complete, background operations started');
} catch (err) {
console.error('Failed to save file:', err);
let errorMessage = formatErrorMessage(err, 'Cannot save file');
// Check if this is a timeout error (which might mean the save actually worked)
if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) {
errorMessage = `Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.`;
}
console.log('Final error message:', errorMessage);
setTabs(tabs => {
const updatedTabs = tabs.map(t => t.id === tab.id ? { ...t, error: `Failed to save file: ${errorMessage}` } : t);
console.log('Updated tabs with error:', updatedTabs.find(t => t.id === tab.id));
const updatedTabs = tabs.map(t => t.id === tab.id ? {
...t,
error: `Failed to save file: ${errorMessage}`
} : t);
return updatedTabs;
});
// Force a re-render to ensure error is displayed
setTimeout(() => {
console.log('Forcing re-render to show error');
setTabs(currentTabs => [...currentTabs]);
}, 100);
} finally {
console.log('Save operation completed, setting isSaving to false');
setIsSaving(false);
console.log('isSaving state after setting to false:', false);
}
};
const handleHostChange = (host: SSHHost | null) => {
setCurrentHost(host);
// Close all tabs when switching hosts
setTabs([]);
setActiveTab('home');
};
// Show connection message when no host is selected
if (!currentHost) {
return (
<div style={{ position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden' }}>
<div style={{ position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20 }}>
<ConfigEditorSidebar
onSelectView={onSelectView}
onOpenFile={handleOpenFile}
tabs={tabs}
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
<ConfigEditorSidebar
onSelectView={onSelectView}
onOpenFile={handleOpenFile}
tabs={tabs}
ref={sidebarRef}
onHostChange={handleHostChange}
/>
</div>
<div style={{ position: 'absolute', top: 0, left: 256, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#09090b' }}>
<div style={{
position: 'absolute',
top: 0,
left: 256,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#09090b'
}}>
<div className="text-center">
<h2 className="text-xl font-semibold text-white mb-2">Connect to a Server</h2>
<p className="text-muted-foreground">Select a server from the sidebar to start editing files</p>
@@ -525,29 +467,31 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
}
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}
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
<ConfigEditorSidebar
onSelectView={onSelectView}
onOpenFile={handleOpenFile}
tabs={tabs}
ref={sidebarRef}
onHostChange={handleHostChange}
/>
</div>
<div style={{ position: 'absolute', top: 0, left: 256, right: 0, height: 44, zIndex: 30 }}>
<div className="flex items-center w-full bg-[#18181b] border-b border-[#222224] h-11 relative px-4" style={{ height: 44 }}>
<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 px-4"
style={{height: 44}}>
{/* Tab list scrollable area */}
<div className="flex-1 min-w-0 h-full flex items-center">
<div className="h-9 w-full bg-[#09090b] border border-[#23232a] rounded-md flex items-center overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent" style={{ minWidth: 0 }}>
<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 }))}
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
activeTab={activeTab}
setActiveTab={setActiveTab}
closeTab={closeTab}
onHomeClick={() => {
setActiveTab('home');
// Immediately refresh home data when clicking home
if (currentHost) {
fetchHomeData();
}
@@ -568,13 +512,24 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
if (tab && !isSaving) handleSave(tab);
}}
type="button"
style={{ height: 36, alignSelf: 'center' }}
style={{height: 36, alignSelf: 'center'}}
>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
<div style={{ position: 'absolute', top: 44, left: 256, right: 0, bottom: 0, overflow: 'hidden', zIndex: 10, background: '#101014', display: 'flex', flexDirection: 'column' }}>
<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}
@@ -593,17 +548,21 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
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 flex-col h-full" style={{flex: 1, minHeight: 0}}>
{/* Error display */}
{tab.error && (
<div className="bg-red-900/20 border border-red-500/30 text-red-300 px-4 py-3 text-sm">
<div
className="bg-red-900/20 border border-red-500/30 text-red-300 px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-red-400"></span>
<span>{tab.error}</span>
</div>
<button
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, error: undefined } : t))}
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t,
error: undefined
} : t))}
className="text-red-400 hover:text-red-300 transition-colors"
>
@@ -613,14 +572,18 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
)}
{/* Success display */}
{tab.success && (
<div className="bg-green-900/20 border border-green-500/30 text-green-300 px-4 py-3 text-sm">
<div
className="bg-green-900/20 border border-green-500/30 text-green-300 px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-green-400"></span>
<span>{tab.success}</span>
</div>
<button
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, success: undefined } : t))}
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t,
success: undefined
} : t))}
className="text-green-400 hover:text-green-300 transition-colors"
>

View File

@@ -10,19 +10,19 @@ import {
import {Separator} from '@/components/ui/separator.tsx';
import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react';
import {ScrollArea} from '@/components/ui/scroll-area.tsx';
import { cn } from '@/lib/utils.ts';
import {cn} from '@/lib/utils.ts';
import {Input} from '@/components/ui/input.tsx';
import {Button} from '@/components/ui/button.tsx';
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx';
import {
getSSHHosts,
listSSHFiles,
connectSSH,
import {
getSSHHosts,
listSSHFiles,
connectSSH,
getSSHStatus,
getConfigEditorPinned,
addConfigEditorPinned,
removeConfigEditorPinned
} from '@/apps/SSH/ssh-axios-fixed.ts';
} from '@/apps/SSH/ssh-axios.ts';
interface SSHHost {
id: number;
@@ -48,9 +48,9 @@ interface SSHHost {
}
const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
{ onSelectView, onOpenFile, tabs, onHostChange }: {
onSelectView: (view: string) => void;
onOpenFile: (file: any) => void;
{onSelectView, onOpenFile, tabs, onHostChange}: {
onSelectView: (view: string) => void;
onOpenFile: (file: any) => void;
tabs: any[];
onHostChange?: (host: SSHHost | null) => void;
},
@@ -64,8 +64,7 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
const [currentPath, setCurrentPath] = useState('/');
const [files, setFiles] = useState<any[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null);
// Add search bar state
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [fileSearch, setFileSearch] = useState('');
@@ -79,12 +78,14 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
return () => clearTimeout(handler);
}, [fileSearch]);
// Add state for SSH sessionId and loading/error
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
const [filesLoading, setFilesLoading] = useState(false);
const [filesError, setFilesError] = useState<string | null>(null);
const [connectingSSH, setConnectingSSH] = useState(false);
const [connectionCache, setConnectionCache] = useState<Record<string, { sessionId: string; timestamp: number }>>({});
const [connectionCache, setConnectionCache] = useState<Record<string, {
sessionId: string;
timestamp: number
}>>({});
const [fetchingFiles, setFetchingFiles] = useState(false);
useEffect(() => {
@@ -96,76 +97,41 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
setErrorSSH(undefined);
try {
const hosts = await getSSHHosts();
console.log('Loaded SSH hosts:', hosts);
// Filter hosts to only show those with enableConfigEditor: true
const configEditorHosts = hosts.filter(host => host.enableConfigEditor);
console.log('Config Editor hosts:', configEditorHosts);
// Debug: Log the first host's credentials
if (configEditorHosts.length > 0) {
const firstHost = configEditorHosts[0];
console.log('First host credentials:', {
id: firstHost.id,
name: firstHost.name,
ip: firstHost.ip,
username: firstHost.username,
authType: firstHost.authType,
hasPassword: !!firstHost.password,
hasKey: !!firstHost.key,
passwordLength: firstHost.password?.length,
keyLength: firstHost.key?.length
});
}
setSSHConnections(configEditorHosts);
} catch (err: any) {
console.error('Failed to load SSH hosts:', err);
setErrorSSH('Failed to load SSH connections');
} finally {
setLoadingSSH(false);
}
}
// Helper to connect to SSH and set sessionId
async function connectToSSH(server: SSHHost): Promise<string | null> {
const sessionId = server.id.toString();
// Check if we already have a recent connection to this server
const cached = connectionCache[sessionId];
if (cached && Date.now() - cached.timestamp < 30000) { // 30 second cache
console.log('Using cached SSH connection for session:', sessionId);
if (cached && Date.now() - cached.timestamp < 30000) {
setSshSessionId(cached.sessionId);
return cached.sessionId;
}
// Prevent multiple simultaneous connections
if (connectingSSH) {
console.log('SSH connection already in progress, skipping...');
return null;
}
setConnectingSSH(true);
try {
console.log('Attempting SSH connection:', {
sessionId,
ip: server.ip,
port: server.port,
username: server.username,
hasPassword: !!server.password,
hasKey: !!server.key,
authType: server.authType,
passwordLength: server.password?.length,
keyLength: server.key?.length
});
// Check if we have the necessary credentials
if (!server.password && !server.key) {
console.error('No authentication credentials available for SSH host');
setFilesError('No authentication credentials available for this SSH host');
return null;
}
const connectionConfig = {
ip: server.ip,
port: server.port,
@@ -174,32 +140,18 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
sshKey: server.key,
keyPassword: server.keyPassword,
};
console.log('SSH connection config:', {
...connectionConfig,
password: connectionConfig.password ? '[REDACTED]' : undefined,
sshKey: connectionConfig.sshKey ? '[REDACTED]' : undefined
});
await connectSSH(sessionId, connectionConfig);
console.log('SSH connection successful for session:', sessionId);
setSshSessionId(sessionId);
// Cache the successful connection
setConnectionCache(prev => ({
...prev,
[sessionId]: { sessionId, timestamp: Date.now() }
[sessionId]: {sessionId, timestamp: Date.now()}
}));
return sessionId;
} catch (err: any) {
console.error('SSH connection failed:', {
sessionId,
error: err?.response?.data?.error || err?.message,
status: err?.response?.status,
data: err?.response?.data
});
setFilesError(err?.response?.data?.error || 'Failed to connect to SSH');
setSshSessionId(null);
return null;
@@ -208,72 +160,51 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
}
}
// Modified fetchFiles to handle SSH connect if needed
async function fetchFiles() {
// Prevent multiple simultaneous fetches
if (fetchingFiles) {
console.log('Already fetching files, skipping...');
return;
}
setFetchingFiles(true);
setFiles([]);
setFilesLoading(true);
setFilesError(null);
try {
// Get pinned files to check against for current host
let pinnedFiles: any[] = [];
try {
if (activeServer) {
pinnedFiles = await getConfigEditorPinned(activeServer.id);
console.log('Fetched pinned files:', pinnedFiles);
}
} catch (err) {
console.error('Failed to fetch pinned files:', err);
}
if (activeServer && sshSessionId) {
console.log('Fetching files for path:', currentPath, 'sessionId:', sshSessionId);
let res: any[] = [];
// Check if SSH session is still valid
try {
const status = await getSSHStatus(sshSessionId);
console.log('SSH session status:', status);
if (!status.connected) {
console.log('SSH session not connected, reconnecting...');
const newSessionId = await connectToSSH(activeServer);
if (newSessionId) {
setSshSessionId(newSessionId);
// Retry with new session
res = await listSSHFiles(newSessionId, currentPath);
console.log('Retry - Raw SSH files response:', res);
console.log('Retry - Files count:', res?.length || 0);
} else {
throw new Error('Failed to reconnect SSH session');
}
} else {
res = await listSSHFiles(sshSessionId, currentPath);
console.log('Raw SSH files response:', res);
console.log('Files count:', res?.length || 0);
console.log('Response type:', typeof res, 'Is array:', Array.isArray(res));
}
} catch (sessionErr) {
console.error('SSH session check failed:', sessionErr);
// Try to reconnect and retry
const newSessionId = await connectToSSH(activeServer);
if (newSessionId) {
setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath);
console.log('Reconnect - Raw SSH files response:', res);
console.log('Reconnect - Files count:', res?.length || 0);
} else {
throw sessionErr;
}
}
const processedFiles = (res || []).map((f: any) => {
const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name;
const isPinned = pinnedFiles.some(pinned => pinned.path === filePath);
@@ -285,18 +216,10 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
sshSessionId: sshSessionId
};
});
console.log('Processed files with pin states:', processedFiles);
setFiles(processedFiles);
}
} catch (err: any) {
console.error('Error in fetchFiles:', err);
console.error('Error details:', {
message: err?.message,
response: err?.response?.data,
status: err?.response?.status,
statusText: err?.response?.statusText
});
setFiles([]);
setFilesError(err?.response?.data?.error || err?.message || 'Failed to list files');
} finally {
@@ -305,50 +228,37 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
}
}
// When activeServer, currentPath, or sshSessionId changes, fetch files
useEffect(() => {
console.log('useEffect triggered:', { view, activeServer: !!activeServer, sshSessionId, currentPath });
// Only fetch files if we're in files view, have an active server, and a valid session
if (view === 'files' && activeServer && sshSessionId && !connectingSSH && !fetchingFiles) {
console.log('Calling fetchFiles...');
// Add a small delay to prevent rapid reconnections
const timeoutId = setTimeout(() => {
fetchFiles();
}, 100);
return () => clearTimeout(timeoutId);
}
// eslint-disable-next-line
}, [currentPath, view, activeServer, sshSessionId]);
// When switching servers, reset sessionId and errors
async function handleSelectServer(server: SSHHost) {
// Prevent multiple rapid server selections
if (connectingSSH) {
console.log('SSH connection in progress, ignoring server selection');
return;
}
// Reset all states when switching servers
setFetchingFiles(false);
setFilesLoading(false);
setFilesError(null);
setFiles([]); // Clear files immediately to show loading state
setFiles([]);
setActiveServer(server);
setCurrentPath(server.defaultPath || '/');
setView('files');
// Establish SSH connection immediately when server is selected
const sessionId = await connectToSSH(server);
if (sessionId) {
setSshSessionId(sessionId);
// Notify parent component about host change
if (onHostChange) {
onHostChange(server);
}
} else {
// If SSH connection fails, stay in servers view
w
setView('servers');
setActiveServer(null);
}
@@ -356,50 +266,36 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
useImperativeHandle(ref, () => ({
openFolder: async (server: SSHHost, path: string) => {
console.log('openFolder called:', { serverId: server.id, path, currentPath, activeServerId: activeServer?.id });
// Prevent multiple simultaneous folder opens
if (connectingSSH || fetchingFiles) {
console.log('SSH connection or file fetch in progress, skipping folder open');
return;
}
// If we're already on the same server and path, just refresh files
if (activeServer?.id === server.id && currentPath === path) {
console.log('Already on same server and path, just refreshing files');
// Add a small delay to prevent rapid successive calls
setTimeout(() => fetchFiles(), 100);
return;
}
// Reset all states when opening a folder
setFetchingFiles(false);
setFilesLoading(false);
setFilesError(null);
setFiles([]);
setActiveServer(server);
setCurrentPath(path);
setView('files');
// Only establish SSH connection if we don't already have one for this server
if (!sshSessionId || activeServer?.id !== server.id) {
console.log('Establishing new SSH connection for server:', server.id);
const sessionId = await connectToSSH(server);
if (sessionId) {
setSshSessionId(sessionId);
// Only notify parent component about host change if the server actually changed
if (onHostChange && activeServer?.id !== server.id) {
onHostChange(server);
}
} else {
// If SSH connection fails, stay in servers view
setView('servers');
setActiveServer(null);
}
} else {
console.log('Using existing SSH session for server:', server.id);
// Only notify parent component about host change if the server actually changed
if (onHostChange && activeServer?.id !== server.id) {
onHostChange(server);
}
@@ -412,51 +308,46 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
}
}));
// Path input focus scroll
useEffect(() => {
if (pathInputRef.current) {
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
}
}, [currentPath]);
// Group SSH connections by folder
const sshByFolder: Record<string, SSHHost[]> = {};
sshConnections.forEach(conn => {
const folder = conn.folder && conn.folder.trim() ? conn.folder : 'No Folder';
if (!sshByFolder[folder]) sshByFolder[folder] = [];
sshByFolder[folder].push(conn);
});
// Move 'No Folder' to the top
const sortedFolders = Object.keys(sshByFolder);
if (sortedFolders.includes('No Folder')) {
sortedFolders.splice(sortedFolders.indexOf('No Folder'), 1);
sortedFolders.unshift('No Folder');
}
// Filter hosts by search
const filteredSshByFolder: Record<string, SSHHost[]> = {};
Object.entries(sshByFolder).forEach(([folder, hosts]) => {
filteredSshByFolder[folder] = hosts.filter(conn => {
const q = debouncedSearch.trim().toLowerCase();
if (!q) return true;
return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) ||
(conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) ||
(conn.tags || []).join(' ').toLowerCase().includes(q);
(conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) ||
(conn.tags || []).join(' ').toLowerCase().includes(q);
});
});
// Filter files by search
const filteredFiles = files.filter(file => {
const q = debouncedFileSearch.trim().toLowerCase();
if (!q) return true;
return file.name.toLowerCase().includes(q);
});
// --- Render ---
return (
<SidebarProvider>
<Sidebar style={{ height: '100vh', maxHeight: '100vh', overflow: 'hidden' }}>
<SidebarContent style={{ height: '100vh', maxHeight: '100vh', overflow: 'hidden' }}>
<Sidebar style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}>
<SidebarContent style={{height: '100vh', maxHeight: '100vh', 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">
Termix / Config
@@ -473,12 +364,12 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
</SidebarMenu>
{/* Main black div: servers list or file/folder browser */}
<div className="flex-1 w-full flex flex-col rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 relative min-h-0 mt-1">
<div
className="flex-1 w-full flex flex-col rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 relative min-h-0 mt-1">
{view === 'servers' && (
<>
{/* Search bar - outside ScrollArea so it's always visible */}
<div className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10 border-b border-[#23232a]">
<div
className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10 border-b border-[#23232a]">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
@@ -487,21 +378,23 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
autoComplete="off"
/>
</div>
<ScrollArea className="flex-1 w-full h-full" style={{ height: '100%', maxHeight: '100%' }}>
<ScrollArea className="flex-1 w-full h-full"
style={{height: '100%', maxHeight: '100%'}}>
<div className="flex flex-col h-full">
{/* SSH hosts/folders section */}
<div className="w-full flex-grow overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Separator className="w-full h-px bg-[#434345] my-2" style={{ maxWidth: 213, margin: '0 auto' }} />
<div
className="w-full flex-grow overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
<div style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="w-full h-px bg-[#434345] my-2"
style={{maxWidth: 213, margin: '0 auto'}}/>
</div>
{/* Host list */}
<div className="mx-auto" style={{maxWidth: '213px', width: '100%'}}>
{/* Accordion for folders/hosts */}
<div className="flex-1 min-h-0">
<Accordion type="multiple" className="w-full" value={sortedFolders}>
<Accordion type="multiple" className="w-full"
value={sortedFolders}>
{sortedFolders.map((folder, idx) => (
<React.Fragment key={folder}>
<AccordionItem value={folder} className="mt-0 w-full !border-b-transparent">
<AccordionItem value={folder}
className="mt-0 w-full !border-b-transparent">
<AccordionTrigger
className="text-base font-semibold rounded-t-none py-2 w-full">{folder}</AccordionTrigger>
<AccordionContent
@@ -513,18 +406,25 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
className="w-full h-10 px-2 bg-[#18181b] border border-[#434345] hover:bg-[#2d2d30] transition-colors text-left justify-start"
onClick={() => handleSelectServer(conn)}
>
<div className="flex items-center w-full">
<div
className="flex items-center w-full">
{conn.pin && <Pin
className="w-0.5 h-0.5 text-yellow-400 mr-1 flex-shrink-0" />}
<span className="font-medium truncate">{conn.name || conn.ip}</span>
className="w-0.5 h-0.5 text-yellow-400 mr-1 flex-shrink-0"/>}
<span
className="font-medium truncate">{conn.name || conn.ip}</span>
</div>
</Button>
))}
</AccordionContent>
</AccordionItem>
{idx < sortedFolders.length - 1 && (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Separator className="h-px bg-[#434345] my-1" style={{ width: 213 }} />
<div style={{
display: 'flex',
justifyContent: 'center'
}}>
<Separator
className="h-px bg-[#434345] my-1"
style={{width: 213}}/>
</div>
)}
</React.Fragment>
@@ -538,18 +438,17 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
</>
)}
{view === 'files' && activeServer && (
<div className="flex flex-col h-full w-full" style={{ maxWidth: 260 }}>
{/* Sticky path input bar - outside ScrollArea */}
<div className="flex items-center gap-2 px-2 py-2 border-b border-[#23232a] bg-[#18181b] z-20" style={{ maxWidth: 260 }}>
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
<div
className="flex items-center gap-2 px-2 py-2 border-b border-[#23232a] bg-[#18181b] z-20"
style={{maxWidth: 260}}>
<Button
size="icon"
variant="outline"
className="h-8 w-8 bg-[#18181b] border border-[#23232a] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring"
onClick={() => {
// If not at root, go up one directory; else, go back to servers view
let path = currentPath;
if (path && path !== '/' && path !== '') {
// Remove trailing slash if present
if (path.endsWith('/')) path = path.slice(0, -1);
const lastSlash = path.lastIndexOf('/');
if (lastSlash > 0) {
@@ -572,7 +471,6 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
className="flex-1 bg-[#18181b] border border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]"
/>
</div>
{/* File search bar */}
<div className="px-2 py-2 border-b border-[#23232a] bg-[#18181b]">
<Input
placeholder="Search files and folders..."
@@ -582,16 +480,22 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
onChange={e => setFileSearch(e.target.value)}
/>
</div>
{/* File list with proper scroll area - separate from topbar */}
<div className="flex-1 w-full h-full bg-[#09090b] border-t border-[#23232a]">
<ScrollArea className="w-full h-full bg-[#09090b]" style={{ height: '100%', maxHeight: '100%', paddingRight: 8, scrollbarGutter: 'stable', background: '#09090b' }}>
<ScrollArea className="w-full h-full bg-[#09090b]" style={{
height: '100%',
maxHeight: '100%',
paddingRight: 8,
scrollbarGutter: 'stable',
background: '#09090b'
}}>
<div className="p-2 pr-2">
{connectingSSH || filesLoading ? (
<div className="text-xs text-muted-foreground">Loading...</div>
) : filesError ? (
<div className="text-xs text-red-500">{filesError}</div>
) : filteredFiles.length === 0 ? (
<div className="text-xs text-muted-foreground">No files or folders found.</div>
<div className="text-xs text-muted-foreground">No files or
folders found.</div>
) : (
<div className="flex flex-col gap-1">
{filteredFiles.map((item: any) => {
@@ -603,21 +507,24 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
"flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded group max-w-full",
isOpen && "opacity-60 cursor-not-allowed pointer-events-none"
)}
style={{ maxWidth: 220, marginBottom: 8 }}
style={{maxWidth: 220, marginBottom: 8}}
>
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => !isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile({
name: item.name,
path: item.path,
isSSH: item.isSSH,
sshSessionId: item.sshSessionId
}))}
name: item.name,
path: item.path,
isSSH: item.isSSH,
sshSessionId: item.sshSessionId
}))}
>
{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 max-w-[120px]">{item.name}</span>
<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 max-w-[120px]">{item.name}</span>
</div>
<div className="flex items-center gap-1">
{item.type === 'file' && (
@@ -628,28 +535,32 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
e.stopPropagation();
try {
if (item.isPinned) {
await removeConfigEditorPinned({
name: item.name,
await removeConfigEditorPinned({
name: item.name,
path: item.path,
hostId: activeServer?.id,
isSSH: true,
sshSessionId: activeServer?.id.toString()
});
// Update local state without refreshing
setFiles(files.map(f =>
f.path === item.path ? { ...f, isPinned: false } : f
setFiles(files.map(f =>
f.path === item.path ? {
...f,
isPinned: false
} : f
));
} else {
await addConfigEditorPinned({
name: item.name,
await addConfigEditorPinned({
name: item.name,
path: item.path,
hostId: activeServer?.id,
isSSH: true,
sshSessionId: activeServer?.id.toString()
});
// Update local state without refreshing
setFiles(files.map(f =>
f.path === item.path ? { ...f, isPinned: true } : f
setFiles(files.map(f =>
f.path === item.path ? {
...f,
isPinned: true
} : f
));
}
} catch (err) {
@@ -657,7 +568,8 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
}
}}
>
<Pin className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
<Pin
className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
</Button>
)}
</div>
@@ -679,4 +591,4 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
</SidebarProvider>
);
});
export { ConfigEditorSidebar };
export {ConfigEditorSidebar};

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Button } from '@/components/ui/button.tsx';
import { Card } from '@/components/ui/card.tsx';
import { Separator } from '@/components/ui/separator.tsx';
import {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, Pin} from 'lucide-react';
interface SSHConnection {
@@ -42,25 +42,25 @@ interface ConfigFileSidebarViewerProps {
}
export function ConfigFileSidebarViewer({
sshConnections,
onAddSSH,
onConnectSSH,
onEditSSH,
onDeleteSSH,
onPinSSH,
currentPath,
files,
onOpenFile,
onOpenFolder,
onStarFile,
onDeleteFile,
isLoading,
error,
isSSHMode,
onSwitchToLocal,
onSwitchToSSH,
currentSSH,
}: ConfigFileSidebarViewerProps) {
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 */}
@@ -68,7 +68,7 @@ export function ConfigFileSidebarViewer({
<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" />
<Plus className="w-4 h-4"/>
</Button>
</div>
<div className="flex flex-col gap-1">
@@ -77,7 +77,7 @@ export function ConfigFileSidebarViewer({
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
<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">
@@ -86,18 +86,19 @@ export function ConfigFileSidebarViewer({
className="flex-1 justify-start text-left px-2 py-1.5 rounded"
onClick={() => onSwitchToSSH(conn)}
>
<Link2 className="w-4 h-4 mr-2" />
<Link2 className="w-4 h-4 mr-2"/>
{conn.name || conn.ip}
{conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400" />}
{conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400"/>}
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}>
<Pin className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`} />
<Pin
className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}>
<Edit className="w-4 h-4" />
<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" />
<Trash2 className="w-4 h-4 text-red-500"/>
</Button>
</div>
))}
@@ -106,7 +107,8 @@ export function ConfigFileSidebarViewer({
{/* 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-muted-foreground font-semibold">{isSSHMode ? 'SSH Path' : 'Local Path'}</span>
<span className="text-xs text-white truncate">{currentPath}</span>
</div>
{isLoading ? (
@@ -116,22 +118,29 @@ export function ConfigFileSidebarViewer({
) : (
<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" />}
<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)}>
<Pin className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`} />
<Button size="icon" variant="ghost" className="h-7 w-7"
onClick={() => onStarFile(item)}>
<Pin
className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteFile(item)}>
<Trash2 className="w-4 h-4 text-red-500" />
<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>}
{files.length === 0 &&
<div className="text-xs text-muted-foreground">No files or folders found.</div>}
</div>
)}
</div>

View File

@@ -1,11 +1,9 @@
import React from 'react';
import { Card } from '@/components/ui/card.tsx';
import { Button } from '@/components/ui/button.tsx';
import { Trash2, Folder, File, Plus, Pin } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs.tsx';
import { Input } from '@/components/ui/input.tsx';
import { useState } from 'react';
import { cn } from '@/lib/utils.ts';
import {Button} from '@/components/ui/button.tsx';
import {Trash2, Folder, File, Plus, Pin} from 'lucide-react';
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx';
import {Input} from '@/components/ui/input.tsx';
import {useState} from 'react';
interface FileItem {
name: string;
@@ -34,31 +32,31 @@ interface ConfigHomeViewProps {
}
export function ConfigHomeView({
recent,
pinned,
shortcuts,
onOpenFile,
onRemoveRecent,
onPinFile,
onUnpinFile,
onOpenShortcut,
onRemoveShortcut,
onAddShortcut
}: ConfigHomeViewProps) {
recent,
pinned,
shortcuts,
onOpenFile,
onRemoveRecent,
onPinFile,
onUnpinFile,
onOpenShortcut,
onRemoveShortcut,
onAddShortcut
}: ConfigHomeViewProps) {
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
const [newShortcut, setNewShortcut] = useState('');
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
<div key={file.path} className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
<div
<div key={file.path}
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenFile(file)}
>
{file.type === 'directory' ?
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" /> :
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
{file.type === 'directory' ?
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>
}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white break-words leading-tight">
@@ -68,23 +66,24 @@ export function ConfigHomeView({
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{onPin && (
<Button
size="sm"
variant="ghost"
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
onClick={onPin}
>
<Pin className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`} />
<Pin
className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
</Button>
)}
{onRemove && (
<Button
size="sm"
variant="ghost"
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
onClick={onRemove}
>
<Trash2 className="w-3 h-3 text-red-500" />
<Trash2 className="w-3 h-3 text-red-500"/>
</Button>
)}
</div>
@@ -92,12 +91,13 @@ export function ConfigHomeView({
);
const renderShortcutCard = (shortcut: ShortcutItem) => (
<div key={shortcut.path} className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
<div
<div key={shortcut.path}
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenShortcut(shortcut)}
>
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white break-words leading-tight">
{shortcut.path}
@@ -105,13 +105,13 @@ export function ConfigHomeView({
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
size="sm"
variant="ghost"
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
onClick={() => onRemoveShortcut(shortcut)}
>
<Trash2 className="w-3 h-3 text-red-500" />
<Trash2 className="w-3 h-3 text-red-500"/>
</Button>
</div>
</div>
@@ -123,18 +123,19 @@ export function ConfigHomeView({
<TabsList className="mb-4 bg-[#18181b] border border-[#23232a]">
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">Recent</TabsTrigger>
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger>
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder Shortcuts</TabsTrigger>
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder
Shortcuts</TabsTrigger>
</TabsList>
<TabsContent value="recent" className="mt-0">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{recent.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No recent files.</span>
</div>
) : recent.map((file) =>
) : recent.map((file) =>
renderFileCard(
file,
file,
() => onRemoveRecent(file),
() => file.isPinned ? onUnpinFile(file) : onPinFile(file),
file.isPinned
@@ -142,24 +143,24 @@ export function ConfigHomeView({
)}
</div>
</TabsContent>
<TabsContent value="pinned" className="mt-0">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{pinned.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No pinned files.</span>
</div>
) : pinned.map((file) =>
) : pinned.map((file) =>
renderFileCard(
file,
undefined, // No remove function for pinned items
() => onUnpinFile(file), // Use pin function for unpinning
file,
undefined,
() => onUnpinFile(file),
true
)
)}
</div>
</TabsContent>
<TabsContent value="shortcuts" className="mt-0">
<div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border border-[#23232a] rounded-lg">
<Input
@@ -185,7 +186,7 @@ export function ConfigHomeView({
}
}}
>
<Plus className="w-3.5 h-3.5 mr-1" />
<Plus className="w-3.5 h-3.5 mr-1"/>
Add
</Button>
</div>
@@ -194,7 +195,7 @@ export function ConfigHomeView({
<div className="flex items-center justify-center py-4 col-span-full">
<span className="text-sm text-muted-foreground">No shortcuts.</span>
</div>
) : shortcuts.map((shortcut) =>
) : shortcuts.map((shortcut) =>
renderShortcutCard(shortcut)
)}
</div>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Button } from '@/components/ui/button.tsx';
import { X, Home } from 'lucide-react';
import {Button} from '@/components/ui/button.tsx';
import {X, Home} from 'lucide-react';
interface ConfigTab {
id: string | number;
@@ -15,7 +15,7 @@ interface ConfigTabListProps {
onHomeClick: () => void;
}
export function ConfigTabList({ tabs, activeTab, setActiveTab, closeTab, onHomeClick }: ConfigTabListProps) {
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
@@ -23,7 +23,7 @@ export function ConfigTabList({ tabs, activeTab, setActiveTab, closeTab, onHomeC
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" />
<Home className="w-4 h-4"/>
</Button>
{tabs.map((tab, index) => {
const isActive = tab.id === activeTab;
@@ -33,7 +33,6 @@ export function ConfigTabList({ tabs, activeTab, setActiveTab, closeTab, onHomeC
className={index < tabs.length - 1 ? "mr-[0.5rem]" : ""}
>
<div className="inline-flex rounded-md shadow-sm" role="group">
{/* Set Active Tab Button */}
<Button
onClick={() => setActiveTab(tab.id)}
variant="outline"
@@ -42,13 +41,12 @@ export function ConfigTabList({ tabs, activeTab, setActiveTab, closeTab, onHomeC
{tab.title}
</Button>
{/* Close Tab Button */}
<Button
onClick={() => closeTab(tab.id)}
variant="outline"
className="h-7 rounded-l-none p-0 !w-9"
>
<X className="!w-5 !h-5" strokeWidth={2.5} />
<X className="!w-5 !h-5" strokeWidth={2.5}/>
</Button>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
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";
@@ -32,7 +32,7 @@ interface SSHHost {
updatedAt: string;
}
export function SSHManager({ onSelectView }: ConfigEditorProps): React.ReactElement {
export function SSHManager({onSelectView}: ConfigEditorProps): React.ReactElement {
const [activeTab, setActiveTab] = useState("host_viewer");
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
@@ -48,7 +48,6 @@ export function SSHManager({ onSelectView }: ConfigEditorProps): React.ReactElem
const handleTabChange = (value: string) => {
setActiveTab(value);
// Reset editingHost when switching to host_viewer
if (value === "host_viewer") {
setEditingHost(null);
}
@@ -61,10 +60,12 @@ export function SSHManager({ onSelectView }: ConfigEditorProps): React.ReactElem
/>
<div className="flex w-screen h-screen overflow-hidden">
<div className="w-[256px]" />
<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">
<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">
@@ -72,13 +73,13 @@ export function SSHManager({ onSelectView }: ConfigEditorProps): React.ReactElem
</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" />
<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" />
<Separator className="p-0.25 mt-1 mb-1"/>
<div className="flex flex-col h-full min-h-0">
<SSHManagerHostEditor
<SSHManagerHostEditor
editingHost={editingHost}
onFormSubmit={handleFormSubmit}
/>

View File

@@ -19,7 +19,7 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx
import React, {useEffect, useRef, useState} from "react";
import {Switch} from "@/components/ui/switch.tsx";
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
import { createSSHHost, updateSSHHost, getSSHHosts } from '@/apps/SSH/ssh-axios-fixed';
import {createSSHHost, updateSSHHost, getSSHHosts} from '@/apps/SSH/ssh-axios';
interface SSHHost {
id: number;
@@ -49,17 +49,14 @@ interface SSHManagerHostEditorProps {
onFormSubmit?: () => void;
}
export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHostEditorProps) {
// State for dynamic data
export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
// State for authentication tab selection
const [authTab, setAuthTab] = useState<'password' | 'key'>('password');
// Fetch hosts and extract folders and configurations
useEffect(() => {
const fetchData = async () => {
try {
@@ -67,14 +64,12 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
const hostsData = await getSSHHosts();
setHosts(hostsData);
// Extract unique folders (excluding empty ones)
const uniqueFolders = [...new Set(
hostsData
.filter(host => host.folder && host.folder.trim() !== '')
.map(host => host.folder)
)].sort();
// Extract unique host names for SSH configurations
const uniqueConfigurations = [...new Set(
hostsData
.filter(host => host.name && host.name.trim() !== '')
@@ -84,7 +79,6 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
setFolders(uniqueFolders);
setSshConfigurations(uniqueConfigurations);
} catch (error) {
console.error('Failed to fetch hosts:', error);
} finally {
setLoading(false);
}
@@ -93,7 +87,6 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
fetchData();
}, []);
// Create dynamic form schema based on fetched data
const formSchema = z.object({
name: z.string().optional(),
ip: z.string().min(1),
@@ -130,7 +123,6 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
enableConfigEditor: z.boolean().default(true),
defaultPath: z.string().optional(),
}).superRefine((data, ctx) => {
// Conditional validation based on authType
if (data.authType === 'password') {
if (!data.password || data.password.trim() === '') {
ctx.addIssue({
@@ -156,7 +148,6 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
}
}
// Validate endpointHost against available configurations
data.tunnelConnections.forEach((connection, index) => {
if (connection.endpointHost && !sshConfigurations.includes(connection.endpointHost)) {
ctx.addIssue({
@@ -193,15 +184,12 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
}
});
// Update form when editingHost changes
useEffect(() => {
if (editingHost) {
// Determine the default auth type based on what's available
const defaultAuthType = editingHost.key ? 'key' : 'password';
// Update the auth tab state
setAuthTab(defaultAuthType);
form.reset({
name: editingHost.name || "",
ip: editingHost.ip || "",
@@ -222,9 +210,8 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
tunnelConnections: editingHost.tunnelConnections || [],
});
} else {
// Reset to password tab for new hosts
setAuthTab('password');
form.reset({
name: "",
ip: "",
@@ -250,52 +237,42 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
const onSubmit = async (data: any) => {
try {
const formData = data as FormData;
// Set default name if empty or undefined
if (!formData.name || formData.name.trim() === '') {
formData.name = `${formData.username}@${formData.ip}`;
}
if (editingHost) {
await updateSSHHost(editingHost.id, formData);
console.log('Host updated successfully');
} else {
await createSSHHost(formData);
console.log('Host created successfully');
}
// Call the callback to redirect to host viewer
if (onFormSubmit) {
onFormSubmit();
}
} catch (error) {
console.error('Failed to save host:', error);
alert('Failed to save host. Please try again.');
}
};
// Tag input state
const [tagInput, setTagInput] = useState("");
// Folder dropdown state
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
const folderInputRef = useRef<HTMLInputElement>(null);
const folderDropdownRef = useRef<HTMLDivElement>(null);
// Folder filtering logic
const folderValue = form.watch('folder');
const filteredFolders = React.useMemo(() => {
if (!folderValue) return folders;
return folders.filter(f => f.toLowerCase().includes(folderValue.toLowerCase()));
}, [folderValue, folders]);
// Handle folder click
const handleFolderClick = (folder: string) => {
form.setValue('folder', folder);
setFolderDropdownOpen(false);
};
// Close dropdown on outside click
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
@@ -319,7 +296,6 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
};
}, [folderDropdownOpen]);
// keyType Dropdown
const keyTypeOptions = [
{value: 'auto', label: 'Auto-detect'},
{value: 'ssh-rsa', label: 'RSA'},
@@ -353,41 +329,35 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
return () => document.removeEventListener("mousedown", onClickOutside);
}, [keyTypeDropdownOpen]);
// SSH Configuration dropdown state and logic
const [sshConfigDropdownOpen, setSshConfigDropdownOpen] = useState<{ [key: number]: boolean }>({});
const sshConfigInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({});
const sshConfigDropdownRefs = useRef<{ [key: number]: HTMLDivElement | null }>({});
// SSH Configuration filtering logic
const getFilteredSshConfigs = (index: number) => {
const value = form.watch(`tunnelConnections.${index}.endpointHost`);
// Get current host name to exclude it from the list
const currentHostName = form.watch('name') || `${form.watch('username')}@${form.watch('ip')}`;
// Filter out the current host and apply search filter
let filtered = sshConfigurations.filter(config => config !== currentHostName);
if (value) {
filtered = filtered.filter(config =>
filtered = filtered.filter(config =>
config.toLowerCase().includes(value.toLowerCase())
);
}
return filtered;
};
// Handle SSH configuration click
const handleSshConfigClick = (config: string, index: number) => {
form.setValue(`tunnelConnections.${index}.endpointHost`, config);
setSshConfigDropdownOpen(prev => ({ ...prev, [index]: false }));
setSshConfigDropdownOpen(prev => ({...prev, [index]: false}));
};
// Close SSH configuration dropdown on outside click
useEffect(() => {
function handleSshConfigClickOutside(event: MouseEvent) {
const openDropdowns = Object.keys(sshConfigDropdownOpen).filter(key => sshConfigDropdownOpen[parseInt(key)]);
openDropdowns.forEach((indexStr: string) => {
const index = parseInt(indexStr);
if (
@@ -396,13 +366,13 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
sshConfigInputRefs.current[index] &&
!sshConfigInputRefs.current[index]?.contains(event.target as Node)
) {
setSshConfigDropdownOpen(prev => ({ ...prev, [index]: false }));
setSshConfigDropdownOpen(prev => ({...prev, [index]: false}));
}
});
}
const hasOpenDropdowns = Object.values(sshConfigDropdownOpen).some(open => open);
if (hasOpenDropdowns) {
document.addEventListener('mousedown', handleSshConfigClickOutside);
} else {
@@ -490,9 +460,9 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
<FormItem className="col-span-10 relative">
<FormLabel>Folder</FormLabel>
<FormControl>
<Input
<Input
ref={folderInputRef}
placeholder="folder"
placeholder="folder"
className="min-h-[40px]"
autoComplete="off"
value={field.value}
@@ -503,7 +473,6 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
}}
/>
</FormControl>
{/* Folder dropdown menu */}
{folderDropdownOpen && filteredFolders.length > 0 && (
<div
ref={folderDropdownRef}
@@ -532,13 +501,15 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
<FormField
control={form.control}
name="tags"
render={({ field }) => (
render={({field}) => (
<FormItem className="col-span-10 overflow-visible">
<FormLabel>Tags</FormLabel>
<FormControl>
<div className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-[#222225] focus-within:ring-2 ring-ring min-h-[40px]">
<div
className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-[#222225] focus-within:ring-2 ring-ring min-h-[40px]">
{field.value.map((tag: string, idx: number) => (
<span key={tag + idx} className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs">
<span key={tag + idx}
className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs">
{tag}
<button
type="button"
@@ -593,8 +564,8 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">Authentication</FormLabel>
<Tabs
value={authTab}
<Tabs
value={authTab}
onValueChange={(value) => {
setAuthTab(value as 'password' | 'key');
form.setValue('authType', value as 'password' | 'key');
@@ -735,12 +706,6 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
</FormItem>
)}
/>
{form.watch('enableTerminal') && (
<div className="mt-4">
{/* Tunnel Config (none yet) */}
</div>
)}
</TabsContent>
<TabsContent value="tunnel">
<FormField
@@ -761,35 +726,52 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
</FormItem>
)}
/>
{form.watch('enableTunnel') && (
<>
<Alert className="mt-4">
<AlertDescription>
<strong>Sshpass Required For Password Authentication</strong>
<div>
For password-based SSH authentication, sshpass must be installed on both the local and remote servers. Install with: <code className="bg-muted px-1 rounded inline">sudo apt install sshpass</code> (Debian/Ubuntu) or the equivalent for your OS.
</div>
<div className="mt-2">
<strong>Other installation methods:</strong>
<div> CentOS/RHEL/Fedora: <code className="bg-muted px-1 rounded inline">sudo yum install sshpass</code> or <code className="bg-muted px-1 rounded inline">sudo dnf install sshpass</code></div>
<div> macOS: <code className="bg-muted px-1 rounded inline">brew install hudochenkov/sshpass/sshpass</code></div>
<div> Windows: Use WSL or consider SSH key authentication</div>
</div>
</AlertDescription>
</Alert>
<Alert className="mt-4">
<AlertDescription>
<strong>Sshpass Required For Password Authentication</strong>
<div>
For password-based SSH authentication, sshpass must be installed on
both the local and remote servers. Install with: <code
className="bg-muted px-1 rounded inline">sudo apt install
sshpass</code> (Debian/Ubuntu) or the equivalent for your OS.
</div>
<div className="mt-2">
<strong>Other installation methods:</strong>
<div> CentOS/RHEL/Fedora: <code
className="bg-muted px-1 rounded inline">sudo yum install
sshpass</code> or <code
className="bg-muted px-1 rounded inline">sudo dnf install
sshpass</code></div>
<div> macOS: <code className="bg-muted px-1 rounded inline">brew
install hudochenkov/sshpass/sshpass</code></div>
<div> Windows: Use WSL or consider SSH key authentication</div>
</div>
</AlertDescription>
</Alert>
<Alert className="mt-4">
<AlertDescription>
<strong>SSH Server Configuration Required</strong>
<div>For reverse SSH tunnels, the endpoint SSH server must allow:</div>
<div> <code className="bg-muted px-1 rounded inline">GatewayPorts
yes</code> (bind remote ports)
</div>
<div> <code className="bg-muted px-1 rounded inline">AllowTcpForwarding
yes</code> (port forwarding)
</div>
<div> <code className="bg-muted px-1 rounded inline">PermitRootLogin
yes</code> (if using root)
</div>
<div className="mt-2">Edit <code
className="bg-muted px-1 rounded inline">/etc/ssh/sshd_config</code> and
restart SSH: <code className="bg-muted px-1 rounded inline">sudo
systemctl restart sshd</code></div>
</AlertDescription>
</Alert>
<Alert className="mt-4">
<AlertDescription>
<strong>SSH Server Configuration Required</strong>
<div>For reverse SSH tunnels, the endpoint SSH server must allow:</div>
<div> <code className="bg-muted px-1 rounded inline">GatewayPorts yes</code> (bind remote ports)</div>
<div> <code className="bg-muted px-1 rounded inline">AllowTcpForwarding yes</code> (port forwarding)</div>
<div> <code className="bg-muted px-1 rounded inline">PermitRootLogin yes</code> (if using root)</div>
<div className="mt-2">Edit <code className="bg-muted px-1 rounded inline">/etc/ssh/sshd_config</code> and restart SSH: <code className="bg-muted px-1 rounded inline">sudo systemctl restart sshd</code></div>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="tunnelConnections"
@@ -799,8 +781,10 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
<FormControl>
<div className="space-y-4">
{field.value.map((connection, index) => (
<div key={index} className="p-4 border rounded-lg bg-muted/50">
<div className="flex items-center justify-between mb-3">
<div key={index}
className="p-4 border rounded-lg bg-muted/50">
<div
className="flex items-center justify-between mb-3">
<h4 className="text-sm font-bold">Connection {index + 1}</h4>
<Button
type="button"
@@ -820,9 +804,11 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
name={`tunnelConnections.${index}.sourcePort`}
render={({field: sourcePortField}) => (
<FormItem className="col-span-4">
<FormLabel>Source Port (Local)</FormLabel>
<FormLabel>Source Port
(Local)</FormLabel>
<FormControl>
<Input placeholder="22" {...sourcePortField} />
<Input
placeholder="22" {...sourcePortField} />
</FormControl>
</FormItem>
)}
@@ -832,9 +818,11 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
name={`tunnelConnections.${index}.endpointPort`}
render={({field: endpointPortField}) => (
<FormItem className="col-span-4">
<FormLabel>Endpoint Port (Remote)</FormLabel>
<FormLabel>Endpoint Port
(Remote)</FormLabel>
<FormControl>
<Input placeholder="224" {...endpointPortField} />
<Input
placeholder="224" {...endpointPortField} />
</FormControl>
</FormItem>
)}
@@ -843,10 +831,12 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
control={form.control}
name={`tunnelConnections.${index}.endpointHost`}
render={({field: endpointHostField}) => (
<FormItem className="col-span-4 relative">
<FormLabel>Endpoint SSH Configuration</FormLabel>
<FormItem
className="col-span-4 relative">
<FormLabel>Endpoint SSH
Configuration</FormLabel>
<FormControl>
<Input
<Input
ref={(el) => {
sshConfigInputRefs.current[index] = el;
}}
@@ -854,14 +844,19 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
className="min-h-[40px]"
autoComplete="off"
value={endpointHostField.value}
onFocus={() => setSshConfigDropdownOpen(prev => ({ ...prev, [index]: true }))}
onFocus={() => setSshConfigDropdownOpen(prev => ({
...prev,
[index]: true
}))}
onChange={e => {
endpointHostField.onChange(e);
setSshConfigDropdownOpen(prev => ({ ...prev, [index]: true }));
setSshConfigDropdownOpen(prev => ({
...prev,
[index]: true
}));
}}
/>
</FormControl>
{/* SSH Configuration dropdown menu */}
{sshConfigDropdownOpen[index] && getFilteredSshConfigs(index).length > 0 && (
<div
ref={(el) => {
@@ -869,7 +864,8 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
}}
className="absolute top-full left-0 z-50 mt-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
<div
className="grid grid-cols-1 gap-1 p-0">
{getFilteredSshConfigs(index).map((config) => (
<Button
key={config}
@@ -889,11 +885,16 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
)}
/>
</div>
<p className="text-sm text-muted-foreground mt-2">
This tunnel will forward traffic from port {form.watch(`tunnelConnections.${index}.sourcePort`) || '22'} on the source machine (current connection details in general tab) to port {form.watch(`tunnelConnections.${index}.endpointPort`) || '224'} on the endpoint machine.
This tunnel will forward traffic from
port {form.watch(`tunnelConnections.${index}.sourcePort`) || '22'} on
the source machine (current connection details
in general tab) to
port {form.watch(`tunnelConnections.${index}.endpointPort`) || '224'} on
the endpoint machine.
</p>
<div className="grid grid-cols-12 gap-4 mt-4">
<FormField
control={form.control}
@@ -902,10 +903,12 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
<FormItem className="col-span-4">
<FormLabel>Max Retries</FormLabel>
<FormControl>
<Input placeholder="3" {...maxRetriesField} />
<Input
placeholder="3" {...maxRetriesField} />
</FormControl>
<FormDescription>
Maximum number of retry attempts for tunnel connection.
Maximum number of retry attempts
for tunnel connection.
</FormDescription>
</FormItem>
)}
@@ -915,12 +918,15 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
name={`tunnelConnections.${index}.retryInterval`}
render={({field: retryIntervalField}) => (
<FormItem className="col-span-4">
<FormLabel>Retry Interval (seconds)</FormLabel>
<FormLabel>Retry Interval
(seconds)</FormLabel>
<FormControl>
<Input placeholder="10" {...retryIntervalField} />
<Input
placeholder="10" {...retryIntervalField} />
</FormControl>
<FormDescription>
Time to wait between retry attempts.
Time to wait between retry
attempts.
</FormDescription>
</FormItem>
)}
@@ -930,7 +936,8 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
name={`tunnelConnections.${index}.autoStart`}
render={({field}) => (
<FormItem className="col-span-4">
<FormLabel>Auto Start on Container Launch</FormLabel>
<FormLabel>Auto Start on Container
Launch</FormLabel>
<FormControl>
<Switch
checked={field.value}
@@ -938,7 +945,8 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
/>
</FormControl>
<FormDescription>
Automatically start this tunnel when the container launches.
Automatically start this tunnel
when the container launches.
</FormDescription>
</FormItem>
)}
@@ -950,10 +958,10 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
type="button"
variant="outline"
onClick={() => {
field.onChange([...field.value, {
sourcePort: 22,
endpointPort: 224,
endpointHost: "",
field.onChange([...field.value, {
sourcePort: 22,
endpointPort: 224,
endpointHost: "",
maxRetries: 3,
retryInterval: 10,
autoStart: false,
@@ -967,7 +975,7 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
</FormItem>
)}
/>
</>
)}
@@ -991,22 +999,23 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
</FormItem>
)}
/>
{form.watch('enableConfigEditor') && (
<div className="mt-4">
<FormField
control={form.control}
name="defaultPath"
render={({field}) => (
<FormItem>
<FormLabel>Default Path</FormLabel>
<FormControl>
<Input placeholder="/home" {...field} />
</FormControl>
<FormDescription>Set default directory shown when connected via Config Editor</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="defaultPath"
render={({field}) => (
<FormItem>
<FormLabel>Default Path</FormLabel>
<FormControl>
<Input placeholder="/home" {...field} />
</FormControl>
<FormDescription>Set default directory shown when connected via
Config Editor</FormDescription>
</FormItem>
)}
/>
</div>
)}
</TabsContent>

View File

@@ -1,12 +1,12 @@
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-fixed";
import { Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search } from "lucide-react";
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;
@@ -31,7 +31,7 @@ interface SSHManagerHostViewerProps {
onEditHost?: (host: SSHHost) => void;
}
export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps) {
export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -48,7 +48,6 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
setHosts(data);
setError(null);
} catch (err) {
console.error('Failed to fetch hosts:', err);
setError('Failed to load hosts');
} finally {
setLoading(false);
@@ -59,9 +58,8 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) {
try {
await deleteSSHHost(hostId);
await fetchHosts(); // Refresh the list
await fetchHosts();
} catch (err) {
console.error('Failed to delete host:', err);
alert('Failed to delete host');
}
}
@@ -73,11 +71,9 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
}
};
// Filter and sort hosts
const filteredAndSortedHosts = useMemo(() => {
let filtered = hosts;
// Apply search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = hosts.filter(host => {
@@ -94,23 +90,19 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
});
}
// 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]) {
@@ -118,20 +110,18 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
}
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]);
@@ -163,7 +153,7 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
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" />
<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.
@@ -187,9 +177,8 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
</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" />
<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}
@@ -197,16 +186,17 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
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">
<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" />
<Folder className="h-4 w-4"/>
<span className="font-medium">{folder}</span>
<Badge variant="secondary" className="text-xs">
{folderHosts.length}
@@ -216,15 +206,16 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
<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}
<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" />}
{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>
@@ -246,7 +237,7 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
}}
className="h-5 w-5 p-0"
>
<Edit className="h-3 w-3" />
<Edit className="h-3 w-3"/>
</Button>
<Button
size="sm"
@@ -257,49 +248,50 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
}}
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
<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" />
<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">
<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 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" />
<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>
<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" />
<FileEdit className="h-2 w-2 mr-0.5"/>
Config
</Badge>
)}

View File

@@ -26,7 +26,7 @@ interface SidebarProps {
onSelectView: (view: string) => void;
}
export function SSHManagerSidebar({ onSelectView }: SidebarProps): React.ReactElement {
export function SSHManagerSidebar({onSelectView}: SidebarProps): React.ReactElement {
return (
<SidebarProvider>
<Sidebar>
@@ -35,17 +35,18 @@ export function SSHManagerSidebar({ onSelectView }: SidebarProps): React.ReactEl
<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" />
<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">
<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" />
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
</SidebarMenu>

View File

@@ -1,8 +1,8 @@
import React, { useState, useRef, useEffect } from "react";
import { SSHSidebar } from "@/apps/SSH/Terminal/SSHSidebar.tsx";
import { SSHTerminal } from "./SSHTerminal.tsx";
import { SSHTopbar } from "@/apps/SSH/Terminal/SSHTopbar.tsx";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable.tsx';
import React, {useState, useRef, useEffect} from "react";
import {SSHSidebar} from "@/apps/SSH/Terminal/SSHSidebar.tsx";
import {SSHTerminal} from "./SSHTerminal.tsx";
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 {
@@ -16,7 +16,7 @@ type Tab = {
terminalRef: React.RefObject<any>;
};
export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
export function SSH({onSelectView}: ConfigEditorProps): React.ReactElement {
const [allTabs, setAllTabs] = useState<Tab[]>([]);
const [currentTab, setCurrentTab] = useState<number | null>(null);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
@@ -72,7 +72,7 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
const updatePanelRects = () => {
setPanelRects((prev) => {
const next: Record<string, DOMRect | null> = { ...prev };
const next: Record<string, DOMRect | null> = {...prev};
Object.entries(panelRefs.current).forEach(([id, ref]) => {
if (ref) {
next[id] = ref.getBoundingClientRect();
@@ -137,11 +137,21 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
});
}
return (
<div ref={el => { panelRefs.current['parent'] = el; }} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 1, overflow: 'hidden' }}>
<div ref={el => {
panelRefs.current['parent'] = el;
}} style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 1,
overflow: 'hidden'
}}>
{allTabs.map((tab) => {
const style = layoutStyles[tab.id]
? { ...layoutStyles[tab.id], overflow: 'hidden' }
: { display: 'none', overflow: 'hidden' };
? {...layoutStyles[tab.id], overflow: 'hidden'}
: {display: 'none', overflow: 'hidden'};
const isVisible = !!layoutStyles[tab.id];
return (
<div key={tab.id} style={style} data-terminal-id={tab.id}>
@@ -170,15 +180,37 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
if (layoutTabs.length === 2) {
const [tab1, tab2] = layoutTabs;
return (
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 10, pointerEvents: 'none' }}>
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 10,
pointerEvents: 'none'
}}>
<ResizablePrimitive.PanelGroup
ref={el => { panelGroupRefs.current['main'] = el; }}
ref={el => {
panelGroupRefs.current['main'] = el;
}}
direction="horizontal"
className="h-full w-full"
id="main-horizontal"
>
<ResizablePanel key={tab1.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${tab1.id}`} order={1}>
<div ref={el => { panelRefs.current[String(tab1.id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
<ResizablePanel key={tab1.id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full" id={`panel-${tab1.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(tab1.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
@@ -194,9 +226,21 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
}}>{tab1.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
<ResizablePanel key={tab2.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${tab2.id}`} order={2}>
<div ref={el => { panelRefs.current[String(tab2.id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={tab2.id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full" id={`panel-${tab2.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(tab2.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
@@ -218,17 +262,43 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
}
if (layoutTabs.length === 3) {
return (
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 10, pointerEvents: 'none' }}>
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 10,
pointerEvents: 'none'
}}>
<ResizablePrimitive.PanelGroup
ref={el => { panelGroupRefs.current['main'] = el; }}
ref={el => {
panelGroupRefs.current['main'] = el;
}}
direction="vertical"
className="h-full w-full"
id="main-vertical"
>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
<ResizablePanelGroup ref={el => { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal">
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[0].id}`} order={1}>
<div ref={el => { panelRefs.current[String(layoutTabs[0].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<ResizablePanelGroup ref={el => {
panelGroupRefs.current['top'] = el;
}} direction="horizontal" className="h-full w-full" id="top-horizontal">
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[0].id}`} order={1}>
<div ref={el => {
panelRefs.current[String(layoutTabs[0].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
@@ -244,9 +314,22 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
}}>{layoutTabs[0].title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[1].id}`} order={2}>
<div ref={el => { panelRefs.current[String(layoutTabs[1].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[1].id}`} order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[1].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
@@ -264,9 +347,21 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}>
<div ref={el => { panelRefs.current[String(layoutTabs[2].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="bottom-panel" order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[2].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
@@ -288,17 +383,43 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
}
if (layoutTabs.length === 4) {
return (
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 10, pointerEvents: 'none' }}>
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 10,
pointerEvents: 'none'
}}>
<ResizablePrimitive.PanelGroup
ref={el => { panelGroupRefs.current['main'] = el; }}
ref={el => {
panelGroupRefs.current['main'] = el;
}}
direction="vertical"
className="h-full w-full"
id="main-vertical"
>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
<ResizablePanelGroup ref={el => { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal">
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[0].id}`} order={1}>
<div ref={el => { panelRefs.current[String(layoutTabs[0].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<ResizablePanelGroup ref={el => {
panelGroupRefs.current['top'] = el;
}} direction="horizontal" className="h-full w-full" id="top-horizontal">
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[0].id}`} order={1}>
<div ref={el => {
panelRefs.current[String(layoutTabs[0].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
@@ -314,9 +435,22 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
}}>{layoutTabs[0].title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[1].id}`} order={2}>
<div ref={el => { panelRefs.current[String(layoutTabs[1].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[1].id}`} order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[1].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
@@ -334,11 +468,27 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}>
<ResizablePanelGroup ref={el => { panelGroupRefs.current['bottom'] = el; }} direction="horizontal" className="h-full w-full" id="bottom-horizontal">
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[2].id}`} order={1}>
<div ref={el => { panelRefs.current[String(layoutTabs[2].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="bottom-panel" order={2}>
<ResizablePanelGroup ref={el => {
panelGroupRefs.current['bottom'] = el;
}} direction="horizontal" className="h-full w-full" id="bottom-horizontal">
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[2].id}`} order={1}>
<div ref={el => {
panelRefs.current[String(layoutTabs[2].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
@@ -354,9 +504,22 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
}}>{layoutTabs[2].title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
<ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[3].id}`} order={2}>
<div ref={el => { panelRefs.current[String(layoutTabs[3].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[3].id}`} order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[3].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
@@ -424,24 +587,31 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
};
return (
<div style={{ display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden' }}>
{/* Sidebar: fixed width */}
<div style={{ width: 256, flexShrink: 0, height: '100vh', position: 'relative', zIndex: 2, margin: 0, padding: 0, border: 'none' }}>
<SSHSidebar
onSelectView={onSelectView}
onAddHostSubmit={onAddHostSubmit}
onHostConnect={onHostConnect}
allTabs={allTabs}
runCommandOnTabs={(tabIds: number[], command: string) => {
allTabs.forEach(tab => {
if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(command);
}
});
}}
/>
<div style={{display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden'}}>
<div style={{
width: 256,
flexShrink: 0,
height: '100vh',
position: 'relative',
zIndex: 2,
margin: 0,
padding: 0,
border: 'none'
}}>
<SSHSidebar
onSelectView={onSelectView}
onAddHostSubmit={onAddHostSubmit}
onHostConnect={onHostConnect}
allTabs={allTabs}
runCommandOnTabs={(tabIds: number[], command: string) => {
allTabs.forEach(tab => {
if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(command);
}
});
}}
/>
</div>
{/* Main area: fills the rest */}
<div
className="terminal-container"
style={{
@@ -454,20 +624,17 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
border: 'none',
}}
>
{/* Always render the topbar at the top */}
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', zIndex: 10 }}>
<SSHTopbar
allTabs={allTabs}
<div style={{position: 'absolute', top: 0, left: 0, width: '100%', zIndex: 10}}>
<SSHTopbar
allTabs={allTabs}
currentTab={currentTab ?? -1}
setActiveTab={setActiveTab}
allSplitScreenTab={allSplitScreenTab}
setSplitScreenTab={setSplitScreenTab}
setCloseTab={setCloseTab}
/>
</div>
{/* Split area below the topbar */}
<div style={{ height: 'calc(100% - 46px)', marginTop: 46, position: 'relative' }}>
{/* Show alert when no terminals are rendered */}
setActiveTab={setActiveTab}
allSplitScreenTab={allSplitScreenTab}
setSplitScreenTab={setSplitScreenTab}
setCloseTab={setCloseTab}
/>
</div>
<div style={{height: 'calc(100% - 46px)', marginTop: 46, position: 'relative'}}>
{allTabs.length === 0 && (
<div style={{
position: 'absolute',
@@ -483,17 +650,17 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
maxWidth: '400px',
zIndex: 30
}}>
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '12px' }}>
<div style={{fontSize: '18px', fontWeight: 'bold', marginBottom: '12px'}}>
Welcome to Termix SSH
</div>
<div style={{ fontSize: '14px', color: '#a1a1aa', lineHeight: '1.5' }}>
Click on any host title in the sidebar to open a terminal connection, or use the "Add Host" button to create a new connection.
<div style={{fontSize: '14px', color: '#a1a1aa', lineHeight: '1.5'}}>
Click on any host title in the sidebar to open a terminal connection, or use the "Add
Host" button to create a new connection.
</div>
</div>
)}
{/* Absolutely render all terminals for persistence and layout */}
{allSplitScreenTab.length > 0 && (
<div style={{ position: 'absolute', top: 0, right: 0, zIndex: 20, height: 28 }}>
<div style={{position: 'absolute', top: 0, right: 0, zIndex: 20, height: 28}}>
<button
style={{
background: '#18181b',
@@ -514,12 +681,10 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
onClick={() => {
if (allSplitScreenTab.length === 1) {
panelGroupRefs.current['main']?.setLayout([50, 50]);
}
else if (allSplitScreenTab.length === 2) {
} else if (allSplitScreenTab.length === 2) {
panelGroupRefs.current['main']?.setLayout([50, 50]);
panelGroupRefs.current['top']?.setLayout([50, 50]);
}
else if (allSplitScreenTab.length === 3) {
} else if (allSplitScreenTab.length === 3) {
panelGroupRefs.current['main']?.setLayout([50, 50]);
panelGroupRefs.current['top']?.setLayout([50, 50]);
panelGroupRefs.current['bottom']?.setLayout([50, 50]);

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, {useState} from 'react';
import {
CornerDownLeft,
@@ -35,9 +35,9 @@ import {
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion.tsx";
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { Input } from "@/components/ui/input.tsx";
import { getSSHHosts } from "@/apps/SSH/ssh-axios-fixed";
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
import {Input} from "@/components/ui/input.tsx";
import {getSSHHosts} from "@/apps/SSH/ssh-axios";
interface SSHHost {
id: number;
@@ -69,7 +69,7 @@ interface SidebarProps {
runCommandOnTabs: (tabIds: number[], command: string) => void;
}
export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnTabs }: SidebarProps): React.ReactElement {
export function SSHSidebar({onSelectView, onHostConnect, allTabs, runCommandOnTabs}: SidebarProps): React.ReactElement {
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(false);
const [hostsError, setHostsError] = useState<string | null>(null);
@@ -80,7 +80,6 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
setHostsError(null);
try {
const newHosts = await getSSHHosts();
// Filter hosts to only show those with enableTerminal: true
const terminalHosts = newHosts.filter(host => host.enableTerminal);
const prevHosts = prevHostsRef.current;
@@ -170,7 +169,6 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
return [...pinned, ...rest];
};
// Tools Sheet State
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [toolsCommand, setToolsCommand] = useState("");
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
@@ -181,11 +179,10 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
const handleRunCommand = () => {
if (selectedTabIds.length && toolsCommand.trim()) {
// Ensure command ends with newline
let cmd = toolsCommand;
if (!cmd.endsWith("\n")) cmd += "\n";
runCommandOnTabs(selectedTabIds, cmd);
setToolsCommand(""); // Clear after run
setToolsCommand("");
}
};
@@ -197,7 +194,7 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / Terminal
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarMenu className="flex flex-col flex-grow h-full overflow-hidden">
@@ -207,15 +204,15 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
onClick={() => onSelectView("homepage")}
variant="outline"
>
<CornerDownLeft />
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1" />
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
<SidebarMenuItem key="Main" className="flex flex-col flex-grow overflow-hidden">
<div className="w-full flex-grow rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
{/* Search bar */}
<div
className="w-full flex-grow rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
<div className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10">
<Input
value={search}
@@ -225,30 +222,41 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
autoComplete="off"
/>
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Separator className="w-full h-px bg-[#434345] my-2" style={{ maxWidth: 213, margin: '0 auto' }} />
<div style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="w-full h-px bg-[#434345] my-2"
style={{maxWidth: 213, margin: '0 auto'}}/>
</div>
{/* Error and status messages */}
{hostsError && (
<div className="px-2 py-1 mt-2">
<div className="text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">{hostsError}</div>
<div
className="text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">{hostsError}</div>
</div>
)}
{!hostsLoading && !hostsError && hosts.length === 0 && (
<div className="px-2 py-1 mt-2">
<div className="text-xs text-muted-foreground bg-muted/20 rounded px-2 py-1 border border-border/20">No hosts found.</div>
<div
className="text-xs text-muted-foreground bg-muted/20 rounded px-2 py-1 border border-border/20">No
hosts found.
</div>
</div>
)}
<div className="flex-1 min-h-0">
<ScrollArea className="w-full h-full">
<Accordion key={`host-accordion-${sortedFolders.length}`} type="multiple" className="w-full" defaultValue={sortedFolders.length > 0 ? sortedFolders : undefined}>
<Accordion key={`host-accordion-${sortedFolders.length}`}
type="multiple" className="w-full"
defaultValue={sortedFolders.length > 0 ? sortedFolders : undefined}>
{sortedFolders.map((folder, idx) => (
<React.Fragment key={folder}>
<AccordionItem value={folder} className={idx === 0 ? "mt-0 !border-b-transparent" : "mt-2 !border-b-transparent"}>
<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">
<AccordionItem value={folder}
className={idx === 0 ? "mt-0 !border-b-transparent" : "mt-2 !border-b-transparent"}>
<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">
{getSortedHosts(hostsByFolder[folder]).map(host => (
<div key={host.id} className="w-full overflow-hidden">
<div key={host.id}
className="w-full overflow-hidden">
<HostMenuItem
host={host}
onHostConnect={onHostConnect}
@@ -258,8 +266,10 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
</AccordionContent>
</AccordionItem>
{idx < sortedFolders.length - 1 && (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Separator className="h-px bg-[#434345] my-1" style={{ width: 213 }} />
<div
style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="h-px bg-[#434345] my-1"
style={{width: 213}}/>
</div>
)}
</React.Fragment>
@@ -271,7 +281,6 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
{/* Tools Button at the very bottom */}
<div className="bg-sidebar">
<Sheet open={toolsSheetOpen} onOpenChange={setToolsSheetOpen}>
<SheetTrigger asChild>
@@ -280,27 +289,32 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
variant="outline"
onClick={() => setToolsSheetOpen(true)}
>
<Hammer className="mr-2 h-4 w-4" />
<Hammer className="mr-2 h-4 w-4"/>
Tools
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col">
<SheetContent side="left"
className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col">
<SheetHeader className="pb-0.5">
<SheetTitle>Tools</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-2 pt-2">
<Accordion type="single" collapsible defaultValue="multiwindow">
<AccordionItem value="multiwindow">
<AccordionTrigger className="text-base font-semibold">Run multiwindow commands</AccordionTrigger>
<AccordionTrigger className="text-base font-semibold">Run multiwindow
commands</AccordionTrigger>
<AccordionContent>
<textarea
className="w-full min-h-[120px] max-h-48 rounded-md border border-input text-foreground p-2 text-sm font-mono resize-vertical focus:outline-none focus:ring-0"
placeholder="Enter command(s) to run on selected tabs..."
value={toolsCommand}
onChange={e => setToolsCommand(e.target.value)}
style={{ fontFamily: 'monospace', marginBottom: 8, background: '#141416' }}
style={{
fontFamily: 'monospace',
marginBottom: 8,
background: '#141416'
}}
/>
{/* Tab selection as tag-like buttons */}
<div className="flex flex-wrap gap-2 mb-2">
{allTabs.map(tab => (
<Button
@@ -337,29 +351,35 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
);
}
const HostMenuItem = React.memo(function HostMenuItem({ host, onHostConnect }: { host: SSHHost; onHostConnect: (hostConfig: any) => void }) {
const HostMenuItem = React.memo(function HostMenuItem({host, onHostConnect}: {
host: SSHHost;
onHostConnect: (hostConfig: any) => void
}) {
const tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0;
return (
<div className="relative group flex flex-col mb-1 w-full overflow-hidden">
<div className={`flex flex-col w-full rounded overflow-hidden border border-[#434345] bg-[#18181b] h-full`}>
<div className="flex w-full h-10">
{/* Full width clickable area */}
<div className="flex items-center h-full px-2 w-full hover:bg-muted transition-colors cursor-pointer"
onClick={() => onHostConnect(host)}
<div
className="flex items-center h-full px-2 w-full hover:bg-muted transition-colors cursor-pointer"
onClick={() => onHostConnect(host)}
>
<div className="flex items-center w-full">
{host.pin &&
<Pin className="h-4.5 mr-1 w-4.5 mt-0.5 text-yellow-500 flex-shrink-0" />
<Pin className="h-4.5 mr-1 w-4.5 mt-0.5 text-yellow-500 flex-shrink-0"/>
}
<span className="font-medium truncate">{host.name || host.ip}</span>
</div>
</div>
</div>
{hasTags && (
<div className="border-t border-border bg-[#18181b] flex items-center gap-1 px-2 py-2 overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent" style={{ height: 30 }}>
<div
className="border-t border-border bg-[#18181b] flex items-center gap-1 px-2 py-2 overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
style={{height: 30}}>
{tags.map((tag: string) => (
<span key={tag} className="bg-muted-foreground/10 text-xs rounded-full px-2 py-0.5 text-muted-foreground whitespace-nowrap border border-border flex-shrink-0 hover:bg-muted transition-colors">
<span key={tag}
className="bg-muted-foreground/10 text-xs rounded-full px-2 py-0.5 text-muted-foreground whitespace-nowrap border border-border flex-shrink-0 hover:bg-muted transition-colors">
{tag}
</span>
))}

View File

@@ -41,7 +41,6 @@ export function SSHTabList({
className={index < allTabs.length - 1 ? "mr-[0.5rem]" : ""}
>
<div className="inline-flex rounded-md shadow-sm" role="group">
{/* Set Active Tab Button */}
<Button
onClick={() => setActiveTab(terminal.id)}
disabled={isSplit}
@@ -51,24 +50,22 @@ export function SSHTabList({
{terminal.title}
</Button>
{/* Split Screen Button */}
<Button
onClick={() => setSplitScreenTab(terminal.id)}
disabled={isSplitButtonDisabled || isActive}
variant="outline"
className="rounded-none p-0 !w-9 !h-9"
>
<SeparatorVertical className="!w-5 !h-5" strokeWidth={2.5} />
<SeparatorVertical className="!w-5 !h-5" strokeWidth={2.5}/>
</Button>
{/* Close Tab Button */}
<Button
onClick={() => setCloseTab(terminal.id)}
disabled={(isSplitScreenActive && isActive) || isSplit}
variant="outline"
className="rounded-l-none p-0 !w-9 !h-9"
>
<X className="!w-5 !h-5" strokeWidth={2.5} />
<X className="!w-5 !h-5" strokeWidth={2.5}/>
</Button>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
import { useXTerm } from 'react-xtermjs';
import { FitAddon } from '@xterm/addon-fit';
import { ClipboardAddon } from '@xterm/addon-clipboard';
import {useEffect, useRef, useState, useImperativeHandle, forwardRef} from 'react';
import {useXTerm} from 'react-xtermjs';
import {FitAddon} from '@xterm/addon-fit';
import {ClipboardAddon} from '@xterm/addon-clipboard';
interface SSHTerminalProps {
hostConfig: any;
@@ -12,10 +12,10 @@ interface SSHTerminalProps {
}
export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
{ hostConfig, isVisible, splitScreen = false },
{hostConfig, isVisible, splitScreen = false},
ref
) {
const { instance: terminal, ref: xtermRef } = useXTerm();
const {instance: terminal, ref: xtermRef} = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null);
const webSocketRef = useRef<WebSocket | null>(null);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
@@ -34,7 +34,7 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
},
sendInput: (data: string) => {
if (webSocketRef.current && webSocketRef.current.readyState === 1) {
webSocketRef.current.send(JSON.stringify({ type: 'input', data }));
webSocketRef.current.send(JSON.stringify({type: 'input', data}));
}
}
}), []);
@@ -43,6 +43,7 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
function handleWindowResize() {
fitAddonRef.current?.fit();
}
window.addEventListener('resize', handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize);
}, []);
@@ -71,7 +72,7 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
const onResize = () => {
if (!xtermRef.current) return;
const { width, height } = xtermRef.current.getBoundingClientRect();
const {width, height} = xtermRef.current.getBoundingClientRect();
if (width < 100 || height < 50) return;
@@ -84,7 +85,7 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
webSocketRef.current?.send(JSON.stringify({
type: 'resize',
data: { cols, rows }
data: {cols, rows}
}));
}, 100);
};
@@ -134,10 +135,8 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
} else if (msg.type === 'error') {
terminal.writeln(`\r\n[ERROR] ${msg.message}`);
} else if (msg.type === 'connected') {
/* nothing for now */
}
} catch (err) {
console.error('Failed to parse message', err);
}
});

View File

@@ -15,7 +15,14 @@ interface SSHTopbarProps {
setCloseTab: (tab: number) => void;
}
export function SSHTopbar({ allTabs, currentTab, setActiveTab, allSplitScreenTab, setSplitScreenTab, setCloseTab }: SSHTopbarProps): React.ReactElement {
export function SSHTopbar({
allTabs,
currentTab,
setActiveTab,
allSplitScreenTab,
setSplitScreenTab,
setCloseTab
}: SSHTopbarProps): React.ReactElement {
return (
<div className="flex h-11.5 z-100" style={{
position: 'absolute',

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from "react";
import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
import { getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel } from "@/apps/SSH/ssh-axios-fixed";
import React, {useState, useEffect, useCallback} from "react";
import {SSHTunnelSidebar} from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
import {SSHTunnelViewer} from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/apps/SSH/ssh-axios";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
@@ -49,27 +49,24 @@ interface TunnelStatus {
retryExhausted?: boolean;
}
export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement {
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({}); // Track loading states
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({});
const fetchHosts = useCallback(async () => {
try {
const hostsData = await getSSHHosts();
setHosts(hostsData);
} catch (err) {
// Silent error handling
}
}, []);
// Poll backend for tunnel statuses
const fetchTunnelStatuses = useCallback(async () => {
try {
const statusData = await getTunnelStatuses();
setTunnelStatuses(statusData);
} catch (err) {
// Silent error handling
}
}, []);
@@ -88,14 +85,13 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
const handleTunnelAction = async (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
setTunnelActions(prev => ({ ...prev, [tunnelName]: true }));
setTunnelActions(prev => ({...prev, [tunnelName]: true}));
try {
if (action === 'connect') {
// Find the endpoint host configuration
const endpointHost = hosts.find(h =>
h.name === tunnel.endpointHost ||
const endpointHost = hosts.find(h =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost
);
@@ -103,7 +99,6 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
throw new Error('Endpoint host not found');
}
// Create tunnel configuration
const tunnelConfig = {
name: tunnelName,
hostName: host.name || `${host.username}@${host.ip}`,
@@ -126,7 +121,7 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort,
maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval * 1000, // Convert to milliseconds
retryInterval: tunnel.retryInterval * 1000,
autoStart: tunnel.autoStart,
isPinned: host.pin
};
@@ -137,26 +132,23 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
} else if (action === 'cancel') {
await cancelTunnel(tunnelName);
}
// Refresh statuses after action
await fetchTunnelStatuses();
} catch (err) {
console.error(`Failed to ${action} tunnel:`, err);
// Let the backend handle error status updates
} finally {
setTunnelActions(prev => ({ ...prev, [tunnelName]: false }));
setTunnelActions(prev => ({...prev, [tunnelName]: false}));
}
};
return (
<div className="flex h-screen w-full">
<div className="w-64 flex-shrink-0">
<SSHTunnelSidebar
onSelectView={onSelectView}
<SSHTunnelSidebar
onSelectView={onSelectView}
/>
</div>
<div className="flex-1 overflow-auto">
<SSHTunnelViewer
<SSHTunnelViewer
hosts={hosts}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}

View File

@@ -1,9 +1,24 @@
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, Pin, Terminal, Network, FileEdit, Tag, Play, Square, AlertCircle, Clock, Wifi, WifiOff, Zap, X } from "lucide-react";
import { Badge } from "@/components/ui/badge.tsx";
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,
Pin,
Terminal,
Network,
FileEdit,
Tag,
Play,
Square,
AlertCircle,
Clock,
Wifi,
WifiOff,
Zap,
X
} from "lucide-react";
import {Badge} from "@/components/ui/badge.tsx";
const CONNECTION_STATES = {
DISCONNECTED: "disconnected",
@@ -62,12 +77,12 @@ interface SSHTunnelObjectProps {
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
}
export function SSHTunnelObject({
host,
tunnelStatuses,
tunnelActions,
onTunnelAction
}: SSHTunnelObjectProps): React.ReactElement {
export function SSHTunnelObject({
host,
tunnelStatuses,
tunnelActions,
onTunnelAction
}: SSHTunnelObjectProps): React.ReactElement {
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
const tunnel = host.tunnelConnections[tunnelIndex];
@@ -76,70 +91,69 @@ export function SSHTunnelObject({
};
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
if (!status) return {
icon: <WifiOff className="h-4 w-4" />,
text: 'Unknown',
if (!status) return {
icon: <WifiOff className="h-4 w-4"/>,
text: 'Unknown',
color: 'text-muted-foreground',
bgColor: 'bg-muted/50',
borderColor: 'border-border'
};
// Handle both the old format (status.status) and new format (status.status)
const statusValue = status.status || 'DISCONNECTED';
switch (statusValue.toUpperCase()) {
case 'CONNECTED':
return {
icon: <Wifi className="h-4 w-4" />,
text: 'Connected',
return {
icon: <Wifi className="h-4 w-4"/>,
text: 'Connected',
color: 'text-green-600 dark:text-green-400',
bgColor: 'bg-green-500/10 dark:bg-green-400/10',
borderColor: 'border-green-500/20 dark:border-green-400/20'
};
case 'CONNECTING':
return {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
text: 'Connecting...',
return {
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
text: 'Connecting...',
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
};
case 'DISCONNECTING':
return {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
text: 'Disconnecting...',
return {
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
text: 'Disconnecting...',
color: 'text-orange-600 dark:text-orange-400',
bgColor: 'bg-orange-500/10 dark:bg-orange-400/10',
borderColor: 'border-orange-500/20 dark:border-orange-400/20'
};
case 'DISCONNECTED':
return {
icon: <WifiOff className="h-4 w-4" />,
text: 'Disconnected',
return {
icon: <WifiOff className="h-4 w-4"/>,
text: 'Disconnected',
color: 'text-muted-foreground',
bgColor: 'bg-muted/30',
borderColor: 'border-border'
};
case 'WAITING':
return {
icon: <Clock className="h-4 w-4" />,
icon: <Clock className="h-4 w-4"/>,
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
};
case 'ERROR':
case 'FAILED':
return {
icon: <AlertCircle className="h-4 w-4" />,
text: status.reason || 'Error',
return {
icon: <AlertCircle className="h-4 w-4"/>,
text: status.reason || 'Error',
color: 'text-red-600 dark:text-red-400',
bgColor: 'bg-red-500/10 dark:bg-red-400/10',
borderColor: 'border-red-500/20 dark:border-red-400/20'
};
default:
return {
icon: <WifiOff className="h-4 w-4" />,
text: statusValue,
return {
icon: <WifiOff className="h-4 w-4"/>,
text: statusValue,
color: 'text-muted-foreground',
bgColor: 'bg-muted/30',
borderColor: 'border-border'
@@ -153,7 +167,7 @@ export function SSHTunnelObject({
{/* Host Header */}
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0" />}
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-card-foreground truncate">
{host.name || `${host.username}@${host.ip}`}
@@ -164,13 +178,13 @@ export function SSHTunnelObject({
</div>
</div>
</div>
{/* Tags */}
{host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{host.tags.slice(0, 3).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 className="h-2 w-2 mr-0.5"/>
{tag}
</Badge>
))}
@@ -181,13 +195,13 @@ export function SSHTunnelObject({
)}
</div>
)}
<Separator className="mb-3" />
<Separator className="mb-3"/>
{/* Tunnel Connections */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
<Network className="h-4 w-4" />
<Network className="h-4 w-4"/>
Tunnel Connections ({host.tunnelConnections.length})
</h4>
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
@@ -203,9 +217,10 @@ export function SSHTunnelObject({
const isDisconnecting = statusValue === 'DISCONNECTING';
const isRetrying = statusValue === 'RETRYING';
const isWaiting = statusValue === 'WAITING';
return (
<div key={tunnelIndex} className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
<div key={tunnelIndex}
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
{/* Tunnel Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
@@ -224,7 +239,7 @@ export function SSHTunnelObject({
<div className="flex items-center gap-1 flex-shrink-0">
{tunnel.autoStart && (
<Badge variant="outline" className="text-xs px-2 py-1">
<Zap className="h-3 w-3 mr-1" />
<Zap className="h-3 w-3 mr-1"/>
Auto
</Badge>
)}
@@ -239,7 +254,7 @@ export function SSHTunnelObject({
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
<Square className="h-3 w-3 mr-1" />
<Square className="h-3 w-3 mr-1"/>
Disconnect
</Button>
</>
@@ -250,7 +265,7 @@ export function SSHTunnelObject({
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
>
<X className="h-3 w-3 mr-1" />
<X className="h-3 w-3 mr-1"/>
Cancel
</Button>
) : (
@@ -261,7 +276,7 @@ export function SSHTunnelObject({
disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
>
<Play className="h-3 w-3 mr-1" />
<Play className="h-3 w-3 mr-1"/>
Connect
</Button>
)}
@@ -274,31 +289,42 @@ export function SSHTunnelObject({
disabled
className="h-7 px-2 text-muted-foreground border-border text-xs"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
<Loader2 className="h-3 w-3 mr-1 animate-spin"/>
{isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'}
</Button>
)}
</div>
</div>
{/* Error/Status Reason */}
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">Error:</div>
{status.reason}
{status.reason && status.reason.includes('Max retries exhausted') && (
<>
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
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-600 dark: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-600 dark:text-blue-400">GitHub issue</a> for help.
<div
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
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-600 dark: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-600 dark:text-blue-400">GitHub
issue</a> for help.
</div>
</>
)}
</div>
)}
{/* Retry Info */}
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div className="font-medium mb-1">
{statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'}
</div>
@@ -316,7 +342,7 @@ export function SSHTunnelObject({
</div>
) : (
<div className="text-center py-4 text-muted-foreground">
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
<Network className="h-8 w-8 mx-auto mb-2 opacity-50"/>
<p className="text-sm">No tunnel connections configured</p>
</div>
)}

View File

@@ -27,7 +27,7 @@ interface SidebarProps {
onSelectView: (view: string) => void;
}
export function SSHTunnelSidebar({ onSelectView }: SidebarProps): React.ReactElement {
export function SSHTunnelSidebar({onSelectView}: SidebarProps): React.ReactElement {
return (
<SidebarProvider>
<Sidebar>
@@ -36,16 +36,17 @@ export function SSHTunnelSidebar({ onSelectView }: SidebarProps): React.ReactEle
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / Tunnel
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent className="flex flex-col flex-grow">
<SidebarMenu>
<SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")} variant="outline">
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
variant="outline">
<CornerDownLeft className="h-4 w-4 mr-2"/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1" />
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
</SidebarMenu>

View File

@@ -1,9 +1,9 @@
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";
import { Input } from "@/components/ui/input.tsx";
import { Search } from "lucide-react";
import {SSHTunnelObject} from "./SSHTunnelObject.tsx";
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Search} from "lucide-react";
interface TunnelConnection {
sourcePort: number;
@@ -50,25 +50,23 @@ interface SSHTunnelViewerProps {
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
}
export function SSHTunnelViewer({
hosts = [],
tunnelStatuses = {},
tunnelActions = {},
onTunnelAction
}: SSHTunnelViewerProps): React.ReactElement {
export function SSHTunnelViewer({
hosts = [],
tunnelStatuses = {},
tunnelActions = {},
onTunnelAction
}: SSHTunnelViewerProps): React.ReactElement {
const [searchQuery, setSearchQuery] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState("");
// Debounce search
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200);
return () => clearTimeout(handler);
}, [searchQuery]);
// Filter hosts by search query
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const query = debouncedSearch.trim().toLowerCase();
return hosts.filter(host => {
const searchableText = [
@@ -84,16 +82,14 @@ export function SSHTunnelViewer({
});
}, [hosts, debouncedSearch]);
// Filter hosts to only show those with enableTunnel: true and tunnelConnections
const tunnelHosts = React.useMemo(() => {
return filteredHosts.filter(host =>
host.enableTunnel &&
host.tunnelConnections &&
return filteredHosts.filter(host =>
host.enableTunnel &&
host.tunnelConnections &&
host.tunnelConnections.length > 0
);
}, [filteredHosts]);
// Group hosts by folder and sort
const hostsByFolder = React.useMemo(() => {
const map: Record<string, SSHHost[]> = {};
tunnelHosts.forEach(host => {
@@ -121,9 +117,8 @@ export function SSHTunnelViewer({
};
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="w-full p-6" style={{width: 'calc(100vw - 256px)', maxWidth: 'none'}}>
<div className="w-full min-w-0" style={{width: '100%', maxWidth: 'none'}}>
<div className="mb-6">
<h1 className="text-2xl font-bold text-foreground mb-2">
SSH Tunnels
@@ -133,9 +128,9 @@ export function SSHTunnelViewer({
</p>
</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" />
<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}
@@ -144,14 +139,13 @@ export function SSHTunnelViewer({
/>
</div>
{/* Accordion Layout */}
{tunnelHosts.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">
{searchQuery.trim() ?
{searchQuery.trim() ?
"No hosts match your search criteria." :
"Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections."
}
@@ -160,8 +154,10 @@ export function SSHTunnelViewer({
) : (
<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}}>
<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">
@@ -185,4 +181,4 @@ export function SSHTunnelViewer({
</div>
</div>
);
}
}

View File

@@ -1,541 +0,0 @@
// SSH Host Management API functions
import axios from 'axios';
interface SSHHostData {
name?: string;
ip: string;
port: number;
username: string;
folder?: string;
tags?: string[];
pin?: boolean;
authType: 'password' | 'key';
password?: string;
key?: File | null;
keyPassword?: string;
keyType?: string;
enableTerminal?: boolean;
enableTunnel?: boolean;
enableConfigEditor?: boolean;
defaultPath?: string;
tunnelConnections?: any[];
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface TunnelConfig {
name: string;
hostName: string;
sourceIP: string;
sourceSSHPort: number;
sourceUsername: string;
sourcePassword?: string;
sourceAuthMethod: string;
sourceSSHKey?: string;
sourceKeyPassword?: string;
sourceKeyType?: string;
endpointIP: string;
endpointSSHPort: number;
endpointUsername: string;
endpointPassword?: string;
endpointAuthMethod: string;
endpointSSHKey?: string;
endpointKeyPassword?: string;
endpointKeyType?: string;
sourcePort: number;
endpointPort: number;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
isPinned: boolean;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
// Determine the base URL based on environment
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const baseURL = isLocalhost ? 'http://localhost:8081' : window.location.origin;
// Create axios instance with base configuration for database operations (port 8081)
const api = axios.create({
baseURL,
headers: {
'Content-Type': 'application/json',
},
});
// Create config editor API instance for file operations (port 8084)
const configEditorApi = axios.create({
baseURL: isLocalhost ? 'http://localhost:8084' : `${window.location.origin}/ssh`,
headers: {
'Content-Type': 'application/json',
},
});
// Create tunnel API instance
const tunnelApi = axios.create({
headers: {
'Content-Type': 'application/json',
},
});
function getCookie(name: string): string | undefined {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift();
}
// Add request interceptor to include JWT token for all API instances
api.interceptors.request.use((config) => {
const token = getCookie('jwt');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
configEditorApi.interceptors.request.use((config) => {
const token = getCookie('jwt');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
tunnelApi.interceptors.request.use((config) => {
const token = getCookie('jwt');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Get all SSH hosts - FIXED: Changed from /ssh/host to /ssh/db/host
export async function getSSHHosts(): Promise<SSHHost[]> {
try {
const response = await api.get('/ssh/db/host');
return response.data;
} catch (error) {
console.error('Error fetching SSH hosts:', error);
throw error;
}
}
// Create new SSH host
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
try {
// Prepare the data according to your backend schema
const submitData = {
name: hostData.name || '',
ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22,
username: hostData.username,
folder: hostData.folder || '',
tags: hostData.tags || [],
pin: hostData.pin || false,
authMethod: hostData.authType,
password: hostData.authType === 'password' ? hostData.password : '',
key: hostData.authType === 'key' ? hostData.key : null,
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
keyType: hostData.authType === 'key' ? hostData.keyType : '',
enableTerminal: hostData.enableTerminal !== false,
enableTunnel: hostData.enableTunnel !== false,
enableConfigEditor: hostData.enableConfigEditor !== false,
defaultPath: hostData.defaultPath || '/',
tunnelConnections: hostData.tunnelConnections || [],
};
if (!submitData.enableTunnel) {
submitData.tunnelConnections = [];
}
if (!submitData.enableConfigEditor) {
submitData.defaultPath = '';
}
if (hostData.authType === 'key' && hostData.key instanceof File) {
const formData = new FormData();
formData.append('key', hostData.key);
const dataWithoutFile = { ...submitData };
delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile));
const response = await api.post('/ssh/db/host', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
} else {
const response = await api.post('/ssh/db/host', submitData);
return response.data;
}
} catch (error) {
console.error('Error creating SSH host:', error);
throw error;
}
}
// Update existing SSH host
export async function updateSSHHost(hostId: number, hostData: SSHHostData): Promise<SSHHost> {
try {
const submitData = {
name: hostData.name || '',
ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22,
username: hostData.username,
folder: hostData.folder || '',
tags: hostData.tags || [],
pin: hostData.pin || false,
authMethod: hostData.authType,
password: hostData.authType === 'password' ? hostData.password : '',
key: hostData.authType === 'key' ? hostData.key : null,
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
keyType: hostData.authType === 'key' ? hostData.keyType : '',
enableTerminal: hostData.enableTerminal !== false,
enableTunnel: hostData.enableTunnel !== false,
enableConfigEditor: hostData.enableConfigEditor !== false,
defaultPath: hostData.defaultPath || '/',
tunnelConnections: hostData.tunnelConnections || [],
};
if (!submitData.enableTunnel) {
submitData.tunnelConnections = [];
}
if (!submitData.enableConfigEditor) {
submitData.defaultPath = '';
}
if (hostData.authType === 'key' && hostData.key instanceof File) {
const formData = new FormData();
formData.append('key', hostData.key);
const dataWithoutFile = { ...submitData };
delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile));
const response = await api.put(`/ssh/db/host/${hostId}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
} else {
const response = await api.put(`/ssh/db/host/${hostId}`, submitData);
return response.data;
}
} catch (error) {
console.error('Error updating SSH host:', error);
throw error;
}
}
// Delete SSH host
export async function deleteSSHHost(hostId: number): Promise<any> {
try {
const response = await api.delete(`/ssh/db/host/${hostId}`);
return response.data;
} catch (error) {
console.error('Error deleting SSH host:', error);
throw error;
}
}
// Get SSH host by ID
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
try {
const response = await api.get(`/ssh/db/host/${hostId}`);
return response.data;
} catch (error) {
console.error('Error fetching SSH host:', error);
throw error;
}
}
// Tunnel-related functions
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
try {
const tunnelUrl = isLocalhost ? 'http://localhost:8083/status' : `${baseURL}/ssh_tunnel/status`;
const response = await tunnelApi.get(tunnelUrl);
return response.data || {};
} catch (error) {
console.error('Error fetching tunnel statuses:', error);
throw error;
}
}
export async function getTunnelStatusByName(tunnelName: string): Promise<TunnelStatus | undefined> {
const statuses = await getTunnelStatuses();
return statuses[tunnelName];
}
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
try {
const tunnelUrl = isLocalhost ? 'http://localhost:8083/connect' : `${baseURL}/ssh_tunnel/connect`;
const response = await tunnelApi.post(tunnelUrl, tunnelConfig);
return response.data;
} catch (error) {
console.error('Error connecting tunnel:', error);
throw error;
}
}
export async function disconnectTunnel(tunnelName: string): Promise<any> {
try {
const tunnelUrl = isLocalhost ? 'http://localhost:8083/disconnect' : `${baseURL}/ssh_tunnel/disconnect`;
const response = await tunnelApi.post(tunnelUrl, { tunnelName });
return response.data;
} catch (error) {
console.error('Error disconnecting tunnel:', error);
throw error;
}
}
export async function cancelTunnel(tunnelName: string): Promise<any> {
try {
const tunnelUrl = isLocalhost ? 'http://localhost:8083/cancel' : `${baseURL}/ssh_tunnel/cancel`;
const response = await tunnelApi.post(tunnelUrl, { tunnelName });
return response.data;
} catch (error) {
console.error('Error canceling tunnel:', error);
throw error;
}
}
export { api, configEditorApi };
// Config Editor API functions
interface ConfigEditorFile {
name: string;
path: string;
type?: 'file' | 'directory';
isSSH?: boolean;
sshSessionId?: string;
}
interface ConfigEditorShortcut {
name: string;
path: string;
}
// Config Editor database functions (use port 8081 for database operations)
export async function getConfigEditorRecent(hostId: number): Promise<ConfigEditorFile[]> {
try {
console.log('Fetching recent files for host:', hostId);
const response = await api.get(`/ssh/config_editor/recent?hostId=${hostId}`);
console.log('Recent files response:', response.data);
return response.data || [];
} catch (error) {
console.error('Error fetching recent files:', error);
return [];
}
}
export async function addConfigEditorRecent(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
try {
console.log('Making request to add recent file:', file);
const response = await api.post('/ssh/config_editor/recent', file);
console.log('Add recent file response:', response.data);
return response.data;
} catch (error) {
console.error('Error adding recent file:', error);
throw error;
}
}
export async function removeConfigEditorRecent(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
try {
const response = await api.delete('/ssh/config_editor/recent', { data: file });
return response.data;
} catch (error) {
console.error('Error removing recent file:', error);
throw error;
}
}
export async function getConfigEditorPinned(hostId: number): Promise<ConfigEditorFile[]> {
try {
const response = await api.get(`/ssh/config_editor/pinned?hostId=${hostId}`);
return response.data || [];
} catch (error) {
console.error('Error fetching pinned files:', error);
return [];
}
}
export async function addConfigEditorPinned(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
try {
const response = await api.post('/ssh/config_editor/pinned', file);
return response.data;
} catch (error) {
console.error('Error adding pinned file:', error);
throw error;
}
}
export async function removeConfigEditorPinned(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
try {
const response = await api.delete('/ssh/config_editor/pinned', { data: file });
return response.data;
} catch (error) {
console.error('Error removing pinned file:', error);
throw error;
}
}
export async function getConfigEditorShortcuts(hostId: number): Promise<ConfigEditorShortcut[]> {
try {
const response = await api.get(`/ssh/config_editor/shortcuts?hostId=${hostId}`);
return response.data || [];
} catch (error) {
console.error('Error fetching shortcuts:', error);
return [];
}
}
export async function addConfigEditorShortcut(shortcut: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
try {
const response = await api.post('/ssh/config_editor/shortcuts', shortcut);
return response.data;
} catch (error) {
console.error('Error adding shortcut:', error);
throw error;
}
}
export async function removeConfigEditorShortcut(shortcut: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
try {
const response = await api.delete('/ssh/config_editor/shortcuts', { data: shortcut });
return response.data;
} catch (error) {
console.error('Error removing shortcut:', error);
throw error;
}
}
// SSH file operations - FIXED: Using configEditorApi for port 8084
export async function connectSSH(sessionId: string, config: {
ip: string;
port: number;
username: string;
password?: string;
sshKey?: string;
keyPassword?: string;
}): Promise<any> {
try {
const response = await configEditorApi.post('/ssh/config_editor/ssh/connect', {
sessionId,
...config
});
return response.data;
} catch (error) {
console.error('Error connecting SSH:', error);
throw error;
}
}
export async function disconnectSSH(sessionId: string): Promise<any> {
try {
const response = await configEditorApi.post('/ssh/config_editor/ssh/disconnect', { sessionId });
return response.data;
} catch (error) {
console.error('Error disconnecting SSH:', error);
throw error;
}
}
export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
try {
const response = await configEditorApi.get('/ssh/config_editor/ssh/status', {
params: { sessionId }
});
return response.data;
} catch (error) {
console.error('Error getting SSH status:', error);
throw error;
}
}
export async function listSSHFiles(sessionId: string, path: string): Promise<any[]> {
try {
const response = await configEditorApi.get('/ssh/config_editor/ssh/listFiles', {
params: { sessionId, path }
});
return response.data || [];
} catch (error) {
console.error('Error listing SSH files:', error);
throw error;
}
}
export async function readSSHFile(sessionId: string, path: string): Promise<{ content: string; path: string }> {
try {
const response = await configEditorApi.get('/ssh/config_editor/ssh/readFile', {
params: { sessionId, path }
});
return response.data;
} catch (error) {
console.error('Error reading SSH file:', error);
throw error;
}
}
export async function writeSSHFile(sessionId: string, path: string, content: string): Promise<any> {
try {
console.log('Making writeSSHFile request:', { sessionId, path, contentLength: content.length });
const response = await configEditorApi.post('/ssh/config_editor/ssh/writeFile', {
sessionId,
path,
content
});
console.log('writeSSHFile response:', response.data);
// Check if the response indicates success
if (response.data && (response.data.message === 'File written successfully' || response.status === 200)) {
console.log('File write operation completed successfully');
return response.data;
} else {
throw new Error('File write operation did not return success status');
}
} catch (error) {
console.error('Error writing SSH file:', error);
console.error('Error type:', typeof error);
console.error('Error constructor:', error?.constructor?.name);
console.error('Error response:', (error as any)?.response);
console.error('Error response data:', (error as any)?.response?.data);
console.error('Error response status:', (error as any)?.response?.status);
throw error;
}
}

View File

@@ -1,4 +1,3 @@
// SSH Host Management API functions
import axios from 'axios';
interface SSHHostData {
@@ -81,11 +80,9 @@ interface TunnelStatus {
retryExhausted?: boolean;
}
// Determine the base URL based on environment
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const baseURL = isLocalhost ? 'http://localhost:8081' : window.location.origin;
// Create axios instance with base configuration for database operations (port 8081)
const api = axios.create({
baseURL,
headers: {
@@ -93,7 +90,6 @@ const api = axios.create({
},
});
// Create config editor API instance for file operations (port 8084)
const configEditorApi = axios.create({
baseURL: isLocalhost ? 'http://localhost:8084' : `${window.location.origin}/ssh`,
headers: {
@@ -101,7 +97,6 @@ const configEditorApi = axios.create({
},
});
// Create tunnel API instance
const tunnelApi = axios.create({
headers: {
'Content-Type': 'application/json',
@@ -114,7 +109,6 @@ function getCookie(name: string): string | undefined {
if (parts.length === 2) return parts.pop()?.split(';').shift();
}
// Add request interceptor to include JWT token for all API instances
api.interceptors.request.use((config) => {
const token = getCookie('jwt');
if (token) {
@@ -139,21 +133,17 @@ tunnelApi.interceptors.request.use((config) => {
return config;
});
// Get all SSH hosts - FIXED: Changed from /ssh/host to /ssh/db/host
export async function getSSHHosts(): Promise<SSHHost[]> {
try {
const response = await api.get('/ssh/db/host');
return response.data;
} catch (error) {
console.error('Error fetching SSH hosts:', error);
throw error;
}
}
// Create new SSH host
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
try {
// Prepare the data according to your backend schema
const submitData = {
name: hostData.name || '',
ip: hostData.ip,
@@ -186,7 +176,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
const formData = new FormData();
formData.append('key', hostData.key);
const dataWithoutFile = { ...submitData };
const dataWithoutFile = {...submitData};
delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile));
@@ -202,12 +192,10 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
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 = {
@@ -241,7 +229,7 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
const formData = new FormData();
formData.append('key', hostData.key);
const dataWithoutFile = { ...submitData };
const dataWithoutFile = {...submitData};
delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile));
@@ -257,41 +245,34 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
return response.data;
}
} catch (error) {
console.error('Error updating SSH host:', error);
throw error;
}
}
// Delete SSH host
export async function deleteSSHHost(hostId: number): Promise<any> {
try {
const response = await api.delete(`/ssh/db/host/${hostId}`);
return response.data;
} catch (error) {
console.error('Error deleting SSH host:', error);
throw error;
}
}
// Get SSH host by ID
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
try {
const response = await api.get(`/ssh/db/host/${hostId}`);
return response.data;
} catch (error) {
console.error('Error fetching SSH host:', error);
throw error;
}
}
// Tunnel-related functions
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
try {
const tunnelUrl = isLocalhost ? 'http://localhost:8083/status' : `${baseURL}/ssh_tunnel/status`;
const tunnelUrl = isLocalhost ? 'http://localhost:8083/ssh/tunnel/status' : `${baseURL}/ssh_tunnel/status`;
const response = await tunnelApi.get(tunnelUrl);
return response.data || {};
} catch (error) {
console.error('Error fetching tunnel statuses:', error);
throw error;
}
}
@@ -303,40 +284,36 @@ export async function getTunnelStatusByName(tunnelName: string): Promise<TunnelS
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
try {
const tunnelUrl = isLocalhost ? 'http://localhost:8083/connect' : `${baseURL}/ssh_tunnel/connect`;
const tunnelUrl = isLocalhost ? 'http://localhost:8083/ssh/tunnel/connect' : `${baseURL}/ssh_tunnel/connect`;
const response = await tunnelApi.post(tunnelUrl, tunnelConfig);
return response.data;
} catch (error) {
console.error('Error connecting tunnel:', error);
throw error;
}
}
export async function disconnectTunnel(tunnelName: string): Promise<any> {
try {
const tunnelUrl = isLocalhost ? 'http://localhost:8083/disconnect' : `${baseURL}/ssh_tunnel/disconnect`;
const response = await tunnelApi.post(tunnelUrl, { tunnelName });
const tunnelUrl = isLocalhost ? 'http://localhost:8083/ssh/tunnel/disconnect' : `${baseURL}/ssh_tunnel/disconnect`;
const response = await tunnelApi.post(tunnelUrl, {tunnelName});
return response.data;
} catch (error) {
console.error('Error disconnecting tunnel:', error);
throw error;
}
}
export async function cancelTunnel(tunnelName: string): Promise<any> {
try {
const tunnelUrl = isLocalhost ? 'http://localhost:8083/cancel' : `${baseURL}/ssh_tunnel/cancel`;
const response = await tunnelApi.post(tunnelUrl, { tunnelName });
const tunnelUrl = isLocalhost ? 'http://localhost:8083/ssh/tunnel/cancel' : `${baseURL}/ssh_tunnel/cancel`;
const response = await tunnelApi.post(tunnelUrl, {tunnelName});
return response.data;
} catch (error) {
console.error('Error canceling tunnel:', error);
throw error;
}
}
export { api, configEditorApi };
export {api, configEditorApi};
// Config Editor API functions
interface ConfigEditorFile {
name: string;
path: string;
@@ -350,32 +327,42 @@ interface ConfigEditorShortcut {
path: string;
}
// Config Editor database functions (use port 8081 for database operations)
export async function getConfigEditorRecent(hostId: number): Promise<ConfigEditorFile[]> {
try {
const response = await api.get(`/ssh/config_editor/recent?hostId=${hostId}`);
return response.data || [];
} catch (error) {
console.error('Error fetching recent files:', error);
return [];
}
}
export async function addConfigEditorRecent(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
export async function addConfigEditorRecent(file: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await api.post('/ssh/config_editor/recent', file);
return response.data;
} catch (error) {
console.error('Error adding recent file:', error);
throw error;
}
}
export async function removeConfigEditorRecent(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
export async function removeConfigEditorRecent(file: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await api.delete('/ssh/config_editor/recent', { data: file });
const response = await api.delete('/ssh/config_editor/recent', {data: file});
return response.data;
} catch (error) {
console.error('Error removing recent file:', error);
throw error;
}
}
@@ -384,26 +371,37 @@ export async function getConfigEditorPinned(hostId: number): Promise<ConfigEdito
const response = await api.get(`/ssh/config_editor/pinned?hostId=${hostId}`);
return response.data || [];
} catch (error) {
console.error('Error fetching pinned files:', error);
return [];
}
}
export async function addConfigEditorPinned(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
export async function addConfigEditorPinned(file: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await api.post('/ssh/config_editor/pinned', file);
return response.data;
} catch (error) {
console.error('Error adding pinned file:', error);
throw error;
}
}
export async function removeConfigEditorPinned(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
export async function removeConfigEditorPinned(file: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await api.delete('/ssh/config_editor/pinned', { data: file });
const response = await api.delete('/ssh/config_editor/pinned', {data: file});
return response.data;
} catch (error) {
console.error('Error removing pinned file:', error);
throw error;
}
}
@@ -412,30 +410,40 @@ export async function getConfigEditorShortcuts(hostId: number): Promise<ConfigEd
const response = await api.get(`/ssh/config_editor/shortcuts?hostId=${hostId}`);
return response.data || [];
} catch (error) {
console.error('Error fetching shortcuts:', error);
return [];
}
}
export async function addConfigEditorShortcut(shortcut: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
export async function addConfigEditorShortcut(shortcut: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await api.post('/ssh/config_editor/shortcuts', shortcut);
return response.data;
} catch (error) {
console.error('Error adding shortcut:', error);
throw error;
}
}
export async function removeConfigEditorShortcut(shortcut: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
export async function removeConfigEditorShortcut(shortcut: {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number
}): Promise<any> {
try {
const response = await api.delete('/ssh/config_editor/shortcuts', { data: shortcut });
const response = await api.delete('/ssh/config_editor/shortcuts', {data: shortcut});
return response.data;
} catch (error) {
console.error('Error removing shortcut:', error);
throw error;
}
}
// SSH file operations - FIXED: Using configEditorApi for port 8084
export async function connectSSH(sessionId: string, config: {
ip: string;
port: number;
@@ -451,17 +459,15 @@ export async function connectSSH(sessionId: string, config: {
});
return response.data;
} catch (error) {
console.error('Error connecting SSH:', error);
throw error;
}
}
export async function disconnectSSH(sessionId: string): Promise<any> {
try {
const response = await configEditorApi.post('/ssh/config_editor/ssh/disconnect', { sessionId });
const response = await configEditorApi.post('/ssh/config_editor/ssh/disconnect', {sessionId});
return response.data;
} catch (error) {
console.error('Error disconnecting SSH:', error);
throw error;
}
}
@@ -469,11 +475,10 @@ export async function disconnectSSH(sessionId: string): Promise<any> {
export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
try {
const response = await configEditorApi.get('/ssh/config_editor/ssh/status', {
params: { sessionId }
params: {sessionId}
});
return response.data;
} catch (error) {
console.error('Error getting SSH status:', error);
throw error;
}
}
@@ -481,11 +486,10 @@ export async function getSSHStatus(sessionId: string): Promise<{ connected: bool
export async function listSSHFiles(sessionId: string, path: string): Promise<any[]> {
try {
const response = await configEditorApi.get('/ssh/config_editor/ssh/listFiles', {
params: { sessionId, path }
params: {sessionId, path}
});
return response.data || [];
} catch (error) {
console.error('Error listing SSH files:', error);
throw error;
}
}
@@ -493,11 +497,10 @@ export async function listSSHFiles(sessionId: string, path: string): Promise<any
export async function readSSHFile(sessionId: string, path: string): Promise<{ content: string; path: string }> {
try {
const response = await configEditorApi.get('/ssh/config_editor/ssh/readFile', {
params: { sessionId, path }
params: {sessionId, path}
});
return response.data;
} catch (error) {
console.error('Error reading SSH file:', error);
throw error;
}
}
@@ -509,9 +512,13 @@ export async function writeSSHFile(sessionId: string, path: string, content: str
path,
content
});
return response.data;
if (response.data && (response.data.message === 'File written successfully' || response.status === 200)) {
return response.data;
} else {
throw new Error('File write operation did not return success status');
}
} catch (error) {
console.error('Error writing SSH file:', error);
throw error;
}
}

View File

@@ -5,7 +5,7 @@ interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
export function Template({ onSelectView }: ConfigEditorProps): React.ReactElement {
export function Template({onSelectView}: ConfigEditorProps): React.ReactElement {
return (
<div>
<TemplateSidebar

View File

@@ -26,7 +26,7 @@ interface SidebarProps {
onSelectView: (view: string) => void;
}
export function TemplateSidebar({ onSelectView }: SidebarProps): React.ReactElement {
export function TemplateSidebar({onSelectView}: SidebarProps): React.ReactElement {
return (
<SidebarProvider>
<Sidebar>
@@ -35,17 +35,17 @@ export function TemplateSidebar({ onSelectView }: SidebarProps): React.ReactElem
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / Template
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1" />
<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">
<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" />
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
</SidebarMenu>

View File

@@ -1,6 +1,6 @@
import express from 'express';
import cors from 'cors';
import { Client as SSHClient } from 'ssh2';
import {Client as SSHClient} from 'ssh2';
import chalk from "chalk";
const app = express();
@@ -38,23 +38,25 @@ const logger = {
}
};
// --- SSH Operations (per-session, in-memory, with cleanup) ---
interface SSHSession {
client: SSHClient;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
}
const sshSessions: Record<string, SSHSession> = {};
const SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
const SESSION_TIMEOUT_MS = 10 * 60 * 1000;
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
try { session.client.end(); } catch {}
try {
session.client.end();
} catch {
}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
logger.info(`Cleaned up SSH session: ${sessionId}`);
}
}
@@ -67,129 +69,111 @@ function scheduleSessionCleanup(sessionId: string) {
}
app.post('/ssh/config_editor/ssh/connect', (req, res) => {
const { sessionId, ip, port, username, password, sshKey, keyPassword } = req.body;
const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body;
if (!sessionId || !ip || !username || !port) {
logger.warn('Missing SSH connection parameters');
return res.status(400).json({ error: 'Missing SSH connection parameters' });
return res.status(400).json({error: 'Missing SSH connection parameters'});
}
logger.info(`Attempting SSH connection: ${ip}:${port} as ${username} (session: ${sessionId})`);
logger.info(`Auth method: ${sshKey ? 'SSH Key' : password ? 'Password' : 'None'}`);
logger.info(`Request body keys: ${Object.keys(req.body).join(', ')}`);
logger.info(`Password present: ${!!password}, Key present: ${!!sshKey}`);
if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId);
const client = new SSHClient();
const config: any = {
host: ip,
port: port || 22,
host: ip,
port: port || 22,
username,
readyTimeout: 20000,
keepaliveInterval: 10000,
readyTimeout: 20000,
keepaliveInterval: 10000,
keepaliveCountMax: 3,
};
if (sshKey && sshKey.trim()) {
config.privateKey = sshKey;
if (keyPassword) config.passphrase = keyPassword;
logger.info('Using SSH key authentication');
if (sshKey && sshKey.trim()) {
config.privateKey = sshKey;
if (keyPassword) config.passphrase = keyPassword;
} else if (password && password.trim()) {
config.password = password;
} else {
return res.status(400).json({error: 'Either password or SSH key must be provided'});
}
else if (password && password.trim()) {
config.password = password;
logger.info('Using password authentication');
}
else {
logger.warn('No password or key provided');
return res.status(400).json({ error: 'Either password or SSH key must be provided' });
}
// Create a response promise to handle async connection
let responseSent = false;
client.on('ready', () => {
if (responseSent) return;
responseSent = true;
sshSessions[sessionId] = { client, isConnected: true, lastActive: Date.now() };
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
scheduleSessionCleanup(sessionId);
logger.info(`SSH connected: ${ip}:${port} as ${username} (session: ${sessionId})`);
res.json({ status: 'success', message: 'SSH connection established' });
res.json({status: 'success', message: 'SSH connection established'});
});
client.on('error', (err) => {
if (responseSent) return;
responseSent = true;
logger.error(`SSH connection error for session ${sessionId}:`, err.message);
logger.error(`Connection details: ${ip}:${port} as ${username}`);
res.status(500).json({ status: 'error', message: err.message });
res.status(500).json({status: 'error', message: err.message});
});
client.on('close', () => {
logger.info(`SSH connection closed for session ${sessionId}`);
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
cleanupSession(sessionId);
});
client.connect(config);
});
app.post('/ssh/config_editor/ssh/disconnect', (req, res) => {
const { sessionId } = req.body;
const {sessionId} = req.body;
cleanupSession(sessionId);
res.json({ status: 'success', message: 'SSH connection disconnected' });
res.json({status: 'success', message: 'SSH connection disconnected'});
});
app.get('/ssh/config_editor/ssh/status', (req, res) => {
const sessionId = req.query.sessionId as string;
const isConnected = !!sshSessions[sessionId]?.isConnected;
res.json({ status: 'success', connected: isConnected });
res.json({status: 'success', connected: isConnected});
});
app.get('/ssh/config_editor/ssh/listFiles', (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const sshPath = decodeURIComponent((req.query.path as string) || '/');
if (!sessionId) {
logger.warn('Session ID is required for listFiles');
return res.status(400).json({ error: 'Session ID is required' });
return res.status(400).json({error: 'Session ID is required'});
}
if (!sshConn?.isConnected) {
logger.warn(`SSH connection not established for session: ${sessionId}`);
return res.status(400).json({ error: 'SSH connection not established' });
return res.status(400).json({error: 'SSH connection not established'});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
// Escape the path properly for shell command
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
if (err) {
logger.error('SSH listFiles error:', err);
return res.status(500).json({ error: err.message });
if (err) {
logger.error('SSH listFiles error:', err);
return res.status(500).json({error: err.message});
}
let data = '';
let errorData = '';
stream.on('data', (chunk: Buffer) => {
data += chunk.toString();
stream.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on('close', (code) => {
if (code !== 0) {
logger.error(`SSH listFiles command failed with code ${code}: ${errorData}`);
return res.status(500).json({ error: `Command failed: ${errorData}` });
logger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
return res.status(500).json({error: `Command failed: ${errorData}`});
}
const lines = data.split('\n').filter(line => line.trim());
const files = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
@@ -198,17 +182,16 @@ app.get('/ssh/config_editor/ssh/listFiles', (req, res) => {
const name = parts.slice(8).join(' ');
const isDirectory = permissions.startsWith('d');
const isLink = permissions.startsWith('l');
// Skip . and .. directories
if (name === '.' || name === '..') continue;
files.push({
name,
type: isDirectory ? 'directory' : (isLink ? 'link' : 'file')
files.push({
name,
type: isDirectory ? 'directory' : (isLink ? 'link' : 'file')
});
}
}
res.json(files);
});
});
@@ -218,226 +201,188 @@ app.get('/ssh/config_editor/ssh/readFile', (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const filePath = decodeURIComponent(req.query.path as string);
if (!sessionId) {
logger.warn('Session ID is required for readFile');
return res.status(400).json({ error: 'Session ID is required' });
return res.status(400).json({error: 'Session ID is required'});
}
if (!sshConn?.isConnected) {
logger.warn(`SSH connection not established for session: ${sessionId}`);
return res.status(400).json({ error: 'SSH connection not established' });
return res.status(400).json({error: 'SSH connection not established'});
}
if (!filePath) {
logger.warn('File path is required for readFile');
return res.status(400).json({ error: 'File path is required' });
return res.status(400).json({error: 'File path is required'});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
// Escape the file path properly
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
if (err) {
logger.error('SSH readFile error:', err);
return res.status(500).json({ error: err.message });
if (err) {
logger.error('SSH readFile error:', err);
return res.status(500).json({error: err.message});
}
let data = '';
let errorData = '';
stream.on('data', (chunk: Buffer) => {
data += chunk.toString();
stream.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on('close', (code) => {
if (code !== 0) {
logger.error(`SSH readFile command failed with code ${code}: ${errorData}`);
return res.status(500).json({ error: `Command failed: ${errorData}` });
logger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
return res.status(500).json({error: `Command failed: ${errorData}`});
}
res.json({ content: data, path: filePath });
res.json({content: data, path: filePath});
});
});
});
app.post('/ssh/config_editor/ssh/writeFile', (req, res) => {
const { sessionId, path: filePath, content } = req.body;
const {sessionId, path: filePath, content} = req.body;
const sshConn = sshSessions[sessionId];
if (!sessionId) {
logger.warn('Session ID is required for writeFile');
return res.status(400).json({ error: 'Session ID is required' });
return res.status(400).json({error: 'Session ID is required'});
}
if (!sshConn?.isConnected) {
logger.warn(`SSH connection not established for session: ${sessionId}`);
return res.status(400).json({ error: 'SSH connection not established' });
return res.status(400).json({error: 'SSH connection not established'});
}
logger.info(`SSH connection status for session ${sessionId}: connected=${sshConn.isConnected}, lastActive=${new Date(sshConn.lastActive).toISOString()}`);
if (!filePath) {
logger.warn('File path is required for writeFile');
return res.status(400).json({ error: 'File path is required' });
return res.status(400).json({error: 'File path is required'});
}
if (content === undefined) {
logger.warn('File content is required for writeFile');
return res.status(400).json({ error: 'File content is required' });
return res.status(400).json({error: 'File content is required'});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
// Write to a temp file, then move - properly escape paths and content
const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
const escapedFilePath = filePath.replace(/'/g, "'\"'\"'");
// Use base64 encoding to safely transfer content
const base64Content = Buffer.from(content, 'utf8').toString('base64');
logger.info(`Starting writeFile operation: session=${sessionId}, path=${filePath}, contentLength=${content.length}, base64Length=${base64Content.length}`);
// Add timeout to prevent hanging
const commandTimeout = setTimeout(() => {
logger.error(`SSH writeFile command timed out for session: ${sessionId}`);
if (!res.headersSent) {
res.status(500).json({ error: 'SSH command timed out' });
}
}, 15000); // 15 second timeout
// First check file permissions and ownership
const commandTimeout = setTimeout(() => {
logger.error(`SSH writeFile command timed out for session: ${sessionId}`);
if (!res.headersSent) {
res.status(500).json({error: 'SSH command timed out'});
}
}, 15000);
const checkCommand = `ls -la '${escapedFilePath}' 2>/dev/null || echo "File does not exist"`;
logger.info(`Checking file details: ${filePath}`);
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
if (checkErr) {
logger.error('File check failed:', checkErr);
return res.status(500).json({ error: `File check failed: ${checkErr.message}` });
return res.status(500).json({error: `File check failed: ${checkErr.message}`});
}
let checkResult = '';
checkStream.on('data', (chunk: Buffer) => {
checkResult += chunk.toString();
});
checkStream.on('close', (checkCode) => {
logger.info(`File check result: ${checkResult.trim()}`);
// Use a simpler approach: write base64 to temp file, decode and write to target, then clean up
// Add explicit exit to ensure the command completes
const writeCommand = `echo '${base64Content}' > '${escapedTempFile}' && base64 -d '${escapedTempFile}' > '${escapedFilePath}' && rm -f '${escapedTempFile}' && echo "SUCCESS" && exit 0`;
logger.info(`Executing write command for: ${filePath}`);
sshConn.client.exec(writeCommand, (err, stream) => {
if (err) {
if (err) {
clearTimeout(commandTimeout);
logger.error('SSH writeFile error:', err);
logger.error('SSH writeFile error:', err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
return res.status(500).json({error: err.message});
}
return;
}
let outputData = '';
let errorData = '';
stream.on('data', (chunk: Buffer) => {
outputData += chunk.toString();
logger.debug(`SSH writeFile stdout: ${chunk.toString()}`);
});
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
logger.debug(`SSH writeFile stderr: ${chunk.toString()}`);
// Check for permission denied and fail fast
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes('Permission denied')) {
clearTimeout(commandTimeout);
logger.error(`Permission denied writing to file: ${filePath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.`
return res.status(403).json({
error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.`
});
}
return;
}
});
stream.on('close', (code) => {
logger.info(`SSH writeFile command completed with code: ${code}, output: "${outputData.trim()}", error: "${errorData.trim()}"`);
clearTimeout(commandTimeout);
// Check if we got the success message
if (outputData.includes('SUCCESS')) {
// Verify the file was actually written by checking its size
const verifyCommand = `ls -la '${escapedFilePath}' 2>/dev/null | awk '{print $5}'`;
logger.info(`Verifying file was written: ${filePath}`);
sshConn.client.exec(verifyCommand, (verifyErr, verifyStream) => {
if (verifyErr) {
logger.warn('File verification failed, but assuming success:');
if (!res.headersSent) {
res.json({ message: 'File written successfully', path: filePath });
res.json({message: 'File written successfully', path: filePath});
}
return;
}
let verifyResult = '';
verifyStream.on('data', (chunk: Buffer) => {
verifyResult += chunk.toString();
});
verifyStream.on('close', (verifyCode) => {
const fileSize = Number(verifyResult.trim());
logger.info(`File verification result: size=${fileSize} bytes`);
if (fileSize > 0) {
logger.info(`File written successfully: ${filePath} (${fileSize} bytes)`);
if (!res.headersSent) {
res.json({ message: 'File written successfully', path: filePath });
res.json({message: 'File written successfully', path: filePath});
}
} else {
logger.error(`File appears to be empty after write: ${filePath}`);
if (!res.headersSent) {
res.status(500).json({ error: 'File write operation may have failed - file appears empty' });
res.status(500).json({error: 'File write operation may have failed - file appears empty'});
}
}
});
});
return;
}
if (code !== 0) {
logger.error(`SSH writeFile command failed with code ${code}: ${errorData}`);
logger.error(`SSH writeFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
if (!res.headersSent) {
return res.status(500).json({ error: `Command failed: ${errorData}` });
return res.status(500).json({error: `Command failed: ${errorData}`});
}
return;
}
// If code is 0 but no SUCCESS message, assume it worked anyway
// This handles cases where the echo "SUCCESS" didn't work but the file write did
logger.info(`File written successfully (code 0, no SUCCESS message): ${filePath}`);
if (!res.headersSent) {
res.json({ message: 'File written successfully', path: filePath });
res.json({message: 'File written successfully', path: filePath});
}
});
stream.on('error', (streamErr) => {
clearTimeout(commandTimeout);
logger.error('SSH writeFile stream error:', streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
res.status(500).json({error: `Stream error: ${streamErr.message}`});
}
});
});
@@ -456,4 +401,5 @@ process.on('SIGTERM', () => {
});
const PORT = 8084;
app.listen(PORT, () => {});
app.listen(PORT, () => {
});

View File

@@ -41,7 +41,7 @@ const logger = {
app.use(bodyParser.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
res.json({status: 'ok'});
});
app.use('/users', userRoutes);
@@ -49,8 +49,9 @@ app.use('/ssh', sshRoutes);
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal Server Error' });
res.status(500).json({error: 'Internal Server Error'});
});
const PORT = 8081;
app.listen(PORT, () => {});
app.listen(PORT, () => {
});

View File

@@ -1,4 +1,4 @@
import { drizzle } from 'drizzle-orm/better-sqlite3';
import {drizzle} from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import * as schema from './schema.js';
import chalk from 'chalk';
@@ -34,108 +34,296 @@ const logger = {
const dataDir = process.env.DATA_DIR || './db/data';
const dbDir = path.resolve(dataDir);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
fs.mkdirSync(dbDir, {recursive: true});
}
const dbPath = path.join(dataDir, 'db.sqlite');
const sqlite = new Database(dbPath);
// Create tables using Drizzle schema
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS users
(
id
TEXT
PRIMARY
KEY,
username
TEXT
NOT
NULL,
password_hash
TEXT
NOT
NULL,
is_admin
INTEGER
NOT
NULL
DEFAULT
0
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings
(
key
TEXT
PRIMARY
KEY,
value
TEXT
NOT
NULL
);
CREATE TABLE IF NOT EXISTS ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
folder TEXT,
tags TEXT,
pin INTEGER NOT NULL DEFAULT 0,
auth_type TEXT NOT NULL,
password TEXT,
key TEXT,
key_password TEXT,
key_type TEXT,
enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT,
enable_config_editor INTEGER NOT NULL DEFAULT 1,
default_path TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS ssh_data
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
name
TEXT,
ip
TEXT
NOT
NULL,
port
INTEGER
NOT
NULL,
username
TEXT
NOT
NULL,
folder
TEXT,
tags
TEXT,
pin
INTEGER
NOT
NULL
DEFAULT
0,
auth_type
TEXT
NOT
NULL,
password
TEXT,
key
TEXT,
key_password
TEXT,
key_type
TEXT,
enable_terminal
INTEGER
NOT
NULL
DEFAULT
1,
enable_tunnel
INTEGER
NOT
NULL
DEFAULT
1,
tunnel_connections
TEXT,
enable_config_editor
INTEGER
NOT
NULL
DEFAULT
1,
default_path
TEXT,
created_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
updated_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
)
);
CREATE TABLE IF NOT EXISTS config_editor_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(host_id) REFERENCES ssh_data(id)
);
CREATE TABLE IF NOT EXISTS config_editor_recent
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
last_opened
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
);
CREATE TABLE IF NOT EXISTS config_editor_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(host_id) REFERENCES ssh_data(id)
);
CREATE TABLE IF NOT EXISTS config_editor_pinned
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
pinned_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
);
CREATE TABLE IF NOT EXISTS config_editor_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(host_id) REFERENCES ssh_data(id)
);
CREATE TABLE IF NOT EXISTS config_editor_shortcuts
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
created_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
);
`);
// Function to safely add a column if it doesn't exist
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
try {
// Try to select the column to see if it exists
sqlite.prepare(`SELECT ${column} FROM ${table} LIMIT 1`).get();
sqlite.prepare(`SELECT ${column}
FROM ${table} LIMIT 1`).get();
} catch (e) {
// Column doesn't exist, add it
try {
sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`);
sqlite.exec(`ALTER TABLE ${table}
ADD COLUMN ${column} ${definition};`);
} catch (alterError) {
logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`);
}
}
};
// Auto-migrate: Add any missing columns based on current schema
const migrateSchema = () => {
logger.info('Checking for schema updates...');
// Add missing columns to users table
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
// Add missing columns to ssh_data table
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
addColumnIfNotExists('ssh_data', 'tags', 'TEXT');
@@ -153,7 +341,6 @@ const migrateSchema = () => {
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
// Add missing columns to config_editor tables
addColumnIfNotExists('config_editor_recent', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('config_editor_pinned', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('config_editor_shortcuts', 'host_id', 'INTEGER NOT NULL');
@@ -161,10 +348,8 @@ const migrateSchema = () => {
logger.success('Schema migration completed');
};
// Run auto-migration
migrateSchema();
// Initialize default settings
try {
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
if (!row) {
@@ -174,4 +359,4 @@ try {
logger.warn('Could not initialize default settings');
}
export const db = drizzle(sqlite, { schema });
export const db = drizzle(sqlite, {schema});

View File

@@ -1,11 +1,11 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
import {sql} from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text('id').primaryKey(), // Unique user ID (nanoid)
username: text('username').notNull(), // Username
password_hash: text('password_hash').notNull(), // Hashed password
is_admin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), // Admin flag
id: text('id').primaryKey(),
username: text('username').notNull(),
password_hash: text('password_hash').notNull(),
is_admin: integer('is_admin', {mode: 'boolean'}).notNull().default(false),
});
export const settings = sqliteTable('settings', {
@@ -14,52 +14,52 @@ export const settings = sqliteTable('settings', {
});
export const sshData = sqliteTable('ssh_data', {
id: integer('id').primaryKey({ autoIncrement: true }),
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
name: text('name'), // Host name
name: text('name'),
ip: text('ip').notNull(),
port: integer('port').notNull(),
username: text('username').notNull(),
folder: text('folder'),
tags: text('tags'), // JSON stringified array
pin: integer('pin', { mode: 'boolean' }).notNull().default(false),
authType: text('auth_type').notNull(), // 'password' | 'key'
tags: text('tags'),
pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
authType: text('auth_type').notNull(),
password: text('password'),
key: text('key', { length: 8192 }), // Increased for larger keys
keyPassword: text('key_password'), // Password for protected keys
keyType: text('key_type'), // Type of SSH key (RSA, ED25519, etc.)
enableTerminal: integer('enable_terminal', { mode: 'boolean' }).notNull().default(true),
enableTunnel: integer('enable_tunnel', { mode: 'boolean' }).notNull().default(true),
tunnelConnections: text('tunnel_connections'), // JSON stringified array of tunnel connections
enableConfigEditor: integer('enable_config_editor', { mode: 'boolean' }).notNull().default(true),
defaultPath: text('default_path'), // Default path for SSH connection
key: text('key', {length: 8192}),
keyPassword: text('key_password'),
keyType: text('key_type'),
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
tunnelConnections: text('tunnel_connections'),
enableConfigEditor: integer('enable_config_editor', {mode: 'boolean'}).notNull().default(true),
defaultPath: text('default_path'),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const configEditorRecent = sqliteTable('config_editor_recent', {
id: integer('id').primaryKey({ autoIncrement: true }),
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID
name: text('name').notNull(), // File name
path: text('path').notNull(), // File path
hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(),
path: text('path').notNull(),
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const configEditorPinned = sqliteTable('config_editor_pinned', {
id: integer('id').primaryKey({ autoIncrement: true }),
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID
name: text('name').notNull(), // File name
path: text('path').notNull(), // File path
hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(),
path: text('path').notNull(),
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const configEditorShortcuts = sqliteTable('config_editor_shortcuts', {
id: integer('id').primaryKey({ autoIncrement: true }),
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID
name: text('name').notNull(), // Folder name
path: text('path').notNull(), // Folder path
hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(),
path: text('path').notNull(),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -1,11 +1,11 @@
import express from 'express';
import { db } from '../db/index.js';
import { sshData, configEditorRecent, configEditorPinned, configEditorShortcuts } from '../db/schema.js';
import { eq, and, desc } from 'drizzle-orm';
import {db} from '../db/index.js';
import {sshData, configEditorRecent, configEditorPinned, configEditorShortcuts} from '../db/schema.js';
import {eq, and, desc} from 'drizzle-orm';
import chalk from 'chalk';
import jwt from 'jsonwebtoken';
import multer from 'multer';
import type { Request, Response, NextFunction } from 'express';
import type {Request, Response, NextFunction} from 'express';
const dbIconSymbol = '🗄️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
@@ -38,6 +38,7 @@ const router = express.Router();
function isNonEmptyString(val: any): val is string {
return typeof val === 'string' && val.trim().length > 0;
}
function isValidPort(val: any): val is number {
return typeof val === 'number' && val > 0 && val < 65536;
}
@@ -48,14 +49,12 @@ interface JWTPayload {
exp?: number;
}
// Configure multer for file uploads
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit
fileSize: 10 * 1024 * 1024,
},
fileFilter: (req, file, cb) => {
// Only allow specific file types for SSH keys
if (file.fieldname === 'key') {
cb(null, true);
} else {
@@ -64,12 +63,11 @@ const upload = multer({
}
});
// JWT authentication middleware
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
logger.warn('Missing or invalid Authorization header');
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
return res.status(401).json({error: 'Missing or invalid Authorization header'});
}
const token = authHeader.split(' ')[1];
const jwtSecret = process.env.JWT_SECRET || 'secret';
@@ -79,11 +77,10 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
next();
} catch (err) {
logger.warn('Invalid or expired token');
return res.status(401).json({ error: 'Invalid or expired token' });
return res.status(401).json({error: 'Invalid or expired token'});
}
}
// Helper to check if request is from localhost
function isLocalhost(req: Request) {
const ip = req.ip || req.connection?.remoteAddress;
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
@@ -93,7 +90,7 @@ function isLocalhost(req: Request) {
router.get('/db/host/internal', async (req: Request, res: Response) => {
if (!isLocalhost(req) && req.headers['x-internal-request'] !== '1') {
logger.warn('Unauthorized attempt to access internal SSH host endpoint');
return res.status(403).json({ error: 'Forbidden' });
return res.status(403).json({error: 'Forbidden'});
}
try {
const data = await db.select().from(sshData);
@@ -110,7 +107,7 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
res.json(result);
} catch (err) {
logger.error('Failed to fetch SSH data (internal)', err);
res.status(500).json({ error: 'Failed to fetch SSH data' });
res.status(500).json({error: 'Failed to fetch SSH data'});
}
});
@@ -118,7 +115,7 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
// POST /ssh/host
router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
let hostData: any;
// Check if this is a multipart form data request (file upload)
if (req.headers['content-type']?.includes('multipart/form-data')) {
// Parse the JSON data from the 'data' field
@@ -127,13 +124,13 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
hostData = JSON.parse(req.body.data);
} catch (err) {
logger.warn('Invalid JSON data in multipart request');
return res.status(400).json({ error: 'Invalid JSON data' });
return res.status(400).json({error: 'Invalid JSON data'});
}
} else {
logger.warn('Missing data field in multipart request');
return res.status(400).json({ error: 'Missing data field' });
return res.status(400).json({error: 'Missing data field'});
}
// Add the file data if present
if (req.file) {
hostData.key = req.file.buffer.toString('utf8');
@@ -142,12 +139,30 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
// Regular JSON request
hostData = req.body;
}
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, pin, enableTerminal, enableTunnel, enableConfigEditor, defaultPath, tunnelConnections } = hostData;
const {
name,
folder,
tags,
ip,
port,
username,
password,
authMethod,
key,
keyPassword,
keyType,
pin,
enableTerminal,
enableTunnel,
enableConfigEditor,
defaultPath,
tunnelConnections
} = hostData;
const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) {
logger.warn('Invalid SSH data input');
return res.status(400).json({ error: 'Invalid SSH data' });
return res.status(400).json({error: 'Invalid SSH data'});
}
const sshDataObj: any = {
@@ -167,7 +182,6 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
defaultPath: defaultPath || null,
};
// Handle authentication data based on authMethod
if (authMethod === 'password') {
sshDataObj.password = password;
sshDataObj.key = null;
@@ -182,10 +196,10 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
try {
await db.insert(sshData).values(sshDataObj);
res.json({ message: 'SSH data created' });
res.json({message: 'SSH data created'});
} catch (err) {
logger.error('Failed to save SSH data', err);
res.status(500).json({ error: 'Failed to save SSH data' });
res.status(500).json({error: 'Failed to save SSH data'});
}
});
@@ -193,37 +207,51 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
// PUT /ssh/host/:id
router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
let hostData: any;
// Check if this is a multipart form data request (file upload)
if (req.headers['content-type']?.includes('multipart/form-data')) {
// Parse the JSON data from the 'data' field
if (req.body.data) {
try {
hostData = JSON.parse(req.body.data);
} catch (err) {
logger.warn('Invalid JSON data in multipart request');
return res.status(400).json({ error: 'Invalid JSON data' });
return res.status(400).json({error: 'Invalid JSON data'});
}
} else {
logger.warn('Missing data field in multipart request');
return res.status(400).json({ error: 'Missing data field' });
return res.status(400).json({error: 'Missing data field'});
}
// Add the file data if present
if (req.file) {
hostData.key = req.file.buffer.toString('utf8');
}
} else {
// Regular JSON request
hostData = req.body;
}
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, pin, enableTerminal, enableTunnel, enableConfigEditor, defaultPath, tunnelConnections } = hostData;
const { id } = req.params;
const {
name,
folder,
tags,
ip,
port,
username,
password,
authMethod,
key,
keyPassword,
keyType,
pin,
enableTerminal,
enableTunnel,
enableConfigEditor,
defaultPath,
tunnelConnections
} = hostData;
const {id} = req.params;
const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !id) {
logger.warn('Invalid SSH data input for update');
return res.status(400).json({ error: 'Invalid SSH data' });
return res.status(400).json({error: 'Invalid SSH data'});
}
const sshDataObj: any = {
@@ -242,7 +270,6 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
defaultPath: defaultPath || null,
};
// Handle authentication data based on authMethod
if (authMethod === 'password') {
sshDataObj.password = password;
sshDataObj.key = null;
@@ -259,10 +286,10 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
await db.update(sshData)
.set(sshDataObj)
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
res.json({ message: 'SSH data updated' });
res.json({message: 'SSH data updated'});
} catch (err) {
logger.error('Failed to update SSH data', err);
res.status(500).json({ error: 'Failed to update SSH data' });
res.status(500).json({error: 'Failed to update SSH data'});
}
});
@@ -272,14 +299,13 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for SSH data fetch');
return res.status(400).json({ error: 'Invalid userId' });
return res.status(400).json({error: 'Invalid userId'});
}
try {
const data = await db
.select()
.from(sshData)
.where(eq(sshData.userId, userId));
// Convert tags to array, booleans to bool, tunnelConnections to array
const result = data.map((row: any) => ({
...row,
tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [],
@@ -292,31 +318,31 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
res.json(result);
} catch (err) {
logger.error('Failed to fetch SSH data', err);
res.status(500).json({ error: 'Failed to fetch SSH data' });
res.status(500).json({error: 'Failed to fetch SSH data'});
}
});
// Route: Get SSH host by ID (requires JWT)
// GET /ssh/host/:id
router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
const { id } = req.params;
const {id} = req.params;
const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !id) {
logger.warn('Invalid request for SSH host fetch');
return res.status(400).json({ error: 'Invalid request' });
return res.status(400).json({error: 'Invalid request'});
}
try {
const data = await db
.select()
.from(sshData)
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
if (data.length === 0) {
return res.status(404).json({ error: 'SSH host not found' });
return res.status(404).json({error: 'SSH host not found'});
}
const host = data[0];
const result = {
...host,
@@ -327,11 +353,11 @@ router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response)
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
enableConfigEditor: !!host.enableConfigEditor,
};
res.json(result);
} catch (err) {
logger.error('Failed to fetch SSH host', err);
res.status(500).json({ error: 'Failed to fetch SSH host' });
res.status(500).json({error: 'Failed to fetch SSH host'});
}
});
@@ -341,11 +367,11 @@ router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) =
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for SSH folder fetch');
return res.status(400).json({ error: 'Invalid userId' });
return res.status(400).json({error: 'Invalid userId'});
}
try {
const data = await db
.select({ folder: sshData.folder })
.select({folder: sshData.folder})
.from(sshData)
.where(eq(sshData.userId, userId));
@@ -361,7 +387,7 @@ router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) =
res.json(folders);
} catch (err) {
logger.error('Failed to fetch SSH folders', err);
res.status(500).json({ error: 'Failed to fetch SSH folders' });
res.status(500).json({error: 'Failed to fetch SSH folders'});
}
});
@@ -369,39 +395,37 @@ router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) =
// DELETE /ssh/host/:id
router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { id } = req.params;
const {id} = req.params;
if (!isNonEmptyString(userId) || !id) {
logger.warn('Invalid userId or id for SSH host delete');
return res.status(400).json({ error: 'Invalid userId or id' });
return res.status(400).json({error: 'Invalid userId or id'});
}
try {
const result = await db.delete(sshData)
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
res.json({ message: 'SSH host deleted' });
res.json({message: 'SSH host deleted'});
} catch (err) {
logger.error('Failed to delete SSH host', err);
res.status(500).json({ error: 'Failed to delete SSH host' });
res.status(500).json({error: 'Failed to delete SSH host'});
}
});
// Config Editor Database Routes
// Route: Get recent files (requires JWT)
// GET /ssh/config_editor/recent
router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for recent files fetch');
return res.status(400).json({ error: 'Invalid userId' });
return res.status(400).json({error: 'Invalid userId'});
}
if (!hostId) {
logger.warn('Host ID is required for recent files fetch');
return res.status(400).json({ error: 'Host ID is required' });
return res.status(400).json({error: 'Host ID is required'});
}
try {
const recentFiles = await db
.select()
@@ -414,7 +438,7 @@ router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: R
res.json(recentFiles);
} catch (err) {
logger.error('Failed to fetch recent files', err);
res.status(500).json({ error: 'Failed to fetch recent files' });
res.status(500).json({error: 'Failed to fetch recent files'});
}
});
@@ -422,29 +446,27 @@ router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: R
// POST /ssh/config_editor/recent
router.post('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { name, path, hostId } = req.body;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for adding recent file');
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
// Check if file already exists in recent for this host
const conditions = [
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.path, path),
eq(configEditorRecent.hostId, hostId)
];
const existing = await db
.select()
.from(configEditorRecent)
.where(and(...conditions));
if (existing.length > 0) {
// Update lastOpened timestamp
await db
.update(configEditorRecent)
.set({ lastOpened: new Date().toISOString() })
.set({lastOpened: new Date().toISOString()})
.where(and(...conditions));
} else {
// Add new recent file
@@ -456,10 +478,10 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
lastOpened: new Date().toISOString()
});
}
res.json({ message: 'File added to recent' });
res.json({message: 'File added to recent'});
} catch (err) {
logger.error('Failed to add recent file', err);
res.status(500).json({ error: 'Failed to add recent file' });
res.status(500).json({error: 'Failed to add recent file'});
}
});
@@ -467,28 +489,25 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
// DELETE /ssh/config_editor/recent
router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { name, path, hostId } = req.body;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for removing recent file');
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
logger.info(`Removing recent file: ${name} at ${path} for user ${userId} and host ${hostId}`);
const conditions = [
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.path, path),
eq(configEditorRecent.hostId, hostId)
];
const result = await db
.delete(configEditorRecent)
.where(and(...conditions));
logger.info(`Recent file removed successfully`);
res.json({ message: 'File removed from recent' });
res.json({message: 'File removed from recent'});
} catch (err) {
logger.error('Failed to remove recent file', err);
res.status(500).json({ error: 'Failed to remove recent file' });
res.status(500).json({error: 'Failed to remove recent file'});
}
});
@@ -497,17 +516,17 @@ router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res
router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for pinned files fetch');
return res.status(400).json({ error: 'Invalid userId' });
return res.status(400).json({error: 'Invalid userId'});
}
if (!hostId) {
logger.warn('Host ID is required for pinned files fetch');
return res.status(400).json({ error: 'Host ID is required' });
return res.status(400).json({error: 'Host ID is required'});
}
try {
const pinnedFiles = await db
.select()
@@ -520,7 +539,7 @@ router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: R
res.json(pinnedFiles);
} catch (err) {
logger.error('Failed to fetch pinned files', err);
res.status(500).json({ error: 'Failed to fetch pinned files' });
res.status(500).json({error: 'Failed to fetch pinned files'});
}
});
@@ -528,26 +547,24 @@ router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: R
// POST /ssh/config_editor/pinned
router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { name, path, hostId } = req.body;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for adding pinned file');
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
// Check if file already exists in pinned for this host
const conditions = [
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.path, path),
eq(configEditorPinned.hostId, hostId)
];
const existing = await db
.select()
.from(configEditorPinned)
.where(and(...conditions));
if (existing.length === 0) {
// Add new pinned file
await db.insert(configEditorPinned).values({
userId,
hostId,
@@ -556,10 +573,10 @@ router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res:
pinnedAt: new Date().toISOString()
});
}
res.json({ message: 'File pinned successfully' });
res.json({message: 'File pinned successfully'});
} catch (err) {
logger.error('Failed to pin file', err);
res.status(500).json({ error: 'Failed to pin file' });
res.status(500).json({error: 'Failed to pin file'});
}
});
@@ -567,28 +584,25 @@ router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res:
// DELETE /ssh/config_editor/pinned
router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { name, path, hostId } = req.body;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for removing pinned file');
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
logger.info(`Removing pinned file: ${name} at ${path} for user ${userId} and host ${hostId}`);
const conditions = [
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.path, path),
eq(configEditorPinned.hostId, hostId)
];
const result = await db
.delete(configEditorPinned)
.where(and(...conditions));
logger.info(`Pinned file removed successfully`);
res.json({ message: 'File unpinned successfully' });
res.json({message: 'File unpinned successfully'});
} catch (err) {
logger.error('Failed to unpin file', err);
res.status(500).json({ error: 'Failed to unpin file' });
res.status(500).json({error: 'Failed to unpin file'});
}
});
@@ -597,17 +611,15 @@ router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res
router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for shortcuts fetch');
return res.status(400).json({ error: 'Invalid userId' });
return res.status(400).json({error: 'Invalid userId'});
}
if (!hostId) {
logger.warn('Host ID is required for shortcuts fetch');
return res.status(400).json({ error: 'Host ID is required' });
return res.status(400).json({error: 'Host ID is required'});
}
try {
const shortcuts = await db
.select()
@@ -620,7 +632,7 @@ router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res
res.json(shortcuts);
} catch (err) {
logger.error('Failed to fetch shortcuts', err);
res.status(500).json({ error: 'Failed to fetch shortcuts' });
res.status(500).json({error: 'Failed to fetch shortcuts'});
}
});
@@ -628,26 +640,23 @@ router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res
// POST /ssh/config_editor/shortcuts
router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { name, path, hostId } = req.body;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for adding shortcut');
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
// Check if shortcut already exists for this host
const conditions = [
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.path, path),
eq(configEditorShortcuts.hostId, hostId)
];
const existing = await db
.select()
.from(configEditorShortcuts)
.where(and(...conditions));
if (existing.length === 0) {
// Add new shortcut
await db.insert(configEditorShortcuts).values({
userId,
hostId,
@@ -656,10 +665,10 @@ router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, re
createdAt: new Date().toISOString()
});
}
res.json({ message: 'Shortcut added successfully' });
res.json({message: 'Shortcut added successfully'});
} catch (err) {
logger.error('Failed to add shortcut', err);
res.status(500).json({ error: 'Failed to add shortcut' });
res.status(500).json({error: 'Failed to add shortcut'});
}
});
@@ -667,28 +676,24 @@ router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, re
// DELETE /ssh/config_editor/shortcuts
router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { name, path, hostId } = req.body;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for removing shortcut');
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
logger.info(`Removing shortcut: ${name} at ${path} for user ${userId} and host ${hostId}`);
const conditions = [
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.path, path),
eq(configEditorShortcuts.hostId, hostId)
];
const result = await db
.delete(configEditorShortcuts)
.where(and(...conditions));
logger.info(`Shortcut removed successfully`);
res.json({ message: 'Shortcut removed successfully' });
res.json({message: 'Shortcut removed successfully'});
} catch (err) {
logger.error('Failed to remove shortcut', err);
res.status(500).json({ error: 'Failed to remove shortcut' });
res.status(500).json({error: 'Failed to remove shortcut'});
}
});

View File

@@ -1,12 +1,12 @@
import express from 'express';
import { db } from '../db/index.js';
import { users, settings } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import {db} from '../db/index.js';
import {users, settings} from '../db/schema.js';
import {eq} from 'drizzle-orm';
import chalk from 'chalk';
import bcrypt from 'bcryptjs';
import { nanoid } from 'nanoid';
import {nanoid} from 'nanoid';
import jwt from 'jsonwebtoken';
import type { Request, Response, NextFunction } from 'express';
import type {Request, Response, NextFunction} from 'express';
const dbIconSymbol = '🗄️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
@@ -51,7 +51,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
logger.warn('Missing or invalid Authorization header');
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
return res.status(401).json({error: 'Missing or invalid Authorization header'});
}
const token = authHeader.split(' ')[1];
const jwtSecret = process.env.JWT_SECRET || 'secret';
@@ -61,7 +61,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
next();
} catch (err) {
logger.warn('Invalid or expired token');
return res.status(401).json({ error: 'Invalid or expired token' });
return res.status(401).json({error: 'Invalid or expired token'});
}
}
@@ -71,14 +71,14 @@ router.post('/create', async (req, res) => {
try {
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
if (row && (row as any).value !== 'true') {
return res.status(403).json({ error: 'Registration is currently disabled' });
return res.status(403).json({error: 'Registration is currently disabled'});
}
} catch (e) {
}
const { username, password } = req.body;
const {username, password} = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
logger.warn('Invalid user creation attempt');
return res.status(400).json({ error: 'Invalid username or password' });
return res.status(400).json({error: 'Invalid username or password'});
}
try {
const existing = await db
@@ -87,7 +87,7 @@ router.post('/create', async (req, res) => {
.where(eq(users.username, username));
if (existing && existing.length > 0) {
logger.warn(`Attempt to create duplicate username: ${username}`);
return res.status(409).json({ error: 'Username already exists' });
return res.status(409).json({error: 'Username already exists'});
}
let isFirstUser = false;
try {
@@ -99,22 +99,22 @@ router.post('/create', async (req, res) => {
const saltRounds = parseInt(process.env.SALT || '10', 10);
const password_hash = await bcrypt.hash(password, saltRounds);
const id = nanoid();
await db.insert(users).values({ id, username, password_hash, is_admin: isFirstUser });
await db.insert(users).values({id, username, password_hash, is_admin: isFirstUser});
logger.success(`User created: ${username} (is_admin: ${isFirstUser})`);
res.json({ message: 'User created', is_admin: isFirstUser });
res.json({message: 'User created', is_admin: isFirstUser});
} catch (err) {
logger.error('Failed to create user', err);
res.status(500).json({ error: 'Failed to create user' });
res.status(500).json({error: 'Failed to create user'});
}
});
// Route: Get user JWT by username and password
// POST /users/get
router.post('/get', async (req, res) => {
const { username, password } = req.body;
const {username, password} = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
logger.warn('Invalid get user attempt');
return res.status(400).json({ error: 'Invalid username or password' });
return res.status(400).json({error: 'Invalid username or password'});
}
try {
const user = await db
@@ -123,20 +123,20 @@ router.post('/get', async (req, res) => {
.where(eq(users.username, username));
if (!user || user.length === 0) {
logger.warn(`User not found: ${username}`);
return res.status(404).json({ error: 'User not found' });
return res.status(404).json({error: 'User not found'});
}
const userRecord = user[0];
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
if (!isMatch) {
logger.warn(`Incorrect password for user: ${username}`);
return res.status(401).json({ error: 'Incorrect password' });
return res.status(401).json({error: 'Incorrect password'});
}
const jwtSecret = process.env.JWT_SECRET || 'secret';
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, { expiresIn: '50d' });
res.json({ token });
const token = jwt.sign({userId: userRecord.id}, jwtSecret, {expiresIn: '50d'});
res.json({token});
} catch (err) {
logger.error('Failed to get user', err);
res.status(500).json({ error: 'Failed to get user' });
res.status(500).json({error: 'Failed to get user'});
}
});
@@ -146,7 +146,7 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId in JWT for /users/me');
return res.status(401).json({ error: 'Invalid userId' });
return res.status(401).json({error: 'Invalid userId'});
}
try {
const user = await db
@@ -155,12 +155,12 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
.where(eq(users.id, userId));
if (!user || user.length === 0) {
logger.warn(`User not found for /users/me: ${userId}`);
return res.status(401).json({ error: 'User not found' });
return res.status(401).json({error: 'User not found'});
}
res.json({ username: user[0].username, is_admin: !!user[0].is_admin });
res.json({username: user[0].username, is_admin: !!user[0].is_admin});
} catch (err) {
logger.error('Failed to get username', err);
res.status(500).json({ error: 'Failed to get username' });
res.status(500).json({error: 'Failed to get username'});
}
});
@@ -170,10 +170,10 @@ router.get('/count', async (req, res) => {
try {
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
const count = (countResult as any)?.count || 0;
res.json({ count });
res.json({count});
} catch (err) {
logger.error('Failed to count users', err);
res.status(500).json({ error: 'Failed to count users' });
res.status(500).json({error: 'Failed to count users'});
}
});
@@ -182,10 +182,10 @@ router.get('/count', async (req, res) => {
router.get('/db-health', async (req, res) => {
try {
db.$client.prepare('SELECT 1').get();
res.json({ status: 'ok' });
res.json({status: 'ok'});
} catch (err) {
logger.error('DB health check failed', err);
res.status(500).json({ error: 'Database not accessible' });
res.status(500).json({error: 'Database not accessible'});
}
});
@@ -194,10 +194,10 @@ router.get('/db-health', async (req, res) => {
router.get('/registration-allowed', async (req, res) => {
try {
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
res.json({ allowed: row ? (row as any).value === 'true' : true });
res.json({allowed: row ? (row as any).value === 'true' : true});
} catch (err) {
logger.error('Failed to get registration allowed', err);
res.status(500).json({ error: 'Failed to get registration allowed' });
res.status(500).json({error: 'Failed to get registration allowed'});
}
});
@@ -208,17 +208,17 @@ router.patch('/registration-allowed', authenticateJWT, async (req, res) => {
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].is_admin) {
return res.status(403).json({ error: 'Not authorized' });
return res.status(403).json({error: 'Not authorized'});
}
const { allowed } = req.body;
const {allowed} = req.body;
if (typeof allowed !== 'boolean') {
return res.status(400).json({ error: 'Invalid value for allowed' });
return res.status(400).json({error: 'Invalid value for allowed'});
}
db.$client.prepare("UPDATE settings SET value = ? WHERE key = 'allow_registration'").run(allowed ? 'true' : 'false');
res.json({ allowed });
res.json({allowed});
} catch (err) {
logger.error('Failed to set registration allowed', err);
res.status(500).json({ error: 'Failed to set registration allowed' });
res.status(500).json({error: 'Failed to set registration allowed'});
}
});

View File

@@ -40,21 +40,19 @@ const logger = {
}
};
// State management for host-based tunnels
const activeTunnels = new Map<string, Client>(); // tunnelName -> Client
const retryCounters = new Map<string, number>(); // tunnelName -> retryCount
const connectionStatus = new Map<string, TunnelStatus>(); // tunnelName -> status
const tunnelVerifications = new Map<string, VerificationData>(); // tunnelName -> verification
const manualDisconnects = new Set<string>(); // tunnelNames
const verificationTimers = new Map<string, NodeJS.Timeout>(); // timer keys -> timeout
const activeRetryTimers = new Map<string, NodeJS.Timeout>(); // tunnelName -> retry timer
const countdownIntervals = new Map<string, NodeJS.Timeout>(); // tunnelName -> countdown interval
const retryExhaustedTunnels = new Set<string>(); // tunnelNames
const activeTunnels = new Map<string, Client>();
const retryCounters = new Map<string, number>();
const connectionStatus = new Map<string, TunnelStatus>();
const tunnelVerifications = new Map<string, VerificationData>();
const manualDisconnects = new Set<string>();
const verificationTimers = new Map<string, NodeJS.Timeout>();
const activeRetryTimers = new Map<string, NodeJS.Timeout>();
const countdownIntervals = new Map<string, NodeJS.Timeout>();
const retryExhaustedTunnels = new Set<string>();
const tunnelConfigs = new Map<string, TunnelConfig>(); // tunnelName -> tunnelConfig
const activeTunnelProcesses = new Map<string, ChildProcess>(); // tunnelName -> ChildProcess
const tunnelConfigs = new Map<string, TunnelConfig>();
const activeTunnelProcesses = new Map<string, ChildProcess>();s
// Types
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
@@ -159,7 +157,6 @@ const ERROR_TYPES = {
type ConnectionState = typeof CONNECTION_STATES[keyof typeof CONNECTION_STATES];
type ErrorType = typeof ERROR_TYPES[keyof typeof ERROR_TYPES];
// Helper functions
function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void {
if (status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName)) {
return;
@@ -218,14 +215,11 @@ function classifyError(errorMessage: string): ErrorType {
return ERROR_TYPES.UNKNOWN;
}
// Helper to build a unique marker for each tunnel
function getTunnelMarker(tunnelName: string) {
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`;
}
// Cleanup and disconnect functions
function cleanupTunnelResources(tunnelName: string): void {
// Fire-and-forget remote pkill (do not block local cleanup)
const tunnelConfig = tunnelConfigs.get(tunnelName);
if (tunnelConfig) {
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
@@ -235,7 +229,6 @@ function cleanupTunnelResources(tunnelName: string): void {
});
}
// Local cleanup (always run immediately)
if (activeTunnelProcesses.has(tunnelName)) {
try {
const proc = activeTunnelProcesses.get(tunnelName);
@@ -398,7 +391,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
const initialNextRetryIn = Math.ceil(retryInterval / 1000);
let currentNextRetryIn = initialNextRetryIn;
// Set initial WAITING status with countdown
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.WAITING,
@@ -407,7 +399,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
nextRetryIn: currentNextRetryIn
});
// Update countdown every second
const countdownInterval = setInterval(() => {
currentNextRetryIn--;
if (currentNextRetryIn > 0) {
@@ -447,7 +438,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
}
}
// Tunnel verification function
function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void {
if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) {
return;
@@ -496,10 +486,7 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
}
} else {
logger.warn(`Verification failed for '${tunnelName}': ${failureReason}`);
// With the new verification approach, we're testing connectivity to the endpoint machine
// A failure might just mean the service isn't running on that port, not that the tunnel is broken
// Only disconnect if it's a critical error (command failed, connection error, or timeout)
if (failureReason.includes('command failed') || failureReason.includes('connection error') || failureReason.includes('timeout')) {
if (!manualDisconnects.has(tunnelName)) {
broadcastTunnelStatus(tunnelName, {
@@ -511,19 +498,13 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
activeTunnels.delete(tunnelName);
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
} else {
// For connection refused or other non-critical errors, assume the tunnel is working
// The service might just not be running on the target port
logger.info(`Assuming tunnel '${tunnelName}' is working despite verification warning: ${failureReason}`);
cleanupVerification(true); // Treat as successful to prevent disconnect
cleanupVerification(true);
}
}
}
function attemptVerification() {
// Test the actual tunnel by trying to connect to the endpoint port
// This verifies that the tunnel is actually working
// With -R forwarding, the endpointPort should be listening on the endpoint machine
// We need to check if the port is accessible from the source machine to the endpoint machine
const testCmd = `timeout 3 bash -c 'nc -z ${tunnelConfig.endpointIP} ${tunnelConfig.endpointPort}'`;
verificationConn.exec(testCmd, (err, stream) => {
@@ -535,7 +516,7 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
let output = '';
let errorOutput = '';
stream.on('data', (data: Buffer) => {
output += data.toString();
});
@@ -548,17 +529,16 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
if (code === 0) {
cleanupVerification(true);
} else {
// Check if it's a timeout or connection refused
const isTimeout = errorOutput.includes('timeout') || errorOutput.includes('Connection timed out');
const isConnectionRefused = errorOutput.includes('Connection refused') || errorOutput.includes('No route to host');
let failureReason = `Cannot connect to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
if (isTimeout) {
failureReason = `Tunnel verification timeout - cannot reach ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
} else if (isConnectionRefused) {
failureReason = `Connection refused to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort} - tunnel may not be established`;
}
cleanupVerification(false, failureReason);
}
});
@@ -571,7 +551,6 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
}
verificationConn.on('ready', () => {
// Add a small delay to allow the tunnel to fully establish
setTimeout(() => {
attemptVerification();
}, 2000);
@@ -633,7 +612,6 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
if (tunnelConfig.sourceKeyPassword) {
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
}
// Add key type handling if specified
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
}
@@ -714,10 +692,9 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
});
});
}, 30000); // Ping every 30 seconds
}, 30000);
}
// Main SSH tunnel connection function
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
const tunnelName = tunnelConfig.name;
const tunnelMarker = getTunnelMarker(tunnelName);
@@ -733,7 +710,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
retryCounters.delete(tunnelName);
}
// Only set status to CONNECTING if we're not already in WAITING state
const currentStatus = connectionStatus.get(tunnelName);
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
broadcastTunnelStatus(tunnelName, {
@@ -835,7 +811,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
let tunnelCmd: string;
if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) {
// For SSH key authentication, we need to create a temporary key file
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`;
tunnelCmd = `echo '${tunnelConfig.endpointSSHKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`;
} else {
@@ -975,7 +950,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
};
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
// Validate SSH key format
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) {
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`);
broadcastTunnelStatus(tunnelName, {
@@ -990,7 +964,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
if (tunnelConfig.sourceKeyPassword) {
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
}
// Add key type handling if specified
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
}
@@ -1006,14 +979,12 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
connOptions.password = tunnelConfig.sourcePassword;
}
// Test basic network connectivity first
const testSocket = new net.Socket();
testSocket.setTimeout(5000);
testSocket.on('connect', () => {
testSocket.destroy();
// Only update status to CONNECTING if we're not already in WAITING state
const currentStatus = connectionStatus.get(tunnelName);
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
broadcastTunnelStatus(tunnelName, {
@@ -1047,7 +1018,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
testSocket.connect(tunnelConfig.sourceSSHPort, tunnelConfig.sourceIP);
}
// Add a helper to kill the tunnel by marker
function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) {
const tunnelMarker = getTunnelMarker(tunnelName);
const conn = new Client();
@@ -1106,7 +1076,6 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
connOptions.password = tunnelConfig.sourcePassword;
}
conn.on('ready', () => {
// Use pkill to kill the tunnel by marker
const killCmd = `pkill -f '${tunnelMarker}'`;
conn.exec(killCmd, (err, stream) => {
if (err) {
@@ -1128,7 +1097,6 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
conn.connect(connOptions);
}
// Express API endpoints
app.get('/ssh/tunnel/status', (req, res) => {
res.json(getAllTunnelStatus());
});
@@ -1153,16 +1121,12 @@ app.post('/ssh/tunnel/connect', (req, res) => {
const tunnelName = tunnelConfig.name;
// Reset retry state for new connection
manualDisconnects.delete(tunnelName);
retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName);
// Store tunnel config
tunnelConfigs.set(tunnelName, tunnelConfig);
// Start connection
connectSSHTunnel(tunnelConfig, 0);
res.json({message: 'Connection request received', tunnelName});
@@ -1193,7 +1157,6 @@ app.post('/ssh/tunnel/disconnect', (req, res) => {
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
handleDisconnect(tunnelName, tunnelConfig, false);
// Clear manual disconnect flag after a delay
setTimeout(() => {
manualDisconnects.delete(tunnelName);
}, 5000);
@@ -1208,7 +1171,6 @@ app.post('/ssh/tunnel/cancel', (req, res) => {
return res.status(400).json({error: 'Tunnel name required'});
}
// Cancel retry operations
retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName);
@@ -1222,18 +1184,15 @@ app.post('/ssh/tunnel/cancel', (req, res) => {
countdownIntervals.delete(tunnelName);
}
// Set status to disconnected
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
manualDisconnect: true
});
// Clean up any existing tunnel resources
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
handleDisconnect(tunnelName, tunnelConfig, false);
// Clear manual disconnect flag after a delay
setTimeout(() => {
manualDisconnects.delete(tunnelName);
}, 5000);
@@ -1241,10 +1200,8 @@ app.post('/ssh/tunnel/cancel', (req, res) => {
res.json({message: 'Cancel request received', tunnelName});
});
// Auto-start functionality
async function initializeAutoStartTunnels(): Promise<void> {
try {
// Fetch hosts with auto-start tunnel connections from the new internal endpoint
const response = await axios.get('http://localhost:8081/ssh/db/host/internal', {
headers: {
'Content-Type': 'application/json',
@@ -1255,12 +1212,10 @@ async function initializeAutoStartTunnels(): Promise<void> {
const hosts: SSHHost[] = response.data || [];
const autoStartTunnels: TunnelConfig[] = [];
// Process each host and extract auto-start tunnel connections
for (const host of hosts) {
if (host.enableTunnel && host.tunnelConnections) {
for (const tunnelConnection of host.tunnelConnections) {
if (tunnelConnection.autoStart) {
// Find the endpoint host
const endpointHost = hosts.find(h =>
h.name === tunnelConnection.endpointHost ||
`${h.username}@${h.ip}` === tunnelConnection.endpointHost
@@ -1303,11 +1258,9 @@ async function initializeAutoStartTunnels(): Promise<void> {
logger.info(`Found ${autoStartTunnels.length} auto-start tunnels`);
// Start each auto-start tunnel
for (const tunnelConfig of autoStartTunnels) {
tunnelConfigs.set(tunnelConfig.name, tunnelConfig);
// Start the tunnel with a delay to avoid overwhelming the system
setTimeout(() => {
connectSSHTunnel(tunnelConfig, 0);
}, 1000);
@@ -1322,4 +1275,4 @@ app.listen(PORT, () => {
setTimeout(() => {
initializeAutoStartTunnels();
}, 2000);
});
});

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useState } from "react"
import {createContext, useContext, useEffect, useState} from "react"
type Theme = "dark" | "light" | "system"

View File

@@ -4,129 +4,130 @@
@custom-variant dark (&:is(.dark *));
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #09090b;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #09090b;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}