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

View File

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

View File

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

View File

@@ -1,293 +1,302 @@
import React, { useState, useEffect } from "react"; import React, {useState, useEffect} from "react";
import { cn } from "@/lib/utils"; import {cn} from "@/lib/utils";
import { Button } from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import {Label} from "@/components/ui/label";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert";
import axios from "axios"; import axios from "axios";
function setCookie(name: string, value: string, days = 7) { function setCookie(name: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString(); const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
} }
function getCookie(name: string) { function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => { return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('='); const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r; return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, ""); }, "");
} }
const apiBase = const apiBase =
typeof window !== "undefined" && window.location.hostname === "localhost" typeof window !== "undefined" && window.location.hostname === "localhost"
? "http://localhost:8081/users" ? "http://localhost:8081/users"
: "/users"; : "/users";
const API = axios.create({ const API = axios.create({
baseURL: apiBase, baseURL: apiBase,
}); });
interface HomepageAuthProps extends React.ComponentProps<"div"> { interface HomepageAuthProps extends React.ComponentProps<"div"> {
setLoggedIn: (loggedIn: boolean) => void; setLoggedIn: (loggedIn: boolean) => void;
setIsAdmin: (isAdmin: boolean) => void; setIsAdmin: (isAdmin: boolean) => void;
setUsername: (username: string | null) => void; setUsername: (username: string | null) => void;
} }
export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername, ...props }: HomepageAuthProps) { export function HomepageAuth({className, setLoggedIn, setIsAdmin, setUsername, ...props}: HomepageAuthProps) {
const [tab, setTab] = useState<"login" | "signup">("login"); const [tab, setTab] = useState<"login" | "signup">("login");
const [localUsername, setLocalUsername] = useState(""); const [localUsername, setLocalUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [internalLoggedIn, setInternalLoggedIn] = useState(false); const [internalLoggedIn, setInternalLoggedIn] = useState(false);
const [firstUser, setFirstUser] = useState(false); const [firstUser, setFirstUser] = useState(false);
const [dbError, setDbError] = useState<string | null>(null); const [dbError, setDbError] = useState<string | null>(null);
const [registrationAllowed, setRegistrationAllowed] = useState(true); const [registrationAllowed, setRegistrationAllowed] = useState(true);
useEffect(() => { useEffect(() => {
API.get("/registration-allowed").then(res => { API.get("/registration-allowed").then(res => {
setRegistrationAllowed(res.data.allowed); setRegistrationAllowed(res.data.allowed);
}); });
}, []); }, []);
useEffect(() => { useEffect(() => {
API.get("/count").then(res => { API.get("/count").then(res => {
if (res.data.count === 0) { if (res.data.count === 0) {
setFirstUser(true); setFirstUser(true);
setTab("signup"); setTab("signup");
} else { } else {
setFirstUser(false); 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 {
setDbError(null); setDbError(null);
} }).catch(() => {
}) setDbError("Could not connect to the database. Please try again later.");
.finally(() => setLoading(false)); });
} else { }, []);
setInternalLoggedIn(false);
setLoggedIn(false); useEffect(() => {
setIsAdmin(false); const jwt = getCookie("jwt");
setUsername(null); 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) { const Spinner = (
e.preventDefault(); <svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24">
setError(null); <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
setLoading(true); <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
try { </svg>
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 = ( return (
<svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24"> <div
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /> className={cn(
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /> "flex-1 flex justify-center items-center min-h-screen bg-background",
</svg> className
); )}
{...props}
return ( >
<div <div
className={cn( className={`w-[420px] max-w-full bg-background rounded-xl shadow-lg p-6 flex flex-col ${internalLoggedIn ? '' : 'border border-border'}`}>
"flex-1 flex justify-center items-center min-h-screen bg-background", {dbError && (
className <Alert variant="destructive" className="mb-4">
)} <AlertTitle>Error</AlertTitle>
{...props} <AlertDescription>{dbError}</AlertDescription>
> </Alert>
<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"
)} )}
onClick={() => setTab("login")} {firstUser && !dbError && !internalLoggedIn && (
aria-selected={tab === "login"} <Alert variant="default" className="mb-4">
disabled={loading || firstUser} <AlertTitle>First User</AlertTitle>
> <AlertDescription>
Login You are the first user and will be made an admin. You can view admin settings in the sidebar
</button> user dropdown.
<button </AlertDescription>
type="button" </Alert>
className={cn( )}
"flex-1 py-2 text-base font-medium rounded-md transition-all", {!registrationAllowed && !internalLoggedIn && (
tab === "signup" <Alert variant="destructive" className="mb-4">
? "bg-primary text-primary-foreground shadow" <AlertTitle>Registration Disabled</AlertTitle>
: "bg-muted text-muted-foreground hover:bg-accent" <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>
<div className="mb-6 text-center"> </div>
<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>
);
} }

View File

@@ -69,7 +69,13 @@ const API = axios.create({
baseURL: apiBase, 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 [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
const [allowRegistration, setAllowRegistration] = React.useState(true); const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false); const [regLoading, setRegLoading] = React.useState(false);
@@ -109,14 +115,16 @@ export function HomepageSidebar({onSelectView, getView, disabled, isAdmin, usern
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem key={"SSH Manager"}> <SidebarMenuItem key={"SSH Manager"}>
<SidebarMenuButton onClick={() => onSelectView("ssh_manager")} disabled={disabled}> <SidebarMenuButton onClick={() => onSelectView("ssh_manager")}
disabled={disabled}>
<HardDrive/> <HardDrive/>
<span>SSH Manager</span> <span>SSH Manager</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<div className="ml-5"> <div className="ml-5">
<SidebarMenuItem key={"Terminal"}> <SidebarMenuItem key={"Terminal"}>
<SidebarMenuButton onClick={() => onSelectView("terminal")} disabled={disabled}> <SidebarMenuButton onClick={() => onSelectView("terminal")}
disabled={disabled}>
<Computer/> <Computer/>
<span>Terminal</span> <span>Terminal</span>
</SidebarMenuButton> </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 CodeMirror from "@uiw/react-codemirror";
import { loadLanguage } from '@uiw/codemirror-extensions-langs'; import {loadLanguage} from '@uiw/codemirror-extensions-langs';
import { hyperLink } from '@uiw/codemirror-extensions-hyper-link'; import {hyperLink} from '@uiw/codemirror-extensions-hyper-link';
import { oneDark } from '@codemirror/theme-one-dark'; import {oneDark} from '@codemirror/theme-one-dark';
import { EditorView } from '@codemirror/view'; import {EditorView} from '@codemirror/view';
interface ConfigCodeEditorProps { interface ConfigCodeEditorProps {
content: string; content: string;
@@ -14,160 +14,285 @@ interface ConfigCodeEditorProps {
export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCodeEditorProps) { export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCodeEditorProps) {
function getLanguageName(filename: string): string { function getLanguageName(filename: string): string {
if (!filename || typeof filename !== 'string') { if (!filename || typeof filename !== 'string') {
return 'text'; // Default to text if no filename return 'text';
} }
const lastDotIndex = filename.lastIndexOf('.'); const lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex === -1) { if (lastDotIndex === -1) {
return 'text'; // No extension found return 'text';
} }
const ext = filename.slice(lastDotIndex + 1).toLowerCase(); const ext = filename.slice(lastDotIndex + 1).toLowerCase();
switch (ext) { switch (ext) {
case 'ng': return 'angular'; case 'ng':
case 'apl': return 'apl'; return 'angular';
case 'asc': return 'asciiArmor'; case 'apl':
case 'ast': return 'asterisk'; return 'apl';
case 'bf': return 'brainfuck'; case 'asc':
case 'c': return 'c'; return 'asciiArmor';
case 'ceylon': return 'ceylon'; case 'ast':
case 'clj': return 'clojure'; return 'asterisk';
case 'cmake': return 'cmake'; case 'bf':
return 'brainfuck';
case 'c':
return 'c';
case 'ceylon':
return 'ceylon';
case 'clj':
return 'clojure';
case 'cmake':
return 'cmake';
case 'cob': case 'cob':
case 'cbl': return 'cobol'; case 'cbl':
case 'coffee': return 'coffeescript'; return 'cobol';
case 'lisp': return 'commonLisp'; case 'coffee':
return 'coffeescript';
case 'lisp':
return 'commonLisp';
case 'cpp': case 'cpp':
case 'cc': case 'cc':
case 'cxx': return 'cpp'; case 'cxx':
case 'cr': return 'crystal'; return 'cpp';
case 'cs': return 'csharp'; case 'cr':
case 'css': return 'css'; return 'crystal';
case 'cypher': return 'cypher'; case 'cs':
case 'd': return 'd'; return 'csharp';
case 'dart': return 'dart'; case 'css':
return 'css';
case 'cypher':
return 'cypher';
case 'd':
return 'd';
case 'dart':
return 'dart';
case 'diff': case 'diff':
case 'patch': return 'diff'; case 'patch':
case 'dockerfile': return 'dockerfile'; return 'diff';
case 'dtd': return 'dtd'; case 'dockerfile':
case 'dylan': return 'dylan'; return 'dockerfile';
case 'ebnf': return 'ebnf'; case 'dtd':
case 'ecl': return 'ecl'; return 'dtd';
case 'eiffel': return 'eiffel'; case 'dylan':
case 'elm': return 'elm'; return 'dylan';
case 'erl': return 'erlang'; case 'ebnf':
case 'factor': return 'factor'; return 'ebnf';
case 'fcl': return 'fcl'; case 'ecl':
case 'fs': return 'forth'; 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 'f90':
case 'for': return 'fortran'; case 'for':
case 's': return 'gas'; return 'fortran';
case 'feature': return 'gherkin'; case 's':
case 'go': return 'go'; return 'gas';
case 'groovy': return 'groovy'; case 'feature':
case 'hs': return 'haskell'; return 'gherkin';
case 'hx': return 'haxe'; case 'go':
return 'go';
case 'groovy':
return 'groovy';
case 'hs':
return 'haskell';
case 'hx':
return 'haxe';
case 'html': case 'html':
case 'htm': return 'html'; case 'htm':
case 'http': return 'http'; return 'html';
case 'idl': return 'idl'; case 'http':
case 'java': return 'java'; return 'http';
case 'idl':
return 'idl';
case 'java':
return 'java';
case 'js': case 'js':
case 'mjs': case 'mjs':
case 'cjs': return 'javascript'; case 'cjs':
return 'javascript';
case 'jinja2': case 'jinja2':
case 'j2': return 'jinja2'; case 'j2':
case 'json': return 'json'; return 'jinja2';
case 'jsx': return 'jsx'; case 'json':
case 'jl': return 'julia'; return 'json';
case 'jsx':
return 'jsx';
case 'jl':
return 'julia';
case 'kt': case 'kt':
case 'kts': return 'kotlin'; case 'kts':
case 'less': return 'less'; return 'kotlin';
case 'lezer': return 'lezer'; case 'less':
case 'liquid': return 'liquid'; return 'less';
case 'litcoffee': return 'livescript'; case 'lezer':
case 'lua': return 'lua'; return 'lezer';
case 'md': return 'markdown'; case 'liquid':
return 'liquid';
case 'litcoffee':
return 'livescript';
case 'lua':
return 'lua';
case 'md':
return 'markdown';
case 'nb': case 'nb':
case 'mat': return 'mathematica'; case 'mat':
case 'mbox': return 'mbox'; return 'mathematica';
case 'mmd': return 'mermaid'; case 'mbox':
case 'mrc': return 'mirc'; return 'mbox';
case 'moo': return 'modelica'; case 'mmd':
case 'mscgen': return 'mscgen'; return 'mermaid';
case 'm': return 'mumps'; case 'mrc':
case 'sql': return 'mysql'; return 'mirc';
case 'nc': return 'nesC'; case 'moo':
case 'nginx': return 'nginx'; return 'modelica';
case 'nix': return 'nix'; case 'mscgen':
case 'nsi': return 'nsis'; return 'mscgen';
case 'nt': return 'ntriples'; case 'm':
case 'mm': return 'objectiveCpp'; return 'mumps';
case 'octave': return 'octave'; case 'sql':
case 'oz': return 'oz'; return 'mysql';
case 'pas': return 'pascal'; 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 'pl':
case 'pm': return 'perl'; case 'pm':
case 'pgsql': return 'pgsql'; return 'perl';
case 'php': return 'php'; case 'pgsql':
case 'pig': return 'pig'; return 'pgsql';
case 'ps1': return 'powershell'; case 'php':
case 'properties': return 'properties'; return 'php';
case 'proto': return 'protobuf'; case 'pig':
case 'pp': return 'puppet'; return 'pig';
case 'py': return 'python'; case 'ps1':
case 'q': return 'q'; return 'powershell';
case 'r': return 'r'; case 'properties':
case 'rb': return 'ruby'; return 'properties';
case 'rs': return 'rust'; case 'proto':
case 'sas': return 'sas'; 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 'sass':
case 'scss': return 'sass'; case 'scss':
case 'scala': return 'scala'; return 'sass';
case 'scm': return 'scheme'; case 'scala':
case 'shader': return 'shader'; return 'scala';
case 'scm':
return 'scheme';
case 'shader':
return 'shader';
case 'sh': case 'sh':
case 'bash': return 'shell'; case 'bash':
case 'siv': return 'sieve'; return 'shell';
case 'st': return 'smalltalk'; case 'siv':
case 'sol': return 'solidity'; return 'sieve';
case 'solr': return 'solr'; case 'st':
case 'rq': return 'sparql'; return 'smalltalk';
case 'sol':
return 'solidity';
case 'solr':
return 'solr';
case 'rq':
return 'sparql';
case 'xlsx': case 'xlsx':
case 'ods': case 'ods':
case 'csv': return 'spreadsheet'; case 'csv':
case 'nut': return 'squirrel'; return 'spreadsheet';
case 'tex': return 'stex'; case 'nut':
case 'styl': return 'stylus'; return 'squirrel';
case 'svelte': return 'svelte'; case 'tex':
case 'swift': return 'swift'; return 'stex';
case 'tcl': return 'tcl'; case 'styl':
case 'textile': return 'textile'; return 'stylus';
case 'tiddlywiki': return 'tiddlyWiki'; case 'svelte':
case 'tiki': return 'tiki'; return 'svelte';
case 'toml': return 'toml'; case 'swift':
case 'troff': return 'troff'; return 'swift';
case 'tsx': return 'tsx'; case 'tcl':
case 'ttcn': return 'ttcn'; 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 'ttl':
case 'turtle': return 'turtle'; case 'turtle':
case 'ts': return 'typescript'; return 'turtle';
case 'vb': return 'vb'; case 'ts':
case 'vbs': return 'vbscript'; return 'typescript';
case 'vm': return 'velocity'; case 'vb':
case 'v': return 'verilog'; return 'vb';
case 'vbs':
return 'vbscript';
case 'vm':
return 'velocity';
case 'v':
return 'verilog';
case 'vhd': case 'vhd':
case 'vhdl': return 'vhdl'; case 'vhdl':
case 'vue': return 'vue'; return 'vhdl';
case 'wat': return 'wast'; case 'vue':
case 'webidl': return 'webIDL'; return 'vue';
case 'wat':
return 'wast';
case 'webidl':
return 'webIDL';
case 'xq': case 'xq':
case 'xquery': return 'xQuery'; case 'xquery':
case 'xml': return 'xml'; return 'xQuery';
case 'yacas': return 'yacas'; case 'xml':
return 'xml';
case 'yacas':
return 'yacas';
case 'yaml': case 'yaml':
case 'yml': return 'yaml'; case 'yml':
case 'z80': return 'z80'; return 'yaml';
default: return 'text'; case 'z80':
return 'z80';
default:
return 'text';
} }
} }
@@ -179,7 +304,14 @@ export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCod
}, []); }, []);
return ( 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 <div
style={{ style={{
width: '100%', width: '100%',
@@ -209,8 +341,8 @@ export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCod
onChange={(value: any) => onContentChange(value)} onChange={(value: any) => onContentChange(value)}
theme={undefined} theme={undefined}
height="100%" height="100%"
basicSetup={{ lineNumbers: true }} basicSetup={{lineNumbers: true}}
style={{ minHeight: '100%', minWidth: '100%', flex: 1 }} style={{minHeight: '100%', minWidth: '100%', flex: 1}}
/> />
</div> </div>
</div> </div>

View File

@@ -1,11 +1,11 @@
import React, { useState, useEffect, useRef } from "react"; import React, {useState, useEffect, useRef} from "react";
import { ConfigEditorSidebar } from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx"; import {ConfigEditorSidebar} from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx";
import { ConfigTabList } from "@/apps/SSH/Config Editor/ConfigTabList.tsx"; import {ConfigTabList} from "@/apps/SSH/Config Editor/ConfigTabList.tsx";
import { ConfigHomeView } from "@/apps/SSH/Config Editor/ConfigHomeView.tsx"; import {ConfigHomeView} from "@/apps/SSH/Config Editor/ConfigHomeView.tsx";
import { ConfigCodeEditor } from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx"; import {ConfigCodeEditor} from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx";
import { Button } from '@/components/ui/button.tsx'; import {Button} from '@/components/ui/button.tsx';
import { ConfigTopbar } from "@/apps/SSH/Config Editor/ConfigTopbar.tsx"; import {ConfigTopbar} from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
import { cn } from '@/lib/utils.ts'; import {cn} from '@/lib/utils.ts';
import { import {
getConfigEditorRecent, getConfigEditorRecent,
getConfigEditorPinned, getConfigEditorPinned,
@@ -20,7 +20,7 @@ import {
writeSSHFile, writeSSHFile,
getSSHStatus, getSSHStatus,
connectSSH connectSSH
} from '@/apps/SSH/ssh-axios-fixed.ts'; } from '@/apps/SSH/ssh-axios.ts';
interface Tab { interface Tab {
id: string | number; id: string | number;
@@ -59,7 +59,7 @@ interface SSHHost {
updatedAt: string; 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 [tabs, setTabs] = useState<Tab[]>([]);
const [activeTab, setActiveTab] = useState<string | number>('home'); const [activeTab, setActiveTab] = useState<string | number>('home');
const [recent, setRecent] = useState<any[]>([]); const [recent, setRecent] = useState<any[]>([]);
@@ -71,89 +71,71 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
const sidebarRef = useRef<any>(null); const sidebarRef = useRef<any>(null);
// Fetch home data when host changes
useEffect(() => { useEffect(() => {
if (currentHost) { if (currentHost) {
fetchHomeData(); fetchHomeData();
} else { } else {
// Clear data when no host is selected
setRecent([]); setRecent([]);
setPinned([]); setPinned([]);
setShortcuts([]); setShortcuts([]);
} }
}, [currentHost]); }, [currentHost]);
// Refresh home data when switching to home view
useEffect(() => { useEffect(() => {
if (activeTab === 'home' && currentHost) { if (activeTab === 'home' && currentHost) {
fetchHomeData(); fetchHomeData();
} }
}, [activeTab, currentHost]); }, [activeTab, currentHost]);
// Periodic refresh of home data when on home view
useEffect(() => { useEffect(() => {
if (activeTab === 'home' && currentHost) { if (activeTab === 'home' && currentHost) {
const interval = setInterval(() => { const interval = setInterval(() => {
fetchHomeData(); fetchHomeData();
}, 2000); // Refresh every 2 seconds when on home view }, 2000);
return () => clearInterval(interval); return () => clearInterval(interval);
} }
}, [activeTab, currentHost]); }, [activeTab, currentHost]);
async function fetchHomeData() { async function fetchHomeData() {
if (!currentHost) return; if (!currentHost) return;
try { try {
console.log('Fetching home data for host:', currentHost.id);
const homeDataPromise = Promise.all([ const homeDataPromise = Promise.all([
getConfigEditorRecent(currentHost.id), getConfigEditorRecent(currentHost.id),
getConfigEditorPinned(currentHost.id), getConfigEditorPinned(currentHost.id),
getConfigEditorShortcuts(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) setTimeout(() => reject(new Error('Fetch home data timed out')), 15000)
); );
const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]); 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 => ({ const recentWithPinnedStatus = (recentRes || []).map(file => ({
...file, ...file,
type: 'file', // Assume all recent files are files, not directories type: 'file',
isPinned: (pinnedRes || []).some(pinnedFile => isPinned: (pinnedRes || []).some(pinnedFile =>
pinnedFile.path === file.path && pinnedFile.name === file.name pinnedFile.path === file.path && pinnedFile.name === file.name
) )
})); }));
// Process pinned files to add type
const pinnedWithType = (pinnedRes || []).map(file => ({ const pinnedWithType = (pinnedRes || []).map(file => ({
...file, ...file,
type: 'file' // Assume all pinned files are files, not directories type: 'file'
})); }));
setRecent(recentWithPinnedStatus); setRecent(recentWithPinnedStatus);
setPinned(pinnedWithType); setPinned(pinnedWithType);
setShortcuts((shortcutsRes || []).map(shortcut => ({ setShortcuts((shortcutsRes || []).map(shortcut => ({
...shortcut, ...shortcut,
type: 'directory' // Shortcuts are always directories type: 'directory'
}))); })));
} catch (err: any) { } catch (err: any) {
console.error('Failed to fetch home data:', err);
} }
} }
// Helper function for consistent error handling
const formatErrorMessage = (err: any, defaultMessage: string): string => { const formatErrorMessage = (err: any, defaultMessage: string): string => {
if (typeof err === 'object' && err !== null && 'response' in err) { if (typeof err === 'object' && err !== null && 'response' in err) {
const axiosErr = err as any; 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 handleOpenFile = async (file: any) => {
const tabId = file.path; const tabId = file.path;
console.log('Opening file:', { file, currentHost, tabId });
if (!tabs.find(t => t.id === 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(); const currentSshSessionId = currentHost?.id.toString();
console.log('Using SSH session ID:', currentSshSessionId, 'for file path:', file.path);
setTabs([...tabs, {
setTabs([...tabs, { id: tabId, title: file.name, fileName: file.name, content: '', filePath: file.path, isSSH: true, sshSessionId: currentSshSessionId, loading: true }]); id: tabId,
title: file.name,
fileName: file.name,
content: '',
filePath: file.path,
isSSH: true,
sshSessionId: currentSshSessionId,
loading: true
}]);
try { try {
const res = await readSSHFile(currentSshSessionId, file.path); 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 ? {
setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, content: res.content, loading: false, error: undefined } : t)); ...t,
// Mark as recent content: res.content,
await addConfigEditorRecent({ loading: false,
name: file.name, error: undefined
path: file.path, } : t));
isSSH: true, await addConfigEditorRecent({
name: file.name,
path: file.path,
isSSH: true,
sshSessionId: currentSshSessionId, sshSessionId: currentSshSessionId,
hostId: currentHost?.id hostId: currentHost?.id
}); });
// Refresh immediately after opening file
fetchHomeData(); fetchHomeData();
} catch (err: any) { } catch (err: any) {
console.error('Failed to read file:', { path: file.path, sessionId: currentSshSessionId, error: err });
const errorMessage = formatErrorMessage(err, 'Cannot read file'); 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); setActiveTab(tabId);
@@ -211,128 +199,103 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
const handleRemoveRecent = async (file: any) => { const handleRemoveRecent = async (file: any) => {
try { try {
await removeConfigEditorRecent({ await removeConfigEditorRecent({
name: file.name, name: file.name,
path: file.path, path: file.path,
isSSH: true, isSSH: true,
sshSessionId: file.sshSessionId, sshSessionId: file.sshSessionId,
hostId: currentHost?.id hostId: currentHost?.id
}); });
// Refresh immediately after removing
fetchHomeData(); fetchHomeData();
} catch (err) { } catch (err) {
console.error('Failed to remove recent file:', err);
} }
}; };
const handlePinFile = async (file: any) => { const handlePinFile = async (file: any) => {
try { try {
await addConfigEditorPinned({ await addConfigEditorPinned({
name: file.name, name: file.name,
path: file.path, path: file.path,
isSSH: true, isSSH: true,
sshSessionId: file.sshSessionId, sshSessionId: file.sshSessionId,
hostId: currentHost?.id hostId: currentHost?.id
}); });
// Refresh immediately after pinning
fetchHomeData(); fetchHomeData();
// Refresh sidebar files to update pin states immediately
if (sidebarRef.current && sidebarRef.current.fetchFiles) { if (sidebarRef.current && sidebarRef.current.fetchFiles) {
sidebarRef.current.fetchFiles(); sidebarRef.current.fetchFiles();
} }
} catch (err) { } catch (err) {
console.error('Failed to pin file:', err);
} }
}; };
const handleUnpinFile = async (file: any) => { const handleUnpinFile = async (file: any) => {
try { try {
await removeConfigEditorPinned({ await removeConfigEditorPinned({
name: file.name, name: file.name,
path: file.path, path: file.path,
isSSH: true, isSSH: true,
sshSessionId: file.sshSessionId, sshSessionId: file.sshSessionId,
hostId: currentHost?.id hostId: currentHost?.id
}); });
// Refresh immediately after unpinning
fetchHomeData(); fetchHomeData();
// Refresh sidebar files to update pin states immediately
if (sidebarRef.current && sidebarRef.current.fetchFiles) { if (sidebarRef.current && sidebarRef.current.fetchFiles) {
sidebarRef.current.fetchFiles(); sidebarRef.current.fetchFiles();
} }
} catch (err) { } catch (err) {
console.error('Failed to unpin file:', err);
} }
}; };
const handleOpenShortcut = async (shortcut: any) => { const handleOpenShortcut = async (shortcut: any) => {
console.log('Opening shortcut:', { shortcut, currentHost });
// Prevent multiple rapid clicks
if (sidebarRef.current?.isOpeningShortcut) { if (sidebarRef.current?.isOpeningShortcut) {
console.log('Shortcut opening already in progress, ignoring click');
return; return;
} }
if (sidebarRef.current && sidebarRef.current.openFolder) { if (sidebarRef.current && sidebarRef.current.openFolder) {
try { try {
// Set flag to prevent multiple simultaneous opens
sidebarRef.current.isOpeningShortcut = true; sidebarRef.current.isOpeningShortcut = true;
// Normalize the path to ensure it starts with /
const normalizedPath = shortcut.path.startsWith('/') ? shortcut.path : `/${shortcut.path}`; const normalizedPath = shortcut.path.startsWith('/') ? shortcut.path : `/${shortcut.path}`;
console.log('Normalized path:', normalizedPath);
await sidebarRef.current.openFolder(currentHost, normalizedPath); await sidebarRef.current.openFolder(currentHost, normalizedPath);
console.log('Shortcut opened successfully');
} catch (err) { } catch (err) {
console.error('Failed to open shortcut:', err);
// Could show error to user here if needed
} finally { } finally {
// Clear flag after operation completes
if (sidebarRef.current) { if (sidebarRef.current) {
sidebarRef.current.isOpeningShortcut = false; sidebarRef.current.isOpeningShortcut = false;
} }
} }
} else { } else {
console.error('Sidebar ref or openFolder function not available');
} }
}; };
const handleAddShortcut = async (folderPath: string) => { const handleAddShortcut = async (folderPath: string) => {
try { try {
const name = folderPath.split('/').pop() || folderPath; const name = folderPath.split('/').pop() || folderPath;
await addConfigEditorShortcut({ await addConfigEditorShortcut({
name, name,
path: folderPath, path: folderPath,
isSSH: true, isSSH: true,
sshSessionId: currentHost?.id.toString(), sshSessionId: currentHost?.id.toString(),
hostId: currentHost?.id hostId: currentHost?.id
}); });
// Refresh immediately after adding shortcut
fetchHomeData(); fetchHomeData();
} catch (err) { } catch (err) {
console.error('Failed to add shortcut:', err);
} }
}; };
const handleRemoveShortcut = async (shortcut: any) => { const handleRemoveShortcut = async (shortcut: any) => {
try { try {
await removeConfigEditorShortcut({ await removeConfigEditorShortcut({
name: shortcut.name, name: shortcut.name,
path: shortcut.path, path: shortcut.path,
isSSH: true, isSSH: true,
sshSessionId: currentHost?.id.toString(), sshSessionId: currentHost?.id.toString(),
hostId: currentHost?.id hostId: currentHost?.id
}); });
// Refresh immediately after removing shortcut
fetchHomeData(); fetchHomeData();
} catch (err) { } catch (err) {
console.error('Failed to remove shortcut:', err);
} }
}; };
// Tab actions
const closeTab = (tabId: string | number) => { const closeTab = (tabId: string | number) => {
const idx = tabs.findIndex(t => t.id === tabId); const idx = tabs.findIndex(t => t.id === tabId);
const newTabs = tabs.filter(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); if (newTabs.length > 0) setActiveTab(newTabs[Math.max(0, idx - 1)].id);
else setActiveTab('home'); else setActiveTab('home');
} }
// Refresh home data when closing tabs to update recent list
if (currentHost) { if (currentHost) {
fetchHomeData(); fetchHomeData();
} }
}; };
const setTabContent = (tabId: string | number, content: string) => { 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) => { const handleSave = async (tab: Tab) => {
// Prevent multiple simultaneous saves
if (isSaving) { if (isSaving) {
console.log('Save already in progress, ignoring save request');
return; return;
} }
setIsSaving(true); setIsSaving(true);
try { 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) { if (!tab.sshSessionId) {
throw new Error('No SSH session ID available'); throw new Error('No SSH session ID available');
} }
if (!tab.filePath) { if (!tab.filePath) {
throw new Error('No file path available'); throw new Error('No file path available');
} }
if (!currentHost?.id) { if (!currentHost?.id) {
throw new Error('No current host available'); throw new Error('No current host available');
} }
// Check SSH connection status first with timeout
try { try {
const statusPromise = getSSHStatus(tab.sshSessionId); 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) setTimeout(() => reject(new Error('SSH status check timed out')), 10000)
); );
const status = await Promise.race([statusPromise, statusTimeoutPromise]); const status = await Promise.race([statusPromise, statusTimeoutPromise]);
if (!status.connected) { 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, { const connectPromise = connectSSH(tab.sshSessionId, {
ip: currentHost.ip, ip: currentHost.ip,
port: currentHost.port, port: currentHost.port,
@@ -402,119 +356,107 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
sshKey: currentHost.key, sshKey: currentHost.key,
keyPassword: currentHost.keyPassword keyPassword: currentHost.keyPassword
}); });
const connectTimeoutPromise = new Promise((_, reject) => const connectTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('SSH reconnection timed out')), 15000) setTimeout(() => reject(new Error('SSH reconnection timed out')), 15000)
); );
await Promise.race([connectPromise, connectTimeoutPromise]); await Promise.race([connectPromise, connectTimeoutPromise]);
console.log('SSH reconnection successful');
} }
} catch (statusErr) { } 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 savePromise = writeSSHFile(tab.sshSessionId, tab.filePath, tab.content);
const timeoutPromise = new Promise((_, reject) => const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => { setTimeout(() => {
console.log('Save operation timed out after 30 seconds');
reject(new Error('Save operation timed out')); reject(new Error('Save operation timed out'));
}, 30000) }, 30000)
); );
const result = await Promise.race([savePromise, timeoutPromise]); const result = await Promise.race([savePromise, timeoutPromise]);
console.log('Save operation completed successfully:', result); setTabs(tabs => tabs.map(t => t.id === tab.id ? {
setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, dirty: false, success: 'File saved successfully' } : t)); ...t,
console.log('File saved successfully - main save operation complete'); dirty: false,
success: 'File saved successfully'
// Auto-hide success message after 3 seconds } : t));
setTimeout(() => { 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); }, 3000);
// Mark as recent and refresh home data in background (non-blocking)
Promise.allSettled([ Promise.allSettled([
(async () => { (async () => {
try { try {
console.log('Adding file to recent...'); await addConfigEditorRecent({
await addConfigEditorRecent({ name: tab.fileName,
name: tab.fileName, path: tab.filePath,
path: tab.filePath, isSSH: true,
isSSH: true,
sshSessionId: tab.sshSessionId, sshSessionId: tab.sshSessionId,
hostId: currentHost.id hostId: currentHost.id
}); });
console.log('File added to recent successfully');
} catch (recentErr) { } catch (recentErr) {
console.warn('Failed to add file to recent:', recentErr);
} }
})(), })(),
(async () => { (async () => {
try { try {
console.log('Refreshing home data...');
await fetchHomeData(); await fetchHomeData();
console.log('Home data refreshed successfully');
} catch (refreshErr) { } catch (refreshErr) {
console.warn('Failed to refresh home data:', refreshErr);
} }
})() })()
]).then(() => { ]).then(() => {
console.log('Background operations completed');
}); });
console.log('File saved successfully - main operation complete, background operations started');
} catch (err) { } catch (err) {
console.error('Failed to save file:', err);
let errorMessage = formatErrorMessage(err, 'Cannot save file'); 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')) { 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.`; 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 => { setTabs(tabs => {
const updatedTabs = tabs.map(t => t.id === tab.id ? { ...t, error: `Failed to save file: ${errorMessage}` } : t); const updatedTabs = tabs.map(t => t.id === tab.id ? {
console.log('Updated tabs with error:', updatedTabs.find(t => t.id === tab.id)); ...t,
error: `Failed to save file: ${errorMessage}`
} : t);
return updatedTabs; return updatedTabs;
}); });
// Force a re-render to ensure error is displayed
setTimeout(() => { setTimeout(() => {
console.log('Forcing re-render to show error');
setTabs(currentTabs => [...currentTabs]); setTabs(currentTabs => [...currentTabs]);
}, 100); }, 100);
} finally { } finally {
console.log('Save operation completed, setting isSaving to false');
setIsSaving(false); setIsSaving(false);
console.log('isSaving state after setting to false:', false);
} }
}; };
const handleHostChange = (host: SSHHost | null) => { const handleHostChange = (host: SSHHost | null) => {
setCurrentHost(host); setCurrentHost(host);
// Close all tabs when switching hosts
setTabs([]); setTabs([]);
setActiveTab('home'); setActiveTab('home');
}; };
// Show connection message when no host is selected
if (!currentHost) { if (!currentHost) {
return ( return (
<div style={{ position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden' }}> <div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
<div style={{ position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20 }}> <div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
<ConfigEditorSidebar <ConfigEditorSidebar
onSelectView={onSelectView} onSelectView={onSelectView}
onOpenFile={handleOpenFile} onOpenFile={handleOpenFile}
tabs={tabs} tabs={tabs}
ref={sidebarRef} ref={sidebarRef}
onHostChange={handleHostChange} onHostChange={handleHostChange}
/> />
</div> </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"> <div className="text-center">
<h2 className="text-xl font-semibold text-white mb-2">Connect to a Server</h2> <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> <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 ( return (
<div style={{ position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden' }}> <div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
<div style={{ position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20 }}> <div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
<ConfigEditorSidebar <ConfigEditorSidebar
onSelectView={onSelectView} onSelectView={onSelectView}
onOpenFile={handleOpenFile} onOpenFile={handleOpenFile}
tabs={tabs} tabs={tabs}
ref={sidebarRef} ref={sidebarRef}
onHostChange={handleHostChange} onHostChange={handleHostChange}
/> />
</div> </div>
<div style={{ position: 'absolute', top: 0, left: 256, right: 0, height: 44, zIndex: 30 }}> <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 className="flex items-center w-full bg-[#18181b] border-b border-[#222224] h-11 relative px-4"
style={{height: 44}}>
{/* Tab list scrollable area */} {/* Tab list scrollable area */}
<div className="flex-1 min-w-0 h-full flex items-center"> <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 <ConfigTopbar
tabs={tabs.map(t => ({ id: t.id, title: t.title }))} tabs={tabs.map(t => ({id: t.id, title: t.title}))}
activeTab={activeTab} activeTab={activeTab}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
closeTab={closeTab} closeTab={closeTab}
onHomeClick={() => { onHomeClick={() => {
setActiveTab('home'); setActiveTab('home');
// Immediately refresh home data when clicking home
if (currentHost) { if (currentHost) {
fetchHomeData(); fetchHomeData();
} }
@@ -568,13 +512,24 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
if (tab && !isSaving) handleSave(tab); if (tab && !isSaving) handleSave(tab);
}} }}
type="button" type="button"
style={{ height: 36, alignSelf: 'center' }} style={{height: 36, alignSelf: 'center'}}
> >
{isSaving ? 'Saving...' : 'Save'} {isSaving ? 'Saving...' : 'Save'}
</Button> </Button>
</div> </div>
</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' ? ( {activeTab === 'home' ? (
<ConfigHomeView <ConfigHomeView
recent={recent} recent={recent}
@@ -593,17 +548,21 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
const tab = tabs.find(t => t.id === activeTab); const tab = tabs.find(t => t.id === activeTab);
if (!tab) return null; if (!tab) return null;
return ( 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 */} {/* Error display */}
{tab.error && ( {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 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-red-400"></span> <span className="text-red-400"></span>
<span>{tab.error}</span> <span>{tab.error}</span>
</div> </div>
<button <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" className="text-red-400 hover:text-red-300 transition-colors"
> >
@@ -613,14 +572,18 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
)} )}
{/* Success display */} {/* Success display */}
{tab.success && ( {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 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-green-400"></span> <span className="text-green-400"></span>
<span>{tab.success}</span> <span>{tab.success}</span>
</div> </div>
<button <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" 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 {Separator} from '@/components/ui/separator.tsx';
import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react'; import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react';
import {ScrollArea} from '@/components/ui/scroll-area.tsx'; 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 {Input} from '@/components/ui/input.tsx';
import {Button} from '@/components/ui/button.tsx'; import {Button} from '@/components/ui/button.tsx';
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx'; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx';
import { import {
getSSHHosts, getSSHHosts,
listSSHFiles, listSSHFiles,
connectSSH, connectSSH,
getSSHStatus, getSSHStatus,
getConfigEditorPinned, getConfigEditorPinned,
addConfigEditorPinned, addConfigEditorPinned,
removeConfigEditorPinned removeConfigEditorPinned
} from '@/apps/SSH/ssh-axios-fixed.ts'; } from '@/apps/SSH/ssh-axios.ts';
interface SSHHost { interface SSHHost {
id: number; id: number;
@@ -48,9 +48,9 @@ interface SSHHost {
} }
const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
{ onSelectView, onOpenFile, tabs, onHostChange }: { {onSelectView, onOpenFile, tabs, onHostChange}: {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
onOpenFile: (file: any) => void; onOpenFile: (file: any) => void;
tabs: any[]; tabs: any[];
onHostChange?: (host: SSHHost | null) => void; onHostChange?: (host: SSHHost | null) => void;
}, },
@@ -64,8 +64,7 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
const [currentPath, setCurrentPath] = useState('/'); const [currentPath, setCurrentPath] = useState('/');
const [files, setFiles] = useState<any[]>([]); const [files, setFiles] = useState<any[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null); const pathInputRef = useRef<HTMLInputElement>(null);
// Add search bar state
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
const [fileSearch, setFileSearch] = useState(''); const [fileSearch, setFileSearch] = useState('');
@@ -79,12 +78,14 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
return () => clearTimeout(handler); return () => clearTimeout(handler);
}, [fileSearch]); }, [fileSearch]);
// Add state for SSH sessionId and loading/error
const [sshSessionId, setSshSessionId] = useState<string | null>(null); const [sshSessionId, setSshSessionId] = useState<string | null>(null);
const [filesLoading, setFilesLoading] = useState(false); const [filesLoading, setFilesLoading] = useState(false);
const [filesError, setFilesError] = useState<string | null>(null); const [filesError, setFilesError] = useState<string | null>(null);
const [connectingSSH, setConnectingSSH] = useState(false); 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); const [fetchingFiles, setFetchingFiles] = useState(false);
useEffect(() => { useEffect(() => {
@@ -96,76 +97,41 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
setErrorSSH(undefined); setErrorSSH(undefined);
try { try {
const hosts = await getSSHHosts(); 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); const configEditorHosts = hosts.filter(host => host.enableConfigEditor);
console.log('Config Editor hosts:', configEditorHosts);
// Debug: Log the first host's credentials
if (configEditorHosts.length > 0) { if (configEditorHosts.length > 0) {
const firstHost = configEditorHosts[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); setSSHConnections(configEditorHosts);
} catch (err: any) { } catch (err: any) {
console.error('Failed to load SSH hosts:', err);
setErrorSSH('Failed to load SSH connections'); setErrorSSH('Failed to load SSH connections');
} finally { } finally {
setLoadingSSH(false); setLoadingSSH(false);
} }
} }
// Helper to connect to SSH and set sessionId
async function connectToSSH(server: SSHHost): Promise<string | null> { async function connectToSSH(server: SSHHost): Promise<string | null> {
const sessionId = server.id.toString(); const sessionId = server.id.toString();
// Check if we already have a recent connection to this server
const cached = connectionCache[sessionId]; const cached = connectionCache[sessionId];
if (cached && Date.now() - cached.timestamp < 30000) { // 30 second cache if (cached && Date.now() - cached.timestamp < 30000) {
console.log('Using cached SSH connection for session:', sessionId);
setSshSessionId(cached.sessionId); setSshSessionId(cached.sessionId);
return cached.sessionId; return cached.sessionId;
} }
// Prevent multiple simultaneous connections
if (connectingSSH) { if (connectingSSH) {
console.log('SSH connection already in progress, skipping...');
return null; return null;
} }
setConnectingSSH(true); setConnectingSSH(true);
try { 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) { if (!server.password && !server.key) {
console.error('No authentication credentials available for SSH host');
setFilesError('No authentication credentials available for this SSH host'); setFilesError('No authentication credentials available for this SSH host');
return null; return null;
} }
const connectionConfig = { const connectionConfig = {
ip: server.ip, ip: server.ip,
port: server.port, port: server.port,
@@ -174,32 +140,18 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
sshKey: server.key, sshKey: server.key,
keyPassword: server.keyPassword, keyPassword: server.keyPassword,
}; };
console.log('SSH connection config:', {
...connectionConfig,
password: connectionConfig.password ? '[REDACTED]' : undefined,
sshKey: connectionConfig.sshKey ? '[REDACTED]' : undefined
});
await connectSSH(sessionId, connectionConfig); await connectSSH(sessionId, connectionConfig);
console.log('SSH connection successful for session:', sessionId);
setSshSessionId(sessionId); setSshSessionId(sessionId);
// Cache the successful connection
setConnectionCache(prev => ({ setConnectionCache(prev => ({
...prev, ...prev,
[sessionId]: { sessionId, timestamp: Date.now() } [sessionId]: {sessionId, timestamp: Date.now()}
})); }));
return sessionId; return sessionId;
} catch (err: any) { } 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'); setFilesError(err?.response?.data?.error || 'Failed to connect to SSH');
setSshSessionId(null); setSshSessionId(null);
return null; return null;
@@ -208,72 +160,51 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
} }
} }
// Modified fetchFiles to handle SSH connect if needed
async function fetchFiles() { async function fetchFiles() {
// Prevent multiple simultaneous fetches
if (fetchingFiles) { if (fetchingFiles) {
console.log('Already fetching files, skipping...');
return; return;
} }
setFetchingFiles(true); setFetchingFiles(true);
setFiles([]); setFiles([]);
setFilesLoading(true); setFilesLoading(true);
setFilesError(null); setFilesError(null);
try { try {
// Get pinned files to check against for current host
let pinnedFiles: any[] = []; let pinnedFiles: any[] = [];
try { try {
if (activeServer) { if (activeServer) {
pinnedFiles = await getConfigEditorPinned(activeServer.id); pinnedFiles = await getConfigEditorPinned(activeServer.id);
console.log('Fetched pinned files:', pinnedFiles);
} }
} catch (err) { } catch (err) {
console.error('Failed to fetch pinned files:', err);
} }
if (activeServer && sshSessionId) { if (activeServer && sshSessionId) {
console.log('Fetching files for path:', currentPath, 'sessionId:', sshSessionId);
let res: any[] = []; let res: any[] = [];
// Check if SSH session is still valid
try { try {
const status = await getSSHStatus(sshSessionId); const status = await getSSHStatus(sshSessionId);
console.log('SSH session status:', status);
if (!status.connected) { if (!status.connected) {
console.log('SSH session not connected, reconnecting...');
const newSessionId = await connectToSSH(activeServer); const newSessionId = await connectToSSH(activeServer);
if (newSessionId) { if (newSessionId) {
setSshSessionId(newSessionId); setSshSessionId(newSessionId);
// Retry with new session
res = await listSSHFiles(newSessionId, currentPath); res = await listSSHFiles(newSessionId, currentPath);
console.log('Retry - Raw SSH files response:', res);
console.log('Retry - Files count:', res?.length || 0);
} else { } else {
throw new Error('Failed to reconnect SSH session'); throw new Error('Failed to reconnect SSH session');
} }
} else { } else {
res = await listSSHFiles(sshSessionId, currentPath); 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) { } catch (sessionErr) {
console.error('SSH session check failed:', sessionErr);
// Try to reconnect and retry
const newSessionId = await connectToSSH(activeServer); const newSessionId = await connectToSSH(activeServer);
if (newSessionId) { if (newSessionId) {
setSshSessionId(newSessionId); setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath); res = await listSSHFiles(newSessionId, currentPath);
console.log('Reconnect - Raw SSH files response:', res);
console.log('Reconnect - Files count:', res?.length || 0);
} else { } else {
throw sessionErr; throw sessionErr;
} }
} }
const processedFiles = (res || []).map((f: any) => { const processedFiles = (res || []).map((f: any) => {
const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name; const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name;
const isPinned = pinnedFiles.some(pinned => pinned.path === filePath); const isPinned = pinnedFiles.some(pinned => pinned.path === filePath);
@@ -285,18 +216,10 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
sshSessionId: sshSessionId sshSessionId: sshSessionId
}; };
}); });
console.log('Processed files with pin states:', processedFiles);
setFiles(processedFiles); setFiles(processedFiles);
} }
} catch (err: any) { } 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([]); setFiles([]);
setFilesError(err?.response?.data?.error || err?.message || 'Failed to list files'); setFilesError(err?.response?.data?.error || err?.message || 'Failed to list files');
} finally { } finally {
@@ -305,50 +228,37 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
} }
} }
// When activeServer, currentPath, or sshSessionId changes, fetch files
useEffect(() => { 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) { if (view === 'files' && activeServer && sshSessionId && !connectingSSH && !fetchingFiles) {
console.log('Calling fetchFiles...');
// Add a small delay to prevent rapid reconnections
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
fetchFiles(); fetchFiles();
}, 100); }, 100);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
} }
// eslint-disable-next-line
}, [currentPath, view, activeServer, sshSessionId]); }, [currentPath, view, activeServer, sshSessionId]);
// When switching servers, reset sessionId and errors
async function handleSelectServer(server: SSHHost) { async function handleSelectServer(server: SSHHost) {
// Prevent multiple rapid server selections
if (connectingSSH) { if (connectingSSH) {
console.log('SSH connection in progress, ignoring server selection');
return; return;
} }
// Reset all states when switching servers
setFetchingFiles(false); setFetchingFiles(false);
setFilesLoading(false); setFilesLoading(false);
setFilesError(null); setFilesError(null);
setFiles([]); // Clear files immediately to show loading state setFiles([]);
setActiveServer(server); setActiveServer(server);
setCurrentPath(server.defaultPath || '/'); setCurrentPath(server.defaultPath || '/');
setView('files'); setView('files');
// Establish SSH connection immediately when server is selected
const sessionId = await connectToSSH(server); const sessionId = await connectToSSH(server);
if (sessionId) { if (sessionId) {
setSshSessionId(sessionId); setSshSessionId(sessionId);
// Notify parent component about host change
if (onHostChange) { if (onHostChange) {
onHostChange(server); onHostChange(server);
} }
} else { } else {
// If SSH connection fails, stay in servers view w
setView('servers'); setView('servers');
setActiveServer(null); setActiveServer(null);
} }
@@ -356,50 +266,36 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
openFolder: async (server: SSHHost, path: string) => { 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) { if (connectingSSH || fetchingFiles) {
console.log('SSH connection or file fetch in progress, skipping folder open');
return; return;
} }
// If we're already on the same server and path, just refresh files
if (activeServer?.id === server.id && currentPath === path) { 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); setTimeout(() => fetchFiles(), 100);
return; return;
} }
// Reset all states when opening a folder
setFetchingFiles(false); setFetchingFiles(false);
setFilesLoading(false); setFilesLoading(false);
setFilesError(null); setFilesError(null);
setFiles([]); setFiles([]);
setActiveServer(server); setActiveServer(server);
setCurrentPath(path); setCurrentPath(path);
setView('files'); setView('files');
// Only establish SSH connection if we don't already have one for this server
if (!sshSessionId || activeServer?.id !== server.id) { if (!sshSessionId || activeServer?.id !== server.id) {
console.log('Establishing new SSH connection for server:', server.id);
const sessionId = await connectToSSH(server); const sessionId = await connectToSSH(server);
if (sessionId) { if (sessionId) {
setSshSessionId(sessionId); setSshSessionId(sessionId);
// Only notify parent component about host change if the server actually changed
if (onHostChange && activeServer?.id !== server.id) { if (onHostChange && activeServer?.id !== server.id) {
onHostChange(server); onHostChange(server);
} }
} else { } else {
// If SSH connection fails, stay in servers view
setView('servers'); setView('servers');
setActiveServer(null); setActiveServer(null);
} }
} else { } 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) { if (onHostChange && activeServer?.id !== server.id) {
onHostChange(server); onHostChange(server);
} }
@@ -412,51 +308,46 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
} }
})); }));
// Path input focus scroll
useEffect(() => { useEffect(() => {
if (pathInputRef.current) { if (pathInputRef.current) {
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth; pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
} }
}, [currentPath]); }, [currentPath]);
// Group SSH connections by folder
const sshByFolder: Record<string, SSHHost[]> = {}; const sshByFolder: Record<string, SSHHost[]> = {};
sshConnections.forEach(conn => { sshConnections.forEach(conn => {
const folder = conn.folder && conn.folder.trim() ? conn.folder : 'No Folder'; const folder = conn.folder && conn.folder.trim() ? conn.folder : 'No Folder';
if (!sshByFolder[folder]) sshByFolder[folder] = []; if (!sshByFolder[folder]) sshByFolder[folder] = [];
sshByFolder[folder].push(conn); sshByFolder[folder].push(conn);
}); });
// Move 'No Folder' to the top
const sortedFolders = Object.keys(sshByFolder); const sortedFolders = Object.keys(sshByFolder);
if (sortedFolders.includes('No Folder')) { if (sortedFolders.includes('No Folder')) {
sortedFolders.splice(sortedFolders.indexOf('No Folder'), 1); sortedFolders.splice(sortedFolders.indexOf('No Folder'), 1);
sortedFolders.unshift('No Folder'); sortedFolders.unshift('No Folder');
} }
// Filter hosts by search
const filteredSshByFolder: Record<string, SSHHost[]> = {}; const filteredSshByFolder: Record<string, SSHHost[]> = {};
Object.entries(sshByFolder).forEach(([folder, hosts]) => { Object.entries(sshByFolder).forEach(([folder, hosts]) => {
filteredSshByFolder[folder] = hosts.filter(conn => { filteredSshByFolder[folder] = hosts.filter(conn => {
const q = debouncedSearch.trim().toLowerCase(); const q = debouncedSearch.trim().toLowerCase();
if (!q) return true; if (!q) return true;
return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) || return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) ||
(conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) || (conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) ||
(conn.tags || []).join(' ').toLowerCase().includes(q); (conn.tags || []).join(' ').toLowerCase().includes(q);
}); });
}); });
// Filter files by search
const filteredFiles = files.filter(file => { const filteredFiles = files.filter(file => {
const q = debouncedFileSearch.trim().toLowerCase(); const q = debouncedFileSearch.trim().toLowerCase();
if (!q) return true; if (!q) return true;
return file.name.toLowerCase().includes(q); return file.name.toLowerCase().includes(q);
}); });
// --- Render ---
return ( return (
<SidebarProvider> <SidebarProvider>
<Sidebar style={{ height: '100vh', maxHeight: '100vh', overflow: 'hidden' }}> <Sidebar style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}>
<SidebarContent 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"> <SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2"> <SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / Config Termix / Config
@@ -473,12 +364,12 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
<Separator className="p-0.25 mt-1 mb-1"/> <Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
{/* Main black div: servers list or file/folder browser */} <div
<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"> 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' && ( {view === 'servers' && (
<> <>
{/* Search bar - outside ScrollArea so it's always visible */} <div
<div className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10 border-b border-[#23232a]"> className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10 border-b border-[#23232a]">
<Input <Input
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
@@ -487,21 +378,23 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
autoComplete="off" autoComplete="off"
/> />
</div> </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"> <div className="flex flex-col h-full">
{/* SSH hosts/folders section */} <div
<div className="w-full flex-grow overflow-hidden p-0 m-0 relative flex flex-col min-h-0"> className="w-full flex-grow overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
<div style={{ display: 'flex', justifyContent: 'center' }}> <div style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="w-full h-px bg-[#434345] my-2" style={{ maxWidth: 213, margin: '0 auto' }} /> <Separator className="w-full h-px bg-[#434345] my-2"
style={{maxWidth: 213, margin: '0 auto'}}/>
</div> </div>
{/* Host list */}
<div className="mx-auto" style={{maxWidth: '213px', width: '100%'}}> <div className="mx-auto" style={{maxWidth: '213px', width: '100%'}}>
{/* Accordion for folders/hosts */}
<div className="flex-1 min-h-0"> <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) => ( {sortedFolders.map((folder, idx) => (
<React.Fragment key={folder}> <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 <AccordionTrigger
className="text-base font-semibold rounded-t-none py-2 w-full">{folder}</AccordionTrigger> className="text-base font-semibold rounded-t-none py-2 w-full">{folder}</AccordionTrigger>
<AccordionContent <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" className="w-full h-10 px-2 bg-[#18181b] border border-[#434345] hover:bg-[#2d2d30] transition-colors text-left justify-start"
onClick={() => handleSelectServer(conn)} onClick={() => handleSelectServer(conn)}
> >
<div className="flex items-center w-full"> <div
className="flex items-center w-full">
{conn.pin && <Pin {conn.pin && <Pin
className="w-0.5 h-0.5 text-yellow-400 mr-1 flex-shrink-0" />} 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> <span
className="font-medium truncate">{conn.name || conn.ip}</span>
</div> </div>
</Button> </Button>
))} ))}
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
{idx < sortedFolders.length - 1 && ( {idx < sortedFolders.length - 1 && (
<div style={{ display: 'flex', justifyContent: 'center' }}> <div style={{
<Separator className="h-px bg-[#434345] my-1" style={{ width: 213 }} /> display: 'flex',
justifyContent: 'center'
}}>
<Separator
className="h-px bg-[#434345] my-1"
style={{width: 213}}/>
</div> </div>
)} )}
</React.Fragment> </React.Fragment>
@@ -538,18 +438,17 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
</> </>
)} )}
{view === 'files' && activeServer && ( {view === 'files' && activeServer && (
<div className="flex flex-col h-full w-full" style={{ maxWidth: 260 }}> <div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
{/* Sticky path input bar - outside ScrollArea */} <div
<div className="flex items-center gap-2 px-2 py-2 border-b border-[#23232a] bg-[#18181b] z-20" style={{ maxWidth: 260 }}> className="flex items-center gap-2 px-2 py-2 border-b border-[#23232a] bg-[#18181b] z-20"
style={{maxWidth: 260}}>
<Button <Button
size="icon" size="icon"
variant="outline" 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" 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={() => { onClick={() => {
// If not at root, go up one directory; else, go back to servers view
let path = currentPath; let path = currentPath;
if (path && path !== '/' && path !== '') { if (path && path !== '/' && path !== '') {
// Remove trailing slash if present
if (path.endsWith('/')) path = path.slice(0, -1); if (path.endsWith('/')) path = path.slice(0, -1);
const lastSlash = path.lastIndexOf('/'); const lastSlash = path.lastIndexOf('/');
if (lastSlash > 0) { 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]" 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> </div>
{/* File search bar */}
<div className="px-2 py-2 border-b border-[#23232a] bg-[#18181b]"> <div className="px-2 py-2 border-b border-[#23232a] bg-[#18181b]">
<Input <Input
placeholder="Search files and folders..." placeholder="Search files and folders..."
@@ -582,16 +480,22 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
onChange={e => setFileSearch(e.target.value)} onChange={e => setFileSearch(e.target.value)}
/> />
</div> </div>
{/* File list with proper scroll area - separate from topbar */}
<div className="flex-1 w-full h-full bg-[#09090b] border-t border-[#23232a]"> <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"> <div className="p-2 pr-2">
{connectingSSH || filesLoading ? ( {connectingSSH || filesLoading ? (
<div className="text-xs text-muted-foreground">Loading...</div> <div className="text-xs text-muted-foreground">Loading...</div>
) : filesError ? ( ) : filesError ? (
<div className="text-xs text-red-500">{filesError}</div> <div className="text-xs text-red-500">{filesError}</div>
) : filteredFiles.length === 0 ? ( ) : 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"> <div className="flex flex-col gap-1">
{filteredFiles.map((item: any) => { {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", "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" isOpen && "opacity-60 cursor-not-allowed pointer-events-none"
)} )}
style={{ maxWidth: 220, marginBottom: 8 }} style={{maxWidth: 220, marginBottom: 8}}
> >
<div <div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0" className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => !isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile({ onClick={() => !isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile({
name: item.name, name: item.name,
path: item.path, path: item.path,
isSSH: item.isSSH, isSSH: item.isSSH,
sshSessionId: item.sshSessionId sshSessionId: item.sshSessionId
}))} }))}
> >
{item.type === 'directory' ? {item.type === 'directory' ?
<Folder className="w-4 h-4 text-blue-400"/> : <Folder
<File className="w-4 h-4 text-muted-foreground"/>} className="w-4 h-4 text-blue-400"/> :
<span className="text-sm text-white truncate max-w-[120px]">{item.name}</span> <File
className="w-4 h-4 text-muted-foreground"/>}
<span
className="text-sm text-white truncate max-w-[120px]">{item.name}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{item.type === 'file' && ( {item.type === 'file' && (
@@ -628,28 +535,32 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
e.stopPropagation(); e.stopPropagation();
try { try {
if (item.isPinned) { if (item.isPinned) {
await removeConfigEditorPinned({ await removeConfigEditorPinned({
name: item.name, name: item.name,
path: item.path, path: item.path,
hostId: activeServer?.id, hostId: activeServer?.id,
isSSH: true, isSSH: true,
sshSessionId: activeServer?.id.toString() sshSessionId: activeServer?.id.toString()
}); });
// Update local state without refreshing setFiles(files.map(f =>
setFiles(files.map(f => f.path === item.path ? {
f.path === item.path ? { ...f, isPinned: false } : f ...f,
isPinned: false
} : f
)); ));
} else { } else {
await addConfigEditorPinned({ await addConfigEditorPinned({
name: item.name, name: item.name,
path: item.path, path: item.path,
hostId: activeServer?.id, hostId: activeServer?.id,
isSSH: true, isSSH: true,
sshSessionId: activeServer?.id.toString() sshSessionId: activeServer?.id.toString()
}); });
// Update local state without refreshing setFiles(files.map(f =>
setFiles(files.map(f => f.path === item.path ? {
f.path === item.path ? { ...f, isPinned: true } : f ...f,
isPinned: true
} : f
)); ));
} }
} catch (err) { } 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> </Button>
)} )}
</div> </div>
@@ -679,4 +591,4 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
</SidebarProvider> </SidebarProvider>
); );
}); });
export { ConfigEditorSidebar }; export {ConfigEditorSidebar};

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button.tsx'; import {Button} from '@/components/ui/button.tsx';
import { Card } from '@/components/ui/card.tsx'; import {Card} from '@/components/ui/card.tsx';
import { Separator } from '@/components/ui/separator.tsx'; import {Separator} from '@/components/ui/separator.tsx';
import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react'; import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react';
interface SSHConnection { interface SSHConnection {
@@ -42,25 +42,25 @@ interface ConfigFileSidebarViewerProps {
} }
export function ConfigFileSidebarViewer({ export function ConfigFileSidebarViewer({
sshConnections, sshConnections,
onAddSSH, onAddSSH,
onConnectSSH, onConnectSSH,
onEditSSH, onEditSSH,
onDeleteSSH, onDeleteSSH,
onPinSSH, onPinSSH,
currentPath, currentPath,
files, files,
onOpenFile, onOpenFile,
onOpenFolder, onOpenFolder,
onStarFile, onStarFile,
onDeleteFile, onDeleteFile,
isLoading, isLoading,
error, error,
isSSHMode, isSSHMode,
onSwitchToLocal, onSwitchToLocal,
onSwitchToSSH, onSwitchToSSH,
currentSSH, currentSSH,
}: ConfigFileSidebarViewerProps) { }: ConfigFileSidebarViewerProps) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* SSH Connections */} {/* SSH Connections */}
@@ -68,7 +68,7 @@ export function ConfigFileSidebarViewer({
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground font-semibold">SSH Connections</span> <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"> <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> </Button>
</div> </div>
<div className="flex flex-col gap-1"> <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" className="w-full justify-start text-left px-2 py-1.5 rounded"
onClick={onSwitchToLocal} onClick={onSwitchToLocal}
> >
<Server className="w-4 h-4 mr-2" /> Local Files <Server className="w-4 h-4 mr-2"/> Local Files
</Button> </Button>
{sshConnections.map((conn) => ( {sshConnections.map((conn) => (
<div key={conn.id} className="flex items-center gap-1 group"> <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" className="flex-1 justify-start text-left px-2 py-1.5 rounded"
onClick={() => onSwitchToSSH(conn)} onClick={() => onSwitchToSSH(conn)}
> >
<Link2 className="w-4 h-4 mr-2" /> <Link2 className="w-4 h-4 mr-2"/>
{conn.name || conn.ip} {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>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}> <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>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}> <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>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteSSH(conn)}> <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> </Button>
</div> </div>
))} ))}
@@ -106,7 +107,8 @@ export function ConfigFileSidebarViewer({
{/* File/Folder Viewer */} {/* File/Folder Viewer */}
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto"> <div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
<div className="mb-2 flex items-center gap-2"> <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> <span className="text-xs text-white truncate">{currentPath}</span>
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -116,22 +118,29 @@ export function ConfigFileSidebarViewer({
) : ( ) : (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{files.map((item) => ( {files.map((item) => (
<Card key={item.path} className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border border-[#23232a] rounded"> <Card key={item.path}
<div className="flex items-center gap-2 flex-1 cursor-pointer" onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}> className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border border-[#23232a] rounded">
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400" /> : <File className="w-4 h-4 text-muted-foreground" />} <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> <span className="text-sm text-white truncate">{item.name}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onStarFile(item)}> <Button size="icon" variant="ghost" className="h-7 w-7"
<Pin className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`} /> onClick={() => onStarFile(item)}>
<Pin
className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
</Button> </Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteFile(item)}> <Button size="icon" variant="ghost" className="h-7 w-7"
<Trash2 className="w-4 h-4 text-red-500" /> onClick={() => onDeleteFile(item)}>
<Trash2 className="w-4 h-4 text-red-500"/>
</Button> </Button>
</div> </div>
</Card> </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>
)} )}
</div> </div>

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import React, { useState, useEffect, useMemo } from "react"; import React, {useState, useEffect, useMemo} from "react";
import { Card, CardContent } from "@/components/ui/card"; import {Card, CardContent} from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area"; import {ScrollArea} from "@/components/ui/scroll-area";
import { Input } from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
import { getSSHHosts, deleteSSHHost } from "@/apps/SSH/ssh-axios-fixed"; import {getSSHHosts, deleteSSHHost} from "@/apps/SSH/ssh-axios";
import { Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search } from "lucide-react"; import {Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search} from "lucide-react";
interface SSHHost { interface SSHHost {
id: number; id: number;
@@ -31,7 +31,7 @@ interface SSHManagerHostViewerProps {
onEditHost?: (host: SSHHost) => void; onEditHost?: (host: SSHHost) => void;
} }
export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps) { export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
const [hosts, setHosts] = useState<SSHHost[]>([]); const [hosts, setHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -48,7 +48,6 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
setHosts(data); setHosts(data);
setError(null); setError(null);
} catch (err) { } catch (err) {
console.error('Failed to fetch hosts:', err);
setError('Failed to load hosts'); setError('Failed to load hosts');
} finally { } finally {
setLoading(false); setLoading(false);
@@ -59,9 +58,8 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) { if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) {
try { try {
await deleteSSHHost(hostId); await deleteSSHHost(hostId);
await fetchHosts(); // Refresh the list await fetchHosts();
} catch (err) { } catch (err) {
console.error('Failed to delete host:', err);
alert('Failed to delete host'); alert('Failed to delete host');
} }
} }
@@ -73,11 +71,9 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
} }
}; };
// Filter and sort hosts
const filteredAndSortedHosts = useMemo(() => { const filteredAndSortedHosts = useMemo(() => {
let filtered = hosts; let filtered = hosts;
// Apply search filter
if (searchQuery.trim()) { if (searchQuery.trim()) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
filtered = hosts.filter(host => { 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) => { 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;
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 aName = a.name || a.username;
const bName = b.name || b.username; const bName = b.name || b.username;
return aName.localeCompare(bName); return aName.localeCompare(bName);
}); });
}, [hosts, searchQuery]); }, [hosts, searchQuery]);
// Group hosts by folder
const hostsByFolder = useMemo(() => { const hostsByFolder = useMemo(() => {
const grouped: { [key: string]: SSHHost[] } = {}; const grouped: { [key: string]: SSHHost[] } = {};
filteredAndSortedHosts.forEach(host => { filteredAndSortedHosts.forEach(host => {
const folder = host.folder || 'Uncategorized'; const folder = host.folder || 'Uncategorized';
if (!grouped[folder]) { if (!grouped[folder]) {
@@ -118,20 +110,18 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
} }
grouped[folder].push(host); grouped[folder].push(host);
}); });
// Sort folders to ensure "Uncategorized" is always first
const sortedFolders = Object.keys(grouped).sort((a, b) => { const sortedFolders = Object.keys(grouped).sort((a, b) => {
if (a === 'Uncategorized') return -1; if (a === 'Uncategorized') return -1;
if (b === 'Uncategorized') return 1; if (b === 'Uncategorized') return 1;
return a.localeCompare(b); return a.localeCompare(b);
}); });
// Create a new object with sorted folders
const sortedGrouped: { [key: string]: SSHHost[] } = {}; const sortedGrouped: { [key: string]: SSHHost[] } = {};
sortedFolders.forEach(folder => { sortedFolders.forEach(folder => {
sortedGrouped[folder] = grouped[folder]; sortedGrouped[folder] = grouped[folder];
}); });
return sortedGrouped; return sortedGrouped;
}, [filteredAndSortedHosts]); }, [filteredAndSortedHosts]);
@@ -163,7 +153,7 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="text-center"> <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> <h3 className="text-lg font-semibold mb-2">No SSH Hosts</h3>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
You haven't added any SSH hosts yet. Click "Add Host" to get started. You haven't added any SSH hosts yet. Click "Add Host" to get started.
@@ -187,9 +177,8 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
</Button> </Button>
</div> </div>
{/* Search Bar */}
<div className="relative mb-3"> <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 <Input
placeholder="Search hosts by name, username, IP, folder, tags..." placeholder="Search hosts by name, username, IP, folder, tags..."
value={searchQuery} value={searchQuery}
@@ -197,16 +186,17 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
className="pl-10" className="pl-10"
/> />
</div> </div>
<ScrollArea className="flex-1 min-h-0"> <ScrollArea className="flex-1 min-h-0">
<div className="space-y-2 pb-20"> <div className="space-y-2 pb-20">
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => ( {Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
<div key={folder} className="border rounded-md"> <div key={folder} className="border rounded-md">
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}> <Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}>
<AccordionItem value={folder} className="border-none"> <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"> <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> <span className="font-medium">{folder}</span>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{folderHosts.length} {folderHosts.length}
@@ -216,15 +206,16 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
<AccordionContent className="p-2"> <AccordionContent className="p-2">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{folderHosts.map((host) => ( {folderHosts.map((host) => (
<div <div
key={host.id} key={host.id}
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2" className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
onClick={() => handleEdit(host)} onClick={() => handleEdit(host)}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1"> <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"> <h3 className="font-medium truncate text-sm">
{host.name || `${host.username}@${host.ip}`} {host.name || `${host.username}@${host.ip}`}
</h3> </h3>
@@ -246,7 +237,7 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
}} }}
className="h-5 w-5 p-0" className="h-5 w-5 p-0"
> >
<Edit className="h-3 w-3" /> <Edit className="h-3 w-3"/>
</Button> </Button>
<Button <Button
size="sm" 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" 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> </Button>
</div> </div>
</div> </div>
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{/* Tags */}
{host.tags && host.tags.length > 0 && ( {host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{host.tags.slice(0, 6).map((tag, index) => ( {host.tags.slice(0, 6).map((tag, index) => (
<Badge key={index} variant="secondary" className="text-xs px-1 py-0"> <Badge key={index} variant="secondary"
<Tag className="h-2 w-2 mr-0.5" /> className="text-xs px-1 py-0">
<Tag className="h-2 w-2 mr-0.5"/>
{tag} {tag}
</Badge> </Badge>
))} ))}
{host.tags.length > 6 && ( {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} +{host.tags.length - 6}
</Badge> </Badge>
)} )}
</div> </div>
)} )}
{/* Features */}
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{host.enableTerminal && ( {host.enableTerminal && (
<Badge variant="outline" className="text-xs px-1 py-0"> <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 Terminal
</Badge> </Badge>
)} )}
{host.enableTunnel && ( {host.enableTunnel && (
<Badge variant="outline" className="text-xs px-1 py-0"> <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 Tunnel
{host.tunnelConnections && host.tunnelConnections.length > 0 && ( {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> </Badge>
)} )}
{host.enableConfigEditor && ( {host.enableConfigEditor && (
<Badge variant="outline" className="text-xs px-1 py-0"> <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 Config
</Badge> </Badge>
)} )}

View File

@@ -26,7 +26,7 @@ interface SidebarProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
} }
export function SSHManagerSidebar({ onSelectView }: SidebarProps): React.ReactElement { export function SSHManagerSidebar({onSelectView}: SidebarProps): React.ReactElement {
return ( return (
<SidebarProvider> <SidebarProvider>
<Sidebar> <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"> <SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / SSH Manager Termix / SSH Manager
</SidebarGroupLabel> </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"> <SidebarGroupContent className="flex flex-col flex-grow">
<SidebarMenu> <SidebarMenu>
{/* Sidebar Items */} {/* Sidebar Items */}
<SidebarMenuItem key={"Homepage"}> <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/> <CornerDownLeft/>
Return Return
</Button> </Button>
<Separator className="p-0.25 mt-1 mb-1" /> <Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>

View File

@@ -1,8 +1,8 @@
import React, { useState, useRef, useEffect } from "react"; import React, {useState, useRef, useEffect} from "react";
import { SSHSidebar } from "@/apps/SSH/Terminal/SSHSidebar.tsx"; import {SSHSidebar} from "@/apps/SSH/Terminal/SSHSidebar.tsx";
import { SSHTerminal } from "./SSHTerminal.tsx"; import {SSHTerminal} from "./SSHTerminal.tsx";
import { SSHTopbar } from "@/apps/SSH/Terminal/SSHTopbar.tsx"; import {SSHTopbar} from "@/apps/SSH/Terminal/SSHTopbar.tsx";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable.tsx'; import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
import * as ResizablePrimitive from "react-resizable-panels"; import * as ResizablePrimitive from "react-resizable-panels";
interface ConfigEditorProps { interface ConfigEditorProps {
@@ -16,7 +16,7 @@ type Tab = {
terminalRef: React.RefObject<any>; terminalRef: React.RefObject<any>;
}; };
export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement { export function SSH({onSelectView}: ConfigEditorProps): React.ReactElement {
const [allTabs, setAllTabs] = useState<Tab[]>([]); const [allTabs, setAllTabs] = useState<Tab[]>([]);
const [currentTab, setCurrentTab] = useState<number | null>(null); const [currentTab, setCurrentTab] = useState<number | null>(null);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]); const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
@@ -72,7 +72,7 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
const updatePanelRects = () => { const updatePanelRects = () => {
setPanelRects((prev) => { setPanelRects((prev) => {
const next: Record<string, DOMRect | null> = { ...prev }; const next: Record<string, DOMRect | null> = {...prev};
Object.entries(panelRefs.current).forEach(([id, ref]) => { Object.entries(panelRefs.current).forEach(([id, ref]) => {
if (ref) { if (ref) {
next[id] = ref.getBoundingClientRect(); next[id] = ref.getBoundingClientRect();
@@ -137,11 +137,21 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
}); });
} }
return ( 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) => { {allTabs.map((tab) => {
const style = layoutStyles[tab.id] const style = layoutStyles[tab.id]
? { ...layoutStyles[tab.id], overflow: 'hidden' } ? {...layoutStyles[tab.id], overflow: 'hidden'}
: { display: 'none', overflow: 'hidden' }; : {display: 'none', overflow: 'hidden'};
const isVisible = !!layoutStyles[tab.id]; const isVisible = !!layoutStyles[tab.id];
return ( return (
<div key={tab.id} style={style} data-terminal-id={tab.id}> <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) { if (layoutTabs.length === 2) {
const [tab1, tab2] = layoutTabs; const [tab1, tab2] = layoutTabs;
return ( 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 <ResizablePrimitive.PanelGroup
ref={el => { panelGroupRefs.current['main'] = el; }} ref={el => {
panelGroupRefs.current['main'] = el;
}}
direction="horizontal" direction="horizontal"
className="h-full w-full" className="h-full w-full"
id="main-horizontal" id="main-horizontal"
> >
<ResizablePanel key={tab1.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${tab1.id}`} order={1}> <ResizablePanel key={tab1.id} defaultSize={50} minSize={20}
<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'}}> 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={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -194,9 +226,21 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
}}>{tab1.title}</div> }}>{tab1.title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} /> <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}> <ResizablePanel key={tab2.id} defaultSize={50} minSize={20}
<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'}}> 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={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -218,17 +262,43 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
} }
if (layoutTabs.length === 3) { if (layoutTabs.length === 3) {
return ( 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 <ResizablePrimitive.PanelGroup
ref={el => { panelGroupRefs.current['main'] = el; }} ref={el => {
panelGroupRefs.current['main'] = el;
}}
direction="vertical" direction="vertical"
className="h-full w-full" className="h-full w-full"
id="main-vertical" id="main-vertical"
> >
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<ResizablePanelGroup ref={el => { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal"> id="top-panel" order={1}>
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[0].id}`} order={1}> <ResizablePanelGroup ref={el => {
<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'}}> 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={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -244,9 +314,22 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
}}>{layoutTabs[0].title}</div> }}>{layoutTabs[0].title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} /> <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}> <ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
<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'}}> 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={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -264,9 +347,21 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} /> <ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<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'}}> 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={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -288,17 +383,43 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
} }
if (layoutTabs.length === 4) { if (layoutTabs.length === 4) {
return ( 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 <ResizablePrimitive.PanelGroup
ref={el => { panelGroupRefs.current['main'] = el; }} ref={el => {
panelGroupRefs.current['main'] = el;
}}
direction="vertical" direction="vertical"
className="h-full w-full" className="h-full w-full"
id="main-vertical" id="main-vertical"
> >
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<ResizablePanelGroup ref={el => { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal"> id="top-panel" order={1}>
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[0].id}`} order={1}> <ResizablePanelGroup ref={el => {
<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'}}> 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={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -314,9 +435,22 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
}}>{layoutTabs[0].title}</div> }}>{layoutTabs[0].title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} /> <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}> <ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
<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'}}> 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={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -334,11 +468,27 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} /> <ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<ResizablePanelGroup ref={el => { panelGroupRefs.current['bottom'] = el; }} direction="horizontal" className="h-full w-full" id="bottom-horizontal"> id="bottom-panel" order={2}>
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[2].id}`} order={1}> <ResizablePanelGroup ref={el => {
<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'}}> 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={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -354,9 +504,22 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
}}>{layoutTabs[2].title}</div> }}>{layoutTabs[2].title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} /> <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}> <ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20}
<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'}}> 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={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -424,24 +587,31 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
}; };
return ( return (
<div style={{ display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden' }}> <div style={{display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden'}}>
{/* Sidebar: fixed width */} <div style={{
<div style={{ width: 256, flexShrink: 0, height: '100vh', position: 'relative', zIndex: 2, margin: 0, padding: 0, border: 'none' }}> width: 256,
<SSHSidebar flexShrink: 0,
onSelectView={onSelectView} height: '100vh',
onAddHostSubmit={onAddHostSubmit} position: 'relative',
onHostConnect={onHostConnect} zIndex: 2,
allTabs={allTabs} margin: 0,
runCommandOnTabs={(tabIds: number[], command: string) => { padding: 0,
allTabs.forEach(tab => { border: 'none'
if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) { }}>
tab.terminalRef.current.sendInput(command); <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> </div>
{/* Main area: fills the rest */}
<div <div
className="terminal-container" className="terminal-container"
style={{ style={{
@@ -454,20 +624,17 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
border: 'none', border: 'none',
}} }}
> >
{/* Always render the topbar at the top */} <div style={{position: 'absolute', top: 0, left: 0, width: '100%', zIndex: 10}}>
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', zIndex: 10 }}> <SSHTopbar
<SSHTopbar allTabs={allTabs}
allTabs={allTabs}
currentTab={currentTab ?? -1} currentTab={currentTab ?? -1}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
allSplitScreenTab={allSplitScreenTab} allSplitScreenTab={allSplitScreenTab}
setSplitScreenTab={setSplitScreenTab} setSplitScreenTab={setSplitScreenTab}
setCloseTab={setCloseTab} setCloseTab={setCloseTab}
/> />
</div> </div>
{/* Split area below the topbar */} <div style={{height: 'calc(100% - 46px)', marginTop: 46, position: 'relative'}}>
<div style={{ height: 'calc(100% - 46px)', marginTop: 46, position: 'relative' }}>
{/* Show alert when no terminals are rendered */}
{allTabs.length === 0 && ( {allTabs.length === 0 && (
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
@@ -483,17 +650,17 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
maxWidth: '400px', maxWidth: '400px',
zIndex: 30 zIndex: 30
}}> }}>
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '12px' }}> <div style={{fontSize: '18px', fontWeight: 'bold', marginBottom: '12px'}}>
Welcome to Termix SSH Welcome to Termix SSH
</div> </div>
<div style={{ fontSize: '14px', color: '#a1a1aa', lineHeight: '1.5' }}> <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. 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>
</div> </div>
)} )}
{/* Absolutely render all terminals for persistence and layout */}
{allSplitScreenTab.length > 0 && ( {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 <button
style={{ style={{
background: '#18181b', background: '#18181b',
@@ -514,12 +681,10 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
onClick={() => { onClick={() => {
if (allSplitScreenTab.length === 1) { if (allSplitScreenTab.length === 1) {
panelGroupRefs.current['main']?.setLayout([50, 50]); panelGroupRefs.current['main']?.setLayout([50, 50]);
} } else if (allSplitScreenTab.length === 2) {
else if (allSplitScreenTab.length === 2) {
panelGroupRefs.current['main']?.setLayout([50, 50]); panelGroupRefs.current['main']?.setLayout([50, 50]);
panelGroupRefs.current['top']?.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['main']?.setLayout([50, 50]);
panelGroupRefs.current['top']?.setLayout([50, 50]); panelGroupRefs.current['top']?.setLayout([50, 50]);
panelGroupRefs.current['bottom']?.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 { import {
CornerDownLeft, CornerDownLeft,
@@ -35,9 +35,9 @@ import {
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion.tsx"; } from "@/components/ui/accordion.tsx";
import { ScrollArea } from "@/components/ui/scroll-area.tsx"; import {ScrollArea} from "@/components/ui/scroll-area.tsx";
import { Input } from "@/components/ui/input.tsx"; import {Input} from "@/components/ui/input.tsx";
import { getSSHHosts } from "@/apps/SSH/ssh-axios-fixed"; import {getSSHHosts} from "@/apps/SSH/ssh-axios";
interface SSHHost { interface SSHHost {
id: number; id: number;
@@ -69,7 +69,7 @@ interface SidebarProps {
runCommandOnTabs: (tabIds: number[], command: string) => void; 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 [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(false); const [hostsLoading, setHostsLoading] = useState(false);
const [hostsError, setHostsError] = useState<string | null>(null); const [hostsError, setHostsError] = useState<string | null>(null);
@@ -80,7 +80,6 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
setHostsError(null); setHostsError(null);
try { try {
const newHosts = await getSSHHosts(); const newHosts = await getSSHHosts();
// Filter hosts to only show those with enableTerminal: true
const terminalHosts = newHosts.filter(host => host.enableTerminal); const terminalHosts = newHosts.filter(host => host.enableTerminal);
const prevHosts = prevHostsRef.current; const prevHosts = prevHostsRef.current;
@@ -170,7 +169,6 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
return [...pinned, ...rest]; return [...pinned, ...rest];
}; };
// Tools Sheet State
const [toolsSheetOpen, setToolsSheetOpen] = useState(false); const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [toolsCommand, setToolsCommand] = useState(""); const [toolsCommand, setToolsCommand] = useState("");
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]); const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
@@ -181,11 +179,10 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
const handleRunCommand = () => { const handleRunCommand = () => {
if (selectedTabIds.length && toolsCommand.trim()) { if (selectedTabIds.length && toolsCommand.trim()) {
// Ensure command ends with newline
let cmd = toolsCommand; let cmd = toolsCommand;
if (!cmd.endsWith("\n")) cmd += "\n"; if (!cmd.endsWith("\n")) cmd += "\n";
runCommandOnTabs(selectedTabIds, cmd); 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"> <SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / Terminal Termix / Terminal
</SidebarGroupLabel> </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"> <SidebarGroupContent className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarMenu 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")} onClick={() => onSelectView("homepage")}
variant="outline" variant="outline"
> >
<CornerDownLeft /> <CornerDownLeft/>
Return Return
</Button> </Button>
<Separator className="p-0.25 mt-1 mb-1" /> <Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem key="Main" className="flex flex-col flex-grow overflow-hidden"> <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"> <div
{/* Search bar */} 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"> <div className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10">
<Input <Input
value={search} value={search}
@@ -225,30 +222,41 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
autoComplete="off" autoComplete="off"
/> />
</div> </div>
<div style={{ display: 'flex', justifyContent: 'center' }}> <div style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="w-full h-px bg-[#434345] my-2" style={{ maxWidth: 213, margin: '0 auto' }} /> <Separator className="w-full h-px bg-[#434345] my-2"
style={{maxWidth: 213, margin: '0 auto'}}/>
</div> </div>
{/* Error and status messages */}
{hostsError && ( {hostsError && (
<div className="px-2 py-1 mt-2"> <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> </div>
)} )}
{!hostsLoading && !hostsError && hosts.length === 0 && ( {!hostsLoading && !hostsError && hosts.length === 0 && (
<div className="px-2 py-1 mt-2"> <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>
)} )}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
<ScrollArea className="w-full h-full"> <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) => ( {sortedFolders.map((folder, idx) => (
<React.Fragment key={folder}> <React.Fragment key={folder}>
<AccordionItem value={folder} className={idx === 0 ? "mt-0 !border-b-transparent" : "mt-2 !border-b-transparent"}> <AccordionItem value={folder}
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2" style={{marginTop: idx === 0 ? 0 : undefined}}>{folder}</AccordionTrigger> className={idx === 0 ? "mt-0 !border-b-transparent" : "mt-2 !border-b-transparent"}>
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1"> <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 => ( {getSortedHosts(hostsByFolder[folder]).map(host => (
<div key={host.id} className="w-full overflow-hidden"> <div key={host.id}
className="w-full overflow-hidden">
<HostMenuItem <HostMenuItem
host={host} host={host}
onHostConnect={onHostConnect} onHostConnect={onHostConnect}
@@ -258,8 +266,10 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
{idx < sortedFolders.length - 1 && ( {idx < sortedFolders.length - 1 && (
<div style={{ display: 'flex', justifyContent: 'center' }}> <div
<Separator className="h-px bg-[#434345] my-1" style={{ width: 213 }} /> style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="h-px bg-[#434345] my-1"
style={{width: 213}}/>
</div> </div>
)} )}
</React.Fragment> </React.Fragment>
@@ -271,7 +281,6 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
{/* Tools Button at the very bottom */}
<div className="bg-sidebar"> <div className="bg-sidebar">
<Sheet open={toolsSheetOpen} onOpenChange={setToolsSheetOpen}> <Sheet open={toolsSheetOpen} onOpenChange={setToolsSheetOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
@@ -280,27 +289,32 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
variant="outline" variant="outline"
onClick={() => setToolsSheetOpen(true)} onClick={() => setToolsSheetOpen(true)}
> >
<Hammer className="mr-2 h-4 w-4" /> <Hammer className="mr-2 h-4 w-4"/>
Tools Tools
</Button> </Button>
</SheetTrigger> </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"> <SheetHeader className="pb-0.5">
<SheetTitle>Tools</SheetTitle> <SheetTitle>Tools</SheetTitle>
</SheetHeader> </SheetHeader>
<div className="flex-1 overflow-y-auto px-2 pt-2"> <div className="flex-1 overflow-y-auto px-2 pt-2">
<Accordion type="single" collapsible defaultValue="multiwindow"> <Accordion type="single" collapsible defaultValue="multiwindow">
<AccordionItem value="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> <AccordionContent>
<textarea <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" 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..." placeholder="Enter command(s) to run on selected tabs..."
value={toolsCommand} value={toolsCommand}
onChange={e => setToolsCommand(e.target.value)} 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"> <div className="flex flex-wrap gap-2 mb-2">
{allTabs.map(tab => ( {allTabs.map(tab => (
<Button <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 tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0; const hasTags = tags.length > 0;
return ( return (
<div className="relative group flex flex-col mb-1 w-full overflow-hidden"> <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 flex-col w-full rounded overflow-hidden border border-[#434345] bg-[#18181b] h-full`}>
<div className="flex w-full h-10"> <div className="flex w-full h-10">
{/* Full width clickable area */} <div
<div className="flex items-center h-full px-2 w-full hover:bg-muted transition-colors cursor-pointer" className="flex items-center h-full px-2 w-full hover:bg-muted transition-colors cursor-pointer"
onClick={() => onHostConnect(host)} onClick={() => onHostConnect(host)}
> >
<div className="flex items-center w-full"> <div className="flex items-center w-full">
{host.pin && {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> <span className="font-medium truncate">{host.name || host.ip}</span>
</div> </div>
</div> </div>
</div> </div>
{hasTags && ( {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) => ( {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} {tag}
</span> </span>
))} ))}

View File

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

View File

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

View File

@@ -15,7 +15,14 @@ interface SSHTopbarProps {
setCloseTab: (tab: number) => void; 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 ( return (
<div className="flex h-11.5 z-100" style={{ <div className="flex h-11.5 z-100" style={{
position: 'absolute', position: 'absolute',

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import React from "react"; import React from "react";
import { SSHTunnelObject } from "./SSHTunnelObject.tsx"; import {SSHTunnelObject} from "./SSHTunnelObject.tsx";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion.tsx"; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
import { Separator } from "@/components/ui/separator.tsx"; import {Separator} from "@/components/ui/separator.tsx";
import { Input } from "@/components/ui/input.tsx"; import {Input} from "@/components/ui/input.tsx";
import { Search } from "lucide-react"; import {Search} from "lucide-react";
interface TunnelConnection { interface TunnelConnection {
sourcePort: number; sourcePort: number;
@@ -50,25 +50,23 @@ interface SSHTunnelViewerProps {
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>; onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
} }
export function SSHTunnelViewer({ export function SSHTunnelViewer({
hosts = [], hosts = [],
tunnelStatuses = {}, tunnelStatuses = {},
tunnelActions = {}, tunnelActions = {},
onTunnelAction onTunnelAction
}: SSHTunnelViewerProps): React.ReactElement { }: SSHTunnelViewerProps): React.ReactElement {
const [searchQuery, setSearchQuery] = React.useState(""); const [searchQuery, setSearchQuery] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState(""); const [debouncedSearch, setDebouncedSearch] = React.useState("");
// Debounce search
React.useEffect(() => { React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200); const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200);
return () => clearTimeout(handler); return () => clearTimeout(handler);
}, [searchQuery]); }, [searchQuery]);
// Filter hosts by search query
const filteredHosts = React.useMemo(() => { const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts; if (!debouncedSearch.trim()) return hosts;
const query = debouncedSearch.trim().toLowerCase(); const query = debouncedSearch.trim().toLowerCase();
return hosts.filter(host => { return hosts.filter(host => {
const searchableText = [ const searchableText = [
@@ -84,16 +82,14 @@ export function SSHTunnelViewer({
}); });
}, [hosts, debouncedSearch]); }, [hosts, debouncedSearch]);
// Filter hosts to only show those with enableTunnel: true and tunnelConnections
const tunnelHosts = React.useMemo(() => { const tunnelHosts = React.useMemo(() => {
return filteredHosts.filter(host => return filteredHosts.filter(host =>
host.enableTunnel && host.enableTunnel &&
host.tunnelConnections && host.tunnelConnections &&
host.tunnelConnections.length > 0 host.tunnelConnections.length > 0
); );
}, [filteredHosts]); }, [filteredHosts]);
// Group hosts by folder and sort
const hostsByFolder = React.useMemo(() => { const hostsByFolder = React.useMemo(() => {
const map: Record<string, SSHHost[]> = {}; const map: Record<string, SSHHost[]> = {};
tunnelHosts.forEach(host => { tunnelHosts.forEach(host => {
@@ -121,9 +117,8 @@ export function SSHTunnelViewer({
}; };
return ( return (
<div className="w-full p-6" style={{ width: 'calc(100vw - 256px)', maxWidth: 'none' }}> <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="w-full min-w-0" style={{width: '100%', maxWidth: 'none'}}>
{/* Header */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-foreground mb-2"> <h1 className="text-2xl font-bold text-foreground mb-2">
SSH Tunnels SSH Tunnels
@@ -133,9 +128,9 @@ export function SSHTunnelViewer({
</p> </p>
</div> </div>
{/* Search Bar */}
<div className="relative mb-3"> <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 <Input
placeholder="Search hosts by name, username, IP, folder, tags..." placeholder="Search hosts by name, username, IP, folder, tags..."
value={searchQuery} value={searchQuery}
@@ -144,14 +139,13 @@ export function SSHTunnelViewer({
/> />
</div> </div>
{/* Accordion Layout */}
{tunnelHosts.length === 0 ? ( {tunnelHosts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<h3 className="text-lg font-semibold text-foreground mb-2"> <h3 className="text-lg font-semibold text-foreground mb-2">
No SSH Tunnels No SSH Tunnels
</h3> </h3>
<p className="text-muted-foreground max-w-md"> <p className="text-muted-foreground max-w-md">
{searchQuery.trim() ? {searchQuery.trim() ?
"No hosts match your search criteria." : "No hosts match your search criteria." :
"Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections." "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}> <Accordion type="multiple" className="w-full" defaultValue={sortedFolders}>
{sortedFolders.map((folder, idx) => ( {sortedFolders.map((folder, idx) => (
<AccordionItem value={folder} key={`folder-${folder}`} className={idx === 0 ? "mt-0" : "mt-2"}> <AccordionItem value={folder} key={`folder-${folder}`}
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2" style={{marginTop: idx === 0 ? 0 : undefined}}> 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} {folder}
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1"> <AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
@@ -185,4 +181,4 @@ export function SSHTunnelViewer({
</div> </div>
</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'; import axios from 'axios';
interface SSHHostData { interface SSHHostData {
@@ -81,11 +80,9 @@ interface TunnelStatus {
retryExhausted?: boolean; retryExhausted?: boolean;
} }
// Determine the base URL based on environment
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const baseURL = isLocalhost ? 'http://localhost:8081' : window.location.origin; const baseURL = isLocalhost ? 'http://localhost:8081' : window.location.origin;
// Create axios instance with base configuration for database operations (port 8081)
const api = axios.create({ const api = axios.create({
baseURL, baseURL,
headers: { headers: {
@@ -93,7 +90,6 @@ const api = axios.create({
}, },
}); });
// Create config editor API instance for file operations (port 8084)
const configEditorApi = axios.create({ const configEditorApi = axios.create({
baseURL: isLocalhost ? 'http://localhost:8084' : `${window.location.origin}/ssh`, baseURL: isLocalhost ? 'http://localhost:8084' : `${window.location.origin}/ssh`,
headers: { headers: {
@@ -101,7 +97,6 @@ const configEditorApi = axios.create({
}, },
}); });
// Create tunnel API instance
const tunnelApi = axios.create({ const tunnelApi = axios.create({
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -114,7 +109,6 @@ function getCookie(name: string): string | undefined {
if (parts.length === 2) return parts.pop()?.split(';').shift(); 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) => { api.interceptors.request.use((config) => {
const token = getCookie('jwt'); const token = getCookie('jwt');
if (token) { if (token) {
@@ -139,21 +133,17 @@ tunnelApi.interceptors.request.use((config) => {
return config; return config;
}); });
// Get all SSH hosts - FIXED: Changed from /ssh/host to /ssh/db/host
export async function getSSHHosts(): Promise<SSHHost[]> { export async function getSSHHosts(): Promise<SSHHost[]> {
try { try {
const response = await api.get('/ssh/db/host'); const response = await api.get('/ssh/db/host');
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error fetching SSH hosts:', error);
throw error; throw error;
} }
} }
// Create new SSH host
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> { export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
try { try {
// Prepare the data according to your backend schema
const submitData = { const submitData = {
name: hostData.name || '', name: hostData.name || '',
ip: hostData.ip, ip: hostData.ip,
@@ -186,7 +176,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
const formData = new FormData(); const formData = new FormData();
formData.append('key', hostData.key); formData.append('key', hostData.key);
const dataWithoutFile = { ...submitData }; const dataWithoutFile = {...submitData};
delete dataWithoutFile.key; delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile)); formData.append('data', JSON.stringify(dataWithoutFile));
@@ -202,12 +192,10 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
return response.data; return response.data;
} }
} catch (error) { } catch (error) {
console.error('Error creating SSH host:', error);
throw error; throw error;
} }
} }
// Update existing SSH host
export async function updateSSHHost(hostId: number, hostData: SSHHostData): Promise<SSHHost> { export async function updateSSHHost(hostId: number, hostData: SSHHostData): Promise<SSHHost> {
try { try {
const submitData = { const submitData = {
@@ -241,7 +229,7 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
const formData = new FormData(); const formData = new FormData();
formData.append('key', hostData.key); formData.append('key', hostData.key);
const dataWithoutFile = { ...submitData }; const dataWithoutFile = {...submitData};
delete dataWithoutFile.key; delete dataWithoutFile.key;
formData.append('data', JSON.stringify(dataWithoutFile)); formData.append('data', JSON.stringify(dataWithoutFile));
@@ -257,41 +245,34 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
return response.data; return response.data;
} }
} catch (error) { } catch (error) {
console.error('Error updating SSH host:', error);
throw error; throw error;
} }
} }
// Delete SSH host
export async function deleteSSHHost(hostId: number): Promise<any> { export async function deleteSSHHost(hostId: number): Promise<any> {
try { try {
const response = await api.delete(`/ssh/db/host/${hostId}`); const response = await api.delete(`/ssh/db/host/${hostId}`);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error deleting SSH host:', error);
throw error; throw error;
} }
} }
// Get SSH host by ID
export async function getSSHHostById(hostId: number): Promise<SSHHost> { export async function getSSHHostById(hostId: number): Promise<SSHHost> {
try { try {
const response = await api.get(`/ssh/db/host/${hostId}`); const response = await api.get(`/ssh/db/host/${hostId}`);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error fetching SSH host:', error);
throw error; throw error;
} }
} }
// Tunnel-related functions
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> { export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
try { 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); const response = await tunnelApi.get(tunnelUrl);
return response.data || {}; return response.data || {};
} catch (error) { } catch (error) {
console.error('Error fetching tunnel statuses:', error);
throw error; throw error;
} }
} }
@@ -303,40 +284,36 @@ export async function getTunnelStatusByName(tunnelName: string): Promise<TunnelS
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> { export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
try { 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); const response = await tunnelApi.post(tunnelUrl, tunnelConfig);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error connecting tunnel:', error);
throw error; throw error;
} }
} }
export async function disconnectTunnel(tunnelName: string): Promise<any> { export async function disconnectTunnel(tunnelName: string): Promise<any> {
try { try {
const tunnelUrl = isLocalhost ? 'http://localhost:8083/disconnect' : `${baseURL}/ssh_tunnel/disconnect`; const tunnelUrl = isLocalhost ? 'http://localhost:8083/ssh/tunnel/disconnect' : `${baseURL}/ssh_tunnel/disconnect`;
const response = await tunnelApi.post(tunnelUrl, { tunnelName }); const response = await tunnelApi.post(tunnelUrl, {tunnelName});
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error disconnecting tunnel:', error);
throw error; throw error;
} }
} }
export async function cancelTunnel(tunnelName: string): Promise<any> { export async function cancelTunnel(tunnelName: string): Promise<any> {
try { try {
const tunnelUrl = isLocalhost ? 'http://localhost:8083/cancel' : `${baseURL}/ssh_tunnel/cancel`; const tunnelUrl = isLocalhost ? 'http://localhost:8083/ssh/tunnel/cancel' : `${baseURL}/ssh_tunnel/cancel`;
const response = await tunnelApi.post(tunnelUrl, { tunnelName }); const response = await tunnelApi.post(tunnelUrl, {tunnelName});
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error canceling tunnel:', error);
throw error; throw error;
} }
} }
export { api, configEditorApi }; export {api, configEditorApi};
// Config Editor API functions
interface ConfigEditorFile { interface ConfigEditorFile {
name: string; name: string;
path: string; path: string;
@@ -350,32 +327,42 @@ interface ConfigEditorShortcut {
path: string; path: string;
} }
// Config Editor database functions (use port 8081 for database operations)
export async function getConfigEditorRecent(hostId: number): Promise<ConfigEditorFile[]> { export async function getConfigEditorRecent(hostId: number): Promise<ConfigEditorFile[]> {
try { try {
const response = await api.get(`/ssh/config_editor/recent?hostId=${hostId}`); const response = await api.get(`/ssh/config_editor/recent?hostId=${hostId}`);
return response.data || []; return response.data || [];
} catch (error) { } catch (error) {
console.error('Error fetching recent files:', error);
return []; 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 { try {
const response = await api.post('/ssh/config_editor/recent', file); const response = await api.post('/ssh/config_editor/recent', file);
return response.data; return response.data;
} catch (error) { } 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 { 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; return response.data;
} catch (error) { } 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}`); const response = await api.get(`/ssh/config_editor/pinned?hostId=${hostId}`);
return response.data || []; return response.data || [];
} catch (error) { } catch (error) {
console.error('Error fetching pinned files:', error);
return []; 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 { try {
const response = await api.post('/ssh/config_editor/pinned', file); const response = await api.post('/ssh/config_editor/pinned', file);
return response.data; return response.data;
} catch (error) { } 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 { 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; return response.data;
} catch (error) { } 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}`); const response = await api.get(`/ssh/config_editor/shortcuts?hostId=${hostId}`);
return response.data || []; return response.data || [];
} catch (error) { } catch (error) {
console.error('Error fetching shortcuts:', error);
return []; 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 { try {
const response = await api.post('/ssh/config_editor/shortcuts', shortcut); const response = await api.post('/ssh/config_editor/shortcuts', shortcut);
return response.data; return response.data;
} catch (error) { } 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 { 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; return response.data;
} catch (error) { } 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: { export async function connectSSH(sessionId: string, config: {
ip: string; ip: string;
port: number; port: number;
@@ -451,17 +459,15 @@ export async function connectSSH(sessionId: string, config: {
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error connecting SSH:', error);
throw error; throw error;
} }
} }
export async function disconnectSSH(sessionId: string): Promise<any> { export async function disconnectSSH(sessionId: string): Promise<any> {
try { 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; return response.data;
} catch (error) { } catch (error) {
console.error('Error disconnecting SSH:', error);
throw error; throw error;
} }
} }
@@ -469,11 +475,10 @@ export async function disconnectSSH(sessionId: string): Promise<any> {
export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> { export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
try { try {
const response = await configEditorApi.get('/ssh/config_editor/ssh/status', { const response = await configEditorApi.get('/ssh/config_editor/ssh/status', {
params: { sessionId } params: {sessionId}
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error getting SSH status:', error);
throw 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[]> { export async function listSSHFiles(sessionId: string, path: string): Promise<any[]> {
try { try {
const response = await configEditorApi.get('/ssh/config_editor/ssh/listFiles', { const response = await configEditorApi.get('/ssh/config_editor/ssh/listFiles', {
params: { sessionId, path } params: {sessionId, path}
}); });
return response.data || []; return response.data || [];
} catch (error) { } catch (error) {
console.error('Error listing SSH files:', error);
throw 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 }> { export async function readSSHFile(sessionId: string, path: string): Promise<{ content: string; path: string }> {
try { try {
const response = await configEditorApi.get('/ssh/config_editor/ssh/readFile', { const response = await configEditorApi.get('/ssh/config_editor/ssh/readFile', {
params: { sessionId, path } params: {sessionId, path}
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error reading SSH file:', error);
throw error; throw error;
} }
} }
@@ -509,9 +512,13 @@ export async function writeSSHFile(sessionId: string, path: string, content: str
path, path,
content 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) { } catch (error) {
console.error('Error writing SSH file:', error);
throw error; throw error;
} }
} }

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import { Client as SSHClient } from 'ssh2'; import {Client as SSHClient} from 'ssh2';
import chalk from "chalk"; import chalk from "chalk";
const app = express(); const app = express();
@@ -38,23 +38,25 @@ const logger = {
} }
}; };
// --- SSH Operations (per-session, in-memory, with cleanup) ---
interface SSHSession { interface SSHSession {
client: SSHClient; client: SSHClient;
isConnected: boolean; isConnected: boolean;
lastActive: number; lastActive: number;
timeout?: NodeJS.Timeout; timeout?: NodeJS.Timeout;
} }
const sshSessions: Record<string, SSHSession> = {}; 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) { function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId]; const session = sshSessions[sessionId];
if (session) { if (session) {
try { session.client.end(); } catch {} try {
session.client.end();
} catch {
}
clearTimeout(session.timeout); clearTimeout(session.timeout);
delete sshSessions[sessionId]; 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) => { 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) { 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); if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId);
const client = new SSHClient(); const client = new SSHClient();
const config: any = { const config: any = {
host: ip, host: ip,
port: port || 22, port: port || 22,
username, username,
readyTimeout: 20000, readyTimeout: 20000,
keepaliveInterval: 10000, keepaliveInterval: 10000,
keepaliveCountMax: 3, keepaliveCountMax: 3,
}; };
if (sshKey && sshKey.trim()) { if (sshKey && sshKey.trim()) {
config.privateKey = sshKey; config.privateKey = sshKey;
if (keyPassword) config.passphrase = keyPassword; if (keyPassword) config.passphrase = keyPassword;
logger.info('Using SSH key authentication'); } 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; let responseSent = false;
client.on('ready', () => { client.on('ready', () => {
if (responseSent) return; if (responseSent) return;
responseSent = true; responseSent = true;
sshSessions[sessionId] = { client, isConnected: true, lastActive: Date.now() }; sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
scheduleSessionCleanup(sessionId); 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) => { client.on('error', (err) => {
if (responseSent) return; if (responseSent) return;
responseSent = true; responseSent = true;
logger.error(`SSH connection error for session ${sessionId}:`, err.message); 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', () => { client.on('close', () => {
logger.info(`SSH connection closed for session ${sessionId}`);
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false; if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
cleanupSession(sessionId); cleanupSession(sessionId);
}); });
client.connect(config); client.connect(config);
}); });
app.post('/ssh/config_editor/ssh/disconnect', (req, res) => { app.post('/ssh/config_editor/ssh/disconnect', (req, res) => {
const { sessionId } = req.body; const {sessionId} = req.body;
cleanupSession(sessionId); 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) => { app.get('/ssh/config_editor/ssh/status', (req, res) => {
const sessionId = req.query.sessionId as string; const sessionId = req.query.sessionId as string;
const isConnected = !!sshSessions[sessionId]?.isConnected; 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) => { app.get('/ssh/config_editor/ssh/listFiles', (req, res) => {
const sessionId = req.query.sessionId as string; const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];
const sshPath = decodeURIComponent((req.query.path as string) || '/'); const sshPath = decodeURIComponent((req.query.path as string) || '/');
if (!sessionId) { 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) { 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(); sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId); scheduleSessionCleanup(sessionId);
// Escape the path properly for shell command
const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => { sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
if (err) { if (err) {
logger.error('SSH listFiles error:', err); logger.error('SSH listFiles error:', err);
return res.status(500).json({ error: err.message }); return res.status(500).json({error: err.message});
} }
let data = ''; let data = '';
let errorData = ''; let errorData = '';
stream.on('data', (chunk: Buffer) => { stream.on('data', (chunk: Buffer) => {
data += chunk.toString(); data += chunk.toString();
}); });
stream.stderr.on('data', (chunk: Buffer) => { stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString(); errorData += chunk.toString();
}); });
stream.on('close', (code) => { stream.on('close', (code) => {
if (code !== 0) { if (code !== 0) {
logger.error(`SSH listFiles command failed with code ${code}: ${errorData}`); logger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
return res.status(500).json({ error: `Command failed: ${errorData}` }); return res.status(500).json({error: `Command failed: ${errorData}`});
} }
const lines = data.split('\n').filter(line => line.trim()); const lines = data.split('\n').filter(line => line.trim());
const files = []; const files = [];
for (let i = 1; i < lines.length; i++) { for (let i = 1; i < lines.length; i++) {
const line = lines[i]; const line = lines[i];
const parts = line.split(/\s+/); 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 name = parts.slice(8).join(' ');
const isDirectory = permissions.startsWith('d'); const isDirectory = permissions.startsWith('d');
const isLink = permissions.startsWith('l'); const isLink = permissions.startsWith('l');
// Skip . and .. directories
if (name === '.' || name === '..') continue; if (name === '.' || name === '..') continue;
files.push({ files.push({
name, name,
type: isDirectory ? 'directory' : (isLink ? 'link' : 'file') type: isDirectory ? 'directory' : (isLink ? 'link' : 'file')
}); });
} }
} }
res.json(files); res.json(files);
}); });
}); });
@@ -218,226 +201,188 @@ app.get('/ssh/config_editor/ssh/readFile', (req, res) => {
const sessionId = req.query.sessionId as string; const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];
const filePath = decodeURIComponent(req.query.path as string); const filePath = decodeURIComponent(req.query.path as string);
if (!sessionId) { 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) { 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) { 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(); sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId); scheduleSessionCleanup(sessionId);
// Escape the file path properly
const escapedPath = filePath.replace(/'/g, "'\"'\"'"); const escapedPath = filePath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => { sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
if (err) { if (err) {
logger.error('SSH readFile error:', err); logger.error('SSH readFile error:', err);
return res.status(500).json({ error: err.message }); return res.status(500).json({error: err.message});
} }
let data = ''; let data = '';
let errorData = ''; let errorData = '';
stream.on('data', (chunk: Buffer) => { stream.on('data', (chunk: Buffer) => {
data += chunk.toString(); data += chunk.toString();
}); });
stream.stderr.on('data', (chunk: Buffer) => { stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString(); errorData += chunk.toString();
}); });
stream.on('close', (code) => { stream.on('close', (code) => {
if (code !== 0) { if (code !== 0) {
logger.error(`SSH readFile command failed with code ${code}: ${errorData}`); logger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
return res.status(500).json({ error: `Command failed: ${errorData}` }); 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) => { 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]; const sshConn = sshSessions[sessionId];
if (!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) { 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) { 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) { 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(); sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId); 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 tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'"); const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
const escapedFilePath = filePath.replace(/'/g, "'\"'\"'"); const escapedFilePath = filePath.replace(/'/g, "'\"'\"'");
// Use base64 encoding to safely transfer content
const base64Content = Buffer.from(content, 'utf8').toString('base64'); const base64Content = Buffer.from(content, 'utf8').toString('base64');
logger.info(`Starting writeFile operation: session=${sessionId}, path=${filePath}, contentLength=${content.length}, base64Length=${base64Content.length}`); const commandTimeout = setTimeout(() => {
logger.error(`SSH writeFile command timed out for session: ${sessionId}`);
// Add timeout to prevent hanging if (!res.headersSent) {
const commandTimeout = setTimeout(() => { res.status(500).json({error: 'SSH command timed out'});
logger.error(`SSH writeFile command timed out for session: ${sessionId}`); }
if (!res.headersSent) { }, 15000);
res.status(500).json({ error: 'SSH command timed out' });
}
}, 15000); // 15 second timeout
// First check file permissions and ownership
const checkCommand = `ls -la '${escapedFilePath}' 2>/dev/null || echo "File does not exist"`; 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) => { sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
if (checkErr) { 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 = ''; let checkResult = '';
checkStream.on('data', (chunk: Buffer) => { checkStream.on('data', (chunk: Buffer) => {
checkResult += chunk.toString(); checkResult += chunk.toString();
}); });
checkStream.on('close', (checkCode) => { 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`; 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) => { sshConn.client.exec(writeCommand, (err, stream) => {
if (err) { if (err) {
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
logger.error('SSH writeFile error:', err); logger.error('SSH writeFile error:', err);
if (!res.headersSent) { if (!res.headersSent) {
return res.status(500).json({ error: err.message }); return res.status(500).json({error: err.message});
} }
return; return;
} }
let outputData = ''; let outputData = '';
let errorData = ''; let errorData = '';
stream.on('data', (chunk: Buffer) => { stream.on('data', (chunk: Buffer) => {
outputData += chunk.toString(); outputData += chunk.toString();
logger.debug(`SSH writeFile stdout: ${chunk.toString()}`);
}); });
stream.stderr.on('data', (chunk: Buffer) => { stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString(); errorData += chunk.toString();
logger.debug(`SSH writeFile stderr: ${chunk.toString()}`);
// Check for permission denied and fail fast
if (chunk.toString().includes('Permission denied')) { if (chunk.toString().includes('Permission denied')) {
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
logger.error(`Permission denied writing to file: ${filePath}`); logger.error(`Permission denied writing to file: ${filePath}`);
if (!res.headersSent) { if (!res.headersSent) {
return res.status(403).json({ return res.status(403).json({
error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.` error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.`
}); });
} }
return; return;
} }
}); });
stream.on('close', (code) => { stream.on('close', (code) => {
logger.info(`SSH writeFile command completed with code: ${code}, output: "${outputData.trim()}", error: "${errorData.trim()}"`);
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
// Check if we got the success message
if (outputData.includes('SUCCESS')) { 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}'`; const verifyCommand = `ls -la '${escapedFilePath}' 2>/dev/null | awk '{print $5}'`;
logger.info(`Verifying file was written: ${filePath}`);
sshConn.client.exec(verifyCommand, (verifyErr, verifyStream) => { sshConn.client.exec(verifyCommand, (verifyErr, verifyStream) => {
if (verifyErr) { if (verifyErr) {
logger.warn('File verification failed, but assuming success:');
if (!res.headersSent) { if (!res.headersSent) {
res.json({ message: 'File written successfully', path: filePath }); res.json({message: 'File written successfully', path: filePath});
} }
return; return;
} }
let verifyResult = ''; let verifyResult = '';
verifyStream.on('data', (chunk: Buffer) => { verifyStream.on('data', (chunk: Buffer) => {
verifyResult += chunk.toString(); verifyResult += chunk.toString();
}); });
verifyStream.on('close', (verifyCode) => { verifyStream.on('close', (verifyCode) => {
const fileSize = Number(verifyResult.trim()); const fileSize = Number(verifyResult.trim());
logger.info(`File verification result: size=${fileSize} bytes`);
if (fileSize > 0) { if (fileSize > 0) {
logger.info(`File written successfully: ${filePath} (${fileSize} bytes)`);
if (!res.headersSent) { if (!res.headersSent) {
res.json({ message: 'File written successfully', path: filePath }); res.json({message: 'File written successfully', path: filePath});
} }
} else { } else {
logger.error(`File appears to be empty after write: ${filePath}`);
if (!res.headersSent) { 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; return;
} }
if (code !== 0) { 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) { if (!res.headersSent) {
return res.status(500).json({ error: `Command failed: ${errorData}` }); return res.status(500).json({error: `Command failed: ${errorData}`});
} }
return; 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) { if (!res.headersSent) {
res.json({ message: 'File written successfully', path: filePath }); res.json({message: 'File written successfully', path: filePath});
} }
}); });
stream.on('error', (streamErr) => { stream.on('error', (streamErr) => {
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
logger.error('SSH writeFile stream error:', streamErr); logger.error('SSH writeFile stream error:', streamErr);
if (!res.headersSent) { 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; const PORT = 8084;
app.listen(PORT, () => {}); app.listen(PORT, () => {
});

View File

@@ -41,7 +41,7 @@ const logger = {
app.use(bodyParser.json()); app.use(bodyParser.json());
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ status: 'ok' }); res.json({status: 'ok'});
}); });
app.use('/users', userRoutes); 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) => { app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.error('Unhandled error:', err); logger.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal Server Error' }); res.status(500).json({error: 'Internal Server Error'});
}); });
const PORT = 8081; 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 Database from 'better-sqlite3';
import * as schema from './schema.js'; import * as schema from './schema.js';
import chalk from 'chalk'; import chalk from 'chalk';
@@ -34,108 +34,296 @@ const logger = {
const dataDir = process.env.DATA_DIR || './db/data'; const dataDir = process.env.DATA_DIR || './db/data';
const dbDir = path.resolve(dataDir); const dbDir = path.resolve(dataDir);
if (!fs.existsSync(dbDir)) { if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true }); fs.mkdirSync(dbDir, {recursive: true});
} }
const dbPath = path.join(dataDir, 'db.sqlite'); const dbPath = path.join(dataDir, 'db.sqlite');
const sqlite = new Database(dbPath); const sqlite = new Database(dbPath);
// Create tables using Drizzle schema
sqlite.exec(` sqlite.exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users
id TEXT PRIMARY KEY, (
username TEXT NOT NULL, id
password_hash TEXT NOT NULL, TEXT
is_admin INTEGER NOT NULL DEFAULT 0 PRIMARY
); KEY,
username
TEXT
NOT
NULL,
password_hash
TEXT
NOT
NULL,
is_admin
INTEGER
NOT
NULL
DEFAULT
0
);
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings
key TEXT PRIMARY KEY, (
value TEXT NOT NULL key
); TEXT
PRIMARY
KEY,
value
TEXT
NOT
NULL
);
CREATE TABLE IF NOT EXISTS ssh_data ( CREATE TABLE IF NOT EXISTS ssh_data
id INTEGER PRIMARY KEY AUTOINCREMENT, (
user_id TEXT NOT NULL, id
name TEXT, INTEGER
ip TEXT NOT NULL, PRIMARY
port INTEGER NOT NULL, KEY
username TEXT NOT NULL, AUTOINCREMENT,
folder TEXT, user_id
tags TEXT, TEXT
pin INTEGER NOT NULL DEFAULT 0, NOT
auth_type TEXT NOT NULL, NULL,
password TEXT, name
key TEXT, TEXT,
key_password TEXT, ip
key_type TEXT, TEXT
enable_terminal INTEGER NOT NULL DEFAULT 1, NOT
enable_tunnel INTEGER NOT NULL DEFAULT 1, NULL,
tunnel_connections TEXT, port
enable_config_editor INTEGER NOT NULL DEFAULT 1, INTEGER
default_path TEXT, NOT
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, username
FOREIGN KEY(user_id) REFERENCES users(id) 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 ( CREATE TABLE IF NOT EXISTS config_editor_recent
id INTEGER PRIMARY KEY AUTOINCREMENT, (
user_id TEXT NOT NULL, id
host_id INTEGER NOT NULL, INTEGER
name TEXT NOT NULL, PRIMARY
path TEXT NOT NULL, KEY
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, AUTOINCREMENT,
FOREIGN KEY(user_id) REFERENCES users(id), user_id
FOREIGN KEY(host_id) REFERENCES ssh_data(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 ( CREATE TABLE IF NOT EXISTS config_editor_pinned
id INTEGER PRIMARY KEY AUTOINCREMENT, (
user_id TEXT NOT NULL, id
host_id INTEGER NOT NULL, INTEGER
name TEXT NOT NULL, PRIMARY
path TEXT NOT NULL, KEY
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, AUTOINCREMENT,
FOREIGN KEY(user_id) REFERENCES users(id), user_id
FOREIGN KEY(host_id) REFERENCES ssh_data(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 ( CREATE TABLE IF NOT EXISTS config_editor_shortcuts
id INTEGER PRIMARY KEY AUTOINCREMENT, (
user_id TEXT NOT NULL, id
host_id INTEGER NOT NULL, INTEGER
name TEXT NOT NULL, PRIMARY
path TEXT NOT NULL, KEY
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, AUTOINCREMENT,
FOREIGN KEY(user_id) REFERENCES users(id), user_id
FOREIGN KEY(host_id) REFERENCES ssh_data(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) => { const addColumnIfNotExists = (table: string, column: string, definition: string) => {
try { try {
// Try to select the column to see if it exists sqlite.prepare(`SELECT ${column}
sqlite.prepare(`SELECT ${column} FROM ${table} LIMIT 1`).get(); FROM ${table} LIMIT 1`).get();
} catch (e) { } catch (e) {
// Column doesn't exist, add it
try { try {
sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`); sqlite.exec(`ALTER TABLE ${table}
ADD COLUMN ${column} ${definition};`);
} catch (alterError) { } catch (alterError) {
logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`); logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`);
} }
} }
}; };
// Auto-migrate: Add any missing columns based on current schema
const migrateSchema = () => { const migrateSchema = () => {
logger.info('Checking for schema updates...'); logger.info('Checking for schema updates...');
// Add missing columns to users table
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0'); addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
// Add missing columns to ssh_data table
addColumnIfNotExists('ssh_data', 'name', 'TEXT'); addColumnIfNotExists('ssh_data', 'name', 'TEXT');
addColumnIfNotExists('ssh_data', 'folder', 'TEXT'); addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
addColumnIfNotExists('ssh_data', 'tags', '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', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
addColumnIfNotExists('ssh_data', 'updated_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_recent', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('config_editor_pinned', 'host_id', 'INTEGER NOT NULL'); addColumnIfNotExists('config_editor_pinned', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('config_editor_shortcuts', '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'); logger.success('Schema migration completed');
}; };
// Run auto-migration
migrateSchema(); migrateSchema();
// Initialize default settings
try { try {
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get(); const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
if (!row) { if (!row) {
@@ -174,4 +359,4 @@ try {
logger.warn('Could not initialize default settings'); 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 {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm'; import {sql} from 'drizzle-orm';
export const users = sqliteTable('users', { export const users = sqliteTable('users', {
id: text('id').primaryKey(), // Unique user ID (nanoid) id: text('id').primaryKey(),
username: text('username').notNull(), // Username username: text('username').notNull(),
password_hash: text('password_hash').notNull(), // Hashed password password_hash: text('password_hash').notNull(),
is_admin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), // Admin flag is_admin: integer('is_admin', {mode: 'boolean'}).notNull().default(false),
}); });
export const settings = sqliteTable('settings', { export const settings = sqliteTable('settings', {
@@ -14,52 +14,52 @@ export const settings = sqliteTable('settings', {
}); });
export const sshData = sqliteTable('ssh_data', { 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), userId: text('user_id').notNull().references(() => users.id),
name: text('name'), // Host name name: text('name'),
ip: text('ip').notNull(), ip: text('ip').notNull(),
port: integer('port').notNull(), port: integer('port').notNull(),
username: text('username').notNull(), username: text('username').notNull(),
folder: text('folder'), folder: text('folder'),
tags: text('tags'), // JSON stringified array tags: text('tags'),
pin: integer('pin', { mode: 'boolean' }).notNull().default(false), pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
authType: text('auth_type').notNull(), // 'password' | 'key' authType: text('auth_type').notNull(),
password: text('password'), password: text('password'),
key: text('key', { length: 8192 }), // Increased for larger keys key: text('key', {length: 8192}),
keyPassword: text('key_password'), // Password for protected keys keyPassword: text('key_password'),
keyType: text('key_type'), // Type of SSH key (RSA, ED25519, etc.) keyType: text('key_type'),
enableTerminal: integer('enable_terminal', { mode: 'boolean' }).notNull().default(true), enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
enableTunnel: integer('enable_tunnel', { mode: 'boolean' }).notNull().default(true), enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
tunnelConnections: text('tunnel_connections'), // JSON stringified array of tunnel connections tunnelConnections: text('tunnel_connections'),
enableConfigEditor: integer('enable_config_editor', { mode: 'boolean' }).notNull().default(true), enableConfigEditor: integer('enable_config_editor', {mode: 'boolean'}).notNull().default(true),
defaultPath: text('default_path'), // Default path for SSH connection defaultPath: text('default_path'),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`), updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
}); });
export const configEditorRecent = sqliteTable('config_editor_recent', { 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), userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(), // File name name: text('name').notNull(),
path: text('path').notNull(), // File path path: text('path').notNull(),
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`), lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
}); });
export const configEditorPinned = sqliteTable('config_editor_pinned', { 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), userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(), // File name name: text('name').notNull(),
path: text('path').notNull(), // File path path: text('path').notNull(),
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`), pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
}); });
export const configEditorShortcuts = sqliteTable('config_editor_shortcuts', { 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), userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(), // Folder name name: text('name').notNull(),
path: text('path').notNull(), // Folder path path: text('path').notNull(),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
}); });

View File

@@ -1,11 +1,11 @@
import express from 'express'; import express from 'express';
import { db } from '../db/index.js'; import {db} from '../db/index.js';
import { sshData, configEditorRecent, configEditorPinned, configEditorShortcuts } from '../db/schema.js'; import {sshData, configEditorRecent, configEditorPinned, configEditorShortcuts} from '../db/schema.js';
import { eq, and, desc } from 'drizzle-orm'; import {eq, and, desc} from 'drizzle-orm';
import chalk from 'chalk'; import chalk from 'chalk';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import multer from 'multer'; import multer from 'multer';
import type { Request, Response, NextFunction } from 'express'; import type {Request, Response, NextFunction} from 'express';
const dbIconSymbol = '🗄️'; const dbIconSymbol = '🗄️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
@@ -38,6 +38,7 @@ const router = express.Router();
function isNonEmptyString(val: any): val is string { function isNonEmptyString(val: any): val is string {
return typeof val === 'string' && val.trim().length > 0; return typeof val === 'string' && val.trim().length > 0;
} }
function isValidPort(val: any): val is number { function isValidPort(val: any): val is number {
return typeof val === 'number' && val > 0 && val < 65536; return typeof val === 'number' && val > 0 && val < 65536;
} }
@@ -48,14 +49,12 @@ interface JWTPayload {
exp?: number; exp?: number;
} }
// Configure multer for file uploads
const upload = multer({ const upload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
limits: { limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit fileSize: 10 * 1024 * 1024,
}, },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
// Only allow specific file types for SSH keys
if (file.fieldname === 'key') { if (file.fieldname === 'key') {
cb(null, true); cb(null, true);
} else { } else {
@@ -64,12 +63,11 @@ const upload = multer({
} }
}); });
// JWT authentication middleware
function authenticateJWT(req: Request, res: Response, next: NextFunction) { function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
logger.warn('Missing or invalid Authorization header'); 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 token = authHeader.split(' ')[1];
const jwtSecret = process.env.JWT_SECRET || 'secret'; const jwtSecret = process.env.JWT_SECRET || 'secret';
@@ -79,11 +77,10 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
next(); next();
} catch (err) { } catch (err) {
logger.warn('Invalid or expired token'); 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) { function isLocalhost(req: Request) {
const ip = req.ip || req.connection?.remoteAddress; const ip = req.ip || req.connection?.remoteAddress;
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; 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) => { router.get('/db/host/internal', async (req: Request, res: Response) => {
if (!isLocalhost(req) && req.headers['x-internal-request'] !== '1') { if (!isLocalhost(req) && req.headers['x-internal-request'] !== '1') {
logger.warn('Unauthorized attempt to access internal SSH host endpoint'); 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 { try {
const data = await db.select().from(sshData); const data = await db.select().from(sshData);
@@ -110,7 +107,7 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
res.json(result); res.json(result);
} catch (err) { } catch (err) {
logger.error('Failed to fetch SSH data (internal)', 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 // POST /ssh/host
router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => { router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
let hostData: any; let hostData: any;
// Check if this is a multipart form data request (file upload) // Check if this is a multipart form data request (file upload)
if (req.headers['content-type']?.includes('multipart/form-data')) { if (req.headers['content-type']?.includes('multipart/form-data')) {
// Parse the JSON data from the 'data' field // 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); hostData = JSON.parse(req.body.data);
} catch (err) { } catch (err) {
logger.warn('Invalid JSON data in multipart request'); 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 { } else {
logger.warn('Missing data field in multipart request'); 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 // Add the file data if present
if (req.file) { if (req.file) {
hostData.key = req.file.buffer.toString('utf8'); 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 // Regular JSON request
hostData = req.body; 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; const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) { if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) {
logger.warn('Invalid SSH data input'); 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 = { const sshDataObj: any = {
@@ -167,7 +182,6 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
defaultPath: defaultPath || null, defaultPath: defaultPath || null,
}; };
// Handle authentication data based on authMethod
if (authMethod === 'password') { if (authMethod === 'password') {
sshDataObj.password = password; sshDataObj.password = password;
sshDataObj.key = null; sshDataObj.key = null;
@@ -182,10 +196,10 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
try { try {
await db.insert(sshData).values(sshDataObj); await db.insert(sshData).values(sshDataObj);
res.json({ message: 'SSH data created' }); res.json({message: 'SSH data created'});
} catch (err) { } catch (err) {
logger.error('Failed to save SSH data', 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 // PUT /ssh/host/:id
router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => { router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
let hostData: any; let hostData: any;
// Check if this is a multipart form data request (file upload)
if (req.headers['content-type']?.includes('multipart/form-data')) { if (req.headers['content-type']?.includes('multipart/form-data')) {
// Parse the JSON data from the 'data' field
if (req.body.data) { if (req.body.data) {
try { try {
hostData = JSON.parse(req.body.data); hostData = JSON.parse(req.body.data);
} catch (err) { } catch (err) {
logger.warn('Invalid JSON data in multipart request'); 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 { } else {
logger.warn('Missing data field in multipart request'); 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) { if (req.file) {
hostData.key = req.file.buffer.toString('utf8'); hostData.key = req.file.buffer.toString('utf8');
} }
} else { } else {
// Regular JSON request
hostData = req.body; hostData = req.body;
} }
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, pin, enableTerminal, enableTunnel, enableConfigEditor, defaultPath, tunnelConnections } = hostData; const {
const { id } = req.params; 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; const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !id) { if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !id) {
logger.warn('Invalid SSH data input for update'); 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 = { const sshDataObj: any = {
@@ -242,7 +270,6 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
defaultPath: defaultPath || null, defaultPath: defaultPath || null,
}; };
// Handle authentication data based on authMethod
if (authMethod === 'password') { if (authMethod === 'password') {
sshDataObj.password = password; sshDataObj.password = password;
sshDataObj.key = null; sshDataObj.key = null;
@@ -259,10 +286,10 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
await db.update(sshData) await db.update(sshData)
.set(sshDataObj) .set(sshDataObj)
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId))); .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) { } catch (err) {
logger.error('Failed to update SSH data', 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; const userId = (req as any).userId;
if (!isNonEmptyString(userId)) { if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for SSH data fetch'); 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 { try {
const data = await db const data = await db
.select() .select()
.from(sshData) .from(sshData)
.where(eq(sshData.userId, userId)); .where(eq(sshData.userId, userId));
// Convert tags to array, booleans to bool, tunnelConnections to array
const result = data.map((row: any) => ({ const result = data.map((row: any) => ({
...row, ...row,
tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [], 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); res.json(result);
} catch (err) { } catch (err) {
logger.error('Failed to fetch SSH data', 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) // Route: Get SSH host by ID (requires JWT)
// GET /ssh/host/:id // GET /ssh/host/:id
router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => { 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; const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !id) { if (!isNonEmptyString(userId) || !id) {
logger.warn('Invalid request for SSH host fetch'); 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 { try {
const data = await db const data = await db
.select() .select()
.from(sshData) .from(sshData)
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId))); .where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
if (data.length === 0) { 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 host = data[0];
const result = { const result = {
...host, ...host,
@@ -327,11 +353,11 @@ router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response)
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [], tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
enableConfigEditor: !!host.enableConfigEditor, enableConfigEditor: !!host.enableConfigEditor,
}; };
res.json(result); res.json(result);
} catch (err) { } catch (err) {
logger.error('Failed to fetch SSH host', 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; const userId = (req as any).userId;
if (!isNonEmptyString(userId)) { if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for SSH folder fetch'); 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 { try {
const data = await db const data = await db
.select({ folder: sshData.folder }) .select({folder: sshData.folder})
.from(sshData) .from(sshData)
.where(eq(sshData.userId, userId)); .where(eq(sshData.userId, userId));
@@ -361,7 +387,7 @@ router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) =
res.json(folders); res.json(folders);
} catch (err) { } catch (err) {
logger.error('Failed to fetch SSH folders', 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 // DELETE /ssh/host/:id
router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => { router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { id } = req.params; const {id} = req.params;
if (!isNonEmptyString(userId) || !id) { if (!isNonEmptyString(userId) || !id) {
logger.warn('Invalid userId or id for SSH host delete'); 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 { try {
const result = await db.delete(sshData) const result = await db.delete(sshData)
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId))); .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) { } catch (err) {
logger.error('Failed to delete SSH host', 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) // Route: Get recent files (requires JWT)
// GET /ssh/config_editor/recent // GET /ssh/config_editor/recent
router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => { router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null; const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) { if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for recent files fetch'); 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) { if (!hostId) {
logger.warn('Host ID is required for recent files fetch'); 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 { try {
const recentFiles = await db const recentFiles = await db
.select() .select()
@@ -414,7 +438,7 @@ router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: R
res.json(recentFiles); res.json(recentFiles);
} catch (err) { } catch (err) {
logger.error('Failed to fetch recent files', 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 // POST /ssh/config_editor/recent
router.post('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => { router.post('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { name, path, hostId } = req.body; const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) { if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for adding recent file'); 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 { try {
// Check if file already exists in recent for this host
const conditions = [ const conditions = [
eq(configEditorRecent.userId, userId), eq(configEditorRecent.userId, userId),
eq(configEditorRecent.path, path), eq(configEditorRecent.path, path),
eq(configEditorRecent.hostId, hostId) eq(configEditorRecent.hostId, hostId)
]; ];
const existing = await db const existing = await db
.select() .select()
.from(configEditorRecent) .from(configEditorRecent)
.where(and(...conditions)); .where(and(...conditions));
if (existing.length > 0) { if (existing.length > 0) {
// Update lastOpened timestamp
await db await db
.update(configEditorRecent) .update(configEditorRecent)
.set({ lastOpened: new Date().toISOString() }) .set({lastOpened: new Date().toISOString()})
.where(and(...conditions)); .where(and(...conditions));
} else { } else {
// Add new recent file // Add new recent file
@@ -456,10 +478,10 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
lastOpened: new Date().toISOString() lastOpened: new Date().toISOString()
}); });
} }
res.json({ message: 'File added to recent' }); res.json({message: 'File added to recent'});
} catch (err) { } catch (err) {
logger.error('Failed to add recent file', 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 // DELETE /ssh/config_editor/recent
router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => { router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { name, path, hostId } = req.body; const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) { if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for removing recent file'); 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 { try {
logger.info(`Removing recent file: ${name} at ${path} for user ${userId} and host ${hostId}`);
const conditions = [ const conditions = [
eq(configEditorRecent.userId, userId), eq(configEditorRecent.userId, userId),
eq(configEditorRecent.path, path), eq(configEditorRecent.path, path),
eq(configEditorRecent.hostId, hostId) eq(configEditorRecent.hostId, hostId)
]; ];
const result = await db const result = await db
.delete(configEditorRecent) .delete(configEditorRecent)
.where(and(...conditions)); .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) { } catch (err) {
logger.error('Failed to remove recent file', 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) => { router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null; const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) { if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for pinned files fetch'); 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) { if (!hostId) {
logger.warn('Host ID is required for pinned files fetch'); 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 { try {
const pinnedFiles = await db const pinnedFiles = await db
.select() .select()
@@ -520,7 +539,7 @@ router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: R
res.json(pinnedFiles); res.json(pinnedFiles);
} catch (err) { } catch (err) {
logger.error('Failed to fetch pinned files', 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 // POST /ssh/config_editor/pinned
router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => { router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { name, path, hostId } = req.body; const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) { if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for adding pinned file'); 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 { try {
// Check if file already exists in pinned for this host
const conditions = [ const conditions = [
eq(configEditorPinned.userId, userId), eq(configEditorPinned.userId, userId),
eq(configEditorPinned.path, path), eq(configEditorPinned.path, path),
eq(configEditorPinned.hostId, hostId) eq(configEditorPinned.hostId, hostId)
]; ];
const existing = await db const existing = await db
.select() .select()
.from(configEditorPinned) .from(configEditorPinned)
.where(and(...conditions)); .where(and(...conditions));
if (existing.length === 0) { if (existing.length === 0) {
// Add new pinned file
await db.insert(configEditorPinned).values({ await db.insert(configEditorPinned).values({
userId, userId,
hostId, hostId,
@@ -556,10 +573,10 @@ router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res:
pinnedAt: new Date().toISOString() pinnedAt: new Date().toISOString()
}); });
} }
res.json({ message: 'File pinned successfully' }); res.json({message: 'File pinned successfully'});
} catch (err) { } catch (err) {
logger.error('Failed to pin file', 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 // DELETE /ssh/config_editor/pinned
router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => { router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { name, path, hostId } = req.body; const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) { if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for removing pinned file'); 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 { try {
logger.info(`Removing pinned file: ${name} at ${path} for user ${userId} and host ${hostId}`);
const conditions = [ const conditions = [
eq(configEditorPinned.userId, userId), eq(configEditorPinned.userId, userId),
eq(configEditorPinned.path, path), eq(configEditorPinned.path, path),
eq(configEditorPinned.hostId, hostId) eq(configEditorPinned.hostId, hostId)
]; ];
const result = await db const result = await db
.delete(configEditorPinned) .delete(configEditorPinned)
.where(and(...conditions)); .where(and(...conditions));
logger.info(`Pinned file removed successfully`); res.json({message: 'File unpinned successfully'});
res.json({ message: 'File unpinned successfully' });
} catch (err) { } catch (err) {
logger.error('Failed to unpin file', 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) => { router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null; const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) { 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) { 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 { try {
const shortcuts = await db const shortcuts = await db
.select() .select()
@@ -620,7 +632,7 @@ router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res
res.json(shortcuts); res.json(shortcuts);
} catch (err) { } catch (err) {
logger.error('Failed to fetch shortcuts', 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 // POST /ssh/config_editor/shortcuts
router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => { router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { name, path, hostId } = req.body; const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) { 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 { try {
// Check if shortcut already exists for this host
const conditions = [ const conditions = [
eq(configEditorShortcuts.userId, userId), eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.path, path), eq(configEditorShortcuts.path, path),
eq(configEditorShortcuts.hostId, hostId) eq(configEditorShortcuts.hostId, hostId)
]; ];
const existing = await db const existing = await db
.select() .select()
.from(configEditorShortcuts) .from(configEditorShortcuts)
.where(and(...conditions)); .where(and(...conditions));
if (existing.length === 0) { if (existing.length === 0) {
// Add new shortcut
await db.insert(configEditorShortcuts).values({ await db.insert(configEditorShortcuts).values({
userId, userId,
hostId, hostId,
@@ -656,10 +665,10 @@ router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, re
createdAt: new Date().toISOString() createdAt: new Date().toISOString()
}); });
} }
res.json({ message: 'Shortcut added successfully' }); res.json({message: 'Shortcut added successfully'});
} catch (err) { } catch (err) {
logger.error('Failed to add shortcut', 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 // DELETE /ssh/config_editor/shortcuts
router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => { router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { name, path, hostId } = req.body; const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) { 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 { try {
logger.info(`Removing shortcut: ${name} at ${path} for user ${userId} and host ${hostId}`);
const conditions = [ const conditions = [
eq(configEditorShortcuts.userId, userId), eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.path, path), eq(configEditorShortcuts.path, path),
eq(configEditorShortcuts.hostId, hostId) eq(configEditorShortcuts.hostId, hostId)
]; ];
const result = await db const result = await db
.delete(configEditorShortcuts) .delete(configEditorShortcuts)
.where(and(...conditions)); .where(and(...conditions));
logger.info(`Shortcut removed successfully`); res.json({message: 'Shortcut removed successfully'});
res.json({ message: 'Shortcut removed successfully' });
} catch (err) { } catch (err) {
logger.error('Failed to remove shortcut', 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 express from 'express';
import { db } from '../db/index.js'; import {db} from '../db/index.js';
import { users, settings } from '../db/schema.js'; import {users, settings} from '../db/schema.js';
import { eq } from 'drizzle-orm'; import {eq} from 'drizzle-orm';
import chalk from 'chalk'; import chalk from 'chalk';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { nanoid } from 'nanoid'; import {nanoid} from 'nanoid';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import type { Request, Response, NextFunction } from 'express'; import type {Request, Response, NextFunction} from 'express';
const dbIconSymbol = '🗄️'; const dbIconSymbol = '🗄️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); 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']; const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
logger.warn('Missing or invalid Authorization header'); 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 token = authHeader.split(' ')[1];
const jwtSecret = process.env.JWT_SECRET || 'secret'; const jwtSecret = process.env.JWT_SECRET || 'secret';
@@ -61,7 +61,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
next(); next();
} catch (err) { } catch (err) {
logger.warn('Invalid or expired token'); 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 { try {
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get(); const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
if (row && (row as any).value !== 'true') { 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) { } catch (e) {
} }
const { username, password } = req.body; const {username, password} = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(password)) { if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
logger.warn('Invalid user creation attempt'); 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 { try {
const existing = await db const existing = await db
@@ -87,7 +87,7 @@ router.post('/create', async (req, res) => {
.where(eq(users.username, username)); .where(eq(users.username, username));
if (existing && existing.length > 0) { if (existing && existing.length > 0) {
logger.warn(`Attempt to create duplicate username: ${username}`); 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; let isFirstUser = false;
try { try {
@@ -99,22 +99,22 @@ router.post('/create', async (req, res) => {
const saltRounds = parseInt(process.env.SALT || '10', 10); const saltRounds = parseInt(process.env.SALT || '10', 10);
const password_hash = await bcrypt.hash(password, saltRounds); const password_hash = await bcrypt.hash(password, saltRounds);
const id = nanoid(); 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})`); 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) { } catch (err) {
logger.error('Failed to create user', 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 // Route: Get user JWT by username and password
// POST /users/get // POST /users/get
router.post('/get', async (req, res) => { router.post('/get', async (req, res) => {
const { username, password } = req.body; const {username, password} = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(password)) { if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
logger.warn('Invalid get user attempt'); 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 { try {
const user = await db const user = await db
@@ -123,20 +123,20 @@ router.post('/get', async (req, res) => {
.where(eq(users.username, username)); .where(eq(users.username, username));
if (!user || user.length === 0) { if (!user || user.length === 0) {
logger.warn(`User not found: ${username}`); 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 userRecord = user[0];
const isMatch = await bcrypt.compare(password, userRecord.password_hash); const isMatch = await bcrypt.compare(password, userRecord.password_hash);
if (!isMatch) { if (!isMatch) {
logger.warn(`Incorrect password for user: ${username}`); 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 jwtSecret = process.env.JWT_SECRET || 'secret';
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, { expiresIn: '50d' }); const token = jwt.sign({userId: userRecord.id}, jwtSecret, {expiresIn: '50d'});
res.json({ token }); res.json({token});
} catch (err) { } catch (err) {
logger.error('Failed to get user', 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; const userId = (req as any).userId;
if (!isNonEmptyString(userId)) { if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId in JWT for /users/me'); 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 { try {
const user = await db const user = await db
@@ -155,12 +155,12 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
.where(eq(users.id, userId)); .where(eq(users.id, userId));
if (!user || user.length === 0) { if (!user || user.length === 0) {
logger.warn(`User not found for /users/me: ${userId}`); 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) { } catch (err) {
logger.error('Failed to get username', 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 { try {
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get(); const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
const count = (countResult as any)?.count || 0; const count = (countResult as any)?.count || 0;
res.json({ count }); res.json({count});
} catch (err) { } catch (err) {
logger.error('Failed to count users', 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) => { router.get('/db-health', async (req, res) => {
try { try {
db.$client.prepare('SELECT 1').get(); db.$client.prepare('SELECT 1').get();
res.json({ status: 'ok' }); res.json({status: 'ok'});
} catch (err) { } catch (err) {
logger.error('DB health check failed', 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) => { router.get('/registration-allowed', async (req, res) => {
try { try {
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get(); 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) { } catch (err) {
logger.error('Failed to get registration allowed', 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 { try {
const user = await db.select().from(users).where(eq(users.id, userId)); const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].is_admin) { 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') { 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'); db.$client.prepare("UPDATE settings SET value = ? WHERE key = 'allow_registration'").run(allowed ? 'true' : 'false');
res.json({ allowed }); res.json({allowed});
} catch (err) { } catch (err) {
logger.error('Failed to set registration allowed', 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>();
const activeTunnels = new Map<string, Client>(); // tunnelName -> Client const retryCounters = new Map<string, number>();
const retryCounters = new Map<string, number>(); // tunnelName -> retryCount const connectionStatus = new Map<string, TunnelStatus>();
const connectionStatus = new Map<string, TunnelStatus>(); // tunnelName -> status const tunnelVerifications = new Map<string, VerificationData>();
const tunnelVerifications = new Map<string, VerificationData>(); // tunnelName -> verification const manualDisconnects = new Set<string>();
const manualDisconnects = new Set<string>(); // tunnelNames const verificationTimers = new Map<string, NodeJS.Timeout>();
const verificationTimers = new Map<string, NodeJS.Timeout>(); // timer keys -> timeout const activeRetryTimers = new Map<string, NodeJS.Timeout>();
const activeRetryTimers = new Map<string, NodeJS.Timeout>(); // tunnelName -> retry timer const countdownIntervals = new Map<string, NodeJS.Timeout>();
const countdownIntervals = new Map<string, NodeJS.Timeout>(); // tunnelName -> countdown interval const retryExhaustedTunnels = new Set<string>();
const retryExhaustedTunnels = new Set<string>(); // tunnelNames
const tunnelConfigs = new Map<string, TunnelConfig>(); // tunnelName -> tunnelConfig const tunnelConfigs = new Map<string, TunnelConfig>();
const activeTunnelProcesses = new Map<string, ChildProcess>(); // tunnelName -> ChildProcess const activeTunnelProcesses = new Map<string, ChildProcess>();s
// Types
interface TunnelConnection { interface TunnelConnection {
sourcePort: number; sourcePort: number;
endpointPort: number; endpointPort: number;
@@ -159,7 +157,6 @@ const ERROR_TYPES = {
type ConnectionState = typeof CONNECTION_STATES[keyof typeof CONNECTION_STATES]; type ConnectionState = typeof CONNECTION_STATES[keyof typeof CONNECTION_STATES];
type ErrorType = typeof ERROR_TYPES[keyof typeof ERROR_TYPES]; type ErrorType = typeof ERROR_TYPES[keyof typeof ERROR_TYPES];
// Helper functions
function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void {
if (status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName)) { if (status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName)) {
return; return;
@@ -218,14 +215,11 @@ function classifyError(errorMessage: string): ErrorType {
return ERROR_TYPES.UNKNOWN; return ERROR_TYPES.UNKNOWN;
} }
// Helper to build a unique marker for each tunnel
function getTunnelMarker(tunnelName: string) { function getTunnelMarker(tunnelName: string) {
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`; return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`;
} }
// Cleanup and disconnect functions
function cleanupTunnelResources(tunnelName: string): void { function cleanupTunnelResources(tunnelName: string): void {
// Fire-and-forget remote pkill (do not block local cleanup)
const tunnelConfig = tunnelConfigs.get(tunnelName); const tunnelConfig = tunnelConfigs.get(tunnelName);
if (tunnelConfig) { if (tunnelConfig) {
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => { killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
@@ -235,7 +229,6 @@ function cleanupTunnelResources(tunnelName: string): void {
}); });
} }
// Local cleanup (always run immediately)
if (activeTunnelProcesses.has(tunnelName)) { if (activeTunnelProcesses.has(tunnelName)) {
try { try {
const proc = activeTunnelProcesses.get(tunnelName); const proc = activeTunnelProcesses.get(tunnelName);
@@ -398,7 +391,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
const initialNextRetryIn = Math.ceil(retryInterval / 1000); const initialNextRetryIn = Math.ceil(retryInterval / 1000);
let currentNextRetryIn = initialNextRetryIn; let currentNextRetryIn = initialNextRetryIn;
// Set initial WAITING status with countdown
broadcastTunnelStatus(tunnelName, { broadcastTunnelStatus(tunnelName, {
connected: false, connected: false,
status: CONNECTION_STATES.WAITING, status: CONNECTION_STATES.WAITING,
@@ -407,7 +399,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
nextRetryIn: currentNextRetryIn nextRetryIn: currentNextRetryIn
}); });
// Update countdown every second
const countdownInterval = setInterval(() => { const countdownInterval = setInterval(() => {
currentNextRetryIn--; currentNextRetryIn--;
if (currentNextRetryIn > 0) { 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 { function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void {
if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) { if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) {
return; return;
@@ -496,10 +486,7 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
} }
} else { } else {
logger.warn(`Verification failed for '${tunnelName}': ${failureReason}`); 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 (failureReason.includes('command failed') || failureReason.includes('connection error') || failureReason.includes('timeout')) {
if (!manualDisconnects.has(tunnelName)) { if (!manualDisconnects.has(tunnelName)) {
broadcastTunnelStatus(tunnelName, { broadcastTunnelStatus(tunnelName, {
@@ -511,19 +498,13 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
activeTunnels.delete(tunnelName); activeTunnels.delete(tunnelName);
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
} else { } 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}`); logger.info(`Assuming tunnel '${tunnelName}' is working despite verification warning: ${failureReason}`);
cleanupVerification(true); // Treat as successful to prevent disconnect cleanupVerification(true);
} }
} }
} }
function attemptVerification() { 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}'`; const testCmd = `timeout 3 bash -c 'nc -z ${tunnelConfig.endpointIP} ${tunnelConfig.endpointPort}'`;
verificationConn.exec(testCmd, (err, stream) => { verificationConn.exec(testCmd, (err, stream) => {
@@ -535,7 +516,7 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
let output = ''; let output = '';
let errorOutput = ''; let errorOutput = '';
stream.on('data', (data: Buffer) => { stream.on('data', (data: Buffer) => {
output += data.toString(); output += data.toString();
}); });
@@ -548,17 +529,16 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
if (code === 0) { if (code === 0) {
cleanupVerification(true); cleanupVerification(true);
} else { } else {
// Check if it's a timeout or connection refused
const isTimeout = errorOutput.includes('timeout') || errorOutput.includes('Connection timed out'); const isTimeout = errorOutput.includes('timeout') || errorOutput.includes('Connection timed out');
const isConnectionRefused = errorOutput.includes('Connection refused') || errorOutput.includes('No route to host'); const isConnectionRefused = errorOutput.includes('Connection refused') || errorOutput.includes('No route to host');
let failureReason = `Cannot connect to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`; let failureReason = `Cannot connect to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
if (isTimeout) { if (isTimeout) {
failureReason = `Tunnel verification timeout - cannot reach ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`; failureReason = `Tunnel verification timeout - cannot reach ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
} else if (isConnectionRefused) { } else if (isConnectionRefused) {
failureReason = `Connection refused to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort} - tunnel may not be established`; failureReason = `Connection refused to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort} - tunnel may not be established`;
} }
cleanupVerification(false, failureReason); cleanupVerification(false, failureReason);
} }
}); });
@@ -571,7 +551,6 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
} }
verificationConn.on('ready', () => { verificationConn.on('ready', () => {
// Add a small delay to allow the tunnel to fully establish
setTimeout(() => { setTimeout(() => {
attemptVerification(); attemptVerification();
}, 2000); }, 2000);
@@ -633,7 +612,6 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
if (tunnelConfig.sourceKeyPassword) { if (tunnelConfig.sourceKeyPassword) {
connOptions.passphrase = tunnelConfig.sourceKeyPassword; connOptions.passphrase = tunnelConfig.sourceKeyPassword;
} }
// Add key type handling if specified
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') { if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
connOptions.privateKeyType = tunnelConfig.sourceKeyType; connOptions.privateKeyType = tunnelConfig.sourceKeyType;
} }
@@ -714,10 +692,9 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName)); handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
}); });
}); });
}, 30000); // Ping every 30 seconds }, 30000);
} }
// Main SSH tunnel connection function
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void { function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
const tunnelName = tunnelConfig.name; const tunnelName = tunnelConfig.name;
const tunnelMarker = getTunnelMarker(tunnelName); const tunnelMarker = getTunnelMarker(tunnelName);
@@ -733,7 +710,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
retryCounters.delete(tunnelName); retryCounters.delete(tunnelName);
} }
// Only set status to CONNECTING if we're not already in WAITING state
const currentStatus = connectionStatus.get(tunnelName); const currentStatus = connectionStatus.get(tunnelName);
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) { if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
broadcastTunnelStatus(tunnelName, { broadcastTunnelStatus(tunnelName, {
@@ -835,7 +811,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
let tunnelCmd: string; let tunnelCmd: string;
if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) { 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, '_')}`; 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}`; 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 { } else {
@@ -975,7 +950,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
}; };
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) { if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
// Validate SSH key format
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) { if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) {
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`); logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`);
broadcastTunnelStatus(tunnelName, { broadcastTunnelStatus(tunnelName, {
@@ -990,7 +964,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
if (tunnelConfig.sourceKeyPassword) { if (tunnelConfig.sourceKeyPassword) {
connOptions.passphrase = tunnelConfig.sourceKeyPassword; connOptions.passphrase = tunnelConfig.sourceKeyPassword;
} }
// Add key type handling if specified
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') { if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
connOptions.privateKeyType = tunnelConfig.sourceKeyType; connOptions.privateKeyType = tunnelConfig.sourceKeyType;
} }
@@ -1006,14 +979,12 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
connOptions.password = tunnelConfig.sourcePassword; connOptions.password = tunnelConfig.sourcePassword;
} }
// Test basic network connectivity first
const testSocket = new net.Socket(); const testSocket = new net.Socket();
testSocket.setTimeout(5000); testSocket.setTimeout(5000);
testSocket.on('connect', () => { testSocket.on('connect', () => {
testSocket.destroy(); testSocket.destroy();
// Only update status to CONNECTING if we're not already in WAITING state
const currentStatus = connectionStatus.get(tunnelName); const currentStatus = connectionStatus.get(tunnelName);
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) { if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
broadcastTunnelStatus(tunnelName, { broadcastTunnelStatus(tunnelName, {
@@ -1047,7 +1018,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
testSocket.connect(tunnelConfig.sourceSSHPort, tunnelConfig.sourceIP); 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) { function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) {
const tunnelMarker = getTunnelMarker(tunnelName); const tunnelMarker = getTunnelMarker(tunnelName);
const conn = new Client(); const conn = new Client();
@@ -1106,7 +1076,6 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
connOptions.password = tunnelConfig.sourcePassword; connOptions.password = tunnelConfig.sourcePassword;
} }
conn.on('ready', () => { conn.on('ready', () => {
// Use pkill to kill the tunnel by marker
const killCmd = `pkill -f '${tunnelMarker}'`; const killCmd = `pkill -f '${tunnelMarker}'`;
conn.exec(killCmd, (err, stream) => { conn.exec(killCmd, (err, stream) => {
if (err) { if (err) {
@@ -1128,7 +1097,6 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
conn.connect(connOptions); conn.connect(connOptions);
} }
// Express API endpoints
app.get('/ssh/tunnel/status', (req, res) => { app.get('/ssh/tunnel/status', (req, res) => {
res.json(getAllTunnelStatus()); res.json(getAllTunnelStatus());
}); });
@@ -1153,16 +1121,12 @@ app.post('/ssh/tunnel/connect', (req, res) => {
const tunnelName = tunnelConfig.name; const tunnelName = tunnelConfig.name;
// Reset retry state for new connection
manualDisconnects.delete(tunnelName); manualDisconnects.delete(tunnelName);
retryCounters.delete(tunnelName); retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName);
// Store tunnel config
tunnelConfigs.set(tunnelName, tunnelConfig); tunnelConfigs.set(tunnelName, tunnelConfig);
// Start connection
connectSSHTunnel(tunnelConfig, 0); connectSSHTunnel(tunnelConfig, 0);
res.json({message: 'Connection request received', tunnelName}); res.json({message: 'Connection request received', tunnelName});
@@ -1193,7 +1157,6 @@ app.post('/ssh/tunnel/disconnect', (req, res) => {
const tunnelConfig = tunnelConfigs.get(tunnelName) || null; const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
handleDisconnect(tunnelName, tunnelConfig, false); handleDisconnect(tunnelName, tunnelConfig, false);
// Clear manual disconnect flag after a delay
setTimeout(() => { setTimeout(() => {
manualDisconnects.delete(tunnelName); manualDisconnects.delete(tunnelName);
}, 5000); }, 5000);
@@ -1208,7 +1171,6 @@ app.post('/ssh/tunnel/cancel', (req, res) => {
return res.status(400).json({error: 'Tunnel name required'}); return res.status(400).json({error: 'Tunnel name required'});
} }
// Cancel retry operations
retryCounters.delete(tunnelName); retryCounters.delete(tunnelName);
retryExhaustedTunnels.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName);
@@ -1222,18 +1184,15 @@ app.post('/ssh/tunnel/cancel', (req, res) => {
countdownIntervals.delete(tunnelName); countdownIntervals.delete(tunnelName);
} }
// Set status to disconnected
broadcastTunnelStatus(tunnelName, { broadcastTunnelStatus(tunnelName, {
connected: false, connected: false,
status: CONNECTION_STATES.DISCONNECTED, status: CONNECTION_STATES.DISCONNECTED,
manualDisconnect: true manualDisconnect: true
}); });
// Clean up any existing tunnel resources
const tunnelConfig = tunnelConfigs.get(tunnelName) || null; const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
handleDisconnect(tunnelName, tunnelConfig, false); handleDisconnect(tunnelName, tunnelConfig, false);
// Clear manual disconnect flag after a delay
setTimeout(() => { setTimeout(() => {
manualDisconnects.delete(tunnelName); manualDisconnects.delete(tunnelName);
}, 5000); }, 5000);
@@ -1241,10 +1200,8 @@ app.post('/ssh/tunnel/cancel', (req, res) => {
res.json({message: 'Cancel request received', tunnelName}); res.json({message: 'Cancel request received', tunnelName});
}); });
// Auto-start functionality
async function initializeAutoStartTunnels(): Promise<void> { async function initializeAutoStartTunnels(): Promise<void> {
try { 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', { const response = await axios.get('http://localhost:8081/ssh/db/host/internal', {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -1255,12 +1212,10 @@ async function initializeAutoStartTunnels(): Promise<void> {
const hosts: SSHHost[] = response.data || []; const hosts: SSHHost[] = response.data || [];
const autoStartTunnels: TunnelConfig[] = []; const autoStartTunnels: TunnelConfig[] = [];
// Process each host and extract auto-start tunnel connections
for (const host of hosts) { for (const host of hosts) {
if (host.enableTunnel && host.tunnelConnections) { if (host.enableTunnel && host.tunnelConnections) {
for (const tunnelConnection of host.tunnelConnections) { for (const tunnelConnection of host.tunnelConnections) {
if (tunnelConnection.autoStart) { if (tunnelConnection.autoStart) {
// Find the endpoint host
const endpointHost = hosts.find(h => const endpointHost = hosts.find(h =>
h.name === tunnelConnection.endpointHost || h.name === tunnelConnection.endpointHost ||
`${h.username}@${h.ip}` === 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`); logger.info(`Found ${autoStartTunnels.length} auto-start tunnels`);
// Start each auto-start tunnel
for (const tunnelConfig of autoStartTunnels) { for (const tunnelConfig of autoStartTunnels) {
tunnelConfigs.set(tunnelConfig.name, tunnelConfig); tunnelConfigs.set(tunnelConfig.name, tunnelConfig);
// Start the tunnel with a delay to avoid overwhelming the system
setTimeout(() => { setTimeout(() => {
connectSSHTunnel(tunnelConfig, 0); connectSSHTunnel(tunnelConfig, 0);
}, 1000); }, 1000);
@@ -1322,4 +1275,4 @@ app.listen(PORT, () => {
setTimeout(() => { setTimeout(() => {
initializeAutoStartTunnels(); initializeAutoStartTunnels();
}, 2000); }, 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" type Theme = "dark" | "light" | "system"

View File

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