Clean up code

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ function setCookie(name: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString(); const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
} }
function getCookie(name: string) { function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => { return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('='); const parts = v.split('=');
@@ -152,7 +153,8 @@ export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername,
)} )}
{...props} {...props}
> >
<div className={`w-[420px] max-w-full bg-background rounded-xl shadow-lg p-6 flex flex-col ${internalLoggedIn ? '' : 'border border-border'}`}> <div
className={`w-[420px] max-w-full bg-background rounded-xl shadow-lg p-6 flex flex-col ${internalLoggedIn ? '' : 'border border-border'}`}>
{dbError && ( {dbError && (
<Alert variant="destructive" className="mb-4"> <Alert variant="destructive" className="mb-4">
<AlertTitle>Error</AlertTitle> <AlertTitle>Error</AlertTitle>
@@ -163,7 +165,8 @@ export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername,
<Alert variant="default" className="mb-4"> <Alert variant="default" className="mb-4">
<AlertTitle>First User</AlertTitle> <AlertTitle>First User</AlertTitle>
<AlertDescription> <AlertDescription>
You are the first user and will be made an admin. You can view admin settings in the sidebar user dropdown. You are the first user and will be made an admin. You can view admin settings in the sidebar
user dropdown.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@@ -171,7 +174,8 @@ export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername,
<Alert variant="destructive" className="mb-4"> <Alert variant="destructive" className="mb-4">
<AlertTitle>Registration Disabled</AlertTitle> <AlertTitle>Registration Disabled</AlertTitle>
<AlertDescription> <AlertDescription>
New account registration is currently disabled by an admin. Please log in or contact an administrator. New account registration is currently disabled by an admin. Please log in or contact an
administrator.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@@ -181,7 +185,9 @@ export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername,
<Alert className="my-2"> <Alert className="my-2">
<AlertTitle>Logged in!</AlertTitle> <AlertTitle>Logged in!</AlertTitle>
<AlertDescription> <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. 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> </AlertDescription>
</Alert> </Alert>
@@ -273,9 +279,12 @@ export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername,
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label> <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} /> <Input id="password" type="password" required className="h-11 text-base"
value={password} onChange={e => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}/>
</div> </div>
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold" disabled={loading || internalLoggedIn}> <Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}>
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")} {loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
</Button> </Button>
</form> </form>

View File

@@ -69,7 +69,13 @@ const API = axios.create({
baseURL: apiBase, baseURL: apiBase,
}); });
export function HomepageSidebar({onSelectView, getView, disabled, isAdmin, username}: SidebarProps): React.ReactElement { export function HomepageSidebar({
onSelectView,
getView,
disabled,
isAdmin,
username
}: SidebarProps): React.ReactElement {
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false); const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
const [allowRegistration, setAllowRegistration] = React.useState(true); const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false); const [regLoading, setRegLoading] = React.useState(false);
@@ -109,14 +115,16 @@ export function HomepageSidebar({onSelectView, getView, disabled, isAdmin, usern
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem key={"SSH Manager"}> <SidebarMenuItem key={"SSH Manager"}>
<SidebarMenuButton onClick={() => onSelectView("ssh_manager")} disabled={disabled}> <SidebarMenuButton onClick={() => onSelectView("ssh_manager")}
disabled={disabled}>
<HardDrive/> <HardDrive/>
<span>SSH Manager</span> <span>SSH Manager</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<div className="ml-5"> <div className="ml-5">
<SidebarMenuItem key={"Terminal"}> <SidebarMenuItem key={"Terminal"}>
<SidebarMenuButton onClick={() => onSelectView("terminal")} disabled={disabled}> <SidebarMenuButton onClick={() => onSelectView("terminal")}
disabled={disabled}>
<Computer/> <Computer/>
<span>Terminal</span> <span>Terminal</span>
</SidebarMenuButton> </SidebarMenuButton>

View File

@@ -14,160 +14,285 @@ interface ConfigCodeEditorProps {
export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCodeEditorProps) { export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCodeEditorProps) {
function getLanguageName(filename: string): string { function getLanguageName(filename: string): string {
if (!filename || typeof filename !== 'string') { if (!filename || typeof filename !== 'string') {
return 'text'; // Default to text if no filename return 'text';
} }
const lastDotIndex = filename.lastIndexOf('.'); const lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex === -1) { if (lastDotIndex === -1) {
return 'text'; // No extension found return 'text';
} }
const ext = filename.slice(lastDotIndex + 1).toLowerCase(); const ext = filename.slice(lastDotIndex + 1).toLowerCase();
switch (ext) { switch (ext) {
case 'ng': return 'angular'; case 'ng':
case 'apl': return 'apl'; return 'angular';
case 'asc': return 'asciiArmor'; case 'apl':
case 'ast': return 'asterisk'; return 'apl';
case 'bf': return 'brainfuck'; case 'asc':
case 'c': return 'c'; return 'asciiArmor';
case 'ceylon': return 'ceylon'; case 'ast':
case 'clj': return 'clojure'; return 'asterisk';
case 'cmake': return 'cmake'; case 'bf':
return 'brainfuck';
case 'c':
return 'c';
case 'ceylon':
return 'ceylon';
case 'clj':
return 'clojure';
case 'cmake':
return 'cmake';
case 'cob': case 'cob':
case 'cbl': return 'cobol'; case 'cbl':
case 'coffee': return 'coffeescript'; return 'cobol';
case 'lisp': return 'commonLisp'; case 'coffee':
return 'coffeescript';
case 'lisp':
return 'commonLisp';
case 'cpp': case 'cpp':
case 'cc': case 'cc':
case 'cxx': return 'cpp'; case 'cxx':
case 'cr': return 'crystal'; return 'cpp';
case 'cs': return 'csharp'; case 'cr':
case 'css': return 'css'; return 'crystal';
case 'cypher': return 'cypher'; case 'cs':
case 'd': return 'd'; return 'csharp';
case 'dart': return 'dart'; case 'css':
return 'css';
case 'cypher':
return 'cypher';
case 'd':
return 'd';
case 'dart':
return 'dart';
case 'diff': case 'diff':
case 'patch': return 'diff'; case 'patch':
case 'dockerfile': return 'dockerfile'; return 'diff';
case 'dtd': return 'dtd'; case 'dockerfile':
case 'dylan': return 'dylan'; return 'dockerfile';
case 'ebnf': return 'ebnf'; case 'dtd':
case 'ecl': return 'ecl'; return 'dtd';
case 'eiffel': return 'eiffel'; case 'dylan':
case 'elm': return 'elm'; return 'dylan';
case 'erl': return 'erlang'; case 'ebnf':
case 'factor': return 'factor'; return 'ebnf';
case 'fcl': return 'fcl'; case 'ecl':
case 'fs': return 'forth'; return 'ecl';
case 'eiffel':
return 'eiffel';
case 'elm':
return 'elm';
case 'erl':
return 'erlang';
case 'factor':
return 'factor';
case 'fcl':
return 'fcl';
case 'fs':
return 'forth';
case 'f90': case 'f90':
case 'for': return 'fortran'; case 'for':
case 's': return 'gas'; return 'fortran';
case 'feature': return 'gherkin'; case 's':
case 'go': return 'go'; return 'gas';
case 'groovy': return 'groovy'; case 'feature':
case 'hs': return 'haskell'; return 'gherkin';
case 'hx': return 'haxe'; case 'go':
return 'go';
case 'groovy':
return 'groovy';
case 'hs':
return 'haskell';
case 'hx':
return 'haxe';
case 'html': case 'html':
case 'htm': return 'html'; case 'htm':
case 'http': return 'http'; return 'html';
case 'idl': return 'idl'; case 'http':
case 'java': return 'java'; return 'http';
case 'idl':
return 'idl';
case 'java':
return 'java';
case 'js': case 'js':
case 'mjs': case 'mjs':
case 'cjs': return 'javascript'; case 'cjs':
return 'javascript';
case 'jinja2': case 'jinja2':
case 'j2': return 'jinja2'; case 'j2':
case 'json': return 'json'; return 'jinja2';
case 'jsx': return 'jsx'; case 'json':
case 'jl': return 'julia'; return 'json';
case 'jsx':
return 'jsx';
case 'jl':
return 'julia';
case 'kt': case 'kt':
case 'kts': return 'kotlin'; case 'kts':
case 'less': return 'less'; return 'kotlin';
case 'lezer': return 'lezer'; case 'less':
case 'liquid': return 'liquid'; return 'less';
case 'litcoffee': return 'livescript'; case 'lezer':
case 'lua': return 'lua'; return 'lezer';
case 'md': return 'markdown'; case 'liquid':
return 'liquid';
case 'litcoffee':
return 'livescript';
case 'lua':
return 'lua';
case 'md':
return 'markdown';
case 'nb': case 'nb':
case 'mat': return 'mathematica'; case 'mat':
case 'mbox': return 'mbox'; return 'mathematica';
case 'mmd': return 'mermaid'; case 'mbox':
case 'mrc': return 'mirc'; return 'mbox';
case 'moo': return 'modelica'; case 'mmd':
case 'mscgen': return 'mscgen'; return 'mermaid';
case 'm': return 'mumps'; case 'mrc':
case 'sql': return 'mysql'; return 'mirc';
case 'nc': return 'nesC'; case 'moo':
case 'nginx': return 'nginx'; return 'modelica';
case 'nix': return 'nix'; case 'mscgen':
case 'nsi': return 'nsis'; return 'mscgen';
case 'nt': return 'ntriples'; case 'm':
case 'mm': return 'objectiveCpp'; return 'mumps';
case 'octave': return 'octave'; case 'sql':
case 'oz': return 'oz'; return 'mysql';
case 'pas': return 'pascal'; case 'nc':
return 'nesC';
case 'nginx':
return 'nginx';
case 'nix':
return 'nix';
case 'nsi':
return 'nsis';
case 'nt':
return 'ntriples';
case 'mm':
return 'objectiveCpp';
case 'octave':
return 'octave';
case 'oz':
return 'oz';
case 'pas':
return 'pascal';
case 'pl': case 'pl':
case 'pm': return 'perl'; case 'pm':
case 'pgsql': return 'pgsql'; return 'perl';
case 'php': return 'php'; case 'pgsql':
case 'pig': return 'pig'; return 'pgsql';
case 'ps1': return 'powershell'; case 'php':
case 'properties': return 'properties'; return 'php';
case 'proto': return 'protobuf'; case 'pig':
case 'pp': return 'puppet'; return 'pig';
case 'py': return 'python'; case 'ps1':
case 'q': return 'q'; return 'powershell';
case 'r': return 'r'; case 'properties':
case 'rb': return 'ruby'; return 'properties';
case 'rs': return 'rust'; case 'proto':
case 'sas': return 'sas'; return 'protobuf';
case 'pp':
return 'puppet';
case 'py':
return 'python';
case 'q':
return 'q';
case 'r':
return 'r';
case 'rb':
return 'ruby';
case 'rs':
return 'rust';
case 'sas':
return 'sas';
case 'sass': case 'sass':
case 'scss': return 'sass'; case 'scss':
case 'scala': return 'scala'; return 'sass';
case 'scm': return 'scheme'; case 'scala':
case 'shader': return 'shader'; return 'scala';
case 'scm':
return 'scheme';
case 'shader':
return 'shader';
case 'sh': case 'sh':
case 'bash': return 'shell'; case 'bash':
case 'siv': return 'sieve'; return 'shell';
case 'st': return 'smalltalk'; case 'siv':
case 'sol': return 'solidity'; return 'sieve';
case 'solr': return 'solr'; case 'st':
case 'rq': return 'sparql'; return 'smalltalk';
case 'sol':
return 'solidity';
case 'solr':
return 'solr';
case 'rq':
return 'sparql';
case 'xlsx': case 'xlsx':
case 'ods': case 'ods':
case 'csv': return 'spreadsheet'; case 'csv':
case 'nut': return 'squirrel'; return 'spreadsheet';
case 'tex': return 'stex'; case 'nut':
case 'styl': return 'stylus'; return 'squirrel';
case 'svelte': return 'svelte'; case 'tex':
case 'swift': return 'swift'; return 'stex';
case 'tcl': return 'tcl'; case 'styl':
case 'textile': return 'textile'; return 'stylus';
case 'tiddlywiki': return 'tiddlyWiki'; case 'svelte':
case 'tiki': return 'tiki'; return 'svelte';
case 'toml': return 'toml'; case 'swift':
case 'troff': return 'troff'; return 'swift';
case 'tsx': return 'tsx'; case 'tcl':
case 'ttcn': return 'ttcn'; return 'tcl';
case 'textile':
return 'textile';
case 'tiddlywiki':
return 'tiddlyWiki';
case 'tiki':
return 'tiki';
case 'toml':
return 'toml';
case 'troff':
return 'troff';
case 'tsx':
return 'tsx';
case 'ttcn':
return 'ttcn';
case 'ttl': case 'ttl':
case 'turtle': return 'turtle'; case 'turtle':
case 'ts': return 'typescript'; return 'turtle';
case 'vb': return 'vb'; case 'ts':
case 'vbs': return 'vbscript'; return 'typescript';
case 'vm': return 'velocity'; case 'vb':
case 'v': return 'verilog'; return 'vb';
case 'vbs':
return 'vbscript';
case 'vm':
return 'velocity';
case 'v':
return 'verilog';
case 'vhd': case 'vhd':
case 'vhdl': return 'vhdl'; case 'vhdl':
case 'vue': return 'vue'; return 'vhdl';
case 'wat': return 'wast'; case 'vue':
case 'webidl': return 'webIDL'; return 'vue';
case 'wat':
return 'wast';
case 'webidl':
return 'webIDL';
case 'xq': case 'xq':
case 'xquery': return 'xQuery'; case 'xquery':
case 'xml': return 'xml'; return 'xQuery';
case 'yacas': return 'yacas'; case 'xml':
return 'xml';
case 'yacas':
return 'yacas';
case 'yaml': case 'yaml':
case 'yml': return 'yaml'; case 'yml':
case 'z80': return 'z80'; return 'yaml';
default: return 'text'; case 'z80':
return 'z80';
default:
return 'text';
} }
} }
@@ -179,7 +304,14 @@ export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCod
}, []); }, []);
return ( return (
<div style={{ width: '100%', height: '100%', position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}> <div style={{
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
<div <div
style={{ style={{
width: '100%', width: '100%',

View File

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

View File

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

View File

@@ -91,7 +91,8 @@ export function ConfigFileSidebarViewer({
{conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400"/>} {conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400"/>}
</Button> </Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}> <Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}>
<Pin className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`} /> <Pin
className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
</Button> </Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}> <Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}>
<Edit className="w-4 h-4"/> <Edit className="w-4 h-4"/>
@@ -106,7 +107,8 @@ export function ConfigFileSidebarViewer({
{/* File/Folder Viewer */} {/* File/Folder Viewer */}
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto"> <div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground font-semibold">{isSSHMode ? 'SSH Path' : 'Local Path'}</span> <span
className="text-xs text-muted-foreground font-semibold">{isSSHMode ? 'SSH Path' : 'Local Path'}</span>
<span className="text-xs text-white truncate">{currentPath}</span> <span className="text-xs text-white truncate">{currentPath}</span>
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -116,22 +118,29 @@ export function ConfigFileSidebarViewer({
) : ( ) : (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{files.map((item) => ( {files.map((item) => (
<Card key={item.path} className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border border-[#23232a] rounded"> <Card key={item.path}
<div className="flex items-center gap-2 flex-1 cursor-pointer" onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}> className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border border-[#23232a] rounded">
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400" /> : <File className="w-4 h-4 text-muted-foreground" />} <div className="flex items-center gap-2 flex-1 cursor-pointer"
onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400"/> :
<File className="w-4 h-4 text-muted-foreground"/>}
<span className="text-sm text-white truncate">{item.name}</span> <span className="text-sm text-white truncate">{item.name}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onStarFile(item)}> <Button size="icon" variant="ghost" className="h-7 w-7"
<Pin className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`} /> onClick={() => onStarFile(item)}>
<Pin
className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
</Button> </Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteFile(item)}> <Button size="icon" variant="ghost" className="h-7 w-7"
onClick={() => onDeleteFile(item)}>
<Trash2 className="w-4 h-4 text-red-500"/> <Trash2 className="w-4 h-4 text-red-500"/>
</Button> </Button>
</div> </div>
</Card> </Card>
))} ))}
{files.length === 0 && <div className="text-xs text-muted-foreground">No files or folders found.</div>} {files.length === 0 &&
<div className="text-xs text-muted-foreground">No files or folders found.</div>}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,11 +1,9 @@
import React from 'react'; import React from 'react';
import { Card } from '@/components/ui/card.tsx';
import {Button} from '@/components/ui/button.tsx'; import {Button} from '@/components/ui/button.tsx';
import {Trash2, Folder, File, Plus, Pin} from 'lucide-react'; import {Trash2, Folder, File, Plus, Pin} from 'lucide-react';
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx'; import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx';
import {Input} from '@/components/ui/input.tsx'; import {Input} from '@/components/ui/input.tsx';
import {useState} from 'react'; import {useState} from 'react';
import { cn } from '@/lib/utils.ts';
interface FileItem { interface FileItem {
name: string; name: string;
@@ -49,9 +47,9 @@ export function ConfigHomeView({
const [newShortcut, setNewShortcut] = useState(''); const [newShortcut, setNewShortcut] = useState('');
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => ( const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
<div key={file.path} className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors"> <div key={file.path}
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
<div <div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0" className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenFile(file)} onClick={() => onOpenFile(file)}
@@ -74,7 +72,8 @@ export function ConfigHomeView({
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md" className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
onClick={onPin} onClick={onPin}
> >
<Pin className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`} /> <Pin
className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
</Button> </Button>
)} )}
{onRemove && ( {onRemove && (
@@ -92,7 +91,8 @@ export function ConfigHomeView({
); );
const renderShortcutCard = (shortcut: ShortcutItem) => ( const renderShortcutCard = (shortcut: ShortcutItem) => (
<div key={shortcut.path} className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors"> <div key={shortcut.path}
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
<div <div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0" className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenShortcut(shortcut)} onClick={() => onOpenShortcut(shortcut)}
@@ -123,7 +123,8 @@ export function ConfigHomeView({
<TabsList className="mb-4 bg-[#18181b] border border-[#23232a]"> <TabsList className="mb-4 bg-[#18181b] border border-[#23232a]">
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">Recent</TabsTrigger> <TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">Recent</TabsTrigger>
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger> <TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger>
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder Shortcuts</TabsTrigger> <TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder
Shortcuts</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="recent" className="mt-0"> <TabsContent value="recent" className="mt-0">
@@ -152,8 +153,8 @@ export function ConfigHomeView({
) : pinned.map((file) => ) : pinned.map((file) =>
renderFileCard( renderFileCard(
file, file,
undefined, // No remove function for pinned items undefined,
() => onUnpinFile(file), // Use pin function for unpinning () => onUnpinFile(file),
true true
) )
)} )}

View File

@@ -33,7 +33,6 @@ export function ConfigTabList({ tabs, activeTab, setActiveTab, closeTab, onHomeC
className={index < tabs.length - 1 ? "mr-[0.5rem]" : ""} className={index < tabs.length - 1 ? "mr-[0.5rem]" : ""}
> >
<div className="inline-flex rounded-md shadow-sm" role="group"> <div className="inline-flex rounded-md shadow-sm" role="group">
{/* Set Active Tab Button */}
<Button <Button
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
variant="outline" variant="outline"
@@ -42,7 +41,6 @@ export function ConfigTabList({ tabs, activeTab, setActiveTab, closeTab, onHomeC
{tab.title} {tab.title}
</Button> </Button>
{/* Close Tab Button */}
<Button <Button
onClick={() => closeTab(tab.id)} onClick={() => closeTab(tab.id)}
variant="outline" variant="outline"

View File

@@ -48,7 +48,6 @@ export function SSHManager({ onSelectView }: ConfigEditorProps): React.ReactElem
const handleTabChange = (value: string) => { const handleTabChange = (value: string) => {
setActiveTab(value); setActiveTab(value);
// Reset editingHost when switching to host_viewer
if (value === "host_viewer") { if (value === "host_viewer") {
setEditingHost(null); setEditingHost(null);
} }
@@ -63,8 +62,10 @@ export function SSHManager({ onSelectView }: ConfigEditorProps): React.ReactElem
<div className="flex w-screen h-screen overflow-hidden"> <div className="flex w-screen h-screen overflow-hidden">
<div className="w-[256px]"/> <div className="w-[256px]"/>
<div className="flex-1 bg-[#18181b] m-[35px] text-white p-4 rounded-md w-[1200px] border h-[calc(100vh-70px)] flex flex-col min-h-0"> <div
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex-1 flex flex-col h-full min-h-0"> className="flex-1 bg-[#18181b] m-[35px] text-white p-4 rounded-md w-[1200px] border h-[calc(100vh-70px)] flex flex-col min-h-0">
<Tabs value={activeTab} onValueChange={handleTabChange}
className="flex-1 flex flex-col h-full min-h-0">
<TabsList> <TabsList>
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger> <TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
<TabsTrigger value="add_host"> <TabsTrigger value="add_host">

View File

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

View File

@@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge";
import {ScrollArea} from "@/components/ui/scroll-area"; import {ScrollArea} from "@/components/ui/scroll-area";
import {Input} from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion"; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
import { getSSHHosts, deleteSSHHost } from "@/apps/SSH/ssh-axios-fixed"; import {getSSHHosts, deleteSSHHost} from "@/apps/SSH/ssh-axios";
import {Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search} from "lucide-react"; import {Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search} from "lucide-react";
interface SSHHost { interface SSHHost {
@@ -48,7 +48,6 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
setHosts(data); setHosts(data);
setError(null); setError(null);
} catch (err) { } catch (err) {
console.error('Failed to fetch hosts:', err);
setError('Failed to load hosts'); setError('Failed to load hosts');
} finally { } finally {
setLoading(false); setLoading(false);
@@ -59,9 +58,8 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) { if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) {
try { try {
await deleteSSHHost(hostId); await deleteSSHHost(hostId);
await fetchHosts(); // Refresh the list await fetchHosts();
} catch (err) { } catch (err) {
console.error('Failed to delete host:', err);
alert('Failed to delete host'); alert('Failed to delete host');
} }
} }
@@ -73,11 +71,9 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
} }
}; };
// Filter and sort hosts
const filteredAndSortedHosts = useMemo(() => { const filteredAndSortedHosts = useMemo(() => {
let filtered = hosts; let filtered = hosts;
// Apply search filter
if (searchQuery.trim()) { if (searchQuery.trim()) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
filtered = hosts.filter(host => { filtered = hosts.filter(host => {
@@ -94,20 +90,16 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
}); });
} }
// Sort: pinned first, then alphabetical by name/username
return filtered.sort((a, b) => { return filtered.sort((a, b) => {
// First, sort by pin status (pinned hosts first)
if (a.pin && !b.pin) return -1; if (a.pin && !b.pin) return -1;
if (!a.pin && b.pin) return 1; if (!a.pin && b.pin) return 1;
// Then sort alphabetically by name or username
const aName = a.name || a.username; const aName = a.name || a.username;
const bName = b.name || b.username; const bName = b.name || b.username;
return aName.localeCompare(bName); return aName.localeCompare(bName);
}); });
}, [hosts, searchQuery]); }, [hosts, searchQuery]);
// Group hosts by folder
const hostsByFolder = useMemo(() => { const hostsByFolder = useMemo(() => {
const grouped: { [key: string]: SSHHost[] } = {}; const grouped: { [key: string]: SSHHost[] } = {};
@@ -119,14 +111,12 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
grouped[folder].push(host); grouped[folder].push(host);
}); });
// Sort folders to ensure "Uncategorized" is always first
const sortedFolders = Object.keys(grouped).sort((a, b) => { const sortedFolders = Object.keys(grouped).sort((a, b) => {
if (a === 'Uncategorized') return -1; if (a === 'Uncategorized') return -1;
if (b === 'Uncategorized') return 1; if (b === 'Uncategorized') return 1;
return a.localeCompare(b); return a.localeCompare(b);
}); });
// Create a new object with sorted folders
const sortedGrouped: { [key: string]: SSHHost[] } = {}; const sortedGrouped: { [key: string]: SSHHost[] } = {};
sortedFolders.forEach(folder => { sortedFolders.forEach(folder => {
sortedGrouped[folder] = grouped[folder]; sortedGrouped[folder] = grouped[folder];
@@ -187,7 +177,6 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
</Button> </Button>
</div> </div>
{/* Search Bar */}
<div className="relative mb-3"> <div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input <Input
@@ -204,7 +193,8 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
<div key={folder} className="border rounded-md"> <div key={folder} className="border rounded-md">
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}> <Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}>
<AccordionItem value={folder} className="border-none"> <AccordionItem value={folder} className="border-none">
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md"> <AccordionTrigger
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Folder className="h-4 w-4"/> <Folder className="h-4 w-4"/>
<span className="font-medium">{folder}</span> <span className="font-medium">{folder}</span>
@@ -224,7 +214,8 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{host.pin && <Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />} {host.pin && <Pin
className="h-3 w-3 text-yellow-500 flex-shrink-0"/>}
<h3 className="font-medium truncate text-sm"> <h3 className="font-medium truncate text-sm">
{host.name || `${host.username}@${host.ip}`} {host.name || `${host.username}@${host.ip}`}
</h3> </h3>
@@ -263,24 +254,24 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
</div> </div>
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{/* Tags */}
{host.tags && host.tags.length > 0 && ( {host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{host.tags.slice(0, 6).map((tag, index) => ( {host.tags.slice(0, 6).map((tag, index) => (
<Badge key={index} variant="secondary" className="text-xs px-1 py-0"> <Badge key={index} variant="secondary"
className="text-xs px-1 py-0">
<Tag className="h-2 w-2 mr-0.5"/> <Tag className="h-2 w-2 mr-0.5"/>
{tag} {tag}
</Badge> </Badge>
))} ))}
{host.tags.length > 6 && ( {host.tags.length > 6 && (
<Badge variant="outline" className="text-xs px-1 py-0"> <Badge variant="outline"
className="text-xs px-1 py-0">
+{host.tags.length - 6} +{host.tags.length - 6}
</Badge> </Badge>
)} )}
</div> </div>
)} )}
{/* Features */}
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{host.enableTerminal && ( {host.enableTerminal && (
<Badge variant="outline" className="text-xs px-1 py-0"> <Badge variant="outline" className="text-xs px-1 py-0">
@@ -293,7 +284,8 @@ export function SSHManagerHostViewer({ onEditHost }: SSHManagerHostViewerProps)
<Network className="h-2 w-2 mr-0.5"/> <Network className="h-2 w-2 mr-0.5"/>
Tunnel Tunnel
{host.tunnelConnections && host.tunnelConnections.length > 0 && ( {host.tunnelConnections && host.tunnelConnections.length > 0 && (
<span className="ml-0.5">({host.tunnelConnections.length})</span> <span
className="ml-0.5">({host.tunnelConnections.length})</span>
)} )}
</Badge> </Badge>
)} )}

View File

@@ -41,7 +41,8 @@ export function SSHManagerSidebar({ onSelectView }: SidebarProps): React.ReactEl
{/* Sidebar Items */} {/* Sidebar Items */}
<SidebarMenuItem key={"Homepage"}> <SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")} variant="outline"> <Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
variant="outline">
<CornerDownLeft/> <CornerDownLeft/>
Return Return
</Button> </Button>

View File

@@ -137,7 +137,17 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
}); });
} }
return ( return (
<div ref={el => { panelRefs.current['parent'] = el; }} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 1, overflow: 'hidden' }}> <div ref={el => {
panelRefs.current['parent'] = el;
}} style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 1,
overflow: 'hidden'
}}>
{allTabs.map((tab) => { {allTabs.map((tab) => {
const style = layoutStyles[tab.id] const style = layoutStyles[tab.id]
? {...layoutStyles[tab.id], overflow: 'hidden'} ? {...layoutStyles[tab.id], overflow: 'hidden'}
@@ -170,15 +180,37 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
if (layoutTabs.length === 2) { if (layoutTabs.length === 2) {
const [tab1, tab2] = layoutTabs; const [tab1, tab2] = layoutTabs;
return ( return (
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 10, pointerEvents: 'none' }}> <div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 10,
pointerEvents: 'none'
}}>
<ResizablePrimitive.PanelGroup <ResizablePrimitive.PanelGroup
ref={el => { panelGroupRefs.current['main'] = el; }} ref={el => {
panelGroupRefs.current['main'] = el;
}}
direction="horizontal" direction="horizontal"
className="h-full w-full" className="h-full w-full"
id="main-horizontal" id="main-horizontal"
> >
<ResizablePanel key={tab1.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${tab1.id}`} order={1}> <ResizablePanel key={tab1.id} defaultSize={50} minSize={20}
<div ref={el => { panelRefs.current[String(tab1.id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> className="!overflow-hidden h-full w-full" id={`panel-${tab1.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(tab1.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -195,8 +227,20 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/> <ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={tab2.id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${tab2.id}`} order={2}> <ResizablePanel key={tab2.id} defaultSize={50} minSize={20}
<div ref={el => { panelRefs.current[String(tab2.id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> className="!overflow-hidden h-full w-full" id={`panel-${tab2.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(tab2.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -218,17 +262,43 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
} }
if (layoutTabs.length === 3) { if (layoutTabs.length === 3) {
return ( return (
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 10, pointerEvents: 'none' }}> <div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 10,
pointerEvents: 'none'
}}>
<ResizablePrimitive.PanelGroup <ResizablePrimitive.PanelGroup
ref={el => { panelGroupRefs.current['main'] = el; }} ref={el => {
panelGroupRefs.current['main'] = el;
}}
direction="vertical" direction="vertical"
className="h-full w-full" className="h-full w-full"
id="main-vertical" id="main-vertical"
> >
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<ResizablePanelGroup ref={el => { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal"> id="top-panel" order={1}>
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[0].id}`} order={1}> <ResizablePanelGroup ref={el => {
<div ref={el => { panelRefs.current[String(layoutTabs[0].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> panelGroupRefs.current['top'] = el;
}} direction="horizontal" className="h-full w-full" id="top-horizontal">
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[0].id}`} order={1}>
<div ref={el => {
panelRefs.current[String(layoutTabs[0].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -245,8 +315,21 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/> <ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[1].id}`} order={2}> <ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
<div ref={el => { panelRefs.current[String(layoutTabs[1].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[1].id}`} order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[1].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -265,8 +348,20 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
</ResizablePanelGroup> </ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/> <ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<div ref={el => { panelRefs.current[String(layoutTabs[2].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> id="bottom-panel" order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[2].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -288,17 +383,43 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
} }
if (layoutTabs.length === 4) { if (layoutTabs.length === 4) {
return ( return (
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 10, pointerEvents: 'none' }}> <div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 10,
pointerEvents: 'none'
}}>
<ResizablePrimitive.PanelGroup <ResizablePrimitive.PanelGroup
ref={el => { panelGroupRefs.current['main'] = el; }} ref={el => {
panelGroupRefs.current['main'] = el;
}}
direction="vertical" direction="vertical"
className="h-full w-full" className="h-full w-full"
id="main-vertical" id="main-vertical"
> >
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<ResizablePanelGroup ref={el => { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal"> id="top-panel" order={1}>
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[0].id}`} order={1}> <ResizablePanelGroup ref={el => {
<div ref={el => { panelRefs.current[String(layoutTabs[0].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> panelGroupRefs.current['top'] = el;
}} direction="horizontal" className="h-full w-full" id="top-horizontal">
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[0].id}`} order={1}>
<div ref={el => {
panelRefs.current[String(layoutTabs[0].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -315,8 +436,21 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/> <ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[1].id}`} order={2}> <ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
<div ref={el => { panelRefs.current[String(layoutTabs[1].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[1].id}`} order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[1].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -335,10 +469,26 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
</ResizablePanelGroup> </ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/> <ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<ResizablePanelGroup ref={el => { panelGroupRefs.current['bottom'] = el; }} direction="horizontal" className="h-full w-full" id="bottom-horizontal"> id="bottom-panel" order={2}>
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[2].id}`} order={1}> <ResizablePanelGroup ref={el => {
<div ref={el => { panelRefs.current[String(layoutTabs[2].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> panelGroupRefs.current['bottom'] = el;
}} direction="horizontal" className="h-full w-full" id="bottom-horizontal">
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[2].id}`} order={1}>
<div ref={el => {
panelRefs.current[String(layoutTabs[2].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -355,8 +505,21 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/> <ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${layoutTabs[3].id}`} order={2}> <ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20}
<div ref={el => { panelRefs.current[String(layoutTabs[3].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}> className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[3].id}`} order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[3].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{ <div style={{
background: '#18181b', background: '#18181b',
color: '#fff', color: '#fff',
@@ -425,8 +588,16 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
return ( return (
<div style={{display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden'}}> <div style={{display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden'}}>
{/* Sidebar: fixed width */} <div style={{
<div style={{ width: 256, flexShrink: 0, height: '100vh', position: 'relative', zIndex: 2, margin: 0, padding: 0, border: 'none' }}> width: 256,
flexShrink: 0,
height: '100vh',
position: 'relative',
zIndex: 2,
margin: 0,
padding: 0,
border: 'none'
}}>
<SSHSidebar <SSHSidebar
onSelectView={onSelectView} onSelectView={onSelectView}
onAddHostSubmit={onAddHostSubmit} onAddHostSubmit={onAddHostSubmit}
@@ -441,7 +612,6 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
}} }}
/> />
</div> </div>
{/* Main area: fills the rest */}
<div <div
className="terminal-container" className="terminal-container"
style={{ style={{
@@ -454,7 +624,6 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
border: 'none', border: 'none',
}} }}
> >
{/* Always render the topbar at the top */}
<div style={{position: 'absolute', top: 0, left: 0, width: '100%', zIndex: 10}}> <div style={{position: 'absolute', top: 0, left: 0, width: '100%', zIndex: 10}}>
<SSHTopbar <SSHTopbar
allTabs={allTabs} allTabs={allTabs}
@@ -465,9 +634,7 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
setCloseTab={setCloseTab} setCloseTab={setCloseTab}
/> />
</div> </div>
{/* Split area below the topbar */}
<div style={{height: 'calc(100% - 46px)', marginTop: 46, position: 'relative'}}> <div style={{height: 'calc(100% - 46px)', marginTop: 46, position: 'relative'}}>
{/* Show alert when no terminals are rendered */}
{allTabs.length === 0 && ( {allTabs.length === 0 && (
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
@@ -487,11 +654,11 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
Welcome to Termix SSH Welcome to Termix SSH
</div> </div>
<div style={{fontSize: '14px', color: '#a1a1aa', lineHeight: '1.5'}}> <div style={{fontSize: '14px', color: '#a1a1aa', lineHeight: '1.5'}}>
Click on any host title in the sidebar to open a terminal connection, or use the "Add Host" button to create a new connection. Click on any host title in the sidebar to open a terminal connection, or use the "Add
Host" button to create a new connection.
</div> </div>
</div> </div>
)} )}
{/* Absolutely render all terminals for persistence and layout */}
{allSplitScreenTab.length > 0 && ( {allSplitScreenTab.length > 0 && (
<div style={{position: 'absolute', top: 0, right: 0, zIndex: 20, height: 28}}> <div style={{position: 'absolute', top: 0, right: 0, zIndex: 20, height: 28}}>
<button <button
@@ -514,12 +681,10 @@ export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement {
onClick={() => { onClick={() => {
if (allSplitScreenTab.length === 1) { if (allSplitScreenTab.length === 1) {
panelGroupRefs.current['main']?.setLayout([50, 50]); panelGroupRefs.current['main']?.setLayout([50, 50]);
} } else if (allSplitScreenTab.length === 2) {
else if (allSplitScreenTab.length === 2) {
panelGroupRefs.current['main']?.setLayout([50, 50]); panelGroupRefs.current['main']?.setLayout([50, 50]);
panelGroupRefs.current['top']?.setLayout([50, 50]); panelGroupRefs.current['top']?.setLayout([50, 50]);
} } else if (allSplitScreenTab.length === 3) {
else if (allSplitScreenTab.length === 3) {
panelGroupRefs.current['main']?.setLayout([50, 50]); panelGroupRefs.current['main']?.setLayout([50, 50]);
panelGroupRefs.current['top']?.setLayout([50, 50]); panelGroupRefs.current['top']?.setLayout([50, 50]);
panelGroupRefs.current['bottom']?.setLayout([50, 50]); panelGroupRefs.current['bottom']?.setLayout([50, 50]);

View File

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

View File

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

View File

@@ -43,6 +43,7 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
function handleWindowResize() { function handleWindowResize() {
fitAddonRef.current?.fit(); fitAddonRef.current?.fit();
} }
window.addEventListener('resize', handleWindowResize); window.addEventListener('resize', handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize); return () => window.removeEventListener('resize', handleWindowResize);
}, []); }, []);
@@ -134,10 +135,8 @@ export const SSHTerminal = forwardRef<any, SSHTerminalProps>(function SSHTermina
} else if (msg.type === 'error') { } else if (msg.type === 'error') {
terminal.writeln(`\r\n[ERROR] ${msg.message}`); terminal.writeln(`\r\n[ERROR] ${msg.message}`);
} else if (msg.type === 'connected') { } else if (msg.type === 'connected') {
/* nothing for now */
} }
} catch (err) { } catch (err) {
console.error('Failed to parse message', err);
} }
}); });

View File

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

View File

@@ -1,7 +1,7 @@
import React, {useState, useEffect, useCallback} from "react"; import React, {useState, useEffect, useCallback} from "react";
import {SSHTunnelSidebar} from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx"; import {SSHTunnelSidebar} from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
import {SSHTunnelViewer} from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx"; import {SSHTunnelViewer} from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
import { getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel } from "@/apps/SSH/ssh-axios-fixed"; import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/apps/SSH/ssh-axios";
interface ConfigEditorProps { interface ConfigEditorProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
@@ -52,24 +52,21 @@ interface TunnelStatus {
export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement { export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement {
const [hosts, setHosts] = useState<SSHHost[]>([]); const [hosts, setHosts] = useState<SSHHost[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({}); const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({}); // Track loading states const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({});
const fetchHosts = useCallback(async () => { const fetchHosts = useCallback(async () => {
try { try {
const hostsData = await getSSHHosts(); const hostsData = await getSSHHosts();
setHosts(hostsData); setHosts(hostsData);
} catch (err) { } catch (err) {
// Silent error handling
} }
}, []); }, []);
// Poll backend for tunnel statuses
const fetchTunnelStatuses = useCallback(async () => { const fetchTunnelStatuses = useCallback(async () => {
try { try {
const statusData = await getTunnelStatuses(); const statusData = await getTunnelStatuses();
setTunnelStatuses(statusData); setTunnelStatuses(statusData);
} catch (err) { } catch (err) {
// Silent error handling
} }
}, []); }, []);
@@ -93,7 +90,6 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
try { try {
if (action === 'connect') { if (action === 'connect') {
// Find the endpoint host configuration
const endpointHost = hosts.find(h => const endpointHost = hosts.find(h =>
h.name === tunnel.endpointHost || h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost `${h.username}@${h.ip}` === tunnel.endpointHost
@@ -103,7 +99,6 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
throw new Error('Endpoint host not found'); throw new Error('Endpoint host not found');
} }
// Create tunnel configuration
const tunnelConfig = { const tunnelConfig = {
name: tunnelName, name: tunnelName,
hostName: host.name || `${host.username}@${host.ip}`, hostName: host.name || `${host.username}@${host.ip}`,
@@ -126,7 +121,7 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
sourcePort: tunnel.sourcePort, sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort, endpointPort: tunnel.endpointPort,
maxRetries: tunnel.maxRetries, maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval * 1000, // Convert to milliseconds retryInterval: tunnel.retryInterval * 1000,
autoStart: tunnel.autoStart, autoStart: tunnel.autoStart,
isPinned: host.pin isPinned: host.pin
}; };
@@ -138,11 +133,8 @@ export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactEleme
await cancelTunnel(tunnelName); await cancelTunnel(tunnelName);
} }
// Refresh statuses after action
await fetchTunnelStatuses(); await fetchTunnelStatuses();
} catch (err) { } catch (err) {
console.error(`Failed to ${action} tunnel:`, err);
// Let the backend handle error status updates
} finally { } finally {
setTunnelActions(prev => ({...prev, [tunnelName]: false})); setTunnelActions(prev => ({...prev, [tunnelName]: false}));
} }

View File

@@ -2,7 +2,22 @@ import React from "react";
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card.tsx"; import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card.tsx";
import {Separator} from "@/components/ui/separator.tsx"; import {Separator} from "@/components/ui/separator.tsx";
import { Loader2, Pin, Terminal, Network, FileEdit, Tag, Play, Square, AlertCircle, Clock, Wifi, WifiOff, Zap, X } from "lucide-react"; import {
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 {Badge} from "@/components/ui/badge.tsx";
const CONNECTION_STATES = { const CONNECTION_STATES = {
@@ -84,7 +99,6 @@ export function SSHTunnelObject({
borderColor: 'border-border' borderColor: 'border-border'
}; };
// Handle both the old format (status.status) and new format (status.status)
const statusValue = status.status || 'DISCONNECTED'; const statusValue = status.status || 'DISCONNECTED';
switch (statusValue.toUpperCase()) { switch (statusValue.toUpperCase()) {
@@ -205,7 +219,8 @@ export function SSHTunnelObject({
const isWaiting = statusValue === 'WAITING'; const isWaiting = statusValue === 'WAITING';
return ( return (
<div key={tunnelIndex} className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}> <div key={tunnelIndex}
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
{/* Tunnel Header */} {/* Tunnel Header */}
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0"> <div className="flex items-start gap-2 flex-1 min-w-0">
@@ -283,13 +298,23 @@ export function SSHTunnelObject({
{/* Error/Status Reason */} {/* Error/Status Reason */}
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && ( {(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20"> <div
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">Error:</div> <div className="font-medium mb-1">Error:</div>
{status.reason} {status.reason}
{status.reason && status.reason.includes('Max retries exhausted') && ( {status.reason && status.reason.includes('Max retries exhausted') && (
<> <>
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20"> <div
Check your Docker logs for the error reason, join the <a href="https://discord.com/invite/jVQGdvHDrf" target="_blank" rel="noopener noreferrer" className="underline text-blue-600 dark:text-blue-400">Discord</a> or create a <a href="https://github.com/LukeGus/Termix/issues/new" target="_blank" rel="noopener noreferrer" className="underline text-blue-600 dark:text-blue-400">GitHub issue</a> for help. className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
Check your Docker logs for the error reason, join the <a
href="https://discord.com/invite/jVQGdvHDrf" target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">Discord</a> or
create a <a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank" rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">GitHub
issue</a> for help.
</div> </div>
</> </>
)} )}
@@ -298,7 +323,8 @@ export function SSHTunnelObject({
{/* Retry Info */} {/* Retry Info */}
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && ( {(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20"> <div
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div className="font-medium mb-1"> <div className="font-medium mb-1">
{statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'} {statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'}
</div> </div>

View File

@@ -41,7 +41,8 @@ export function SSHTunnelSidebar({ onSelectView }: SidebarProps): React.ReactEle
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem key={"Homepage"}> <SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")} variant="outline"> <Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
variant="outline">
<CornerDownLeft className="h-4 w-4 mr-2"/> <CornerDownLeft className="h-4 w-4 mr-2"/>
Return Return
</Button> </Button>

View File

@@ -59,13 +59,11 @@ export function SSHTunnelViewer({
const [searchQuery, setSearchQuery] = React.useState(""); const [searchQuery, setSearchQuery] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState(""); const [debouncedSearch, setDebouncedSearch] = React.useState("");
// Debounce search
React.useEffect(() => { React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200); const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200);
return () => clearTimeout(handler); return () => clearTimeout(handler);
}, [searchQuery]); }, [searchQuery]);
// Filter hosts by search query
const filteredHosts = React.useMemo(() => { const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts; if (!debouncedSearch.trim()) return hosts;
@@ -84,7 +82,6 @@ export function SSHTunnelViewer({
}); });
}, [hosts, debouncedSearch]); }, [hosts, debouncedSearch]);
// Filter hosts to only show those with enableTunnel: true and tunnelConnections
const tunnelHosts = React.useMemo(() => { const tunnelHosts = React.useMemo(() => {
return filteredHosts.filter(host => return filteredHosts.filter(host =>
host.enableTunnel && host.enableTunnel &&
@@ -93,7 +90,6 @@ export function SSHTunnelViewer({
); );
}, [filteredHosts]); }, [filteredHosts]);
// Group hosts by folder and sort
const hostsByFolder = React.useMemo(() => { const hostsByFolder = React.useMemo(() => {
const map: Record<string, SSHHost[]> = {}; const map: Record<string, SSHHost[]> = {};
tunnelHosts.forEach(host => { tunnelHosts.forEach(host => {
@@ -123,7 +119,6 @@ export function SSHTunnelViewer({
return ( return (
<div className="w-full p-6" style={{width: 'calc(100vw - 256px)', maxWidth: 'none'}}> <div className="w-full p-6" style={{width: 'calc(100vw - 256px)', maxWidth: 'none'}}>
<div className="w-full min-w-0" style={{width: '100%', maxWidth: 'none'}}> <div className="w-full min-w-0" style={{width: '100%', maxWidth: 'none'}}>
{/* Header */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-foreground mb-2"> <h1 className="text-2xl font-bold text-foreground mb-2">
SSH Tunnels SSH Tunnels
@@ -133,9 +128,9 @@ export function SSHTunnelViewer({
</p> </p>
</div> </div>
{/* Search Bar */}
<div className="relative mb-3"> <div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input <Input
placeholder="Search hosts by name, username, IP, folder, tags..." placeholder="Search hosts by name, username, IP, folder, tags..."
value={searchQuery} value={searchQuery}
@@ -144,7 +139,6 @@ export function SSHTunnelViewer({
/> />
</div> </div>
{/* Accordion Layout */}
{tunnelHosts.length === 0 ? ( {tunnelHosts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<h3 className="text-lg font-semibold text-foreground mb-2"> <h3 className="text-lg font-semibold text-foreground mb-2">
@@ -160,8 +154,10 @@ export function SSHTunnelViewer({
) : ( ) : (
<Accordion type="multiple" className="w-full" defaultValue={sortedFolders}> <Accordion type="multiple" className="w-full" defaultValue={sortedFolders}>
{sortedFolders.map((folder, idx) => ( {sortedFolders.map((folder, idx) => (
<AccordionItem value={folder} key={`folder-${folder}`} className={idx === 0 ? "mt-0" : "mt-2"}> <AccordionItem value={folder} key={`folder-${folder}`}
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2" style={{marginTop: idx === 0 ? 0 : undefined}}> className={idx === 0 ? "mt-0" : "mt-2"}>
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2"
style={{marginTop: idx === 0 ? 0 : undefined}}>
{folder} {folder}
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1"> <AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">

View File

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

View File

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

View File

@@ -39,9 +39,9 @@ export function TemplateSidebar({ onSelectView }: SidebarProps): React.ReactElem
<SidebarGroupContent className="flex flex-col flex-grow"> <SidebarGroupContent className="flex flex-col flex-grow">
<SidebarMenu> <SidebarMenu>
{/* Sidebar Items */}
<SidebarMenuItem key={"Homepage"}> <SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")} variant="outline"> <Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
variant="outline">
<CornerDownLeft/> <CornerDownLeft/>
Return Return
</Button> </Button>

View File

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

View File

@@ -53,4 +53,5 @@ app.use((err: unknown, req: express.Request, res: express.Response, next: expres
}); });
const PORT = 8081; const PORT = 8081;
app.listen(PORT, () => {}); app.listen(PORT, () => {
});

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ const router = express.Router();
function isNonEmptyString(val: any): val is string { function isNonEmptyString(val: any): val is string {
return typeof val === 'string' && val.trim().length > 0; return typeof val === 'string' && val.trim().length > 0;
} }
function isValidPort(val: any): val is number { function isValidPort(val: any): val is number {
return typeof val === 'number' && val > 0 && val < 65536; return typeof val === 'number' && val > 0 && val < 65536;
} }
@@ -48,14 +49,12 @@ interface JWTPayload {
exp?: number; exp?: number;
} }
// Configure multer for file uploads
const upload = multer({ const upload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
limits: { limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit fileSize: 10 * 1024 * 1024,
}, },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
// Only allow specific file types for SSH keys
if (file.fieldname === 'key') { if (file.fieldname === 'key') {
cb(null, true); cb(null, true);
} else { } else {
@@ -64,7 +63,6 @@ const upload = multer({
} }
}); });
// JWT authentication middleware
function authenticateJWT(req: Request, res: Response, next: NextFunction) { function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
@@ -83,7 +81,6 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
} }
} }
// Helper to check if request is from localhost
function isLocalhost(req: Request) { function isLocalhost(req: Request) {
const ip = req.ip || req.connection?.remoteAddress; const ip = req.ip || req.connection?.remoteAddress;
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
@@ -143,7 +140,25 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
hostData = req.body; hostData = req.body;
} }
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, pin, enableTerminal, enableTunnel, enableConfigEditor, defaultPath, tunnelConnections } = hostData; const {
name,
folder,
tags,
ip,
port,
username,
password,
authMethod,
key,
keyPassword,
keyType,
pin,
enableTerminal,
enableTunnel,
enableConfigEditor,
defaultPath,
tunnelConnections
} = hostData;
const userId = (req as any).userId; const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) { if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) {
logger.warn('Invalid SSH data input'); logger.warn('Invalid SSH data input');
@@ -167,7 +182,6 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
defaultPath: defaultPath || null, defaultPath: defaultPath || null,
}; };
// Handle authentication data based on authMethod
if (authMethod === 'password') { if (authMethod === 'password') {
sshDataObj.password = password; sshDataObj.password = password;
sshDataObj.key = null; sshDataObj.key = null;
@@ -194,9 +208,7 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => { router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
let hostData: any; let hostData: any;
// Check if this is a multipart form data request (file upload)
if (req.headers['content-type']?.includes('multipart/form-data')) { if (req.headers['content-type']?.includes('multipart/form-data')) {
// Parse the JSON data from the 'data' field
if (req.body.data) { if (req.body.data) {
try { try {
hostData = JSON.parse(req.body.data); hostData = JSON.parse(req.body.data);
@@ -209,16 +221,32 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
return res.status(400).json({error: 'Missing data field'}); return res.status(400).json({error: 'Missing data field'});
} }
// Add the file data if present
if (req.file) { if (req.file) {
hostData.key = req.file.buffer.toString('utf8'); hostData.key = req.file.buffer.toString('utf8');
} }
} else { } else {
// Regular JSON request
hostData = req.body; hostData = req.body;
} }
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, pin, enableTerminal, enableTunnel, enableConfigEditor, defaultPath, tunnelConnections } = hostData; const {
name,
folder,
tags,
ip,
port,
username,
password,
authMethod,
key,
keyPassword,
keyType,
pin,
enableTerminal,
enableTunnel,
enableConfigEditor,
defaultPath,
tunnelConnections
} = hostData;
const {id} = req.params; const {id} = req.params;
const userId = (req as any).userId; const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !id) { if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !id) {
@@ -242,7 +270,6 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
defaultPath: defaultPath || null, defaultPath: defaultPath || null,
}; };
// Handle authentication data based on authMethod
if (authMethod === 'password') { if (authMethod === 'password') {
sshDataObj.password = password; sshDataObj.password = password;
sshDataObj.key = null; sshDataObj.key = null;
@@ -279,7 +306,6 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
.select() .select()
.from(sshData) .from(sshData)
.where(eq(sshData.userId, userId)); .where(eq(sshData.userId, userId));
// Convert tags to array, booleans to bool, tunnelConnections to array
const result = data.map((row: any) => ({ const result = data.map((row: any) => ({
...row, ...row,
tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [], tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [],
@@ -384,8 +410,6 @@ router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Respons
} }
}); });
// Config Editor Database Routes
// Route: Get recent files (requires JWT) // Route: Get recent files (requires JWT)
// GET /ssh/config_editor/recent // GET /ssh/config_editor/recent
router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => { router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
@@ -428,7 +452,6 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'}); return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
} }
try { try {
// Check if file already exists in recent for this host
const conditions = [ const conditions = [
eq(configEditorRecent.userId, userId), eq(configEditorRecent.userId, userId),
eq(configEditorRecent.path, path), eq(configEditorRecent.path, path),
@@ -441,7 +464,6 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
.where(and(...conditions)); .where(and(...conditions));
if (existing.length > 0) { if (existing.length > 0) {
// Update lastOpened timestamp
await db await db
.update(configEditorRecent) .update(configEditorRecent)
.set({lastOpened: new Date().toISOString()}) .set({lastOpened: new Date().toISOString()})
@@ -473,8 +495,6 @@ router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'}); return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
} }
try { try {
logger.info(`Removing recent file: ${name} at ${path} for user ${userId} and host ${hostId}`);
const conditions = [ const conditions = [
eq(configEditorRecent.userId, userId), eq(configEditorRecent.userId, userId),
eq(configEditorRecent.path, path), eq(configEditorRecent.path, path),
@@ -484,7 +504,6 @@ router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res
const result = await db const result = await db
.delete(configEditorRecent) .delete(configEditorRecent)
.where(and(...conditions)); .where(and(...conditions));
logger.info(`Recent file removed successfully`);
res.json({message: 'File removed from recent'}); res.json({message: 'File removed from recent'});
} catch (err) { } catch (err) {
logger.error('Failed to remove recent file', err); logger.error('Failed to remove recent file', err);
@@ -534,7 +553,6 @@ router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res:
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'}); return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
} }
try { try {
// Check if file already exists in pinned for this host
const conditions = [ const conditions = [
eq(configEditorPinned.userId, userId), eq(configEditorPinned.userId, userId),
eq(configEditorPinned.path, path), eq(configEditorPinned.path, path),
@@ -547,7 +565,6 @@ router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res:
.where(and(...conditions)); .where(and(...conditions));
if (existing.length === 0) { if (existing.length === 0) {
// Add new pinned file
await db.insert(configEditorPinned).values({ await db.insert(configEditorPinned).values({
userId, userId,
hostId, hostId,
@@ -573,8 +590,6 @@ router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'}); return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
} }
try { try {
logger.info(`Removing pinned file: ${name} at ${path} for user ${userId} and host ${hostId}`);
const conditions = [ const conditions = [
eq(configEditorPinned.userId, userId), eq(configEditorPinned.userId, userId),
eq(configEditorPinned.path, path), eq(configEditorPinned.path, path),
@@ -584,7 +599,6 @@ router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res
const result = await db const result = await db
.delete(configEditorPinned) .delete(configEditorPinned)
.where(and(...conditions)); .where(and(...conditions));
logger.info(`Pinned file removed successfully`);
res.json({message: 'File unpinned successfully'}); res.json({message: 'File unpinned successfully'});
} catch (err) { } catch (err) {
logger.error('Failed to unpin file', err); logger.error('Failed to unpin file', err);
@@ -599,12 +613,10 @@ router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null; const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) { if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for shortcuts fetch');
return res.status(400).json({error: 'Invalid userId'}); return res.status(400).json({error: 'Invalid userId'});
} }
if (!hostId) { if (!hostId) {
logger.warn('Host ID is required for shortcuts fetch');
return res.status(400).json({error: 'Host ID is required'}); return res.status(400).json({error: 'Host ID is required'});
} }
@@ -630,11 +642,9 @@ router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, re
const userId = (req as any).userId; const userId = (req as any).userId;
const {name, path, hostId} = req.body; const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) { if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for adding shortcut');
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'}); return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
} }
try { try {
// Check if shortcut already exists for this host
const conditions = [ const conditions = [
eq(configEditorShortcuts.userId, userId), eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.path, path), eq(configEditorShortcuts.path, path),
@@ -647,7 +657,6 @@ router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, re
.where(and(...conditions)); .where(and(...conditions));
if (existing.length === 0) { if (existing.length === 0) {
// Add new shortcut
await db.insert(configEditorShortcuts).values({ await db.insert(configEditorShortcuts).values({
userId, userId,
hostId, hostId,
@@ -669,12 +678,9 @@ router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request,
const userId = (req as any).userId; const userId = (req as any).userId;
const {name, path, hostId} = req.body; const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) { if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for removing shortcut');
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'}); return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
} }
try { try {
logger.info(`Removing shortcut: ${name} at ${path} for user ${userId} and host ${hostId}`);
const conditions = [ const conditions = [
eq(configEditorShortcuts.userId, userId), eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.path, path), eq(configEditorShortcuts.path, path),
@@ -684,7 +690,6 @@ router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request,
const result = await db const result = await db
.delete(configEditorShortcuts) .delete(configEditorShortcuts)
.where(and(...conditions)); .where(and(...conditions));
logger.info(`Shortcut removed successfully`);
res.json({message: 'Shortcut removed successfully'}); res.json({message: 'Shortcut removed successfully'});
} catch (err) { } catch (err) {
logger.error('Failed to remove shortcut', err); logger.error('Failed to remove shortcut', err);

View File

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

View File

@@ -126,6 +126,7 @@
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }