Finalized ssh tunnels, updatetd database schemas, started on config editor.
This commit is contained in:
@@ -1,95 +1,168 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import type { LanguageName } from '@uiw/codemirror-extensions-langs';
|
||||
import { loadLanguage } from '@uiw/codemirror-extensions-langs';
|
||||
import { hyperLink } from '@uiw/codemirror-extensions-hyper-link';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { createTheme } from '@uiw/codemirror-themes';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
|
||||
interface ConfigCodeEditorProps {
|
||||
content: string;
|
||||
fileName: string;
|
||||
fileNameOld: string;
|
||||
onContentChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ConfigCodeEditor({content, fileName, onContentChange,}: ConfigCodeEditorProps) {
|
||||
const langName = getLanguageName(fileName);
|
||||
const langExt = langName ? loadLanguage(langName) : null;
|
||||
const extensions = [hyperLink];
|
||||
if (langExt) extensions.unshift(langExt);
|
||||
export function ConfigCodeEditor({content, fileNameOld, onContentChange}: ConfigCodeEditorProps) {
|
||||
const fileName = "test.js"
|
||||
|
||||
// Custom theme based on built-in 'dark', with overrides for background, gutter, and font size
|
||||
const customDarkTheme = [
|
||||
createTheme({
|
||||
theme: 'dark',
|
||||
settings: {
|
||||
background: '#09090b',
|
||||
gutterBackground: '#18181b',
|
||||
gutterForeground: 'oklch(0.985 0 0)',
|
||||
foreground: '#e0e0e0',
|
||||
caret: '#ffcc00',
|
||||
selection: '#22223b99',
|
||||
selectionMatch: '#22223b66',
|
||||
lineHighlight: '#18181b',
|
||||
gutterBorder: '1px solid #22223b',
|
||||
},
|
||||
styles: [
|
||||
{ tag: t.keyword, color: '#ff5370' }, // red
|
||||
{ tag: t.string, color: '#c3e88d' }, // green
|
||||
{ tag: t.number, color: '#82aaff' }, // blue
|
||||
{ tag: t.comment, color: '#5c6370' }, // gray
|
||||
{ tag: t.variableName, color: '#f78c6c' }, // orange
|
||||
{ tag: t.function(t.variableName), color: '#82aaff' }, // blue
|
||||
{ tag: t.typeName, color: '#ffcb6b' }, // yellow
|
||||
{ tag: t.className, color: '#ffcb6b' }, // yellow
|
||||
{ tag: t.definition(t.typeName), color: '#ffcb6b' }, // yellow
|
||||
{ tag: t.operator, color: '#89ddff' }, // cyan
|
||||
{ tag: t.bool, color: '#f78c6c' }, // orange
|
||||
{ tag: t.null, color: '#f78c6c' }, // orange
|
||||
{ tag: t.tagName, color: '#ff5370' }, // red
|
||||
{ tag: t.attributeName, color: '#c792ea' }, // purple
|
||||
{ tag: t.angleBracket, color: '#89ddff' }, // cyan
|
||||
],
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
fontSize: '13px',
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
function getLanguageName(filename: string): LanguageName | undefined {
|
||||
function getLanguageName(filename: string): string {
|
||||
const ext = filename.slice(filename.lastIndexOf('.') + 1).toLowerCase();
|
||||
|
||||
switch (ext) {
|
||||
case 'js':
|
||||
case 'mjs':
|
||||
case 'cjs': return 'javascript';
|
||||
case 'ts': return 'typescript';
|
||||
case 'tsx': return 'tsx';
|
||||
case 'json': return 'json';
|
||||
case 'css': return 'css';
|
||||
case 'html':
|
||||
case 'htm': return 'html';
|
||||
case 'md': return 'markdown';
|
||||
case 'py': return 'python';
|
||||
case 'sh': return 'shell';
|
||||
case 'yaml':
|
||||
case 'yml': return 'yaml';
|
||||
case 'go': return 'go';
|
||||
case 'java': return 'java';
|
||||
case 'ng': return 'angular';
|
||||
case 'apl': return 'apl';
|
||||
case 'asc': return 'asciiArmor';
|
||||
case 'ast': return 'asterisk';
|
||||
case 'bf': return 'brainfuck';
|
||||
case 'c': return 'c';
|
||||
case 'ceylon': return 'ceylon';
|
||||
case 'clj': return 'clojure';
|
||||
case 'cmake': return 'cmake';
|
||||
case 'cob':
|
||||
case 'cbl': return 'cobol';
|
||||
case 'coffee': return 'coffeescript';
|
||||
case 'lisp': return 'commonLisp';
|
||||
case 'cpp':
|
||||
case 'cc':
|
||||
case 'cxx': return 'cpp';
|
||||
case 'rs': return 'rust';
|
||||
case 'php': return 'php';
|
||||
case 'rb': return 'ruby';
|
||||
case 'swift': return 'swift';
|
||||
case 'cr': return 'crystal';
|
||||
case 'cs': return 'csharp';
|
||||
case 'css': return 'css';
|
||||
case 'cypher': return 'cypher';
|
||||
case 'd': return 'd';
|
||||
case 'dart': return 'dart';
|
||||
case 'diff':
|
||||
case 'patch': return 'diff';
|
||||
case 'dockerfile': return 'dockerfile';
|
||||
case 'dtd': return 'dtd';
|
||||
case 'dylan': return 'dylan';
|
||||
case 'ebnf': return 'ebnf';
|
||||
case 'ecl': return 'ecl';
|
||||
case 'eiffel': return 'eiffel';
|
||||
case 'elm': return 'elm';
|
||||
case 'erl': return 'erlang';
|
||||
case 'factor': return 'factor';
|
||||
case 'fcl': return 'fcl';
|
||||
case 'fs': return 'forth';
|
||||
case 'f90':
|
||||
case 'for': return 'fortran';
|
||||
case 's': return 'gas';
|
||||
case 'feature': return 'gherkin';
|
||||
case 'go': return 'go';
|
||||
case 'groovy': return 'groovy';
|
||||
case 'hs': return 'haskell';
|
||||
case 'hx': return 'haxe';
|
||||
case 'html':
|
||||
case 'htm': return 'html';
|
||||
case 'http': return 'http';
|
||||
case 'idl': return 'idl';
|
||||
case 'java': return 'java';
|
||||
case 'js':
|
||||
case 'mjs':
|
||||
case 'cjs': return 'javascript';
|
||||
case 'jinja2':
|
||||
case 'j2': return 'jinja2';
|
||||
case 'json': return 'json';
|
||||
case 'jsx': return 'jsx';
|
||||
case 'jl': return 'julia';
|
||||
case 'kt':
|
||||
case 'kts': return 'kotlin';
|
||||
case 'less': return 'less';
|
||||
case 'lezer': return 'lezer';
|
||||
case 'liquid': return 'liquid';
|
||||
case 'litcoffee': return 'livescript';
|
||||
case 'lua': return 'lua';
|
||||
case 'md': return 'markdown';
|
||||
case 'nb':
|
||||
case 'mat': return 'mathematica';
|
||||
case 'mbox': return 'mbox';
|
||||
case 'mmd': return 'mermaid';
|
||||
case 'mrc': return 'mirc';
|
||||
case 'moo': return 'modelica';
|
||||
case 'mscgen': return 'mscgen';
|
||||
case 'm': return 'mumps';
|
||||
case 'sql': return 'mysql';
|
||||
case 'nc': return 'nesC';
|
||||
case 'nginx': return 'nginx';
|
||||
case 'nix': return 'nix';
|
||||
case 'nsi': return 'nsis';
|
||||
case 'nt': return 'ntriples';
|
||||
case 'mm': return 'objectiveCpp';
|
||||
case 'octave': return 'octave';
|
||||
case 'oz': return 'oz';
|
||||
case 'pas': return 'pascal';
|
||||
case 'pl':
|
||||
case 'pm': return 'perl';
|
||||
case 'pgsql': return 'pgsql';
|
||||
case 'php': return 'php';
|
||||
case 'pig': return 'pig';
|
||||
case 'ps1': return 'powershell';
|
||||
case 'properties': return 'properties';
|
||||
case 'proto': return 'protobuf';
|
||||
case 'pp': return 'puppet';
|
||||
case 'py': return 'python';
|
||||
case 'q': return 'q';
|
||||
case 'r': return 'r';
|
||||
case 'rb': return 'ruby';
|
||||
case 'rs': return 'rust';
|
||||
case 'sas': return 'sas';
|
||||
case 'sass':
|
||||
case 'scss': return 'sass';
|
||||
case 'scala': return 'scala';
|
||||
case 'scm': return 'scheme';
|
||||
case 'shader': return 'shader';
|
||||
case 'sh':
|
||||
case 'bash': return 'shell';
|
||||
case 'siv': return 'sieve';
|
||||
case 'st': return 'smalltalk';
|
||||
case 'sol': return 'solidity';
|
||||
case 'solr': return 'solr';
|
||||
case 'rq': return 'sparql';
|
||||
case 'xlsx':
|
||||
case 'ods':
|
||||
case 'csv': return 'spreadsheet';
|
||||
case 'nut': return 'squirrel';
|
||||
case 'tex': return 'stex';
|
||||
case 'styl': return 'stylus';
|
||||
case 'svelte': return 'svelte';
|
||||
case 'swift': return 'swift';
|
||||
case 'tcl': return 'tcl';
|
||||
case 'textile': return 'textile';
|
||||
case 'tiddlywiki': return 'tiddlyWiki';
|
||||
case 'tiki': return 'tiki';
|
||||
case 'toml': return 'toml';
|
||||
case 'troff': return 'troff';
|
||||
case 'tsx': return 'tsx';
|
||||
case 'ttcn': return 'ttcn';
|
||||
case 'ttl':
|
||||
case 'turtle': return 'turtle';
|
||||
case 'ts': return 'typescript';
|
||||
case 'vb': return 'vb';
|
||||
case 'vbs': return 'vbscript';
|
||||
case 'vm': return 'velocity';
|
||||
case 'v': return 'verilog';
|
||||
case 'vhd':
|
||||
case 'vhdl': return 'vhdl';
|
||||
case 'vue': return 'vue';
|
||||
case 'wat': return 'wast';
|
||||
case 'webidl': return 'webIDL';
|
||||
case 'xq':
|
||||
case 'xquery': return 'xQuery';
|
||||
case 'xml': return 'xml';
|
||||
case 'sql': return 'sql';
|
||||
default: return undefined;
|
||||
case 'yacas': return 'yacas';
|
||||
case 'yaml':
|
||||
case 'yml': return 'yaml';
|
||||
case 'z80': return 'z80';
|
||||
default: return 'js';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,9 +188,21 @@ export function ConfigCodeEditor({content, fileName, onContentChange,}: ConfigCo
|
||||
>
|
||||
<CodeMirror
|
||||
value={content}
|
||||
extensions={extensions.concat(customDarkTheme)}
|
||||
extensions={[
|
||||
loadLanguage(getLanguageName(fileName)),
|
||||
hyperLink,
|
||||
oneDark,
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: '#09090b !important',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: '#18181b !important',
|
||||
},
|
||||
})
|
||||
]}
|
||||
onChange={(value: any) => onContentChange(value)}
|
||||
theme={customDarkTheme}
|
||||
theme={undefined}
|
||||
height="100%"
|
||||
basicSetup={{ lineNumbers: true }}
|
||||
style={{ minHeight: '100%', minWidth: '100%', flex: 1 }}
|
||||
|
||||
@@ -1,33 +1,232 @@
|
||||
import React, { useState } from "react";
|
||||
import {ConfigEditorSidebar} from "@/apps/Config Editor/ConfigEditorSidebar.tsx";
|
||||
import {ConfigCodeEditor} from "@/apps/Config Editor/ConfigCodeEditor.tsx";
|
||||
import {ConfigTopbar} from "@/apps/Config Editor/ConfigTopbar.tsx";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { ConfigEditorSidebar } from "@/apps/Config Editor/ConfigEditorSidebar";
|
||||
import { ConfigTabList } from "@/apps/Config Editor/ConfigTabList";
|
||||
import { ConfigHomeView } from "@/apps/Config Editor/ConfigHomeView";
|
||||
import { ConfigCodeEditor } from "@/apps/Config Editor/ConfigCodeEditor";
|
||||
import axios from 'axios';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfigTopbar } from "@/apps/Config Editor/ConfigTopbar";
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ConfigEditorProps {
|
||||
onSelectView: (view: string) => void;
|
||||
function getJWT() {
|
||||
return document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
|
||||
}
|
||||
|
||||
export function ConfigEditor({onSelectView}: ConfigEditorProps): React.ReactElement {
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [fileName, setFileName] = useState<string>("config.yaml");
|
||||
interface Tab {
|
||||
id: string | number;
|
||||
title: string;
|
||||
fileName: string;
|
||||
content: string;
|
||||
isSSH?: boolean;
|
||||
sshSessionId?: string;
|
||||
filePath?: string;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
dirty?: boolean;
|
||||
}
|
||||
|
||||
export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) => void }): React.ReactElement {
|
||||
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<string | number>('home');
|
||||
const [recent, setRecent] = useState<any[]>([]);
|
||||
const [pinned, setPinned] = useState<any[]>([]);
|
||||
const [shortcuts, setShortcuts] = useState<any[]>([]);
|
||||
const [loadingHome, setLoadingHome] = useState(false);
|
||||
const [errorHome, setErrorHome] = useState<string | undefined>(undefined);
|
||||
|
||||
const API_BASE_DB = 'http://localhost:8081'; // For database-backed endpoints
|
||||
const API_BASE = 'http://localhost:8084'; // For stateless file/ssh operations
|
||||
|
||||
const sidebarRef = useRef<any>(null);
|
||||
|
||||
// Fetch home data
|
||||
useEffect(() => {
|
||||
fetchHomeData();
|
||||
}, []);
|
||||
async function fetchHomeData() {
|
||||
setLoadingHome(true);
|
||||
setErrorHome(undefined);
|
||||
try {
|
||||
const jwt = getJWT();
|
||||
const [recentRes, pinnedRes, shortcutsRes] = await Promise.all([
|
||||
axios.get(`${API_BASE_DB}/config_editor/recent`, { headers: { Authorization: `Bearer ${jwt}` } }),
|
||||
axios.get(`${API_BASE_DB}/config_editor/pinned`, { headers: { Authorization: `Bearer ${jwt}` } }),
|
||||
axios.get(`${API_BASE_DB}/config_editor/shortcuts`, { headers: { Authorization: `Bearer ${jwt}` } }),
|
||||
]);
|
||||
setRecent(recentRes.data || []);
|
||||
setPinned(pinnedRes.data || []);
|
||||
setShortcuts(shortcutsRes.data || []);
|
||||
} catch (err: any) {
|
||||
setErrorHome('Failed to load home data');
|
||||
} finally {
|
||||
setLoadingHome(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Home view actions
|
||||
const handleOpenFile = async (file: any) => {
|
||||
const tabId = file.path;
|
||||
if (!tabs.find(t => t.id === tabId)) {
|
||||
setTabs([...tabs, { id: tabId, title: file.name, fileName: file.name, content: '', filePath: file.path, isSSH: file.isSSH, sshSessionId: file.sshSessionId, loading: true }]);
|
||||
try {
|
||||
let content = '';
|
||||
const jwt = getJWT();
|
||||
if (file.isSSH) {
|
||||
const res = await axios.get(`${API_BASE}/ssh/readFile`, { params: { sessionId: file.sshSessionId, path: file.path }, headers: { Authorization: `Bearer ${jwt}` } });
|
||||
content = res.data.content;
|
||||
} else {
|
||||
const folder = file.path.substring(0, file.path.lastIndexOf('/'));
|
||||
const res = await axios.get(`${API_BASE}/file`, { params: { folder, name: file.name }, headers: { Authorization: `Bearer ${jwt}` } });
|
||||
content = res.data;
|
||||
}
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, content, loading: false, error: undefined } : t));
|
||||
// Mark as recent
|
||||
await axios.post(`${API_BASE_DB}/config_editor/recent`, { name: file.name, path: file.path }, { headers: { Authorization: `Bearer ${jwt}` } });
|
||||
fetchHomeData();
|
||||
} catch (err: any) {
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, loading: false, error: err?.message || 'Failed to load file' } : t));
|
||||
}
|
||||
}
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
const handleRemoveRecent = async (file: any) => {
|
||||
// Implement backend delete if needed
|
||||
setRecent(recent.filter(f => f.path !== file.path));
|
||||
};
|
||||
const handlePinFile = async (file: any) => {
|
||||
const jwt = getJWT();
|
||||
await axios.post(`${API_BASE_DB}/config_editor/pinned`, { name: file.name, path: file.path }, { headers: { Authorization: `Bearer ${jwt}` } });
|
||||
fetchHomeData();
|
||||
};
|
||||
const handleOpenShortcut = async (shortcut: any) => {
|
||||
// Find the server for this shortcut (local or SSH)
|
||||
let server: any = { isLocal: true, name: 'Local Files', id: 'local', defaultPath: '/' };
|
||||
if (shortcut.server) {
|
||||
server = shortcut.server;
|
||||
}
|
||||
// Use the sidebar's openFolder method
|
||||
if ((window as any).configSidebarRef && (window as any).configSidebarRef.openFolder) {
|
||||
(window as any).configSidebarRef.openFolder(server, shortcut.path);
|
||||
}
|
||||
};
|
||||
// Add add/remove shortcut logic
|
||||
const handleAddShortcut = async (folderPath: string) => {
|
||||
try {
|
||||
const jwt = getJWT();
|
||||
await axios.post(`${API_BASE_DB}/config_editor/shortcuts`, { name: folderPath.split('/').pop(), path: folderPath });
|
||||
fetchHomeData();
|
||||
} catch {}
|
||||
};
|
||||
const handleRemoveShortcut = async (shortcut: any) => {
|
||||
try {
|
||||
const jwt = getJWT();
|
||||
await axios.post(`${API_BASE_DB}/config_editor/shortcuts/delete`, { path: shortcut.path });
|
||||
fetchHomeData();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// Tab actions
|
||||
const closeTab = (tabId: string | number) => {
|
||||
const idx = tabs.findIndex(t => t.id === tabId);
|
||||
const newTabs = tabs.filter(t => t.id !== tabId);
|
||||
setTabs(newTabs);
|
||||
if (activeTab === tabId) {
|
||||
if (newTabs.length > 0) setActiveTab(newTabs[Math.max(0, idx - 1)].id);
|
||||
else setActiveTab('home');
|
||||
}
|
||||
};
|
||||
const setTabContent = (tabId: string | number, content: string) => {
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, content, dirty: true } : t));
|
||||
};
|
||||
const handleSave = async (tab: Tab) => {
|
||||
try {
|
||||
const jwt = getJWT();
|
||||
if (tab.isSSH) {
|
||||
await axios.post(`${API_BASE}/ssh/writeFile`, { sessionId: tab.sshSessionId, path: tab.filePath, content: tab.content }, { headers: { Authorization: `Bearer ${jwt}` } });
|
||||
} else {
|
||||
await axios.post(`${API_BASE}/file?folder=${encodeURIComponent(tab.filePath?.substring(0, tab.filePath?.lastIndexOf('/')) || '')}&name=${encodeURIComponent(tab.fileName)}`, { content: tab.content }, { headers: { Authorization: `Bearer ${jwt}` } });
|
||||
}
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, dirty: false } : t));
|
||||
// Mark as recent
|
||||
await axios.post(`${API_BASE_DB}/config_editor/recent`, { name: tab.fileName, path: tab.filePath }, { headers: { Authorization: `Bearer ${jwt}` } });
|
||||
fetchHomeData();
|
||||
} catch (err) {
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, error: 'Failed to save file' } : t));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden' }}>
|
||||
{/* Sidebar - fixed width, full height */}
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20 }}>
|
||||
<ConfigEditorSidebar onSelectView={onSelectView} />
|
||||
<ConfigEditorSidebar onSelectView={onSelectView} onOpenFile={handleOpenFile} tabs={tabs} ref={sidebarRef} />
|
||||
</div>
|
||||
{/* Topbar - fixed height, full width minus sidebar */}
|
||||
<div style={{ position: 'absolute', top: 0, left: 256, right: 0, height: 46, zIndex: 30 }}>
|
||||
<ConfigTopbar />
|
||||
<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" style={{ height: 44 }}>
|
||||
{/* Tab list scrollable area, full width except for Save button */}
|
||||
<div className="flex-1 min-w-0 h-full flex items-center pr-0">
|
||||
<div className="h-9 w-full bg-[#09090b] border border-[#23232a] rounded-md flex items-center overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent" style={{ minWidth: 0 }}>
|
||||
<ConfigTopbar
|
||||
tabs={tabs.map(t => ({ id: t.id, title: t.title }))}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
closeTab={closeTab}
|
||||
onHomeClick={() => setActiveTab('home')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Save button only for file tabs, stationary at right */}
|
||||
{activeTab !== 'home' && (() => {
|
||||
const tab = tabs.find(t => t.id === activeTab);
|
||||
if (!tab) return null;
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'ml-2 mr-2 px-3 py-1.5 border rounded-md text-base font-medium transition-colors',
|
||||
'border-[#2d2d30] text-white bg-transparent hover:bg-[#23232a] active:bg-[#23232a] focus:bg-[#23232a]',
|
||||
!tab.dirty ? 'opacity-60 cursor-not-allowed' : 'hover:border-[#2d2d30]'
|
||||
)}
|
||||
disabled={!tab.dirty}
|
||||
onClick={() => handleSave(tab)}
|
||||
type="button"
|
||||
style={{ height: 36, alignSelf: 'center' }}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{/* Editor area - fills remaining space, with padding for sidebar and topbar */}
|
||||
<div style={{ position: 'absolute', top: 46, left: 256, right: 0, bottom: 0, overflow: 'hidden', zIndex: 10 }}>
|
||||
<ConfigCodeEditor
|
||||
content={content}
|
||||
fileName={fileName}
|
||||
onContentChange={setContent}
|
||||
/>
|
||||
<div style={{ position: 'absolute', top: 44, left: 256, right: 0, bottom: 0, overflow: 'hidden', zIndex: 10, background: '#101014', display: 'flex', flexDirection: 'column' }}>
|
||||
{activeTab === 'home' ? (
|
||||
<ConfigHomeView
|
||||
recent={recent}
|
||||
pinned={pinned}
|
||||
shortcuts={shortcuts}
|
||||
onOpenFile={handleOpenFile}
|
||||
onRemoveRecent={handleRemoveRecent}
|
||||
onPinFile={handlePinFile}
|
||||
onOpenShortcut={handleOpenShortcut}
|
||||
onRemoveShortcut={handleRemoveShortcut}
|
||||
onAddShortcut={handleAddShortcut}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const tab = tabs.find(t => t.id === activeTab);
|
||||
if (!tab) return null;
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{ flex: 1, minHeight: 0 }}>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ConfigCodeEditor
|
||||
content={tab.content}
|
||||
fileNameOld={tab.fileName}
|
||||
onContentChange={content => setTabContent(tab.id, content)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
140
src/apps/Config Editor/ConfigFileSidebarViewer.tsx
Normal file
140
src/apps/Config Editor/ConfigFileSidebarViewer.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Plus, Folder, File, Star, Trash2, Edit, Link2, Server } from 'lucide-react';
|
||||
|
||||
interface SSHConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: 'file' | 'directory' | 'link';
|
||||
path: string;
|
||||
isStarred?: boolean;
|
||||
}
|
||||
|
||||
interface ConfigFileSidebarViewerProps {
|
||||
sshConnections: SSHConnection[];
|
||||
onAddSSH: () => void;
|
||||
onConnectSSH: (conn: SSHConnection) => void;
|
||||
onEditSSH: (conn: SSHConnection) => void;
|
||||
onDeleteSSH: (conn: SSHConnection) => void;
|
||||
onPinSSH: (conn: SSHConnection) => void;
|
||||
currentPath: string;
|
||||
files: FileItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onOpenFolder: (folder: FileItem) => void;
|
||||
onStarFile: (file: FileItem) => void;
|
||||
onDeleteFile: (file: FileItem) => void;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
isSSHMode: boolean;
|
||||
onSwitchToLocal: () => void;
|
||||
onSwitchToSSH: (conn: SSHConnection) => void;
|
||||
currentSSH?: SSHConnection;
|
||||
}
|
||||
|
||||
export function ConfigFileSidebarViewer({
|
||||
sshConnections,
|
||||
onAddSSH,
|
||||
onConnectSSH,
|
||||
onEditSSH,
|
||||
onDeleteSSH,
|
||||
onPinSSH,
|
||||
currentPath,
|
||||
files,
|
||||
onOpenFile,
|
||||
onOpenFolder,
|
||||
onStarFile,
|
||||
onDeleteFile,
|
||||
isLoading,
|
||||
error,
|
||||
isSSHMode,
|
||||
onSwitchToLocal,
|
||||
onSwitchToSSH,
|
||||
currentSSH,
|
||||
}: ConfigFileSidebarViewerProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* SSH Connections */}
|
||||
<div className="p-2 bg-[#18181b] border-b border-[#23232a]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground font-semibold">SSH Connections</span>
|
||||
<Button size="icon" variant="outline" onClick={onAddSSH} className="ml-2 h-7 w-7">
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant={!isSSHMode ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start text-left px-2 py-1.5 rounded"
|
||||
onClick={onSwitchToLocal}
|
||||
>
|
||||
<Server className="w-4 h-4 mr-2" /> Local Files
|
||||
</Button>
|
||||
{sshConnections.map((conn) => (
|
||||
<div key={conn.id} className="flex items-center gap-1 group">
|
||||
<Button
|
||||
variant={isSSHMode && currentSSH?.id === conn.id ? 'secondary' : 'ghost'}
|
||||
className="flex-1 justify-start text-left px-2 py-1.5 rounded"
|
||||
onClick={() => onSwitchToSSH(conn)}
|
||||
>
|
||||
<Link2 className="w-4 h-4 mr-2" />
|
||||
{conn.name || conn.ip}
|
||||
{conn.isPinned && <Star className="w-3 h-3 ml-1 text-yellow-400" />}
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}>
|
||||
<Star className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`} />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteSSH(conn)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* File/Folder Viewer */}
|
||||
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground font-semibold">{isSSHMode ? 'SSH Path' : 'Local Path'}</span>
|
||||
<span className="text-xs text-white truncate">{currentPath}</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-xs text-muted-foreground">Loading...</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-red-500">{error}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{files.map((item) => (
|
||||
<Card key={item.path} className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border border-[#23232a] rounded">
|
||||
<div className="flex items-center gap-2 flex-1 cursor-pointer" onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
|
||||
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400" /> : <File className="w-4 h-4 text-muted-foreground" />}
|
||||
<span className="text-sm text-white truncate">{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onStarFile(item)}>
|
||||
<Star className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`} />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteFile(item)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{files.length === 0 && <div className="text-xs text-muted-foreground">No files or folders found.</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/apps/Config Editor/ConfigHomeView.tsx
Normal file
131
src/apps/Config Editor/ConfigHomeView.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Star, Trash2, Folder, File, Plus } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
path: string;
|
||||
isStarred?: boolean;
|
||||
type: 'file' | 'directory';
|
||||
}
|
||||
interface ShortcutItem {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface ConfigHomeViewProps {
|
||||
recent: FileItem[];
|
||||
pinned: FileItem[];
|
||||
shortcuts: ShortcutItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onRemoveRecent: (file: FileItem) => void;
|
||||
onPinFile: (file: FileItem) => void;
|
||||
onOpenShortcut: (shortcut: ShortcutItem) => void;
|
||||
onRemoveShortcut: (shortcut: ShortcutItem) => void;
|
||||
onAddShortcut: (path: string) => void;
|
||||
}
|
||||
|
||||
export function ConfigHomeView({
|
||||
recent,
|
||||
pinned,
|
||||
shortcuts,
|
||||
onOpenFile,
|
||||
onRemoveRecent,
|
||||
onPinFile,
|
||||
onOpenShortcut,
|
||||
onRemoveShortcut,
|
||||
onAddShortcut,
|
||||
}: ConfigHomeViewProps) {
|
||||
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
||||
const [newShortcut, setNewShortcut] = useState('');
|
||||
return (
|
||||
<div className="p-6 flex flex-col gap-8 h-full bg-[#09090b]">
|
||||
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
|
||||
<TabsList className="mb-1">
|
||||
<TabsTrigger value="recent">Recent</TabsTrigger>
|
||||
<TabsTrigger value="pinned">Pinned</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts">Folder Shortcuts</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="recent">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{recent.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">No recent files.</span>
|
||||
) : recent.map((file, index) => (
|
||||
<Card key={`${file.path}-${index}`} className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded">
|
||||
<Button variant="ghost" className="p-0 h-7 w-7" onClick={() => onOpenFile(file)}>
|
||||
{file.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400" /> : <File className="w-4 h-4 text-muted-foreground" />}
|
||||
</Button>
|
||||
<span className="text-sm text-white truncate max-w-[120px]">{file.name}</span>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinFile(file)}>
|
||||
<Star className={`w-4 h-4 ${file.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`} />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onRemoveRecent(file)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="pinned">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{pinned.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">No pinned files.</span>
|
||||
) : pinned.map((file, index) => (
|
||||
<Card key={`${file.path}-${index}`} className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded">
|
||||
<Button variant="ghost" className="p-0 h-7 w-7" onClick={() => onOpenFile(file)}>
|
||||
{file.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400" /> : <File className="w-4 h-4 text-muted-foreground" />}
|
||||
</Button>
|
||||
<span className="text-sm text-white truncate max-w-[120px]">{file.name}</span>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinFile(file)}>
|
||||
<Star className={`w-4 h-4 ${file.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`} />
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="shortcuts">
|
||||
<div className="flex items-center gap-2 mb-0">
|
||||
<Input
|
||||
placeholder="Enter folder path"
|
||||
value={newShortcut}
|
||||
onChange={e => setNewShortcut(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
if (newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
setNewShortcut('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{shortcuts.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground mt-4">No shortcuts.</span>
|
||||
) : shortcuts.map((shortcut, index) => (
|
||||
<Card key={`${shortcut.path}-${index}`} className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded">
|
||||
<Button variant="ghost" className="p-0 h-7 w-7" onClick={() => onOpenShortcut(shortcut)}>
|
||||
<Folder className="w-4 h-4 text-blue-400" />
|
||||
</Button>
|
||||
<span className="text-sm text-white truncate max-w-[120px]">{shortcut.name || shortcut.path.split('/').pop()}</span>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onRemoveShortcut(shortcut)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/apps/Config Editor/ConfigTabList.tsx
Normal file
53
src/apps/Config Editor/ConfigTabList.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { X, Home } from 'lucide-react';
|
||||
|
||||
interface ConfigTab {
|
||||
id: string | number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface ConfigTabListProps {
|
||||
tabs: ConfigTab[];
|
||||
activeTab: string | number;
|
||||
setActiveTab: (tab: string | number) => void;
|
||||
closeTab: (tab: string | number) => void;
|
||||
onHomeClick: () => void;
|
||||
}
|
||||
|
||||
export function ConfigTabList({ tabs, activeTab, setActiveTab, closeTab, onHomeClick }: ConfigTabListProps) {
|
||||
return (
|
||||
<div className="inline-flex items-center h-full px-[0.5rem] overflow-x-auto">
|
||||
<Button
|
||||
onClick={onHomeClick}
|
||||
variant="outline"
|
||||
className={`h-7 mr-[0.5rem] rounded-md flex items-center ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
</Button>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
return (
|
||||
<div key={tab.id} className={index < tabs.length - 1 ? 'mr-[0.5rem]' : ''}>
|
||||
<div className="inline-flex rounded-md shadow-sm" role="group">
|
||||
<Button
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
variant="outline"
|
||||
className={`h-7 rounded-r-none flex items-center ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||
>
|
||||
{tab.title}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => closeTab(tab.id)}
|
||||
variant="outline"
|
||||
className="h-7 rounded-l-none p-0 !w-7 !h-7 flex items-center"
|
||||
>
|
||||
<X className="!w-4 !h-4" strokeWidth={2.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
import React from "react";
|
||||
import { ConfigTabList } from "./ConfigTabList";
|
||||
|
||||
export function ConfigTopbar(): React.ReactElement {
|
||||
export function ConfigTopbar(props: any): React.ReactElement {
|
||||
return (
|
||||
<div className="flex h-11.5 z-100" style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 46,
|
||||
backgroundColor: '#18181b',
|
||||
borderBottom: '1px solid #222224',
|
||||
zIndex: 100,
|
||||
}}>
|
||||
test
|
||||
</div>
|
||||
<ConfigTabList {...props} />
|
||||
)
|
||||
}
|
||||
@@ -816,16 +816,12 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa
|
||||
|
||||
<FormField
|
||||
control={addHostForm.control}
|
||||
name="port"
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="22"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value) || 22)}
|
||||
/>
|
||||
<Input placeholder="username123" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -834,12 +830,16 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa
|
||||
|
||||
<FormField
|
||||
control={addHostForm.control}
|
||||
name="username"
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="username123" {...field} />
|
||||
<Input
|
||||
placeholder="22"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value) || 22)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -1057,7 +1057,7 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa
|
||||
<SidebarMenuItem key="Main" className="flex flex-col flex-grow overflow-hidden">
|
||||
<div className="w-full flex-grow rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
|
||||
{/* Search bar */}
|
||||
<div className="w-full px-2 pt-2 pb-1 bg-[#09090b] z-10">
|
||||
<div className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
@@ -1066,7 +1066,9 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<Separator className="mx-2 mt-1" />
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Separator className="w-full h-px bg-[#434345] my-2" style={{ maxWidth: 213, margin: '0 auto' }} />
|
||||
</div>
|
||||
{/* Error and status messages */}
|
||||
{hostsError && (
|
||||
<div className="px-2 py-1 mt-2">
|
||||
@@ -1082,26 +1084,33 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa
|
||||
<ScrollArea className="w-full h-full">
|
||||
<Accordion key={`host-accordion-${sortedFolders.length}`} type="multiple" className="w-full" defaultValue={sortedFolders.length > 0 ? sortedFolders : undefined}>
|
||||
{sortedFolders.map((folder, idx) => (
|
||||
<AccordionItem value={folder} key={`folder-${folder}`} className={idx === 0 ? "mt-0" : "mt-2"}>
|
||||
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2" style={{marginTop: idx === 0 ? 0 : undefined}}>{folder}</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
|
||||
{getSortedHosts(hostsByFolder[folder]).map(host => (
|
||||
<div key={host.id} className="w-full overflow-hidden">
|
||||
<HostMenuItem
|
||||
host={host}
|
||||
onHostConnect={handleHostConnect}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onEditHost={() => {
|
||||
setEditHostData(host);
|
||||
setEditHostOpen(true);
|
||||
}}
|
||||
popoverOpen={!!hostPopoverOpen[host.id]}
|
||||
setPopoverOpen={(open: boolean) => handlePopoverOpenChange(host.id, open)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<React.Fragment key={folder}>
|
||||
<AccordionItem value={folder} className={idx === 0 ? "mt-0 !border-b-transparent" : "mt-2 !border-b-transparent"}>
|
||||
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2" style={{marginTop: idx === 0 ? 0 : undefined}}>{folder}</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
|
||||
{getSortedHosts(hostsByFolder[folder]).map(host => (
|
||||
<div key={host.id} className="w-full overflow-hidden">
|
||||
<HostMenuItem
|
||||
host={host}
|
||||
onHostConnect={handleHostConnect}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onEditHost={() => {
|
||||
setEditHostData(host);
|
||||
setEditHostOpen(true);
|
||||
}}
|
||||
popoverOpen={!!hostPopoverOpen[host.id]}
|
||||
setPopoverOpen={(open: boolean) => handlePopoverOpenChange(host.id, open)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
{idx < sortedFolders.length - 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Separator className="h-px bg-[#434345] my-1" style={{ width: 213 }} />
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
@@ -1315,16 +1324,12 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa
|
||||
/>
|
||||
<FormField
|
||||
control={editHostForm.control}
|
||||
name="port"
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="22"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value) || 22)}
|
||||
/>
|
||||
<Input placeholder="username123" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -1332,12 +1337,16 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa
|
||||
/>
|
||||
<FormField
|
||||
control={editHostForm.control}
|
||||
name="username"
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="username123" {...field} />
|
||||
<Input
|
||||
placeholder="22"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value) || 22)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -1690,10 +1699,10 @@ const HostMenuItem = React.memo(function HostMenuItem({ host, onHostConnect, onD
|
||||
const hasTags = tags.length > 0;
|
||||
return (
|
||||
<div className="relative group flex flex-col mb-1 w-full overflow-hidden" style={{ height: hasTags ? 70 : 40, maxWidth: '213px' }}>
|
||||
<div className={`flex flex-col w-full rounded overflow-hidden border border-border bg-secondary h-full`} style={{ maxWidth: '213px' }}>
|
||||
<div className={`flex flex-col w-full rounded overflow-hidden border border-[#434345] bg-[#18181b] h-full`} style={{ maxWidth: '213px' }}>
|
||||
<div className="flex w-full h-10">
|
||||
{/* Left: Name + Star - Horizontal scroll only */}
|
||||
<div className="flex items-center h-full px-2 overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent min-w-0 border-r border-border hover:bg-muted transition-colors cursor-pointer"
|
||||
<div className="flex items-center h-full px-2 overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent min-w-0 border-r border-[#434345] hover:bg-muted transition-colors cursor-pointer"
|
||||
style={{ flex: '0 1 calc(100% - 32px)', maxWidth: 'calc(100% - 32px)' }}
|
||||
onClick={() => onHostConnect(host)}
|
||||
>
|
||||
@@ -1729,9 +1738,9 @@ const HostMenuItem = React.memo(function HostMenuItem({ host, onHostConnect, onD
|
||||
</div>
|
||||
</div>
|
||||
{hasTags && (
|
||||
<div className="border-t border-border bg-secondary 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, width: '100%' }}>
|
||||
<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, width: '100%' }}>
|
||||
{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">
|
||||
<span key={tag} className="bg-muted-foreground/10 text-xs rounded-full px-2 py-0.5 text-muted-foreground whitespace-nowrap border border-border flex-shrink-0 hover:bg-muted transition-colors">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
|
||||
290
src/backend/config_editor/config_editor.ts
Normal file
290
src/backend/config_editor/config_editor.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Client as SSHClient } from 'ssh2';
|
||||
import chalk from "chalk";
|
||||
|
||||
const app = express();
|
||||
const PORT = 8084;
|
||||
|
||||
app.use(cors({
|
||||
origin: 'http://localhost:5173',
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
const sshIconSymbol = '📁';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Local File Operations ---
|
||||
function normalizeFilePath(inputPath: string): string {
|
||||
if (!inputPath || typeof inputPath !== 'string') throw new Error('Invalid path');
|
||||
let normalizedPath = inputPath.replace(/\\/g, '/');
|
||||
const windowsAbsPath = /^[a-zA-Z]:\//;
|
||||
if (windowsAbsPath.test(normalizedPath)) return path.resolve(normalizedPath);
|
||||
if (normalizedPath.startsWith('/')) return path.resolve(normalizedPath);
|
||||
return path.resolve(process.cwd(), normalizedPath);
|
||||
}
|
||||
function isDirectory(p: string): boolean {
|
||||
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
||||
}
|
||||
|
||||
app.get('/files', (req, res) => {
|
||||
try {
|
||||
const folderParam = req.query.folder as string || '';
|
||||
const folderPath = normalizeFilePath(folderParam);
|
||||
if (!fs.existsSync(folderPath) || !isDirectory(folderPath)) {
|
||||
logger.error('Directory not found:', folderPath);
|
||||
return res.status(404).json({ error: 'Directory not found' });
|
||||
}
|
||||
fs.readdir(folderPath, { withFileTypes: true }, (err, files) => {
|
||||
if (err) {
|
||||
logger.error('Error reading directory:', err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
const result = files.map(f => ({ name: f.name, type: f.isDirectory() ? 'directory' : 'file' }));
|
||||
res.json(result);
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.error('Error in /files endpoint:', err);
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/file', (req, res) => {
|
||||
try {
|
||||
const folderParam = req.query.folder as string || '';
|
||||
const fileName = req.query.name as string;
|
||||
if (!fileName) return res.status(400).json({ error: 'Missing "name" parameter' });
|
||||
const folderPath = normalizeFilePath(folderParam);
|
||||
const filePath = path.join(folderPath, fileName);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.error(`File not found: ${filePath}`);
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
if (isDirectory(filePath)) {
|
||||
logger.error(`Path is a directory: ${filePath}`);
|
||||
return res.status(400).json({ error: 'Path is a directory' });
|
||||
}
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.send(content);
|
||||
} catch (err: any) {
|
||||
logger.error('Error in /file GET endpoint:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/file', (req, res) => {
|
||||
try {
|
||||
const folderParam = req.query.folder as string || '';
|
||||
const fileName = req.query.name as string;
|
||||
const content = req.body.content;
|
||||
if (!fileName) return res.status(400).json({ error: 'Missing "name" parameter' });
|
||||
if (content === undefined) return res.status(400).json({ error: 'Missing "content" in request body' });
|
||||
const folderPath = normalizeFilePath(folderParam);
|
||||
const filePath = path.join(folderPath, fileName);
|
||||
if (!fs.existsSync(folderPath)) fs.mkdirSync(folderPath, { recursive: true });
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
res.json({ message: 'File written successfully' });
|
||||
} catch (err: any) {
|
||||
logger.error('Error in /file POST endpoint:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- SSH Operations (per-session, in-memory, with cleanup) ---
|
||||
interface SSHSession {
|
||||
client: SSHClient;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
}
|
||||
const sshSessions: Record<string, SSHSession> = {};
|
||||
const SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
function cleanupSession(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
try { session.client.end(); } catch {}
|
||||
clearTimeout(session.timeout);
|
||||
delete sshSessions[sessionId];
|
||||
logger.info(`Cleaned up SSH session: ${sessionId}`);
|
||||
}
|
||||
}
|
||||
function scheduleSessionCleanup(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
if (session.timeout) clearTimeout(session.timeout);
|
||||
session.timeout = setTimeout(() => cleanupSession(sessionId), SESSION_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/ssh/connect', (req, res) => {
|
||||
const { sessionId, ip, port, username, password, sshKey, keyPassword } = req.body;
|
||||
if (!sessionId || !ip || !username || !port) {
|
||||
logger.warn('Missing SSH connection parameters');
|
||||
return res.status(400).json({ error: 'Missing SSH connection parameters' });
|
||||
}
|
||||
if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId);
|
||||
const client = new SSHClient();
|
||||
const config: any = {
|
||||
host: ip, port: port || 22, username,
|
||||
readyTimeout: 20000, keepaliveInterval: 10000, keepaliveCountMax: 3,
|
||||
};
|
||||
if (sshKey) { config.privateKey = sshKey; if (keyPassword) config.passphrase = keyPassword; }
|
||||
else if (password) config.password = password;
|
||||
else { logger.warn('No password or key provided'); return res.status(400).json({ error: 'Either password or SSH key must be provided' }); }
|
||||
client.on('ready', () => {
|
||||
sshSessions[sessionId] = { client, isConnected: true, lastActive: Date.now() };
|
||||
scheduleSessionCleanup(sessionId);
|
||||
logger.info(`SSH connected: ${ip}:${port} as ${username} (session: ${sessionId})`);
|
||||
res.json({ status: 'success', message: 'SSH connection established' });
|
||||
});
|
||||
client.on('error', (err) => {
|
||||
logger.error('SSH connection error:', err.message);
|
||||
res.status(500).json({ status: 'error', message: err.message });
|
||||
});
|
||||
client.on('close', () => {
|
||||
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
|
||||
cleanupSession(sessionId);
|
||||
});
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
app.post('/ssh/disconnect', (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
cleanupSession(sessionId);
|
||||
res.json({ status: 'success', message: 'SSH connection disconnected' });
|
||||
});
|
||||
|
||||
app.get('/ssh/status', (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const isConnected = !!sshSessions[sessionId]?.isConnected;
|
||||
res.json({ status: 'success', connected: isConnected });
|
||||
});
|
||||
|
||||
app.get('/ssh/listFiles', (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const { path: sshPath = '/' } = req.query;
|
||||
if (!sshConn?.isConnected) {
|
||||
logger.warn('SSH connection not established for session', sessionId);
|
||||
return res.status(400).json({ error: 'SSH connection not established' });
|
||||
}
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
sshConn.client.exec(`ls -la "${sshPath}"`, (err, stream) => {
|
||||
if (err) { logger.error('SSH listFiles error:', err); return res.status(500).json({ error: err.message }); }
|
||||
let data = '';
|
||||
stream.on('data', (chunk: Buffer) => { data += chunk.toString(); });
|
||||
stream.stderr.on('data', (_chunk: Buffer) => { /* ignore for now */ });
|
||||
stream.on('close', () => {
|
||||
const lines = data.split('\n').filter(line => line.trim());
|
||||
const files = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 9) {
|
||||
const permissions = parts[0];
|
||||
const name = parts.slice(8).join(' ');
|
||||
const isDirectory = permissions.startsWith('d');
|
||||
const isLink = permissions.startsWith('l');
|
||||
files.push({ name, type: isDirectory ? 'directory' : (isLink ? 'link' : 'file') });
|
||||
}
|
||||
}
|
||||
res.json(files);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/ssh/readFile', (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const { path: filePath } = req.query;
|
||||
if (!sshConn?.isConnected) {
|
||||
logger.warn('SSH connection not established for session', sessionId);
|
||||
return res.status(400).json({ error: 'SSH connection not established' });
|
||||
}
|
||||
if (!filePath) {
|
||||
logger.warn('File path is required for readFile');
|
||||
return res.status(400).json({ error: 'File path is required' });
|
||||
}
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
sshConn.client.exec(`cat "${filePath}"`, (err, stream) => {
|
||||
if (err) { logger.error('SSH readFile error:', err); return res.status(500).json({ error: err.message }); }
|
||||
let data = '';
|
||||
stream.on('data', (chunk: Buffer) => { data += chunk.toString(); });
|
||||
stream.stderr.on('data', (_chunk: Buffer) => { /* ignore for now */ });
|
||||
stream.on('close', () => {
|
||||
res.json({ content: data, path: filePath });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/ssh/writeFile', (req, res) => {
|
||||
const { sessionId, path: filePath, content } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
if (!sshConn?.isConnected) {
|
||||
logger.warn('SSH connection not established for session', sessionId);
|
||||
return res.status(400).json({ error: 'SSH connection not established' });
|
||||
}
|
||||
if (!filePath) {
|
||||
logger.warn('File path is required for writeFile');
|
||||
return res.status(400).json({ error: 'File path is required' });
|
||||
}
|
||||
if (content === undefined) {
|
||||
logger.warn('File content is required for writeFile');
|
||||
return res.status(400).json({ error: 'File content is required' });
|
||||
}
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
// Write to a temp file, then move
|
||||
const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const safeContent = content.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(`echo '${safeContent}' > "${tempFile}" && mv "${tempFile}" "${filePath}"`, (err, stream) => {
|
||||
if (err) { logger.error('SSH writeFile error:', err); return res.status(500).json({ error: err.message }); }
|
||||
stream.on('close', () => {
|
||||
res.json({ message: 'File written successfully', path: filePath });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
Object.keys(sshSessions).forEach(cleanupSession);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
Object.keys(sshSessions).forEach(cleanupSession);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {});
|
||||
401
src/backend/config_editor/old_database.js
Normal file
401
src/backend/config_editor/old_database.js
Normal file
@@ -0,0 +1,401 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const Database = require('better-sqlite3');
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const cors = require("cors");
|
||||
const jwt = require('jsonwebtoken');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
const PORT = 8081;
|
||||
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
const getReadableTimestamp = () => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
timeZone: 'UTC',
|
||||
}).format(new Date());
|
||||
};
|
||||
|
||||
const logger = {
|
||||
info: (...args) => console.log(`💾 | 🔧 [${getReadableTimestamp()}] INFO:`, ...args),
|
||||
error: (...args) => console.error(`💾 | ❌ [${getReadableTimestamp()}] ERROR:`, ...args),
|
||||
warn: (...args) => console.warn(`💾 | ⚠️ [${getReadableTimestamp()}] WARN:`, ...args),
|
||||
debug: (...args) => console.debug(`💾 | 🔍 [${getReadableTimestamp()}] DEBUG:`, ...args)
|
||||
};
|
||||
|
||||
const SALT = process.env.SALT || 'default_salt';
|
||||
const JWT_SECRET = SALT + '_jwt_secret';
|
||||
const DB_PATH = path.join(__dirname, 'data', 'users.db');
|
||||
|
||||
const dataDir = path.join(__dirname, 'data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.prepare(`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
is_admin BOOLEAN DEFAULT 0,
|
||||
theme TEXT DEFAULT 'vscode'
|
||||
)`).run();
|
||||
|
||||
db.prepare(`CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
signup_enabled BOOLEAN DEFAULT 1
|
||||
)`).run();
|
||||
|
||||
const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count;
|
||||
if (settingsCount === 0) {
|
||||
db.prepare('INSERT INTO settings (signup_enabled) VALUES (1)').run();
|
||||
}
|
||||
|
||||
db.prepare(`CREATE TABLE IF NOT EXISTS user_recent_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
last_opened TEXT NOT NULL,
|
||||
server_name TEXT,
|
||||
server_ip TEXT,
|
||||
server_port INTEGER,
|
||||
server_user TEXT,
|
||||
server_default_path TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`).run();
|
||||
|
||||
db.prepare(`CREATE TABLE IF NOT EXISTS user_starred_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
last_opened TEXT NOT NULL,
|
||||
server_name TEXT,
|
||||
server_ip TEXT,
|
||||
server_port INTEGER,
|
||||
server_user TEXT,
|
||||
server_default_path TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`).run();
|
||||
|
||||
db.prepare(`CREATE TABLE IF NOT EXISTS user_folder_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
folder_path TEXT NOT NULL,
|
||||
folder_name TEXT NOT NULL,
|
||||
server_name TEXT,
|
||||
server_ip TEXT,
|
||||
server_port INTEGER,
|
||||
server_user TEXT,
|
||||
server_default_path TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`).run();
|
||||
|
||||
db.prepare(`CREATE TABLE IF NOT EXISTS user_open_tabs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
tab_id TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
content TEXT,
|
||||
saved_content TEXT,
|
||||
is_dirty BOOLEAN DEFAULT 0,
|
||||
server_name TEXT,
|
||||
server_ip TEXT,
|
||||
server_port INTEGER,
|
||||
server_user TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`).run();
|
||||
|
||||
db.prepare(`CREATE TABLE IF NOT EXISTS user_current_path (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE,
|
||||
current_path TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`).run();
|
||||
|
||||
db.prepare(`CREATE TABLE IF NOT EXISTS user_ssh_servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
server_ip TEXT NOT NULL,
|
||||
server_port INTEGER DEFAULT 22,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT,
|
||||
ssh_key TEXT,
|
||||
default_path TEXT DEFAULT '/',
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`).run();
|
||||
|
||||
function getKeyAndIV() {
|
||||
const key = crypto.createHash('sha256').update(SALT).digest();
|
||||
const iv = Buffer.alloc(16, 0);
|
||||
return { key, iv };
|
||||
}
|
||||
|
||||
function encrypt(text) {
|
||||
const { key, iv } = getKeyAndIV();
|
||||
const cipher = crypto.createCipheriv('aes-256-ctr', key, iv);
|
||||
let crypted = cipher.update(text, 'utf8', 'hex');
|
||||
crypted += cipher.final('hex');
|
||||
return crypted;
|
||||
}
|
||||
|
||||
function decrypt(text) {
|
||||
const { key, iv } = getKeyAndIV();
|
||||
const decipher = crypto.createDecipheriv('aes-256-ctr', key, iv);
|
||||
let dec = decipher.update(text, 'hex', 'utf8');
|
||||
dec += decipher.final('utf8');
|
||||
return dec;
|
||||
}
|
||||
|
||||
function generateToken(user) {
|
||||
return jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' });
|
||||
}
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader) return res.status(401).json({ error: 'No token provided' });
|
||||
const token = authHeader.split(' ')[1];
|
||||
if (!token) return res.status(401).json({ error: 'Invalid token format' });
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/register', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
|
||||
|
||||
const settings = db.prepare('SELECT signup_enabled FROM settings WHERE id = 1').get();
|
||||
if (!settings.signup_enabled) {
|
||||
return res.status(403).json({ error: 'Signups are currently disabled' });
|
||||
}
|
||||
|
||||
try {
|
||||
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||
const isFirstUser = userCount === 0;
|
||||
|
||||
const hash = await bcrypt.hash(password + SALT, 10);
|
||||
const stmt = db.prepare('INSERT INTO users (username, password, created_at, is_admin) VALUES (?, ?, ?, ?)');
|
||||
stmt.run(username, encrypt(hash), new Date().toISOString(), isFirstUser ? 1 : 0);
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
const token = generateToken(user);
|
||||
return res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
isAdmin: user.is_admin === 1
|
||||
},
|
||||
isFirstUser
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === 'SQLITE_CONSTRAINT') {
|
||||
return res.status(409).json({ error: 'Username already exists' });
|
||||
}
|
||||
logger.error('Registration error:', err);
|
||||
return res.status(500).json({ error: 'Registration failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/login', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) return res.status(401).json({ error: 'Username and password required' });
|
||||
try {
|
||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
const hash = decrypt(user.password);
|
||||
const valid = await bcrypt.compare(password + SALT, hash);
|
||||
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
const token = generateToken(user);
|
||||
return res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
isAdmin: user.is_admin === 1
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Login error:', err);
|
||||
return res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/profile', authMiddleware, (req, res) => {
|
||||
const user = db.prepare('SELECT id, username, created_at, is_admin FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
return res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
created_at: user.created_at,
|
||||
isAdmin: user.is_admin === 1
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/check-first-user', (req, res) => {
|
||||
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||
return res.json({ isFirstUser: userCount === 0 });
|
||||
});
|
||||
|
||||
app.get('/admin/settings', authMiddleware, (req, res) => {
|
||||
const user = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!user || !user.is_admin) return res.status(403).json({ error: 'Admin access required' });
|
||||
|
||||
const settings = db.prepare('SELECT signup_enabled FROM settings WHERE id = 1').get();
|
||||
return res.json({ settings });
|
||||
});
|
||||
|
||||
app.post('/admin/settings', authMiddleware, (req, res) => {
|
||||
const user = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!user || !user.is_admin) return res.status(403).json({ error: 'Admin access required' });
|
||||
|
||||
const { signup_enabled } = req.body;
|
||||
if (typeof signup_enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'Invalid signup_enabled value' });
|
||||
}
|
||||
|
||||
db.prepare('UPDATE settings SET signup_enabled = ? WHERE id = 1').run(signup_enabled ? 1 : 0);
|
||||
return res.json({ message: 'Settings updated successfully' });
|
||||
});
|
||||
|
||||
app.use('/file', authMiddleware);
|
||||
app.use('/files', authMiddleware);
|
||||
|
||||
app.post('/user/data', authMiddleware, (req, res) => {
|
||||
const { recentFiles, starredFiles, folderShortcuts, openTabs, currentPath, sshServers, theme } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
try {
|
||||
db.prepare('BEGIN').run();
|
||||
|
||||
if (recentFiles) {
|
||||
db.prepare('DELETE FROM user_recent_files WHERE user_id = ?').run(userId);
|
||||
const stmt = db.prepare('INSERT INTO user_recent_files (user_id, file_path, file_name, last_opened, server_name, server_ip, server_port, server_user, server_default_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
recentFiles.forEach(file => {
|
||||
stmt.run(userId, file.path, file.name, file.lastOpened, file.serverName, file.serverIp, file.serverPort, file.serverUser, file.serverDefaultPath);
|
||||
});
|
||||
}
|
||||
|
||||
if (starredFiles) {
|
||||
db.prepare('DELETE FROM user_starred_files WHERE user_id = ?').run(userId);
|
||||
const stmt = db.prepare('INSERT INTO user_starred_files (user_id, file_path, file_name, last_opened, server_name, server_ip, server_port, server_user, server_default_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
starredFiles.forEach(file => {
|
||||
stmt.run(userId, file.path, file.name, file.lastOpened, file.serverName, file.serverIp, file.serverPort, file.serverUser, file.serverDefaultPath);
|
||||
});
|
||||
}
|
||||
|
||||
if (folderShortcuts) {
|
||||
db.prepare('DELETE FROM user_folder_shortcuts WHERE user_id = ?').run(userId);
|
||||
const stmt = db.prepare('INSERT INTO user_folder_shortcuts (user_id, folder_path, folder_name, server_name, server_ip, server_port, server_user, server_default_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
folderShortcuts.forEach(folder => {
|
||||
stmt.run(userId, folder.path, folder.name, folder.serverName, folder.serverIp, folder.serverPort, folder.serverUser, folder.serverDefaultPath);
|
||||
});
|
||||
}
|
||||
|
||||
if (openTabs) {
|
||||
db.prepare('DELETE FROM user_open_tabs WHERE user_id = ?').run(userId);
|
||||
const stmt = db.prepare('INSERT INTO user_open_tabs (user_id, tab_id, file_name, file_path, content, saved_content, is_dirty, server_name, server_ip, server_port, server_user) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
openTabs.forEach(tab => {
|
||||
stmt.run(userId, tab.id, tab.name, tab.path, tab.content || '', tab.savedContent || '', tab.isDirty ? 1 : 0, tab.serverName, tab.serverIp, tab.serverPort, tab.serverUser);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentPath) {
|
||||
db.prepare('INSERT OR REPLACE INTO user_current_path (user_id, current_path) VALUES (?, ?)').run(userId, currentPath);
|
||||
}
|
||||
|
||||
if (sshServers) {
|
||||
db.prepare('DELETE FROM user_ssh_servers WHERE user_id = ?').run(userId);
|
||||
const stmt = db.prepare('INSERT INTO user_ssh_servers (user_id, server_name, server_ip, server_port, username, password, ssh_key, default_path, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
sshServers.forEach(server => {
|
||||
stmt.run(userId, server.name, server.ip, server.port || 22, server.user, server.password ? encrypt(server.password) : null, server.sshKey ? encrypt(server.sshKey) : null, server.defaultPath || '/', server.createdAt || new Date().toISOString());
|
||||
});
|
||||
}
|
||||
|
||||
if (theme) {
|
||||
db.prepare('UPDATE users SET theme = ? WHERE id = ?').run(theme, userId);
|
||||
}
|
||||
|
||||
db.prepare('COMMIT').run();
|
||||
res.json({ message: 'User data saved successfully' });
|
||||
} catch (err) {
|
||||
db.prepare('ROLLBACK').run();
|
||||
logger.error('Error saving user data:', err);
|
||||
res.status(500).json({ error: 'Failed to save user data' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/user/data', authMiddleware, (req, res) => {
|
||||
const userId = req.user.id;
|
||||
|
||||
try {
|
||||
const recentFiles = db.prepare('SELECT file_path as path, file_name as name, last_opened as lastOpened, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser, server_default_path as serverDefaultPath FROM user_recent_files WHERE user_id = ?').all(userId);
|
||||
|
||||
const starredFiles = db.prepare('SELECT file_path as path, file_name as name, last_opened as lastOpened, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser, server_default_path as serverDefaultPath FROM user_starred_files WHERE user_id = ?').all(userId);
|
||||
|
||||
const folderShortcuts = db.prepare('SELECT folder_path as path, folder_name as name, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser, server_default_path as serverDefaultPath FROM user_folder_shortcuts WHERE user_id = ?').all(userId);
|
||||
|
||||
const openTabs = db.prepare('SELECT tab_id as id, file_name as name, file_path as path, content, saved_content as savedContent, is_dirty as isDirty, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser FROM user_open_tabs WHERE user_id = ?').all(userId);
|
||||
|
||||
const currentPath = db.prepare('SELECT current_path FROM user_current_path WHERE user_id = ?').get(userId);
|
||||
|
||||
const sshServers = db.prepare('SELECT server_name as name, server_ip as ip, server_port as port, username as user, password, ssh_key as sshKey, default_path as defaultPath, created_at as createdAt FROM user_ssh_servers WHERE user_id = ?').all(userId);
|
||||
|
||||
const decryptedServers = sshServers.map(server => ({
|
||||
...server,
|
||||
password: server.password ? decrypt(server.password) : null,
|
||||
sshKey: server.sshKey ? decrypt(server.sshKey) : null
|
||||
}));
|
||||
|
||||
const userTheme = db.prepare('SELECT theme FROM users WHERE id = ?').get(userId)?.theme || 'vscode';
|
||||
|
||||
const data = {
|
||||
recentFiles,
|
||||
starredFiles,
|
||||
folderShortcuts,
|
||||
openTabs,
|
||||
currentPath: currentPath?.current_path || '/',
|
||||
sshServers: decryptedServers,
|
||||
theme: userTheme
|
||||
};
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
logger.error('Error loading user data:', err);
|
||||
res.status(500).json({ error: 'Failed to load user data' });
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
db.prepare('ALTER TABLE users ADD COLUMN theme TEXT DEFAULT "vscode"').run();
|
||||
} catch (e) {
|
||||
if (!e.message.includes('duplicate column')) throw e;
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Database API listening at http://localhost:${PORT}`);
|
||||
});
|
||||
141
src/backend/config_editor/old_file_manager.js
Normal file
141
src/backend/config_editor/old_file_manager.js
Normal file
@@ -0,0 +1,141 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
|
||||
const app = express();
|
||||
const PORT = 8082;
|
||||
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
const getReadableTimestamp = () => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
timeZone: 'UTC',
|
||||
}).format(new Date());
|
||||
};
|
||||
|
||||
const logger = {
|
||||
info: (...args) => console.log(`📁 | 🔧 [${getReadableTimestamp()}] INFO:`, ...args),
|
||||
error: (...args) => console.error(`📁 | ❌ [${getReadableTimestamp()}] ERROR:`, ...args),
|
||||
warn: (...args) => console.warn(`📁 | ⚠️ [${getReadableTimestamp()}] WARN:`, ...args),
|
||||
debug: (...args) => console.debug(`📁 | 🔍 [${getReadableTimestamp()}] DEBUG:`, ...args)
|
||||
};
|
||||
|
||||
function normalizeFilePath(inputPath) {
|
||||
if (!inputPath || typeof inputPath !== 'string') {
|
||||
throw new Error('Invalid path');
|
||||
}
|
||||
|
||||
let normalizedPath = inputPath.replace(/\\/g, '/');
|
||||
|
||||
const windowsAbsPath = /^[a-zA-Z]:\//;
|
||||
if (windowsAbsPath.test(normalizedPath)) {
|
||||
return path.resolve(normalizedPath);
|
||||
}
|
||||
|
||||
if (normalizedPath.startsWith('/')) {
|
||||
return path.resolve(normalizedPath);
|
||||
}
|
||||
|
||||
return path.resolve(process.cwd(), normalizedPath);
|
||||
}
|
||||
|
||||
function isDirectory(path) {
|
||||
try {
|
||||
return fs.statSync(path).isDirectory();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/files', (req, res) => {
|
||||
try {
|
||||
const folderParam = req.query.folder || '';
|
||||
const folderPath = normalizeFilePath(folderParam);
|
||||
|
||||
if (!fs.existsSync(folderPath) || !isDirectory(folderPath)) {
|
||||
return res.status(404).json({ error: 'Directory not found' });
|
||||
}
|
||||
|
||||
fs.readdir(folderPath, { withFileTypes: true }, (err, files) => {
|
||||
if (err) {
|
||||
logger.error('Error reading directory:', err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
const result = files.map(f => ({
|
||||
name: f.name,
|
||||
type: f.isDirectory() ? 'directory' : 'file',
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Error in /files endpoint:', err);
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/file', (req, res) => {
|
||||
try {
|
||||
const folderParam = req.query.folder || '';
|
||||
const fileName = req.query.name;
|
||||
if (!fileName) return res.status(400).json({ error: 'Missing "name" parameter' });
|
||||
|
||||
const folderPath = normalizeFilePath(folderParam);
|
||||
const filePath = path.join(folderPath, fileName);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.error(`File not found: ${filePath}`);
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
if (isDirectory(filePath)) {
|
||||
logger.error(`Path is a directory: ${filePath}`);
|
||||
return res.status(400).json({ error: 'Path is a directory' });
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.send(content);
|
||||
} catch (err) {
|
||||
logger.error('Error in /file GET endpoint:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/file', (req, res) => {
|
||||
try {
|
||||
const folderParam = req.query.folder || '';
|
||||
const fileName = req.query.name;
|
||||
const content = req.body.content;
|
||||
|
||||
if (!fileName) return res.status(400).json({ error: 'Missing "name" parameter' });
|
||||
if (content === undefined) return res.status(400).json({ error: 'Missing "content" in request body' });
|
||||
|
||||
const folderPath = normalizeFilePath(folderParam);
|
||||
const filePath = path.join(folderPath, fileName);
|
||||
|
||||
if (!fs.existsSync(folderPath)) {
|
||||
fs.mkdirSync(folderPath, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
res.json({ message: 'File written successfully' });
|
||||
} catch (err) {
|
||||
logger.error('Error in /file POST endpoint:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`File manager API listening at http://localhost:${PORT}`);
|
||||
});
|
||||
1068
src/backend/config_editor/old_file_viewer.jsx
Normal file
1068
src/backend/config_editor/old_file_viewer.jsx
Normal file
File diff suppressed because it is too large
Load Diff
380
src/backend/config_editor/old_homeview.jsx
Normal file
380
src/backend/config_editor/old_homeview.jsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Paper,
|
||||
Text,
|
||||
Group,
|
||||
Button,
|
||||
ActionIcon,
|
||||
ScrollArea,
|
||||
TextInput,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
Loader
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
Star,
|
||||
Folder,
|
||||
File,
|
||||
Trash2,
|
||||
Plus,
|
||||
History,
|
||||
Bookmark,
|
||||
Folders
|
||||
} from 'lucide-react';
|
||||
import { StarHoverableIcon } from './FileViewer.jsx';
|
||||
|
||||
function compareServers(a, b) {
|
||||
if (!a && !b) return true;
|
||||
if (!a || !b) return false;
|
||||
if (a.isLocal && b.isLocal) return true;
|
||||
return a.name === b.name && a.ip === b.ip && a.port === b.port && a.user === b.user;
|
||||
}
|
||||
|
||||
export function HomeView({ onFileSelect, recentFiles, starredFiles, setStarredFiles, folderShortcuts, setFolderShortcuts, setFolder, setActiveTab, handleRemoveRecent, onSSHConnect, currentServer, isSSHConnecting }) {
|
||||
const [newFolderPath, setNewFolderPath] = useState('');
|
||||
const [activeSection, setActiveSection] = useState('recent');
|
||||
|
||||
const handleStarFile = (file) => {
|
||||
const isStarred = starredFiles.some(f => f.path === file.path);
|
||||
if (isStarred) {
|
||||
setStarredFiles(starredFiles.filter(f => f.path !== file.path));
|
||||
} else {
|
||||
setStarredFiles([...starredFiles, file]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveStarred = (file) => {
|
||||
setStarredFiles(starredFiles.filter(f => f.path !== file.path));
|
||||
};
|
||||
|
||||
const handleRemoveFolder = (folder) => {
|
||||
setFolderShortcuts(folderShortcuts.filter(f => f.path !== folder.path));
|
||||
};
|
||||
|
||||
const handleAddFolder = () => {
|
||||
if (!newFolderPath) return;
|
||||
setFolderShortcuts([...folderShortcuts, { path: newFolderPath, name: newFolderPath.split('/').pop(), server: currentServer }]);
|
||||
setNewFolderPath('');
|
||||
};
|
||||
|
||||
const getServerSpecificData = (data) => {
|
||||
if (!currentServer) return [];
|
||||
return data.filter(item => compareServers(item.server, currentServer));
|
||||
};
|
||||
|
||||
const serverRecentFiles = getServerSpecificData(recentFiles);
|
||||
const serverStarredFiles = getServerSpecificData(starredFiles);
|
||||
const serverFolderShortcuts = getServerSpecificData(folderShortcuts);
|
||||
|
||||
const handleFileClick = async (file) => {
|
||||
if (file.server && !file.server.isLocal) {
|
||||
if (onSSHConnect && (!currentServer || !compareServers(currentServer, file.server))) {
|
||||
const connected = await onSSHConnect(file.server);
|
||||
if (!connected) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const pathParts = file.path.split('/').filter(Boolean);
|
||||
const fileName = pathParts.pop() || '';
|
||||
const folderPath = '/' + pathParts.join('/');
|
||||
onFileSelect(fileName, folderPath, file.server, file.path);
|
||||
} else {
|
||||
let parentFolder;
|
||||
if (navigator.platform.includes('Win') && file.path.includes(':')) {
|
||||
const lastSlashIndex = file.path.lastIndexOf('/');
|
||||
if (lastSlashIndex === -1) {
|
||||
const driveLetter = file.path.substring(0, file.path.indexOf(':') + 1);
|
||||
parentFolder = driveLetter + '/';
|
||||
} else {
|
||||
parentFolder = file.path.substring(0, lastSlashIndex + 1);
|
||||
}
|
||||
} else {
|
||||
const lastSlashIndex = file.path.lastIndexOf('/');
|
||||
parentFolder = lastSlashIndex === -1 ? '/' : file.path.substring(0, lastSlashIndex + 1);
|
||||
}
|
||||
onFileSelect(file.name, parentFolder);
|
||||
}
|
||||
};
|
||||
|
||||
const FileItem = ({ file, onStar, onRemove, showRemove }) => {
|
||||
const parentFolder = file.path.substring(0, file.path.lastIndexOf('/')) || '/';
|
||||
const isSSHFile = file.server;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="xs"
|
||||
style={{
|
||||
backgroundColor: '#36414C',
|
||||
border: '1px solid #4A5568',
|
||||
cursor: 'pointer',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
transition: 'background 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingRight: 0,
|
||||
}}
|
||||
onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'}
|
||||
onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'}
|
||||
onClick={() => handleFileClick(file)}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
maxWidth: 'calc(100% - 40px)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<File size={16} color={isSSHFile ? "#4299E1" : "#A0AEC0"} style={{ userSelect: 'none', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0, marginLeft: 8 }}>
|
||||
<Text size="sm" color="white" style={{ lineHeight: 1.2, wordBreak: 'break-word', whiteSpace: 'normal', userSelect: 'none', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text size="xs" color="dimmed" style={{ lineHeight: 1.2, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.path}</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingLeft: 4
|
||||
}}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="yellow"
|
||||
style={{ borderRadius: '50%', marginLeft: 0, background: 'none', width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onStar(file);
|
||||
}}
|
||||
>
|
||||
{starredFiles.some(f => f.path === file.path) ? (
|
||||
<Star size={16} fill="currentColor" />
|
||||
) : (
|
||||
<StarHoverableIcon size={16} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
{showRemove && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
style={{ borderRadius: '50%', marginLeft: 0, background: 'none', width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onRemove(file);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const FolderItem = ({ folder, onRemove }) => (
|
||||
<Paper
|
||||
p="xs"
|
||||
style={{
|
||||
backgroundColor: '#36414C',
|
||||
border: '1px solid #4A5568',
|
||||
cursor: 'pointer',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'}
|
||||
onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'}
|
||||
onClick={() => {
|
||||
setFolder(folder.path);
|
||||
}}
|
||||
>
|
||||
<Group spacing={4} align="flex-start" noWrap>
|
||||
<Folder size={16} color="#4299E1" style={{ marginTop: 2 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" color="white" style={{ lineHeight: 1.2, wordBreak: 'break-word', whiteSpace: 'normal', userSelect: 'none', overflow: 'hidden', textOverflow: 'ellipsis' }}>{folder.name}</Text>
|
||||
<Text size="xs" color="dimmed" style={{ lineHeight: 1.2, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{folder.path}</Text>
|
||||
</div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
style={{ borderRadius: '50%', marginLeft: 0, background: 'none', width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onRemove(folder);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
h="100%"
|
||||
spacing="md"
|
||||
p="md"
|
||||
style={{
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{!currentServer && (
|
||||
<Paper p="md" style={{ backgroundColor: '#2F3740', border: '1px solid #4A5568' }}>
|
||||
<Text color="dimmed" align="center" size="lg">
|
||||
Please select a server from the sidebar to view your files
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
{currentServer && (
|
||||
<>
|
||||
<Paper p="xs" style={{ backgroundColor: '#2F3740', border: '1px solid #4A5568' }}>
|
||||
<Text color="white" size="sm" weight={500}>
|
||||
Connected to: {currentServer.name} ({currentServer.user}@{currentServer.ip}:{currentServer.port})
|
||||
</Text>
|
||||
</Paper>
|
||||
{isSSHConnecting ? (
|
||||
<Paper p="md" style={{ backgroundColor: '#2F3740', border: '1px solid #4A5568' }}>
|
||||
<Group justify="center" spacing="md">
|
||||
<Loader size="sm" color="#4299E1" />
|
||||
<Text color="dimmed" align="center" size="lg">
|
||||
Connecting to SSH server...
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
<Group spacing="md" mb="md">
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
leftSection={<History size={18} />}
|
||||
onClick={() => setActiveSection('recent')}
|
||||
style={{ backgroundColor: activeSection === 'recent' ? '#36414C' : '#4A5568', color: 'white', borderColor: '#4A5568', transition: 'background 0.2s' }}
|
||||
onMouseOver={e => e.currentTarget.style.backgroundColor = activeSection === 'recent' ? '#36414C' : '#36414C'}
|
||||
onMouseOut={e => e.currentTarget.style.backgroundColor = activeSection === 'recent' ? '#36414C' : '#4A5568'}
|
||||
>
|
||||
Recent
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="yellow"
|
||||
leftSection={<Bookmark size={18} />}
|
||||
onClick={() => setActiveSection('starred')}
|
||||
style={{ backgroundColor: activeSection === 'starred' ? '#36414C' : '#4A5568', color: 'white', borderColor: '#4A5568', transition: 'background 0.2s' }}
|
||||
onMouseOver={e => e.currentTarget.style.backgroundColor = activeSection === 'starred' ? '#36414C' : '#36414C'}
|
||||
onMouseOut={e => e.currentTarget.style.backgroundColor = activeSection === 'starred' ? '#36414C' : '#4A5568'}
|
||||
>
|
||||
Starred
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="teal"
|
||||
leftSection={<Folders size={18} />}
|
||||
onClick={() => setActiveSection('folders')}
|
||||
style={{ backgroundColor: activeSection === 'folders' ? '#36414C' : '#4A5568', color: 'white', borderColor: '#4A5568', transition: 'background 0.2s' }}
|
||||
onMouseOver={e => e.currentTarget.style.backgroundColor = activeSection === 'folders' ? '#36414C' : '#36414C'}
|
||||
onMouseOut={e => e.currentTarget.style.backgroundColor = activeSection === 'folders' ? '#36414C' : '#4A5568'}
|
||||
>
|
||||
Folder Shortcuts
|
||||
</Button>
|
||||
</Group>
|
||||
{activeSection === 'recent' && (
|
||||
<div style={{ height: 'calc(100vh - 200px)', overflow: 'hidden' }}>
|
||||
<SimpleGrid cols={3} spacing="md">
|
||||
{serverRecentFiles.length === 0 ? (
|
||||
<Text color="dimmed" align="center" style={{ gridColumn: '1 / -1', padding: '2rem' }}>No recent files</Text>
|
||||
) : (
|
||||
serverRecentFiles.map(file => (
|
||||
<FileItem
|
||||
key={file.path}
|
||||
file={file}
|
||||
onStar={handleStarFile}
|
||||
onRemove={handleRemoveRecent}
|
||||
showRemove={true}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'starred' && (
|
||||
<div style={{ height: 'calc(100vh - 200px)', overflow: 'hidden' }}>
|
||||
<SimpleGrid cols={3} spacing="md">
|
||||
{serverStarredFiles.length === 0 ? (
|
||||
<Text color="dimmed" align="center" style={{ gridColumn: '1 / -1', padding: '2rem' }}>No starred files</Text>
|
||||
) : (
|
||||
serverStarredFiles.map(file => (
|
||||
<FileItem
|
||||
key={file.path}
|
||||
file={file}
|
||||
onStar={handleStarFile}
|
||||
showRemove={false}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'folders' && (
|
||||
<Stack spacing="md">
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Enter folder path"
|
||||
value={newFolderPath}
|
||||
onChange={(e) => setNewFolderPath(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
styles={{
|
||||
input: {
|
||||
backgroundColor: '#36414C',
|
||||
borderColor: '#4A5568',
|
||||
color: 'white',
|
||||
'&::placeholder': {
|
||||
color: '#A0AEC0'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={handleAddFolder}
|
||||
variant="filled"
|
||||
color="blue"
|
||||
style={{
|
||||
backgroundColor: '#36414C',
|
||||
border: '1px solid #4A5568',
|
||||
'&:hover': {
|
||||
backgroundColor: '#4A5568'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Group>
|
||||
<Divider color="#4A5568" />
|
||||
<div style={{ height: 'calc(100vh - 280px)', overflow: 'hidden' }}>
|
||||
<SimpleGrid cols={3} spacing="md">
|
||||
{serverFolderShortcuts.length === 0 ? (
|
||||
<Text color="dimmed" align="center" style={{ gridColumn: '1 / -1', padding: '2rem' }}>No folder shortcuts</Text>
|
||||
) : (
|
||||
serverFolderShortcuts.map(folder => (
|
||||
<FolderItem
|
||||
key={folder.path}
|
||||
folder={folder}
|
||||
onRemove={handleRemoveFolder}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
358
src/backend/config_editor/old_ssh.js
Normal file
358
src/backend/config_editor/old_ssh.js
Normal file
@@ -0,0 +1,358 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const cors = require("cors");
|
||||
const bcrypt = require("bcrypt");
|
||||
const SSHClient = require("ssh2").Client;
|
||||
|
||||
const app = express();
|
||||
const PORT = 8083;
|
||||
|
||||
let sshConnection = null;
|
||||
let isConnected = false;
|
||||
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
const getReadableTimestamp = () => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
timeZone: 'UTC',
|
||||
}).format(new Date());
|
||||
};
|
||||
|
||||
const logger = {
|
||||
info: (...args) => console.log(`💻 | 🔧 [${getReadableTimestamp()}] INFO:`, ...args),
|
||||
error: (...args) => console.error(`💻 | ❌ [${getReadableTimestamp()}] ERROR:`, ...args),
|
||||
warn: (...args) => console.warn(`💻 | ⚠️ [${getReadableTimestamp()}] WARN:`, ...args),
|
||||
debug: (...args) => console.debug(`💻 | 🔍 [${getReadableTimestamp()}] DEBUG:`, ...args)
|
||||
};
|
||||
|
||||
const closeSSHConnection = () => {
|
||||
if (sshConnection && isConnected) {
|
||||
try {
|
||||
sshConnection.end();
|
||||
sshConnection = null;
|
||||
isConnected = false;
|
||||
} catch (err) {
|
||||
logger.error('Error closing SSH connection:', err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const executeSSHCommand = (command) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!sshConnection || !isConnected) {
|
||||
return reject(new Error('SSH connection not established'));
|
||||
}
|
||||
|
||||
sshConnection.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('Error executing SSH command:', err.message);
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
let data = '';
|
||||
let error = '';
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (chunk) => {
|
||||
error += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH command failed with code ${code}:`, error);
|
||||
return reject(new Error(`Command failed with code ${code}: ${error}`));
|
||||
}
|
||||
resolve(data.trim());
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
app.post('/sshConnect', async (req, res) => {
|
||||
try {
|
||||
const hostConfig = req.body;
|
||||
|
||||
if (!hostConfig || !hostConfig.ip || !hostConfig.user) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Missing required host configuration (ip, user)'
|
||||
});
|
||||
}
|
||||
|
||||
closeSSHConnection();
|
||||
|
||||
sshConnection = new SSHClient();
|
||||
|
||||
const connectionConfig = {
|
||||
host: hostConfig.ip,
|
||||
port: hostConfig.port || 22,
|
||||
username: hostConfig.user,
|
||||
readyTimeout: 20000,
|
||||
keepaliveInterval: 10000,
|
||||
keepaliveCountMax: 3
|
||||
};
|
||||
|
||||
if (hostConfig.sshKey) {
|
||||
connectionConfig.privateKey = hostConfig.sshKey;
|
||||
} else if (hostConfig.password) {
|
||||
connectionConfig.password = hostConfig.password;
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Either password or SSH key must be provided'
|
||||
});
|
||||
}
|
||||
|
||||
sshConnection.on('ready', () => {
|
||||
isConnected = true;
|
||||
});
|
||||
|
||||
sshConnection.on('error', (err) => {
|
||||
logger.error('SSH connection error:', err.message);
|
||||
isConnected = false;
|
||||
});
|
||||
|
||||
sshConnection.on('close', () => {
|
||||
isConnected = false;
|
||||
});
|
||||
|
||||
sshConnection.on('end', () => {
|
||||
isConnected = false;
|
||||
});
|
||||
|
||||
sshConnection.connect(connectionConfig);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('SSH connection timeout'));
|
||||
}, 20000);
|
||||
|
||||
sshConnection.once('ready', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
|
||||
sshConnection.once('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
status: 'success',
|
||||
message: 'SSH connection established successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('SSH connection failed:', error.message);
|
||||
closeSSHConnection();
|
||||
|
||||
return res.status(500).json({
|
||||
status: 'error',
|
||||
message: `SSH connection failed: ${error.message}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/listFiles', async (req, res) => {
|
||||
try {
|
||||
const { path = '/' } = req.query;
|
||||
|
||||
if (!sshConnection || !isConnected) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'SSH connection not established. Please connect first.'
|
||||
});
|
||||
}
|
||||
|
||||
const lsCommand = `ls -la "${path}"`;
|
||||
const result = await executeSSHCommand(lsCommand);
|
||||
|
||||
const lines = result.split('\n').filter(line => line.trim());
|
||||
const files = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const parts = line.split(/\s+/);
|
||||
|
||||
if (parts.length >= 9) {
|
||||
const permissions = parts[0];
|
||||
const links = parseInt(parts[1]) || 0;
|
||||
const owner = parts[2];
|
||||
const group = parts[3];
|
||||
const size = parseInt(parts[4]) || 0;
|
||||
const month = parts[5];
|
||||
const day = parseInt(parts[6]) || 0;
|
||||
const timeOrYear = parts[7];
|
||||
const name = parts.slice(8).join(' ');
|
||||
|
||||
const isDirectory = permissions.startsWith('d');
|
||||
const isLink = permissions.startsWith('l');
|
||||
|
||||
files.push({
|
||||
name: name,
|
||||
type: isDirectory ? 'directory' : (isLink ? 'link' : 'file'),
|
||||
size: size,
|
||||
permissions: permissions,
|
||||
owner: owner,
|
||||
group: group,
|
||||
modified: `${month} ${day} ${timeOrYear}`,
|
||||
isDirectory: isDirectory,
|
||||
isLink: isLink
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
status: 'success',
|
||||
path: path,
|
||||
files: files,
|
||||
totalCount: files.length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error listing files:', error.message);
|
||||
|
||||
return res.status(500).json({
|
||||
status: 'error',
|
||||
message: `Failed to list files: ${error.message}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/sshDisconnect', async (req, res) => {
|
||||
try {
|
||||
closeSSHConnection();
|
||||
|
||||
return res.status(200).json({
|
||||
status: 'success',
|
||||
message: 'SSH connection disconnected successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error disconnecting SSH:', error.message);
|
||||
|
||||
return res.status(500).json({
|
||||
status: 'error',
|
||||
message: `Failed to disconnect: ${error.message}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/sshStatus', async (req, res) => {
|
||||
return res.status(200).json({
|
||||
status: 'success',
|
||||
connected: isConnected,
|
||||
hasConnection: !!sshConnection
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/readFile', async (req, res) => {
|
||||
try {
|
||||
const { path: filePath } = req.query;
|
||||
|
||||
if (!sshConnection || !isConnected) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'SSH connection not established. Please connect first.'
|
||||
});
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'File path is required'
|
||||
});
|
||||
}
|
||||
|
||||
const catCommand = `cat "${filePath}"`;
|
||||
const result = await executeSSHCommand(catCommand);
|
||||
|
||||
return res.status(200).json({
|
||||
status: 'success',
|
||||
content: result,
|
||||
path: filePath
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error reading file:', error.message);
|
||||
|
||||
return res.status(500).json({
|
||||
status: 'error',
|
||||
message: `Failed to read file: ${error.message}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/writeFile', async (req, res) => {
|
||||
try {
|
||||
const { path: filePath, content } = req.body;
|
||||
|
||||
if (!sshConnection || !isConnected) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'SSH connection not established. Please connect first.'
|
||||
});
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'File path is required'
|
||||
});
|
||||
}
|
||||
|
||||
if (content === undefined) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'File content is required'
|
||||
});
|
||||
}
|
||||
|
||||
const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const echoCommand = `echo '${content.replace(/'/g, "'\"'\"'")}' > "${tempFile}"`;
|
||||
await executeSSHCommand(echoCommand);
|
||||
|
||||
const mvCommand = `mv "${tempFile}" "${filePath}"`;
|
||||
await executeSSHCommand(mvCommand);
|
||||
|
||||
return res.status(200).json({
|
||||
status: 'success',
|
||||
message: 'File written successfully',
|
||||
path: filePath
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error writing file:', error.message);
|
||||
|
||||
return res.status(500).json({
|
||||
status: 'error',
|
||||
message: `Failed to write file: ${error.message}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
closeSSHConnection();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
closeSSHConnection();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`SSH API listening at http://localhost:${PORT}`);
|
||||
});
|
||||
148
src/backend/config_editor/old_tablist.jsx
Normal file
148
src/backend/config_editor/old_tablist.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@mantine/core';
|
||||
import { Home } from 'lucide-react';
|
||||
|
||||
export function TabList({ tabs, activeTab, setActiveTab, closeTab, onHomeClick }) {
|
||||
return (
|
||||
<div style={{
|
||||
height: '40px',
|
||||
backgroundColor: '#2F3740',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
margin: '0 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 4px',
|
||||
overflowX: 'auto',
|
||||
width: '100%',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: '#4A5568 #2F3740'
|
||||
}}>
|
||||
<style>
|
||||
{`
|
||||
div::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
div::-webkit-scrollbar-track {
|
||||
background: #2F3740;
|
||||
}
|
||||
div::-webkit-scrollbar-thumb {
|
||||
background: #4A5568;
|
||||
border-radius: 3px;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: activeTab === 'home' ? '#36414C' : '#2F3740',
|
||||
borderRadius: '4px',
|
||||
height: '32px',
|
||||
minWidth: '48px',
|
||||
marginRight: '4px',
|
||||
border: '1px solid #4A5568',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={onHomeClick}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
style={{
|
||||
height: '100%',
|
||||
padding: '0 8px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
minWidth: '48px',
|
||||
borderRadius: 0,
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'}
|
||||
onMouseOut={e => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<Home size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
{tabs.map((tab, i) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: isActive ? '#36414C' : '#2F3740',
|
||||
borderRadius: '4px',
|
||||
height: '32px',
|
||||
minWidth: '120px',
|
||||
maxWidth: '200px',
|
||||
marginRight: '4px',
|
||||
border: '1px solid #4A5568',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
padding: '0 8px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
textAlign: 'left',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
borderRadius: 0,
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'}
|
||||
onMouseOut={e => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
{tab.name}
|
||||
</Button>
|
||||
<div style={{
|
||||
width: '1px',
|
||||
height: '16px',
|
||||
backgroundColor: '#4A5568',
|
||||
margin: '0 4px'
|
||||
}} />
|
||||
<Button
|
||||
onClick={() => closeTab(tab.id)}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
style={{
|
||||
height: '100%',
|
||||
padding: '0 8px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
minWidth: '32px',
|
||||
borderRadius: 0,
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'}
|
||||
onMouseOut={e => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/backend/databasae/routes/config_editor.ts
Normal file
1
src/backend/databasae/routes/config_editor.ts
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -3,9 +3,19 @@ import bodyParser from 'body-parser';
|
||||
import userRoutes from './routes/users.js';
|
||||
import sshRoutes from './routes/ssh.js';
|
||||
import sshTunnelRoutes from './routes/ssh_tunnel.js';
|
||||
import configEditorRoutes from './routes/config_editor.js';
|
||||
import chalk from 'chalk';
|
||||
import cors from 'cors';
|
||||
|
||||
// CORS for local dev
|
||||
const app = express();
|
||||
app.use(cors({
|
||||
origin: 'http://localhost:5173',
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
|
||||
// Custom logger (adapted from starter.ts, with a database icon)
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
@@ -33,13 +43,6 @@ const logger = {
|
||||
}
|
||||
};
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors({
|
||||
origin: 'http://localhost:5173',
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
@@ -49,6 +52,7 @@ app.get('/health', (req, res) => {
|
||||
app.use('/users', userRoutes);
|
||||
app.use('/ssh', sshRoutes);
|
||||
app.use('/ssh_tunnel', sshTunnelRoutes);
|
||||
app.use('/config_editor', configEditorRoutes);
|
||||
|
||||
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
@@ -37,7 +37,6 @@ if (!fs.existsSync(dbDir)) {
|
||||
}
|
||||
|
||||
const sqlite = new Database('./db/data/db.sqlite');
|
||||
logger.success('Database connection established');
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
@@ -62,6 +61,26 @@ CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
key_type TEXT,
|
||||
save_auth_method INTEGER,
|
||||
is_pinned INTEGER,
|
||||
default_path TEXT,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS config_ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
auth_method TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
save_auth_method INTEGER,
|
||||
is_pinned INTEGER,
|
||||
default_path TEXT,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ssh_tunnel_data (
|
||||
@@ -98,6 +117,18 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS config_editor_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT,
|
||||
path TEXT NOT NULL,
|
||||
server TEXT,
|
||||
last_opened TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
`);
|
||||
try {
|
||||
sqlite.prepare('SELECT is_admin FROM users LIMIT 1').get();
|
||||
@@ -23,6 +23,7 @@ export const sshData = sqliteTable('ssh_data', {
|
||||
keyType: text('key_type'), // Type of SSH key (RSA, ED25519, etc.)
|
||||
saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }),
|
||||
isPinned: integer('is_pinned', { mode: 'boolean' }),
|
||||
defaultPath: text('default_path'), // Default path for SSH connection
|
||||
});
|
||||
|
||||
export const sshTunnelData = sqliteTable('ssh_tunnel_data', {
|
||||
@@ -58,4 +59,35 @@ export const sshTunnelData = sqliteTable('ssh_tunnel_data', {
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
});
|
||||
|
||||
export const configEditorData = sqliteTable('config_editor_data', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
type: text('type').notNull(), // 'recent' | 'pinned' | 'shortcut'
|
||||
name: text('name'),
|
||||
path: text('path').notNull(),
|
||||
server: text('server', { length: 2048 }), // JSON stringified server info (if SSH)
|
||||
lastOpened: text('last_opened'), // ISO string (for recent)
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const configSshData = sqliteTable('config_ssh_data', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name'),
|
||||
folder: text('folder'),
|
||||
tags: text('tags'),
|
||||
ip: text('ip').notNull(),
|
||||
port: integer('port').notNull(),
|
||||
username: text('username'),
|
||||
password: text('password'),
|
||||
authMethod: text('auth_method'),
|
||||
key: text('key', { length: 8192 }),
|
||||
keyPassword: text('key_password'),
|
||||
keyType: text('key_type'),
|
||||
saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }),
|
||||
isPinned: integer('is_pinned', { mode: 'boolean' }),
|
||||
defaultPath: text('default_path'),
|
||||
});
|
||||
317
src/backend/database/routes/config_editor.ts
Normal file
317
src/backend/database/routes/config_editor.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import express from 'express';
|
||||
import { db } from '../db/index.js';
|
||||
import { configEditorData, configSshData } from '../db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// --- JWT Auth Middleware ---
|
||||
interface JWTPayload {
|
||||
userId: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||
(req as any).userId = payload.userId;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config Data Endpoints (DB-backed, per user) ---
|
||||
router.get('/recent', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
try {
|
||||
const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'recent')));
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch recent files' });
|
||||
}
|
||||
});
|
||||
router.post('/recent', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path: filePath, server, lastOpened } = req.body;
|
||||
if (!filePath) return res.status(400).json({ error: 'Missing path' });
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
await db.insert(configEditorData).values({
|
||||
userId,
|
||||
type: 'recent',
|
||||
name,
|
||||
path: filePath,
|
||||
server: server ? JSON.stringify(server) : null,
|
||||
lastOpened: lastOpened || now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
res.json({ message: 'Added to recent' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to add to recent' });
|
||||
}
|
||||
});
|
||||
router.get('/pinned', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
try {
|
||||
const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'pinned')));
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch pinned files' });
|
||||
}
|
||||
});
|
||||
router.post('/pinned', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path: filePath, server } = req.body;
|
||||
if (!filePath) return res.status(400).json({ error: 'Missing path' });
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
await db.insert(configEditorData).values({
|
||||
userId,
|
||||
type: 'pinned',
|
||||
name,
|
||||
path: filePath,
|
||||
server: server ? JSON.stringify(server) : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
res.json({ message: 'Added to pinned' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to add to pinned' });
|
||||
}
|
||||
});
|
||||
router.get('/shortcuts', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
try {
|
||||
const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut')));
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch shortcuts' });
|
||||
}
|
||||
});
|
||||
router.post('/shortcuts', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, path: folderPath, server } = req.body;
|
||||
if (!folderPath) return res.status(400).json({ error: 'Missing path' });
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
await db.insert(configEditorData).values({
|
||||
userId,
|
||||
type: 'shortcut',
|
||||
name,
|
||||
path: folderPath,
|
||||
server: server ? JSON.stringify(server) : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
res.json({ message: 'Added to shortcuts' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to add to shortcuts' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /config_editor/shortcuts
|
||||
router.delete('/shortcuts', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { path } = req.body;
|
||||
if (!path) return res.status(400).json({ error: 'Missing path' });
|
||||
try {
|
||||
await db.delete(configEditorData)
|
||||
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut'), eq(configEditorData.path, path)));
|
||||
res.json({ message: 'Shortcut removed' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to remove shortcut' });
|
||||
}
|
||||
});
|
||||
// POST /config_editor/shortcuts/delete (for compatibility)
|
||||
router.post('/shortcuts/delete', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { path } = req.body;
|
||||
if (!path) return res.status(400).json({ error: 'Missing path' });
|
||||
try {
|
||||
await db.delete(configEditorData)
|
||||
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut'), eq(configEditorData.path, path)));
|
||||
res.json({ message: 'Shortcut removed' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to remove shortcut' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Local Default Path Endpoints ---
|
||||
// GET /config_editor/local_default_path
|
||||
router.get('/local_default_path', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
try {
|
||||
const row = await db.select().from(configEditorData)
|
||||
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'local_default_path')))
|
||||
.then(rows => rows[0]);
|
||||
res.json({ defaultPath: row?.path || '/' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch local default path' });
|
||||
}
|
||||
});
|
||||
// POST /config_editor/local_default_path
|
||||
router.post('/local_default_path', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { defaultPath } = req.body;
|
||||
if (!defaultPath) return res.status(400).json({ error: 'Missing defaultPath' });
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
// Upsert: delete old, insert new
|
||||
await db.delete(configEditorData)
|
||||
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'local_default_path')));
|
||||
await db.insert(configEditorData).values({
|
||||
userId,
|
||||
type: 'local_default_path',
|
||||
name: 'Local Files',
|
||||
path: defaultPath,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
res.json({ message: 'Local default path saved' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to save local default path' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- SSH Connection CRUD for Config Editor ---
|
||||
// GET /config_editor/ssh/host
|
||||
router.get('/ssh/host', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
}
|
||||
try {
|
||||
const data = await db.select().from(configSshData).where(eq(configSshData.userId, userId));
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch SSH hosts' });
|
||||
}
|
||||
});
|
||||
// POST /config_editor/ssh/host
|
||||
router.post('/ssh/host', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { name, folder, tags, ip, port, username, password, sshKey, keyPassword, keyType, isPinned, defaultPath, authMethod } = req.body;
|
||||
if (!userId || !ip || !port) {
|
||||
return res.status(400).json({ error: 'Invalid SSH data' });
|
||||
}
|
||||
const sshDataObj: any = {
|
||||
userId,
|
||||
name,
|
||||
folder,
|
||||
tags: Array.isArray(tags) ? tags.join(',') : tags,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
authMethod,
|
||||
isPinned: isPinned ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
};
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else if (authMethod === 'key') {
|
||||
sshDataObj.key = sshKey;
|
||||
sshDataObj.keyPassword = keyPassword;
|
||||
sshDataObj.keyType = keyType;
|
||||
sshDataObj.password = null;
|
||||
}
|
||||
try {
|
||||
await db.insert(configSshData).values(sshDataObj);
|
||||
res.json({ message: 'SSH host created' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to create SSH host' });
|
||||
}
|
||||
});
|
||||
// PUT /config_editor/ssh/host/:id
|
||||
router.put('/ssh/host/:id', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id } = req.params;
|
||||
const { name, folder, tags, ip, port, username, password, sshKey, keyPassword, keyType, isPinned, defaultPath, authMethod } = req.body;
|
||||
if (!userId || !ip || !port || !id) {
|
||||
return res.status(400).json({ error: 'Invalid SSH data' });
|
||||
}
|
||||
const sshDataObj: any = {
|
||||
name,
|
||||
folder,
|
||||
tags: Array.isArray(tags) ? tags.join(',') : tags,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
authMethod,
|
||||
isPinned: isPinned ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
};
|
||||
if (authMethod === 'password') {
|
||||
sshDataObj.password = password;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else if (authMethod === 'key') {
|
||||
sshDataObj.key = sshKey;
|
||||
sshDataObj.keyPassword = keyPassword;
|
||||
sshDataObj.keyType = keyType;
|
||||
sshDataObj.password = null;
|
||||
}
|
||||
try {
|
||||
await db.update(configSshData)
|
||||
.set(sshDataObj)
|
||||
.where(and(eq(configSshData.id, Number(id)), eq(configSshData.userId, userId)));
|
||||
res.json({ message: 'SSH host updated' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to update SSH host' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- SSH Connection CRUD (reuse /ssh/host endpoints, or proxy) ---
|
||||
router.delete('/ssh/host/:id', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id } = req.params;
|
||||
if (!userId || !id) {
|
||||
return res.status(400).json({ error: 'Invalid userId or id' });
|
||||
}
|
||||
try {
|
||||
await db.delete(configSshData)
|
||||
.where(and(eq(configSshData.id, Number(id)), eq(configSshData.userId, userId)));
|
||||
res.json({ message: 'SSH host deleted' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to delete SSH host' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /config_editor/ssh/folders
|
||||
router.get('/ssh/folders', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'Invalid userId' });
|
||||
}
|
||||
try {
|
||||
const data = await db
|
||||
.select({ folder: configSshData.folder })
|
||||
.from(configSshData)
|
||||
.where(eq(configSshData.userId, userId));
|
||||
const folderCounts: Record<string, number> = {};
|
||||
data.forEach(d => {
|
||||
if (d.folder && d.folder.trim() !== '') {
|
||||
folderCounts[d.folder] = (folderCounts[d.folder] || 0) + 1;
|
||||
}
|
||||
});
|
||||
const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0);
|
||||
res.json(folders);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch SSH folders' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -69,7 +69,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
// Route: Create SSH data (requires JWT)
|
||||
// POST /ssh/host
|
||||
router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned } = req.body;
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned, defaultPath } = req.body;
|
||||
const userId = (req as any).userId;
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) {
|
||||
logger.warn('Invalid SSH data input');
|
||||
@@ -87,6 +87,7 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
authMethod,
|
||||
saveAuthMethod: saveAuthMethod ? 1 : 0,
|
||||
isPinned: isPinned ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
};
|
||||
|
||||
if (saveAuthMethod) {
|
||||
@@ -120,7 +121,7 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||
// Route: Update SSH data (requires JWT)
|
||||
// PUT /ssh/host/:id
|
||||
router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned } = req.body;
|
||||
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned, defaultPath } = req.body;
|
||||
const { id } = req.params;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
@@ -139,6 +140,7 @@ router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) =>
|
||||
authMethod,
|
||||
saveAuthMethod: saveAuthMethod ? 1 : 0,
|
||||
isPinned: isPinned ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
};
|
||||
|
||||
if (saveAuthMethod) {
|
||||
@@ -1082,7 +1082,7 @@ app.delete('/tunnel/:name', (req, res) => {
|
||||
});
|
||||
|
||||
// Start the server
|
||||
const PORT = process.env.SSH_TUNNEL_PORT || 8083;
|
||||
const PORT = 8083;
|
||||
app.listen(PORT, () => {
|
||||
// Initialize auto-start tunnels after a short delay
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// npx tsc -p tsconfig.node.json
|
||||
// node ./dist/backend/starter.js
|
||||
|
||||
import './db/database.js'
|
||||
import './database/database.js'
|
||||
import './ssh/ssh.js';
|
||||
import './ssh_tunnel/ssh_tunnel.js';
|
||||
import './config_editor/config_editor.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const fixedIconSymbol = '🚀';
|
||||
|
||||
Reference in New Issue
Block a user