diff --git a/docker/Dockerfile b/docker/Dockerfile index 8a2357a4..be401b40 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 5512ac4e..9b84669d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 8fc5adfe..d6f5033a 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -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 \ No newline at end of file diff --git a/src/apps/Homepage/HomepageAuth.tsx b/src/apps/Homepage/HomepageAuth.tsx index d9e26c15..ef8a1659 100644 --- a/src/apps/Homepage/HomepageAuth.tsx +++ b/src/apps/Homepage/HomepageAuth.tsx @@ -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(null); - const [internalLoggedIn, setInternalLoggedIn] = useState(false); - const [firstUser, setFirstUser] = useState(false); - const [dbError, setDbError] = useState(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(null); + const [internalLoggedIn, setInternalLoggedIn] = useState(false); + const [firstUser, setFirstUser] = useState(false); + const [dbError, setDbError] = useState(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 = ( + + + + + ); - const Spinner = ( - - - - - ); - - return ( -
-
- {dbError && ( - - Error - {dbError} - - )} - {firstUser && !dbError && !internalLoggedIn && ( - - First User - - You are the first user and will be made an admin. You can view admin settings in the sidebar user dropdown. - - - )} - {!registrationAllowed && !internalLoggedIn && ( - - Registration Disabled - - New account registration is currently disabled by an admin. Please log in or contact an administrator. - - - )} - {(internalLoggedIn || (loading && getCookie("jwt"))) && ( -
-
- - Logged in! - - 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. - - - -
- -
- -
- -
- -
-
-
- )} - {(!internalLoggedIn && (!loading || !getCookie("jwt"))) && ( - <> -
- - +
+ +
+ +
+ +
+
+
+ )} + {(!internalLoggedIn && (!loading || !getCookie("jwt"))) && ( + <> +
+ + +
+
+

+ {tab === "login" ? "Login to your account" : "Create a new account"} +

+
+
+
+ + setLocalUsername(e.target.value)} + disabled={loading || internalLoggedIn} + /> +
+
+ + setPassword(e.target.value)} + disabled={loading || internalLoggedIn}/> +
+ +
+ + )} + {error && ( + + Error + {error} + )} - onClick={() => setTab("signup")} - aria-selected={tab === "signup"} - disabled={loading || !registrationAllowed} - > - Sign Up - -
-

- {tab === "login" ? "Login to your account" : "Create a new account"} -

-
-
-
- - setLocalUsername(e.target.value)} - disabled={loading || internalLoggedIn} - /> -
-
- - setPassword(e.target.value)} disabled={loading || internalLoggedIn} /> -
- -
- - )} - {error && ( - - Error - {error} - - )} - - - ); + + ); } \ No newline at end of file diff --git a/src/apps/Homepage/HomepageSidebar.tsx b/src/apps/Homepage/HomepageSidebar.tsx index 2669a643..83de4887 100644 --- a/src/apps/Homepage/HomepageSidebar.tsx +++ b/src/apps/Homepage/HomepageSidebar.tsx @@ -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 - onSelectView("ssh_manager")} disabled={disabled}> + onSelectView("ssh_manager")} + disabled={disabled}> SSH Manager
- onSelectView("terminal")} disabled={disabled}> + onSelectView("terminal")} + disabled={disabled}> Terminal diff --git a/src/apps/SSH/Config Editor/ConfigCodeEditor.tsx b/src/apps/SSH/Config Editor/ConfigCodeEditor.tsx index ce8d23b2..c216bf16 100644 --- a/src/apps/SSH/Config Editor/ConfigCodeEditor.tsx +++ b/src/apps/SSH/Config Editor/ConfigCodeEditor.tsx @@ -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 ( -
+
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}} />
diff --git a/src/apps/SSH/Config Editor/ConfigEditor.tsx b/src/apps/SSH/Config Editor/ConfigEditor.tsx index e89ba853..7251ba77 100644 --- a/src/apps/SSH/Config Editor/ConfigEditor.tsx +++ b/src/apps/SSH/Config Editor/ConfigEditor.tsx @@ -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([]); const [activeTab, setActiveTab] = useState('home'); const [recent, setRecent] = useState([]); @@ -71,89 +71,71 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) => const sidebarRef = useRef(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 ( -
-
- +
+
-
+

Connect to a Server

Select a server from the sidebar to start editing files

@@ -525,29 +467,31 @@ export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) => } return ( -
-
- +
+
-
-
+
+
{/* Tab list scrollable area */}
-
+
({ 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'}
-
+
{activeTab === 'home' ? ( const tab = tabs.find(t => t.id === activeTab); if (!tab) return null; return ( -
+
{/* Error display */} {tab.error && ( -
+
⚠️ {tab.error}
))} {idx < sortedFolders.length - 1 && ( -
- +
+
)} @@ -538,18 +438,17 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( )} {view === 'files' && activeServer && ( -
- {/* Sticky path input bar - outside ScrollArea */} -
+
+
- {/* File search bar */}
setFileSearch(e.target.value)} />
- {/* File list with proper scroll area - separate from topbar */}
- +
{connectingSSH || filesLoading ? (
Loading...
) : filesError ? (
{filesError}
) : filteredFiles.length === 0 ? ( -
No files or folders found.
+
No files or + folders found.
) : (
{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}} >
!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' ? - : - } - {item.name} + : + } + {item.name}
{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( } }} > - + )}
@@ -679,4 +591,4 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( ); }); -export { ConfigEditorSidebar }; \ No newline at end of file +export {ConfigEditorSidebar}; \ No newline at end of file diff --git a/src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx b/src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx index 7dc4ac5d..99d3e7d8 100644 --- a/src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx +++ b/src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx @@ -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 (
{/* SSH Connections */} @@ -68,7 +68,7 @@ export function ConfigFileSidebarViewer({
SSH Connections
@@ -77,7 +77,7 @@ export function ConfigFileSidebarViewer({ className="w-full justify-start text-left px-2 py-1.5 rounded" onClick={onSwitchToLocal} > - Local Files + Local Files {sshConnections.map((conn) => (
@@ -86,18 +86,19 @@ export function ConfigFileSidebarViewer({ className="flex-1 justify-start text-left px-2 py-1.5 rounded" onClick={() => onSwitchToSSH(conn)} > - + {conn.name || conn.ip} - {conn.isPinned && } + {conn.isPinned && }
))} @@ -106,7 +107,8 @@ export function ConfigFileSidebarViewer({ {/* File/Folder Viewer */}
- {isSSHMode ? 'SSH Path' : 'Local Path'} + {isSSHMode ? 'SSH Path' : 'Local Path'} {currentPath}
{isLoading ? ( @@ -116,22 +118,29 @@ export function ConfigFileSidebarViewer({ ) : (
{files.map((item) => ( - -
item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}> - {item.type === 'directory' ? : } + +
item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}> + {item.type === 'directory' ? : + } {item.name}
- -
))} - {files.length === 0 &&
No files or folders found.
} + {files.length === 0 && +
No files or folders found.
}
)}
diff --git a/src/apps/SSH/Config Editor/ConfigHomeView.tsx b/src/apps/SSH/Config Editor/ConfigHomeView.tsx index 0279e82a..74bf3429 100644 --- a/src/apps/SSH/Config Editor/ConfigHomeView.tsx +++ b/src/apps/SSH/Config Editor/ConfigHomeView.tsx @@ -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) => ( -
-
+
onOpenFile(file)} > - {file.type === 'directory' ? - : - + {file.type === 'directory' ? + : + }
@@ -68,23 +66,24 @@ export function ConfigHomeView({
{onPin && ( - )} {onRemove && ( - )}
@@ -92,12 +91,13 @@ export function ConfigHomeView({ ); const renderShortcutCard = (shortcut: ShortcutItem) => ( -
-
+
onOpenShortcut(shortcut)} > - +
{shortcut.path} @@ -105,13 +105,13 @@ export function ConfigHomeView({
-
@@ -123,18 +123,19 @@ export function ConfigHomeView({ Recent Pinned - Folder Shortcuts + Folder + Shortcuts - +
{recent.length === 0 ? (
No recent files.
- ) : 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({ )}
- +
{pinned.length === 0 ? (
No pinned files.
- ) : 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 ) )}
- +
- + Add
@@ -194,7 +195,7 @@ export function ConfigHomeView({
No shortcuts.
- ) : shortcuts.map((shortcut) => + ) : shortcuts.map((shortcut) => renderShortcutCard(shortcut) )}
diff --git a/src/apps/SSH/Config Editor/ConfigTabList.tsx b/src/apps/SSH/Config Editor/ConfigTabList.tsx index f6c2bb4c..37ae5962 100644 --- a/src/apps/SSH/Config Editor/ConfigTabList.tsx +++ b/src/apps/SSH/Config Editor/ConfigTabList.tsx @@ -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 (
{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]" : ""} >
- {/* Set Active Tab Button */} - {/* Close Tab Button */}
diff --git a/src/apps/SSH/Manager/SSHManager.tsx b/src/apps/SSH/Manager/SSHManager.tsx index c2463e2c..90c11750 100644 --- a/src/apps/SSH/Manager/SSHManager.tsx +++ b/src/apps/SSH/Manager/SSHManager.tsx @@ -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(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 />
-
+
-
- +
+ Host Viewer @@ -72,13 +73,13 @@ export function SSHManager({ onSelectView }: ConfigEditorProps): React.ReactElem - + - +
- diff --git a/src/apps/SSH/Manager/SSHManagerHostEditor.tsx b/src/apps/SSH/Manager/SSHManagerHostEditor.tsx index 25ccd51c..faa06c81 100644 --- a/src/apps/SSH/Manager/SSHManagerHostEditor.tsx +++ b/src/apps/SSH/Manager/SSHManagerHostEditor.tsx @@ -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([]); const [folders, setFolders] = useState([]); const [sshConfigurations, setSshConfigurations] = useState([]); 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(null); const folderDropdownRef = useRef(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 Folder - - {/* Folder dropdown menu */} {folderDropdownOpen && filteredFolders.length > 0 && (
( + render={({field}) => ( Tags -
+
{field.value.map((tag: string, idx: number) => ( - + {tag}
Authentication - { setAuthTab(value as 'password' | 'key'); form.setValue('authType', value as 'password' | 'key'); @@ -735,12 +706,6 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo )} /> - - {form.watch('enableTerminal') && ( -
- {/* Tunnel Config (none yet) */} -
- )} )} /> - + {form.watch('enableTunnel') && ( <> - - - Sshpass Required For Password Authentication -
- For password-based SSH authentication, sshpass must be installed on both the local and remote servers. Install with: sudo apt install sshpass (Debian/Ubuntu) or the equivalent for your OS. -
-
- Other installation methods: -
• CentOS/RHEL/Fedora: sudo yum install sshpass or sudo dnf install sshpass
-
• macOS: brew install hudochenkov/sshpass/sshpass
-
• Windows: Use WSL or consider SSH key authentication
-
-
-
+ + + Sshpass Required For Password Authentication +
+ For password-based SSH authentication, sshpass must be installed on + both the local and remote servers. Install with: sudo apt install + sshpass (Debian/Ubuntu) or the equivalent for your OS. +
+
+ Other installation methods: +
• CentOS/RHEL/Fedora: sudo yum install + sshpass or sudo dnf install + sshpass
+
• macOS: brew + install hudochenkov/sshpass/sshpass
+
• Windows: Use WSL or consider SSH key authentication
+
+
+
+ + + + SSH Server Configuration Required +
For reverse SSH tunnels, the endpoint SSH server must allow:
+
GatewayPorts + yes (bind remote ports) +
+
AllowTcpForwarding + yes (port forwarding) +
+
PermitRootLogin + yes (if using root) +
+
Edit /etc/ssh/sshd_config and + restart SSH: sudo + systemctl restart sshd
+
+
- - - SSH Server Configuration Required -
For reverse SSH tunnels, the endpoint SSH server must allow:
-
GatewayPorts yes (bind remote ports)
-
AllowTcpForwarding yes (port forwarding)
-
PermitRootLogin yes (if using root)
-
Edit /etc/ssh/sshd_config and restart SSH: sudo systemctl restart sshd
-
-
-
{field.value.map((connection, index) => ( -
-
+
+

Connection {index + 1}

- +

- 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.

- +
Max Retries - + - Maximum number of retry attempts for tunnel connection. + Maximum number of retry attempts + for tunnel connection. )} @@ -915,12 +918,15 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo name={`tunnelConnections.${index}.retryInterval`} render={({field: retryIntervalField}) => ( - Retry Interval (seconds) + Retry Interval + (seconds) - + - Time to wait between retry attempts. + Time to wait between retry + attempts. )} @@ -930,7 +936,8 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo name={`tunnelConnections.${index}.autoStart`} render={({field}) => ( - Auto Start on Container Launch + Auto Start on Container + Launch - Automatically start this tunnel when the container launches. + Automatically start this tunnel + when the container launches. )} @@ -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 )} /> - + )} @@ -991,22 +999,23 @@ export function SSHManagerHostEditor({ editingHost, onFormSubmit }: SSHManagerHo )} /> - + {form.watch('enableConfigEditor') && (
- ( - - Default Path - - - - Set default directory shown when connected via Config Editor - - )} - /> + ( + + Default Path + + + + Set default directory shown when connected via + Config Editor + + )} + />
)} diff --git a/src/apps/SSH/Manager/SSHManagerHostViewer.tsx b/src/apps/SSH/Manager/SSHManagerHostViewer.tsx index fe2c91bf..7fa5e714 100644 --- a/src/apps/SSH/Manager/SSHManagerHostViewer.tsx +++ b/src/apps/SSH/Manager/SSHManagerHostViewer.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 (
- +

No SSH Hosts

You haven't added any SSH hosts yet. Click "Add Host" to get started. @@ -187,9 +177,8 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)

- {/* Search Bar */}
- +
- +
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
- +
- + {folder} {folderHosts.length} @@ -216,15 +206,16 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
{folderHosts.map((host) => ( -
handleEdit(host)} >
- {host.pin && } + {host.pin && }

{host.name || `${host.username}@${host.ip}`}

@@ -246,7 +237,7 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps) }} className="h-5 w-5 p-0" > - +
- +
- {/* Tags */} {host.tags && host.tags.length > 0 && (
{host.tags.slice(0, 6).map((tag, index) => ( - - + + {tag} ))} {host.tags.length > 6 && ( - + +{host.tags.length - 6} )}
)} - - {/* Features */} +
{host.enableTerminal && ( - + Terminal )} {host.enableTunnel && ( - + Tunnel {host.tunnelConnections && host.tunnelConnections.length > 0 && ( - ({host.tunnelConnections.length}) + ({host.tunnelConnections.length}) )} )} {host.enableConfigEditor && ( - + Config )} diff --git a/src/apps/SSH/Manager/SSHManagerSidebar.tsx b/src/apps/SSH/Manager/SSHManagerSidebar.tsx index c29d95fe..819830f3 100644 --- a/src/apps/SSH/Manager/SSHManagerSidebar.tsx +++ b/src/apps/SSH/Manager/SSHManagerSidebar.tsx @@ -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 ( @@ -35,17 +35,18 @@ export function SSHManagerSidebar({ onSelectView }: SidebarProps): React.ReactEl Termix / SSH Manager - + {/* Sidebar Items */} - - + diff --git a/src/apps/SSH/Terminal/SSH.tsx b/src/apps/SSH/Terminal/SSH.tsx index 110225df..46f42961 100644 --- a/src/apps/SSH/Terminal/SSH.tsx +++ b/src/apps/SSH/Terminal/SSH.tsx @@ -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; }; -export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement { +export function SSH({onSelectView}: ConfigEditorProps): React.ReactElement { const [allTabs, setAllTabs] = useState([]); const [currentTab, setCurrentTab] = useState(null); const [allSplitScreenTab, setAllSplitScreenTab] = useState([]); @@ -72,7 +72,7 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement { const updatePanelRects = () => { setPanelRects((prev) => { - const next: Record = { ...prev }; + const next: Record = {...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 ( -
{ panelRefs.current['parent'] = el; }} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 1, overflow: 'hidden' }}> +
{ + 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 (
@@ -170,15 +180,37 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement { if (layoutTabs.length === 2) { const [tab1, tab2] = layoutTabs; return ( -
+
{ panelGroupRefs.current['main'] = el; }} + ref={el => { + panelGroupRefs.current['main'] = el; + }} direction="horizontal" className="h-full w-full" id="main-horizontal" > - -
{ panelRefs.current[String(tab1.id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> + +
{ + panelRefs.current[String(tab1.id)] = el; + }} style={{ + height: '100%', + width: '100%', + display: 'flex', + flexDirection: 'column', + background: 'transparent', + margin: 0, + padding: 0, + position: 'relative' + }}>
{tab1.title}
- - -
{ panelRefs.current[String(tab2.id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> + + +
{ + panelRefs.current[String(tab2.id)] = el; + }} style={{ + height: '100%', + width: '100%', + display: 'flex', + flexDirection: 'column', + background: 'transparent', + margin: 0, + padding: 0, + position: 'relative' + }}>
+
{ panelGroupRefs.current['main'] = el; }} + ref={el => { + panelGroupRefs.current['main'] = el; + }} direction="vertical" className="h-full w-full" id="main-vertical" > - - { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal"> - -
{ panelRefs.current[String(layoutTabs[0].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> + + { + panelGroupRefs.current['top'] = el; + }} direction="horizontal" className="h-full w-full" id="top-horizontal"> + +
{ + panelRefs.current[String(layoutTabs[0].id)] = el; + }} style={{ + height: '100%', + width: '100%', + display: 'flex', + flexDirection: 'column', + background: 'transparent', + margin: 0, + padding: 0, + position: 'relative' + }}>
{layoutTabs[0].title}
- - -
{ panelRefs.current[String(layoutTabs[1].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> + + +
{ + panelRefs.current[String(layoutTabs[1].id)] = el; + }} style={{ + height: '100%', + width: '100%', + display: 'flex', + flexDirection: 'column', + background: 'transparent', + margin: 0, + padding: 0, + position: 'relative' + }}>
- - -
{ panelRefs.current[String(layoutTabs[2].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> + + +
{ + panelRefs.current[String(layoutTabs[2].id)] = el; + }} style={{ + height: '100%', + width: '100%', + display: 'flex', + flexDirection: 'column', + background: 'transparent', + margin: 0, + padding: 0, + position: 'relative' + }}>
+
{ panelGroupRefs.current['main'] = el; }} + ref={el => { + panelGroupRefs.current['main'] = el; + }} direction="vertical" className="h-full w-full" id="main-vertical" > - - { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal"> - -
{ panelRefs.current[String(layoutTabs[0].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> + + { + panelGroupRefs.current['top'] = el; + }} direction="horizontal" className="h-full w-full" id="top-horizontal"> + +
{ + panelRefs.current[String(layoutTabs[0].id)] = el; + }} style={{ + height: '100%', + width: '100%', + display: 'flex', + flexDirection: 'column', + background: 'transparent', + margin: 0, + padding: 0, + position: 'relative' + }}>
{layoutTabs[0].title}
- - -
{ panelRefs.current[String(layoutTabs[1].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> + + +
{ + panelRefs.current[String(layoutTabs[1].id)] = el; + }} style={{ + height: '100%', + width: '100%', + display: 'flex', + flexDirection: 'column', + background: 'transparent', + margin: 0, + padding: 0, + position: 'relative' + }}>
- - - { panelGroupRefs.current['bottom'] = el; }} direction="horizontal" className="h-full w-full" id="bottom-horizontal"> - -
{ panelRefs.current[String(layoutTabs[2].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> + + + { + panelGroupRefs.current['bottom'] = el; + }} direction="horizontal" className="h-full w-full" id="bottom-horizontal"> + +
{ + panelRefs.current[String(layoutTabs[2].id)] = el; + }} style={{ + height: '100%', + width: '100%', + display: 'flex', + flexDirection: 'column', + background: 'transparent', + margin: 0, + padding: 0, + position: 'relative' + }}>
{layoutTabs[2].title}
- - -
{ panelRefs.current[String(layoutTabs[3].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> + + +
{ + panelRefs.current[String(layoutTabs[3].id)] = el; + }} style={{ + height: '100%', + width: '100%', + display: 'flex', + flexDirection: 'column', + background: 'transparent', + margin: 0, + padding: 0, + position: 'relative' + }}>
- {/* Sidebar: fixed width */} -
- { - allTabs.forEach(tab => { - if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) { - tab.terminalRef.current.sendInput(command); - } - }); - }} - /> +
+
+ { + allTabs.forEach(tab => { + if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) { + tab.terminalRef.current.sendInput(command); + } + }); + }} + />
- {/* Main area: fills the rest */}
- {/* Always render the topbar at the top */} -
- + -
- {/* Split area below the topbar */} -
- {/* Show alert when no terminals are rendered */} + setActiveTab={setActiveTab} + allSplitScreenTab={allSplitScreenTab} + setSplitScreenTab={setSplitScreenTab} + setCloseTab={setCloseTab} + /> +
+
{allTabs.length === 0 && (
-
+
Welcome to Termix SSH
-
- Click on any host title in the sidebar to open a terminal connection, or use the "Add Host" button to create a new connection. +
+ Click on any host title in the sidebar to open a terminal connection, or use the "Add + Host" button to create a new connection.
)} - {/* Absolutely render all terminals for persistence and layout */} {allSplitScreenTab.length > 0 && ( -
+
- + -
- {/* Search bar */} +
-
- +
+
- {/* Error and status messages */} {hostsError && (
-
{hostsError}
+
{hostsError}
)} {!hostsLoading && !hostsError && hosts.length === 0 && (
-
No hosts found.
+
No + hosts found. +
)}
- 0 ? sortedFolders : undefined}> + 0 ? sortedFolders : undefined}> {sortedFolders.map((folder, idx) => ( - - {folder} - + + {folder} + {getSortedHosts(hostsByFolder[folder]).map(host => ( -
+
{idx < sortedFolders.length - 1 && ( -
- +
+
)} @@ -271,7 +281,6 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT - {/* Tools Button at the very bottom */}
@@ -280,27 +289,32 @@ export function SSHSidebar({ onSelectView, onHostConnect, allTabs, runCommandOnT variant="outline" onClick={() => setToolsSheetOpen(true)} > - + Tools - + Tools
- Run multiwindow commands + Run multiwindow + commands