Clean up code
This commit is contained in:
@@ -2,13 +2,10 @@
|
||||
FROM node:18-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies for native modules
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Copy dependency files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies with caching
|
||||
RUN npm ci --force && \
|
||||
npm cache clean --force
|
||||
|
||||
@@ -16,30 +13,24 @@ RUN npm ci --force && \
|
||||
FROM deps AS frontend-builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Build frontend
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Build backend TypeScript
|
||||
FROM deps AS backend-builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Build backend TypeScript to JavaScript
|
||||
RUN npm run build:backend
|
||||
|
||||
# Stage 4: Production dependencies
|
||||
FROM node:18-alpine AS production-deps
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only production dependency files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm ci --only=production --ignore-scripts --force && \
|
||||
npm cache clean --force
|
||||
|
||||
@@ -47,13 +38,10 @@ RUN npm ci --only=production --ignore-scripts --force && \
|
||||
FROM node:18-alpine AS native-builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Copy dependency files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install only the native modules we need
|
||||
RUN npm ci --only=production bcryptjs better-sqlite3 --force && \
|
||||
npm cache clean --force
|
||||
|
||||
@@ -63,35 +51,26 @@ ENV DATA_DIR=/app/data \
|
||||
PORT=8080 \
|
||||
NODE_ENV=production
|
||||
|
||||
# Install dependencies in a single layer
|
||||
RUN apk add --no-cache nginx gettext su-exec && \
|
||||
mkdir -p /app/data && \
|
||||
chown -R node:node /app/data
|
||||
|
||||
# Setup nginx and frontend
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html
|
||||
|
||||
# Setup backend
|
||||
WORKDIR /app
|
||||
|
||||
# Copy production dependencies and native modules
|
||||
COPY --from=production-deps /app/node_modules /app/node_modules
|
||||
COPY --from=native-builder /app/node_modules/bcryptjs /app/node_modules/bcryptjs
|
||||
COPY --from=native-builder /app/node_modules/better-sqlite3 /app/node_modules/better-sqlite3
|
||||
|
||||
# Copy compiled backend JavaScript
|
||||
COPY --from=backend-builder /app/dist/backend ./dist/backend
|
||||
|
||||
# Copy package.json for scripts
|
||||
COPY package.json ./
|
||||
|
||||
RUN chown -R node:node /app
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
# Expose ports
|
||||
EXPOSE ${PORT} 8081 8082 8083 8084
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
services:
|
||||
termix:
|
||||
#image: ghcr.io/lukegus/termix:latest
|
||||
image: ghcr.io/lukegus/termix:dev-1.0-development-latest
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3800:8080"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
# Generate random salt here https://www.lastpass.com/features/password-generator (max 32 characters, include all characters for settings)
|
||||
SALT: "2v.F7!6a!jIzmJsu|[)h61$ZMXs;,i+~"
|
||||
PORT: 8080
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
|
||||
@@ -4,11 +4,9 @@ set -e
|
||||
export PORT=${PORT:-8080}
|
||||
echo "Configuring web UI to run on port: $PORT"
|
||||
|
||||
# Configure nginx with the correct port
|
||||
envsubst '${PORT}' < /etc/nginx/nginx.conf > /etc/nginx/nginx.conf.tmp
|
||||
mv /etc/nginx/nginx.conf.tmp /etc/nginx/nginx.conf
|
||||
|
||||
# Setup data directory
|
||||
mkdir -p /app/data
|
||||
chown -R node:node /app/data
|
||||
chmod 755 /app/data
|
||||
@@ -16,12 +14,10 @@ chmod 755 /app/data
|
||||
echo "Starting nginx..."
|
||||
nginx
|
||||
|
||||
# Start backend services
|
||||
echo "Starting backend services..."
|
||||
cd /app
|
||||
export NODE_ENV=production
|
||||
|
||||
# Start the compiled TypeScript backend
|
||||
if command -v su-exec > /dev/null 2>&1; then
|
||||
su-exec node node dist/backend/starter.js
|
||||
else
|
||||
@@ -30,5 +26,4 @@ fi
|
||||
|
||||
echo "All services started"
|
||||
|
||||
# Keep container running
|
||||
tail -f /dev/null
|
||||
@@ -1,293 +1,302 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import React, {useState, useEffect} from "react";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Label} from "@/components/ui/label";
|
||||
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert";
|
||||
import axios from "axios";
|
||||
|
||||
function setCookie(name: string, value: string, days = 7) {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||
}
|
||||
|
||||
function getCookie(name: string) {
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
const parts = v.split('=');
|
||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||
}, "");
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
const parts = v.split('=');
|
||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||
}, "");
|
||||
}
|
||||
|
||||
const apiBase =
|
||||
typeof window !== "undefined" && window.location.hostname === "localhost"
|
||||
? "http://localhost:8081/users"
|
||||
: "/users";
|
||||
typeof window !== "undefined" && window.location.hostname === "localhost"
|
||||
? "http://localhost:8081/users"
|
||||
: "/users";
|
||||
|
||||
const API = axios.create({
|
||||
baseURL: apiBase,
|
||||
baseURL: apiBase,
|
||||
});
|
||||
|
||||
interface HomepageAuthProps extends React.ComponentProps<"div"> {
|
||||
setLoggedIn: (loggedIn: boolean) => void;
|
||||
setIsAdmin: (isAdmin: boolean) => void;
|
||||
setUsername: (username: string | null) => void;
|
||||
setLoggedIn: (loggedIn: boolean) => void;
|
||||
setIsAdmin: (isAdmin: boolean) => void;
|
||||
setUsername: (username: string | null) => void;
|
||||
}
|
||||
|
||||
export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername, ...props }: HomepageAuthProps) {
|
||||
const [tab, setTab] = useState<"login" | "signup">("login");
|
||||
const [localUsername, setLocalUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
|
||||
const [firstUser, setFirstUser] = useState(false);
|
||||
const [dbError, setDbError] = useState<string | null>(null);
|
||||
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||
useEffect(() => {
|
||||
API.get("/registration-allowed").then(res => {
|
||||
setRegistrationAllowed(res.data.allowed);
|
||||
});
|
||||
}, []);
|
||||
export function HomepageAuth({className, setLoggedIn, setIsAdmin, setUsername, ...props}: HomepageAuthProps) {
|
||||
const [tab, setTab] = useState<"login" | "signup">("login");
|
||||
const [localUsername, setLocalUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
|
||||
const [firstUser, setFirstUser] = useState(false);
|
||||
const [dbError, setDbError] = useState<string | null>(null);
|
||||
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||
useEffect(() => {
|
||||
API.get("/registration-allowed").then(res => {
|
||||
setRegistrationAllowed(res.data.allowed);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
API.get("/count").then(res => {
|
||||
if (res.data.count === 0) {
|
||||
setFirstUser(true);
|
||||
setTab("signup");
|
||||
} else {
|
||||
setFirstUser(false);
|
||||
}
|
||||
setDbError(null);
|
||||
}).catch(() => {
|
||||
setDbError("Could not connect to the database. Please try again later.");
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const jwt = getCookie("jwt");
|
||||
if (jwt) {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
API.get("/me", { headers: { Authorization: `Bearer ${jwt}` } }),
|
||||
API.get("/db-health")
|
||||
])
|
||||
.then(([meRes]) => {
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.data.is_admin);
|
||||
setUsername(meRes.data.username || null);
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setCookie("jwt", "", -1);
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError("Could not connect to the database. Please try again later.");
|
||||
} else {
|
||||
useEffect(() => {
|
||||
API.get("/count").then(res => {
|
||||
if (res.data.count === 0) {
|
||||
setFirstUser(true);
|
||||
setTab("signup");
|
||||
} else {
|
||||
setFirstUser(false);
|
||||
}
|
||||
setDbError(null);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
} else {
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
}).catch(() => {
|
||||
setDbError("Could not connect to the database. Please try again later.");
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const jwt = getCookie("jwt");
|
||||
if (jwt) {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}}),
|
||||
API.get("/db-health")
|
||||
])
|
||||
.then(([meRes]) => {
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.data.is_admin);
|
||||
setUsername(meRes.data.username || null);
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setCookie("jwt", "", -1);
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError("Could not connect to the database. Please try again later.");
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
} else {
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
}
|
||||
}, [setLoggedIn, setIsAdmin, setUsername]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
let res, meRes;
|
||||
if (tab === "login") {
|
||||
res = await API.post("/get", {username: localUsername, password});
|
||||
} else {
|
||||
await API.post("/create", {username: localUsername, password});
|
||||
res = await API.post("/get", {username: localUsername, password});
|
||||
}
|
||||
setCookie("jwt", res.data.token);
|
||||
[meRes] = await Promise.all([
|
||||
API.get("/me", {headers: {Authorization: `Bearer ${res.data.token}`}}),
|
||||
API.get("/db-health")
|
||||
]);
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.data.is_admin);
|
||||
setUsername(meRes.data.username || null);
|
||||
setDbError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Unknown error");
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setCookie("jwt", "", -1);
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError("Could not connect to the database. Please try again later.");
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [setLoggedIn, setIsAdmin, setUsername]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
let res, meRes;
|
||||
if (tab === "login") {
|
||||
res = await API.post("/get", { username: localUsername, password });
|
||||
} else {
|
||||
await API.post("/create", { username: localUsername, password });
|
||||
res = await API.post("/get", { username: localUsername, password });
|
||||
}
|
||||
setCookie("jwt", res.data.token);
|
||||
[meRes] = await Promise.all([
|
||||
API.get("/me", { headers: { Authorization: `Bearer ${res.data.token}` } }),
|
||||
API.get("/db-health")
|
||||
]);
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.data.is_admin);
|
||||
setUsername(meRes.data.username || null);
|
||||
setDbError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Unknown error");
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setCookie("jwt", "", -1);
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError("Could not connect to the database. Please try again later.");
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const Spinner = (
|
||||
<svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Spinner = (
|
||||
<svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex justify-center items-center min-h-screen bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className={`w-[420px] max-w-full bg-background rounded-xl shadow-lg p-6 flex flex-col ${internalLoggedIn ? '' : 'border border-border'}`}>
|
||||
{dbError && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{dbError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{firstUser && !dbError && !internalLoggedIn && (
|
||||
<Alert variant="default" className="mb-4">
|
||||
<AlertTitle>First User</AlertTitle>
|
||||
<AlertDescription>
|
||||
You are the first user and will be made an admin. You can view admin settings in the sidebar user dropdown.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!registrationAllowed && !internalLoggedIn && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Registration Disabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
New account registration is currently disabled by an admin. Please log in or contact an administrator.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{(internalLoggedIn || (loading && getCookie("jwt"))) && (
|
||||
<div className="flex flex-1 justify-center items-center p-0 m-0">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Alert className="my-2">
|
||||
<AlertTitle>Logged in!</AlertTitle>
|
||||
<AlertDescription>
|
||||
You are logged in! Use the sidebar to access all available tools. To get started, create an SSH Host in the SSH Manager tab. Once created, you can connect to that host using the other apps in the sidebar.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border"></div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
|
||||
>
|
||||
Feedback
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border"></div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
|
||||
>
|
||||
Discord
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border"></div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
|
||||
>
|
||||
Fund
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(!internalLoggedIn && (!loading || !getCookie("jwt"))) && (
|
||||
<>
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "login"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex justify-center items-center min-h-screen bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={`w-[420px] max-w-full bg-background rounded-xl shadow-lg p-6 flex flex-col ${internalLoggedIn ? '' : 'border border-border'}`}>
|
||||
{dbError && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{dbError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
onClick={() => setTab("login")}
|
||||
aria-selected={tab === "login"}
|
||||
disabled={loading || firstUser}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "signup"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
{firstUser && !dbError && !internalLoggedIn && (
|
||||
<Alert variant="default" className="mb-4">
|
||||
<AlertTitle>First User</AlertTitle>
|
||||
<AlertDescription>
|
||||
You are the first user and will be made an admin. You can view admin settings in the sidebar
|
||||
user dropdown.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!registrationAllowed && !internalLoggedIn && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Registration Disabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
New account registration is currently disabled by an admin. Please log in or contact an
|
||||
administrator.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{(internalLoggedIn || (loading && getCookie("jwt"))) && (
|
||||
<div className="flex flex-1 justify-center items-center p-0 m-0">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Alert className="my-2">
|
||||
<AlertTitle>Logged in!</AlertTitle>
|
||||
<AlertDescription>
|
||||
You are logged in! Use the sidebar to access all available tools. To get started,
|
||||
create an SSH Host in the SSH Manager tab. Once created, you can connect to that
|
||||
host using the other apps in the sidebar.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border"></div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
|
||||
>
|
||||
Feedback
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border"></div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
|
||||
>
|
||||
Discord
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border"></div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
|
||||
>
|
||||
Fund
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(!internalLoggedIn && (!loading || !getCookie("jwt"))) && (
|
||||
<>
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "login"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTab("login")}
|
||||
aria-selected={tab === "login"}
|
||||
disabled={loading || firstUser}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "signup"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTab("signup")}
|
||||
aria-selected={tab === "signup"}
|
||||
disabled={loading || !registrationAllowed}
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{tab === "login" ? "Login to your account" : "Create a new account"}
|
||||
</h2>
|
||||
</div>
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={e => setLocalUsername(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" required className="h-11 text-base"
|
||||
value={password} onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}>
|
||||
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
onClick={() => setTab("signup")}
|
||||
aria-selected={tab === "signup"}
|
||||
disabled={loading || !registrationAllowed}
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{tab === "login" ? "Login to your account" : "Create a new account"}
|
||||
</h2>
|
||||
</div>
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={e => setLocalUsername(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" required className="h-11 text-base" value={password} onChange={e => setPassword(e.target.value)} disabled={loading || internalLoggedIn} />
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold" disabled={loading || internalLoggedIn}>
|
||||
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,7 +69,13 @@ const API = axios.create({
|
||||
baseURL: apiBase,
|
||||
});
|
||||
|
||||
export function HomepageSidebar({onSelectView, getView, disabled, isAdmin, username}: SidebarProps): React.ReactElement {
|
||||
export function HomepageSidebar({
|
||||
onSelectView,
|
||||
getView,
|
||||
disabled,
|
||||
isAdmin,
|
||||
username
|
||||
}: SidebarProps): React.ReactElement {
|
||||
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
|
||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||
const [regLoading, setRegLoading] = React.useState(false);
|
||||
@@ -109,14 +115,16 @@ export function HomepageSidebar({onSelectView, getView, disabled, isAdmin, usern
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem key={"SSH Manager"}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("ssh_manager")} disabled={disabled}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("ssh_manager")}
|
||||
disabled={disabled}>
|
||||
<HardDrive/>
|
||||
<span>SSH Manager</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<div className="ml-5">
|
||||
<SidebarMenuItem key={"Terminal"}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("terminal")} disabled={disabled}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("terminal")}
|
||||
disabled={disabled}>
|
||||
<Computer/>
|
||||
<span>Terminal</span>
|
||||
</SidebarMenuButton>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, {useState, useEffect} from "react";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { loadLanguage } from '@uiw/codemirror-extensions-langs';
|
||||
import { hyperLink } from '@uiw/codemirror-extensions-hyper-link';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import {loadLanguage} from '@uiw/codemirror-extensions-langs';
|
||||
import {hyperLink} from '@uiw/codemirror-extensions-hyper-link';
|
||||
import {oneDark} from '@codemirror/theme-one-dark';
|
||||
import {EditorView} from '@codemirror/view';
|
||||
|
||||
interface ConfigCodeEditorProps {
|
||||
content: string;
|
||||
@@ -14,160 +14,285 @@ interface ConfigCodeEditorProps {
|
||||
export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCodeEditorProps) {
|
||||
function getLanguageName(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return 'text'; // Default to text if no filename
|
||||
return 'text';
|
||||
}
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
if (lastDotIndex === -1) {
|
||||
return 'text'; // No extension found
|
||||
return 'text';
|
||||
}
|
||||
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
|
||||
|
||||
switch (ext) {
|
||||
case 'ng': return 'angular';
|
||||
case 'apl': return 'apl';
|
||||
case 'asc': return 'asciiArmor';
|
||||
case 'ast': return 'asterisk';
|
||||
case 'bf': return 'brainfuck';
|
||||
case 'c': return 'c';
|
||||
case 'ceylon': return 'ceylon';
|
||||
case 'clj': return 'clojure';
|
||||
case 'cmake': return 'cmake';
|
||||
case 'ng':
|
||||
return 'angular';
|
||||
case 'apl':
|
||||
return 'apl';
|
||||
case 'asc':
|
||||
return 'asciiArmor';
|
||||
case 'ast':
|
||||
return 'asterisk';
|
||||
case 'bf':
|
||||
return 'brainfuck';
|
||||
case 'c':
|
||||
return 'c';
|
||||
case 'ceylon':
|
||||
return 'ceylon';
|
||||
case 'clj':
|
||||
return 'clojure';
|
||||
case 'cmake':
|
||||
return 'cmake';
|
||||
case 'cob':
|
||||
case 'cbl': return 'cobol';
|
||||
case 'coffee': return 'coffeescript';
|
||||
case 'lisp': return 'commonLisp';
|
||||
case 'cbl':
|
||||
return 'cobol';
|
||||
case 'coffee':
|
||||
return 'coffeescript';
|
||||
case 'lisp':
|
||||
return 'commonLisp';
|
||||
case 'cpp':
|
||||
case 'cc':
|
||||
case 'cxx': return 'cpp';
|
||||
case 'cr': return 'crystal';
|
||||
case 'cs': return 'csharp';
|
||||
case 'css': return 'css';
|
||||
case 'cypher': return 'cypher';
|
||||
case 'd': return 'd';
|
||||
case 'dart': return 'dart';
|
||||
case 'cxx':
|
||||
return 'cpp';
|
||||
case 'cr':
|
||||
return 'crystal';
|
||||
case 'cs':
|
||||
return 'csharp';
|
||||
case 'css':
|
||||
return 'css';
|
||||
case 'cypher':
|
||||
return 'cypher';
|
||||
case 'd':
|
||||
return 'd';
|
||||
case 'dart':
|
||||
return 'dart';
|
||||
case 'diff':
|
||||
case 'patch': return 'diff';
|
||||
case 'dockerfile': return 'dockerfile';
|
||||
case 'dtd': return 'dtd';
|
||||
case 'dylan': return 'dylan';
|
||||
case 'ebnf': return 'ebnf';
|
||||
case 'ecl': return 'ecl';
|
||||
case 'eiffel': return 'eiffel';
|
||||
case 'elm': return 'elm';
|
||||
case 'erl': return 'erlang';
|
||||
case 'factor': return 'factor';
|
||||
case 'fcl': return 'fcl';
|
||||
case 'fs': return 'forth';
|
||||
case 'patch':
|
||||
return 'diff';
|
||||
case 'dockerfile':
|
||||
return 'dockerfile';
|
||||
case 'dtd':
|
||||
return 'dtd';
|
||||
case 'dylan':
|
||||
return 'dylan';
|
||||
case 'ebnf':
|
||||
return 'ebnf';
|
||||
case 'ecl':
|
||||
return 'ecl';
|
||||
case 'eiffel':
|
||||
return 'eiffel';
|
||||
case 'elm':
|
||||
return 'elm';
|
||||
case 'erl':
|
||||
return 'erlang';
|
||||
case 'factor':
|
||||
return 'factor';
|
||||
case 'fcl':
|
||||
return 'fcl';
|
||||
case 'fs':
|
||||
return 'forth';
|
||||
case 'f90':
|
||||
case 'for': return 'fortran';
|
||||
case 's': return 'gas';
|
||||
case 'feature': return 'gherkin';
|
||||
case 'go': return 'go';
|
||||
case 'groovy': return 'groovy';
|
||||
case 'hs': return 'haskell';
|
||||
case 'hx': return 'haxe';
|
||||
case 'for':
|
||||
return 'fortran';
|
||||
case 's':
|
||||
return 'gas';
|
||||
case 'feature':
|
||||
return 'gherkin';
|
||||
case 'go':
|
||||
return 'go';
|
||||
case 'groovy':
|
||||
return 'groovy';
|
||||
case 'hs':
|
||||
return 'haskell';
|
||||
case 'hx':
|
||||
return 'haxe';
|
||||
case 'html':
|
||||
case 'htm': return 'html';
|
||||
case 'http': return 'http';
|
||||
case 'idl': return 'idl';
|
||||
case 'java': return 'java';
|
||||
case 'htm':
|
||||
return 'html';
|
||||
case 'http':
|
||||
return 'http';
|
||||
case 'idl':
|
||||
return 'idl';
|
||||
case 'java':
|
||||
return 'java';
|
||||
case 'js':
|
||||
case 'mjs':
|
||||
case 'cjs': return 'javascript';
|
||||
case 'cjs':
|
||||
return 'javascript';
|
||||
case 'jinja2':
|
||||
case 'j2': return 'jinja2';
|
||||
case 'json': return 'json';
|
||||
case 'jsx': return 'jsx';
|
||||
case 'jl': return 'julia';
|
||||
case 'j2':
|
||||
return 'jinja2';
|
||||
case 'json':
|
||||
return 'json';
|
||||
case 'jsx':
|
||||
return 'jsx';
|
||||
case 'jl':
|
||||
return 'julia';
|
||||
case 'kt':
|
||||
case 'kts': return 'kotlin';
|
||||
case 'less': return 'less';
|
||||
case 'lezer': return 'lezer';
|
||||
case 'liquid': return 'liquid';
|
||||
case 'litcoffee': return 'livescript';
|
||||
case 'lua': return 'lua';
|
||||
case 'md': return 'markdown';
|
||||
case 'kts':
|
||||
return 'kotlin';
|
||||
case 'less':
|
||||
return 'less';
|
||||
case 'lezer':
|
||||
return 'lezer';
|
||||
case 'liquid':
|
||||
return 'liquid';
|
||||
case 'litcoffee':
|
||||
return 'livescript';
|
||||
case 'lua':
|
||||
return 'lua';
|
||||
case 'md':
|
||||
return 'markdown';
|
||||
case 'nb':
|
||||
case 'mat': return 'mathematica';
|
||||
case 'mbox': return 'mbox';
|
||||
case 'mmd': return 'mermaid';
|
||||
case 'mrc': return 'mirc';
|
||||
case 'moo': return 'modelica';
|
||||
case 'mscgen': return 'mscgen';
|
||||
case 'm': return 'mumps';
|
||||
case 'sql': return 'mysql';
|
||||
case 'nc': return 'nesC';
|
||||
case 'nginx': return 'nginx';
|
||||
case 'nix': return 'nix';
|
||||
case 'nsi': return 'nsis';
|
||||
case 'nt': return 'ntriples';
|
||||
case 'mm': return 'objectiveCpp';
|
||||
case 'octave': return 'octave';
|
||||
case 'oz': return 'oz';
|
||||
case 'pas': return 'pascal';
|
||||
case 'mat':
|
||||
return 'mathematica';
|
||||
case 'mbox':
|
||||
return 'mbox';
|
||||
case 'mmd':
|
||||
return 'mermaid';
|
||||
case 'mrc':
|
||||
return 'mirc';
|
||||
case 'moo':
|
||||
return 'modelica';
|
||||
case 'mscgen':
|
||||
return 'mscgen';
|
||||
case 'm':
|
||||
return 'mumps';
|
||||
case 'sql':
|
||||
return 'mysql';
|
||||
case 'nc':
|
||||
return 'nesC';
|
||||
case 'nginx':
|
||||
return 'nginx';
|
||||
case 'nix':
|
||||
return 'nix';
|
||||
case 'nsi':
|
||||
return 'nsis';
|
||||
case 'nt':
|
||||
return 'ntriples';
|
||||
case 'mm':
|
||||
return 'objectiveCpp';
|
||||
case 'octave':
|
||||
return 'octave';
|
||||
case 'oz':
|
||||
return 'oz';
|
||||
case 'pas':
|
||||
return 'pascal';
|
||||
case 'pl':
|
||||
case 'pm': return 'perl';
|
||||
case 'pgsql': return 'pgsql';
|
||||
case 'php': return 'php';
|
||||
case 'pig': return 'pig';
|
||||
case 'ps1': return 'powershell';
|
||||
case 'properties': return 'properties';
|
||||
case 'proto': return 'protobuf';
|
||||
case 'pp': return 'puppet';
|
||||
case 'py': return 'python';
|
||||
case 'q': return 'q';
|
||||
case 'r': return 'r';
|
||||
case 'rb': return 'ruby';
|
||||
case 'rs': return 'rust';
|
||||
case 'sas': return 'sas';
|
||||
case 'pm':
|
||||
return 'perl';
|
||||
case 'pgsql':
|
||||
return 'pgsql';
|
||||
case 'php':
|
||||
return 'php';
|
||||
case 'pig':
|
||||
return 'pig';
|
||||
case 'ps1':
|
||||
return 'powershell';
|
||||
case 'properties':
|
||||
return 'properties';
|
||||
case 'proto':
|
||||
return 'protobuf';
|
||||
case 'pp':
|
||||
return 'puppet';
|
||||
case 'py':
|
||||
return 'python';
|
||||
case 'q':
|
||||
return 'q';
|
||||
case 'r':
|
||||
return 'r';
|
||||
case 'rb':
|
||||
return 'ruby';
|
||||
case 'rs':
|
||||
return 'rust';
|
||||
case 'sas':
|
||||
return 'sas';
|
||||
case 'sass':
|
||||
case 'scss': return 'sass';
|
||||
case 'scala': return 'scala';
|
||||
case 'scm': return 'scheme';
|
||||
case 'shader': return 'shader';
|
||||
case 'scss':
|
||||
return 'sass';
|
||||
case 'scala':
|
||||
return 'scala';
|
||||
case 'scm':
|
||||
return 'scheme';
|
||||
case 'shader':
|
||||
return 'shader';
|
||||
case 'sh':
|
||||
case 'bash': return 'shell';
|
||||
case 'siv': return 'sieve';
|
||||
case 'st': return 'smalltalk';
|
||||
case 'sol': return 'solidity';
|
||||
case 'solr': return 'solr';
|
||||
case 'rq': return 'sparql';
|
||||
case 'bash':
|
||||
return 'shell';
|
||||
case 'siv':
|
||||
return 'sieve';
|
||||
case 'st':
|
||||
return 'smalltalk';
|
||||
case 'sol':
|
||||
return 'solidity';
|
||||
case 'solr':
|
||||
return 'solr';
|
||||
case 'rq':
|
||||
return 'sparql';
|
||||
case 'xlsx':
|
||||
case 'ods':
|
||||
case 'csv': return 'spreadsheet';
|
||||
case 'nut': return 'squirrel';
|
||||
case 'tex': return 'stex';
|
||||
case 'styl': return 'stylus';
|
||||
case 'svelte': return 'svelte';
|
||||
case 'swift': return 'swift';
|
||||
case 'tcl': return 'tcl';
|
||||
case 'textile': return 'textile';
|
||||
case 'tiddlywiki': return 'tiddlyWiki';
|
||||
case 'tiki': return 'tiki';
|
||||
case 'toml': return 'toml';
|
||||
case 'troff': return 'troff';
|
||||
case 'tsx': return 'tsx';
|
||||
case 'ttcn': return 'ttcn';
|
||||
case 'csv':
|
||||
return 'spreadsheet';
|
||||
case 'nut':
|
||||
return 'squirrel';
|
||||
case 'tex':
|
||||
return 'stex';
|
||||
case 'styl':
|
||||
return 'stylus';
|
||||
case 'svelte':
|
||||
return 'svelte';
|
||||
case 'swift':
|
||||
return 'swift';
|
||||
case 'tcl':
|
||||
return 'tcl';
|
||||
case 'textile':
|
||||
return 'textile';
|
||||
case 'tiddlywiki':
|
||||
return 'tiddlyWiki';
|
||||
case 'tiki':
|
||||
return 'tiki';
|
||||
case 'toml':
|
||||
return 'toml';
|
||||
case 'troff':
|
||||
return 'troff';
|
||||
case 'tsx':
|
||||
return 'tsx';
|
||||
case 'ttcn':
|
||||
return 'ttcn';
|
||||
case 'ttl':
|
||||
case 'turtle': return 'turtle';
|
||||
case 'ts': return 'typescript';
|
||||
case 'vb': return 'vb';
|
||||
case 'vbs': return 'vbscript';
|
||||
case 'vm': return 'velocity';
|
||||
case 'v': return 'verilog';
|
||||
case 'turtle':
|
||||
return 'turtle';
|
||||
case 'ts':
|
||||
return 'typescript';
|
||||
case 'vb':
|
||||
return 'vb';
|
||||
case 'vbs':
|
||||
return 'vbscript';
|
||||
case 'vm':
|
||||
return 'velocity';
|
||||
case 'v':
|
||||
return 'verilog';
|
||||
case 'vhd':
|
||||
case 'vhdl': return 'vhdl';
|
||||
case 'vue': return 'vue';
|
||||
case 'wat': return 'wast';
|
||||
case 'webidl': return 'webIDL';
|
||||
case 'vhdl':
|
||||
return 'vhdl';
|
||||
case 'vue':
|
||||
return 'vue';
|
||||
case 'wat':
|
||||
return 'wast';
|
||||
case 'webidl':
|
||||
return 'webIDL';
|
||||
case 'xq':
|
||||
case 'xquery': return 'xQuery';
|
||||
case 'xml': return 'xml';
|
||||
case 'yacas': return 'yacas';
|
||||
case 'xquery':
|
||||
return 'xQuery';
|
||||
case 'xml':
|
||||
return 'xml';
|
||||
case 'yacas':
|
||||
return 'yacas';
|
||||
case 'yaml':
|
||||
case 'yml': return 'yaml';
|
||||
case 'z80': return 'z80';
|
||||
default: return 'text';
|
||||
case 'yml':
|
||||
return 'yaml';
|
||||
case 'z80':
|
||||
return 'z80';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +304,14 @@ export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCod
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -209,8 +341,8 @@ export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCod
|
||||
onChange={(value: any) => onContentChange(value)}
|
||||
theme={undefined}
|
||||
height="100%"
|
||||
basicSetup={{ lineNumbers: true }}
|
||||
style={{ minHeight: '100%', minWidth: '100%', flex: 1 }}
|
||||
basicSetup={{lineNumbers: true}}
|
||||
style={{minHeight: '100%', minWidth: '100%', flex: 1}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { ConfigEditorSidebar } from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx";
|
||||
import { ConfigTabList } from "@/apps/SSH/Config Editor/ConfigTabList.tsx";
|
||||
import { ConfigHomeView } from "@/apps/SSH/Config Editor/ConfigHomeView.tsx";
|
||||
import { ConfigCodeEditor } from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx";
|
||||
import { Button } from '@/components/ui/button.tsx';
|
||||
import { ConfigTopbar } from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
|
||||
import { cn } from '@/lib/utils.ts';
|
||||
import React, {useState, useEffect, useRef} from "react";
|
||||
import {ConfigEditorSidebar} from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx";
|
||||
import {ConfigTabList} from "@/apps/SSH/Config Editor/ConfigTabList.tsx";
|
||||
import {ConfigHomeView} from "@/apps/SSH/Config Editor/ConfigHomeView.tsx";
|
||||
import {ConfigCodeEditor} from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx";
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {ConfigTopbar} from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
import {
|
||||
getConfigEditorRecent,
|
||||
getConfigEditorPinned,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
writeSSHFile,
|
||||
getSSHStatus,
|
||||
connectSSH
|
||||
} from '@/apps/SSH/ssh-axios-fixed.ts';
|
||||
} from '@/apps/SSH/ssh-axios.ts';
|
||||
|
||||
interface Tab {
|
||||
id: string | number;
|
||||
@@ -59,7 +59,7 @@ interface SSHHost {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) => void }): React.ReactElement {
|
||||
export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => void }): React.ReactElement {
|
||||
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<string | number>('home');
|
||||
const [recent, setRecent] = useState<any[]>([]);
|
||||
@@ -71,89 +71,71 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
|
||||
|
||||
const sidebarRef = useRef<any>(null);
|
||||
|
||||
// Fetch home data when host changes
|
||||
useEffect(() => {
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
} else {
|
||||
// Clear data when no host is selected
|
||||
setRecent([]);
|
||||
setPinned([]);
|
||||
setShortcuts([]);
|
||||
}
|
||||
}, [currentHost]);
|
||||
|
||||
// Refresh home data when switching to home view
|
||||
useEffect(() => {
|
||||
if (activeTab === 'home' && currentHost) {
|
||||
fetchHomeData();
|
||||
}
|
||||
}, [activeTab, currentHost]);
|
||||
|
||||
|
||||
|
||||
// Periodic refresh of home data when on home view
|
||||
useEffect(() => {
|
||||
if (activeTab === 'home' && currentHost) {
|
||||
const interval = setInterval(() => {
|
||||
fetchHomeData();
|
||||
}, 2000); // Refresh every 2 seconds when on home view
|
||||
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [activeTab, currentHost]);
|
||||
|
||||
async function fetchHomeData() {
|
||||
if (!currentHost) return;
|
||||
|
||||
|
||||
try {
|
||||
console.log('Fetching home data for host:', currentHost.id);
|
||||
|
||||
const homeDataPromise = Promise.all([
|
||||
getConfigEditorRecent(currentHost.id),
|
||||
getConfigEditorPinned(currentHost.id),
|
||||
getConfigEditorShortcuts(currentHost.id),
|
||||
]);
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Fetch home data timed out')), 15000)
|
||||
);
|
||||
|
||||
|
||||
const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]);
|
||||
|
||||
console.log('Home data fetched successfully:', {
|
||||
recentCount: recentRes?.length || 0,
|
||||
pinnedCount: pinnedRes?.length || 0,
|
||||
shortcutsCount: shortcutsRes?.length || 0
|
||||
});
|
||||
|
||||
// Process recent files to add isPinned property and type
|
||||
|
||||
const recentWithPinnedStatus = (recentRes || []).map(file => ({
|
||||
...file,
|
||||
type: 'file', // Assume all recent files are files, not directories
|
||||
isPinned: (pinnedRes || []).some(pinnedFile =>
|
||||
type: 'file',
|
||||
isPinned: (pinnedRes || []).some(pinnedFile =>
|
||||
pinnedFile.path === file.path && pinnedFile.name === file.name
|
||||
)
|
||||
}));
|
||||
|
||||
// Process pinned files to add type
|
||||
|
||||
const pinnedWithType = (pinnedRes || []).map(file => ({
|
||||
...file,
|
||||
type: 'file' // Assume all pinned files are files, not directories
|
||||
type: 'file'
|
||||
}));
|
||||
|
||||
|
||||
setRecent(recentWithPinnedStatus);
|
||||
setPinned(pinnedWithType);
|
||||
setShortcuts((shortcutsRes || []).map(shortcut => ({
|
||||
...shortcut,
|
||||
type: 'directory' // Shortcuts are always directories
|
||||
type: 'directory'
|
||||
})));
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch home data:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for consistent error handling
|
||||
const formatErrorMessage = (err: any, defaultMessage: string): string => {
|
||||
if (typeof err === 'object' && err !== null && 'response' in err) {
|
||||
const axiosErr = err as any;
|
||||
@@ -175,35 +157,41 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
|
||||
}
|
||||
};
|
||||
|
||||
// Home view actions
|
||||
const handleOpenFile = async (file: any) => {
|
||||
const tabId = file.path;
|
||||
console.log('Opening file:', { file, currentHost, tabId });
|
||||
|
||||
|
||||
if (!tabs.find(t => t.id === tabId)) {
|
||||
// Use the current host's SSH session ID instead of the stored one
|
||||
const currentSshSessionId = currentHost?.id.toString();
|
||||
console.log('Using SSH session ID:', currentSshSessionId, 'for file path:', file.path);
|
||||
|
||||
setTabs([...tabs, { id: tabId, title: file.name, fileName: file.name, content: '', filePath: file.path, isSSH: true, sshSessionId: currentSshSessionId, loading: true }]);
|
||||
|
||||
setTabs([...tabs, {
|
||||
id: tabId,
|
||||
title: file.name,
|
||||
fileName: file.name,
|
||||
content: '',
|
||||
filePath: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: currentSshSessionId,
|
||||
loading: true
|
||||
}]);
|
||||
try {
|
||||
const res = await readSSHFile(currentSshSessionId, file.path);
|
||||
console.log('File read successful:', { path: file.path, contentLength: res.content?.length });
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, content: res.content, loading: false, error: undefined } : t));
|
||||
// Mark as recent
|
||||
await addConfigEditorRecent({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {
|
||||
...t,
|
||||
content: res.content,
|
||||
loading: false,
|
||||
error: undefined
|
||||
} : t));
|
||||
await addConfigEditorRecent({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: currentSshSessionId,
|
||||
hostId: currentHost?.id
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
// Refresh immediately after opening file
|
||||
fetchHomeData();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to read file:', { path: file.path, sessionId: currentSshSessionId, error: err });
|
||||
const errorMessage = formatErrorMessage(err, 'Cannot read file');
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, loading: false, error: errorMessage } : t));
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false, error: errorMessage} : t));
|
||||
}
|
||||
}
|
||||
setActiveTab(tabId);
|
||||
@@ -211,128 +199,103 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
|
||||
|
||||
const handleRemoveRecent = async (file: any) => {
|
||||
try {
|
||||
await removeConfigEditorRecent({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
await removeConfigEditorRecent({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: file.sshSessionId,
|
||||
hostId: currentHost?.id
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
// Refresh immediately after removing
|
||||
fetchHomeData();
|
||||
} catch (err) {
|
||||
console.error('Failed to remove recent file:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePinFile = async (file: any) => {
|
||||
try {
|
||||
await addConfigEditorPinned({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
await addConfigEditorPinned({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: file.sshSessionId,
|
||||
hostId: currentHost?.id
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
// Refresh immediately after pinning
|
||||
fetchHomeData();
|
||||
// Refresh sidebar files to update pin states immediately
|
||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
||||
sidebarRef.current.fetchFiles();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to pin file:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpinFile = async (file: any) => {
|
||||
try {
|
||||
await removeConfigEditorPinned({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
await removeConfigEditorPinned({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: file.sshSessionId,
|
||||
hostId: currentHost?.id
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
// Refresh immediately after unpinning
|
||||
fetchHomeData();
|
||||
// Refresh sidebar files to update pin states immediately
|
||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
||||
sidebarRef.current.fetchFiles();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to unpin file:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenShortcut = async (shortcut: any) => {
|
||||
console.log('Opening shortcut:', { shortcut, currentHost });
|
||||
|
||||
// Prevent multiple rapid clicks
|
||||
if (sidebarRef.current?.isOpeningShortcut) {
|
||||
console.log('Shortcut opening already in progress, ignoring click');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (sidebarRef.current && sidebarRef.current.openFolder) {
|
||||
try {
|
||||
// Set flag to prevent multiple simultaneous opens
|
||||
sidebarRef.current.isOpeningShortcut = true;
|
||||
|
||||
// Normalize the path to ensure it starts with /
|
||||
|
||||
const normalizedPath = shortcut.path.startsWith('/') ? shortcut.path : `/${shortcut.path}`;
|
||||
console.log('Normalized path:', normalizedPath);
|
||||
|
||||
|
||||
await sidebarRef.current.openFolder(currentHost, normalizedPath);
|
||||
console.log('Shortcut opened successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to open shortcut:', err);
|
||||
// Could show error to user here if needed
|
||||
} finally {
|
||||
// Clear flag after operation completes
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.isOpeningShortcut = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('Sidebar ref or openFolder function not available');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddShortcut = async (folderPath: string) => {
|
||||
try {
|
||||
const name = folderPath.split('/').pop() || folderPath;
|
||||
await addConfigEditorShortcut({
|
||||
name,
|
||||
path: folderPath,
|
||||
isSSH: true,
|
||||
await addConfigEditorShortcut({
|
||||
name,
|
||||
path: folderPath,
|
||||
isSSH: true,
|
||||
sshSessionId: currentHost?.id.toString(),
|
||||
hostId: currentHost?.id
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
// Refresh immediately after adding shortcut
|
||||
fetchHomeData();
|
||||
} catch (err) {
|
||||
console.error('Failed to add shortcut:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveShortcut = async (shortcut: any) => {
|
||||
try {
|
||||
await removeConfigEditorShortcut({
|
||||
name: shortcut.name,
|
||||
path: shortcut.path,
|
||||
isSSH: true,
|
||||
await removeConfigEditorShortcut({
|
||||
name: shortcut.name,
|
||||
path: shortcut.path,
|
||||
isSSH: true,
|
||||
sshSessionId: currentHost?.id.toString(),
|
||||
hostId: currentHost?.id
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
// Refresh immediately after removing shortcut
|
||||
fetchHomeData();
|
||||
} catch (err) {
|
||||
console.error('Failed to remove shortcut:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Tab actions
|
||||
const closeTab = (tabId: string | number) => {
|
||||
const idx = tabs.findIndex(t => t.id === tabId);
|
||||
const newTabs = tabs.filter(t => t.id !== tabId);
|
||||
@@ -341,59 +304,50 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
|
||||
if (newTabs.length > 0) setActiveTab(newTabs[Math.max(0, idx - 1)].id);
|
||||
else setActiveTab('home');
|
||||
}
|
||||
// Refresh home data when closing tabs to update recent list
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
}
|
||||
};
|
||||
|
||||
const setTabContent = (tabId: string | number, content: string) => {
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, content, dirty: true, error: undefined, success: undefined } : t));
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {
|
||||
...t,
|
||||
content,
|
||||
dirty: true,
|
||||
error: undefined,
|
||||
success: undefined
|
||||
} : t));
|
||||
};
|
||||
|
||||
const handleSave = async (tab: Tab) => {
|
||||
// Prevent multiple simultaneous saves
|
||||
if (isSaving) {
|
||||
console.log('Save already in progress, ignoring save request');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
|
||||
try {
|
||||
console.log('Saving file:', {
|
||||
tabId: tab.id,
|
||||
fileName: tab.fileName,
|
||||
filePath: tab.filePath,
|
||||
sshSessionId: tab.sshSessionId,
|
||||
contentLength: tab.content?.length,
|
||||
currentHost: currentHost?.id
|
||||
});
|
||||
|
||||
if (!tab.sshSessionId) {
|
||||
throw new Error('No SSH session ID available');
|
||||
}
|
||||
|
||||
|
||||
if (!tab.filePath) {
|
||||
throw new Error('No file path available');
|
||||
}
|
||||
|
||||
|
||||
if (!currentHost?.id) {
|
||||
throw new Error('No current host available');
|
||||
}
|
||||
|
||||
// Check SSH connection status first with timeout
|
||||
|
||||
try {
|
||||
const statusPromise = getSSHStatus(tab.sshSessionId);
|
||||
const statusTimeoutPromise = new Promise((_, reject) =>
|
||||
const statusTimeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SSH status check timed out')), 10000)
|
||||
);
|
||||
|
||||
|
||||
const status = await Promise.race([statusPromise, statusTimeoutPromise]);
|
||||
|
||||
|
||||
if (!status.connected) {
|
||||
console.log('SSH session disconnected, attempting to reconnect...');
|
||||
// Try to reconnect using current host credentials with timeout
|
||||
const connectPromise = connectSSH(tab.sshSessionId, {
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
@@ -402,119 +356,107 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
|
||||
sshKey: currentHost.key,
|
||||
keyPassword: currentHost.keyPassword
|
||||
});
|
||||
const connectTimeoutPromise = new Promise((_, reject) =>
|
||||
const connectTimeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SSH reconnection timed out')), 15000)
|
||||
);
|
||||
|
||||
|
||||
await Promise.race([connectPromise, connectTimeoutPromise]);
|
||||
console.log('SSH reconnection successful');
|
||||
}
|
||||
} catch (statusErr) {
|
||||
console.warn('Could not check SSH status or reconnect, proceeding with save attempt:', statusErr);
|
||||
}
|
||||
|
||||
// Add timeout to prevent hanging
|
||||
console.log('Starting save operation with 30 second timeout...');
|
||||
|
||||
const savePromise = writeSSHFile(tab.sshSessionId, tab.filePath, tab.content);
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => {
|
||||
console.log('Save operation timed out after 30 seconds');
|
||||
reject(new Error('Save operation timed out'));
|
||||
}, 30000)
|
||||
);
|
||||
|
||||
|
||||
const result = await Promise.race([savePromise, timeoutPromise]);
|
||||
console.log('Save operation completed successfully:', result);
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, dirty: false, success: 'File saved successfully' } : t));
|
||||
console.log('File saved successfully - main save operation complete');
|
||||
|
||||
// Auto-hide success message after 3 seconds
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
dirty: false,
|
||||
success: 'File saved successfully'
|
||||
} : t));
|
||||
|
||||
setTimeout(() => {
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, success: undefined } : t));
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {...t, success: undefined} : t));
|
||||
}, 3000);
|
||||
|
||||
// Mark as recent and refresh home data in background (non-blocking)
|
||||
|
||||
Promise.allSettled([
|
||||
(async () => {
|
||||
try {
|
||||
console.log('Adding file to recent...');
|
||||
await addConfigEditorRecent({
|
||||
name: tab.fileName,
|
||||
path: tab.filePath,
|
||||
isSSH: true,
|
||||
await addConfigEditorRecent({
|
||||
name: tab.fileName,
|
||||
path: tab.filePath,
|
||||
isSSH: true,
|
||||
sshSessionId: tab.sshSessionId,
|
||||
hostId: currentHost.id
|
||||
hostId: currentHost.id
|
||||
});
|
||||
console.log('File added to recent successfully');
|
||||
} catch (recentErr) {
|
||||
console.warn('Failed to add file to recent:', recentErr);
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
try {
|
||||
console.log('Refreshing home data...');
|
||||
await fetchHomeData();
|
||||
console.log('Home data refreshed successfully');
|
||||
} catch (refreshErr) {
|
||||
console.warn('Failed to refresh home data:', refreshErr);
|
||||
}
|
||||
})()
|
||||
]).then(() => {
|
||||
console.log('Background operations completed');
|
||||
});
|
||||
|
||||
console.log('File saved successfully - main operation complete, background operations started');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to save file:', err);
|
||||
|
||||
let errorMessage = formatErrorMessage(err, 'Cannot save file');
|
||||
|
||||
// Check if this is a timeout error (which might mean the save actually worked)
|
||||
|
||||
if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) {
|
||||
errorMessage = `Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.`;
|
||||
}
|
||||
|
||||
console.log('Final error message:', errorMessage);
|
||||
|
||||
|
||||
setTabs(tabs => {
|
||||
const updatedTabs = tabs.map(t => t.id === tab.id ? { ...t, error: `Failed to save file: ${errorMessage}` } : t);
|
||||
console.log('Updated tabs with error:', updatedTabs.find(t => t.id === tab.id));
|
||||
const updatedTabs = tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
error: `Failed to save file: ${errorMessage}`
|
||||
} : t);
|
||||
return updatedTabs;
|
||||
});
|
||||
|
||||
// Force a re-render to ensure error is displayed
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('Forcing re-render to show error');
|
||||
setTabs(currentTabs => [...currentTabs]);
|
||||
}, 100);
|
||||
} finally {
|
||||
console.log('Save operation completed, setting isSaving to false');
|
||||
setIsSaving(false);
|
||||
console.log('isSaving state after setting to false:', false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHostChange = (host: SSHHost | null) => {
|
||||
setCurrentHost(host);
|
||||
// Close all tabs when switching hosts
|
||||
setTabs([]);
|
||||
setActiveTab('home');
|
||||
};
|
||||
|
||||
// Show connection message when no host is selected
|
||||
if (!currentHost) {
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20 }}>
|
||||
<ConfigEditorSidebar
|
||||
onSelectView={onSelectView}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
|
||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
|
||||
<ConfigEditorSidebar
|
||||
onSelectView={onSelectView}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
ref={sidebarRef}
|
||||
onHostChange={handleHostChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ position: 'absolute', top: 0, left: 256, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#09090b' }}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 256,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#09090b'
|
||||
}}>
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Connect to a Server</h2>
|
||||
<p className="text-muted-foreground">Select a server from the sidebar to start editing files</p>
|
||||
@@ -525,29 +467,31 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20 }}>
|
||||
<ConfigEditorSidebar
|
||||
onSelectView={onSelectView}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
|
||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
|
||||
<ConfigEditorSidebar
|
||||
onSelectView={onSelectView}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
ref={sidebarRef}
|
||||
onHostChange={handleHostChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ position: 'absolute', top: 0, left: 256, right: 0, height: 44, zIndex: 30 }}>
|
||||
<div className="flex items-center w-full bg-[#18181b] border-b border-[#222224] h-11 relative px-4" style={{ height: 44 }}>
|
||||
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 44, zIndex: 30}}>
|
||||
<div className="flex items-center w-full bg-[#18181b] border-b border-[#222224] h-11 relative px-4"
|
||||
style={{height: 44}}>
|
||||
{/* Tab list scrollable area */}
|
||||
<div className="flex-1 min-w-0 h-full flex items-center">
|
||||
<div className="h-9 w-full bg-[#09090b] border border-[#23232a] rounded-md flex items-center overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent" style={{ minWidth: 0 }}>
|
||||
<div
|
||||
className="h-9 w-full bg-[#09090b] border border-[#23232a] rounded-md flex items-center overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
|
||||
style={{minWidth: 0}}>
|
||||
<ConfigTopbar
|
||||
tabs={tabs.map(t => ({ id: t.id, title: t.title }))}
|
||||
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
closeTab={closeTab}
|
||||
onHomeClick={() => {
|
||||
setActiveTab('home');
|
||||
// Immediately refresh home data when clicking home
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
}
|
||||
@@ -568,13 +512,24 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
|
||||
if (tab && !isSaving) handleSave(tab);
|
||||
}}
|
||||
type="button"
|
||||
style={{ height: 36, alignSelf: 'center' }}
|
||||
style={{height: 36, alignSelf: 'center'}}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ position: 'absolute', top: 44, left: 256, right: 0, bottom: 0, overflow: 'hidden', zIndex: 10, background: '#101014', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 44,
|
||||
left: 256,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
zIndex: 10,
|
||||
background: '#101014',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{activeTab === 'home' ? (
|
||||
<ConfigHomeView
|
||||
recent={recent}
|
||||
@@ -593,17 +548,21 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
|
||||
const tab = tabs.find(t => t.id === activeTab);
|
||||
if (!tab) return null;
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
|
||||
{/* Error display */}
|
||||
{tab.error && (
|
||||
<div className="bg-red-900/20 border border-red-500/30 text-red-300 px-4 py-3 text-sm">
|
||||
<div
|
||||
className="bg-red-900/20 border border-red-500/30 text-red-300 px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-red-400">⚠️</span>
|
||||
<span>{tab.error}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, error: undefined } : t))}
|
||||
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
error: undefined
|
||||
} : t))}
|
||||
className="text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
✕
|
||||
@@ -613,14 +572,18 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) =>
|
||||
)}
|
||||
{/* Success display */}
|
||||
{tab.success && (
|
||||
<div className="bg-green-900/20 border border-green-500/30 text-green-300 px-4 py-3 text-sm">
|
||||
<div
|
||||
className="bg-green-900/20 border border-green-500/30 text-green-300 px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-400">✓</span>
|
||||
<span>{tab.success}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, success: undefined } : t))}
|
||||
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
success: undefined
|
||||
} : t))}
|
||||
className="text-green-400 hover:text-green-300 transition-colors"
|
||||
>
|
||||
✕
|
||||
|
||||
@@ -10,19 +10,19 @@ import {
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react';
|
||||
import {ScrollArea} from '@/components/ui/scroll-area.tsx';
|
||||
import { cn } from '@/lib/utils.ts';
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx';
|
||||
import {
|
||||
getSSHHosts,
|
||||
listSSHFiles,
|
||||
connectSSH,
|
||||
import {
|
||||
getSSHHosts,
|
||||
listSSHFiles,
|
||||
connectSSH,
|
||||
getSSHStatus,
|
||||
getConfigEditorPinned,
|
||||
addConfigEditorPinned,
|
||||
removeConfigEditorPinned
|
||||
} from '@/apps/SSH/ssh-axios-fixed.ts';
|
||||
} from '@/apps/SSH/ssh-axios.ts';
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -48,9 +48,9 @@ interface SSHHost {
|
||||
}
|
||||
|
||||
const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
{ onSelectView, onOpenFile, tabs, onHostChange }: {
|
||||
onSelectView: (view: string) => void;
|
||||
onOpenFile: (file: any) => void;
|
||||
{onSelectView, onOpenFile, tabs, onHostChange}: {
|
||||
onSelectView: (view: string) => void;
|
||||
onOpenFile: (file: any) => void;
|
||||
tabs: any[];
|
||||
onHostChange?: (host: SSHHost | null) => void;
|
||||
},
|
||||
@@ -64,8 +64,7 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [files, setFiles] = useState<any[]>([]);
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Add search bar state
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [fileSearch, setFileSearch] = useState('');
|
||||
@@ -79,12 +78,14 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
return () => clearTimeout(handler);
|
||||
}, [fileSearch]);
|
||||
|
||||
// Add state for SSH sessionId and loading/error
|
||||
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [filesError, setFilesError] = useState<string | null>(null);
|
||||
const [connectingSSH, setConnectingSSH] = useState(false);
|
||||
const [connectionCache, setConnectionCache] = useState<Record<string, { sessionId: string; timestamp: number }>>({});
|
||||
const [connectionCache, setConnectionCache] = useState<Record<string, {
|
||||
sessionId: string;
|
||||
timestamp: number
|
||||
}>>({});
|
||||
const [fetchingFiles, setFetchingFiles] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -96,76 +97,41 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
setErrorSSH(undefined);
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
console.log('Loaded SSH hosts:', hosts);
|
||||
// Filter hosts to only show those with enableConfigEditor: true
|
||||
const configEditorHosts = hosts.filter(host => host.enableConfigEditor);
|
||||
console.log('Config Editor hosts:', configEditorHosts);
|
||||
|
||||
// Debug: Log the first host's credentials
|
||||
|
||||
if (configEditorHosts.length > 0) {
|
||||
const firstHost = configEditorHosts[0];
|
||||
console.log('First host credentials:', {
|
||||
id: firstHost.id,
|
||||
name: firstHost.name,
|
||||
ip: firstHost.ip,
|
||||
username: firstHost.username,
|
||||
authType: firstHost.authType,
|
||||
hasPassword: !!firstHost.password,
|
||||
hasKey: !!firstHost.key,
|
||||
passwordLength: firstHost.password?.length,
|
||||
keyLength: firstHost.key?.length
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
setSSHConnections(configEditorHosts);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load SSH hosts:', err);
|
||||
setErrorSSH('Failed to load SSH connections');
|
||||
} finally {
|
||||
setLoadingSSH(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to connect to SSH and set sessionId
|
||||
async function connectToSSH(server: SSHHost): Promise<string | null> {
|
||||
const sessionId = server.id.toString();
|
||||
|
||||
// Check if we already have a recent connection to this server
|
||||
|
||||
const cached = connectionCache[sessionId];
|
||||
if (cached && Date.now() - cached.timestamp < 30000) { // 30 second cache
|
||||
console.log('Using cached SSH connection for session:', sessionId);
|
||||
if (cached && Date.now() - cached.timestamp < 30000) {
|
||||
setSshSessionId(cached.sessionId);
|
||||
return cached.sessionId;
|
||||
}
|
||||
|
||||
// Prevent multiple simultaneous connections
|
||||
|
||||
if (connectingSSH) {
|
||||
console.log('SSH connection already in progress, skipping...');
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
setConnectingSSH(true);
|
||||
|
||||
|
||||
try {
|
||||
console.log('Attempting SSH connection:', {
|
||||
sessionId,
|
||||
ip: server.ip,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
hasPassword: !!server.password,
|
||||
hasKey: !!server.key,
|
||||
authType: server.authType,
|
||||
passwordLength: server.password?.length,
|
||||
keyLength: server.key?.length
|
||||
});
|
||||
|
||||
// Check if we have the necessary credentials
|
||||
if (!server.password && !server.key) {
|
||||
console.error('No authentication credentials available for SSH host');
|
||||
setFilesError('No authentication credentials available for this SSH host');
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const connectionConfig = {
|
||||
ip: server.ip,
|
||||
port: server.port,
|
||||
@@ -174,32 +140,18 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
sshKey: server.key,
|
||||
keyPassword: server.keyPassword,
|
||||
};
|
||||
|
||||
console.log('SSH connection config:', {
|
||||
...connectionConfig,
|
||||
password: connectionConfig.password ? '[REDACTED]' : undefined,
|
||||
sshKey: connectionConfig.sshKey ? '[REDACTED]' : undefined
|
||||
});
|
||||
|
||||
|
||||
await connectSSH(sessionId, connectionConfig);
|
||||
|
||||
console.log('SSH connection successful for session:', sessionId);
|
||||
|
||||
setSshSessionId(sessionId);
|
||||
|
||||
// Cache the successful connection
|
||||
|
||||
setConnectionCache(prev => ({
|
||||
...prev,
|
||||
[sessionId]: { sessionId, timestamp: Date.now() }
|
||||
[sessionId]: {sessionId, timestamp: Date.now()}
|
||||
}));
|
||||
|
||||
|
||||
return sessionId;
|
||||
} catch (err: any) {
|
||||
console.error('SSH connection failed:', {
|
||||
sessionId,
|
||||
error: err?.response?.data?.error || err?.message,
|
||||
status: err?.response?.status,
|
||||
data: err?.response?.data
|
||||
});
|
||||
setFilesError(err?.response?.data?.error || 'Failed to connect to SSH');
|
||||
setSshSessionId(null);
|
||||
return null;
|
||||
@@ -208,72 +160,51 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
}
|
||||
}
|
||||
|
||||
// Modified fetchFiles to handle SSH connect if needed
|
||||
async function fetchFiles() {
|
||||
// Prevent multiple simultaneous fetches
|
||||
if (fetchingFiles) {
|
||||
console.log('Already fetching files, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setFetchingFiles(true);
|
||||
setFiles([]);
|
||||
setFilesLoading(true);
|
||||
setFilesError(null);
|
||||
|
||||
|
||||
try {
|
||||
// Get pinned files to check against for current host
|
||||
let pinnedFiles: any[] = [];
|
||||
try {
|
||||
if (activeServer) {
|
||||
pinnedFiles = await getConfigEditorPinned(activeServer.id);
|
||||
console.log('Fetched pinned files:', pinnedFiles);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch pinned files:', err);
|
||||
}
|
||||
|
||||
|
||||
if (activeServer && sshSessionId) {
|
||||
console.log('Fetching files for path:', currentPath, 'sessionId:', sshSessionId);
|
||||
|
||||
let res: any[] = [];
|
||||
|
||||
// Check if SSH session is still valid
|
||||
|
||||
try {
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
console.log('SSH session status:', status);
|
||||
if (!status.connected) {
|
||||
console.log('SSH session not connected, reconnecting...');
|
||||
const newSessionId = await connectToSSH(activeServer);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
// Retry with new session
|
||||
res = await listSSHFiles(newSessionId, currentPath);
|
||||
console.log('Retry - Raw SSH files response:', res);
|
||||
console.log('Retry - Files count:', res?.length || 0);
|
||||
} else {
|
||||
throw new Error('Failed to reconnect SSH session');
|
||||
}
|
||||
} else {
|
||||
res = await listSSHFiles(sshSessionId, currentPath);
|
||||
console.log('Raw SSH files response:', res);
|
||||
console.log('Files count:', res?.length || 0);
|
||||
console.log('Response type:', typeof res, 'Is array:', Array.isArray(res));
|
||||
}
|
||||
} catch (sessionErr) {
|
||||
console.error('SSH session check failed:', sessionErr);
|
||||
// Try to reconnect and retry
|
||||
const newSessionId = await connectToSSH(activeServer);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
res = await listSSHFiles(newSessionId, currentPath);
|
||||
console.log('Reconnect - Raw SSH files response:', res);
|
||||
console.log('Reconnect - Files count:', res?.length || 0);
|
||||
} else {
|
||||
throw sessionErr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const processedFiles = (res || []).map((f: any) => {
|
||||
const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name;
|
||||
const isPinned = pinnedFiles.some(pinned => pinned.path === filePath);
|
||||
@@ -285,18 +216,10 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
sshSessionId: sshSessionId
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Processed files with pin states:', processedFiles);
|
||||
|
||||
setFiles(processedFiles);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error in fetchFiles:', err);
|
||||
console.error('Error details:', {
|
||||
message: err?.message,
|
||||
response: err?.response?.data,
|
||||
status: err?.response?.status,
|
||||
statusText: err?.response?.statusText
|
||||
});
|
||||
setFiles([]);
|
||||
setFilesError(err?.response?.data?.error || err?.message || 'Failed to list files');
|
||||
} finally {
|
||||
@@ -305,50 +228,37 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
}
|
||||
}
|
||||
|
||||
// When activeServer, currentPath, or sshSessionId changes, fetch files
|
||||
useEffect(() => {
|
||||
console.log('useEffect triggered:', { view, activeServer: !!activeServer, sshSessionId, currentPath });
|
||||
|
||||
// Only fetch files if we're in files view, have an active server, and a valid session
|
||||
if (view === 'files' && activeServer && sshSessionId && !connectingSSH && !fetchingFiles) {
|
||||
console.log('Calling fetchFiles...');
|
||||
// Add a small delay to prevent rapid reconnections
|
||||
const timeoutId = setTimeout(() => {
|
||||
fetchFiles();
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [currentPath, view, activeServer, sshSessionId]);
|
||||
|
||||
// When switching servers, reset sessionId and errors
|
||||
async function handleSelectServer(server: SSHHost) {
|
||||
// Prevent multiple rapid server selections
|
||||
if (connectingSSH) {
|
||||
console.log('SSH connection in progress, ignoring server selection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset all states when switching servers
|
||||
|
||||
setFetchingFiles(false);
|
||||
setFilesLoading(false);
|
||||
setFilesError(null);
|
||||
setFiles([]); // Clear files immediately to show loading state
|
||||
|
||||
setFiles([]);
|
||||
|
||||
setActiveServer(server);
|
||||
setCurrentPath(server.defaultPath || '/');
|
||||
setView('files');
|
||||
|
||||
// Establish SSH connection immediately when server is selected
|
||||
|
||||
const sessionId = await connectToSSH(server);
|
||||
if (sessionId) {
|
||||
setSshSessionId(sessionId);
|
||||
// Notify parent component about host change
|
||||
if (onHostChange) {
|
||||
onHostChange(server);
|
||||
}
|
||||
} else {
|
||||
// If SSH connection fails, stay in servers view
|
||||
w
|
||||
setView('servers');
|
||||
setActiveServer(null);
|
||||
}
|
||||
@@ -356,50 +266,36 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openFolder: async (server: SSHHost, path: string) => {
|
||||
console.log('openFolder called:', { serverId: server.id, path, currentPath, activeServerId: activeServer?.id });
|
||||
|
||||
// Prevent multiple simultaneous folder opens
|
||||
if (connectingSSH || fetchingFiles) {
|
||||
console.log('SSH connection or file fetch in progress, skipping folder open');
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're already on the same server and path, just refresh files
|
||||
|
||||
if (activeServer?.id === server.id && currentPath === path) {
|
||||
console.log('Already on same server and path, just refreshing files');
|
||||
// Add a small delay to prevent rapid successive calls
|
||||
setTimeout(() => fetchFiles(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset all states when opening a folder
|
||||
|
||||
setFetchingFiles(false);
|
||||
setFilesLoading(false);
|
||||
setFilesError(null);
|
||||
setFiles([]);
|
||||
|
||||
|
||||
setActiveServer(server);
|
||||
setCurrentPath(path);
|
||||
setView('files');
|
||||
|
||||
// Only establish SSH connection if we don't already have one for this server
|
||||
|
||||
if (!sshSessionId || activeServer?.id !== server.id) {
|
||||
console.log('Establishing new SSH connection for server:', server.id);
|
||||
const sessionId = await connectToSSH(server);
|
||||
if (sessionId) {
|
||||
setSshSessionId(sessionId);
|
||||
// Only notify parent component about host change if the server actually changed
|
||||
if (onHostChange && activeServer?.id !== server.id) {
|
||||
onHostChange(server);
|
||||
}
|
||||
} else {
|
||||
// If SSH connection fails, stay in servers view
|
||||
setView('servers');
|
||||
setActiveServer(null);
|
||||
}
|
||||
} else {
|
||||
console.log('Using existing SSH session for server:', server.id);
|
||||
// Only notify parent component about host change if the server actually changed
|
||||
if (onHostChange && activeServer?.id !== server.id) {
|
||||
onHostChange(server);
|
||||
}
|
||||
@@ -412,51 +308,46 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
}
|
||||
}));
|
||||
|
||||
// Path input focus scroll
|
||||
useEffect(() => {
|
||||
if (pathInputRef.current) {
|
||||
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
|
||||
}
|
||||
}, [currentPath]);
|
||||
|
||||
// Group SSH connections by folder
|
||||
const sshByFolder: Record<string, SSHHost[]> = {};
|
||||
sshConnections.forEach(conn => {
|
||||
const folder = conn.folder && conn.folder.trim() ? conn.folder : 'No Folder';
|
||||
if (!sshByFolder[folder]) sshByFolder[folder] = [];
|
||||
sshByFolder[folder].push(conn);
|
||||
});
|
||||
// Move 'No Folder' to the top
|
||||
|
||||
const sortedFolders = Object.keys(sshByFolder);
|
||||
if (sortedFolders.includes('No Folder')) {
|
||||
sortedFolders.splice(sortedFolders.indexOf('No Folder'), 1);
|
||||
sortedFolders.unshift('No Folder');
|
||||
}
|
||||
|
||||
// Filter hosts by search
|
||||
const filteredSshByFolder: Record<string, SSHHost[]> = {};
|
||||
Object.entries(sshByFolder).forEach(([folder, hosts]) => {
|
||||
filteredSshByFolder[folder] = hosts.filter(conn => {
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) ||
|
||||
(conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) ||
|
||||
(conn.tags || []).join(' ').toLowerCase().includes(q);
|
||||
(conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) ||
|
||||
(conn.tags || []).join(' ').toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
// Filter files by search
|
||||
const filteredFiles = files.filter(file => {
|
||||
const q = debouncedFileSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return file.name.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
// --- Render ---
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar style={{ height: '100vh', maxHeight: '100vh', overflow: 'hidden' }}>
|
||||
<SidebarContent style={{ height: '100vh', maxHeight: '100vh', overflow: 'hidden' }}>
|
||||
<Sidebar style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}>
|
||||
<SidebarContent style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}>
|
||||
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Termix / Config
|
||||
@@ -473,12 +364,12 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
{/* Main black div: servers list or file/folder browser */}
|
||||
<div className="flex-1 w-full flex flex-col rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 relative min-h-0 mt-1">
|
||||
<div
|
||||
className="flex-1 w-full flex flex-col rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 relative min-h-0 mt-1">
|
||||
{view === 'servers' && (
|
||||
<>
|
||||
{/* Search bar - outside ScrollArea so it's always visible */}
|
||||
<div className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10 border-b border-[#23232a]">
|
||||
<div
|
||||
className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10 border-b border-[#23232a]">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
@@ -487,21 +378,23 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 w-full h-full" style={{ height: '100%', maxHeight: '100%' }}>
|
||||
<ScrollArea className="flex-1 w-full h-full"
|
||||
style={{height: '100%', maxHeight: '100%'}}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* SSH hosts/folders section */}
|
||||
<div className="w-full flex-grow overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Separator className="w-full h-px bg-[#434345] my-2" style={{ maxWidth: 213, margin: '0 auto' }} />
|
||||
<div
|
||||
className="w-full flex-grow overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
|
||||
<div style={{display: 'flex', justifyContent: 'center'}}>
|
||||
<Separator className="w-full h-px bg-[#434345] my-2"
|
||||
style={{maxWidth: 213, margin: '0 auto'}}/>
|
||||
</div>
|
||||
{/* Host list */}
|
||||
<div className="mx-auto" style={{maxWidth: '213px', width: '100%'}}>
|
||||
{/* Accordion for folders/hosts */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<Accordion type="multiple" className="w-full" value={sortedFolders}>
|
||||
<Accordion type="multiple" className="w-full"
|
||||
value={sortedFolders}>
|
||||
{sortedFolders.map((folder, idx) => (
|
||||
<React.Fragment key={folder}>
|
||||
<AccordionItem value={folder} className="mt-0 w-full !border-b-transparent">
|
||||
<AccordionItem value={folder}
|
||||
className="mt-0 w-full !border-b-transparent">
|
||||
<AccordionTrigger
|
||||
className="text-base font-semibold rounded-t-none py-2 w-full">{folder}</AccordionTrigger>
|
||||
<AccordionContent
|
||||
@@ -513,18 +406,25 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
className="w-full h-10 px-2 bg-[#18181b] border border-[#434345] hover:bg-[#2d2d30] transition-colors text-left justify-start"
|
||||
onClick={() => handleSelectServer(conn)}
|
||||
>
|
||||
<div className="flex items-center w-full">
|
||||
<div
|
||||
className="flex items-center w-full">
|
||||
{conn.pin && <Pin
|
||||
className="w-0.5 h-0.5 text-yellow-400 mr-1 flex-shrink-0" />}
|
||||
<span className="font-medium truncate">{conn.name || conn.ip}</span>
|
||||
className="w-0.5 h-0.5 text-yellow-400 mr-1 flex-shrink-0"/>}
|
||||
<span
|
||||
className="font-medium truncate">{conn.name || conn.ip}</span>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
{idx < sortedFolders.length - 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Separator className="h-px bg-[#434345] my-1" style={{ width: 213 }} />
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<Separator
|
||||
className="h-px bg-[#434345] my-1"
|
||||
style={{width: 213}}/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
@@ -538,18 +438,17 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
</>
|
||||
)}
|
||||
{view === 'files' && activeServer && (
|
||||
<div className="flex flex-col h-full w-full" style={{ maxWidth: 260 }}>
|
||||
{/* Sticky path input bar - outside ScrollArea */}
|
||||
<div className="flex items-center gap-2 px-2 py-2 border-b border-[#23232a] bg-[#18181b] z-20" style={{ maxWidth: 260 }}>
|
||||
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 py-2 border-b border-[#23232a] bg-[#18181b] z-20"
|
||||
style={{maxWidth: 260}}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8 bg-[#18181b] border border-[#23232a] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
onClick={() => {
|
||||
// If not at root, go up one directory; else, go back to servers view
|
||||
let path = currentPath;
|
||||
if (path && path !== '/' && path !== '') {
|
||||
// Remove trailing slash if present
|
||||
if (path.endsWith('/')) path = path.slice(0, -1);
|
||||
const lastSlash = path.lastIndexOf('/');
|
||||
if (lastSlash > 0) {
|
||||
@@ -572,7 +471,6 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
className="flex-1 bg-[#18181b] border border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]"
|
||||
/>
|
||||
</div>
|
||||
{/* File search bar */}
|
||||
<div className="px-2 py-2 border-b border-[#23232a] bg-[#18181b]">
|
||||
<Input
|
||||
placeholder="Search files and folders..."
|
||||
@@ -582,16 +480,22 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
onChange={e => setFileSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* File list with proper scroll area - separate from topbar */}
|
||||
<div className="flex-1 w-full h-full bg-[#09090b] border-t border-[#23232a]">
|
||||
<ScrollArea className="w-full h-full bg-[#09090b]" style={{ height: '100%', maxHeight: '100%', paddingRight: 8, scrollbarGutter: 'stable', background: '#09090b' }}>
|
||||
<ScrollArea className="w-full h-full bg-[#09090b]" style={{
|
||||
height: '100%',
|
||||
maxHeight: '100%',
|
||||
paddingRight: 8,
|
||||
scrollbarGutter: 'stable',
|
||||
background: '#09090b'
|
||||
}}>
|
||||
<div className="p-2 pr-2">
|
||||
{connectingSSH || filesLoading ? (
|
||||
<div className="text-xs text-muted-foreground">Loading...</div>
|
||||
) : filesError ? (
|
||||
<div className="text-xs text-red-500">{filesError}</div>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">No files or folders found.</div>
|
||||
<div className="text-xs text-muted-foreground">No files or
|
||||
folders found.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{filteredFiles.map((item: any) => {
|
||||
@@ -603,21 +507,24 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
"flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded group max-w-full",
|
||||
isOpen && "opacity-60 cursor-not-allowed pointer-events-none"
|
||||
)}
|
||||
style={{ maxWidth: 220, marginBottom: 8 }}
|
||||
style={{maxWidth: 220, marginBottom: 8}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => !isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
isSSH: item.isSSH,
|
||||
sshSessionId: item.sshSessionId
|
||||
}))}
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
isSSH: item.isSSH,
|
||||
sshSessionId: item.sshSessionId
|
||||
}))}
|
||||
>
|
||||
{item.type === 'directory' ?
|
||||
<Folder className="w-4 h-4 text-blue-400"/> :
|
||||
<File className="w-4 h-4 text-muted-foreground"/>}
|
||||
<span className="text-sm text-white truncate max-w-[120px]">{item.name}</span>
|
||||
<Folder
|
||||
className="w-4 h-4 text-blue-400"/> :
|
||||
<File
|
||||
className="w-4 h-4 text-muted-foreground"/>}
|
||||
<span
|
||||
className="text-sm text-white truncate max-w-[120px]">{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{item.type === 'file' && (
|
||||
@@ -628,28 +535,32 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (item.isPinned) {
|
||||
await removeConfigEditorPinned({
|
||||
name: item.name,
|
||||
await removeConfigEditorPinned({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
hostId: activeServer?.id,
|
||||
isSSH: true,
|
||||
sshSessionId: activeServer?.id.toString()
|
||||
});
|
||||
// Update local state without refreshing
|
||||
setFiles(files.map(f =>
|
||||
f.path === item.path ? { ...f, isPinned: false } : f
|
||||
setFiles(files.map(f =>
|
||||
f.path === item.path ? {
|
||||
...f,
|
||||
isPinned: false
|
||||
} : f
|
||||
));
|
||||
} else {
|
||||
await addConfigEditorPinned({
|
||||
name: item.name,
|
||||
await addConfigEditorPinned({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
hostId: activeServer?.id,
|
||||
isSSH: true,
|
||||
sshSessionId: activeServer?.id.toString()
|
||||
});
|
||||
// Update local state without refreshing
|
||||
setFiles(files.map(f =>
|
||||
f.path === item.path ? { ...f, isPinned: true } : f
|
||||
setFiles(files.map(f =>
|
||||
f.path === item.path ? {
|
||||
...f,
|
||||
isPinned: true
|
||||
} : f
|
||||
));
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -657,7 +568,8 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pin className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
||||
<Pin
|
||||
className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -679,4 +591,4 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
</SidebarProvider>
|
||||
);
|
||||
});
|
||||
export { ConfigEditorSidebar };
|
||||
export {ConfigEditorSidebar};
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button.tsx';
|
||||
import { Card } from '@/components/ui/card.tsx';
|
||||
import { Separator } from '@/components/ui/separator.tsx';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Card} from '@/components/ui/card.tsx';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react';
|
||||
|
||||
interface SSHConnection {
|
||||
@@ -42,25 +42,25 @@ interface ConfigFileSidebarViewerProps {
|
||||
}
|
||||
|
||||
export function ConfigFileSidebarViewer({
|
||||
sshConnections,
|
||||
onAddSSH,
|
||||
onConnectSSH,
|
||||
onEditSSH,
|
||||
onDeleteSSH,
|
||||
onPinSSH,
|
||||
currentPath,
|
||||
files,
|
||||
onOpenFile,
|
||||
onOpenFolder,
|
||||
onStarFile,
|
||||
onDeleteFile,
|
||||
isLoading,
|
||||
error,
|
||||
isSSHMode,
|
||||
onSwitchToLocal,
|
||||
onSwitchToSSH,
|
||||
currentSSH,
|
||||
}: ConfigFileSidebarViewerProps) {
|
||||
sshConnections,
|
||||
onAddSSH,
|
||||
onConnectSSH,
|
||||
onEditSSH,
|
||||
onDeleteSSH,
|
||||
onPinSSH,
|
||||
currentPath,
|
||||
files,
|
||||
onOpenFile,
|
||||
onOpenFolder,
|
||||
onStarFile,
|
||||
onDeleteFile,
|
||||
isLoading,
|
||||
error,
|
||||
isSSHMode,
|
||||
onSwitchToLocal,
|
||||
onSwitchToSSH,
|
||||
currentSSH,
|
||||
}: ConfigFileSidebarViewerProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* SSH Connections */}
|
||||
@@ -68,7 +68,7 @@ export function ConfigFileSidebarViewer({
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground font-semibold">SSH Connections</span>
|
||||
<Button size="icon" variant="outline" onClick={onAddSSH} className="ml-2 h-7 w-7">
|
||||
<Plus className="w-4 h-4" />
|
||||
<Plus className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -77,7 +77,7 @@ export function ConfigFileSidebarViewer({
|
||||
className="w-full justify-start text-left px-2 py-1.5 rounded"
|
||||
onClick={onSwitchToLocal}
|
||||
>
|
||||
<Server className="w-4 h-4 mr-2" /> Local Files
|
||||
<Server className="w-4 h-4 mr-2"/> Local Files
|
||||
</Button>
|
||||
{sshConnections.map((conn) => (
|
||||
<div key={conn.id} className="flex items-center gap-1 group">
|
||||
@@ -86,18 +86,19 @@ export function ConfigFileSidebarViewer({
|
||||
className="flex-1 justify-start text-left px-2 py-1.5 rounded"
|
||||
onClick={() => onSwitchToSSH(conn)}
|
||||
>
|
||||
<Link2 className="w-4 h-4 mr-2" />
|
||||
<Link2 className="w-4 h-4 mr-2"/>
|
||||
{conn.name || conn.ip}
|
||||
{conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400" />}
|
||||
{conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400"/>}
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}>
|
||||
<Pin className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`} />
|
||||
<Pin
|
||||
className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}>
|
||||
<Edit className="w-4 h-4" />
|
||||
<Edit className="w-4 h-4"/>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteSSH(conn)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
<Trash2 className="w-4 h-4 text-red-500"/>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -106,7 +107,8 @@ export function ConfigFileSidebarViewer({
|
||||
{/* File/Folder Viewer */}
|
||||
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground font-semibold">{isSSHMode ? 'SSH Path' : 'Local Path'}</span>
|
||||
<span
|
||||
className="text-xs text-muted-foreground font-semibold">{isSSHMode ? 'SSH Path' : 'Local Path'}</span>
|
||||
<span className="text-xs text-white truncate">{currentPath}</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
@@ -116,22 +118,29 @@ export function ConfigFileSidebarViewer({
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{files.map((item) => (
|
||||
<Card key={item.path} className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border border-[#23232a] rounded">
|
||||
<div className="flex items-center gap-2 flex-1 cursor-pointer" onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
|
||||
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400" /> : <File className="w-4 h-4 text-muted-foreground" />}
|
||||
<Card key={item.path}
|
||||
className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border border-[#23232a] rounded">
|
||||
<div className="flex items-center gap-2 flex-1 cursor-pointer"
|
||||
onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
|
||||
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400"/> :
|
||||
<File className="w-4 h-4 text-muted-foreground"/>}
|
||||
<span className="text-sm text-white truncate">{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onStarFile(item)}>
|
||||
<Pin className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`} />
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
||||
onClick={() => onStarFile(item)}>
|
||||
<Pin
|
||||
className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteFile(item)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
||||
onClick={() => onDeleteFile(item)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500"/>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{files.length === 0 && <div className="text-xs text-muted-foreground">No files or folders found.</div>}
|
||||
{files.length === 0 &&
|
||||
<div className="text-xs text-muted-foreground">No files or folders found.</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card.tsx';
|
||||
import { Button } from '@/components/ui/button.tsx';
|
||||
import { Trash2, Folder, File, Plus, Pin } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs.tsx';
|
||||
import { Input } from '@/components/ui/input.tsx';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils.ts';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Trash2, Folder, File, Plus, Pin} from 'lucide-react';
|
||||
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {useState} from 'react';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
@@ -34,31 +32,31 @@ interface ConfigHomeViewProps {
|
||||
}
|
||||
|
||||
export function ConfigHomeView({
|
||||
recent,
|
||||
pinned,
|
||||
shortcuts,
|
||||
onOpenFile,
|
||||
onRemoveRecent,
|
||||
onPinFile,
|
||||
onUnpinFile,
|
||||
onOpenShortcut,
|
||||
onRemoveShortcut,
|
||||
onAddShortcut
|
||||
}: ConfigHomeViewProps) {
|
||||
recent,
|
||||
pinned,
|
||||
shortcuts,
|
||||
onOpenFile,
|
||||
onRemoveRecent,
|
||||
onPinFile,
|
||||
onUnpinFile,
|
||||
onOpenShortcut,
|
||||
onRemoveShortcut,
|
||||
onAddShortcut
|
||||
}: ConfigHomeViewProps) {
|
||||
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
||||
const [newShortcut, setNewShortcut] = useState('');
|
||||
|
||||
|
||||
|
||||
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
|
||||
<div key={file.path} className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
|
||||
<div
|
||||
<div key={file.path}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenFile(file)}
|
||||
>
|
||||
{file.type === 'directory' ?
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" /> :
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
{file.type === 'directory' ?
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>
|
||||
}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
@@ -68,23 +66,24 @@ export function ConfigHomeView({
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{onPin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={onPin}
|
||||
>
|
||||
<Pin className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`} />
|
||||
<Pin
|
||||
className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500" />
|
||||
<Trash2 className="w-3 h-3 text-red-500"/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -92,12 +91,13 @@ export function ConfigHomeView({
|
||||
);
|
||||
|
||||
const renderShortcutCard = (shortcut: ShortcutItem) => (
|
||||
<div key={shortcut.path} className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
|
||||
<div
|
||||
<div key={shortcut.path}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenShortcut(shortcut)}
|
||||
>
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{shortcut.path}
|
||||
@@ -105,13 +105,13 @@ export function ConfigHomeView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={() => onRemoveShortcut(shortcut)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500" />
|
||||
<Trash2 className="w-3 h-3 text-red-500"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,18 +123,19 @@ export function ConfigHomeView({
|
||||
<TabsList className="mb-4 bg-[#18181b] border border-[#23232a]">
|
||||
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">Recent</TabsTrigger>
|
||||
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder Shortcuts</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder
|
||||
Shortcuts</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{recent.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No recent files.</span>
|
||||
</div>
|
||||
) : recent.map((file) =>
|
||||
) : recent.map((file) =>
|
||||
renderFileCard(
|
||||
file,
|
||||
file,
|
||||
() => onRemoveRecent(file),
|
||||
() => file.isPinned ? onUnpinFile(file) : onPinFile(file),
|
||||
file.isPinned
|
||||
@@ -142,24 +143,24 @@ export function ConfigHomeView({
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
<TabsContent value="pinned" className="mt-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{pinned.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No pinned files.</span>
|
||||
</div>
|
||||
) : pinned.map((file) =>
|
||||
) : pinned.map((file) =>
|
||||
renderFileCard(
|
||||
file,
|
||||
undefined, // No remove function for pinned items
|
||||
() => onUnpinFile(file), // Use pin function for unpinning
|
||||
file,
|
||||
undefined,
|
||||
() => onUnpinFile(file),
|
||||
true
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
<TabsContent value="shortcuts" className="mt-0">
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border border-[#23232a] rounded-lg">
|
||||
<Input
|
||||
@@ -185,7 +186,7 @@ export function ConfigHomeView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
<Plus className="w-3.5 h-3.5 mr-1"/>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
@@ -194,7 +195,7 @@ export function ConfigHomeView({
|
||||
<div className="flex items-center justify-center py-4 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No shortcuts.</span>
|
||||
</div>
|
||||
) : shortcuts.map((shortcut) =>
|
||||
) : shortcuts.map((shortcut) =>
|
||||
renderShortcutCard(shortcut)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button.tsx';
|
||||
import { X, Home } from 'lucide-react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {X, Home} from 'lucide-react';
|
||||
|
||||
interface ConfigTab {
|
||||
id: string | number;
|
||||
@@ -15,7 +15,7 @@ interface ConfigTabListProps {
|
||||
onHomeClick: () => void;
|
||||
}
|
||||
|
||||
export function ConfigTabList({ tabs, activeTab, setActiveTab, closeTab, onHomeClick }: ConfigTabListProps) {
|
||||
export function ConfigTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: ConfigTabListProps) {
|
||||
return (
|
||||
<div className="inline-flex items-center h-full px-[0.5rem] overflow-x-auto">
|
||||
<Button
|
||||
@@ -23,7 +23,7 @@ export function ConfigTabList({ tabs, activeTab, setActiveTab, closeTab, onHomeC
|
||||
variant="outline"
|
||||
className={`h-7 mr-[0.5rem] rounded-md flex items-center ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
<Home className="w-4 h-4"/>
|
||||
</Button>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
@@ -33,7 +33,6 @@ export function ConfigTabList({ tabs, activeTab, setActiveTab, closeTab, onHomeC
|
||||
className={index < tabs.length - 1 ? "mr-[0.5rem]" : ""}
|
||||
>
|
||||
<div className="inline-flex rounded-md shadow-sm" role="group">
|
||||
{/* Set Active Tab Button */}
|
||||
<Button
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
variant="outline"
|
||||
@@ -42,13 +41,12 @@ export function ConfigTabList({ tabs, activeTab, setActiveTab, closeTab, onHomeC
|
||||
{tab.title}
|
||||
</Button>
|
||||
|
||||
{/* Close Tab Button */}
|
||||
<Button
|
||||
onClick={() => closeTab(tab.id)}
|
||||
variant="outline"
|
||||
className="h-7 rounded-l-none p-0 !w-9"
|
||||
>
|
||||
<X className="!w-5 !h-5" strokeWidth={2.5} />
|
||||
<X className="!w-5 !h-5" strokeWidth={2.5}/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, {useState} from "react";
|
||||
import {SSHManagerSidebar} from "@/apps/SSH/Manager/SSHManagerSidebar.tsx";
|
||||
import {SSHManagerHostViewer} from "@/apps/SSH/Manager/SSHManagerHostViewer.tsx"
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||
@@ -32,7 +32,7 @@ interface SSHHost {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function SSHManager({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
export function SSHManager({onSelectView}: ConfigEditorProps): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||
|
||||
@@ -48,7 +48,6 @@ export function SSHManager({ onSelectView }: ConfigEditorProps): React.ReactElem
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
// Reset editingHost when switching to host_viewer
|
||||
if (value === "host_viewer") {
|
||||
setEditingHost(null);
|
||||
}
|
||||
@@ -61,10 +60,12 @@ export function SSHManager({ onSelectView }: ConfigEditorProps): React.ReactElem
|
||||
/>
|
||||
|
||||
<div className="flex w-screen h-screen overflow-hidden">
|
||||
<div className="w-[256px]" />
|
||||
<div className="w-[256px]"/>
|
||||
|
||||
<div className="flex-1 bg-[#18181b] m-[35px] text-white p-4 rounded-md w-[1200px] border h-[calc(100vh-70px)] flex flex-col min-h-0">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex-1 flex flex-col h-full min-h-0">
|
||||
<div
|
||||
className="flex-1 bg-[#18181b] m-[35px] text-white p-4 rounded-md w-[1200px] border h-[calc(100vh-70px)] flex flex-col min-h-0">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}
|
||||
className="flex-1 flex flex-col h-full min-h-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
|
||||
<TabsTrigger value="add_host">
|
||||
@@ -72,13 +73,13 @@ export function SSHManager({ onSelectView }: ConfigEditorProps): React.ReactElem
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SSHManagerHostViewer onEditHost={handleEditHost}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<SSHManagerHostEditor
|
||||
<SSHManagerHostEditor
|
||||
editingHost={editingHost}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {Switch} from "@/components/ui/switch.tsx";
|
||||
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
|
||||
import { createSSHHost, updateSSHHost, getSSHHosts } from '@/apps/SSH/ssh-axios-fixed';
|
||||
import {createSSHHost, updateSSHHost, getSSHHosts} from '@/apps/SSH/ssh-axios';
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -49,17 +49,14 @@ interface SSHManagerHostEditorProps {
|
||||
onFormSubmit?: () => void;
|
||||
}
|
||||
|
||||
export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHostEditorProps) {
|
||||
// State for dynamic data
|
||||
export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// State for authentication tab selection
|
||||
|
||||
const [authTab, setAuthTab] = useState<'password' | 'key'>('password');
|
||||
|
||||
// Fetch hosts and extract folders and configurations
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
@@ -67,14 +64,12 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
const hostsData = await getSSHHosts();
|
||||
setHosts(hostsData);
|
||||
|
||||
// Extract unique folders (excluding empty ones)
|
||||
const uniqueFolders = [...new Set(
|
||||
hostsData
|
||||
.filter(host => host.folder && host.folder.trim() !== '')
|
||||
.map(host => host.folder)
|
||||
)].sort();
|
||||
|
||||
// Extract unique host names for SSH configurations
|
||||
const uniqueConfigurations = [...new Set(
|
||||
hostsData
|
||||
.filter(host => host.name && host.name.trim() !== '')
|
||||
@@ -84,7 +79,6 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
setFolders(uniqueFolders);
|
||||
setSshConfigurations(uniqueConfigurations);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hosts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -93,7 +87,6 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Create dynamic form schema based on fetched data
|
||||
const formSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
ip: z.string().min(1),
|
||||
@@ -130,7 +123,6 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
enableConfigEditor: z.boolean().default(true),
|
||||
defaultPath: z.string().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
// Conditional validation based on authType
|
||||
if (data.authType === 'password') {
|
||||
if (!data.password || data.password.trim() === '') {
|
||||
ctx.addIssue({
|
||||
@@ -156,7 +148,6 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
}
|
||||
}
|
||||
|
||||
// Validate endpointHost against available configurations
|
||||
data.tunnelConnections.forEach((connection, index) => {
|
||||
if (connection.endpointHost && !sshConfigurations.includes(connection.endpointHost)) {
|
||||
ctx.addIssue({
|
||||
@@ -193,15 +184,12 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
}
|
||||
});
|
||||
|
||||
// Update form when editingHost changes
|
||||
useEffect(() => {
|
||||
if (editingHost) {
|
||||
// Determine the default auth type based on what's available
|
||||
const defaultAuthType = editingHost.key ? 'key' : 'password';
|
||||
|
||||
// Update the auth tab state
|
||||
|
||||
setAuthTab(defaultAuthType);
|
||||
|
||||
|
||||
form.reset({
|
||||
name: editingHost.name || "",
|
||||
ip: editingHost.ip || "",
|
||||
@@ -222,9 +210,8 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
tunnelConnections: editingHost.tunnelConnections || [],
|
||||
});
|
||||
} else {
|
||||
// Reset to password tab for new hosts
|
||||
setAuthTab('password');
|
||||
|
||||
|
||||
form.reset({
|
||||
name: "",
|
||||
ip: "",
|
||||
@@ -250,52 +237,42 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
const onSubmit = async (data: any) => {
|
||||
try {
|
||||
const formData = data as FormData;
|
||||
|
||||
// Set default name if empty or undefined
|
||||
|
||||
if (!formData.name || formData.name.trim() === '') {
|
||||
formData.name = `${formData.username}@${formData.ip}`;
|
||||
}
|
||||
|
||||
|
||||
if (editingHost) {
|
||||
await updateSSHHost(editingHost.id, formData);
|
||||
console.log('Host updated successfully');
|
||||
} else {
|
||||
await createSSHHost(formData);
|
||||
console.log('Host created successfully');
|
||||
}
|
||||
|
||||
// Call the callback to redirect to host viewer
|
||||
|
||||
if (onFormSubmit) {
|
||||
onFormSubmit();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save host:', error);
|
||||
alert('Failed to save host. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
// Tag input state
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
|
||||
// Folder dropdown state
|
||||
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const folderDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Folder filtering logic
|
||||
const folderValue = form.watch('folder');
|
||||
const filteredFolders = React.useMemo(() => {
|
||||
if (!folderValue) return folders;
|
||||
return folders.filter(f => f.toLowerCase().includes(folderValue.toLowerCase()));
|
||||
}, [folderValue, folders]);
|
||||
|
||||
// Handle folder click
|
||||
const handleFolderClick = (folder: string) => {
|
||||
form.setValue('folder', folder);
|
||||
setFolderDropdownOpen(false);
|
||||
};
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
@@ -319,7 +296,6 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
};
|
||||
}, [folderDropdownOpen]);
|
||||
|
||||
// keyType Dropdown
|
||||
const keyTypeOptions = [
|
||||
{value: 'auto', label: 'Auto-detect'},
|
||||
{value: 'ssh-rsa', label: 'RSA'},
|
||||
@@ -353,41 +329,35 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||
}, [keyTypeDropdownOpen]);
|
||||
|
||||
// SSH Configuration dropdown state and logic
|
||||
const [sshConfigDropdownOpen, setSshConfigDropdownOpen] = useState<{ [key: number]: boolean }>({});
|
||||
const sshConfigInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({});
|
||||
const sshConfigDropdownRefs = useRef<{ [key: number]: HTMLDivElement | null }>({});
|
||||
|
||||
// SSH Configuration filtering logic
|
||||
const getFilteredSshConfigs = (index: number) => {
|
||||
const value = form.watch(`tunnelConnections.${index}.endpointHost`);
|
||||
|
||||
// Get current host name to exclude it from the list
|
||||
|
||||
const currentHostName = form.watch('name') || `${form.watch('username')}@${form.watch('ip')}`;
|
||||
|
||||
// Filter out the current host and apply search filter
|
||||
|
||||
let filtered = sshConfigurations.filter(config => config !== currentHostName);
|
||||
|
||||
|
||||
if (value) {
|
||||
filtered = filtered.filter(config =>
|
||||
filtered = filtered.filter(config =>
|
||||
config.toLowerCase().includes(value.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// Handle SSH configuration click
|
||||
const handleSshConfigClick = (config: string, index: number) => {
|
||||
form.setValue(`tunnelConnections.${index}.endpointHost`, config);
|
||||
setSshConfigDropdownOpen(prev => ({ ...prev, [index]: false }));
|
||||
setSshConfigDropdownOpen(prev => ({...prev, [index]: false}));
|
||||
};
|
||||
|
||||
// Close SSH configuration dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleSshConfigClickOutside(event: MouseEvent) {
|
||||
const openDropdowns = Object.keys(sshConfigDropdownOpen).filter(key => sshConfigDropdownOpen[parseInt(key)]);
|
||||
|
||||
|
||||
openDropdowns.forEach((indexStr: string) => {
|
||||
const index = parseInt(indexStr);
|
||||
if (
|
||||
@@ -396,13 +366,13 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
sshConfigInputRefs.current[index] &&
|
||||
!sshConfigInputRefs.current[index]?.contains(event.target as Node)
|
||||
) {
|
||||
setSshConfigDropdownOpen(prev => ({ ...prev, [index]: false }));
|
||||
setSshConfigDropdownOpen(prev => ({...prev, [index]: false}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const hasOpenDropdowns = Object.values(sshConfigDropdownOpen).some(open => open);
|
||||
|
||||
|
||||
if (hasOpenDropdowns) {
|
||||
document.addEventListener('mousedown', handleSshConfigClickOutside);
|
||||
} else {
|
||||
@@ -490,9 +460,9 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
<FormItem className="col-span-10 relative">
|
||||
<FormLabel>Folder</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
<Input
|
||||
ref={folderInputRef}
|
||||
placeholder="folder"
|
||||
placeholder="folder"
|
||||
className="min-h-[40px]"
|
||||
autoComplete="off"
|
||||
value={field.value}
|
||||
@@ -503,7 +473,6 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
{/* Folder dropdown menu */}
|
||||
{folderDropdownOpen && filteredFolders.length > 0 && (
|
||||
<div
|
||||
ref={folderDropdownRef}
|
||||
@@ -532,13 +501,15 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tags"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-10 overflow-visible">
|
||||
<FormLabel>Tags</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-[#222225] focus-within:ring-2 ring-ring min-h-[40px]">
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-[#222225] focus-within:ring-2 ring-ring min-h-[40px]">
|
||||
{field.value.map((tag: string, idx: number) => (
|
||||
<span key={tag + idx} className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs">
|
||||
<span key={tag + idx}
|
||||
className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
@@ -593,8 +564,8 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
/>
|
||||
</div>
|
||||
<FormLabel className="mb-3 mt-3 font-bold">Authentication</FormLabel>
|
||||
<Tabs
|
||||
value={authTab}
|
||||
<Tabs
|
||||
value={authTab}
|
||||
onValueChange={(value) => {
|
||||
setAuthTab(value as 'password' | 'key');
|
||||
form.setValue('authType', value as 'password' | 'key');
|
||||
@@ -735,12 +706,6 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch('enableTerminal') && (
|
||||
<div className="mt-4">
|
||||
{/* Tunnel Config (none yet) */}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="tunnel">
|
||||
<FormField
|
||||
@@ -761,35 +726,52 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
{form.watch('enableTunnel') && (
|
||||
<>
|
||||
<Alert className="mt-4">
|
||||
<AlertDescription>
|
||||
<strong>Sshpass Required For Password Authentication</strong>
|
||||
<div>
|
||||
For password-based SSH authentication, sshpass must be installed on both the local and remote servers. Install with: <code className="bg-muted px-1 rounded inline">sudo apt install sshpass</code> (Debian/Ubuntu) or the equivalent for your OS.
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<strong>Other installation methods:</strong>
|
||||
<div>• CentOS/RHEL/Fedora: <code className="bg-muted px-1 rounded inline">sudo yum install sshpass</code> or <code className="bg-muted px-1 rounded inline">sudo dnf install sshpass</code></div>
|
||||
<div>• macOS: <code className="bg-muted px-1 rounded inline">brew install hudochenkov/sshpass/sshpass</code></div>
|
||||
<div>• Windows: Use WSL or consider SSH key authentication</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Alert className="mt-4">
|
||||
<AlertDescription>
|
||||
<strong>Sshpass Required For Password Authentication</strong>
|
||||
<div>
|
||||
For password-based SSH authentication, sshpass must be installed on
|
||||
both the local and remote servers. Install with: <code
|
||||
className="bg-muted px-1 rounded inline">sudo apt install
|
||||
sshpass</code> (Debian/Ubuntu) or the equivalent for your OS.
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<strong>Other installation methods:</strong>
|
||||
<div>• CentOS/RHEL/Fedora: <code
|
||||
className="bg-muted px-1 rounded inline">sudo yum install
|
||||
sshpass</code> or <code
|
||||
className="bg-muted px-1 rounded inline">sudo dnf install
|
||||
sshpass</code></div>
|
||||
<div>• macOS: <code className="bg-muted px-1 rounded inline">brew
|
||||
install hudochenkov/sshpass/sshpass</code></div>
|
||||
<div>• Windows: Use WSL or consider SSH key authentication</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert className="mt-4">
|
||||
<AlertDescription>
|
||||
<strong>SSH Server Configuration Required</strong>
|
||||
<div>For reverse SSH tunnels, the endpoint SSH server must allow:</div>
|
||||
<div>• <code className="bg-muted px-1 rounded inline">GatewayPorts
|
||||
yes</code> (bind remote ports)
|
||||
</div>
|
||||
<div>• <code className="bg-muted px-1 rounded inline">AllowTcpForwarding
|
||||
yes</code> (port forwarding)
|
||||
</div>
|
||||
<div>• <code className="bg-muted px-1 rounded inline">PermitRootLogin
|
||||
yes</code> (if using root)
|
||||
</div>
|
||||
<div className="mt-2">Edit <code
|
||||
className="bg-muted px-1 rounded inline">/etc/ssh/sshd_config</code> and
|
||||
restart SSH: <code className="bg-muted px-1 rounded inline">sudo
|
||||
systemctl restart sshd</code></div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert className="mt-4">
|
||||
<AlertDescription>
|
||||
<strong>SSH Server Configuration Required</strong>
|
||||
<div>For reverse SSH tunnels, the endpoint SSH server must allow:</div>
|
||||
<div>• <code className="bg-muted px-1 rounded inline">GatewayPorts yes</code> (bind remote ports)</div>
|
||||
<div>• <code className="bg-muted px-1 rounded inline">AllowTcpForwarding yes</code> (port forwarding)</div>
|
||||
<div>• <code className="bg-muted px-1 rounded inline">PermitRootLogin yes</code> (if using root)</div>
|
||||
<div className="mt-2">Edit <code className="bg-muted px-1 rounded inline">/etc/ssh/sshd_config</code> and restart SSH: <code className="bg-muted px-1 rounded inline">sudo systemctl restart sshd</code></div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tunnelConnections"
|
||||
@@ -799,8 +781,10 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
{field.value.map((connection, index) => (
|
||||
<div key={index} className="p-4 border rounded-lg bg-muted/50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div key={index}
|
||||
className="p-4 border rounded-lg bg-muted/50">
|
||||
<div
|
||||
className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-bold">Connection {index + 1}</h4>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -820,9 +804,11 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
name={`tunnelConnections.${index}.sourcePort`}
|
||||
render={({field: sourcePortField}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Source Port (Local)</FormLabel>
|
||||
<FormLabel>Source Port
|
||||
(Local)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="22" {...sourcePortField} />
|
||||
<Input
|
||||
placeholder="22" {...sourcePortField} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -832,9 +818,11 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
name={`tunnelConnections.${index}.endpointPort`}
|
||||
render={({field: endpointPortField}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Endpoint Port (Remote)</FormLabel>
|
||||
<FormLabel>Endpoint Port
|
||||
(Remote)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="224" {...endpointPortField} />
|
||||
<Input
|
||||
placeholder="224" {...endpointPortField} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -843,10 +831,12 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.endpointHost`}
|
||||
render={({field: endpointHostField}) => (
|
||||
<FormItem className="col-span-4 relative">
|
||||
<FormLabel>Endpoint SSH Configuration</FormLabel>
|
||||
<FormItem
|
||||
className="col-span-4 relative">
|
||||
<FormLabel>Endpoint SSH
|
||||
Configuration</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
<Input
|
||||
ref={(el) => {
|
||||
sshConfigInputRefs.current[index] = el;
|
||||
}}
|
||||
@@ -854,14 +844,19 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
className="min-h-[40px]"
|
||||
autoComplete="off"
|
||||
value={endpointHostField.value}
|
||||
onFocus={() => setSshConfigDropdownOpen(prev => ({ ...prev, [index]: true }))}
|
||||
onFocus={() => setSshConfigDropdownOpen(prev => ({
|
||||
...prev,
|
||||
[index]: true
|
||||
}))}
|
||||
onChange={e => {
|
||||
endpointHostField.onChange(e);
|
||||
setSshConfigDropdownOpen(prev => ({ ...prev, [index]: true }));
|
||||
setSshConfigDropdownOpen(prev => ({
|
||||
...prev,
|
||||
[index]: true
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
{/* SSH Configuration dropdown menu */}
|
||||
{sshConfigDropdownOpen[index] && getFilteredSshConfigs(index).length > 0 && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
@@ -869,7 +864,8 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
}}
|
||||
className="absolute top-full left-0 z-50 mt-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-1 p-0">
|
||||
<div
|
||||
className="grid grid-cols-1 gap-1 p-0">
|
||||
{getFilteredSshConfigs(index).map((config) => (
|
||||
<Button
|
||||
key={config}
|
||||
@@ -889,11 +885,16 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
This tunnel will forward traffic from port {form.watch(`tunnelConnections.${index}.sourcePort`) || '22'} on the source machine (current connection details in general tab) to port {form.watch(`tunnelConnections.${index}.endpointPort`) || '224'} on the endpoint machine.
|
||||
This tunnel will forward traffic from
|
||||
port {form.watch(`tunnelConnections.${index}.sourcePort`) || '22'} on
|
||||
the source machine (current connection details
|
||||
in general tab) to
|
||||
port {form.watch(`tunnelConnections.${index}.endpointPort`) || '224'} on
|
||||
the endpoint machine.
|
||||
</p>
|
||||
|
||||
|
||||
<div className="grid grid-cols-12 gap-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -902,10 +903,12 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Max Retries</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="3" {...maxRetriesField} />
|
||||
<Input
|
||||
placeholder="3" {...maxRetriesField} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum number of retry attempts for tunnel connection.
|
||||
Maximum number of retry attempts
|
||||
for tunnel connection.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -915,12 +918,15 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
name={`tunnelConnections.${index}.retryInterval`}
|
||||
render={({field: retryIntervalField}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Retry Interval (seconds)</FormLabel>
|
||||
<FormLabel>Retry Interval
|
||||
(seconds)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="10" {...retryIntervalField} />
|
||||
<Input
|
||||
placeholder="10" {...retryIntervalField} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Time to wait between retry attempts.
|
||||
Time to wait between retry
|
||||
attempts.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -930,7 +936,8 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
name={`tunnelConnections.${index}.autoStart`}
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Auto Start on Container Launch</FormLabel>
|
||||
<FormLabel>Auto Start on Container
|
||||
Launch</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
@@ -938,7 +945,8 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Automatically start this tunnel when the container launches.
|
||||
Automatically start this tunnel
|
||||
when the container launches.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -950,10 +958,10 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
field.onChange([...field.value, {
|
||||
sourcePort: 22,
|
||||
endpointPort: 224,
|
||||
endpointHost: "",
|
||||
field.onChange([...field.value, {
|
||||
sourcePort: 22,
|
||||
endpointPort: 224,
|
||||
endpointHost: "",
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: false,
|
||||
@@ -967,7 +975,7 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
</>
|
||||
)}
|
||||
@@ -991,22 +999,23 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
{form.watch('enableConfigEditor') && (
|
||||
<div className="mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="defaultPath"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Default Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/home" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Set default directory shown when connected via Config Editor</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="defaultPath"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Default Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/home" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Set default directory shown when connected via
|
||||
Config Editor</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { getSSHHosts, deleteSSHHost } from "@/apps/SSH/ssh-axios-fixed";
|
||||
import { Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search } from "lucide-react";
|
||||
import React, {useState, useEffect, useMemo} from "react";
|
||||
import {Card, CardContent} from "@/components/ui/card";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
|
||||
import {getSSHHosts, deleteSSHHost} from "@/apps/SSH/ssh-axios";
|
||||
import {Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search} from "lucide-react";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -31,7 +31,7 @@ interface SSHManagerHostViewerProps {
|
||||
onEditHost?: (host: SSHHost) => void;
|
||||
}
|
||||
|
||||
export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -48,7 +48,6 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
|
||||
setHosts(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch hosts:', err);
|
||||
setError('Failed to load hosts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -59,9 +58,8 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
|
||||
if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) {
|
||||
try {
|
||||
await deleteSSHHost(hostId);
|
||||
await fetchHosts(); // Refresh the list
|
||||
await fetchHosts();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete host:', err);
|
||||
alert('Failed to delete host');
|
||||
}
|
||||
}
|
||||
@@ -73,11 +71,9 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
|
||||
}
|
||||
};
|
||||
|
||||
// Filter and sort hosts
|
||||
const filteredAndSortedHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = hosts.filter(host => {
|
||||
@@ -94,23 +90,19 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: pinned first, then alphabetical by name/username
|
||||
return filtered.sort((a, b) => {
|
||||
// First, sort by pin status (pinned hosts first)
|
||||
if (a.pin && !b.pin) return -1;
|
||||
if (!a.pin && b.pin) return 1;
|
||||
|
||||
// Then sort alphabetically by name or username
|
||||
|
||||
const aName = a.name || a.username;
|
||||
const bName = b.name || b.username;
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
}, [hosts, searchQuery]);
|
||||
|
||||
// Group hosts by folder
|
||||
const hostsByFolder = useMemo(() => {
|
||||
const grouped: { [key: string]: SSHHost[] } = {};
|
||||
|
||||
|
||||
filteredAndSortedHosts.forEach(host => {
|
||||
const folder = host.folder || 'Uncategorized';
|
||||
if (!grouped[folder]) {
|
||||
@@ -118,20 +110,18 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
|
||||
}
|
||||
grouped[folder].push(host);
|
||||
});
|
||||
|
||||
// Sort folders to ensure "Uncategorized" is always first
|
||||
|
||||
const sortedFolders = Object.keys(grouped).sort((a, b) => {
|
||||
if (a === 'Uncategorized') return -1;
|
||||
if (b === 'Uncategorized') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// Create a new object with sorted folders
|
||||
|
||||
const sortedGrouped: { [key: string]: SSHHost[] } = {};
|
||||
sortedFolders.forEach(folder => {
|
||||
sortedGrouped[folder] = grouped[folder];
|
||||
});
|
||||
|
||||
|
||||
return sortedGrouped;
|
||||
}, [filteredAndSortedHosts]);
|
||||
|
||||
@@ -163,7 +153,7 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||
<h3 className="text-lg font-semibold mb-2">No SSH Hosts</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
You haven't added any SSH hosts yet. Click "Add Host" to get started.
|
||||
@@ -187,9 +177,8 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||
<Input
|
||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||
value={searchQuery}
|
||||
@@ -197,16 +186,17 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-2 pb-20">
|
||||
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
|
||||
<div key={folder} className="border rounded-md">
|
||||
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}>
|
||||
<AccordionItem value={folder} className="border-none">
|
||||
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||
<AccordionTrigger
|
||||
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-4 w-4" />
|
||||
<Folder className="h-4 w-4"/>
|
||||
<span className="font-medium">{folder}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{folderHosts.length}
|
||||
@@ -216,15 +206,16 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
|
||||
<AccordionContent className="p-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{folderHosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
<div
|
||||
key={host.id}
|
||||
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
|
||||
onClick={() => handleEdit(host)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{host.pin && <Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />}
|
||||
{host.pin && <Pin
|
||||
className="h-3 w-3 text-yellow-500 flex-shrink-0"/>}
|
||||
<h3 className="font-medium truncate text-sm">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
@@ -246,7 +237,7 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
|
||||
}}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
<Edit className="h-3 w-3"/>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -257,49 +248,50 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
{/* Tags */}
|
||||
{host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{host.tags.slice(0, 6).map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
|
||||
<Tag className="h-2 w-2 mr-0.5" />
|
||||
<Badge key={index} variant="secondary"
|
||||
className="text-xs px-1 py-0">
|
||||
<Tag className="h-2 w-2 mr-0.5"/>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{host.tags.length > 6 && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Badge variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
+{host.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features */}
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{host.enableTerminal && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Terminal className="h-2 w-2 mr-0.5" />
|
||||
<Terminal className="h-2 w-2 mr-0.5"/>
|
||||
Terminal
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Network className="h-2 w-2 mr-0.5" />
|
||||
<Network className="h-2 w-2 mr-0.5"/>
|
||||
Tunnel
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 && (
|
||||
<span className="ml-0.5">({host.tunnelConnections.length})</span>
|
||||
<span
|
||||
className="ml-0.5">({host.tunnelConnections.length})</span>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableConfigEditor && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<FileEdit className="h-2 w-2 mr-0.5" />
|
||||
<FileEdit className="h-2 w-2 mr-0.5"/>
|
||||
Config
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -26,7 +26,7 @@ interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
export function SSHManagerSidebar({ onSelectView }: SidebarProps): React.ReactElement {
|
||||
export function SSHManagerSidebar({onSelectView}: SidebarProps): React.ReactElement {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
@@ -35,17 +35,18 @@ export function SSHManagerSidebar({ onSelectView }: SidebarProps): React.ReactEl
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Termix / SSH Manager
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
||||
<SidebarMenu>
|
||||
|
||||
{/* Sidebar Items */}
|
||||
<SidebarMenuItem key={"Homepage"}>
|
||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")} variant="outline">
|
||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
|
||||
variant="outline">
|
||||
<CornerDownLeft/>
|
||||
Return
|
||||
</Button>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
</SidebarMenuItem>
|
||||
|
||||
</SidebarMenu>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { SSHSidebar } from "@/apps/SSH/Terminal/SSHSidebar.tsx";
|
||||
import { SSHTerminal } from "./SSHTerminal.tsx";
|
||||
import { SSHTopbar } from "@/apps/SSH/Terminal/SSHTopbar.tsx";
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable.tsx';
|
||||
import React, {useState, useRef, useEffect} from "react";
|
||||
import {SSHSidebar} from "@/apps/SSH/Terminal/SSHSidebar.tsx";
|
||||
import {SSHTerminal} from "./SSHTerminal.tsx";
|
||||
import {SSHTopbar} from "@/apps/SSH/Terminal/SSHTopbar.tsx";
|
||||
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
@@ -16,7 +16,7 @@ type Tab = {
|
||||
terminalRef: React.RefObject<any>;
|
||||
};
|
||||
|
||||
export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
export function SSH({onSelectView}: ConfigEditorProps): React.ReactElement {
|
||||
const [allTabs, setAllTabs] = useState<Tab[]>([]);
|
||||
const [currentTab, setCurrentTab] = useState<number | null>(null);
|
||||
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
||||
@@ -72,7 +72,7 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
|
||||
const updatePanelRects = () => {
|
||||
setPanelRects((prev) => {
|
||||
const next: Record<string, DOMRect | null> = { ...prev };
|
||||
const next: Record<string, DOMRect | null> = {...prev};
|
||||
Object.entries(panelRefs.current).forEach(([id, ref]) => {
|
||||
if (ref) {
|
||||
next[id] = ref.getBoundingClientRect();
|
||||
@@ -137,11 +137,21 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div ref={el => { panelRefs.current['parent'] = el; }} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 1, overflow: 'hidden' }}>
|
||||
<div ref={el => {
|
||||
panelRefs.current['parent'] = el;
|
||||
}} style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 1,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{allTabs.map((tab) => {
|
||||
const style = layoutStyles[tab.id]
|
||||
? { ...layoutStyles[tab.id], overflow: 'hidden' }
|
||||
: { display: 'none', overflow: 'hidden' };
|
||||
? {...layoutStyles[tab.id], overflow: 'hidden'}
|
||||
: {display: 'none', overflow: 'hidden'};
|
||||
const isVisible = !!layoutStyles[tab.id];
|
||||
return (
|
||||
<div key={tab.id} style={style} data-terminal-id={tab.id}>
|
||||
@@ -170,15 +180,37 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
if (layoutTabs.length === 2) {
|
||||
const [tab1, tab2] = layoutTabs;
|
||||
return (
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 10, pointerEvents: 'none' }}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<ResizablePrimitive.PanelGroup
|
||||
ref={el => { panelGroupRefs.current['main'] = el; }}
|
||||
ref={el => {
|
||||
panelGroupRefs.current['main'] = el;
|
||||
}}
|
||||
direction="horizontal"
|
||||
className="h-full w-full"
|
||||
id="main-horizontal"
|
||||
>
|
||||
<ResizablePanel key={tab1.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${tab1.id}`} order={1}>
|
||||
<div ref={el => { panelRefs.current[String(tab1.id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<ResizablePanel key={tab1.id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full" id={`panel-${tab1.id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(tab1.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
@@ -194,9 +226,21 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
}}>{tab1.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||
<ResizablePanel key={tab2.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${tab2.id}`} order={2}>
|
||||
<div ref={el => { panelRefs.current[String(tab2.id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
||||
<ResizablePanel key={tab2.id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full" id={`panel-${tab2.id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(tab2.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
@@ -218,17 +262,43 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
}
|
||||
if (layoutTabs.length === 3) {
|
||||
return (
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 10, pointerEvents: 'none' }}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<ResizablePrimitive.PanelGroup
|
||||
ref={el => { panelGroupRefs.current['main'] = el; }}
|
||||
ref={el => {
|
||||
panelGroupRefs.current['main'] = el;
|
||||
}}
|
||||
direction="vertical"
|
||||
className="h-full w-full"
|
||||
id="main-vertical"
|
||||
>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
|
||||
<ResizablePanelGroup ref={el => { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal">
|
||||
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[0].id}`} order={1}>
|
||||
<div ref={el => { panelRefs.current[String(layoutTabs[0].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id="top-panel" order={1}>
|
||||
<ResizablePanelGroup ref={el => {
|
||||
panelGroupRefs.current['top'] = el;
|
||||
}} direction="horizontal" className="h-full w-full" id="top-horizontal">
|
||||
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${layoutTabs[0].id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(layoutTabs[0].id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
@@ -244,9 +314,22 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
}}>{layoutTabs[0].title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[1].id}`} order={2}>
|
||||
<div ref={el => { panelRefs.current[String(layoutTabs[1].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
||||
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${layoutTabs[1].id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(layoutTabs[1].id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
@@ -264,9 +347,21 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}>
|
||||
<div ref={el => { panelRefs.current[String(layoutTabs[2].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id="bottom-panel" order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(layoutTabs[2].id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
@@ -288,17 +383,43 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
}
|
||||
if (layoutTabs.length === 4) {
|
||||
return (
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 10, pointerEvents: 'none' }}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<ResizablePrimitive.PanelGroup
|
||||
ref={el => { panelGroupRefs.current['main'] = el; }}
|
||||
ref={el => {
|
||||
panelGroupRefs.current['main'] = el;
|
||||
}}
|
||||
direction="vertical"
|
||||
className="h-full w-full"
|
||||
id="main-vertical"
|
||||
>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
|
||||
<ResizablePanelGroup ref={el => { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal">
|
||||
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[0].id}`} order={1}>
|
||||
<div ref={el => { panelRefs.current[String(layoutTabs[0].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id="top-panel" order={1}>
|
||||
<ResizablePanelGroup ref={el => {
|
||||
panelGroupRefs.current['top'] = el;
|
||||
}} direction="horizontal" className="h-full w-full" id="top-horizontal">
|
||||
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${layoutTabs[0].id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(layoutTabs[0].id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
@@ -314,9 +435,22 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
}}>{layoutTabs[0].title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[1].id}`} order={2}>
|
||||
<div ref={el => { panelRefs.current[String(layoutTabs[1].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
||||
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${layoutTabs[1].id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(layoutTabs[1].id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
@@ -334,11 +468,27 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}>
|
||||
<ResizablePanelGroup ref={el => { panelGroupRefs.current['bottom'] = el; }} direction="horizontal" className="h-full w-full" id="bottom-horizontal">
|
||||
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[2].id}`} order={1}>
|
||||
<div ref={el => { panelRefs.current[String(layoutTabs[2].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id="bottom-panel" order={2}>
|
||||
<ResizablePanelGroup ref={el => {
|
||||
panelGroupRefs.current['bottom'] = el;
|
||||
}} direction="horizontal" className="h-full w-full" id="bottom-horizontal">
|
||||
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${layoutTabs[2].id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(layoutTabs[2].id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
@@ -354,9 +504,22 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
}}>{layoutTabs[2].title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{ pointerEvents: 'auto', zIndex: 12 }} />
|
||||
<ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[3].id}`} order={2}>
|
||||
<div ref={el => { panelRefs.current[String(layoutTabs[3].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
|
||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
||||
<ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${layoutTabs[3].id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(layoutTabs[3].id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
@@ -424,24 +587,31 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden' }}>
|
||||
{/* Sidebar: fixed width */}
|
||||
<div style={{ width: 256, flexShrink: 0, height: '100vh', position: 'relative', zIndex: 2, margin: 0, padding: 0, border: 'none' }}>
|
||||
<SSHSidebar
|
||||
onSelectView={onSelectView}
|
||||
onAddHostSubmit={onAddHostSubmit}
|
||||
onHostConnect={onHostConnect}
|
||||
allTabs={allTabs}
|
||||
runCommandOnTabs={(tabIds: number[], command: string) => {
|
||||
allTabs.forEach(tab => {
|
||||
if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) {
|
||||
tab.terminalRef.current.sendInput(command);
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div style={{display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden'}}>
|
||||
<div style={{
|
||||
width: 256,
|
||||
flexShrink: 0,
|
||||
height: '100vh',
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
border: 'none'
|
||||
}}>
|
||||
<SSHSidebar
|
||||
onSelectView={onSelectView}
|
||||
onAddHostSubmit={onAddHostSubmit}
|
||||
onHostConnect={onHostConnect}
|
||||
allTabs={allTabs}
|
||||
runCommandOnTabs={(tabIds: number[], command: string) => {
|
||||
allTabs.forEach(tab => {
|
||||
if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) {
|
||||
tab.terminalRef.current.sendInput(command);
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Main area: fills the rest */}
|
||||
<div
|
||||
className="terminal-container"
|
||||
style={{
|
||||
@@ -454,20 +624,17 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Always render the topbar at the top */}
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', zIndex: 10 }}>
|
||||
<SSHTopbar
|
||||
allTabs={allTabs}
|
||||
<div style={{position: 'absolute', top: 0, left: 0, width: '100%', zIndex: 10}}>
|
||||
<SSHTopbar
|
||||
allTabs={allTabs}
|
||||
currentTab={currentTab ?? -1}
|
||||
setActiveTab={setActiveTab}
|
||||
allSplitScreenTab={allSplitScreenTab}
|
||||
setSplitScreenTab={setSplitScreenTab}
|
||||
setCloseTab={setCloseTab}
|
||||
/>
|
||||
</div>
|
||||
{/* Split area below the topbar */}
|
||||
<div style={{ height: 'calc(100% - 46px)', marginTop: 46, position: 'relative' }}>
|
||||
{/* Show alert when no terminals are rendered */}
|
||||
setActiveTab={setActiveTab}
|
||||
allSplitScreenTab={allSplitScreenTab}
|
||||
setSplitScreenTab={setSplitScreenTab}
|
||||
setCloseTab={setCloseTab}
|
||||
/>
|
||||
</div>
|
||||
<div style={{height: 'calc(100% - 46px)', marginTop: 46, position: 'relative'}}>
|
||||
{allTabs.length === 0 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
@@ -483,17 +650,17 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
maxWidth: '400px',
|
||||
zIndex: 30
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '12px' }}>
|
||||
<div style={{fontSize: '18px', fontWeight: 'bold', marginBottom: '12px'}}>
|
||||
Welcome to Termix SSH
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#a1a1aa', lineHeight: '1.5' }}>
|
||||
Click on any host title in the sidebar to open a terminal connection, or use the "Add Host" button to create a new connection.
|
||||
<div style={{fontSize: '14px', color: '#a1a1aa', lineHeight: '1.5'}}>
|
||||
Click on any host title in the sidebar to open a terminal connection, or use the "Add
|
||||
Host" button to create a new connection.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Absolutely render all terminals for persistence and layout */}
|
||||
{allSplitScreenTab.length > 0 && (
|
||||
<div style={{ position: 'absolute', top: 0, right: 0, zIndex: 20, height: 28 }}>
|
||||
<div style={{position: 'absolute', top: 0, right: 0, zIndex: 20, height: 28}}>
|
||||
<button
|
||||
style={{
|
||||
background: '#18181b',
|
||||
@@ -514,12 +681,10 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
onClick={() => {
|
||||
if (allSplitScreenTab.length === 1) {
|
||||
panelGroupRefs.current['main']?.setLayout([50, 50]);
|
||||
}
|
||||
else if (allSplitScreenTab.length === 2) {
|
||||
} else if (allSplitScreenTab.length === 2) {
|
||||
panelGroupRefs.current['main']?.setLayout([50, 50]);
|
||||
panelGroupRefs.current['top']?.setLayout([50, 50]);
|
||||
}
|
||||
else if (allSplitScreenTab.length === 3) {
|
||||
} else if (allSplitScreenTab.length === 3) {
|
||||
panelGroupRefs.current['main']?.setLayout([50, 50]);
|
||||
panelGroupRefs.current['top']?.setLayout([50, 50]);
|
||||
panelGroupRefs.current['bottom']?.setLayout([50, 50]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, {useState} from 'react';
|
||||
|
||||
import {
|
||||
CornerDownLeft,
|
||||
@@ -35,9 +35,9 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion.tsx";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { getSSHHosts } from "@/apps/SSH/ssh-axios-fixed";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {getSSHHosts} from "@/apps/SSH/ssh-axios";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -69,7 +69,7 @@ interface SidebarProps {
|
||||
runCommandOnTabs: (tabIds: number[], command: string) => void;
|
||||
}
|
||||
|
||||
export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnTabs }: SidebarProps): React.ReactElement {
|
||||
export function SSHSidebar({onSelectView, onHostConnect, allTabs, runCommandOnTabs}: SidebarProps): React.ReactElement {
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [hostsLoading, setHostsLoading] = useState(false);
|
||||
const [hostsError, setHostsError] = useState<string | null>(null);
|
||||
@@ -80,7 +80,6 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
|
||||
setHostsError(null);
|
||||
try {
|
||||
const newHosts = await getSSHHosts();
|
||||
// Filter hosts to only show those with enableTerminal: true
|
||||
const terminalHosts = newHosts.filter(host => host.enableTerminal);
|
||||
|
||||
const prevHosts = prevHostsRef.current;
|
||||
@@ -170,7 +169,6 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
|
||||
return [...pinned, ...rest];
|
||||
};
|
||||
|
||||
// Tools Sheet State
|
||||
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
||||
const [toolsCommand, setToolsCommand] = useState("");
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
||||
@@ -181,11 +179,10 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
|
||||
|
||||
const handleRunCommand = () => {
|
||||
if (selectedTabIds.length && toolsCommand.trim()) {
|
||||
// Ensure command ends with newline
|
||||
let cmd = toolsCommand;
|
||||
if (!cmd.endsWith("\n")) cmd += "\n";
|
||||
runCommandOnTabs(selectedTabIds, cmd);
|
||||
setToolsCommand(""); // Clear after run
|
||||
setToolsCommand("");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -197,7 +194,7 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Termix / Terminal
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SidebarGroupContent className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
<SidebarMenu className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
|
||||
@@ -207,15 +204,15 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
|
||||
onClick={() => onSelectView("homepage")}
|
||||
variant="outline"
|
||||
>
|
||||
<CornerDownLeft />
|
||||
<CornerDownLeft/>
|
||||
Return
|
||||
</Button>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem key="Main" className="flex flex-col flex-grow overflow-hidden">
|
||||
<div className="w-full flex-grow rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
|
||||
{/* Search bar */}
|
||||
<div
|
||||
className="w-full flex-grow rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
|
||||
<div className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10">
|
||||
<Input
|
||||
value={search}
|
||||
@@ -225,30 +222,41 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Separator className="w-full h-px bg-[#434345] my-2" style={{ maxWidth: 213, margin: '0 auto' }} />
|
||||
<div style={{display: 'flex', justifyContent: 'center'}}>
|
||||
<Separator className="w-full h-px bg-[#434345] my-2"
|
||||
style={{maxWidth: 213, margin: '0 auto'}}/>
|
||||
</div>
|
||||
{/* Error and status messages */}
|
||||
{hostsError && (
|
||||
<div className="px-2 py-1 mt-2">
|
||||
<div className="text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">{hostsError}</div>
|
||||
<div
|
||||
className="text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">{hostsError}</div>
|
||||
</div>
|
||||
)}
|
||||
{!hostsLoading && !hostsError && hosts.length === 0 && (
|
||||
<div className="px-2 py-1 mt-2">
|
||||
<div className="text-xs text-muted-foreground bg-muted/20 rounded px-2 py-1 border border-border/20">No hosts found.</div>
|
||||
<div
|
||||
className="text-xs text-muted-foreground bg-muted/20 rounded px-2 py-1 border border-border/20">No
|
||||
hosts found.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ScrollArea className="w-full h-full">
|
||||
<Accordion key={`host-accordion-${sortedFolders.length}`} type="multiple" className="w-full" defaultValue={sortedFolders.length > 0 ? sortedFolders : undefined}>
|
||||
<Accordion key={`host-accordion-${sortedFolders.length}`}
|
||||
type="multiple" className="w-full"
|
||||
defaultValue={sortedFolders.length > 0 ? sortedFolders : undefined}>
|
||||
{sortedFolders.map((folder, idx) => (
|
||||
<React.Fragment key={folder}>
|
||||
<AccordionItem value={folder} className={idx === 0 ? "mt-0 !border-b-transparent" : "mt-2 !border-b-transparent"}>
|
||||
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2" style={{marginTop: idx === 0 ? 0 : undefined}}>{folder}</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
|
||||
<AccordionItem value={folder}
|
||||
className={idx === 0 ? "mt-0 !border-b-transparent" : "mt-2 !border-b-transparent"}>
|
||||
<AccordionTrigger
|
||||
className="text-base font-semibold rounded-t-none px-3 py-2"
|
||||
style={{marginTop: idx === 0 ? 0 : undefined}}>{folder}</AccordionTrigger>
|
||||
<AccordionContent
|
||||
className="flex flex-col gap-1 px-3 pb-2 pt-1">
|
||||
{getSortedHosts(hostsByFolder[folder]).map(host => (
|
||||
<div key={host.id} className="w-full overflow-hidden">
|
||||
<div key={host.id}
|
||||
className="w-full overflow-hidden">
|
||||
<HostMenuItem
|
||||
host={host}
|
||||
onHostConnect={onHostConnect}
|
||||
@@ -258,8 +266,10 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
{idx < sortedFolders.length - 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Separator className="h-px bg-[#434345] my-1" style={{ width: 213 }} />
|
||||
<div
|
||||
style={{display: 'flex', justifyContent: 'center'}}>
|
||||
<Separator className="h-px bg-[#434345] my-1"
|
||||
style={{width: 213}}/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
@@ -271,7 +281,6 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
{/* Tools Button at the very bottom */}
|
||||
<div className="bg-sidebar">
|
||||
<Sheet open={toolsSheetOpen} onOpenChange={setToolsSheetOpen}>
|
||||
<SheetTrigger asChild>
|
||||
@@ -280,27 +289,32 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
|
||||
variant="outline"
|
||||
onClick={() => setToolsSheetOpen(true)}
|
||||
>
|
||||
<Hammer className="mr-2 h-4 w-4" />
|
||||
<Hammer className="mr-2 h-4 w-4"/>
|
||||
Tools
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col">
|
||||
<SheetContent side="left"
|
||||
className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col">
|
||||
<SheetHeader className="pb-0.5">
|
||||
<SheetTitle>Tools</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto px-2 pt-2">
|
||||
<Accordion type="single" collapsible defaultValue="multiwindow">
|
||||
<AccordionItem value="multiwindow">
|
||||
<AccordionTrigger className="text-base font-semibold">Run multiwindow commands</AccordionTrigger>
|
||||
<AccordionTrigger className="text-base font-semibold">Run multiwindow
|
||||
commands</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<textarea
|
||||
className="w-full min-h-[120px] max-h-48 rounded-md border border-input text-foreground p-2 text-sm font-mono resize-vertical focus:outline-none focus:ring-0"
|
||||
placeholder="Enter command(s) to run on selected tabs..."
|
||||
value={toolsCommand}
|
||||
onChange={e => setToolsCommand(e.target.value)}
|
||||
style={{ fontFamily: 'monospace', marginBottom: 8, background: '#141416' }}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
marginBottom: 8,
|
||||
background: '#141416'
|
||||
}}
|
||||
/>
|
||||
{/* Tab selection as tag-like buttons */}
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{allTabs.map(tab => (
|
||||
<Button
|
||||
@@ -337,29 +351,35 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT
|
||||
);
|
||||
}
|
||||
|
||||
const HostMenuItem = React.memo(function HostMenuItem({ host, onHostConnect }: { host: SSHHost; onHostConnect: (hostConfig: any) => void }) {
|
||||
const HostMenuItem = React.memo(function HostMenuItem({host, onHostConnect}: {
|
||||
host: SSHHost;
|
||||
onHostConnect: (hostConfig: any) => void
|
||||
}) {
|
||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||
const hasTags = tags.length > 0;
|
||||
return (
|
||||
<div className="relative group flex flex-col mb-1 w-full overflow-hidden">
|
||||
<div className={`flex flex-col w-full rounded overflow-hidden border border-[#434345] bg-[#18181b] h-full`}>
|
||||
<div className="flex w-full h-10">
|
||||
{/* Full width clickable area */}
|
||||
<div className="flex items-center h-full px-2 w-full hover:bg-muted transition-colors cursor-pointer"
|
||||
onClick={() => onHostConnect(host)}
|
||||
<div
|
||||
className="flex items-center h-full px-2 w-full hover:bg-muted transition-colors cursor-pointer"
|
||||
onClick={() => onHostConnect(host)}
|
||||
>
|
||||
<div className="flex items-center w-full">
|
||||
{host.pin &&
|
||||
<Pin className="h-4.5 mr-1 w-4.5 mt-0.5 text-yellow-500 flex-shrink-0" />
|
||||
<Pin className="h-4.5 mr-1 w-4.5 mt-0.5 text-yellow-500 flex-shrink-0"/>
|
||||
}
|
||||
<span className="font-medium truncate">{host.name || host.ip}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasTags && (
|
||||
<div className="border-t border-border bg-[#18181b] flex items-center gap-1 px-2 py-2 overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent" style={{ height: 30 }}>
|
||||
<div
|
||||
className="border-t border-border bg-[#18181b] flex items-center gap-1 px-2 py-2 overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
|
||||
style={{height: 30}}>
|
||||
{tags.map((tag: string) => (
|
||||
<span key={tag} className="bg-muted-foreground/10 text-xs rounded-full px-2 py-0.5 text-muted-foreground whitespace-nowrap border border-border flex-shrink-0 hover:bg-muted transition-colors">
|
||||
<span key={tag}
|
||||
className="bg-muted-foreground/10 text-xs rounded-full px-2 py-0.5 text-muted-foreground whitespace-nowrap border border-border flex-shrink-0 hover:bg-muted transition-colors">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -41,7 +41,6 @@ export function SSHTabList({
|
||||
className={index < allTabs.length - 1 ? "mr-[0.5rem]" : ""}
|
||||
>
|
||||
<div className="inline-flex rounded-md shadow-sm" role="group">
|
||||
{/* Set Active Tab Button */}
|
||||
<Button
|
||||
onClick={() => setActiveTab(terminal.id)}
|
||||
disabled={isSplit}
|
||||
@@ -51,24 +50,22 @@ export function SSHTabList({
|
||||
{terminal.title}
|
||||
</Button>
|
||||
|
||||
{/* Split Screen Button */}
|
||||
<Button
|
||||
onClick={() => setSplitScreenTab(terminal.id)}
|
||||
disabled={isSplitButtonDisabled || isActive}
|
||||
variant="outline"
|
||||
className="rounded-none p-0 !w-9 !h-9"
|
||||
>
|
||||
<SeparatorVertical className="!w-5 !h-5" strokeWidth={2.5} />
|
||||
<SeparatorVertical className="!w-5 !h-5" strokeWidth={2.5}/>
|
||||
</Button>
|
||||
|
||||
{/* Close Tab Button */}
|
||||
<Button
|
||||
onClick={() => setCloseTab(terminal.id)}
|
||||
disabled={(isSplitScreenActive && isActive) || isSplit}
|
||||
variant="outline"
|
||||
className="rounded-l-none p-0 !w-9 !h-9"
|
||||
>
|
||||
<X className="!w-5 !h-5" strokeWidth={2.5} />
|
||||
<X className="!w-5 !h-5" strokeWidth={2.5}/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
|
||||
import { useXTerm } from 'react-xtermjs';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
||||
import {useEffect, useRef, useState, useImperativeHandle, forwardRef} from 'react';
|
||||
import {useXTerm} from 'react-xtermjs';
|
||||
import {FitAddon} from '@xterm/addon-fit';
|
||||
import {ClipboardAddon} from '@xterm/addon-clipboard';
|
||||
|
||||
interface SSHTerminalProps {
|
||||
hostConfig: any;
|
||||
@@ -12,10 +12,10 @@ interface SSHTerminalProps {
|
||||
}
|
||||
|
||||
export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
{ hostConfig, isVisible, splitScreen = false },
|
||||
{hostConfig, isVisible, splitScreen = false},
|
||||
ref
|
||||
) {
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const {instance: terminal, ref: xtermRef} = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -34,7 +34,7 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
||||
},
|
||||
sendInput: (data: string) => {
|
||||
if (webSocketRef.current && webSocketRef.current.readyState === 1) {
|
||||
webSocketRef.current.send(JSON.stringify({ type: 'input', data }));
|
||||
webSocketRef.current.send(JSON.stringify({type: 'input', data}));
|
||||
}
|
||||
}
|
||||
}), []);
|
||||
@@ -43,6 +43,7 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
||||
function handleWindowResize() {
|
||||
fitAddonRef.current?.fit();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
return () => window.removeEventListener('resize', handleWindowResize);
|
||||
}, []);
|
||||
@@ -71,7 +72,7 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
||||
|
||||
const onResize = () => {
|
||||
if (!xtermRef.current) return;
|
||||
const { width, height } = xtermRef.current.getBoundingClientRect();
|
||||
const {width, height} = xtermRef.current.getBoundingClientRect();
|
||||
|
||||
if (width < 100 || height < 50) return;
|
||||
|
||||
@@ -84,7 +85,7 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
||||
|
||||
webSocketRef.current?.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
data: { cols, rows }
|
||||
data: {cols, rows}
|
||||
}));
|
||||
}, 100);
|
||||
};
|
||||
@@ -134,10 +135,8 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
|
||||
} else if (msg.type === 'error') {
|
||||
terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
||||
} else if (msg.type === 'connected') {
|
||||
/* nothing for now */
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse message', err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,14 @@ interface SSHTopbarProps {
|
||||
setCloseTab: (tab: number) => void;
|
||||
}
|
||||
|
||||
export function SSHTopbar({ allTabs, currentTab, setActiveTab, allSplitScreenTab, setSplitScreenTab, setCloseTab }: SSHTopbarProps): React.ReactElement {
|
||||
export function SSHTopbar({
|
||||
allTabs,
|
||||
currentTab,
|
||||
setActiveTab,
|
||||
allSplitScreenTab,
|
||||
setSplitScreenTab,
|
||||
setCloseTab
|
||||
}: SSHTopbarProps): React.ReactElement {
|
||||
return (
|
||||
<div className="flex h-11.5 z-100" style={{
|
||||
position: 'absolute',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { SSHTunnelSidebar } from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
|
||||
import { SSHTunnelViewer } from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
|
||||
import { getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel } from "@/apps/SSH/ssh-axios-fixed";
|
||||
import React, {useState, useEffect, useCallback} from "react";
|
||||
import {SSHTunnelSidebar} from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
|
||||
import {SSHTunnelViewer} from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
|
||||
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/apps/SSH/ssh-axios";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
onSelectView: (view: string) => void;
|
||||
@@ -49,27 +49,24 @@ interface TunnelStatus {
|
||||
retryExhausted?: boolean;
|
||||
}
|
||||
|
||||
export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement {
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
|
||||
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({}); // Track loading states
|
||||
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({});
|
||||
|
||||
const fetchHosts = useCallback(async () => {
|
||||
try {
|
||||
const hostsData = await getSSHHosts();
|
||||
setHosts(hostsData);
|
||||
} catch (err) {
|
||||
// Silent error handling
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Poll backend for tunnel statuses
|
||||
const fetchTunnelStatuses = useCallback(async () => {
|
||||
try {
|
||||
const statusData = await getTunnelStatuses();
|
||||
setTunnelStatuses(statusData);
|
||||
} catch (err) {
|
||||
// Silent error handling
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -88,14 +85,13 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
||||
const handleTunnelAction = async (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => {
|
||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||
|
||||
setTunnelActions(prev => ({ ...prev, [tunnelName]: true }));
|
||||
|
||||
|
||||
setTunnelActions(prev => ({...prev, [tunnelName]: true}));
|
||||
|
||||
try {
|
||||
if (action === 'connect') {
|
||||
// Find the endpoint host configuration
|
||||
const endpointHost = hosts.find(h =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
const endpointHost = hosts.find(h =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost
|
||||
);
|
||||
|
||||
@@ -103,7 +99,6 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
||||
throw new Error('Endpoint host not found');
|
||||
}
|
||||
|
||||
// Create tunnel configuration
|
||||
const tunnelConfig = {
|
||||
name: tunnelName,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
@@ -126,7 +121,7 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
||||
sourcePort: tunnel.sourcePort,
|
||||
endpointPort: tunnel.endpointPort,
|
||||
maxRetries: tunnel.maxRetries,
|
||||
retryInterval: tunnel.retryInterval * 1000, // Convert to milliseconds
|
||||
retryInterval: tunnel.retryInterval * 1000,
|
||||
autoStart: tunnel.autoStart,
|
||||
isPinned: host.pin
|
||||
};
|
||||
@@ -137,26 +132,23 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
|
||||
} else if (action === 'cancel') {
|
||||
await cancelTunnel(tunnelName);
|
||||
}
|
||||
|
||||
// Refresh statuses after action
|
||||
|
||||
await fetchTunnelStatuses();
|
||||
} catch (err) {
|
||||
console.error(`Failed to ${action} tunnel:`, err);
|
||||
// Let the backend handle error status updates
|
||||
} finally {
|
||||
setTunnelActions(prev => ({ ...prev, [tunnelName]: false }));
|
||||
setTunnelActions(prev => ({...prev, [tunnelName]: false}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full">
|
||||
<div className="w-64 flex-shrink-0">
|
||||
<SSHTunnelSidebar
|
||||
onSelectView={onSelectView}
|
||||
<SSHTunnelSidebar
|
||||
onSelectView={onSelectView}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<SSHTunnelViewer
|
||||
<SSHTunnelViewer
|
||||
hosts={hosts}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Loader2, Pin, Terminal, Network, FileEdit, Tag, Play, Square, AlertCircle, Clock, Wifi, WifiOff, Zap, X } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {
|
||||
Loader2,
|
||||
Pin,
|
||||
Terminal,
|
||||
Network,
|
||||
FileEdit,
|
||||
Tag,
|
||||
Play,
|
||||
Square,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Zap,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import {Badge} from "@/components/ui/badge.tsx";
|
||||
|
||||
const CONNECTION_STATES = {
|
||||
DISCONNECTED: "disconnected",
|
||||
@@ -62,12 +77,12 @@ interface SSHTunnelObjectProps {
|
||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||
}
|
||||
|
||||
export function SSHTunnelObject({
|
||||
host,
|
||||
tunnelStatuses,
|
||||
tunnelActions,
|
||||
onTunnelAction
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
export function SSHTunnelObject({
|
||||
host,
|
||||
tunnelStatuses,
|
||||
tunnelActions,
|
||||
onTunnelAction
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
|
||||
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||
@@ -76,70 +91,69 @@ export function SSHTunnelObject({
|
||||
};
|
||||
|
||||
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
|
||||
if (!status) return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
text: 'Unknown',
|
||||
if (!status) return {
|
||||
icon: <WifiOff className="h-4 w-4"/>,
|
||||
text: 'Unknown',
|
||||
color: 'text-muted-foreground',
|
||||
bgColor: 'bg-muted/50',
|
||||
borderColor: 'border-border'
|
||||
};
|
||||
|
||||
// Handle both the old format (status.status) and new format (status.status)
|
||||
|
||||
const statusValue = status.status || 'DISCONNECTED';
|
||||
|
||||
|
||||
switch (statusValue.toUpperCase()) {
|
||||
case 'CONNECTED':
|
||||
return {
|
||||
icon: <Wifi className="h-4 w-4" />,
|
||||
text: 'Connected',
|
||||
return {
|
||||
icon: <Wifi className="h-4 w-4"/>,
|
||||
text: 'Connected',
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bgColor: 'bg-green-500/10 dark:bg-green-400/10',
|
||||
borderColor: 'border-green-500/20 dark:border-green-400/20'
|
||||
};
|
||||
case 'CONNECTING':
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||
text: 'Connecting...',
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
|
||||
text: 'Connecting...',
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
|
||||
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
|
||||
};
|
||||
case 'DISCONNECTING':
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||
text: 'Disconnecting...',
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
|
||||
text: 'Disconnecting...',
|
||||
color: 'text-orange-600 dark:text-orange-400',
|
||||
bgColor: 'bg-orange-500/10 dark:bg-orange-400/10',
|
||||
borderColor: 'border-orange-500/20 dark:border-orange-400/20'
|
||||
};
|
||||
case 'DISCONNECTED':
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
text: 'Disconnected',
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4"/>,
|
||||
text: 'Disconnected',
|
||||
color: 'text-muted-foreground',
|
||||
bgColor: 'bg-muted/30',
|
||||
borderColor: 'border-border'
|
||||
};
|
||||
case 'WAITING':
|
||||
return {
|
||||
icon: <Clock className="h-4 w-4" />,
|
||||
icon: <Clock className="h-4 w-4"/>,
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
|
||||
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
|
||||
};
|
||||
case 'ERROR':
|
||||
case 'FAILED':
|
||||
return {
|
||||
icon: <AlertCircle className="h-4 w-4" />,
|
||||
text: status.reason || 'Error',
|
||||
return {
|
||||
icon: <AlertCircle className="h-4 w-4"/>,
|
||||
text: status.reason || 'Error',
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
bgColor: 'bg-red-500/10 dark:bg-red-400/10',
|
||||
borderColor: 'border-red-500/20 dark:border-red-400/20'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
text: statusValue,
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4"/>,
|
||||
text: statusValue,
|
||||
color: 'text-muted-foreground',
|
||||
bgColor: 'bg-muted/30',
|
||||
borderColor: 'border-border'
|
||||
@@ -153,7 +167,7 @@ export function SSHTunnelObject({
|
||||
{/* Host Header */}
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0" />}
|
||||
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-card-foreground truncate">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
@@ -164,13 +178,13 @@ export function SSHTunnelObject({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Tags */}
|
||||
{host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{host.tags.slice(0, 3).map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
|
||||
<Tag className="h-2 w-2 mr-0.5" />
|
||||
<Tag className="h-2 w-2 mr-0.5"/>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -181,13 +195,13 @@ export function SSHTunnelObject({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="mb-3" />
|
||||
|
||||
|
||||
<Separator className="mb-3"/>
|
||||
|
||||
{/* Tunnel Connections */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||
<Network className="h-4 w-4" />
|
||||
<Network className="h-4 w-4"/>
|
||||
Tunnel Connections ({host.tunnelConnections.length})
|
||||
</h4>
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
@@ -203,9 +217,10 @@ export function SSHTunnelObject({
|
||||
const isDisconnecting = statusValue === 'DISCONNECTING';
|
||||
const isRetrying = statusValue === 'RETRYING';
|
||||
const isWaiting = statusValue === 'WAITING';
|
||||
|
||||
|
||||
return (
|
||||
<div key={tunnelIndex} className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
||||
<div key={tunnelIndex}
|
||||
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
||||
{/* Tunnel Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
@@ -224,7 +239,7 @@ export function SSHTunnelObject({
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{tunnel.autoStart && (
|
||||
<Badge variant="outline" className="text-xs px-2 py-1">
|
||||
<Zap className="h-3 w-3 mr-1" />
|
||||
<Zap className="h-3 w-3 mr-1"/>
|
||||
Auto
|
||||
</Badge>
|
||||
)}
|
||||
@@ -239,7 +254,7 @@ export function SSHTunnelObject({
|
||||
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
<Square className="h-3 w-3 mr-1"/>
|
||||
Disconnect
|
||||
</Button>
|
||||
</>
|
||||
@@ -250,7 +265,7 @@ export function SSHTunnelObject({
|
||||
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
|
||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
<X className="h-3 w-3 mr-1"/>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
@@ -261,7 +276,7 @@ export function SSHTunnelObject({
|
||||
disabled={isConnecting || isDisconnecting}
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
<Play className="h-3 w-3 mr-1"/>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
@@ -274,31 +289,42 @@ export function SSHTunnelObject({
|
||||
disabled
|
||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin"/>
|
||||
{isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Error/Status Reason */}
|
||||
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
<div
|
||||
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
<div className="font-medium mb-1">Error:</div>
|
||||
{status.reason}
|
||||
{status.reason && status.reason.includes('Max retries exhausted') && (
|
||||
<>
|
||||
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||
Check your Docker logs for the error reason, join the <a href="https://discord.com/invite/jVQGdvHDrf" target="_blank" rel="noopener noreferrer" className="underline text-blue-600 dark:text-blue-400">Discord</a> or create a <a href="https://github.com/LukeGus/Termix/issues/new" target="_blank" rel="noopener noreferrer" className="underline text-blue-600 dark:text-blue-400">GitHub issue</a> for help.
|
||||
<div
|
||||
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||
Check your Docker logs for the error reason, join the <a
|
||||
href="https://discord.com/invite/jVQGdvHDrf" target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400">Discord</a> or
|
||||
create a <a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400">GitHub
|
||||
issue</a> for help.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Retry Info */}
|
||||
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
|
||||
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
<div
|
||||
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'}
|
||||
</div>
|
||||
@@ -316,7 +342,7 @@ export function SSHTunnelObject({
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50"/>
|
||||
<p className="text-sm">No tunnel connections configured</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -27,7 +27,7 @@ interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
export function SSHTunnelSidebar({ onSelectView }: SidebarProps): React.ReactElement {
|
||||
export function SSHTunnelSidebar({onSelectView}: SidebarProps): React.ReactElement {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
@@ -36,16 +36,17 @@ export function SSHTunnelSidebar({ onSelectView }: SidebarProps): React.ReactEle
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Termix / Tunnel
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
||||
<SidebarMenu>
|
||||
|
||||
<SidebarMenuItem key={"Homepage"}>
|
||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")} variant="outline">
|
||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
|
||||
variant="outline">
|
||||
<CornerDownLeft className="h-4 w-4 mr-2"/>
|
||||
Return
|
||||
</Button>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
</SidebarMenuItem>
|
||||
|
||||
</SidebarMenu>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from "react";
|
||||
import { SSHTunnelObject } from "./SSHTunnelObject.tsx";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Search } from "lucide-react";
|
||||
import {SSHTunnelObject} from "./SSHTunnelObject.tsx";
|
||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {Search} from "lucide-react";
|
||||
|
||||
interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
@@ -50,25 +50,23 @@ interface SSHTunnelViewerProps {
|
||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||
}
|
||||
|
||||
export function SSHTunnelViewer({
|
||||
hosts = [],
|
||||
tunnelStatuses = {},
|
||||
tunnelActions = {},
|
||||
onTunnelAction
|
||||
}: SSHTunnelViewerProps): React.ReactElement {
|
||||
export function SSHTunnelViewer({
|
||||
hosts = [],
|
||||
tunnelStatuses = {},
|
||||
tunnelActions = {},
|
||||
onTunnelAction
|
||||
}: SSHTunnelViewerProps): React.ReactElement {
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||
|
||||
// Debounce search
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Filter hosts by search query
|
||||
const filteredHosts = React.useMemo(() => {
|
||||
if (!debouncedSearch.trim()) return hosts;
|
||||
|
||||
|
||||
const query = debouncedSearch.trim().toLowerCase();
|
||||
return hosts.filter(host => {
|
||||
const searchableText = [
|
||||
@@ -84,16 +82,14 @@ export function SSHTunnelViewer({
|
||||
});
|
||||
}, [hosts, debouncedSearch]);
|
||||
|
||||
// Filter hosts to only show those with enableTunnel: true and tunnelConnections
|
||||
const tunnelHosts = React.useMemo(() => {
|
||||
return filteredHosts.filter(host =>
|
||||
host.enableTunnel &&
|
||||
host.tunnelConnections &&
|
||||
return filteredHosts.filter(host =>
|
||||
host.enableTunnel &&
|
||||
host.tunnelConnections &&
|
||||
host.tunnelConnections.length > 0
|
||||
);
|
||||
}, [filteredHosts]);
|
||||
|
||||
// Group hosts by folder and sort
|
||||
const hostsByFolder = React.useMemo(() => {
|
||||
const map: Record<string, SSHHost[]> = {};
|
||||
tunnelHosts.forEach(host => {
|
||||
@@ -121,9 +117,8 @@ export function SSHTunnelViewer({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full p-6" style={{ width: 'calc(100vw - 256px)', maxWidth: 'none' }}>
|
||||
<div className="w-full min-w-0" style={{ width: '100%', maxWidth: 'none' }}>
|
||||
{/* Header */}
|
||||
<div className="w-full p-6" style={{width: 'calc(100vw - 256px)', maxWidth: 'none'}}>
|
||||
<div className="w-full min-w-0" style={{width: '100%', maxWidth: 'none'}}>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">
|
||||
SSH Tunnels
|
||||
@@ -133,9 +128,9 @@ export function SSHTunnelViewer({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||
<Input
|
||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||
value={searchQuery}
|
||||
@@ -144,14 +139,13 @@ export function SSHTunnelViewer({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Accordion Layout */}
|
||||
{tunnelHosts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
No SSH Tunnels
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{searchQuery.trim() ?
|
||||
{searchQuery.trim() ?
|
||||
"No hosts match your search criteria." :
|
||||
"Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections."
|
||||
}
|
||||
@@ -160,8 +154,10 @@ export function SSHTunnelViewer({
|
||||
) : (
|
||||
<Accordion type="multiple" className="w-full" defaultValue={sortedFolders}>
|
||||
{sortedFolders.map((folder, idx) => (
|
||||
<AccordionItem value={folder} key={`folder-${folder}`} className={idx === 0 ? "mt-0" : "mt-2"}>
|
||||
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2" style={{marginTop: idx === 0 ? 0 : undefined}}>
|
||||
<AccordionItem value={folder} key={`folder-${folder}`}
|
||||
className={idx === 0 ? "mt-0" : "mt-2"}>
|
||||
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2"
|
||||
style={{marginTop: idx === 0 ? 0 : undefined}}>
|
||||
{folder}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
|
||||
@@ -185,4 +181,4 @@ export function SSHTunnelViewer({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// SSH Host Management API functions
|
||||
import axios from 'axios';
|
||||
|
||||
interface SSHHostData {
|
||||
@@ -81,11 +80,9 @@ interface TunnelStatus {
|
||||
retryExhausted?: boolean;
|
||||
}
|
||||
|
||||
// Determine the base URL based on environment
|
||||
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
const baseURL = isLocalhost ? 'http://localhost:8081' : window.location.origin;
|
||||
|
||||
// Create axios instance with base configuration for database operations (port 8081)
|
||||
const api = axios.create({
|
||||
baseURL,
|
||||
headers: {
|
||||
@@ -93,7 +90,6 @@ const api = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Create config editor API instance for file operations (port 8084)
|
||||
const configEditorApi = axios.create({
|
||||
baseURL: isLocalhost ? 'http://localhost:8084' : `${window.location.origin}/ssh`,
|
||||
headers: {
|
||||
@@ -101,7 +97,6 @@ const configEditorApi = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Create tunnel API instance
|
||||
const tunnelApi = axios.create({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -114,7 +109,6 @@ function getCookie(name: string): string | undefined {
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift();
|
||||
}
|
||||
|
||||
// Add request interceptor to include JWT token for all API instances
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getCookie('jwt');
|
||||
if (token) {
|
||||
@@ -139,21 +133,17 @@ tunnelApi.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
// Get all SSH hosts - FIXED: Changed from /ssh/host to /ssh/db/host
|
||||
export async function getSSHHosts(): Promise<SSHHost[]> {
|
||||
try {
|
||||
const response = await api.get('/ssh/db/host');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching SSH hosts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new SSH host
|
||||
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
try {
|
||||
// Prepare the data according to your backend schema
|
||||
const submitData = {
|
||||
name: hostData.name || '',
|
||||
ip: hostData.ip,
|
||||
@@ -186,7 +176,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
const formData = new FormData();
|
||||
formData.append('key', hostData.key);
|
||||
|
||||
const dataWithoutFile = { ...submitData };
|
||||
const dataWithoutFile = {...submitData};
|
||||
delete dataWithoutFile.key;
|
||||
formData.append('data', JSON.stringify(dataWithoutFile));
|
||||
|
||||
@@ -202,12 +192,10 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
return response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating SSH host:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update existing SSH host
|
||||
export async function updateSSHHost(hostId: number, hostData: SSHHostData): Promise<SSHHost> {
|
||||
try {
|
||||
const submitData = {
|
||||
@@ -241,7 +229,7 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
|
||||
const formData = new FormData();
|
||||
formData.append('key', hostData.key);
|
||||
|
||||
const dataWithoutFile = { ...submitData };
|
||||
const dataWithoutFile = {...submitData};
|
||||
delete dataWithoutFile.key;
|
||||
formData.append('data', JSON.stringify(dataWithoutFile));
|
||||
|
||||
@@ -257,41 +245,34 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
|
||||
return response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating SSH host:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete SSH host
|
||||
export async function deleteSSHHost(hostId: number): Promise<any> {
|
||||
try {
|
||||
const response = await api.delete(`/ssh/db/host/${hostId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error deleting SSH host:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get SSH host by ID
|
||||
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
||||
try {
|
||||
const response = await api.get(`/ssh/db/host/${hostId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching SSH host:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Tunnel-related functions
|
||||
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
|
||||
try {
|
||||
const tunnelUrl = isLocalhost ? 'http://localhost:8083/status' : `${baseURL}/ssh_tunnel/status`;
|
||||
const tunnelUrl = isLocalhost ? 'http://localhost:8083/ssh/tunnel/status' : `${baseURL}/ssh_tunnel/status`;
|
||||
const response = await tunnelApi.get(tunnelUrl);
|
||||
return response.data || {};
|
||||
} catch (error) {
|
||||
console.error('Error fetching tunnel statuses:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -303,40 +284,36 @@ export async function getTunnelStatusByName(tunnelName: string): Promise<TunnelS
|
||||
|
||||
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
|
||||
try {
|
||||
const tunnelUrl = isLocalhost ? 'http://localhost:8083/connect' : `${baseURL}/ssh_tunnel/connect`;
|
||||
const tunnelUrl = isLocalhost ? 'http://localhost:8083/ssh/tunnel/connect' : `${baseURL}/ssh_tunnel/connect`;
|
||||
const response = await tunnelApi.post(tunnelUrl, tunnelConfig);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error connecting tunnel:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectTunnel(tunnelName: string): Promise<any> {
|
||||
try {
|
||||
const tunnelUrl = isLocalhost ? 'http://localhost:8083/disconnect' : `${baseURL}/ssh_tunnel/disconnect`;
|
||||
const response = await tunnelApi.post(tunnelUrl, { tunnelName });
|
||||
const tunnelUrl = isLocalhost ? 'http://localhost:8083/ssh/tunnel/disconnect' : `${baseURL}/ssh_tunnel/disconnect`;
|
||||
const response = await tunnelApi.post(tunnelUrl, {tunnelName});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting tunnel:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelTunnel(tunnelName: string): Promise<any> {
|
||||
try {
|
||||
const tunnelUrl = isLocalhost ? 'http://localhost:8083/cancel' : `${baseURL}/ssh_tunnel/cancel`;
|
||||
const response = await tunnelApi.post(tunnelUrl, { tunnelName });
|
||||
const tunnelUrl = isLocalhost ? 'http://localhost:8083/ssh/tunnel/cancel' : `${baseURL}/ssh_tunnel/cancel`;
|
||||
const response = await tunnelApi.post(tunnelUrl, {tunnelName});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error canceling tunnel:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export { api, configEditorApi };
|
||||
export {api, configEditorApi};
|
||||
|
||||
// Config Editor API functions
|
||||
interface ConfigEditorFile {
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -350,32 +327,42 @@ interface ConfigEditorShortcut {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Config Editor database functions (use port 8081 for database operations)
|
||||
export async function getConfigEditorRecent(hostId: number): Promise<ConfigEditorFile[]> {
|
||||
try {
|
||||
const response = await api.get(`/ssh/config_editor/recent?hostId=${hostId}`);
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching recent files:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addConfigEditorRecent(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
|
||||
export async function addConfigEditorRecent(file: {
|
||||
name: string;
|
||||
path: string;
|
||||
isSSH: boolean;
|
||||
sshSessionId?: string;
|
||||
hostId: number
|
||||
}): Promise<any> {
|
||||
try {
|
||||
const response = await api.post('/ssh/config_editor/recent', file);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error adding recent file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeConfigEditorRecent(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
|
||||
export async function removeConfigEditorRecent(file: {
|
||||
name: string;
|
||||
path: string;
|
||||
isSSH: boolean;
|
||||
sshSessionId?: string;
|
||||
hostId: number
|
||||
}): Promise<any> {
|
||||
try {
|
||||
const response = await api.delete('/ssh/config_editor/recent', { data: file });
|
||||
const response = await api.delete('/ssh/config_editor/recent', {data: file});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error removing recent file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,26 +371,37 @@ export async function getConfigEditorPinned(hostId: number): Promise<ConfigEdito
|
||||
const response = await api.get(`/ssh/config_editor/pinned?hostId=${hostId}`);
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching pinned files:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addConfigEditorPinned(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
|
||||
export async function addConfigEditorPinned(file: {
|
||||
name: string;
|
||||
path: string;
|
||||
isSSH: boolean;
|
||||
sshSessionId?: string;
|
||||
hostId: number
|
||||
}): Promise<any> {
|
||||
try {
|
||||
const response = await api.post('/ssh/config_editor/pinned', file);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error adding pinned file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeConfigEditorPinned(file: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
|
||||
export async function removeConfigEditorPinned(file: {
|
||||
name: string;
|
||||
path: string;
|
||||
isSSH: boolean;
|
||||
sshSessionId?: string;
|
||||
hostId: number
|
||||
}): Promise<any> {
|
||||
try {
|
||||
const response = await api.delete('/ssh/config_editor/pinned', { data: file });
|
||||
const response = await api.delete('/ssh/config_editor/pinned', {data: file});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error removing pinned file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,30 +410,40 @@ export async function getConfigEditorShortcuts(hostId: number): Promise<ConfigEd
|
||||
const response = await api.get(`/ssh/config_editor/shortcuts?hostId=${hostId}`);
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching shortcuts:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addConfigEditorShortcut(shortcut: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
|
||||
export async function addConfigEditorShortcut(shortcut: {
|
||||
name: string;
|
||||
path: string;
|
||||
isSSH: boolean;
|
||||
sshSessionId?: string;
|
||||
hostId: number
|
||||
}): Promise<any> {
|
||||
try {
|
||||
const response = await api.post('/ssh/config_editor/shortcuts', shortcut);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error adding shortcut:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeConfigEditorShortcut(shortcut: { name: string; path: string; isSSH: boolean; sshSessionId?: string; hostId: number }): Promise<any> {
|
||||
export async function removeConfigEditorShortcut(shortcut: {
|
||||
name: string;
|
||||
path: string;
|
||||
isSSH: boolean;
|
||||
sshSessionId?: string;
|
||||
hostId: number
|
||||
}): Promise<any> {
|
||||
try {
|
||||
const response = await api.delete('/ssh/config_editor/shortcuts', { data: shortcut });
|
||||
const response = await api.delete('/ssh/config_editor/shortcuts', {data: shortcut});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error removing shortcut:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// SSH file operations - FIXED: Using configEditorApi for port 8084
|
||||
export async function connectSSH(sessionId: string, config: {
|
||||
ip: string;
|
||||
port: number;
|
||||
@@ -451,17 +459,15 @@ export async function connectSSH(sessionId: string, config: {
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error connecting SSH:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectSSH(sessionId: string): Promise<any> {
|
||||
try {
|
||||
const response = await configEditorApi.post('/ssh/config_editor/ssh/disconnect', { sessionId });
|
||||
const response = await configEditorApi.post('/ssh/config_editor/ssh/disconnect', {sessionId});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting SSH:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -469,11 +475,10 @@ export async function disconnectSSH(sessionId: string): Promise<any> {
|
||||
export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
|
||||
try {
|
||||
const response = await configEditorApi.get('/ssh/config_editor/ssh/status', {
|
||||
params: { sessionId }
|
||||
params: {sessionId}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting SSH status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -481,11 +486,10 @@ export async function getSSHStatus(sessionId: string): Promise<{ connected: bool
|
||||
export async function listSSHFiles(sessionId: string, path: string): Promise<any[]> {
|
||||
try {
|
||||
const response = await configEditorApi.get('/ssh/config_editor/ssh/listFiles', {
|
||||
params: { sessionId, path }
|
||||
params: {sessionId, path}
|
||||
});
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
console.error('Error listing SSH files:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -493,11 +497,10 @@ export async function listSSHFiles(sessionId: string, path: string): Promise<any
|
||||
export async function readSSHFile(sessionId: string, path: string): Promise<{ content: string; path: string }> {
|
||||
try {
|
||||
const response = await configEditorApi.get('/ssh/config_editor/ssh/readFile', {
|
||||
params: { sessionId, path }
|
||||
params: {sessionId, path}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error reading SSH file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -509,9 +512,13 @@ export async function writeSSHFile(sessionId: string, path: string, content: str
|
||||
path,
|
||||
content
|
||||
});
|
||||
return response.data;
|
||||
|
||||
if (response.data && (response.data.message === 'File written successfully' || response.status === 200)) {
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error('File write operation did not return success status');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error writing SSH file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ interface ConfigEditorProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
export function Template({ onSelectView }: ConfigEditorProps): React.ReactElement {
|
||||
export function Template({onSelectView}: ConfigEditorProps): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<TemplateSidebar
|
||||
|
||||
@@ -26,7 +26,7 @@ interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
export function TemplateSidebar({ onSelectView }: SidebarProps): React.ReactElement {
|
||||
export function TemplateSidebar({onSelectView}: SidebarProps): React.ReactElement {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
@@ -35,17 +35,17 @@ export function TemplateSidebar({ onSelectView }: SidebarProps): React.ReactElem
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Termix / Template
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
||||
<SidebarMenu>
|
||||
|
||||
{/* Sidebar Items */}
|
||||
<SidebarMenuItem key={"Homepage"}>
|
||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")} variant="outline">
|
||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
|
||||
variant="outline">
|
||||
<CornerDownLeft/>
|
||||
Return
|
||||
</Button>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
</SidebarMenuItem>
|
||||
|
||||
</SidebarMenu>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { Client as SSHClient } from 'ssh2';
|
||||
import {Client as SSHClient} from 'ssh2';
|
||||
import chalk from "chalk";
|
||||
|
||||
const app = express();
|
||||
@@ -38,23 +38,25 @@ const logger = {
|
||||
}
|
||||
};
|
||||
|
||||
// --- SSH Operations (per-session, in-memory, with cleanup) ---
|
||||
interface SSHSession {
|
||||
client: SSHClient;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
const sshSessions: Record<string, SSHSession> = {};
|
||||
const SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const SESSION_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function cleanupSession(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
try { session.client.end(); } catch {}
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {
|
||||
}
|
||||
clearTimeout(session.timeout);
|
||||
delete sshSessions[sessionId];
|
||||
logger.info(`Cleaned up SSH session: ${sessionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,129 +69,111 @@ function scheduleSessionCleanup(sessionId: string) {
|
||||
}
|
||||
|
||||
app.post('/ssh/config_editor/ssh/connect', (req, res) => {
|
||||
const { sessionId, ip, port, username, password, sshKey, keyPassword } = req.body;
|
||||
const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body;
|
||||
if (!sessionId || !ip || !username || !port) {
|
||||
logger.warn('Missing SSH connection parameters');
|
||||
return res.status(400).json({ error: 'Missing SSH connection parameters' });
|
||||
return res.status(400).json({error: 'Missing SSH connection parameters'});
|
||||
}
|
||||
|
||||
logger.info(`Attempting SSH connection: ${ip}:${port} as ${username} (session: ${sessionId})`);
|
||||
logger.info(`Auth method: ${sshKey ? 'SSH Key' : password ? 'Password' : 'None'}`);
|
||||
logger.info(`Request body keys: ${Object.keys(req.body).join(', ')}`);
|
||||
logger.info(`Password present: ${!!password}, Key present: ${!!sshKey}`);
|
||||
|
||||
|
||||
if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId);
|
||||
const client = new SSHClient();
|
||||
const config: any = {
|
||||
host: ip,
|
||||
port: port || 22,
|
||||
host: ip,
|
||||
port: port || 22,
|
||||
username,
|
||||
readyTimeout: 20000,
|
||||
keepaliveInterval: 10000,
|
||||
readyTimeout: 20000,
|
||||
keepaliveInterval: 10000,
|
||||
keepaliveCountMax: 3,
|
||||
};
|
||||
|
||||
if (sshKey && sshKey.trim()) {
|
||||
config.privateKey = sshKey;
|
||||
if (keyPassword) config.passphrase = keyPassword;
|
||||
logger.info('Using SSH key authentication');
|
||||
|
||||
if (sshKey && sshKey.trim()) {
|
||||
config.privateKey = sshKey;
|
||||
if (keyPassword) config.passphrase = keyPassword;
|
||||
} else if (password && password.trim()) {
|
||||
config.password = password;
|
||||
} else {
|
||||
return res.status(400).json({error: 'Either password or SSH key must be provided'});
|
||||
}
|
||||
else if (password && password.trim()) {
|
||||
config.password = password;
|
||||
logger.info('Using password authentication');
|
||||
}
|
||||
else {
|
||||
logger.warn('No password or key provided');
|
||||
return res.status(400).json({ error: 'Either password or SSH key must be provided' });
|
||||
}
|
||||
|
||||
// Create a response promise to handle async connection
|
||||
|
||||
let responseSent = false;
|
||||
|
||||
|
||||
client.on('ready', () => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
sshSessions[sessionId] = { client, isConnected: true, lastActive: Date.now() };
|
||||
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
logger.info(`SSH connected: ${ip}:${port} as ${username} (session: ${sessionId})`);
|
||||
res.json({ status: 'success', message: 'SSH connection established' });
|
||||
res.json({status: 'success', message: 'SSH connection established'});
|
||||
});
|
||||
|
||||
|
||||
client.on('error', (err) => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
logger.error(`SSH connection error for session ${sessionId}:`, err.message);
|
||||
logger.error(`Connection details: ${ip}:${port} as ${username}`);
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
res.status(500).json({status: 'error', message: err.message});
|
||||
});
|
||||
|
||||
|
||||
client.on('close', () => {
|
||||
logger.info(`SSH connection closed for session ${sessionId}`);
|
||||
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
|
||||
cleanupSession(sessionId);
|
||||
});
|
||||
|
||||
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
app.post('/ssh/config_editor/ssh/disconnect', (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
const {sessionId} = req.body;
|
||||
cleanupSession(sessionId);
|
||||
res.json({ status: 'success', message: 'SSH connection disconnected' });
|
||||
res.json({status: 'success', message: 'SSH connection disconnected'});
|
||||
});
|
||||
|
||||
app.get('/ssh/config_editor/ssh/status', (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const isConnected = !!sshSessions[sessionId]?.isConnected;
|
||||
res.json({ status: 'success', connected: isConnected });
|
||||
res.json({status: 'success', connected: isConnected});
|
||||
});
|
||||
|
||||
app.get('/ssh/config_editor/ssh/listFiles', (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const sshPath = decodeURIComponent((req.query.path as string) || '/');
|
||||
|
||||
|
||||
if (!sessionId) {
|
||||
logger.warn('Session ID is required for listFiles');
|
||||
return res.status(400).json({ error: 'Session ID is required' });
|
||||
return res.status(400).json({error: 'Session ID is required'});
|
||||
}
|
||||
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
logger.warn(`SSH connection not established for session: ${sessionId}`);
|
||||
return res.status(400).json({ error: 'SSH connection not established' });
|
||||
return res.status(400).json({error: 'SSH connection not established'});
|
||||
}
|
||||
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
// Escape the path properly for shell command
|
||||
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('SSH listFiles error:', err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
if (err) {
|
||||
logger.error('SSH listFiles error:', err);
|
||||
return res.status(500).json({error: err.message});
|
||||
}
|
||||
|
||||
|
||||
let data = '';
|
||||
let errorData = '';
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
|
||||
stream.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH listFiles command failed with code ${code}: ${errorData}`);
|
||||
return res.status(500).json({ error: `Command failed: ${errorData}` });
|
||||
logger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||
}
|
||||
|
||||
|
||||
const lines = data.split('\n').filter(line => line.trim());
|
||||
const files = [];
|
||||
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const parts = line.split(/\s+/);
|
||||
@@ -198,17 +182,16 @@ app.get('/ssh/config_editor/ssh/listFiles', (req, res) => {
|
||||
const name = parts.slice(8).join(' ');
|
||||
const isDirectory = permissions.startsWith('d');
|
||||
const isLink = permissions.startsWith('l');
|
||||
|
||||
// Skip . and .. directories
|
||||
|
||||
if (name === '.' || name === '..') continue;
|
||||
|
||||
files.push({
|
||||
name,
|
||||
type: isDirectory ? 'directory' : (isLink ? 'link' : 'file')
|
||||
|
||||
files.push({
|
||||
name,
|
||||
type: isDirectory ? 'directory' : (isLink ? 'link' : 'file')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
res.json(files);
|
||||
});
|
||||
});
|
||||
@@ -218,226 +201,188 @@ app.get('/ssh/config_editor/ssh/readFile', (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const filePath = decodeURIComponent(req.query.path as string);
|
||||
|
||||
|
||||
if (!sessionId) {
|
||||
logger.warn('Session ID is required for readFile');
|
||||
return res.status(400).json({ error: 'Session ID is required' });
|
||||
return res.status(400).json({error: 'Session ID is required'});
|
||||
}
|
||||
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
logger.warn(`SSH connection not established for session: ${sessionId}`);
|
||||
return res.status(400).json({ error: 'SSH connection not established' });
|
||||
return res.status(400).json({error: 'SSH connection not established'});
|
||||
}
|
||||
|
||||
|
||||
if (!filePath) {
|
||||
logger.warn('File path is required for readFile');
|
||||
return res.status(400).json({ error: 'File path is required' });
|
||||
return res.status(400).json({error: 'File path is required'});
|
||||
}
|
||||
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
// Escape the file path properly
|
||||
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('SSH readFile error:', err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
if (err) {
|
||||
logger.error('SSH readFile error:', err);
|
||||
return res.status(500).json({error: err.message});
|
||||
}
|
||||
|
||||
|
||||
let data = '';
|
||||
let errorData = '';
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
|
||||
stream.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH readFile command failed with code ${code}: ${errorData}`);
|
||||
return res.status(500).json({ error: `Command failed: ${errorData}` });
|
||||
logger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||
}
|
||||
|
||||
res.json({ content: data, path: filePath });
|
||||
|
||||
res.json({content: data, path: filePath});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/ssh/config_editor/ssh/writeFile', (req, res) => {
|
||||
const { sessionId, path: filePath, content } = req.body;
|
||||
const {sessionId, path: filePath, content} = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
|
||||
if (!sessionId) {
|
||||
logger.warn('Session ID is required for writeFile');
|
||||
return res.status(400).json({ error: 'Session ID is required' });
|
||||
return res.status(400).json({error: 'Session ID is required'});
|
||||
}
|
||||
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
logger.warn(`SSH connection not established for session: ${sessionId}`);
|
||||
return res.status(400).json({ error: 'SSH connection not established' });
|
||||
return res.status(400).json({error: 'SSH connection not established'});
|
||||
}
|
||||
|
||||
logger.info(`SSH connection status for session ${sessionId}: connected=${sshConn.isConnected}, lastActive=${new Date(sshConn.lastActive).toISOString()}`);
|
||||
|
||||
|
||||
if (!filePath) {
|
||||
logger.warn('File path is required for writeFile');
|
||||
return res.status(400).json({ error: 'File path is required' });
|
||||
return res.status(400).json({error: 'File path is required'});
|
||||
}
|
||||
|
||||
|
||||
if (content === undefined) {
|
||||
logger.warn('File content is required for writeFile');
|
||||
return res.status(400).json({ error: 'File content is required' });
|
||||
return res.status(400).json({error: 'File content is required'});
|
||||
}
|
||||
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
// Write to a temp file, then move - properly escape paths and content
|
||||
|
||||
const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
|
||||
const escapedFilePath = filePath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
// Use base64 encoding to safely transfer content
|
||||
|
||||
const base64Content = Buffer.from(content, 'utf8').toString('base64');
|
||||
|
||||
logger.info(`Starting writeFile operation: session=${sessionId}, path=${filePath}, contentLength=${content.length}, base64Length=${base64Content.length}`);
|
||||
|
||||
// Add timeout to prevent hanging
|
||||
const commandTimeout = setTimeout(() => {
|
||||
logger.error(`SSH writeFile command timed out for session: ${sessionId}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'SSH command timed out' });
|
||||
}
|
||||
}, 15000); // 15 second timeout
|
||||
|
||||
// First check file permissions and ownership
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
logger.error(`SSH writeFile command timed out for session: ${sessionId}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: 'SSH command timed out'});
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
const checkCommand = `ls -la '${escapedFilePath}' 2>/dev/null || echo "File does not exist"`;
|
||||
logger.info(`Checking file details: ${filePath}`);
|
||||
|
||||
|
||||
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
|
||||
if (checkErr) {
|
||||
logger.error('File check failed:', checkErr);
|
||||
return res.status(500).json({ error: `File check failed: ${checkErr.message}` });
|
||||
return res.status(500).json({error: `File check failed: ${checkErr.message}`});
|
||||
}
|
||||
|
||||
|
||||
let checkResult = '';
|
||||
checkStream.on('data', (chunk: Buffer) => {
|
||||
checkResult += chunk.toString();
|
||||
});
|
||||
|
||||
|
||||
checkStream.on('close', (checkCode) => {
|
||||
logger.info(`File check result: ${checkResult.trim()}`);
|
||||
|
||||
// Use a simpler approach: write base64 to temp file, decode and write to target, then clean up
|
||||
// Add explicit exit to ensure the command completes
|
||||
const writeCommand = `echo '${base64Content}' > '${escapedTempFile}' && base64 -d '${escapedTempFile}' > '${escapedFilePath}' && rm -f '${escapedTempFile}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
logger.info(`Executing write command for: ${filePath}`);
|
||||
|
||||
|
||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('SSH writeFile error:', err);
|
||||
logger.error('SSH writeFile error:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
return res.status(500).json({error: err.message});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
let outputData = '';
|
||||
let errorData = '';
|
||||
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
logger.debug(`SSH writeFile stdout: ${chunk.toString()}`);
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
logger.debug(`SSH writeFile stderr: ${chunk.toString()}`);
|
||||
|
||||
// Check for permission denied and fail fast
|
||||
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes('Permission denied')) {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error(`Permission denied writing to file: ${filePath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.`
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.`
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
stream.on('close', (code) => {
|
||||
logger.info(`SSH writeFile command completed with code: ${code}, output: "${outputData.trim()}", error: "${errorData.trim()}"`);
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
// Check if we got the success message
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
// Verify the file was actually written by checking its size
|
||||
const verifyCommand = `ls -la '${escapedFilePath}' 2>/dev/null | awk '{print $5}'`;
|
||||
logger.info(`Verifying file was written: ${filePath}`);
|
||||
|
||||
|
||||
sshConn.client.exec(verifyCommand, (verifyErr, verifyStream) => {
|
||||
if (verifyErr) {
|
||||
logger.warn('File verification failed, but assuming success:');
|
||||
if (!res.headersSent) {
|
||||
res.json({ message: 'File written successfully', path: filePath });
|
||||
res.json({message: 'File written successfully', path: filePath});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
let verifyResult = '';
|
||||
verifyStream.on('data', (chunk: Buffer) => {
|
||||
verifyResult += chunk.toString();
|
||||
});
|
||||
|
||||
|
||||
verifyStream.on('close', (verifyCode) => {
|
||||
const fileSize = Number(verifyResult.trim());
|
||||
logger.info(`File verification result: size=${fileSize} bytes`);
|
||||
|
||||
|
||||
if (fileSize > 0) {
|
||||
logger.info(`File written successfully: ${filePath} (${fileSize} bytes)`);
|
||||
if (!res.headersSent) {
|
||||
res.json({ message: 'File written successfully', path: filePath });
|
||||
res.json({message: 'File written successfully', path: filePath});
|
||||
}
|
||||
} else {
|
||||
logger.error(`File appears to be empty after write: ${filePath}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'File write operation may have failed - file appears empty' });
|
||||
res.status(500).json({error: 'File write operation may have failed - file appears empty'});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH writeFile command failed with code ${code}: ${errorData}`);
|
||||
logger.error(`SSH writeFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: `Command failed: ${errorData}` });
|
||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If code is 0 but no SUCCESS message, assume it worked anyway
|
||||
// This handles cases where the echo "SUCCESS" didn't work but the file write did
|
||||
logger.info(`File written successfully (code 0, no SUCCESS message): ${filePath}`);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({ message: 'File written successfully', path: filePath });
|
||||
res.json({message: 'File written successfully', path: filePath});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('SSH writeFile stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -456,4 +401,5 @@ process.on('SIGTERM', () => {
|
||||
});
|
||||
|
||||
const PORT = 8084;
|
||||
app.listen(PORT, () => {});
|
||||
app.listen(PORT, () => {
|
||||
});
|
||||
@@ -41,7 +41,7 @@ const logger = {
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
res.json({status: 'ok'});
|
||||
});
|
||||
|
||||
app.use('/users', userRoutes);
|
||||
@@ -49,8 +49,9 @@ app.use('/ssh', sshRoutes);
|
||||
|
||||
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
res.status(500).json({error: 'Internal Server Error'});
|
||||
});
|
||||
|
||||
const PORT = 8081;
|
||||
app.listen(PORT, () => {});
|
||||
app.listen(PORT, () => {
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import {drizzle} from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import * as schema from './schema.js';
|
||||
import chalk from 'chalk';
|
||||
@@ -34,108 +34,296 @@ const logger = {
|
||||
const dataDir = process.env.DATA_DIR || './db/data';
|
||||
const dbDir = path.resolve(dataDir);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
fs.mkdirSync(dbDir, {recursive: true});
|
||||
}
|
||||
|
||||
const dbPath = path.join(dataDir, 'db.sqlite');
|
||||
const sqlite = new Database(dbPath);
|
||||
|
||||
// Create tables using Drizzle schema
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
id
|
||||
TEXT
|
||||
PRIMARY
|
||||
KEY,
|
||||
username
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
password_hash
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
is_admin
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings
|
||||
(
|
||||
key
|
||||
TEXT
|
||||
PRIMARY
|
||||
KEY,
|
||||
value
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
pin INTEGER NOT NULL DEFAULT 0,
|
||||
auth_type TEXT NOT NULL,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
enable_config_editor INTEGER NOT NULL DEFAULT 1,
|
||||
default_path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ssh_data
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT,
|
||||
ip
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
port
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
username
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
folder
|
||||
TEXT,
|
||||
tags
|
||||
TEXT,
|
||||
pin
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
0,
|
||||
auth_type
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
password
|
||||
TEXT,
|
||||
key
|
||||
TEXT,
|
||||
key_password
|
||||
TEXT,
|
||||
key_type
|
||||
TEXT,
|
||||
enable_terminal
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
1,
|
||||
enable_tunnel
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
1,
|
||||
tunnel_connections
|
||||
TEXT,
|
||||
enable_config_editor
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
1,
|
||||
default_path
|
||||
TEXT,
|
||||
created_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
updated_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_editor_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(host_id) REFERENCES ssh_data(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS config_editor_recent
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
host_id
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
last_opened
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
),
|
||||
FOREIGN KEY
|
||||
(
|
||||
host_id
|
||||
) REFERENCES ssh_data
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_editor_pinned (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(host_id) REFERENCES ssh_data(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS config_editor_pinned
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
host_id
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
pinned_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
),
|
||||
FOREIGN KEY
|
||||
(
|
||||
host_id
|
||||
) REFERENCES ssh_data
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_editor_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(host_id) REFERENCES ssh_data(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS config_editor_shortcuts
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
host_id
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
created_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
),
|
||||
FOREIGN KEY
|
||||
(
|
||||
host_id
|
||||
) REFERENCES ssh_data
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
`);
|
||||
|
||||
// Function to safely add a column if it doesn't exist
|
||||
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
|
||||
try {
|
||||
// Try to select the column to see if it exists
|
||||
sqlite.prepare(`SELECT ${column} FROM ${table} LIMIT 1`).get();
|
||||
sqlite.prepare(`SELECT ${column}
|
||||
FROM ${table} LIMIT 1`).get();
|
||||
} catch (e) {
|
||||
// Column doesn't exist, add it
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`);
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN ${column} ${definition};`);
|
||||
} catch (alterError) {
|
||||
logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-migrate: Add any missing columns based on current schema
|
||||
const migrateSchema = () => {
|
||||
logger.info('Checking for schema updates...');
|
||||
|
||||
// Add missing columns to users table
|
||||
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
|
||||
|
||||
// Add missing columns to ssh_data table
|
||||
|
||||
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'tags', 'TEXT');
|
||||
@@ -153,7 +341,6 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
|
||||
// Add missing columns to config_editor tables
|
||||
addColumnIfNotExists('config_editor_recent', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('config_editor_pinned', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('config_editor_shortcuts', 'host_id', 'INTEGER NOT NULL');
|
||||
@@ -161,10 +348,8 @@ const migrateSchema = () => {
|
||||
logger.success('Schema migration completed');
|
||||
};
|
||||
|
||||
// Run auto-migration
|
||||
migrateSchema();
|
||||
|
||||
// Initialize default settings
|
||||
try {
|
||||
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
if (!row) {
|
||||
@@ -174,4 +359,4 @@ try {
|
||||
logger.warn('Could not initialize default settings');
|
||||
}
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
export const db = drizzle(sqlite, {schema});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
|
||||
import {sql} from 'drizzle-orm';
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(), // Unique user ID (nanoid)
|
||||
username: text('username').notNull(), // Username
|
||||
password_hash: text('password_hash').notNull(), // Hashed password
|
||||
is_admin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), // Admin flag
|
||||
id: text('id').primaryKey(),
|
||||
username: text('username').notNull(),
|
||||
password_hash: text('password_hash').notNull(),
|
||||
is_admin: integer('is_admin', {mode: 'boolean'}).notNull().default(false),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
@@ -14,52 +14,52 @@ export const settings = sqliteTable('settings', {
|
||||
});
|
||||
|
||||
export const sshData = sqliteTable('ssh_data', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name'), // Host name
|
||||
name: text('name'),
|
||||
ip: text('ip').notNull(),
|
||||
port: integer('port').notNull(),
|
||||
username: text('username').notNull(),
|
||||
folder: text('folder'),
|
||||
tags: text('tags'), // JSON stringified array
|
||||
pin: integer('pin', { mode: 'boolean' }).notNull().default(false),
|
||||
authType: text('auth_type').notNull(), // 'password' | 'key'
|
||||
tags: text('tags'),
|
||||
pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
|
||||
authType: text('auth_type').notNull(),
|
||||
password: text('password'),
|
||||
key: text('key', { length: 8192 }), // Increased for larger keys
|
||||
keyPassword: text('key_password'), // Password for protected keys
|
||||
keyType: text('key_type'), // Type of SSH key (RSA, ED25519, etc.)
|
||||
enableTerminal: integer('enable_terminal', { mode: 'boolean' }).notNull().default(true),
|
||||
enableTunnel: integer('enable_tunnel', { mode: 'boolean' }).notNull().default(true),
|
||||
tunnelConnections: text('tunnel_connections'), // JSON stringified array of tunnel connections
|
||||
enableConfigEditor: integer('enable_config_editor', { mode: 'boolean' }).notNull().default(true),
|
||||
defaultPath: text('default_path'), // Default path for SSH connection
|
||||
key: text('key', {length: 8192}),
|
||||
keyPassword: text('key_password'),
|
||||
keyType: text('key_type'),
|
||||
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
|
||||
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
|
||||
tunnelConnections: text('tunnel_connections'),
|
||||
enableConfigEditor: integer('enable_config_editor', {mode: 'boolean'}).notNull().default(true),
|
||||
defaultPath: text('default_path'),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const configEditorRecent = sqliteTable('config_editor_recent', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID
|
||||
name: text('name').notNull(), // File name
|
||||
path: text('path').notNull(), // File path
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const configEditorPinned = sqliteTable('config_editor_pinned', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID
|
||||
name: text('name').notNull(), // File name
|
||||
path: text('path').notNull(), // File path
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const configEditorShortcuts = sqliteTable('config_editor_shortcuts', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id), // SSH host ID
|
||||
name: text('name').notNull(), // Folder name
|
||||
path: text('path').notNull(), // Folder path
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import express from 'express';
|
||||
import { db } from '../db/index.js';
|
||||
import { sshData, configEditorRecent, configEditorPinned, configEditorShortcuts } from '../db/schema.js';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import {db} from '../db/index.js';
|
||||
import {sshData, configEditorRecent, configEditorPinned, configEditorShortcuts} from '../db/schema.js';
|
||||
import {eq, and, desc} from 'drizzle-orm';
|
||||
import chalk from 'chalk';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import multer from 'multer';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import type {Request, Response, NextFunction} from 'express';
|
||||
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
@@ -38,6 +38,7 @@ const router = express.Router();
|
||||
function isNonEmptyString(val: any): val is string {
|
||||
return typeof val === 'string' && val.trim().length > 0;
|
||||
}
|
||||
|
||||
function isValidPort(val: any): val is number {
|
||||
return typeof val === 'number' && val > 0 && val < 65536;
|
||||
}
|
||||
@@ -48,14 +49,12 @@ interface JWTPayload {
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
// Configure multer for file uploads
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB limit
|
||||
fileSize: 10 * 1024 * 1024,
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Only allow specific file types for SSH keys
|
||||
if (file.fieldname === 'key') {
|
||||
cb(null, true);
|
||||
} else {
|
||||
@@ -64,12 +63,11 @@ const upload = multer({
|
||||
}
|
||||
});
|
||||
|
||||
// JWT authentication middleware
|
||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
logger.warn('Missing or invalid Authorization header');
|
||||
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
||||
return res.status(401).json({error: 'Missing or invalid Authorization header'});
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||
@@ -79,11 +77,10 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
next();
|
||||
} catch (err) {
|
||||
logger.warn('Invalid or expired token');
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
return res.status(401).json({error: 'Invalid or expired token'});
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to check if request is from localhost
|
||||
function isLocalhost(req: Request) {
|
||||
const ip = req.ip || req.connection?.remoteAddress;
|
||||
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
||||
@@ -93,7 +90,7 @@ function isLocalhost(req: Request) {
|
||||
router.get('/db/host/internal', async (req: Request, res: Response) => {
|
||||
if (!isLocalhost(req) && req.headers['x-internal-request'] !== '1') {
|
||||
logger.warn('Unauthorized attempt to access internal SSH host endpoint');
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
return res.status(403).json({error: 'Forbidden'});
|
||||
}
|
||||
try {
|
||||
const data = await db.select().from(sshData);
|
||||
@@ -110,7 +107,7 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH data (internal)', err);
|
||||
res.status(500).json({ error: 'Failed to fetch SSH data' });
|
||||
res.status(500).json({error: 'Failed to fetch SSH data'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -118,7 +115,7 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
|
||||
// POST /ssh/host
|
||||
router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
|
||||
let hostData: any;
|
||||
|
||||
|
||||
// Check if this is a multipart form data request (file upload)
|
||||
if (req.headers['content-type']?.includes('multipart/form-data')) {
|
||||
// Parse the JSON data from the 'data' field
|
||||
@@ -127,13 +124,13 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
||||
hostData = JSON.parse(req.body.data);
|
||||
} catch (err) {
|
||||
logger.warn('Invalid JSON data in multipart request');
|
||||
return res.status(400).json({ error: 'Invalid JSON data' });
|
||||
return res.status(400).json({error: 'Invalid JSON data'});
|
||||
}
|
||||
} else {
|
||||
logger.warn('Missing data field in multipart request');
|
||||
return res.status(400).json({ error: 'Missing data field' });
|
||||
return res.status(400).json({error: 'Missing data field'});
|
||||
}
|
||||
|
||||
|
||||
// Add the file data if present
|
||||
if (req.file) {
|
||||
hostData.key = req.file.buffer.toString('utf8');
|
||||
@@ -142,12 +139,30 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
||||
// Regular JSON request
|
||||
hostData = req.body;
|
||||
}
|
||||
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, pin, enableTerminal, enableTunnel, enableConfigEditor, defaultPath, tunnelConnections } = hostData;
|
||||
|
||||
const {
|
||||
name,
|
||||
folder,
|
||||
tags,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
authMethod,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
pin,
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
enableConfigEditor,
|
||||
defaultPath,
|
||||
tunnelConnections
|
||||
} = hostData;
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) {
|
||||
logger.warn('Invalid SSH data input');
|
||||
return res.status(400).json({ error: 'Invalid SSH data' });
|
||||
return res.status(400).json({error: 'Invalid SSH data'});
|
||||
}
|
||||
|
||||
const sshDataObj: any = {
|
||||
@@ -167,7 +182,6 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
||||
defaultPath: defaultPath || null,
|
||||
};
|
||||
|
||||
// Handle authentication data based on authMethod
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
@@ -182,10 +196,10 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
||||
|
||||
try {
|
||||
await db.insert(sshData).values(sshDataObj);
|
||||
res.json({ message: 'SSH data created' });
|
||||
res.json({message: 'SSH data created'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to save SSH data', err);
|
||||
res.status(500).json({ error: 'Failed to save SSH data' });
|
||||
res.status(500).json({error: 'Failed to save SSH data'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -193,37 +207,51 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
|
||||
// PUT /ssh/host/:id
|
||||
router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
|
||||
let hostData: any;
|
||||
|
||||
// Check if this is a multipart form data request (file upload)
|
||||
|
||||
if (req.headers['content-type']?.includes('multipart/form-data')) {
|
||||
// Parse the JSON data from the 'data' field
|
||||
if (req.body.data) {
|
||||
try {
|
||||
hostData = JSON.parse(req.body.data);
|
||||
} catch (err) {
|
||||
logger.warn('Invalid JSON data in multipart request');
|
||||
return res.status(400).json({ error: 'Invalid JSON data' });
|
||||
return res.status(400).json({error: 'Invalid JSON data'});
|
||||
}
|
||||
} else {
|
||||
logger.warn('Missing data field in multipart request');
|
||||
return res.status(400).json({ error: 'Missing data field' });
|
||||
return res.status(400).json({error: 'Missing data field'});
|
||||
}
|
||||
|
||||
// Add the file data if present
|
||||
|
||||
if (req.file) {
|
||||
hostData.key = req.file.buffer.toString('utf8');
|
||||
}
|
||||
} else {
|
||||
// Regular JSON request
|
||||
hostData = req.body;
|
||||
}
|
||||
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, pin, enableTerminal, enableTunnel, enableConfigEditor, defaultPath, tunnelConnections } = hostData;
|
||||
const { id } = req.params;
|
||||
|
||||
const {
|
||||
name,
|
||||
folder,
|
||||
tags,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
authMethod,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
pin,
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
enableConfigEditor,
|
||||
defaultPath,
|
||||
tunnelConnections
|
||||
} = hostData;
|
||||
const {id} = req.params;
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !id) {
|
||||
logger.warn('Invalid SSH data input for update');
|
||||
return res.status(400).json({ error: 'Invalid SSH data' });
|
||||
return res.status(400).json({error: 'Invalid SSH data'});
|
||||
}
|
||||
|
||||
const sshDataObj: any = {
|
||||
@@ -242,7 +270,6 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
|
||||
defaultPath: defaultPath || null,
|
||||
};
|
||||
|
||||
// Handle authentication data based on authMethod
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
@@ -259,10 +286,10 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
|
||||
await db.update(sshData)
|
||||
.set(sshDataObj)
|
||||
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
|
||||
res.json({ message: 'SSH data updated' });
|
||||
res.json({message: 'SSH data updated'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to update SSH data', err);
|
||||
res.status(500).json({ error: 'Failed to update SSH data' });
|
||||
res.status(500).json({error: 'Failed to update SSH data'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -272,14 +299,13 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for SSH data fetch');
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
return res.status(400).json({error: 'Invalid userId'});
|
||||
}
|
||||
try {
|
||||
const data = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(eq(sshData.userId, userId));
|
||||
// Convert tags to array, booleans to bool, tunnelConnections to array
|
||||
const result = data.map((row: any) => ({
|
||||
...row,
|
||||
tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [],
|
||||
@@ -292,31 +318,31 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH data', err);
|
||||
res.status(500).json({ error: 'Failed to fetch SSH data' });
|
||||
res.status(500).json({error: 'Failed to fetch SSH data'});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get SSH host by ID (requires JWT)
|
||||
// GET /ssh/host/:id
|
||||
router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
const {id} = req.params;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
logger.warn('Invalid request for SSH host fetch');
|
||||
return res.status(400).json({ error: 'Invalid request' });
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const data = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
|
||||
|
||||
|
||||
if (data.length === 0) {
|
||||
return res.status(404).json({ error: 'SSH host not found' });
|
||||
return res.status(404).json({error: 'SSH host not found'});
|
||||
}
|
||||
|
||||
|
||||
const host = data[0];
|
||||
const result = {
|
||||
...host,
|
||||
@@ -327,11 +353,11 @@ router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response)
|
||||
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
|
||||
enableConfigEditor: !!host.enableConfigEditor,
|
||||
};
|
||||
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH host', err);
|
||||
res.status(500).json({ error: 'Failed to fetch SSH host' });
|
||||
res.status(500).json({error: 'Failed to fetch SSH host'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -341,11 +367,11 @@ router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) =
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for SSH folder fetch');
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
return res.status(400).json({error: 'Invalid userId'});
|
||||
}
|
||||
try {
|
||||
const data = await db
|
||||
.select({ folder: sshData.folder })
|
||||
.select({folder: sshData.folder})
|
||||
.from(sshData)
|
||||
.where(eq(sshData.userId, userId));
|
||||
|
||||
@@ -361,7 +387,7 @@ router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) =
|
||||
res.json(folders);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH folders', err);
|
||||
res.status(500).json({ error: 'Failed to fetch SSH folders' });
|
||||
res.status(500).json({error: 'Failed to fetch SSH folders'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -369,39 +395,37 @@ router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) =
|
||||
// DELETE /ssh/host/:id
|
||||
router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id } = req.params;
|
||||
const {id} = req.params;
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
logger.warn('Invalid userId or id for SSH host delete');
|
||||
return res.status(400).json({ error: 'Invalid userId or id' });
|
||||
return res.status(400).json({error: 'Invalid userId or id'});
|
||||
}
|
||||
try {
|
||||
const result = await db.delete(sshData)
|
||||
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
|
||||
res.json({ message: 'SSH host deleted' });
|
||||
res.json({message: 'SSH host deleted'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete SSH host', err);
|
||||
res.status(500).json({ error: 'Failed to delete SSH host' });
|
||||
res.status(500).json({error: 'Failed to delete SSH host'});
|
||||
}
|
||||
});
|
||||
|
||||
// Config Editor Database Routes
|
||||
|
||||
// Route: Get recent files (requires JWT)
|
||||
// GET /ssh/config_editor/recent
|
||||
router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
|
||||
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for recent files fetch');
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
return res.status(400).json({error: 'Invalid userId'});
|
||||
}
|
||||
|
||||
|
||||
if (!hostId) {
|
||||
logger.warn('Host ID is required for recent files fetch');
|
||||
return res.status(400).json({ error: 'Host ID is required' });
|
||||
return res.status(400).json({error: 'Host ID is required'});
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const recentFiles = await db
|
||||
.select()
|
||||
@@ -414,7 +438,7 @@ router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: R
|
||||
res.json(recentFiles);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch recent files', err);
|
||||
res.status(500).json({ error: 'Failed to fetch recent files' });
|
||||
res.status(500).json({error: 'Failed to fetch recent files'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -422,29 +446,27 @@ router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: R
|
||||
// POST /ssh/config_editor/recent
|
||||
router.post('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path, hostId } = req.body;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
logger.warn('Invalid request for adding recent file');
|
||||
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
|
||||
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
|
||||
}
|
||||
try {
|
||||
// Check if file already exists in recent for this host
|
||||
const conditions = [
|
||||
eq(configEditorRecent.userId, userId),
|
||||
eq(configEditorRecent.userId, userId),
|
||||
eq(configEditorRecent.path, path),
|
||||
eq(configEditorRecent.hostId, hostId)
|
||||
];
|
||||
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(configEditorRecent)
|
||||
.where(and(...conditions));
|
||||
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update lastOpened timestamp
|
||||
await db
|
||||
.update(configEditorRecent)
|
||||
.set({ lastOpened: new Date().toISOString() })
|
||||
.set({lastOpened: new Date().toISOString()})
|
||||
.where(and(...conditions));
|
||||
} else {
|
||||
// Add new recent file
|
||||
@@ -456,10 +478,10 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
|
||||
lastOpened: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
res.json({ message: 'File added to recent' });
|
||||
res.json({message: 'File added to recent'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to add recent file', err);
|
||||
res.status(500).json({ error: 'Failed to add recent file' });
|
||||
res.status(500).json({error: 'Failed to add recent file'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -467,28 +489,25 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
|
||||
// DELETE /ssh/config_editor/recent
|
||||
router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path, hostId } = req.body;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
logger.warn('Invalid request for removing recent file');
|
||||
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
|
||||
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
|
||||
}
|
||||
try {
|
||||
logger.info(`Removing recent file: ${name} at ${path} for user ${userId} and host ${hostId}`);
|
||||
|
||||
const conditions = [
|
||||
eq(configEditorRecent.userId, userId),
|
||||
eq(configEditorRecent.userId, userId),
|
||||
eq(configEditorRecent.path, path),
|
||||
eq(configEditorRecent.hostId, hostId)
|
||||
];
|
||||
|
||||
|
||||
const result = await db
|
||||
.delete(configEditorRecent)
|
||||
.where(and(...conditions));
|
||||
logger.info(`Recent file removed successfully`);
|
||||
res.json({ message: 'File removed from recent' });
|
||||
res.json({message: 'File removed from recent'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to remove recent file', err);
|
||||
res.status(500).json({ error: 'Failed to remove recent file' });
|
||||
res.status(500).json({error: 'Failed to remove recent file'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -497,17 +516,17 @@ router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res
|
||||
router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
|
||||
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for pinned files fetch');
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
return res.status(400).json({error: 'Invalid userId'});
|
||||
}
|
||||
|
||||
|
||||
if (!hostId) {
|
||||
logger.warn('Host ID is required for pinned files fetch');
|
||||
return res.status(400).json({ error: 'Host ID is required' });
|
||||
return res.status(400).json({error: 'Host ID is required'});
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const pinnedFiles = await db
|
||||
.select()
|
||||
@@ -520,7 +539,7 @@ router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: R
|
||||
res.json(pinnedFiles);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch pinned files', err);
|
||||
res.status(500).json({ error: 'Failed to fetch pinned files' });
|
||||
res.status(500).json({error: 'Failed to fetch pinned files'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -528,26 +547,24 @@ router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: R
|
||||
// POST /ssh/config_editor/pinned
|
||||
router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path, hostId } = req.body;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
logger.warn('Invalid request for adding pinned file');
|
||||
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
|
||||
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
|
||||
}
|
||||
try {
|
||||
// Check if file already exists in pinned for this host
|
||||
const conditions = [
|
||||
eq(configEditorPinned.userId, userId),
|
||||
eq(configEditorPinned.userId, userId),
|
||||
eq(configEditorPinned.path, path),
|
||||
eq(configEditorPinned.hostId, hostId)
|
||||
];
|
||||
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(configEditorPinned)
|
||||
.where(and(...conditions));
|
||||
|
||||
|
||||
if (existing.length === 0) {
|
||||
// Add new pinned file
|
||||
await db.insert(configEditorPinned).values({
|
||||
userId,
|
||||
hostId,
|
||||
@@ -556,10 +573,10 @@ router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res:
|
||||
pinnedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
res.json({ message: 'File pinned successfully' });
|
||||
res.json({message: 'File pinned successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to pin file', err);
|
||||
res.status(500).json({ error: 'Failed to pin file' });
|
||||
res.status(500).json({error: 'Failed to pin file'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -567,28 +584,25 @@ router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res:
|
||||
// DELETE /ssh/config_editor/pinned
|
||||
router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path, hostId } = req.body;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
logger.warn('Invalid request for removing pinned file');
|
||||
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
|
||||
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
|
||||
}
|
||||
try {
|
||||
logger.info(`Removing pinned file: ${name} at ${path} for user ${userId} and host ${hostId}`);
|
||||
|
||||
const conditions = [
|
||||
eq(configEditorPinned.userId, userId),
|
||||
eq(configEditorPinned.userId, userId),
|
||||
eq(configEditorPinned.path, path),
|
||||
eq(configEditorPinned.hostId, hostId)
|
||||
];
|
||||
|
||||
|
||||
const result = await db
|
||||
.delete(configEditorPinned)
|
||||
.where(and(...conditions));
|
||||
logger.info(`Pinned file removed successfully`);
|
||||
res.json({ message: 'File unpinned successfully' });
|
||||
res.json({message: 'File unpinned successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to unpin file', err);
|
||||
res.status(500).json({ error: 'Failed to unpin file' });
|
||||
res.status(500).json({error: 'Failed to unpin file'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -597,17 +611,15 @@ router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res
|
||||
router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
|
||||
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for shortcuts fetch');
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
return res.status(400).json({error: 'Invalid userId'});
|
||||
}
|
||||
|
||||
|
||||
if (!hostId) {
|
||||
logger.warn('Host ID is required for shortcuts fetch');
|
||||
return res.status(400).json({ error: 'Host ID is required' });
|
||||
return res.status(400).json({error: 'Host ID is required'});
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const shortcuts = await db
|
||||
.select()
|
||||
@@ -620,7 +632,7 @@ router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res
|
||||
res.json(shortcuts);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch shortcuts', err);
|
||||
res.status(500).json({ error: 'Failed to fetch shortcuts' });
|
||||
res.status(500).json({error: 'Failed to fetch shortcuts'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -628,26 +640,23 @@ router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res
|
||||
// POST /ssh/config_editor/shortcuts
|
||||
router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path, hostId } = req.body;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
logger.warn('Invalid request for adding shortcut');
|
||||
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
|
||||
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
|
||||
}
|
||||
try {
|
||||
// Check if shortcut already exists for this host
|
||||
const conditions = [
|
||||
eq(configEditorShortcuts.userId, userId),
|
||||
eq(configEditorShortcuts.userId, userId),
|
||||
eq(configEditorShortcuts.path, path),
|
||||
eq(configEditorShortcuts.hostId, hostId)
|
||||
];
|
||||
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(configEditorShortcuts)
|
||||
.where(and(...conditions));
|
||||
|
||||
|
||||
if (existing.length === 0) {
|
||||
// Add new shortcut
|
||||
await db.insert(configEditorShortcuts).values({
|
||||
userId,
|
||||
hostId,
|
||||
@@ -656,10 +665,10 @@ router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, re
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
res.json({ message: 'Shortcut added successfully' });
|
||||
res.json({message: 'Shortcut added successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to add shortcut', err);
|
||||
res.status(500).json({ error: 'Failed to add shortcut' });
|
||||
res.status(500).json({error: 'Failed to add shortcut'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -667,28 +676,24 @@ router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, re
|
||||
// DELETE /ssh/config_editor/shortcuts
|
||||
router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path, hostId } = req.body;
|
||||
const {name, path, hostId} = req.body;
|
||||
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
|
||||
logger.warn('Invalid request for removing shortcut');
|
||||
return res.status(400).json({ error: 'Invalid request - userId, name, path, and hostId are required' });
|
||||
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
|
||||
}
|
||||
try {
|
||||
logger.info(`Removing shortcut: ${name} at ${path} for user ${userId} and host ${hostId}`);
|
||||
|
||||
const conditions = [
|
||||
eq(configEditorShortcuts.userId, userId),
|
||||
eq(configEditorShortcuts.userId, userId),
|
||||
eq(configEditorShortcuts.path, path),
|
||||
eq(configEditorShortcuts.hostId, hostId)
|
||||
];
|
||||
|
||||
|
||||
const result = await db
|
||||
.delete(configEditorShortcuts)
|
||||
.where(and(...conditions));
|
||||
logger.info(`Shortcut removed successfully`);
|
||||
res.json({ message: 'Shortcut removed successfully' });
|
||||
res.json({message: 'Shortcut removed successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to remove shortcut', err);
|
||||
res.status(500).json({ error: 'Failed to remove shortcut' });
|
||||
res.status(500).json({error: 'Failed to remove shortcut'});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import express from 'express';
|
||||
import { db } from '../db/index.js';
|
||||
import { users, settings } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {db} from '../db/index.js';
|
||||
import {users, settings} from '../db/schema.js';
|
||||
import {eq} from 'drizzle-orm';
|
||||
import chalk from 'chalk';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {nanoid} from 'nanoid';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import type {Request, Response, NextFunction} from 'express';
|
||||
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
@@ -51,7 +51,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
logger.warn('Missing or invalid Authorization header');
|
||||
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
||||
return res.status(401).json({error: 'Missing or invalid Authorization header'});
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||
@@ -61,7 +61,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
next();
|
||||
} catch (err) {
|
||||
logger.warn('Invalid or expired token');
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
return res.status(401).json({error: 'Invalid or expired token'});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,14 +71,14 @@ router.post('/create', async (req, res) => {
|
||||
try {
|
||||
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
if (row && (row as any).value !== 'true') {
|
||||
return res.status(403).json({ error: 'Registration is currently disabled' });
|
||||
return res.status(403).json({error: 'Registration is currently disabled'});
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
const { username, password } = req.body;
|
||||
const {username, password} = req.body;
|
||||
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
||||
logger.warn('Invalid user creation attempt');
|
||||
return res.status(400).json({ error: 'Invalid username or password' });
|
||||
return res.status(400).json({error: 'Invalid username or password'});
|
||||
}
|
||||
try {
|
||||
const existing = await db
|
||||
@@ -87,7 +87,7 @@ router.post('/create', async (req, res) => {
|
||||
.where(eq(users.username, username));
|
||||
if (existing && existing.length > 0) {
|
||||
logger.warn(`Attempt to create duplicate username: ${username}`);
|
||||
return res.status(409).json({ error: 'Username already exists' });
|
||||
return res.status(409).json({error: 'Username already exists'});
|
||||
}
|
||||
let isFirstUser = false;
|
||||
try {
|
||||
@@ -99,22 +99,22 @@ router.post('/create', async (req, res) => {
|
||||
const saltRounds = parseInt(process.env.SALT || '10', 10);
|
||||
const password_hash = await bcrypt.hash(password, saltRounds);
|
||||
const id = nanoid();
|
||||
await db.insert(users).values({ id, username, password_hash, is_admin: isFirstUser });
|
||||
await db.insert(users).values({id, username, password_hash, is_admin: isFirstUser});
|
||||
logger.success(`User created: ${username} (is_admin: ${isFirstUser})`);
|
||||
res.json({ message: 'User created', is_admin: isFirstUser });
|
||||
res.json({message: 'User created', is_admin: isFirstUser});
|
||||
} catch (err) {
|
||||
logger.error('Failed to create user', err);
|
||||
res.status(500).json({ error: 'Failed to create user' });
|
||||
res.status(500).json({error: 'Failed to create user'});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get user JWT by username and password
|
||||
// POST /users/get
|
||||
router.post('/get', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
const {username, password} = req.body;
|
||||
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
||||
logger.warn('Invalid get user attempt');
|
||||
return res.status(400).json({ error: 'Invalid username or password' });
|
||||
return res.status(400).json({error: 'Invalid username or password'});
|
||||
}
|
||||
try {
|
||||
const user = await db
|
||||
@@ -123,20 +123,20 @@ router.post('/get', async (req, res) => {
|
||||
.where(eq(users.username, username));
|
||||
if (!user || user.length === 0) {
|
||||
logger.warn(`User not found: ${username}`);
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
return res.status(404).json({error: 'User not found'});
|
||||
}
|
||||
const userRecord = user[0];
|
||||
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
|
||||
if (!isMatch) {
|
||||
logger.warn(`Incorrect password for user: ${username}`);
|
||||
return res.status(401).json({ error: 'Incorrect password' });
|
||||
return res.status(401).json({error: 'Incorrect password'});
|
||||
}
|
||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, { expiresIn: '50d' });
|
||||
res.json({ token });
|
||||
const token = jwt.sign({userId: userRecord.id}, jwtSecret, {expiresIn: '50d'});
|
||||
res.json({token});
|
||||
} catch (err) {
|
||||
logger.error('Failed to get user', err);
|
||||
res.status(500).json({ error: 'Failed to get user' });
|
||||
res.status(500).json({error: 'Failed to get user'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -146,7 +146,7 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId in JWT for /users/me');
|
||||
return res.status(401).json({ error: 'Invalid userId' });
|
||||
return res.status(401).json({error: 'Invalid userId'});
|
||||
}
|
||||
try {
|
||||
const user = await db
|
||||
@@ -155,12 +155,12 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
|
||||
.where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
logger.warn(`User not found for /users/me: ${userId}`);
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
return res.status(401).json({error: 'User not found'});
|
||||
}
|
||||
res.json({ username: user[0].username, is_admin: !!user[0].is_admin });
|
||||
res.json({username: user[0].username, is_admin: !!user[0].is_admin});
|
||||
} catch (err) {
|
||||
logger.error('Failed to get username', err);
|
||||
res.status(500).json({ error: 'Failed to get username' });
|
||||
res.status(500).json({error: 'Failed to get username'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -170,10 +170,10 @@ router.get('/count', async (req, res) => {
|
||||
try {
|
||||
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||
const count = (countResult as any)?.count || 0;
|
||||
res.json({ count });
|
||||
res.json({count});
|
||||
} catch (err) {
|
||||
logger.error('Failed to count users', err);
|
||||
res.status(500).json({ error: 'Failed to count users' });
|
||||
res.status(500).json({error: 'Failed to count users'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -182,10 +182,10 @@ router.get('/count', async (req, res) => {
|
||||
router.get('/db-health', async (req, res) => {
|
||||
try {
|
||||
db.$client.prepare('SELECT 1').get();
|
||||
res.json({ status: 'ok' });
|
||||
res.json({status: 'ok'});
|
||||
} catch (err) {
|
||||
logger.error('DB health check failed', err);
|
||||
res.status(500).json({ error: 'Database not accessible' });
|
||||
res.status(500).json({error: 'Database not accessible'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -194,10 +194,10 @@ router.get('/db-health', async (req, res) => {
|
||||
router.get('/registration-allowed', async (req, res) => {
|
||||
try {
|
||||
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
res.json({ allowed: row ? (row as any).value === 'true' : true });
|
||||
res.json({allowed: row ? (row as any).value === 'true' : true});
|
||||
} catch (err) {
|
||||
logger.error('Failed to get registration allowed', err);
|
||||
res.status(500).json({ error: 'Failed to get registration allowed' });
|
||||
res.status(500).json({error: 'Failed to get registration allowed'});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -208,17 +208,17 @@ router.patch('/registration-allowed', authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0 || !user[0].is_admin) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
return res.status(403).json({error: 'Not authorized'});
|
||||
}
|
||||
const { allowed } = req.body;
|
||||
const {allowed} = req.body;
|
||||
if (typeof allowed !== 'boolean') {
|
||||
return res.status(400).json({ error: 'Invalid value for allowed' });
|
||||
return res.status(400).json({error: 'Invalid value for allowed'});
|
||||
}
|
||||
db.$client.prepare("UPDATE settings SET value = ? WHERE key = 'allow_registration'").run(allowed ? 'true' : 'false');
|
||||
res.json({ allowed });
|
||||
res.json({allowed});
|
||||
} catch (err) {
|
||||
logger.error('Failed to set registration allowed', err);
|
||||
res.status(500).json({ error: 'Failed to set registration allowed' });
|
||||
res.status(500).json({error: 'Failed to set registration allowed'});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -40,21 +40,19 @@ const logger = {
|
||||
}
|
||||
};
|
||||
|
||||
// State management for host-based tunnels
|
||||
const activeTunnels = new Map<string, Client>(); // tunnelName -> Client
|
||||
const retryCounters = new Map<string, number>(); // tunnelName -> retryCount
|
||||
const connectionStatus = new Map<string, TunnelStatus>(); // tunnelName -> status
|
||||
const tunnelVerifications = new Map<string, VerificationData>(); // tunnelName -> verification
|
||||
const manualDisconnects = new Set<string>(); // tunnelNames
|
||||
const verificationTimers = new Map<string, NodeJS.Timeout>(); // timer keys -> timeout
|
||||
const activeRetryTimers = new Map<string, NodeJS.Timeout>(); // tunnelName -> retry timer
|
||||
const countdownIntervals = new Map<string, NodeJS.Timeout>(); // tunnelName -> countdown interval
|
||||
const retryExhaustedTunnels = new Set<string>(); // tunnelNames
|
||||
const activeTunnels = new Map<string, Client>();
|
||||
const retryCounters = new Map<string, number>();
|
||||
const connectionStatus = new Map<string, TunnelStatus>();
|
||||
const tunnelVerifications = new Map<string, VerificationData>();
|
||||
const manualDisconnects = new Set<string>();
|
||||
const verificationTimers = new Map<string, NodeJS.Timeout>();
|
||||
const activeRetryTimers = new Map<string, NodeJS.Timeout>();
|
||||
const countdownIntervals = new Map<string, NodeJS.Timeout>();
|
||||
const retryExhaustedTunnels = new Set<string>();
|
||||
|
||||
const tunnelConfigs = new Map<string, TunnelConfig>(); // tunnelName -> tunnelConfig
|
||||
const activeTunnelProcesses = new Map<string, ChildProcess>(); // tunnelName -> ChildProcess
|
||||
const tunnelConfigs = new Map<string, TunnelConfig>();
|
||||
const activeTunnelProcesses = new Map<string, ChildProcess>();s
|
||||
|
||||
// Types
|
||||
interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
@@ -159,7 +157,6 @@ const ERROR_TYPES = {
|
||||
type ConnectionState = typeof CONNECTION_STATES[keyof typeof CONNECTION_STATES];
|
||||
type ErrorType = typeof ERROR_TYPES[keyof typeof ERROR_TYPES];
|
||||
|
||||
// Helper functions
|
||||
function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void {
|
||||
if (status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName)) {
|
||||
return;
|
||||
@@ -218,14 +215,11 @@ function classifyError(errorMessage: string): ErrorType {
|
||||
return ERROR_TYPES.UNKNOWN;
|
||||
}
|
||||
|
||||
// Helper to build a unique marker for each tunnel
|
||||
function getTunnelMarker(tunnelName: string) {
|
||||
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
}
|
||||
|
||||
// Cleanup and disconnect functions
|
||||
function cleanupTunnelResources(tunnelName: string): void {
|
||||
// Fire-and-forget remote pkill (do not block local cleanup)
|
||||
const tunnelConfig = tunnelConfigs.get(tunnelName);
|
||||
if (tunnelConfig) {
|
||||
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
|
||||
@@ -235,7 +229,6 @@ function cleanupTunnelResources(tunnelName: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
// Local cleanup (always run immediately)
|
||||
if (activeTunnelProcesses.has(tunnelName)) {
|
||||
try {
|
||||
const proc = activeTunnelProcesses.get(tunnelName);
|
||||
@@ -398,7 +391,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
||||
const initialNextRetryIn = Math.ceil(retryInterval / 1000);
|
||||
let currentNextRetryIn = initialNextRetryIn;
|
||||
|
||||
// Set initial WAITING status with countdown
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.WAITING,
|
||||
@@ -407,7 +399,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
||||
nextRetryIn: currentNextRetryIn
|
||||
});
|
||||
|
||||
// Update countdown every second
|
||||
const countdownInterval = setInterval(() => {
|
||||
currentNextRetryIn--;
|
||||
if (currentNextRetryIn > 0) {
|
||||
@@ -447,7 +438,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
||||
}
|
||||
}
|
||||
|
||||
// Tunnel verification function
|
||||
function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void {
|
||||
if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) {
|
||||
return;
|
||||
@@ -496,10 +486,7 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Verification failed for '${tunnelName}': ${failureReason}`);
|
||||
|
||||
// With the new verification approach, we're testing connectivity to the endpoint machine
|
||||
// A failure might just mean the service isn't running on that port, not that the tunnel is broken
|
||||
// Only disconnect if it's a critical error (command failed, connection error, or timeout)
|
||||
|
||||
if (failureReason.includes('command failed') || failureReason.includes('connection error') || failureReason.includes('timeout')) {
|
||||
if (!manualDisconnects.has(tunnelName)) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
@@ -511,19 +498,13 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
||||
activeTunnels.delete(tunnelName);
|
||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||
} else {
|
||||
// For connection refused or other non-critical errors, assume the tunnel is working
|
||||
// The service might just not be running on the target port
|
||||
logger.info(`Assuming tunnel '${tunnelName}' is working despite verification warning: ${failureReason}`);
|
||||
cleanupVerification(true); // Treat as successful to prevent disconnect
|
||||
cleanupVerification(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function attemptVerification() {
|
||||
// Test the actual tunnel by trying to connect to the endpoint port
|
||||
// This verifies that the tunnel is actually working
|
||||
// With -R forwarding, the endpointPort should be listening on the endpoint machine
|
||||
// We need to check if the port is accessible from the source machine to the endpoint machine
|
||||
const testCmd = `timeout 3 bash -c 'nc -z ${tunnelConfig.endpointIP} ${tunnelConfig.endpointPort}'`;
|
||||
|
||||
verificationConn.exec(testCmd, (err, stream) => {
|
||||
@@ -535,7 +516,7 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
||||
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
|
||||
|
||||
stream.on('data', (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
@@ -548,17 +529,16 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
||||
if (code === 0) {
|
||||
cleanupVerification(true);
|
||||
} else {
|
||||
// Check if it's a timeout or connection refused
|
||||
const isTimeout = errorOutput.includes('timeout') || errorOutput.includes('Connection timed out');
|
||||
const isConnectionRefused = errorOutput.includes('Connection refused') || errorOutput.includes('No route to host');
|
||||
|
||||
|
||||
let failureReason = `Cannot connect to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
|
||||
if (isTimeout) {
|
||||
failureReason = `Tunnel verification timeout - cannot reach ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
|
||||
} else if (isConnectionRefused) {
|
||||
failureReason = `Connection refused to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort} - tunnel may not be established`;
|
||||
}
|
||||
|
||||
|
||||
cleanupVerification(false, failureReason);
|
||||
}
|
||||
});
|
||||
@@ -571,7 +551,6 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
||||
}
|
||||
|
||||
verificationConn.on('ready', () => {
|
||||
// Add a small delay to allow the tunnel to fully establish
|
||||
setTimeout(() => {
|
||||
attemptVerification();
|
||||
}, 2000);
|
||||
@@ -633,7 +612,6 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
||||
if (tunnelConfig.sourceKeyPassword) {
|
||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||
}
|
||||
// Add key type handling if specified
|
||||
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
|
||||
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
|
||||
}
|
||||
@@ -714,10 +692,9 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void
|
||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||
});
|
||||
});
|
||||
}, 30000); // Ping every 30 seconds
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
// Main SSH tunnel connection function
|
||||
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
const tunnelName = tunnelConfig.name;
|
||||
const tunnelMarker = getTunnelMarker(tunnelName);
|
||||
@@ -733,7 +710,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
retryCounters.delete(tunnelName);
|
||||
}
|
||||
|
||||
// Only set status to CONNECTING if we're not already in WAITING state
|
||||
const currentStatus = connectionStatus.get(tunnelName);
|
||||
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
@@ -835,7 +811,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
|
||||
let tunnelCmd: string;
|
||||
if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) {
|
||||
// For SSH key authentication, we need to create a temporary key file
|
||||
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
tunnelCmd = `echo '${tunnelConfig.endpointSSHKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`;
|
||||
} else {
|
||||
@@ -975,7 +950,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
};
|
||||
|
||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||
// Validate SSH key format
|
||||
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) {
|
||||
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
@@ -990,7 +964,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
if (tunnelConfig.sourceKeyPassword) {
|
||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||
}
|
||||
// Add key type handling if specified
|
||||
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
|
||||
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
|
||||
}
|
||||
@@ -1006,14 +979,12 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
connOptions.password = tunnelConfig.sourcePassword;
|
||||
}
|
||||
|
||||
// Test basic network connectivity first
|
||||
const testSocket = new net.Socket();
|
||||
testSocket.setTimeout(5000);
|
||||
|
||||
testSocket.on('connect', () => {
|
||||
testSocket.destroy();
|
||||
|
||||
// Only update status to CONNECTING if we're not already in WAITING state
|
||||
const currentStatus = connectionStatus.get(tunnelName);
|
||||
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
@@ -1047,7 +1018,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||
testSocket.connect(tunnelConfig.sourceSSHPort, tunnelConfig.sourceIP);
|
||||
}
|
||||
|
||||
// Add a helper to kill the tunnel by marker
|
||||
function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) {
|
||||
const tunnelMarker = getTunnelMarker(tunnelName);
|
||||
const conn = new Client();
|
||||
@@ -1106,7 +1076,6 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
||||
connOptions.password = tunnelConfig.sourcePassword;
|
||||
}
|
||||
conn.on('ready', () => {
|
||||
// Use pkill to kill the tunnel by marker
|
||||
const killCmd = `pkill -f '${tunnelMarker}'`;
|
||||
conn.exec(killCmd, (err, stream) => {
|
||||
if (err) {
|
||||
@@ -1128,7 +1097,6 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
||||
conn.connect(connOptions);
|
||||
}
|
||||
|
||||
// Express API endpoints
|
||||
app.get('/ssh/tunnel/status', (req, res) => {
|
||||
res.json(getAllTunnelStatus());
|
||||
});
|
||||
@@ -1153,16 +1121,12 @@ app.post('/ssh/tunnel/connect', (req, res) => {
|
||||
|
||||
const tunnelName = tunnelConfig.name;
|
||||
|
||||
|
||||
// Reset retry state for new connection
|
||||
manualDisconnects.delete(tunnelName);
|
||||
retryCounters.delete(tunnelName);
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
|
||||
// Store tunnel config
|
||||
tunnelConfigs.set(tunnelName, tunnelConfig);
|
||||
|
||||
// Start connection
|
||||
connectSSHTunnel(tunnelConfig, 0);
|
||||
|
||||
res.json({message: 'Connection request received', tunnelName});
|
||||
@@ -1193,7 +1157,6 @@ app.post('/ssh/tunnel/disconnect', (req, res) => {
|
||||
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
|
||||
handleDisconnect(tunnelName, tunnelConfig, false);
|
||||
|
||||
// Clear manual disconnect flag after a delay
|
||||
setTimeout(() => {
|
||||
manualDisconnects.delete(tunnelName);
|
||||
}, 5000);
|
||||
@@ -1208,7 +1171,6 @@ app.post('/ssh/tunnel/cancel', (req, res) => {
|
||||
return res.status(400).json({error: 'Tunnel name required'});
|
||||
}
|
||||
|
||||
// Cancel retry operations
|
||||
retryCounters.delete(tunnelName);
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
|
||||
@@ -1222,18 +1184,15 @@ app.post('/ssh/tunnel/cancel', (req, res) => {
|
||||
countdownIntervals.delete(tunnelName);
|
||||
}
|
||||
|
||||
// Set status to disconnected
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
manualDisconnect: true
|
||||
});
|
||||
|
||||
// Clean up any existing tunnel resources
|
||||
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
|
||||
handleDisconnect(tunnelName, tunnelConfig, false);
|
||||
|
||||
// Clear manual disconnect flag after a delay
|
||||
setTimeout(() => {
|
||||
manualDisconnects.delete(tunnelName);
|
||||
}, 5000);
|
||||
@@ -1241,10 +1200,8 @@ app.post('/ssh/tunnel/cancel', (req, res) => {
|
||||
res.json({message: 'Cancel request received', tunnelName});
|
||||
});
|
||||
|
||||
// Auto-start functionality
|
||||
async function initializeAutoStartTunnels(): Promise<void> {
|
||||
try {
|
||||
// Fetch hosts with auto-start tunnel connections from the new internal endpoint
|
||||
const response = await axios.get('http://localhost:8081/ssh/db/host/internal', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -1255,12 +1212,10 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
const hosts: SSHHost[] = response.data || [];
|
||||
const autoStartTunnels: TunnelConfig[] = [];
|
||||
|
||||
// Process each host and extract auto-start tunnel connections
|
||||
for (const host of hosts) {
|
||||
if (host.enableTunnel && host.tunnelConnections) {
|
||||
for (const tunnelConnection of host.tunnelConnections) {
|
||||
if (tunnelConnection.autoStart) {
|
||||
// Find the endpoint host
|
||||
const endpointHost = hosts.find(h =>
|
||||
h.name === tunnelConnection.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnelConnection.endpointHost
|
||||
@@ -1303,11 +1258,9 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
|
||||
logger.info(`Found ${autoStartTunnels.length} auto-start tunnels`);
|
||||
|
||||
// Start each auto-start tunnel
|
||||
for (const tunnelConfig of autoStartTunnels) {
|
||||
tunnelConfigs.set(tunnelConfig.name, tunnelConfig);
|
||||
|
||||
// Start the tunnel with a delay to avoid overwhelming the system
|
||||
setTimeout(() => {
|
||||
connectSSHTunnel(tunnelConfig, 0);
|
||||
}, 1000);
|
||||
@@ -1322,4 +1275,4 @@ app.listen(PORT, () => {
|
||||
setTimeout(() => {
|
||||
initializeAutoStartTunnels();
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
import {createContext, useContext, useEffect, useState} from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
|
||||
|
||||
229
src/index.css
229
src/index.css
@@ -4,129 +4,130 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #09090b;
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #09090b;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user