Format code

This commit is contained in:
LukeGus
2025-08-18 00:13:21 -05:00
parent fa64e98ef9
commit c1d06028c3
31 changed files with 1791 additions and 1780 deletions

View File

@@ -49,7 +49,6 @@ function AppContent() {
setIsAuthenticated(false); setIsAuthenticated(false);
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
// Clear invalid JWT
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
}) })
.finally(() => setAuthLoading(false)); .finally(() => setAuthLoading(false));
@@ -85,27 +84,19 @@ function AppContent() {
setUsername(authData.username) setUsername(authData.username)
} }
// Determine what to show based on current tab
const currentTabData = tabs.find(tab => tab.id === currentTab); const currentTabData = tabs.find(tab => tab.id === currentTab);
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager'; const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager';
const showHome = currentTabData?.type === 'home'; const showHome = currentTabData?.type === 'home';
const showSshManager = currentTabData?.type === 'ssh_manager'; const showSshManager = currentTabData?.type === 'ssh_manager';
const showAdmin = currentTabData?.type === 'admin'; const showAdmin = currentTabData?.type === 'admin';
console.log('Current tab:', currentTab);
console.log('Current tab data:', currentTabData);
console.log('Show terminal view:', showTerminalView);
console.log('All tabs:', tabs);
return ( return (
<div> <div>
{/* Enhanced background overlay - detailed pattern when not authenticated */}
{!isAuthenticated && !authLoading && ( {!isAuthenticated && !authLoading && (
<div <div
className="fixed inset-0 bg-gradient-to-br from-background via-muted/20 to-background z-[9999]" className="fixed inset-0 bg-gradient-to-br from-background via-muted/20 to-background z-[9999]"
aria-hidden="true" aria-hidden="true"
> >
{/* Diagonal stripes pattern */}
<div className="absolute inset-0 opacity-20"> <div className="absolute inset-0 opacity-20">
<div className="absolute inset-0" style={{ <div className="absolute inset-0" style={{
backgroundImage: `repeating-linear-gradient( backgroundImage: `repeating-linear-gradient(
@@ -117,8 +108,7 @@ function AppContent() {
)` )`
}} /> }} />
</div> </div>
{/* Subtle grid pattern */}
<div className="absolute inset-0 opacity-10"> <div className="absolute inset-0 opacity-10">
<div className="absolute inset-0" style={{ <div className="absolute inset-0" style={{
backgroundImage: `linear-gradient(hsl(var(--border) / 0.3) 1px, transparent 1px), backgroundImage: `linear-gradient(hsl(var(--border) / 0.3) 1px, transparent 1px),
@@ -126,13 +116,11 @@ function AppContent() {
backgroundSize: '40px 40px' backgroundSize: '40px 40px'
}} /> }} />
</div> </div>
{/* Radial gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-background/60" /> <div className="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-background/60" />
</div> </div>
)} )}
{/* Show login form directly when not authenticated */}
{!isAuthenticated && !authLoading && ( {!isAuthenticated && !authLoading && (
<div className="fixed inset-0 flex items-center justify-center z-[10000]"> <div className="fixed inset-0 flex items-center justify-center z-[10000]">
<Homepage <Homepage
@@ -144,8 +132,7 @@ function AppContent() {
/> />
</div> </div>
)} )}
{/* Show sidebar layout only when authenticated */}
{isAuthenticated && ( {isAuthenticated && (
<LeftSidebar <LeftSidebar
onSelectView={handleSelectView} onSelectView={handleSelectView}
@@ -153,7 +140,6 @@ function AppContent() {
isAdmin={isAdmin} isAdmin={isAdmin}
username={username} username={username}
> >
{/* Always render TerminalView to maintain terminal persistence */}
<div <div
className="h-screen w-full" className="h-screen w-full"
style={{ style={{
@@ -167,8 +153,7 @@ function AppContent() {
> >
<AppView isTopbarOpen={isTopbarOpen} /> <AppView isTopbarOpen={isTopbarOpen} />
</div> </div>
{/* Always render Homepage to keep it mounted */}
<div <div
className="h-screen w-full" className="h-screen w-full"
style={{ style={{
@@ -189,7 +174,6 @@ function AppContent() {
/> />
</div> </div>
{/* Always render SSH Manager but toggle visibility for persistence */}
<div <div
className="h-screen w-full" className="h-screen w-full"
style={{ style={{
@@ -204,7 +188,6 @@ function AppContent() {
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} /> <HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
</div> </div>
{/* Admin Settings tab */}
<div <div
className="h-screen w-full" className="h-screen w-full"
style={{ style={{
@@ -218,8 +201,7 @@ function AppContent() {
> >
<AdminSettings isTopbarOpen={isTopbarOpen} /> <AdminSettings isTopbarOpen={isTopbarOpen} />
</div> </div>
{/* Legacy views removed; tab system controls main content */}
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/> <TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
</LeftSidebar> </LeftSidebar>
)} )}

View File

@@ -41,94 +41,340 @@ const dbPath = path.join(dataDir, 'db.sqlite');
const sqlite = new Database(dbPath); const sqlite = new Database(dbPath);
sqlite.exec(` sqlite.exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users
id TEXT PRIMARY KEY, (
username TEXT NOT NULL, id
password_hash TEXT NOT NULL, TEXT
is_admin INTEGER NOT NULL DEFAULT 0, PRIMARY
KEY,
is_oidc INTEGER NOT NULL DEFAULT 0, username
client_id TEXT NOT NULL, TEXT
client_secret TEXT NOT NULL, NOT
issuer_url TEXT NOT NULL, NULL,
authorization_url TEXT NOT NULL, password_hash
token_url TEXT NOT NULL, TEXT
redirect_uri TEXT, NOT
identifier_path TEXT NOT NULL, NULL,
name_path TEXT NOT NULL, is_admin
scopes TEXT NOT NULL INTEGER
NOT
NULL
DEFAULT
0,
is_oidc
INTEGER
NOT
NULL
DEFAULT
0,
client_id
TEXT
NOT
NULL,
client_secret
TEXT
NOT
NULL,
issuer_url
TEXT
NOT
NULL,
authorization_url
TEXT
NOT
NULL,
token_url
TEXT
NOT
NULL,
redirect_uri
TEXT,
identifier_path
TEXT
NOT
NULL,
name_path
TEXT
NOT
NULL,
scopes
TEXT
NOT
NULL
); );
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings
key TEXT PRIMARY KEY, (
value TEXT NOT NULL key
TEXT
PRIMARY
KEY,
value
TEXT
NOT
NULL
); );
CREATE TABLE IF NOT EXISTS ssh_data ( CREATE TABLE IF NOT EXISTS ssh_data
id INTEGER PRIMARY KEY AUTOINCREMENT, (
user_id TEXT NOT NULL, id
name TEXT, INTEGER
ip TEXT NOT NULL, PRIMARY
port INTEGER NOT NULL, KEY
username TEXT NOT NULL, AUTOINCREMENT,
folder TEXT, user_id
tags TEXT, TEXT
pin INTEGER NOT NULL DEFAULT 0, NOT
auth_type TEXT NOT NULL, NULL,
password TEXT, name
key TEXT, TEXT,
key_password TEXT, ip
key_type TEXT, TEXT
enable_terminal INTEGER NOT NULL DEFAULT 1, NOT
enable_tunnel INTEGER NOT NULL DEFAULT 1, NULL,
tunnel_connections TEXT, port
enable_file_manager INTEGER NOT NULL DEFAULT 1, INTEGER
default_path TEXT, NOT
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, username
FOREIGN KEY (user_id) REFERENCES users(id) TEXT
); NOT
NULL,
folder
TEXT,
tags
TEXT,
pin
INTEGER
NOT
NULL
DEFAULT
0,
auth_type
TEXT
NOT
NULL,
password
TEXT,
key
TEXT,
key_password
TEXT,
key_type
TEXT,
enable_terminal
INTEGER
NOT
NULL
DEFAULT
1,
enable_tunnel
INTEGER
NOT
NULL
DEFAULT
1,
tunnel_connections
TEXT,
enable_file_manager
INTEGER
NOT
NULL
DEFAULT
1,
default_path
TEXT,
created_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
updated_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
)
);
CREATE TABLE IF NOT EXISTS file_manager_recent ( CREATE TABLE IF NOT EXISTS file_manager_recent
id INTEGER PRIMARY KEY AUTOINCREMENT, (
user_id TEXT NOT NULL, id
host_id INTEGER NOT NULL, INTEGER
name TEXT NOT NULL, PRIMARY
path TEXT NOT NULL, KEY
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, AUTOINCREMENT,
FOREIGN KEY (user_id) REFERENCES users(id), user_id
FOREIGN KEY (host_id) REFERENCES ssh_data(id) TEXT
); NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
last_opened
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
);
CREATE TABLE IF NOT EXISTS file_manager_pinned ( CREATE TABLE IF NOT EXISTS file_manager_pinned
id INTEGER PRIMARY KEY AUTOINCREMENT, (
user_id TEXT NOT NULL, id
host_id INTEGER NOT NULL, INTEGER
name TEXT NOT NULL, PRIMARY
path TEXT NOT NULL, KEY
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, AUTOINCREMENT,
FOREIGN KEY (user_id) REFERENCES users(id), user_id
FOREIGN KEY (host_id) REFERENCES ssh_data(id) TEXT
); NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
pinned_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
);
CREATE TABLE IF NOT EXISTS file_manager_shortcuts ( CREATE TABLE IF NOT EXISTS file_manager_shortcuts
id INTEGER PRIMARY KEY AUTOINCREMENT, (
user_id TEXT NOT NULL, id
host_id INTEGER NOT NULL, INTEGER
name TEXT NOT NULL, PRIMARY
path TEXT NOT NULL, KEY
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, AUTOINCREMENT,
FOREIGN KEY (user_id) REFERENCES users(id), user_id
FOREIGN KEY (host_id) REFERENCES ssh_data(id) TEXT
); NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
created_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
);
CREATE TABLE IF NOT EXISTS dismissed_alerts ( CREATE TABLE IF NOT EXISTS dismissed_alerts
id INTEGER PRIMARY KEY AUTOINCREMENT, (
user_id TEXT NOT NULL, id
alert_id TEXT NOT NULL, INTEGER
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY
FOREIGN KEY (user_id) REFERENCES users(id) KEY
); AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
alert_id
TEXT
NOT
NULL,
dismissed_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
)
);
`); `);
const addColumnIfNotExists = (table: string, column: string, definition: string) => { const addColumnIfNotExists = (table: string, column: string, definition: string) => {
@@ -162,7 +408,7 @@ const migrateSchema = () => {
logger.info('Removed redirect_uri column from users table'); logger.info('Removed redirect_uri column from users table');
} catch (e) { } catch (e) {
} }
addColumnIfNotExists('users', 'identifier_path', 'TEXT'); addColumnIfNotExists('users', 'identifier_path', 'TEXT');
addColumnIfNotExists('users', 'name_path', 'TEXT'); addColumnIfNotExists('users', 'name_path', 'TEXT');
addColumnIfNotExists('users', 'scopes', 'TEXT'); addColumnIfNotExists('users', 'scopes', 'TEXT');

View File

@@ -90,10 +90,10 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
if (cachedData) { if (cachedData) {
return cachedData; return cachedData;
} }
try { try {
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`; const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
@@ -108,13 +108,13 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
const alerts: TermixAlert[] = await response.json() as TermixAlert[]; const alerts: TermixAlert[] = await response.json() as TermixAlert[];
const now = new Date(); const now = new Date();
const validAlerts = alerts.filter(alert => { const validAlerts = alerts.filter(alert => {
const expiryDate = new Date(alert.expiresAt); const expiryDate = new Date(alert.expiresAt);
const isValid = expiryDate > now; const isValid = expiryDate > now;
return isValid; return isValid;
}); });
alertCache.set(cacheKey, validAlerts); alertCache.set(cacheKey, validAlerts);
return validAlerts; return validAlerts;
} catch (error) { } catch (error) {
@@ -146,11 +146,11 @@ router.get('/', async (req, res) => {
router.get('/user/:userId', async (req, res) => { router.get('/user/:userId', async (req, res) => {
try { try {
const {userId} = req.params; const {userId} = req.params;
if (!userId) { if (!userId) {
return res.status(400).json({error: 'User ID is required'}); return res.status(400).json({error: 'User ID is required'});
} }
const allAlerts = await fetchAlertsFromGitHub(); const allAlerts = await fetchAlertsFromGitHub();
const dismissedAlertRecords = await db const dismissedAlertRecords = await db
@@ -215,7 +215,7 @@ router.post('/dismiss', async (req, res) => {
router.get('/dismissed/:userId', async (req, res) => { router.get('/dismissed/:userId', async (req, res) => {
try { try {
const {userId} = req.params; const {userId} = req.params;
if (!userId) { if (!userId) {
return res.status(400).json({error: 'User ID is required'}); return res.status(400).json({error: 'User ID is required'});
} }

View File

@@ -695,7 +695,7 @@ router.delete('/file_manager/shortcuts', authenticateJWT, async (req: Request, r
// POST /ssh/bulk-import // POST /ssh/bulk-import
router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => { router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { hosts } = req.body; const {hosts} = req.body;
if (!Array.isArray(hosts) || hosts.length === 0) { if (!Array.isArray(hosts) || hosts.length === 0) {
logger.warn('Invalid bulk import data - hosts array is required and must not be empty'); logger.warn('Invalid bulk import data - hosts array is required and must not be empty');
@@ -715,7 +715,7 @@ router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response)
for (let i = 0; i < hosts.length; i++) { for (let i = 0; i < hosts.length; i++) {
const hostData = hosts[i]; const hostData = hosts[i];
try { try {
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) { if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) {
results.failed++; results.failed++;

View File

@@ -10,13 +10,9 @@ app.use(cors({
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'] allowedHeaders: ['Content-Type', 'Authorization']
})); }));
app.use(express.json({limit: '100mb'}));
// Increase JSON body parser limit for larger file uploads app.use(express.urlencoded({limit: '100mb', extended: true}));
app.use(express.json({ limit: '100mb' })); app.use(express.raw({limit: '200mb', type: 'application/octet-stream'}));
app.use(express.urlencoded({ limit: '100mb', extended: true }));
// Add raw body parser for very large files
app.use(express.raw({ limit: '200mb', type: 'application/octet-stream' }));
const sshIconSymbol = '📁'; const sshIconSymbol = '📁';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
@@ -314,9 +310,8 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({error: 'SSH command timed out'}); res.status(500).json({error: 'SSH command timed out'});
} }
}, 60000); // Increased timeout to 60 seconds }, 60000);
// Try SFTP first, fallback to command line if it fails
const trySFTP = () => { const trySFTP = () => {
try { try {
sshConn.client.sftp((err, sftp) => { sshConn.client.sftp((err, sftp) => {
@@ -326,7 +321,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
return; return;
} }
// Convert content to buffer
let fileBuffer; let fileBuffer;
try { try {
if (typeof content === 'string') { if (typeof content === 'string') {
@@ -345,9 +339,8 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
return; return;
} }
// Create write stream with error handling
const writeStream = sftp.createWriteStream(filePath); const writeStream = sftp.createWriteStream(filePath);
let hasError = false; let hasError = false;
let hasFinished = false; let hasFinished = false;
@@ -378,7 +371,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
} }
}); });
// Write the buffer to the stream
try { try {
writeStream.write(fileBuffer); writeStream.write(fileBuffer);
writeStream.end(); writeStream.end();
@@ -395,14 +387,13 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
} }
}; };
// Fallback method using command line
const tryFallbackMethod = () => { const tryFallbackMethod = () => {
try { try {
const base64Content = Buffer.from(content, 'utf8').toString('base64'); const base64Content = Buffer.from(content, 'utf8').toString('base64');
const escapedPath = filePath.replace(/'/g, "'\"'\"'"); const escapedPath = filePath.replace(/'/g, "'\"'\"'");
const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`; const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
sshConn.client.exec(writeCommand, (err, stream) => { sshConn.client.exec(writeCommand, (err, stream) => {
if (err) { if (err) {
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
@@ -426,7 +417,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
stream.on('close', (code) => { stream.on('close', (code) => {
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
if (outputData.includes('SUCCESS')) { if (outputData.includes('SUCCESS')) {
logger.success(`File written successfully via fallback: ${filePath}`); logger.success(`File written successfully via fallback: ${filePath}`);
if (!res.headersSent) { if (!res.headersSent) {
@@ -457,11 +448,9 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
} }
}; };
// Start with SFTP
trySFTP(); trySFTP();
}); });
// Upload file route
app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => { app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
const {sessionId, path: filePath, content, fileName} = req.body; const {sessionId, path: filePath, content, fileName} = req.body;
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];
@@ -488,9 +477,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({error: 'SSH command timed out'}); res.status(500).json({error: 'SSH command timed out'});
} }
}, 60000); // Increased timeout to 60 seconds }, 60000);
// Try SFTP first, fallback to command line if it fails
const trySFTP = () => { const trySFTP = () => {
try { try {
sshConn.client.sftp((err, sftp) => { sshConn.client.sftp((err, sftp) => {
@@ -500,7 +488,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
return; return;
} }
// Convert content to buffer
let fileBuffer; let fileBuffer;
try { try {
if (typeof content === 'string') { if (typeof content === 'string') {
@@ -519,9 +506,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
return; return;
} }
// Create write stream with error handling
const writeStream = sftp.createWriteStream(fullPath); const writeStream = sftp.createWriteStream(fullPath);
let hasError = false; let hasError = false;
let hasFinished = false; let hasFinished = false;
@@ -552,7 +538,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
} }
}); });
// Write the buffer to the stream
try { try {
writeStream.write(fileBuffer); writeStream.write(fileBuffer);
writeStream.end(); writeStream.end();
@@ -569,26 +554,23 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
} }
}; };
// Fallback method using command line with chunked approach
const tryFallbackMethod = () => { const tryFallbackMethod = () => {
try { try {
// Convert content to base64 and split into smaller chunks if needed
const base64Content = Buffer.from(content, 'utf8').toString('base64'); const base64Content = Buffer.from(content, 'utf8').toString('base64');
const chunkSize = 1000000; // 1MB chunks const chunkSize = 1000000;
const chunks = []; const chunks = [];
for (let i = 0; i < base64Content.length; i += chunkSize) { for (let i = 0; i < base64Content.length; i += chunkSize) {
chunks.push(base64Content.slice(i, i + chunkSize)); chunks.push(base64Content.slice(i, i + chunkSize));
} }
if (chunks.length === 1) { if (chunks.length === 1) {
// Single chunk - use simple approach
const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'"); const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`; const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
sshConn.client.exec(writeCommand, (err, stream) => { sshConn.client.exec(writeCommand, (err, stream) => {
if (err) { if (err) {
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
@@ -612,7 +594,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
stream.on('close', (code) => { stream.on('close', (code) => {
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
if (outputData.includes('SUCCESS')) { if (outputData.includes('SUCCESS')) {
logger.success(`File uploaded successfully via fallback: ${fullPath}`); logger.success(`File uploaded successfully via fallback: ${fullPath}`);
if (!res.headersSent) { if (!res.headersSent) {
@@ -635,17 +617,16 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
}); });
}); });
} else { } else {
// Multiple chunks - use chunked approach
const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'"); const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
let writeCommand = `> '${escapedPath}'`; // Start with empty file let writeCommand = `> '${escapedPath}'`;
chunks.forEach((chunk, index) => { chunks.forEach((chunk, index) => {
writeCommand += ` && echo '${chunk}' | base64 -d >> '${escapedPath}'`; writeCommand += ` && echo '${chunk}' | base64 -d >> '${escapedPath}'`;
}); });
writeCommand += ` && echo "SUCCESS"`; writeCommand += ` && echo "SUCCESS"`;
sshConn.client.exec(writeCommand, (err, stream) => { sshConn.client.exec(writeCommand, (err, stream) => {
@@ -671,7 +652,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
stream.on('close', (code) => { stream.on('close', (code) => {
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
if (outputData.includes('SUCCESS')) { if (outputData.includes('SUCCESS')) {
logger.success(`File uploaded successfully via chunked fallback: ${fullPath}`); logger.success(`File uploaded successfully via chunked fallback: ${fullPath}`);
if (!res.headersSent) { if (!res.headersSent) {
@@ -703,11 +684,9 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
} }
}; };
// Start with SFTP
trySFTP(); trySFTP();
}); });
// Create new file route
app.post('/ssh/file_manager/ssh/createFile', (req, res) => { app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
const {sessionId, path: filePath, fileName, content = ''} = req.body; const {sessionId, path: filePath, fileName, content = ''} = req.body;
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];
@@ -804,7 +783,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
}); });
}); });
// Create folder route
app.post('/ssh/file_manager/ssh/createFolder', (req, res) => { app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
const {sessionId, path: folderPath, folderName} = req.body; const {sessionId, path: folderPath, folderName} = req.body;
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];
@@ -901,7 +879,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
}); });
}); });
// Delete file/folder route
app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => { app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
const {sessionId, path: itemPath, isDirectory} = req.body; const {sessionId, path: itemPath, isDirectory} = req.body;
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];
@@ -930,7 +907,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
} }
}, 15000); }, 15000);
const deleteCommand = isDirectory const deleteCommand = isDirectory
? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0` ? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0`
: `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`; : `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`;
@@ -999,7 +976,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
}); });
}); });
// Rename file/folder route
app.put('/ssh/file_manager/ssh/renameItem', (req, res) => { app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
const {sessionId, oldPath, newName} = req.body; const {sessionId, oldPath, newName} = req.body;
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];

View File

@@ -3,7 +3,7 @@ import chalk from 'chalk';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import net from 'net'; import net from 'net';
import cors from 'cors'; import cors from 'cors';
import { Client, type ConnectConfig } from 'ssh2'; import {Client, type ConnectConfig} from 'ssh2';
type HostRecord = { type HostRecord = {
id: number; id: number;
@@ -21,7 +21,7 @@ type HostStatus = 'online' | 'offline';
type StatusEntry = { type StatusEntry = {
status: HostStatus; status: HostStatus;
lastChecked: string; // ISO string lastChecked: string;
}; };
const app = express(); const app = express();
@@ -30,7 +30,6 @@ app.use(cors({
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'] allowedHeaders: ['Content-Type', 'Authorization']
})); }));
// Fallback explicit CORS headers to cover any edge cases
app.use((req, res, next) => { app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
@@ -42,7 +41,6 @@ app.use((req, res, next) => {
}); });
app.use(express.json()); app.use(express.json());
// Logger (customized for Server Stats)
const statsIconSymbol = '📡'; const statsIconSymbol = '📡';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
@@ -69,15 +67,13 @@ const logger = {
} }
}; };
// In-memory state of last known statuses
const hostStatuses: Map<number, StatusEntry> = new Map(); const hostStatuses: Map<number, StatusEntry> = new Map();
// Fetch all hosts from the database service (internal endpoint, no JWT)
async function fetchAllHosts(): Promise<HostRecord[]> { async function fetchAllHosts(): Promise<HostRecord[]> {
const url = 'http://localhost:8081/ssh/db/host/internal'; const url = 'http://localhost:8081/ssh/db/host/internal';
try { try {
const resp = await fetch(url, { const resp = await fetch(url, {
headers: { 'x-internal-request': '1' } headers: {'x-internal-request': '1'}
}); });
if (!resp.ok) { if (!resp.ok) {
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`); throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
@@ -112,9 +108,7 @@ function buildSshConfig(host: HostRecord): ConnectConfig {
port: host.port || 22, port: host.port || 22,
username: host.username || 'root', username: host.username || 'root',
readyTimeout: 10_000, readyTimeout: 10_000,
algorithms: { algorithms: {}
// keep defaults minimal to avoid negotiation issues
}
} as ConnectConfig; } as ConnectConfig;
if (host.authType === 'password') { if (host.authType === 'password') {
@@ -138,7 +132,10 @@ async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Pr
const onError = (err: Error) => { const onError = (err: Error) => {
if (!settled) { if (!settled) {
settled = true; settled = true;
try { client.end(); } catch {} try {
client.end();
} catch {
}
reject(err); reject(err);
} }
}; };
@@ -148,7 +145,10 @@ async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Pr
const result = await fn(client); const result = await fn(client);
if (!settled) { if (!settled) {
settled = true; settled = true;
try { client.end(); } catch {} try {
client.end();
} catch {
}
resolve(result); resolve(result);
} }
} catch (err: any) { } catch (err: any) {
@@ -166,16 +166,20 @@ async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Pr
}); });
} }
function execCommand(client: Client, command: string): Promise<{ stdout: string; stderr: string; code: number | null; }> { function execCommand(client: Client, command: string): Promise<{
stdout: string;
stderr: string;
code: number | null;
}> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
client.exec(command, { pty: false }, (err, stream) => { client.exec(command, {pty: false}, (err, stream) => {
if (err) return reject(err); if (err) return reject(err);
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
let exitCode: number | null = null; let exitCode: number | null = null;
stream.on('close', (code: number | undefined) => { stream.on('close', (code: number | undefined) => {
exitCode = typeof code === 'number' ? code : null; exitCode = typeof code === 'number' ? code : null;
resolve({ stdout, stderr, code: exitCode }); resolve({stdout, stderr, code: exitCode});
}).on('data', (data: Buffer) => { }).on('data', (data: Buffer) => {
stdout += data.toString('utf8'); stdout += data.toString('utf8');
}).stderr.on('data', (data: Buffer) => { }).stderr.on('data', (data: Buffer) => {
@@ -190,9 +194,9 @@ function parseCpuLine(cpuLine: string): { total: number; idle: number } | undefi
if (parts[0] !== 'cpu') return undefined; if (parts[0] !== 'cpu') return undefined;
const nums = parts.slice(1).map(n => Number(n)).filter(n => Number.isFinite(n)); const nums = parts.slice(1).map(n => Number(n)).filter(n => Number.isFinite(n));
if (nums.length < 4) return undefined; if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0); // idle + iowait const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0); const total = nums.reduce((a, b) => a + b, 0);
return { total, idle }; return {total, idle};
} }
function toFixedNum(n: number | null | undefined, digits = 2): number | null { function toFixedNum(n: number | null | undefined, digits = 2): number | null {
@@ -210,7 +214,6 @@ async function collectMetrics(host: HostRecord): Promise<{
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null }; disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
}> { }> {
return withSshConnection(host, async (client) => { return withSshConnection(host, async (client) => {
// CPU
let cpuPercent: number | null = null; let cpuPercent: number | null = null;
let cores: number | null = null; let cores: number | null = null;
let loadTriplet: [number, number, number] | null = null; let loadTriplet: [number, number, number] | null = null;
@@ -245,7 +248,6 @@ async function collectMetrics(host: HostRecord): Promise<{
loadTriplet = null; loadTriplet = null;
} }
// Memory
let memPercent: number | null = null; let memPercent: number | null = null;
let usedGiB: number | null = null; let usedGiB: number | null = null;
let totalGiB: number | null = null; let totalGiB: number | null = null;
@@ -256,7 +258,7 @@ async function collectMetrics(host: HostRecord): Promise<{
const line = lines.find(l => l.startsWith(key)); const line = lines.find(l => l.startsWith(key));
if (!line) return null; if (!line) return null;
const m = line.match(/\d+/); const m = line.match(/\d+/);
return m ? Number(m[0]) : null; // in kB return m ? Number(m[0]) : null;
}; };
const totalKb = getVal('MemTotal:'); const totalKb = getVal('MemTotal:');
const availKb = getVal('MemAvailable:'); const availKb = getVal('MemAvailable:');
@@ -272,14 +274,12 @@ async function collectMetrics(host: HostRecord): Promise<{
totalGiB = null; totalGiB = null;
} }
// Disk
let diskPercent: number | null = null; let diskPercent: number | null = null;
let usedHuman: string | null = null; let usedHuman: string | null = null;
let totalHuman: string | null = null; let totalHuman: string | null = null;
try { try {
const diskOut = await execCommand(client, 'df -h -P / | tail -n +2'); const diskOut = await execCommand(client, 'df -h -P / | tail -n +2');
const line = diskOut.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || ''; const line = diskOut.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
// Expected columns: Filesystem Size Used Avail Use% Mounted
const parts = line.split(/\s+/); const parts = line.split(/\s+/);
if (parts.length >= 6) { if (parts.length >= 6) {
totalHuman = parts[1] || null; totalHuman = parts[1] || null;
@@ -295,9 +295,13 @@ async function collectMetrics(host: HostRecord): Promise<{
} }
return { return {
cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet }, cpu: {percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet},
memory: { percent: toFixedNum(memPercent, 0), usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null }, memory: {
disk: { percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman }, percent: toFixedNum(memPercent, 0),
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null
},
disk: {percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman},
}; };
}); });
} }
@@ -310,7 +314,10 @@ function tcpPing(host: string, port: number, timeoutMs = 5000): Promise<boolean>
const onDone = (result: boolean) => { const onDone = (result: boolean) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
try { socket.destroy(); } catch {} try {
socket.destroy();
} catch {
}
resolve(result); resolve(result);
}; };
@@ -334,7 +341,7 @@ async function pollStatusesOnce(): Promise<void> {
const checks = hosts.map(async (h) => { const checks = hosts.map(async (h) => {
const isOnline = await tcpPing(h.ip, h.port, 5000); const isOnline = await tcpPing(h.ip, h.port, 5000);
hostStatuses.set(h.id, { status: isOnline ? 'online' : 'offline', lastChecked: now }); hostStatuses.set(h.id, {status: isOnline ? 'online' : 'offline', lastChecked: now});
return isOnline; return isOnline;
}); });
@@ -344,7 +351,6 @@ async function pollStatusesOnce(): Promise<void> {
} }
app.get('/status', async (req, res) => { app.get('/status', async (req, res) => {
// Return current cached statuses; if empty, trigger a poll
if (hostStatuses.size === 0) { if (hostStatuses.size === 0) {
await pollStatusesOnce(); await pollStatusesOnce();
} }
@@ -358,7 +364,7 @@ app.get('/status', async (req, res) => {
app.get('/status/:id', async (req, res) => { app.get('/status/:id', async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!id) { if (!id) {
return res.status(400).json({ error: 'Invalid id' }); return res.status(400).json({error: 'Invalid id'});
} }
if (!hostStatuses.has(id)) { if (!hostStatuses.has(id)) {
@@ -367,34 +373,34 @@ app.get('/status/:id', async (req, res) => {
const entry = hostStatuses.get(id); const entry = hostStatuses.get(id);
if (!entry) { if (!entry) {
return res.status(404).json({ error: 'Host not found' }); return res.status(404).json({error: 'Host not found'});
} }
res.json(entry); res.json(entry);
}); });
app.post('/refresh', async (req, res) => { app.post('/refresh', async (req, res) => {
await pollStatusesOnce(); await pollStatusesOnce();
res.json({ message: 'Refreshed' }); res.json({message: 'Refreshed'});
}); });
app.get('/metrics/:id', async (req, res) => { app.get('/metrics/:id', async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!id) { if (!id) {
return res.status(400).json({ error: 'Invalid id' }); return res.status(400).json({error: 'Invalid id'});
} }
try { try {
const host = await fetchHostById(id); const host = await fetchHostById(id);
if (!host) { if (!host) {
return res.status(404).json({ error: 'Host not found' }); return res.status(404).json({error: 'Host not found'});
} }
const metrics = await collectMetrics(host); const metrics = await collectMetrics(host);
res.json({ ...metrics, lastChecked: new Date().toISOString() }); res.json({...metrics, lastChecked: new Date().toISOString()});
} catch (err) { } catch (err) {
logger.error('Failed to collect metrics', err); logger.error('Failed to collect metrics', err);
return res.json({ return res.json({
cpu: { percent: null, cores: null, load: null }, cpu: {percent: null, cores: null, load: null},
memory: { percent: null, usedGiB: null, totalGiB: null }, memory: {percent: null, usedGiB: null, totalGiB: null},
disk: { percent: null, usedHuman: null, totalHuman: null }, disk: {percent: null, usedHuman: null, totalHuman: null},
lastChecked: new Date().toISOString() lastChecked: new Date().toISOString()
}); });
} }
@@ -409,7 +415,6 @@ app.listen(PORT, async () => {
} }
}); });
// Background polling every minute
setInterval(() => { setInterval(() => {
pollStatusesOnce().catch(err => logger.error('Background poll failed', err)); pollStatusesOnce().catch(err => logger.error('Background poll failed', err));
}, 60_000); }, 60_000);

View File

@@ -132,15 +132,14 @@
} }
} }
/* Thin scrollbar utility for scrollable containers */
.thin-scrollbar { .thin-scrollbar {
scrollbar-width: thin; /* Firefox */ scrollbar-width: thin;
scrollbar-color: #303032 transparent; /* Firefox */ scrollbar-color: #303032 transparent;
} }
.thin-scrollbar::-webkit-scrollbar { .thin-scrollbar::-webkit-scrollbar {
height: 6px; /* horizontal */ height: 6px;
width: 6px; /* vertical, if any */ width: 6px;
} }
.thin-scrollbar::-webkit-scrollbar-track { .thin-scrollbar::-webkit-scrollbar-track {
@@ -154,7 +153,6 @@
background-clip: content-box; background-clip: content-box;
} }
/* Thin scrollbar styles */
.thin-scrollbar::-webkit-scrollbar { .thin-scrollbar::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -173,7 +171,6 @@
background: #5a5a5d; background: #5a5a5d;
} }
/* Custom scrollbar for Firefox */
.thin-scrollbar { .thin-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #434345 #18181b; scrollbar-color: #434345 #18181b;

View File

@@ -19,7 +19,7 @@ import {Shield, Trash2, Users} from "lucide-react";
import axios from "axios"; import axios from "axios";
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users"; const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
const API = axios.create({ baseURL: apiBase }); const API = axios.create({baseURL: apiBase});
function getCookie(name: string) { function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => { return document.cookie.split('; ').reduce((r, v) => {
@@ -32,14 +32,12 @@ interface AdminSettingsProps {
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
} }
export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): React.ReactElement { export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
const { state: sidebarState } = useSidebar(); const {state: sidebarState} = useSidebar();
// Registration toggle
const [allowRegistration, setAllowRegistration] = React.useState(true); const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false); const [regLoading, setRegLoading] = React.useState(false);
// OIDC config
const [oidcConfig, setOidcConfig] = React.useState({ const [oidcConfig, setOidcConfig] = React.useState({
client_id: '', client_id: '',
client_secret: '', client_secret: '',
@@ -54,8 +52,12 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
const [oidcError, setOidcError] = React.useState<string | null>(null); const [oidcError, setOidcError] = React.useState<string | null>(null);
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null); const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
// Users/admins const [users, setUsers] = React.useState<Array<{
const [users, setUsers] = React.useState<Array<{ id: string; username: string; is_admin: boolean; is_oidc: boolean }>>([]); id: string;
username: string;
is_admin: boolean;
is_oidc: boolean
}>>([]);
const [usersLoading, setUsersLoading] = React.useState(false); const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState(""); const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false); const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
@@ -65,15 +67,15 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
React.useEffect(() => { React.useEffect(() => {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
if (!jwt) return; if (!jwt) return;
// Preload OIDC config and users API.get("/oidc-config", {headers: {Authorization: `Bearer ${jwt}`}})
API.get("/oidc-config", { headers: { Authorization: `Bearer ${jwt}` } }) .then(res => {
.then(res => { if (res.data) setOidcConfig(res.data); }) if (res.data) setOidcConfig(res.data);
.catch(() => {}); })
.catch(() => {
});
fetchUsers(); fetchUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Load initial registration toggle status
React.useEffect(() => { React.useEffect(() => {
API.get("/registration-allowed") API.get("/registration-allowed")
.then(res => { .then(res => {
@@ -81,7 +83,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
setAllowRegistration(res.data.allowed); setAllowRegistration(res.data.allowed);
} }
}) })
.catch(() => {}); .catch(() => {
});
}, []); }, []);
const fetchUsers = async () => { const fetchUsers = async () => {
@@ -89,7 +92,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
if (!jwt) return; if (!jwt) return;
setUsersLoading(true); setUsersLoading(true);
try { try {
const response = await API.get("/list", { headers: { Authorization: `Bearer ${jwt}` } }); const response = await API.get("/list", {headers: {Authorization: `Bearer ${jwt}`}});
setUsers(response.data.users); setUsers(response.data.users);
} finally { } finally {
setUsersLoading(false); setUsersLoading(false);
@@ -100,7 +103,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
setRegLoading(true); setRegLoading(true);
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await API.patch("/registration-allowed", { allowed: checked }, { headers: { Authorization: `Bearer ${jwt}` } }); await API.patch("/registration-allowed", {allowed: checked}, {headers: {Authorization: `Bearer ${jwt}`}});
setAllowRegistration(checked); setAllowRegistration(checked);
} finally { } finally {
setRegLoading(false); setRegLoading(false);
@@ -113,7 +116,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
setOidcError(null); setOidcError(null);
setOidcSuccess(null); setOidcSuccess(null);
const required = ['client_id','client_secret','issuer_url','authorization_url','token_url']; const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]); const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
if (missing.length > 0) { if (missing.length > 0) {
setOidcError(`Missing required fields: ${missing.join(', ')}`); setOidcError(`Missing required fields: ${missing.join(', ')}`);
@@ -123,7 +126,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await API.post("/oidc-config", oidcConfig, { headers: { Authorization: `Bearer ${jwt}` } }); await API.post("/oidc-config", oidcConfig, {headers: {Authorization: `Bearer ${jwt}`}});
setOidcSuccess("OIDC configuration updated successfully!"); setOidcSuccess("OIDC configuration updated successfully!");
} catch (err: any) { } catch (err: any) {
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration"); setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
@@ -133,7 +136,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
}; };
const handleOIDCConfigChange = (field: string, value: string) => { const handleOIDCConfigChange = (field: string, value: string) => {
setOidcConfig(prev => ({ ...prev, [field]: value })); setOidcConfig(prev => ({...prev, [field]: value}));
}; };
const makeUserAdmin = async (e: React.FormEvent) => { const makeUserAdmin = async (e: React.FormEvent) => {
@@ -144,7 +147,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
setMakeAdminSuccess(null); setMakeAdminSuccess(null);
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await API.post("/make-admin", { username: newAdminUsername.trim() }, { headers: { Authorization: `Bearer ${jwt}` } }); await API.post("/make-admin", {username: newAdminUsername.trim()}, {headers: {Authorization: `Bearer ${jwt}`}});
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`); setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
setNewAdminUsername(""); setNewAdminUsername("");
fetchUsers(); fetchUsers();
@@ -159,18 +162,20 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
if (!confirm(`Remove admin status from ${username}?`)) return; if (!confirm(`Remove admin status from ${username}?`)) return;
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await API.post("/remove-admin", { username }, { headers: { Authorization: `Bearer ${jwt}` } }); await API.post("/remove-admin", {username}, {headers: {Authorization: `Bearer ${jwt}`}});
fetchUsers(); fetchUsers();
} catch {} } catch {
}
}; };
const deleteUser = async (username: string) => { const deleteUser = async (username: string) => {
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return; if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await API.delete("/delete-user", { headers: { Authorization: `Bearer ${jwt}` }, data: { username } }); await API.delete("/delete-user", {headers: {Authorization: `Bearer ${jwt}`}, data: {username}});
fetchUsers(); fetchUsers();
} catch {} } catch {
}
}; };
const topMarginPx = isTopbarOpen ? 74 : 26; const topMarginPx = isTopbarOpen ? 74 : 26;
@@ -185,7 +190,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
}; };
return ( return (
<div style={wrapperStyle} className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden"> <div style={wrapperStyle}
className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
<div className="h-full w-full flex flex-col"> <div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2"> <div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">Admin Settings</h1> <h1 className="font-bold text-lg">Admin Settings</h1>
@@ -217,7 +223,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-semibold">User Registration</h3> <h3 className="text-lg font-semibold">User Registration</h3>
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration} disabled={regLoading}/> <Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration}
disabled={regLoading}/>
Allow new account registration Allow new account registration
</label> </label>
</div> </div>
@@ -226,7 +233,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<TabsContent value="oidc" className="space-y-6"> <TabsContent value="oidc" className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3> <h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
<p className="text-sm text-muted-foreground">Configure external identity provider for OIDC/OAuth2 authentication.</p> <p className="text-sm text-muted-foreground">Configure external identity provider for
OIDC/OAuth2 authentication.</p>
{oidcError && ( {oidcError && (
<Alert variant="destructive"> <Alert variant="destructive">
@@ -238,39 +246,66 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4"> <form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="client_id">Client ID</Label> <Label htmlFor="client_id">Client ID</Label>
<Input id="client_id" value={oidcConfig.client_id} onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)} placeholder="your-client-id" required /> <Input id="client_id" value={oidcConfig.client_id}
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
placeholder="your-client-id" required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="client_secret">Client Secret</Label> <Label htmlFor="client_secret">Client Secret</Label>
<Input id="client_secret" type="password" value={oidcConfig.client_secret} onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)} placeholder="your-client-secret" required /> <Input id="client_secret" type="password" value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder="your-client-secret" required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="authorization_url">Authorization URL</Label> <Label htmlFor="authorization_url">Authorization URL</Label>
<Input id="authorization_url" value={oidcConfig.authorization_url} onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)} placeholder="https://your-provider.com/application/o/authorize/" required /> <Input id="authorization_url" value={oidcConfig.authorization_url}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
placeholder="https://your-provider.com/application/o/authorize/"
required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="issuer_url">Issuer URL</Label> <Label htmlFor="issuer_url">Issuer URL</Label>
<Input id="issuer_url" value={oidcConfig.issuer_url} onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)} placeholder="https://your-provider.com/application/o/termix/" required /> <Input id="issuer_url" value={oidcConfig.issuer_url}
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
placeholder="https://your-provider.com/application/o/termix/" required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="token_url">Token URL</Label> <Label htmlFor="token_url">Token URL</Label>
<Input id="token_url" value={oidcConfig.token_url} onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)} placeholder="https://your-provider.com/application/o/token/" required /> <Input id="token_url" value={oidcConfig.token_url}
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
placeholder="https://your-provider.com/application/o/token/" required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="identifier_path">User Identifier Path</Label> <Label htmlFor="identifier_path">User Identifier Path</Label>
<Input id="identifier_path" value={oidcConfig.identifier_path} onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)} placeholder="sub" required /> <Input id="identifier_path" value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder="sub" required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name_path">Display Name Path</Label> <Label htmlFor="name_path">Display Name Path</Label>
<Input id="name_path" value={oidcConfig.name_path} onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)} placeholder="name" required /> <Input id="name_path" value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder="name" required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label> <Label htmlFor="scopes">Scopes</Label>
<Input id="scopes" value={oidcConfig.scopes} onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)} placeholder="openid email profile" required /> <Input id="scopes" value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
placeholder="openid email profile" required/>
</div> </div>
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<Button type="submit" className="flex-1" disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button> <Button type="submit" className="flex-1"
<Button type="button" variant="outline" onClick={() => setOidcConfig({ client_id: '', client_secret: '', issuer_url: '', authorization_url: '', token_url: '', identifier_path: 'sub', name_path: 'name', scopes: 'openid email profile' })}>Reset</Button> disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
<Button type="button" variant="outline" onClick={() => setOidcConfig({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
})}>Reset</Button>
</div> </div>
{oidcSuccess && ( {oidcSuccess && (
@@ -287,7 +322,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">User Management</h3> <h3 className="text-lg font-semibold">User Management</h3>
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline" size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button> <Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
</div> </div>
{usersLoading ? ( {usersLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading users...</div> <div className="text-center py-8 text-muted-foreground">Loading users...</div>
@@ -307,12 +343,17 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<TableCell className="px-4 font-medium"> <TableCell className="px-4 font-medium">
{user.username} {user.username}
{user.is_admin && ( {user.is_admin && (
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span> <span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
)} )}
</TableCell> </TableCell>
<TableCell className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell> <TableCell
className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4"> <TableCell className="px-4">
<Button variant="ghost" size="sm" onClick={() => deleteUser(user.username)} className="text-red-600 hover:text-red-700 hover:bg-red-50" disabled={user.is_admin}> <Button variant="ghost" size="sm"
onClick={() => deleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}>
<Trash2 className="h-4 w-4"/> <Trash2 className="h-4 w-4"/>
</Button> </Button>
</TableCell> </TableCell>
@@ -334,8 +375,11 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="new-admin-username">Username</Label> <Label htmlFor="new-admin-username">Username</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input id="new-admin-username" value={newAdminUsername} onChange={(e) => setNewAdminUsername(e.target.value)} placeholder="Enter username to make admin" required /> <Input id="new-admin-username" value={newAdminUsername}
<Button type="submit" disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button> onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder="Enter username to make admin" required/>
<Button type="submit"
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
</div> </div>
</div> </div>
{makeAdminError && ( {makeAdminError && (
@@ -369,11 +413,15 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<TableRow key={admin.id}> <TableRow key={admin.id}>
<TableCell className="px-4 font-medium"> <TableCell className="px-4 font-medium">
{admin.username} {admin.username}
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span> <span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
</TableCell> </TableCell>
<TableCell className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell> <TableCell
className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4"> <TableCell className="px-4">
<Button variant="ghost" size="sm" onClick={() => removeAdminStatus(admin.username)} className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"> <Button variant="ghost" size="sm"
onClick={() => removeAdminStatus(admin.username)}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
<Shield className="h-4 w-4"/> <Shield className="h-4 w-4"/>
Remove Admin Remove Admin
</Button> </Button>

View File

@@ -44,7 +44,6 @@ export function Homepage({
const [userId, setUserId] = useState<string | null>(null); const [userId, setUserId] = useState<string | null>(null);
const [dbError, setDbError] = useState<string | null>(null); const [dbError, setDbError] = useState<string | null>(null);
// Update local state when props change
useEffect(() => { useEffect(() => {
setLoggedIn(isAuthenticated); setLoggedIn(isAuthenticated);
}, [isAuthenticated]); }, [isAuthenticated]);

View File

@@ -42,17 +42,17 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await API.get(`/user/${userId}`); const response = await API.get(`/user/${userId}`);
const userAlerts = response.data.alerts || []; const userAlerts = response.data.alerts || [];
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => { const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 }; const priorityOrder = {critical: 4, high: 3, medium: 2, low: 1};
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 0; const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 0; const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
if (aPriority !== bPriority) { if (aPriority !== bPriority) {
return bPriority - aPriority; return bPriority - aPriority;
} }
@@ -71,7 +71,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
const handleDismissAlert = async (alertId: string) => { const handleDismissAlert = async (alertId: string) => {
if (!userId) return; if (!userId) return;
try { try {
const response = await API.post('/dismiss', { const response = await API.post('/dismiss', {
userId, userId,
@@ -130,8 +130,8 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
if (!currentAlert) { if (!currentAlert) {
return null; return null;
} }
const priorityCounts = { critical: 0, high: 0, medium: 0, low: 0 }; const priorityCounts = {critical: 0, high: 0, medium: 0, low: 0};
alerts.forEach(alert => { alerts.forEach(alert => {
const priority = alert.priority || 'low'; const priority = alert.priority || 'low';
priorityCounts[priority as keyof typeof priorityCounts]++; priorityCounts[priority as keyof typeof priorityCounts]++;

View File

@@ -149,13 +149,11 @@ export function HomepageAuth({
setUsername(meRes.data.username || null); setUsername(meRes.data.username || null);
setUserId(meRes.data.id || null); setUserId(meRes.data.id || null);
setDbError(null); setDbError(null);
// Call onAuthSuccess to update App state
onAuthSuccess({ onAuthSuccess({
isAdmin: !!meRes.data.is_admin, isAdmin: !!meRes.data.is_admin,
username: meRes.data.username || null, username: meRes.data.username || null,
userId: meRes.data.id || null userId: meRes.data.id || null
}); });
// Update internal state immediately
setInternalLoggedIn(true); setInternalLoggedIn(true);
if (tab === "signup") { if (tab === "signup") {
setSignupConfirmPassword(""); setSignupConfirmPassword("");
@@ -309,13 +307,11 @@ export function HomepageAuth({
setUsername(meRes.data.username || null); setUsername(meRes.data.username || null);
setUserId(meRes.data.id || null); setUserId(meRes.data.id || null);
setDbError(null); setDbError(null);
// Call onAuthSuccess to update App state
onAuthSuccess({ onAuthSuccess({
isAdmin: !!meRes.data.is_admin, isAdmin: !!meRes.data.is_admin,
username: meRes.data.username || null, username: meRes.data.username || null,
userId: meRes.data.id || null userId: meRes.data.id || null
}); });
// Update internal state immediately
setInternalLoggedIn(true); setInternalLoggedIn(true);
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
}) })
@@ -347,341 +343,341 @@ export function HomepageAuth({
className={`w-[420px] max-w-full p-6 flex flex-col ${className || ''}`} className={`w-[420px] max-w-full p-6 flex flex-col ${className || ''}`}
{...props} {...props}
> >
{dbError && ( {dbError && (
<Alert variant="destructive" className="mb-4"> <Alert variant="destructive" className="mb-4">
<AlertTitle>Error</AlertTitle> <AlertTitle>Error</AlertTitle>
<AlertDescription>{dbError}</AlertDescription> <AlertDescription>{dbError}</AlertDescription>
</Alert> </Alert>
)} )}
{firstUser && !dbError && !internalLoggedIn && ( {firstUser && !dbError && !internalLoggedIn && (
<Alert variant="default" className="mb-4"> <Alert variant="default" className="mb-4">
<AlertTitle>First User</AlertTitle> <AlertTitle>First User</AlertTitle>
<AlertDescription className="inline"> <AlertDescription className="inline">
You are the first user and will be made an admin. You can view admin settings in the sidebar You are the first user and will be made an admin. You can view admin settings in the sidebar
user dropdown. If you think this is a mistake, check the docker logs, or create a{" "} user dropdown. If you think this is a mistake, check the docker logs, or create a{" "}
<a <a
href="https://github.com/LukeGus/Termix/issues/new" href="https://github.com/LukeGus/Termix/issues/new"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800 inline" className="text-blue-600 underline hover:text-blue-800 inline"
> >
GitHub issue GitHub issue
</a>. </a>.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{!registrationAllowed && !internalLoggedIn && ( {!registrationAllowed && !internalLoggedIn && (
<Alert variant="destructive" className="mb-4"> <Alert variant="destructive" className="mb-4">
<AlertTitle>Registration Disabled</AlertTitle> <AlertTitle>Registration Disabled</AlertTitle>
<AlertDescription> <AlertDescription>
New account registration is currently disabled by an admin. Please log in or contact an New account registration is currently disabled by an admin. Please log in or contact an
administrator. administrator.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && ( {(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && (
<> <>
<div className="flex gap-2 mb-6"> <div className="flex gap-2 mb-6">
<button <button
type="button" type="button"
className={cn( className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all", "flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "login" tab === "login"
? "bg-primary text-primary-foreground shadow" ? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent" : "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => {
setTab("login");
if (tab === "reset") resetPasswordState();
if (tab === "signup") clearFormFields();
}}
aria-selected={tab === "login"}
disabled={loading || firstUser}
>
Login
</button>
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "signup"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => {
setTab("signup");
if (tab === "reset") resetPasswordState();
if (tab === "login") clearFormFields();
}}
aria-selected={tab === "signup"}
disabled={loading || !registrationAllowed}
>
Sign Up
</button>
{oidcConfigured && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "external"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => {
setTab("external");
if (tab === "reset") resetPasswordState();
if (tab === "login" || tab === "signup") clearFormFields();
}}
aria-selected={tab === "external"}
disabled={oidcLoading}
>
External
</button>
)} )}
</div> onClick={() => {
<div className="mb-6 text-center"> setTab("login");
<h2 className="text-xl font-bold mb-1"> if (tab === "reset") resetPasswordState();
{tab === "login" ? "Login to your account" : if (tab === "signup") clearFormFields();
tab === "signup" ? "Create a new account" : }}
tab === "external" ? "Login with external provider" : aria-selected={tab === "login"}
"Reset your password"} disabled={loading || firstUser}
</h2> >
</div> Login
</button>
{tab === "external" || tab === "reset" ? ( <button
<div className="flex flex-col gap-5"> type="button"
{tab === "external" && ( className={cn(
<> "flex-1 py-2 text-base font-medium rounded-md transition-all",
<div className="text-center text-muted-foreground mb-4"> tab === "signup"
<p>Login using your configured external identity provider</p> ? "bg-primary text-primary-foreground shadow"
</div> : "bg-muted text-muted-foreground hover:bg-accent"
<Button )}
type="button" onClick={() => {
className="w-full h-11 mt-2 text-base font-semibold" setTab("signup");
disabled={oidcLoading} if (tab === "reset") resetPasswordState();
onClick={handleOIDCLogin} if (tab === "login") clearFormFields();
> }}
{oidcLoading ? Spinner : "Login with External Provider"} aria-selected={tab === "signup"}
</Button> disabled={loading || !registrationAllowed}
</> >
Sign Up
</button>
{oidcConfigured && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "external"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)} )}
{tab === "reset" && ( onClick={() => {
<> setTab("external");
{resetStep === "initiate" && ( if (tab === "reset") resetPasswordState();
<> if (tab === "login" || tab === "signup") clearFormFields();
<div className="text-center text-muted-foreground mb-4"> }}
<p>Enter your username to receive a password reset code. The code aria-selected={tab === "external"}
will be logged in the docker container logs.</p> disabled={oidcLoading}
</div> >
<div className="flex flex-col gap-4"> External
<div className="flex flex-col gap-2"> </button>
<Label htmlFor="reset-username">Username</Label> )}
<Input </div>
id="reset-username" <div className="mb-6 text-center">
type="text" <h2 className="text-xl font-bold mb-1">
required {tab === "login" ? "Login to your account" :
className="h-11 text-base" tab === "signup" ? "Create a new account" :
value={localUsername} tab === "external" ? "Login with external provider" :
onChange={e => setLocalUsername(e.target.value)} "Reset your password"}
disabled={resetLoading} </h2>
/> </div>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !localUsername.trim()}
onClick={initiatePasswordReset}
>
{resetLoading ? Spinner : "Send Reset Code"}
</Button>
</div>
</>
)}
{resetStep === "verify" && ( {tab === "external" || tab === "reset" ? (
<> <div className="flex flex-col gap-5">
<div className="text-center text-muted-foreground mb-4"> {tab === "external" && (
<p>Enter the 6-digit code from the docker container logs for <>
user: <strong>{localUsername}</strong></p> <div className="text-center text-muted-foreground mb-4">
</div> <p>Login using your configured external identity provider</p>
<div className="flex flex-col gap-4"> </div>
<div className="flex flex-col gap-2"> <Button
<Label htmlFor="reset-code">Reset Code</Label> type="button"
<Input className="w-full h-11 mt-2 text-base font-semibold"
id="reset-code" disabled={oidcLoading}
type="text" onClick={handleOIDCLogin}
required >
maxLength={6} {oidcLoading ? Spinner : "Login with External Provider"}
className="h-11 text-base text-center text-lg tracking-widest" </Button>
value={resetCode} </>
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))} )}
disabled={resetLoading} {tab === "reset" && (
placeholder="000000" <>
/> {resetStep === "initiate" && (
</div> <>
<Button <div className="text-center text-muted-foreground mb-4">
type="button" <p>Enter your username to receive a password reset code. The code
className="w-full h-11 text-base font-semibold" will be logged in the docker container logs.</p>
disabled={resetLoading || resetCode.length !== 6} </div>
onClick={verifyResetCode} <div className="flex flex-col gap-4">
> <div className="flex flex-col gap-2">
{resetLoading ? Spinner : "Verify Code"} <Label htmlFor="reset-username">Username</Label>
</Button> <Input
<Button id="reset-username"
type="button" type="text"
variant="outline" required
className="w-full h-11 text-base font-semibold" className="h-11 text-base"
value={localUsername}
onChange={e => setLocalUsername(e.target.value)}
disabled={resetLoading} disabled={resetLoading}
onClick={() => { />
setResetStep("initiate");
setResetCode("");
}}
>
Back
</Button>
</div> </div>
</>
)}
{resetSuccess && (
<>
<Alert className="mb-4">
<AlertTitle>Success!</AlertTitle>
<AlertDescription>
Your password has been successfully reset! You can now log in
with your new password.
</AlertDescription>
</Alert>
<Button <Button
type="button" type="button"
className="w-full h-11 text-base font-semibold" className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !localUsername.trim()}
onClick={initiatePasswordReset}
>
{resetLoading ? Spinner : "Send Reset Code"}
</Button>
</div>
</>
)}
{resetStep === "verify" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter the 6-digit code from the docker container logs for
user: <strong>{localUsername}</strong></p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">Reset Code</Label>
<Input
id="reset-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
disabled={resetLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || resetCode.length !== 6}
onClick={verifyResetCode}
>
{resetLoading ? Spinner : "Verify Code"}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => { onClick={() => {
setTab("login"); setResetStep("initiate");
resetPasswordState(); setResetCode("");
}} }}
> >
Go to Login Back
</Button> </Button>
</> </div>
)} </>
)}
{resetStep === "newPassword" && !resetSuccess && ( {resetSuccess && (
<> <>
<div className="text-center text-muted-foreground mb-4"> <Alert className="mb-4">
<p>Enter your new password for <AlertTitle>Success!</AlertTitle>
user: <strong>{localUsername}</strong></p> <AlertDescription>
</div> Your password has been successfully reset! You can now log in
<div className="flex flex-col gap-5"> with your new password.
<div className="flex flex-col gap-2"> </AlertDescription>
<Label htmlFor="new-password">New Password</Label> </Alert>
<Input <Button
id="new-password" type="button"
type="password" className="w-full h-11 text-base font-semibold"
required onClick={() => {
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200" setTab("login");
value={newPassword} resetPasswordState();
onChange={e => setNewPassword(e.target.value)} }}
disabled={resetLoading} >
autoComplete="new-password" Go to Login
/> </Button>
</div> </>
<div className="flex flex-col gap-2"> )}
<Label htmlFor="confirm-password">Confirm Password</Label>
<Input {resetStep === "newPassword" && !resetSuccess && (
id="confirm-password" <>
type="password" <div className="text-center text-muted-foreground mb-4">
required <p>Enter your new password for
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200" user: <strong>{localUsername}</strong></p>
value={confirmPassword} </div>
onChange={e => setConfirmPassword(e.target.value)} <div className="flex flex-col gap-5">
disabled={resetLoading} <div className="flex flex-col gap-2">
autoComplete="new-password" <Label htmlFor="new-password">New Password</Label>
/> <Input
</div> id="new-password"
<Button type="password"
type="button" required
className="w-full h-11 text-base font-semibold" className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
disabled={resetLoading || !newPassword || !confirmPassword} value={newPassword}
onClick={completePasswordReset} onChange={e => setNewPassword(e.target.value)}
>
{resetLoading ? Spinner : "Reset Password"}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading} disabled={resetLoading}
onClick={() => { autoComplete="new-password"
setResetStep("verify"); />
setNewPassword("");
setConfirmPassword("");
}}
>
Back
</Button>
</div> </div>
</> <div className="flex flex-col gap-2">
)} <Label htmlFor="confirm-password">Confirm Password</Label>
</> <Input
)} id="confirm-password"
type="password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !newPassword || !confirmPassword}
onClick={completePasswordReset}
>
{resetLoading ? Spinner : "Reset Password"}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
Back
</Button>
</div>
</>
)}
</>
)}
</div>
) : (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={e => setLocalUsername(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div> </div>
) : ( <div className="flex flex-col gap-2">
<form className="flex flex-col gap-5" onSubmit={handleSubmit}> <Label htmlFor="password">Password</Label>
<Input id="password" type="password" required className="h-11 text-base"
value={password} onChange={e => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}/>
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label> <Label htmlFor="signup-confirm-password">Confirm Password</Label>
<Input <Input id="signup-confirm-password" type="password" required
id="username" className="h-11 text-base"
type="text" value={signupConfirmPassword}
required onChange={e => setSignupConfirmPassword(e.target.value)}
className="h-11 text-base"
value={localUsername}
onChange={e => setLocalUsername(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required className="h-11 text-base"
value={password} onChange={e => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}/> disabled={loading || internalLoggedIn}/>
</div> </div>
{tab === "signup" && ( )}
<div className="flex flex-col gap-2"> <Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
<Label htmlFor="signup-confirm-password">Confirm Password</Label> disabled={loading || internalLoggedIn}>
<Input id="signup-confirm-password" type="password" required {loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
className="h-11 text-base" </Button>
value={signupConfirmPassword} {tab === "login" && (
onChange={e => setSignupConfirmPassword(e.target.value)} <Button type="button" variant="outline"
disabled={loading || internalLoggedIn}/> className="w-full h-11 text-base font-semibold"
</div> disabled={loading || internalLoggedIn}
)} onClick={() => {
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold" setTab("reset");
disabled={loading || internalLoggedIn}> resetPasswordState();
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")} clearFormFields();
}}
>
Reset Password
</Button> </Button>
{tab === "login" && ( )}
<Button type="button" variant="outline" </form>
className="w-full h-11 text-base font-semibold" )}
disabled={loading || internalLoggedIn} </>
onClick={() => { )}
setTab("reset"); {error && (
resetPasswordState(); <Alert variant="destructive" className="mt-4">
clearFormFields(); <AlertTitle>Error</AlertTitle>
}} <AlertDescription>{error}</AlertDescription>
> </Alert>
Reset Password )}
</Button> </div>
)}
</form>
)}
</>
)}
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
); );
} }

View File

@@ -1,21 +1,21 @@
import React, { useEffect, useRef, useState } from "react"; import React, {useEffect, useRef, useState} from "react";
import {TerminalComponent} from "@/ui/apps/Terminal/TerminalComponent.tsx"; import {TerminalComponent} from "@/ui/apps/Terminal/TerminalComponent.tsx";
import {Server as ServerView} from "@/ui/apps/Server/Server.tsx"; import {Server as ServerView} from "@/ui/apps/Server/Server.tsx";
import {FileManager} from "@/ui/apps/File Manager/FileManager.tsx"; import {FileManager} from "@/ui/apps/File Manager/FileManager.tsx";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"; import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx'; import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
import * as ResizablePrimitive from "react-resizable-panels"; import * as ResizablePrimitive from "react-resizable-panels";
import { useSidebar } from "@/components/ui/sidebar.tsx"; import {useSidebar} from "@/components/ui/sidebar.tsx";
import {LucideRefreshCcw, LucideRefreshCw, RefreshCcw, RefreshCcwDot} from "lucide-react"; import {LucideRefreshCcw, LucideRefreshCw, RefreshCcw, RefreshCcwDot} from "lucide-react";
import { Button } from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
interface TerminalViewProps { interface TerminalViewProps {
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
} }
export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.ReactElement { export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactElement {
const {tabs, currentTab, allSplitScreenTab} = useTabs() as any; const {tabs, currentTab, allSplitScreenTab} = useTabs() as any;
const { state: sidebarState } = useSidebar(); const {state: sidebarState} = useSidebar();
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal' || tab.type === 'server' || tab.type === 'file_manager'); const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal' || tab.type === 'server' || tab.type === 'file_manager');
@@ -51,7 +51,6 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
}); });
}; };
// Coalesce layout → measure → fit callbacks
const layoutScheduleRef = useRef<number | null>(null); const layoutScheduleRef = useRef<number | null>(null);
const scheduleMeasureAndFit = () => { const scheduleMeasureAndFit = () => {
if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current); if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current);
@@ -63,7 +62,6 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
}); });
}; };
// Hide terminals until layout → rects → fit applied to prevent first-frame wrapping
const hideThenFit = () => { const hideThenFit = () => {
setReady(false); setReady(false);
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -77,13 +75,10 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
useEffect(() => { useEffect(() => {
hideThenFit(); hideThenFit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]); }, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]);
// When split layout toggles on/off, topbar toggles, or sidebar state changes → measure+fit
useEffect(() => { useEffect(() => {
scheduleMeasureAndFit(); scheduleMeasureAndFit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]); }, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
useEffect(() => { useEffect(() => {
@@ -93,14 +88,15 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
}) : null; }) : null;
if (containerRef.current && roContainer) roContainer.observe(containerRef.current); if (containerRef.current && roContainer) roContainer.observe(containerRef.current);
return () => roContainer?.disconnect(); return () => roContainer?.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => { useEffect(() => {
const onWinResize = () => { updatePanelRects(); fitActiveAndNotify(); }; const onWinResize = () => {
updatePanelRects();
fitActiveAndNotify();
};
window.addEventListener('resize', onWinResize); window.addEventListener('resize', onWinResize);
return () => window.removeEventListener('resize', onWinResize); return () => window.removeEventListener('resize', onWinResize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const HEADER_H = 28; const HEADER_H = 28;
@@ -109,24 +105,34 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
const styles: Record<number, React.CSSProperties> = {}; const styles: Record<number, React.CSSProperties> = {};
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id)); const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab); const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
const layoutTabs = [mainTab, ...splitTabs.filter((t:any)=> t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[]; const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
if (allSplitScreenTab.length === 0 && mainTab) { if (allSplitScreenTab.length === 0 && mainTab) {
styles[mainTab.id] = { position:'absolute', top:2, left:2, right:2, bottom:2, zIndex: 20, display: 'block', pointerEvents:'auto', opacity: ready ? 1 : 0 }; styles[mainTab.id] = {
position: 'absolute',
top: 2,
left: 2,
right: 2,
bottom: 2,
zIndex: 20,
display: 'block',
pointerEvents: 'auto',
opacity: ready ? 1 : 0
};
} else { } else {
layoutTabs.forEach((t: any) => { layoutTabs.forEach((t: any) => {
const rect = panelRects[String(t.id)]; const rect = panelRects[String(t.id)];
const parentRect = containerRef.current?.getBoundingClientRect(); const parentRect = containerRef.current?.getBoundingClientRect();
if (rect && parentRect) { if (rect && parentRect) {
styles[t.id] = { styles[t.id] = {
position:'absolute', position: 'absolute',
top: (rect.top - parentRect.top) + HEADER_H + 2, top: (rect.top - parentRect.top) + HEADER_H + 2,
left: (rect.left - parentRect.left) + 2, left: (rect.left - parentRect.left) + 2,
width: rect.width - 4, width: rect.width - 4,
height: rect.height - HEADER_H - 4, height: rect.height - HEADER_H - 4,
zIndex: 20, zIndex: 20,
display: 'block', display: 'block',
pointerEvents:'auto', pointerEvents: 'auto',
opacity: ready ? 1 : 0, opacity: ready ? 1 : 0,
}; };
} }
@@ -134,22 +140,21 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
} }
return ( return (
<div style={{position:'absolute', inset:0, zIndex:1}}> <div style={{position: 'absolute', inset: 0, zIndex: 1}}>
{terminalTabs.map((t:any) => { {terminalTabs.map((t: any) => {
const hasStyle = !!styles[t.id]; const hasStyle = !!styles[t.id];
const isVisible = hasStyle || (allSplitScreenTab.length===0 && t.id===currentTab); const isVisible = hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
// Visible style from computed positions; otherwise keep mounted but hidden and non-interactive
const finalStyle: React.CSSProperties = hasStyle const finalStyle: React.CSSProperties = hasStyle
? {...styles[t.id], overflow:'hidden'} ? {...styles[t.id], overflow: 'hidden'}
: { : {
position:'absolute', inset:0, visibility:'hidden', pointerEvents:'none', zIndex:0, position: 'absolute', inset: 0, visibility: 'hidden', pointerEvents: 'none', zIndex: 0,
} as React.CSSProperties; } as React.CSSProperties;
const effectiveVisible = isVisible && ready; const effectiveVisible = isVisible && ready;
return ( return (
<div key={t.id} style={finalStyle}> <div key={t.id} style={finalStyle}>
<div className="absolute inset-0 rounded-md" style={{background:'#18181b'}}> <div className="absolute inset-0 rounded-md" style={{background: '#18181b'}}>
{t.type === 'terminal' ? ( {t.type === 'terminal' ? (
<TerminalComponent <TerminalComponent
ref={t.terminalRef} ref={t.terminalRef}
@@ -157,7 +162,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
isVisible={effectiveVisible} isVisible={effectiveVisible}
title={t.title} title={t.title}
showTitle={false} showTitle={false}
splitScreen={allSplitScreenTab.length>0} splitScreen={allSplitScreenTab.length > 0}
/> />
) : t.type === 'server' ? ( ) : t.type === 'server' ? (
<ServerView <ServerView
@@ -181,7 +186,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
); );
}; };
const ResetButton = ({ onClick }: { onClick: () => void }) => ( const ResetButton = ({onClick}: { onClick: () => void }) => (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -189,7 +194,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
aria-label="Reset split sizes" aria-label="Reset split sizes"
className="absolute top-0 right-0 h-[28px] w-[28px] !rounded-none border-l-1 border-b-1 border-[#222224] bg-[#1b1b1e] hover:bg-[#232327] text-white flex items-center justify-center p-0" className="absolute top-0 right-0 h-[28px] w-[28px] !rounded-none border-l-1 border-b-1 border-[#222224] bg-[#1b1b1e] hover:bg-[#232327] text-white flex items-center justify-center p-0"
> >
<RefreshCcw className="h-4 w-4" /> <RefreshCcw className="h-4 w-4"/>
</Button> </Button>
); );
@@ -201,28 +206,75 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
const renderSplitOverlays = () => { const renderSplitOverlays = () => {
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id)); const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab); const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
const layoutTabs = [mainTab, ...splitTabs.filter((t:any)=> t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[]; const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
if (allSplitScreenTab.length === 0) return null; if (allSplitScreenTab.length === 0) return null;
const handleStyle = { pointerEvents:'auto', zIndex:12, background:'#303032' } as React.CSSProperties; const handleStyle = {pointerEvents: 'auto', zIndex: 12, background: '#303032'} as React.CSSProperties;
const commonGroupProps = { onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit } as any; const commonGroupProps = {onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit} as any;
if (layoutTabs.length === 2) { if (layoutTabs.length === 2) {
const [a,b] = layoutTabs as any[]; const [a, b] = layoutTabs as any[];
return ( return (
<div style={{ position:'absolute', inset:0, zIndex:10, pointerEvents:'none' }}> <div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal" className="h-full w-full" {...commonGroupProps}> <ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal"
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${a.id}`} order={1}> className="h-full w-full" {...commonGroupProps}>
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',background:'transparent',position:'relative'}}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{a.title}</div> id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{a.title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${b.id}`} order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',background:'transparent',position:'relative'}}> id={`panel-${b.id}`} order={2}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}> <div ref={el => {
panelRefs.current[String(b.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>
{b.title} {b.title}
<ResetButton onClick={handleReset} /> <ResetButton onClick={handleReset}/>
</div> </div>
</div> </div>
</ResizablePanel> </ResizablePanel>
@@ -231,32 +283,101 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
); );
} }
if (layoutTabs.length === 3) { if (layoutTabs.length === 3) {
const [a,b,c] = layoutTabs as any[]; const [a, b, c] = layoutTabs as any[];
return ( return (
<div style={{ position:'absolute', inset:0, zIndex:10, pointerEvents:'none' }}> <div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full" id="main-vertical" {...commonGroupProps}> <ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}> id="main-vertical" {...commonGroupProps}>
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal" className="h-full w-full" id="top-horizontal" {...commonGroupProps}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${a.id}`} order={1}> id="top-panel" order={1}>
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}> <ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{a.title}</div> className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{a.title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${b.id}`} order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}> id={`panel-${b.id}`} order={2}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}> <div ref={el => {
panelRefs.current[String(b.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>
{b.title} {b.title}
<ResetButton onClick={handleReset} /> <ResetButton onClick={handleReset}/>
</div> </div>
</div> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<div ref={el => { panelRefs.current[String(c.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}> id="bottom-panel" order={2}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{c.title}</div> <div ref={el => {
panelRefs.current[String(c.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{c.title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePrimitive.PanelGroup> </ResizablePrimitive.PanelGroup>
@@ -264,40 +385,133 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
); );
} }
if (layoutTabs.length === 4) { if (layoutTabs.length === 4) {
const [a,b,c,d] = layoutTabs as any[]; const [a, b, c, d] = layoutTabs as any[];
return ( return (
<div style={{ position:'absolute', inset:0, zIndex:10, pointerEvents:'none' }}> <div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full" id="main-vertical" {...commonGroupProps}> <ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}> id="main-vertical" {...commonGroupProps}>
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal" className="h-full w-full" id="top-horizontal" {...commonGroupProps}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${a.id}`} order={1}> id="top-panel" order={1}>
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}> <ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{a.title}</div> className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{a.title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${b.id}`} order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}> id={`panel-${b.id}`} order={2}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}> <div ref={el => {
panelRefs.current[String(b.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>
{b.title} {b.title}
<ResetButton onClick={handleReset} /> <ResetButton onClick={handleReset}/>
</div> </div>
</div> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id="bottom-panel" order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal" className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}> id="bottom-panel" order={2}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${c.id}`} order={1}> <ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal"
<div ref={el => { panelRefs.current[String(c.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}> className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{c.title}</div> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${c.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(c.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{c.title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${d.id}`} order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
<div ref={el => { panelRefs.current[String(d.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}> id={`panel-${d.id}`} order={2}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{d.title}</div> <div ref={el => {
panelRefs.current[String(d.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{d.title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
@@ -318,8 +532,8 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
ref={containerRef} ref={containerRef}
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden" className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
style={{ style={{
position:'relative', position: 'relative',
background:'#18181b', background: '#18181b',
marginLeft: leftMarginPx, marginLeft: leftMarginPx,
marginRight: 17, marginRight: 17,
marginTop: topMarginPx, marginTop: topMarginPx,

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, {useState} from "react";
import {CardTitle} from "@/components/ui/card.tsx"; import {CardTitle} from "@/components/ui/card.tsx";
import {ChevronDown, Folder} from "lucide-react"; import {ChevronDown, Folder} from "lucide-react";
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
@@ -35,7 +35,7 @@ interface FolderCardProps {
isLast: boolean; isLast: boolean;
} }
export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardProps): React.ReactElement { export function FolderCard({folderName, hosts, isFirst, isLast}: FolderCardProps): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(true); const [isExpanded, setIsExpanded] = useState(true);
const toggleExpanded = () => { const toggleExpanded = () => {
@@ -43,7 +43,8 @@ export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardPro
}; };
return ( return (
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden" style={{padding: '0', margin: '0'}}> <div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden"
style={{padding: '0', margin: '0'}}>
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}> <div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
<div className="flex gap-2 pr-10"> <div className="flex gap-2 pr-10">
<div className="flex-shrink-0 flex items-center"> <div className="flex-shrink-0 flex items-center">
@@ -65,10 +66,10 @@ export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardPro
<div className="flex flex-col p-2 gap-y-3"> <div className="flex flex-col p-2 gap-y-3">
{hosts.map((host, index) => ( {hosts.map((host, index) => (
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}> <React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
<Host host={host} /> <Host host={host}/>
{index < hosts.length - 1 && ( {index < hosts.length - 1 && (
<div className="relative -mx-2"> <div className="relative -mx-2">
<Separator className="p-0.25 absolute inset-x-0" /> <Separator className="p-0.25 absolute inset-x-0"/>
</div> </div>
)} )}
</React.Fragment> </React.Fragment>

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useState } from "react"; import React, {useEffect, useState} from "react";
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status"; import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import {ButtonGroup} from "@/components/ui/button-group.tsx"; import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Server, Terminal} from "lucide-react"; import {Server, Terminal} from "lucide-react";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"; import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
import { getServerStatusById } from "@/ui/main-axios.ts"; import {getServerStatusById} from "@/ui/main-axios.ts";
interface SSHHost { interface SSHHost {
id: number; id: number;
@@ -33,12 +33,12 @@ interface HostProps {
host: SSHHost; host: SSHHost;
} }
export function Host({ host }: HostProps): React.ReactElement { export function Host({host}: HostProps): React.ReactElement {
const { addTab } = useTabs(); const {addTab} = useTabs();
const [serverStatus, setServerStatus] = useState<'online' | 'offline'>('offline'); const [serverStatus, setServerStatus] = useState<'online' | 'offline'>('offline');
const tags = Array.isArray(host.tags) ? host.tags : []; const tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0; const hasTags = tags.length > 0;
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`; const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
useEffect(() => { useEffect(() => {
@@ -66,13 +66,13 @@ export function Host({ host }: HostProps): React.ReactElement {
}, [host.id]); }, [host.id]);
const handleTerminalClick = () => { const handleTerminalClick = () => {
addTab({ type: 'terminal', title, hostConfig: host }); addTab({type: 'terminal', title, hostConfig: host});
}; };
const handleServerClick = () => { const handleServerClick = () => {
addTab({ type: 'server', title, hostConfig: host }); addTab({type: 'server', title, hostConfig: host});
}; };
return ( return (
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -87,8 +87,8 @@ export function Host({ host }: HostProps): React.ReactElement {
<Server/> <Server/>
</Button> </Button>
{host.enableTerminal && ( {host.enableTerminal && (
<Button <Button
variant="outline" variant="outline"
className="!px-2 border-1 border-[#303032]" className="!px-2 border-1 border-[#303032]"
onClick={handleTerminalClick} onClick={handleTerminalClick}
> >

View File

@@ -49,7 +49,7 @@ import axios from "axios";
import {Card} from "@/components/ui/card.tsx"; import {Card} from "@/components/ui/card.tsx";
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx"; import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
import {getSSHHosts} from "@/ui/main-axios.ts"; import {getSSHHosts} from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/Navigation/Tabs/TabContext.tsx"; import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
interface SSHHost { interface SSHHost {
id: number; id: number;
@@ -102,29 +102,14 @@ const API = axios.create({
}); });
export function LeftSidebar({ export function LeftSidebar({
onSelectView, onSelectView,
getView, getView,
disabled, disabled,
isAdmin, isAdmin,
username, username,
children, children,
}: SidebarProps): React.ReactElement { }: SidebarProps): React.ReactElement {
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false); const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
const [oidcConfig, setOidcConfig] = React.useState({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
});
const [oidcLoading, setOidcLoading] = React.useState(false);
const [oidcError, setOidcError] = React.useState<string | null>(null);
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false); const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
const [deletePassword, setDeletePassword] = React.useState(""); const [deletePassword, setDeletePassword] = React.useState("");
@@ -138,21 +123,21 @@ export function LeftSidebar({
is_admin: boolean; is_admin: boolean;
is_oidc: boolean; is_oidc: boolean;
}>>([]); }>>([]);
const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState(""); const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [usersLoading, setUsersLoading] = React.useState(false);
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false); const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null); const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null); const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
const [oidcConfig, setOidcConfig] = React.useState<any>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true); const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
// Tabs context for opening SSH Manager tab const {tabs: tabList, addTab, setCurrentTab, allSplitScreenTab} = useTabs() as any;
const { tabs: tabList, addTab, setCurrentTab, allSplitScreenTab } = useTabs() as any;
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0; const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager'); const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
const openSshManagerTab = () => { const openSshManagerTab = () => {
if (sshManagerTab || isSplitScreenActive) return; if (sshManagerTab || isSplitScreenActive) return;
const id = addTab({ type: 'ssh_manager', title: 'SSH Manager' } as any); const id = addTab({type: 'ssh_manager', title: 'SSH Manager'} as any);
setCurrentTab(id); setCurrentTab(id);
}; };
const adminTab = tabList.find((t) => t.type === 'admin'); const adminTab = tabList.find((t) => t.type === 'admin');
@@ -162,11 +147,10 @@ export function LeftSidebar({
setCurrentTab(adminTab.id); setCurrentTab(adminTab.id);
return; return;
} }
const id = addTab({ type: 'admin', title: 'Admin' } as any); const id = addTab({type: 'admin', title: 'Admin'} as any);
setCurrentTab(id); setCurrentTab(id);
}; };
// SSH Hosts state management
const [hosts, setHosts] = useState<SSHHost[]>([]); const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(false); const [hostsLoading, setHostsLoading] = useState(false);
const [hostsError, setHostsError] = useState<string | null>(null); const [hostsError, setHostsError] = useState<string | null>(null);
@@ -202,23 +186,16 @@ export function LeftSidebar({
} }
}, [isAdmin]); }, [isAdmin]);
// SSH Hosts data fetching
const fetchHosts = React.useCallback(async () => { const fetchHosts = React.useCallback(async () => {
try { try {
const newHosts = await getSSHHosts(); const newHosts = await getSSHHosts();
// Show all hosts in sidebar, regardless of terminal setting
// Terminal visibility is handled in the UI components
const prevHosts = prevHostsRef.current; const prevHosts = prevHostsRef.current;
// Create a stable map of existing hosts by ID for comparison
const existingHostsMap = new Map(prevHosts.map(h => [h.id, h])); const existingHostsMap = new Map(prevHosts.map(h => [h.id, h]));
const newHostsMap = new Map(newHosts.map(h => [h.id, h])); const newHostsMap = new Map(newHosts.map(h => [h.id, h]));
// Check if there are any meaningful changes
let hasChanges = false; let hasChanges = false;
// Check for new hosts, removed hosts, or changed hosts
if (newHosts.length !== prevHosts.length) { if (newHosts.length !== prevHosts.length) {
hasChanges = true; hasChanges = true;
} else { } else {
@@ -228,8 +205,7 @@ export function LeftSidebar({
hasChanges = true; hasChanges = true;
break; break;
} }
// Only check fields that affect the display
if ( if (
newHost.name !== existingHost.name || newHost.name !== existingHost.name ||
newHost.folder !== existingHost.folder || newHost.folder !== existingHost.folder ||
@@ -245,9 +221,8 @@ export function LeftSidebar({
} }
} }
} }
if (hasChanges) { if (hasChanges) {
// Use a small delay to batch updates and reduce jittering
setTimeout(() => { setTimeout(() => {
setHosts(newHosts); setHosts(newHosts);
prevHostsRef.current = newHosts; prevHostsRef.current = newHosts;
@@ -264,7 +239,6 @@ export function LeftSidebar({
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchHosts]); }, [fetchHosts]);
// Immediate refresh when SSH hosts are changed elsewhere in the app
React.useEffect(() => { React.useEffect(() => {
const handleHostsChanged = () => { const handleHostsChanged = () => {
fetchHosts(); fetchHosts();
@@ -273,13 +247,11 @@ export function LeftSidebar({
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener); return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
}, [fetchHosts]); }, [fetchHosts]);
// Search debouncing
React.useEffect(() => { React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200); const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler); return () => clearTimeout(handler);
}, [search]); }, [search]);
// Filter and organize hosts with stable references
const filteredHosts = React.useMemo(() => { const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts; if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase(); const q = debouncedSearch.trim().toLowerCase();
@@ -323,68 +295,6 @@ export function LeftSidebar({
return [...pinned, ...rest]; return [...pinned, ...rest];
}, []); }, []);
const handleToggle = async (checked: boolean) => {
if (!isAdmin) {
return;
}
setRegLoading(true);
const jwt = getCookie("jwt");
try {
await API.patch(
"/registration-allowed",
{allowed: checked},
{headers: {Authorization: `Bearer ${jwt}`}}
);
setAllowRegistration(checked);
} catch (e) {
} finally {
setRegLoading(false);
}
};
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!isAdmin) {
return;
}
setOidcLoading(true);
setOidcError(null);
setOidcSuccess(null);
const requiredFields = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
const missingFields = requiredFields.filter(field => !oidcConfig[field as keyof typeof oidcConfig]);
if (missingFields.length > 0) {
setOidcError(`Missing required fields: ${missingFields.join(', ')}`);
setOidcLoading(false);
return;
}
const jwt = getCookie("jwt");
try {
await API.post(
"/oidc-config",
oidcConfig,
{headers: {Authorization: `Bearer ${jwt}`}}
);
setOidcSuccess("OIDC configuration updated successfully!");
} catch (err: any) {
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
} finally {
setOidcLoading(false);
}
};
const handleOIDCConfigChange = (field: string, value: string) => {
setOidcConfig(prev => ({
...prev,
[field]: value
}));
};
const handleDeleteAccount = async (e: React.FormEvent) => { const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setDeleteLoading(true); setDeleteLoading(true);
@@ -416,7 +326,7 @@ export function LeftSidebar({
if (!jwt || !isAdmin) { if (!jwt || !isAdmin) {
return; return;
} }
setUsersLoading(true); setUsersLoading(true);
try { try {
const response = await API.get("/list", { const response = await API.get("/list", {
@@ -427,7 +337,6 @@ export function LeftSidebar({
const adminUsers = response.data.users.filter((user: any) => user.is_admin); const adminUsers = response.data.users.filter((user: any) => user.is_admin);
setAdminCount(adminUsers.length); setAdminCount(adminUsers.length);
} catch (err: any) { } catch (err: any) {
console.error("Failed to fetch users:", err);
} finally { } finally {
setUsersLoading(false); setUsersLoading(false);
} }
@@ -439,7 +348,7 @@ export function LeftSidebar({
if (!jwt || !isAdmin) { if (!jwt || !isAdmin) {
return; return;
} }
try { try {
const response = await API.get("/list", { const response = await API.get("/list", {
headers: {Authorization: `Bearer ${jwt}`} headers: {Authorization: `Bearer ${jwt}`}
@@ -447,7 +356,6 @@ export function LeftSidebar({
const adminUsers = response.data.users.filter((user: any) => user.is_admin); const adminUsers = response.data.users.filter((user: any) => user.is_admin);
setAdminCount(adminUsers.length); setAdminCount(adminUsers.length);
} catch (err: any) { } catch (err: any) {
console.error("Failed to fetch admin count:", err);
} }
}; };
@@ -494,7 +402,6 @@ export function LeftSidebar({
); );
fetchUsers(); fetchUsers();
} catch (err: any) { } catch (err: any) {
console.error("Failed to remove admin status:", err);
} }
}; };
@@ -513,7 +420,6 @@ export function LeftSidebar({
}); });
fetchUsers(); fetchUsers();
} catch (err: any) { } catch (err: any) {
console.error("Failed to delete user:", err);
} }
}; };
@@ -536,14 +442,15 @@ export function LeftSidebar({
<Separator className="p-0.25"/> <Separator className="p-0.25"/>
<SidebarContent> <SidebarContent>
<SidebarGroup className="!m-0 !p-0 !-mb-2"> <SidebarGroup className="!m-0 !p-0 !-mb-2">
<Button className="m-2 flex flex-row font-semibold" variant="outline" onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive} title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}> <Button className="m-2 flex flex-row font-semibold" variant="outline"
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
<HardDrive strokeWidth="2.5"/> <HardDrive strokeWidth="2.5"/>
Host Manager Host Manager
</Button> </Button>
</SidebarGroup> </SidebarGroup>
<Separator className="p-0.25"/> <Separator className="p-0.25"/>
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2"> <SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
{/* Search Input */}
<div className="bg-[#131316] rounded-lg"> <div className="bg-[#131316] rounded-lg">
<Input <Input
value={search} value={search}
@@ -553,17 +460,16 @@ export function LeftSidebar({
autoComplete="off" autoComplete="off"
/> />
</div> </div>
{/* Error Display */}
{hostsError && ( {hostsError && (
<div className="px-1"> <div className="px-1">
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full"> <div
className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
{hostsError} {hostsError}
</div> </div>
</div> </div>
)} )}
{/* Loading State */}
{hostsLoading && ( {hostsLoading && (
<div className="px-4 pb-2"> <div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-center">
@@ -572,7 +478,6 @@ export function LeftSidebar({
</div> </div>
)} )}
{/* Hosts by Folder */}
{sortedFolders.map((folder, idx) => ( {sortedFolders.map((folder, idx) => (
<FolderCard <FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`} key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
@@ -608,7 +513,7 @@ export function LeftSidebar({
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none" className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onSelect={() => { onClick={() => {
if (isAdmin) openAdminTab(); if (isAdmin) openAdminTab();
}}> }}>
<span>Admin Settings</span> <span>Admin Settings</span>
@@ -616,12 +521,12 @@ export function LeftSidebar({
)} )}
<DropdownMenuItem <DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none" className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onSelect={handleLogout}> onClick={handleLogout}>
<span>Sign out</span> <span>Sign out</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none" className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onSelect={() => setDeleteAccountOpen(true)} onClick={() => setDeleteAccountOpen(true)}
disabled={isAdmin && adminCount <= 1} disabled={isAdmin && adminCount <= 1}
> >
<span <span
@@ -635,383 +540,74 @@ export function LeftSidebar({
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarFooter> </SidebarFooter>
{/* Admin Settings Sheet */}
{isAdmin && (
<Sheet modal={false} open={adminSheetOpen && isAdmin} onOpenChange={(open) => {
if (open && !isAdmin) return;
setAdminSheetOpen(open);
}}>
<SheetContent side="left" className="w-[700px] max-h-screen overflow-y-auto">
<SheetHeader className="px-6 pb-4">
<SheetTitle>Admin Settings</SheetTitle>
</SheetHeader>
<div className="px-6">
<Tabs defaultValue="registration" className="w-full">
<TabsList className="grid w-full grid-cols-4 mb-6">
<TabsTrigger value="registration" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
Reg
</TabsTrigger>
<TabsTrigger value="oidc" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
OIDC
</TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
Users
</TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
Admins
</TabsTrigger>
</TabsList>
{/* Registration Settings Tab */} </Sidebar>
<TabsContent value="registration" className="space-y-6"> <SidebarInset>
<div className="space-y-4"> {children}
<h3 className="text-lg font-semibold">User Registration</h3> </SidebarInset>
<label className="flex items-center gap-2"> </SidebarProvider>
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
disabled={regLoading}/>
Allow new account registration
</label>
</div>
</TabsContent>
{/* OIDC Configuration Tab */} {!isSidebarOpen && (
<TabsContent value="oidc" className="space-y-6"> <div
<div className="space-y-4"> onClick={() => setIsSidebarOpen(true)}
<h3 className="text-lg font-semibold">External Authentication className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md">
(OIDC)</h3> <ChevronRight size={10}/>
<p className="text-sm text-muted-foreground"> </div>
Configure external identity provider for OIDC/OAuth2 authentication. )}
Users will see an "External" login option once configured.
</p>
{oidcError && ( {deleteAccountOpen && (
<Alert variant="destructive"> <div
<AlertTitle>Error</AlertTitle> className="fixed inset-0 z-[999999] flex"
<AlertDescription>{oidcError}</AlertDescription> style={{
</Alert> position: 'fixed',
)} top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 999999,
pointerEvents: 'auto',
isolation: 'isolate',
transform: 'translateZ(0)',
willChange: 'z-index'
}}
>
<div
className="w-[400px] h-full bg-[#18181b] border-r-2 border-[#303032] flex flex-col shadow-2xl"
style={{
backgroundColor: '#18181b',
boxShadow: '4px 0 20px rgba(0, 0, 0, 0.5)',
zIndex: 9999999,
position: 'relative',
isolation: 'isolate',
transform: 'translateZ(0)'
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
<h2 className="text-lg font-semibold text-white">Delete Account</h2>
<Button
variant="outline"
size="sm"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title="Close Delete Account"
>
<span className="text-lg font-bold leading-none">×</span>
</Button>
</div>
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4"> <div className="flex-1 overflow-y-auto p-4">
<div className="space-y-2"> <div className="space-y-4">
<Label htmlFor="client_id">Client ID</Label> <div className="text-sm text-gray-300">
<Input
id="client_id"
value={oidcConfig.client_id}
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
placeholder="your-client-id"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="client_secret">Client Secret</Label>
<Input
id="client_secret"
type="password"
value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder="your-client-secret"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="authorization_url">Authorization URL</Label>
<Input
id="authorization_url"
value={oidcConfig.authorization_url}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
placeholder="https://your-provider.com/application/o/authorize/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="issuer_url">Issuer URL</Label>
<Input
id="issuer_url"
value={oidcConfig.issuer_url}
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
placeholder="https://your-provider.com/application/o/termix/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="token_url">Token URL</Label>
<Input
id="token_url"
value={oidcConfig.token_url}
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
placeholder="https://your-provider.com/application/o/token/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="identifier_path">User Identifier Path</Label>
<Input
id="identifier_path"
value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder="sub"
required
/>
<p className="text-xs text-muted-foreground">
JSON path to extract user ID from JWT (e.g., "sub", "email",
"preferred_username")
</p>
</div>
<div className="space-y-2">
<Label htmlFor="name_path">Display Name Path</Label>
<Input
id="name_path"
value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder="name"
required
/>
<p className="text-xs text-muted-foreground">
JSON path to extract display name from JWT (e.g., "name",
"preferred_username")
</p>
</div>
<div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label>
<Input
id="scopes"
value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
placeholder="openid email profile"
required
/>
<p className="text-xs text-muted-foreground">
Space-separated list of OAuth2 scopes to request
</p>
</div>
<div className="flex gap-2 pt-2">
<Button
type="submit"
className="flex-1"
disabled={oidcLoading}
>
{oidcLoading ? "Saving..." : "Save Configuration"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setOidcConfig({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
});
}}
>
Reset
</Button>
</div>
{oidcSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{oidcSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
</TabsContent>
{/* Users Management Tab */}
<TabsContent value="users" className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">User Management</h3>
<Button
onClick={fetchUsers}
disabled={usersLoading}
variant="outline"
size="sm"
>
{usersLoading ? "Loading..." : "Refresh"}
</Button>
</div>
{usersLoading ? (
<div className="text-center py-8 text-muted-foreground">
Loading users...
</div>
) : (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="px-4 font-medium">
{user.username}
{user.is_admin && (
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
Admin
</span>
)}
</TableCell>
<TableCell className="px-4">
{user.is_oidc ? "External" : "Local"}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() => deleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}
>
<Trash2 className="h-4 w-4"/>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
{/* Admins Management Tab */}
<TabsContent value="admins" className="space-y-6">
<div className="space-y-6">
<h3 className="text-lg font-semibold">Admin Management</h3>
{/* Add New Admin Form */}
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
<h4 className="font-medium">Make User Admin</h4>
<form onSubmit={makeUserAdmin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-admin-username">Username</Label>
<div className="flex gap-2">
<Input
id="new-admin-username"
value={newAdminUsername}
onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder="Enter username to make admin"
required
/>
<Button
type="submit"
disabled={makeAdminLoading || !newAdminUsername.trim()}
>
{makeAdminLoading ? "Adding..." : "Make Admin"}
</Button>
</div>
</div>
{makeAdminError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{makeAdminError}</AlertDescription>
</Alert>
)}
{makeAdminSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{makeAdminSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
{/* Current Admins Table */}
<div className="space-y-4">
<h4 className="font-medium">Current Admins</h4>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.filter(user => user.is_admin).map((admin) => (
<TableRow key={admin.id}>
<TableCell className="px-4 font-medium">
{admin.username}
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
Admin
</span>
</TableCell>
<TableCell className="px-4">
{admin.is_oidc ? "External" : "Local"}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() => removeAdminStatus(admin.username)}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
disabled={admin.username === username}
>
<Shield className="h-4 w-4"/>
Remove Admin
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
<SheetFooter className="px-6 pt-6 pb-6">
<Separator className="p-0.25 mt-2 mb-2"/>
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)}
{/* Delete Account Confirmation Sheet */}
<Sheet modal={false} open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
<SheetContent side="left" className="w-[400px]">
<SheetHeader className="pb-0">
<SheetTitle>Delete Account</SheetTitle>
<SheetDescription>
This action cannot be undone. This will permanently delete your account and all This action cannot be undone. This will permanently delete your account and all
associated data. associated data.
</SheetDescription> </div>
</SheetHeader>
<div className="pb-4 px-4 flex flex-col gap-4">
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>Warning</AlertTitle> <AlertTitle>Warning</AlertTitle>
<AlertDescription> <AlertDescription>
@@ -1076,19 +672,18 @@ export function LeftSidebar({
</div> </div>
</form> </form>
</div> </div>
</SheetContent> </div>
</Sheet> </div>
</Sidebar>
<SidebarInset>
{children}
</SidebarInset>
</SidebarProvider>
{!isSidebarOpen && ( <div
<div className="flex-1"
onClick={() => setIsSidebarOpen(true)} onClick={() => {
className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md"> setDeleteAccountOpen(false);
<ChevronRight size={10} /> setDeletePassword("");
setDeleteError(null);
}}
style={{cursor: 'pointer'}}
/>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,7 +1,14 @@
import React from "react"; import React from "react";
import {ButtonGroup} from "@/components/ui/button-group.tsx"; import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import {Home, SeparatorVertical, X, Terminal as TerminalIcon, Server as ServerIcon, Folder as FolderIcon} from "lucide-react"; import {
Home,
SeparatorVertical,
X,
Terminal as TerminalIcon,
Server as ServerIcon,
Folder as FolderIcon
} from "lucide-react";
interface TabProps { interface TabProps {
tabType: string; tabType: string;
@@ -17,7 +24,19 @@ interface TabProps {
disableClose?: boolean; disableClose?: boolean;
} }
export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, canSplit = false, canClose = false, disableActivate = false, disableSplit = false, disableClose = false}: TabProps): React.ReactElement { export function Tab({
tabType,
title,
isActive,
onActivate,
onClose,
onSplit,
canSplit = false,
canClose = false,
disableActivate = false,
disableSplit = false,
disableClose = false
}: TabProps): React.ReactElement {
if (tabType === "home") { if (tabType === "home") {
return ( return (
<Button <Button
@@ -42,7 +61,8 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can
onClick={onActivate} onClick={onActivate}
disabled={disableActivate} disabled={disableActivate}
> >
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ? <FolderIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>} {isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ?
<FolderIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>}
{title || (isServer ? 'Server' : isFileManager ? 'file_manager' : 'Terminal')} {title || (isServer ? 'Server' : isFileManager ? 'file_manager' : 'Terminal')}
</Button> </Button>
{canSplit && ( {canSplit && (
@@ -53,7 +73,7 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can
disabled={disableSplit} disabled={disableSplit}
title={disableSplit ? 'Cannot split this tab' : 'Split'} title={disableSplit ? 'Cannot split this tab' : 'Split'}
> >
<SeparatorVertical className="w-[28px] h-[28px]" /> <SeparatorVertical className="w-[28px] h-[28px]"/>
</Button> </Button>
)} )}
{canClose && ( {canClose && (

View File

@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useRef, type ReactNode } from 'react'; import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
export interface Tab { export interface Tab {
id: number; id: number;
@@ -33,9 +33,9 @@ interface TabProviderProps {
children: ReactNode; children: ReactNode;
} }
export function TabProvider({ children }: TabProviderProps) { export function TabProvider({children}: TabProviderProps) {
const [tabs, setTabs] = useState<Tab[]>([ const [tabs, setTabs] = useState<Tab[]>([
{ id: 1, type: 'home', title: 'Home' } {id: 1, type: 'home', title: 'Home'}
]); ]);
const [currentTab, setCurrentTab] = useState<number>(1); const [currentTab, setCurrentTab] = useState<number>(1);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]); const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
@@ -44,7 +44,6 @@ export function TabProvider({ children }: TabProviderProps) {
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string { function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal'); const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal');
const baseTitle = (desiredTitle || defaultTitle).trim(); const baseTitle = (desiredTitle || defaultTitle).trim();
// Extract base name without trailing " (n)"
const match = baseTitle.match(/^(.*) \((\d+)\)$/); const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle; const root = match ? match[1] : baseTitle;
@@ -64,7 +63,6 @@ export function TabProvider({ children }: TabProviderProps) {
}); });
if (!rootUsed) return root; if (!rootUsed) return root;
// Start at (2) for the second instance
let n = 2; let n = 2;
while (usedNumbers.has(n)) n += 1; while (usedNumbers.has(n)) n += 1;
return `${root} (${n})`; return `${root} (${n})`;
@@ -74,8 +72,8 @@ export function TabProvider({ children }: TabProviderProps) {
const id = nextTabId.current++; const id = nextTabId.current++;
const needsUniqueTitle = tabData.type === 'terminal' || tabData.type === 'server' || tabData.type === 'file_manager'; const needsUniqueTitle = tabData.type === 'terminal' || tabData.type === 'server' || tabData.type === 'file_manager';
const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : (tabData.title || ''); const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : (tabData.title || '');
const newTab: Tab = { const newTab: Tab = {
...tabData, ...tabData,
id, id,
title: effectiveTitle, title: effectiveTitle,
terminalRef: tabData.type === 'terminal' ? React.createRef<any>() : undefined terminalRef: tabData.type === 'terminal' ? React.createRef<any>() : undefined
@@ -92,10 +90,10 @@ export function TabProvider({ children }: TabProviderProps) {
if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") { if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") {
tab.terminalRef.current.disconnect(); tab.terminalRef.current.disconnect();
} }
setTabs(prev => prev.filter(tab => tab.id !== tabId)); setTabs(prev => prev.filter(tab => tab.id !== tabId));
setAllSplitScreenTab(prev => prev.filter(id => id !== tabId)); setAllSplitScreenTab(prev => prev.filter(id => id !== tabId));
if (currentTab === tabId) { if (currentTab === tabId) {
const remainingTabs = tabs.filter(tab => tab.id !== tabId); const remainingTabs = tabs.filter(tab => tab.id !== tabId);
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1); setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1);

View File

@@ -24,7 +24,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any; const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any;
const leftPosition = state === "collapsed" ? "26px" : "264px"; const leftPosition = state === "collapsed" ? "26px" : "264px";
// SSH Tools state
const [toolsSheetOpen, setToolsSheetOpen] = useState(false); const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]); const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
@@ -47,7 +46,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const handleStartRecording = () => { const handleStartRecording = () => {
setIsRecording(true); setIsRecording(true);
// Focus on the input when recording starts
setTimeout(() => { setTimeout(() => {
const input = document.getElementById('ssh-tools-input') as HTMLInputElement; const input = document.getElementById('ssh-tools-input') as HTMLInputElement;
if (input) input.focus(); if (input) input.focus();
@@ -60,7 +58,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
}; };
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Don't handle input change for special keys - let onKeyDown handle them
}; };
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -69,9 +66,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const value = e.currentTarget.value; const value = e.currentTarget.value;
let commandToSend = ''; let commandToSend = '';
// Handle special keys and control sequences
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
// Control sequences
if (e.key === 'c') { if (e.key === 'c') {
commandToSend = '\x03'; // Ctrl+C (SIGINT) commandToSend = '\x03'; // Ctrl+C (SIGINT)
e.preventDefault(); e.preventDefault();
@@ -177,7 +172,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
e.preventDefault(); e.preventDefault();
} }
// Send the command to all selected terminals
if (commandToSend) { if (commandToSend) {
selectedTabIds.forEach(tabId => { selectedTabIds.forEach(tabId => {
const tab = tabs.find((t: any) => t.id === tabId); const tab = tabs.find((t: any) => t.id === tabId);
@@ -190,8 +184,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (selectedTabIds.length === 0) return; if (selectedTabIds.length === 0) return;
// Handle regular character input
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) { if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
const char = e.key; const char = e.key;
selectedTabIds.forEach(tabId => { selectedTabIds.forEach(tabId => {
@@ -209,7 +202,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager'; const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
const currentTabIsAdmin = currentTabObj?.type === 'admin'; const currentTabIsAdmin = currentTabObj?.type === 'admin';
// Get terminal tabs for selection
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal'); const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal');
function getCookie(name: string) { function getCookie(name: string) {
@@ -237,7 +229,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
padding: "0" padding: "0"
}} }}
> >
<div className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar"> <div
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
{tabs.map((tab: any) => { {tabs.map((tab: any) => {
const isActive = tab.id === currentTab; const isActive = tab.id === currentTab;
const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id); const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id);
@@ -246,9 +239,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const isFileManager = tab.type === 'file_manager'; const isFileManager = tab.type === 'file_manager';
const isSshManager = tab.type === 'ssh_manager'; const isSshManager = tab.type === 'ssh_manager';
const isAdmin = tab.type === 'admin'; const isAdmin = tab.type === 'admin';
// Split availability
const isSplittable = isTerminal || isServer || isFileManager; const isSplittable = isTerminal || isServer || isFileManager;
// Disable split entirely when on Home or SSH Manager
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit); const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin; const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive); const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive);
@@ -296,13 +287,12 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
<div <div
onClick={() => setIsTopbarOpen(true)} onClick={() => setIsTopbarOpen(true)}
className="absolute top-0 left-0 w-full h-[10px] bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md"> className="absolute top-0 left-0 w-full h-[10px] bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md">
<ChevronDown size={10} /> <ChevronDown size={10}/>
</div> </div>
)} )}
{/* Custom SSH Tools Overlay */}
{toolsSheetOpen && ( {toolsSheetOpen && (
<div <div
className="fixed inset-0 z-[999999] flex justify-end" className="fixed inset-0 z-[999999] flex justify-end"
style={{ style={{
position: 'fixed', position: 'fixed',
@@ -316,13 +306,13 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
transform: 'translateZ(0)' transform: 'translateZ(0)'
}} }}
> >
<div <div
className="flex-1" className="flex-1"
onClick={() => setToolsSheetOpen(false)} onClick={() => setToolsSheetOpen(false)}
style={{ cursor: 'pointer' }} style={{cursor: 'pointer'}}
/> />
<div <div
className="w-[400px] h-full bg-[#18181b] border-l-2 border-[#303032] flex flex-col shadow-2xl" className="w-[400px] h-full bg-[#18181b] border-l-2 border-[#303032] flex flex-col shadow-2xl"
style={{ style={{
backgroundColor: '#18181b', backgroundColor: '#18181b',
@@ -346,7 +336,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
<span className="text-lg font-bold leading-none">×</span> <span className="text-lg font-bold leading-none">×</span>
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4"> <div className="space-y-4">
<h1 className="font-semibold"> <h1 className="font-semibold">
@@ -374,11 +364,12 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
</Button> </Button>
)} )}
</div> </div>
{isRecording && ( {isRecording && (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-white">Select terminals:</label> <label className="text-sm font-medium text-white">Select
terminals:</label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2"> <div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
{terminalTabs.map(tab => ( {terminalTabs.map(tab => (
<Button <Button
@@ -387,8 +378,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
variant="outline" variant="outline"
size="sm" size="sm"
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${ className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
selectedTabIds.includes(tab.id) selectedTabIds.includes(tab.id)
? 'bg-blue-600 text-white border-blue-700 hover:bg-blue-700' ? 'bg-blue-600 text-white border-blue-700 hover:bg-blue-700'
: 'bg-transparent text-gray-300 border-gray-500 hover:bg-gray-700' : 'bg-transparent text-gray-300 border-gray-500 hover:bg-gray-700'
}`} }`}
onClick={() => handleTabToggle(tab.id)} onClick={() => handleTabToggle(tab.id)}
@@ -398,9 +389,10 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
))} ))}
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-white">Type commands (all keys supported):</label> <label className="text-sm font-medium text-white">Type commands (all
keys supported):</label>
<Input <Input
id="ssh-tools-input" id="ssh-tools-input"
placeholder="Type here" placeholder="Type here"
@@ -411,7 +403,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
readOnly readOnly
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Commands will be sent to {selectedTabIds.length} selected terminal(s). Commands will be sent to {selectedTabIds.length} selected
terminal(s).
</p> </p>
</div> </div>
</> </>
@@ -426,8 +419,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
</h1> </h1>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="enable-copy-paste" id="enable-copy-paste"
onCheckedChange={updateRightClickCopyPaste} onCheckedChange={updateRightClickCopyPaste}
defaultChecked={getCookie("rightClickCopyPaste") === "true"} defaultChecked={getCookie("rightClickCopyPaste") === "true"}
/> />

View File

@@ -74,11 +74,9 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null); const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
// New state for operations
const [showOperations, setShowOperations] = useState(false); const [showOperations, setShowOperations] = useState(false);
const [currentPath, setCurrentPath] = useState('/'); const [currentPath, setCurrentPath] = useState('/');
// Delete modal state
const [deletingItem, setDeletingItem] = useState<any | null>(null); const [deletingItem, setDeletingItem] = useState<any | null>(null);
const sidebarRef = useRef<any>(null); const sidebarRef = useRef<any>(null);
@@ -86,7 +84,6 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
useEffect(() => { useEffect(() => {
if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) { if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) {
setCurrentHost(initialHost); setCurrentHost(initialHost);
// Defer to ensure sidebar is mounted
setTimeout(() => { setTimeout(() => {
try { try {
const path = initialHost.defaultPath || '/'; const path = initialHost.defaultPath || '/';
@@ -448,16 +445,13 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
} }
}; };
// Host is locked; no external host change from UI
const handleHostChange = (_host: SSHHost | null) => { const handleHostChange = (_host: SSHHost | null) => {
}; };
const handleOperationComplete = () => { const handleOperationComplete = () => {
// Refresh the sidebar files
if (sidebarRef.current && sidebarRef.current.fetchFiles) { if (sidebarRef.current && sidebarRef.current.fetchFiles) {
sidebarRef.current.fetchFiles(); sidebarRef.current.fetchFiles();
} }
// Refresh home data
if (currentHost) { if (currentHost) {
fetchHomeData(); fetchHomeData();
} }
@@ -471,22 +465,19 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
toast.error(error); toast.error(error);
}; };
// Function to update current path from sidebar
const updateCurrentPath = (newPath: string) => { const updateCurrentPath = (newPath: string) => {
setCurrentPath(newPath); setCurrentPath(newPath);
}; };
// Function to handle delete from sidebar
const handleDeleteFromSidebar = (item: any) => { const handleDeleteFromSidebar = (item: any) => {
setDeletingItem(item); setDeletingItem(item);
}; };
// Function to perform the actual delete
const performDelete = async (item: any) => { const performDelete = async (item: any) => {
if (!currentHost?.id) return; if (!currentHost?.id) return;
try { try {
const { deleteSSHItem } = await import('@/ui/main-axios.ts'); const {deleteSSHItem} = await import('@/ui/main-axios.ts');
await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory'); await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`); toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`);
setDeletingItem(null); setDeletingItem(null);
@@ -552,8 +543,8 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
</div> </div>
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 50, zIndex: 30}}> <div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 50, zIndex: 30}}>
<div className="flex items-center w-full bg-[#18181b] border-b-2 border-[#303032] h-[50px] relative"> <div className="flex items-center w-full bg-[#18181b] border-b-2 border-[#303032] h-[50px] relative">
{/* Tab list scrollable area */} <div
<div className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar"> className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
<FIleManagerTopNavbar <FIleManagerTopNavbar
tabs={tabs.map(t => ({id: t.id, title: t.title}))} tabs={tabs.map(t => ({id: t.id, title: t.title}))}
activeTab={activeTab} activeTab={activeTab}
@@ -577,7 +568,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
)} )}
title="File Operations" title="File Operations"
> >
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4"/>
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -591,7 +582,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : '' activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : ''
)} )}
> >
{isSaving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} {isSaving ? <RefreshCw className="h-4 w-4 animate-spin"/> : <Save className="h-4 w-4"/>}
</Button> </Button>
</div> </div>
</div> </div>
@@ -608,9 +599,6 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column'
}}> }}>
{/* Success/Error Messages */}
{/* The custom alert divs are removed, so this block is no longer needed. */}
{activeTab === 'home' ? ( {activeTab === 'home' ? (
<div className="flex h-full"> <div className="flex h-full">
<div className="flex-1"> <div className="flex-1">
@@ -658,17 +646,14 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
)} )}
</div> </div>
{/* Delete Confirmation Modal */}
{deletingItem && ( {deletingItem && (
<div className="fixed inset-0 z-[99999]"> <div className="fixed inset-0 z-[99999]">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60"></div> <div className="absolute inset-0 bg-black/60"></div>
{/* Modal */}
<div className="relative h-full flex items-center justify-center"> <div className="relative h-full flex items-center justify-center">
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 max-w-md mx-4 shadow-2xl"> <div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 max-w-md mx-4 shadow-2xl">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> <h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Trash2 className="w-5 h-5 text-red-400" /> <Trash2 className="w-5 h-5 text-red-400"/>
Confirm Delete Confirm Delete
</h3> </h3>
<p className="text-white mb-4"> <p className="text-white mb-4">

View File

@@ -32,17 +32,17 @@ interface FileManagerHomeViewProps {
} }
export function FileManagerHomeView({ export function FileManagerHomeView({
recent, recent,
pinned, pinned,
shortcuts, shortcuts,
onOpenFile, onOpenFile,
onRemoveRecent, onRemoveRecent,
onPinFile, onPinFile,
onUnpinFile, onUnpinFile,
onOpenShortcut, onOpenShortcut,
onRemoveShortcut, onRemoveShortcut,
onAddShortcut onAddShortcut
}: FileManagerHomeViewProps) { }: FileManagerHomeViewProps) {
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent'); const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
const [newShortcut, setNewShortcut] = useState(''); const [newShortcut, setNewShortcut] = useState('');
@@ -128,7 +128,8 @@ export function FileManagerHomeView({
</TabsList> </TabsList>
<TabsContent value="recent" className="mt-0"> <TabsContent value="recent" className="mt-0">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full"> <div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{recent.length === 0 ? ( {recent.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full"> <div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No recent files.</span> <span className="text-sm text-muted-foreground">No recent files.</span>
@@ -145,7 +146,8 @@ export function FileManagerHomeView({
</TabsContent> </TabsContent>
<TabsContent value="pinned" className="mt-0"> <TabsContent value="pinned" className="mt-0">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full"> <div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{pinned.length === 0 ? ( {pinned.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full"> <div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No pinned files.</span> <span className="text-sm text-muted-foreground">No pinned files.</span>
@@ -190,7 +192,8 @@ export function FileManagerHomeView({
Add Add
</Button> </Button>
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full"> <div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{shortcuts.length === 0 ? ( {shortcuts.length === 0 ? (
<div className="flex items-center justify-center py-4 col-span-full"> <div className="flex items-center justify-center py-4 col-span-full">
<span className="text-sm text-muted-foreground">No shortcuts.</span> <span className="text-sm text-muted-foreground">No shortcuts.</span>

View File

@@ -83,7 +83,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
}>>({}); }>>({});
const [fetchingFiles, setFetchingFiles] = useState(false); const [fetchingFiles, setFetchingFiles] = useState(false);
// Context menu state
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
visible: boolean; visible: boolean;
x: number; x: number;
@@ -96,21 +95,18 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
item: null item: null
}); });
// Rename state
const [renamingItem, setRenamingItem] = useState<{ const [renamingItem, setRenamingItem] = useState<{
item: any; item: any;
newName: string; newName: string;
} | null>(null); } | null>(null);
useEffect(() => { useEffect(() => {
// when host changes, set path and connect
const nextPath = host?.defaultPath || '/'; const nextPath = host?.defaultPath || '/';
setCurrentPath(nextPath); setCurrentPath(nextPath);
onPathChange?.(nextPath); onPathChange?.(nextPath);
(async () => { (async () => {
await connectToSSH(host); await connectToSSH(host);
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [host?.id]); }, [host?.id]);
async function connectToSSH(server: SSHHost): Promise<string | null> { async function connectToSSH(server: SSHHost): Promise<string | null> {
@@ -282,35 +278,28 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
const handleContextMenu = (e: React.MouseEvent, item: any) => { const handleContextMenu = (e: React.MouseEvent, item: any) => {
e.preventDefault(); e.preventDefault();
// Get viewport dimensions
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
// Context menu dimensions (approximate) const menuWidth = 160;
const menuWidth = 160; // min-w-[160px] const menuHeight = 80;
const menuHeight = 80; // Approximate height for 2 menu items
// Calculate position
let x = e.clientX; let x = e.clientX;
let y = e.clientY; let y = e.clientY;
// Adjust X position if menu would go off right edge
if (x + menuWidth > viewportWidth) { if (x + menuWidth > viewportWidth) {
x = e.clientX - menuWidth; x = e.clientX - menuWidth;
} }
// Adjust Y position if menu would go off bottom edge
if (y + menuHeight > viewportHeight) { if (y + menuHeight > viewportHeight) {
y = e.clientY - menuHeight; y = e.clientY - menuHeight;
} }
// Ensure menu doesn't go off left edge
if (x < 0) { if (x < 0) {
x = 0; x = 0;
} }
// Ensure menu doesn't go off top edge
if (y < 0) { if (y < 0) {
y = 0; y = 0;
} }
@@ -369,12 +358,10 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
}; };
const startDelete = (item: any) => { const startDelete = (item: any) => {
// Call the parent's delete handler instead of managing locally
onDeleteItem?.(item); onDeleteItem?.(item);
closeContextMenu(); closeContextMenu();
}; };
// Close context menu when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = () => closeContextMenu(); const handleClickOutside = () => closeContextMenu();
document.addEventListener('click', handleClickOutside); document.addEventListener('click', handleClickOutside);
@@ -392,7 +379,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
<div className="flex-1 w-full h-full flex flex-col bg-[#09090b] border-r-2 border-[#303032] overflow-hidden p-0 relative min-h-0"> <div className="flex-1 w-full h-full flex flex-col bg-[#09090b] border-r-2 border-[#303032] overflow-hidden p-0 relative min-h-0">
{host && ( {host && (
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}> <div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
<div className="flex items-center gap-2 px-2 py-2 border-b-2 border-[#303032] bg-[#18181b] z-20" style={{maxWidth: 260}}> <div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-[#303032] bg-[#18181b] z-20" style={{maxWidth: 260}}>
<Button <Button
size="icon" size="icon"
variant="outline" variant="outline"
@@ -440,7 +427,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
{filteredFiles.map((item: any) => { {filteredFiles.map((item: any) => {
const isOpen = (tabs || []).some((t: any) => t.id === item.path); const isOpen = (tabs || []).some((t: any) => t.id === item.path);
const isRenaming = renamingItem?.item?.path === item.path; const isRenaming = renamingItem?.item?.path === item.path;
const isDeleting = false; // Deletion is handled by parent const isDeleting = false;
return ( return (
<div <div
@@ -519,7 +506,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
)); ));
} }
} catch (err) { } catch (err) {
console.error('Failed to pin/unpin file:', err);
} }
}} }}
> >
@@ -555,7 +541,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
</div> </div>
</div> </div>
{/* Context Menu */}
{contextMenu.visible && contextMenu.item && ( {contextMenu.visible && contextMenu.item && (
<div <div
className="fixed z-[99998] bg-[#18181b] border-2 border-[#303032] rounded-lg shadow-xl py-1 min-w-[160px]" className="fixed z-[99998] bg-[#18181b] border-2 border-[#303032] rounded-lg shadow-xl py-1 min-w-[160px]"

View File

@@ -42,69 +42,27 @@ interface FileManagerLeftSidebarVileViewerProps {
} }
export function FileManagerLeftSidebarFileViewer({ export function FileManagerLeftSidebarFileViewer({
sshConnections, sshConnections,
onAddSSH, onAddSSH,
onConnectSSH, onConnectSSH,
onEditSSH, onEditSSH,
onDeleteSSH, onDeleteSSH,
onPinSSH, onPinSSH,
currentPath, currentPath,
files, files,
onOpenFile, onOpenFile,
onOpenFolder, onOpenFolder,
onStarFile, onStarFile,
onDeleteFile, onDeleteFile,
isLoading, isLoading,
error, error,
isSSHMode, isSSHMode,
onSwitchToLocal, onSwitchToLocal,
onSwitchToSSH, onSwitchToSSH,
currentSSH, currentSSH,
}: FileManagerLeftSidebarVileViewerProps) { }: FileManagerLeftSidebarVileViewerProps) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* SSH Connections */}
<div className="p-2 bg-[#18181b] border-b-2 border-[#303032]">
<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 && <Pin className="w-3 h-3 ml-1 text-yellow-400"/>}
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}>
<Pin
className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
</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="flex-1 bg-[#09090b] p-2 overflow-y-auto">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span <span

View File

@@ -1,21 +1,21 @@
import React, { useState, useRef } from 'react'; import React, {useState, useRef} from 'react';
import { Button } from '@/components/ui/button.tsx'; import {Button} from '@/components/ui/button.tsx';
import { Input } from '@/components/ui/input.tsx'; import {Input} from '@/components/ui/input.tsx';
import { Card } from '@/components/ui/card.tsx'; import {Card} from '@/components/ui/card.tsx';
import { Separator } from '@/components/ui/separator.tsx'; import {Separator} from '@/components/ui/separator.tsx';
import { import {
Upload, Upload,
FilePlus, FilePlus,
FolderPlus, FolderPlus,
Trash2, Trash2,
Edit3, Edit3,
X, X,
Check, Check,
AlertCircle, AlertCircle,
FileText, FileText,
Folder Folder
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils.ts'; import {cn} from '@/lib/utils.ts';
interface FileManagerOperationsProps { interface FileManagerOperationsProps {
currentPath: string; currentPath: string;
@@ -26,18 +26,18 @@ interface FileManagerOperationsProps {
} }
export function FileManagerOperations({ export function FileManagerOperations({
currentPath, currentPath,
sshSessionId, sshSessionId,
onOperationComplete, onOperationComplete,
onError, onError,
onSuccess onSuccess
}: FileManagerOperationsProps) { }: FileManagerOperationsProps) {
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
const [showCreateFile, setShowCreateFile] = useState(false); const [showCreateFile, setShowCreateFile] = useState(false);
const [showCreateFolder, setShowCreateFolder] = useState(false); const [showCreateFolder, setShowCreateFolder] = useState(false);
const [showDelete, setShowDelete] = useState(false); const [showDelete, setShowDelete] = useState(false);
const [showRename, setShowRename] = useState(false); const [showRename, setShowRename] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null); const [uploadFile, setUploadFile] = useState<File | null>(null);
const [newFileName, setNewFileName] = useState(''); const [newFileName, setNewFileName] = useState('');
const [newFolderName, setNewFolderName] = useState(''); const [newFolderName, setNewFolderName] = useState('');
@@ -46,18 +46,18 @@ export function FileManagerOperations({
const [renamePath, setRenamePath] = useState(''); const [renamePath, setRenamePath] = useState('');
const [renameIsDirectory, setRenameIsDirectory] = useState(false); const [renameIsDirectory, setRenameIsDirectory] = useState(false);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileUpload = async () => { const handleFileUpload = async () => {
if (!uploadFile || !sshSessionId) return; if (!uploadFile || !sshSessionId) return;
setIsLoading(true); setIsLoading(true);
try { try {
const content = await uploadFile.text(); const content = await uploadFile.text();
const { uploadSSHFile } = await import('@/ui/main-axios.ts'); const {uploadSSHFile} = await import('@/ui/main-axios.ts');
await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content); await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
onSuccess(`File "${uploadFile.name}" uploaded successfully`); onSuccess(`File "${uploadFile.name}" uploaded successfully`);
setShowUpload(false); setShowUpload(false);
@@ -72,11 +72,11 @@ export function FileManagerOperations({
const handleCreateFile = async () => { const handleCreateFile = async () => {
if (!newFileName.trim() || !sshSessionId) return; if (!newFileName.trim() || !sshSessionId) return;
setIsLoading(true); setIsLoading(true);
try { try {
const { createSSHFile } = await import('@/ui/main-axios.ts'); const {createSSHFile} = await import('@/ui/main-axios.ts');
await createSSHFile(sshSessionId, currentPath, newFileName.trim()); await createSSHFile(sshSessionId, currentPath, newFileName.trim());
onSuccess(`File "${newFileName.trim()}" created successfully`); onSuccess(`File "${newFileName.trim()}" created successfully`);
setShowCreateFile(false); setShowCreateFile(false);
@@ -91,11 +91,11 @@ export function FileManagerOperations({
const handleCreateFolder = async () => { const handleCreateFolder = async () => {
if (!newFolderName.trim() || !sshSessionId) return; if (!newFolderName.trim() || !sshSessionId) return;
setIsLoading(true); setIsLoading(true);
try { try {
const { createSSHFolder } = await import('@/ui/main-axios.ts'); const {createSSHFolder} = await import('@/ui/main-axios.ts');
await createSSHFolder(sshSessionId, currentPath, newFolderName.trim()); await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
onSuccess(`Folder "${newFolderName.trim()}" created successfully`); onSuccess(`Folder "${newFolderName.trim()}" created successfully`);
setShowCreateFolder(false); setShowCreateFolder(false);
@@ -110,11 +110,11 @@ export function FileManagerOperations({
const handleDelete = async () => { const handleDelete = async () => {
if (!deletePath || !sshSessionId) return; if (!deletePath || !sshSessionId) return;
setIsLoading(true); setIsLoading(true);
try { try {
const { deleteSSHItem } = await import('@/ui/main-axios.ts'); const {deleteSSHItem} = await import('@/ui/main-axios.ts');
await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory); await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
onSuccess(`${deleteIsDirectory ? 'Folder' : 'File'} deleted successfully`); onSuccess(`${deleteIsDirectory ? 'Folder' : 'File'} deleted successfully`);
setShowDelete(false); setShowDelete(false);
@@ -130,11 +130,11 @@ export function FileManagerOperations({
const handleRename = async () => { const handleRename = async () => {
if (!renamePath || !newName.trim() || !sshSessionId) return; if (!renamePath || !newName.trim() || !sshSessionId) return;
setIsLoading(true); setIsLoading(true);
try { try {
const { renameSSHItem } = await import('@/ui/main-axios.ts'); const {renameSSHItem} = await import('@/ui/main-axios.ts');
await renameSSHItem(sshSessionId, renamePath, newName.trim()); await renameSSHItem(sshSessionId, renamePath, newName.trim());
onSuccess(`${renameIsDirectory ? 'Folder' : 'File'} renamed successfully`); onSuccess(`${renameIsDirectory ? 'Folder' : 'File'} renamed successfully`);
setShowRename(false); setShowRename(false);
@@ -179,7 +179,7 @@ export function FileManagerOperations({
if (!sshSessionId) { if (!sshSessionId) {
return ( return (
<div className="p-4 text-center"> <div className="p-4 text-center">
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2" /> <AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2"/>
<p className="text-sm text-muted-foreground">Connect to SSH to use file operations</p> <p className="text-sm text-muted-foreground">Connect to SSH to use file operations</p>
</div> </div>
); );
@@ -187,7 +187,6 @@ export function FileManagerOperations({
return ( return (
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
{/* Operation Buttons */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Button <Button
variant="outline" variant="outline"
@@ -195,7 +194,7 @@ export function FileManagerOperations({
onClick={() => setShowUpload(true)} onClick={() => setShowUpload(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]" className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
> >
<Upload className="w-4 h-4 mr-2" /> <Upload className="w-4 h-4 mr-2"/>
Upload File Upload File
</Button> </Button>
<Button <Button
@@ -204,7 +203,7 @@ export function FileManagerOperations({
onClick={() => setShowCreateFile(true)} onClick={() => setShowCreateFile(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]" className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
> >
<FilePlus className="w-4 h-4 mr-2" /> <FilePlus className="w-4 h-4 mr-2"/>
New File New File
</Button> </Button>
<Button <Button
@@ -213,7 +212,7 @@ export function FileManagerOperations({
onClick={() => setShowCreateFolder(true)} onClick={() => setShowCreateFolder(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]" className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
> >
<FolderPlus className="w-4 h-4 mr-2" /> <FolderPlus className="w-4 h-4 mr-2"/>
New Folder New Folder
</Button> </Button>
<Button <Button
@@ -222,7 +221,7 @@ export function FileManagerOperations({
onClick={() => setShowRename(true)} onClick={() => setShowRename(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]" className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
> >
<Edit3 className="w-4 h-4 mr-2" /> <Edit3 className="w-4 h-4 mr-2"/>
Rename Rename
</Button> </Button>
<Button <Button
@@ -231,29 +230,27 @@ export function FileManagerOperations({
onClick={() => setShowDelete(true)} onClick={() => setShowDelete(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2" className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2"/>
Delete Item Delete Item
</Button> </Button>
</div> </div>
{/* Current Path Display */}
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-3"> <div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-3">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Folder className="w-4 h-4 text-blue-400" /> <Folder className="w-4 h-4 text-blue-400"/>
<span className="text-muted-foreground">Current Path:</span> <span className="text-muted-foreground">Current Path:</span>
<span className="text-white font-mono truncate">{currentPath}</span> <span className="text-white font-mono truncate">{currentPath}</span>
</div> </div>
</div> </div>
<Separator className="p-0.25 bg-[#303032]" /> <Separator className="p-0.25 bg-[#303032]"/>
{/* Upload File Modal */}
{showUpload && ( {showUpload && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4"> <Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div> <div>
<h3 className="text-lg font-semibold text-white flex items-center gap-2"> <h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Upload className="w-5 h-5" /> <Upload className="w-5 h-5"/>
Upload File Upload File
</h3> </h3>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
@@ -266,15 +263,15 @@ export function FileManagerOperations({
onClick={() => setShowUpload(false)} onClick={() => setShowUpload(false)}
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4"/>
</Button> </Button>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="border-2 border-dashed border-[#434345] rounded-lg p-6 text-center"> <div className="border-2 border-dashed border-[#434345] rounded-lg p-6 text-center">
{uploadFile ? ( {uploadFile ? (
<div className="space-y-2"> <div className="space-y-2">
<FileText className="w-8 h-8 text-blue-400 mx-auto" /> <FileText className="w-8 h-8 text-blue-400 mx-auto"/>
<p className="text-white font-medium">{uploadFile.name}</p> <p className="text-white font-medium">{uploadFile.name}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{(uploadFile.size / 1024).toFixed(2)} KB {(uploadFile.size / 1024).toFixed(2)} KB
@@ -290,7 +287,7 @@ export function FileManagerOperations({
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<Upload className="w-8 h-8 text-muted-foreground mx-auto" /> <Upload className="w-8 h-8 text-muted-foreground mx-auto"/>
<p className="text-white">Click to select a file</p> <p className="text-white">Click to select a file</p>
<Button <Button
variant="outline" variant="outline"
@@ -302,7 +299,7 @@ export function FileManagerOperations({
</div> </div>
)} )}
</div> </div>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -310,7 +307,7 @@ export function FileManagerOperations({
className="hidden" className="hidden"
accept="*/*" accept="*/*"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={handleFileUpload} onClick={handleFileUpload}
@@ -331,12 +328,11 @@ export function FileManagerOperations({
</Card> </Card>
)} )}
{/* Create File Modal */}
{showCreateFile && ( {showCreateFile && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4"> <Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2"> <h3 className="text-lg font-semibold text-white flex items-center gap-2">
<FilePlus className="w-5 h-5" /> <FilePlus className="w-5 h-5"/>
Create New File Create New File
</h3> </h3>
<Button <Button
@@ -345,10 +341,10 @@ export function FileManagerOperations({
onClick={() => setShowCreateFile(false)} onClick={() => setShowCreateFile(false)}
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4"/>
</Button> </Button>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="text-sm font-medium text-white mb-2 block"> <label className="text-sm font-medium text-white mb-2 block">
@@ -362,7 +358,7 @@ export function FileManagerOperations({
onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()} onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()}
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={handleCreateFile} onClick={handleCreateFile}
@@ -383,12 +379,11 @@ export function FileManagerOperations({
</Card> </Card>
)} )}
{/* Create Folder Modal */}
{showCreateFolder && ( {showCreateFolder && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4"> <Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2"> <h3 className="text-lg font-semibold text-white flex items-center gap-2">
<FolderPlus className="w-5 h-5" /> <FolderPlus className="w-5 h-5"/>
Create New Folder Create New Folder
</h3> </h3>
<Button <Button
@@ -397,10 +392,10 @@ export function FileManagerOperations({
onClick={() => setShowCreateFolder(false)} onClick={() => setShowCreateFolder(false)}
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4"/>
</Button> </Button>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="text-sm font-medium text-white mb-2 block"> <label className="text-sm font-medium text-white mb-2 block">
@@ -414,7 +409,7 @@ export function FileManagerOperations({
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()} onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={handleCreateFolder} onClick={handleCreateFolder}
@@ -435,12 +430,11 @@ export function FileManagerOperations({
</Card> </Card>
)} )}
{/* Delete Modal */}
{showDelete && ( {showDelete && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4"> <Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2"> <h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Trash2 className="w-5 h-5 text-red-400" /> <Trash2 className="w-5 h-5 text-red-400"/>
Delete Item Delete Item
</h3> </h3>
<Button <Button
@@ -449,18 +443,18 @@ export function FileManagerOperations({
onClick={() => setShowDelete(false)} onClick={() => setShowDelete(false)}
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4"/>
</Button> </Button>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3"> <div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
<div className="flex items-center gap-2 text-red-300"> <div className="flex items-center gap-2 text-red-300">
<AlertCircle className="w-4 h-4" /> <AlertCircle className="w-4 h-4"/>
<span className="text-sm font-medium">Warning: This action cannot be undone</span> <span className="text-sm font-medium">Warning: This action cannot be undone</span>
</div> </div>
</div> </div>
<div> <div>
<label className="text-sm font-medium text-white mb-2 block"> <label className="text-sm font-medium text-white mb-2 block">
Item Path Item Path
@@ -472,7 +466,7 @@ export function FileManagerOperations({
className="bg-[#23232a] border-2 border-[#434345] text-white" className="bg-[#23232a] border-2 border-[#434345] text-white"
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
@@ -485,7 +479,7 @@ export function FileManagerOperations({
This is a directory (will delete recursively) This is a directory (will delete recursively)
</label> </label>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={handleDelete} onClick={handleDelete}
@@ -507,12 +501,11 @@ export function FileManagerOperations({
</Card> </Card>
)} )}
{/* Rename Modal */}
{showRename && ( {showRename && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4"> <Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2"> <h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Edit3 className="w-5 h-5" /> <Edit3 className="w-5 h-5"/>
Rename Item Rename Item
</h3> </h3>
<Button <Button
@@ -521,10 +514,10 @@ export function FileManagerOperations({
onClick={() => setShowRename(false)} onClick={() => setShowRename(false)}
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4"/>
</Button> </Button>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="text-sm font-medium text-white mb-2 block"> <label className="text-sm font-medium text-white mb-2 block">
@@ -537,7 +530,7 @@ export function FileManagerOperations({
className="bg-[#23232a] border-2 border-[#434345] text-white" className="bg-[#23232a] border-2 border-[#434345] text-white"
/> />
</div> </div>
<div> <div>
<label className="text-sm font-medium text-white mb-2 block"> <label className="text-sm font-medium text-white mb-2 block">
New Name New Name
@@ -550,7 +543,7 @@ export function FileManagerOperations({
onKeyDown={(e) => e.key === 'Enter' && handleRename()} onKeyDown={(e) => e.key === 'Enter' && handleRename()}
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
@@ -563,7 +556,7 @@ export function FileManagerOperations({
This is a directory This is a directory
</label> </label>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={handleRename} onClick={handleRename}

View File

@@ -6,97 +6,96 @@ import {HostManagerHostEditor} from "@/ui/apps/Host Manager/HostManagerHostEdito
import {useSidebar} from "@/components/ui/sidebar.tsx"; import {useSidebar} from "@/components/ui/sidebar.tsx";
interface HostManagerProps { interface HostManagerProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
} }
interface SSHHost { interface SSHHost {
id: number; id: number;
name: string; name: string;
ip: string; ip: string;
port: number; port: number;
username: string; username: string;
folder: string; folder: string;
tags: string[]; tags: string[];
pin: boolean; pin: boolean;
authType: string; authType: string;
password?: string; password?: string;
key?: string; key?: string;
keyPassword?: string; keyPassword?: string;
keyType?: string; keyType?: string;
enableTerminal: boolean; enableTerminal: boolean;
enableTunnel: boolean; enableTunnel: boolean;
enableFileManager: boolean; enableFileManager: boolean;
defaultPath: string; defaultPath: string;
tunnelConnections: any[]; tunnelConnections: any[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement { export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
const [activeTab, setActiveTab] = useState("host_viewer"); const [activeTab, setActiveTab] = useState("host_viewer");
const [editingHost, setEditingHost] = useState<SSHHost | null>(null); const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
const {state: sidebarState} = useSidebar(); const {state: sidebarState} = useSidebar();
const handleEditHost = (host: SSHHost) => { const handleEditHost = (host: SSHHost) => {
setEditingHost(host); setEditingHost(host);
setActiveTab("add_host"); setActiveTab("add_host");
}; };
const handleFormSubmit = () => { const handleFormSubmit = () => {
setEditingHost(null); setEditingHost(null);
setActiveTab("host_viewer"); setActiveTab("host_viewer");
}; };
const handleTabChange = (value: string) => { const handleTabChange = (value: string) => {
setActiveTab(value); setActiveTab(value);
if (value === "host_viewer") { if (value === "host_viewer") {
setEditingHost(null); setEditingHost(null);
} }
}; };
// Dynamic margins similar to TerminalView but with 16px gaps when retracted const topMarginPx = isTopbarOpen ? 74 : 26;
const topMarginPx = isTopbarOpen ? 74 : 26; const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8; const bottomMarginPx = 8;
const bottomMarginPx = 8;
return ( return (
<div> <div>
<div className="w-full"> <div className="w-full">
<div <div
className="bg-[#18181b] text-white p-4 pt-0 rounded-lg border-2 border-[#303032] flex flex-col min-h-0 overflow-hidden" className="bg-[#18181b] text-white p-4 pt-0 rounded-lg border-2 border-[#303032] flex flex-col min-h-0 overflow-hidden"
style={{ style={{
marginLeft: leftMarginPx, marginLeft: leftMarginPx,
marginRight: 17, marginRight: 17,
marginTop: topMarginPx, marginTop: topMarginPx,
marginBottom: bottomMarginPx, marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)` height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
}} }}
> >
<Tabs value={activeTab} onValueChange={handleTabChange} <Tabs value={activeTab} onValueChange={handleTabChange}
className="flex-1 flex flex-col h-full min-h-0"> className="flex-1 flex flex-col h-full min-h-0">
<TabsList className="bg-[#18181b] border-2 border-[#303032] mt-1.5"> <TabsList className="bg-[#18181b] border-2 border-[#303032] mt-1.5">
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger> <TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
<TabsTrigger value="add_host"> <TabsTrigger value="add_host">
{editingHost ? "Edit Host" : "Add Host"} {editingHost ? "Edit Host" : "Add Host"}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0"> <TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 -mt-0.5 mb-1"/> <Separator className="p-0.25 -mt-0.5 mb-1"/>
<HostManagerHostViewer onEditHost={handleEditHost}/> <HostManagerHostViewer onEditHost={handleEditHost}/>
</TabsContent> </TabsContent>
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0"> <TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 -mt-0.5 mb-1"/> <Separator className="p-0.25 -mt-0.5 mb-1"/>
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<HostManagerHostEditor <HostManagerHostEditor
editingHost={editingHost} editingHost={editingHost}
onFormSubmit={handleFormSubmit} onFormSubmit={handleFormSubmit}
/> />
</div> </div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
</div> </div>
</div> </div>
) )
} }

View File

@@ -811,7 +811,9 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
render={({field: sourcePortField}) => ( render={({field: sourcePortField}) => (
<FormItem className="col-span-4"> <FormItem className="col-span-4">
<FormLabel>Source Port <FormLabel>Source Port
(Source refers to the Current Connection Details in the General tab)</FormLabel> (Source refers to the Current
Connection Details in the
General tab)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="22" {...sourcePortField} /> placeholder="22" {...sourcePortField} />
@@ -1029,8 +1031,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
</ScrollArea> </ScrollArea>
<footer className="shrink-0 w-full pb-0"> <footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25"/> <Separator className="p-0.25"/>
<Button <Button
className="" className=""
type="submit" type="submit"
variant="outline" variant="outline"
style={{ style={{

View File

@@ -581,7 +581,7 @@ EXAMPLE STRUCTURE:
Format Guide Format Guide
</Button> </Button>
<div className="w-px h-6 bg-border mx-2" /> <div className="w-px h-6 bg-border mx-2"/>
<Button onClick={fetchHosts} variant="outline" size="sm"> <Button onClick={fetchHosts} variant="outline" size="sm">
Refresh Refresh

View File

@@ -1,256 +1,256 @@
import React from "react"; import React from "react";
import { useSidebar } from "@/components/ui/sidebar"; import {useSidebar} from "@/components/ui/sidebar";
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status"; import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
import {Separator} from "@/components/ui/separator.tsx"; import {Separator} from "@/components/ui/separator.tsx";
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import { Progress } from "@/components/ui/progress" import {Progress} from "@/components/ui/progress"
import {Cpu, HardDrive, MemoryStick} from "lucide-react"; import {Cpu, HardDrive, MemoryStick} from "lucide-react";
import {Tunnel} from "@/ui/apps/Tunnel/Tunnel.tsx"; import {Tunnel} from "@/ui/apps/Tunnel/Tunnel.tsx";
import { getServerStatusById, getServerMetricsById, ServerMetrics } from "@/ui/main-axios.ts"; import {getServerStatusById, getServerMetricsById, ServerMetrics} from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/Navigation/Tabs/TabContext.tsx"; import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
interface ServerProps { interface ServerProps {
hostConfig?: any; hostConfig?: any;
title?: string; title?: string;
isVisible?: boolean; isVisible?: boolean;
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
embedded?: boolean; // when rendered inside a pane in TerminalView embedded?: boolean;
} }
export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = true, embedded = false }: ServerProps): React.ReactElement { export function Server({
const { state: sidebarState } = useSidebar(); hostConfig,
const { addTab } = useTabs() as any; title,
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline'); isVisible = true,
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null); isTopbarOpen = true,
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig); embedded = false
}: ServerProps): React.ReactElement {
const {state: sidebarState} = useSidebar();
const {addTab} = useTabs() as any;
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
// Listen for host configuration changes React.useEffect(() => {
React.useEffect(() => { setCurrentHostConfig(hostConfig);
setCurrentHostConfig(hostConfig); }, [hostConfig]);
}, [hostConfig]);
// Always fetch latest host config when component mounts or hostConfig changes React.useEffect(() => {
React.useEffect(() => { const fetchLatestHostConfig = async () => {
const fetchLatestHostConfig = async () => { if (hostConfig?.id) {
if (hostConfig?.id) { try {
try { const {getSSHHosts} = await import('@/ui/main-axios.ts');
// Import the getSSHHosts function to fetch updated host data const hosts = await getSSHHosts();
const { getSSHHosts } = await import('@/ui/main-axios.ts'); const updatedHost = hosts.find(h => h.id === hostConfig.id);
const hosts = await getSSHHosts(); if (updatedHost) {
const updatedHost = hosts.find(h => h.id === hostConfig.id); setCurrentHostConfig(updatedHost);
if (updatedHost) { }
setCurrentHostConfig(updatedHost); } catch (error) {
} }
} catch (error) { }
console.error('Failed to fetch latest host config:', error); };
}
}
};
// Fetch immediately when component mounts or hostConfig changes fetchLatestHostConfig();
fetchLatestHostConfig();
// Also listen for SSH hosts changed event to refresh host config const handleHostsChanged = async () => {
const handleHostsChanged = async () => { if (hostConfig?.id) {
if (hostConfig?.id) { try {
try { const {getSSHHosts} = await import('@/ui/main-axios.ts');
// Import the getSSHHosts function to fetch updated host data const hosts = await getSSHHosts();
const { getSSHHosts } = await import('@/ui/main-axios.ts'); const updatedHost = hosts.find(h => h.id === hostConfig.id);
const hosts = await getSSHHosts(); if (updatedHost) {
const updatedHost = hosts.find(h => h.id === hostConfig.id); setCurrentHostConfig(updatedHost);
if (updatedHost) { }
setCurrentHostConfig(updatedHost); } catch (error) {
} }
} catch (error) { }
console.error('Failed to refresh host config:', error); };
}
}
};
window.addEventListener('ssh-hosts:changed', handleHostsChanged); window.addEventListener('ssh-hosts:changed', handleHostsChanged);
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged); return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged);
}, [hostConfig?.id]); }, [hostConfig?.id]);
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
let intervalId: number | undefined; let intervalId: number | undefined;
const fetchStatus = async () => { const fetchStatus = async () => {
try { try {
const res = await getServerStatusById(currentHostConfig?.id); const res = await getServerStatusById(currentHostConfig?.id);
if (!cancelled) { if (!cancelled) {
setServerStatus(res?.status === 'online' ? 'online' : 'offline'); setServerStatus(res?.status === 'online' ? 'online' : 'offline');
} }
} catch { } catch {
if (!cancelled) setServerStatus('offline'); if (!cancelled) setServerStatus('offline');
} }
}; };
const fetchMetrics = async () => { const fetchMetrics = async () => {
if (!currentHostConfig?.id) return; if (!currentHostConfig?.id) return;
try { try {
const data = await getServerMetricsById(currentHostConfig.id); const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) setMetrics(data); if (!cancelled) setMetrics(data);
} catch { } catch {
if (!cancelled) setMetrics(null); if (!cancelled) setMetrics(null);
} }
}; };
if (currentHostConfig?.id) { if (currentHostConfig?.id) {
fetchStatus(); fetchStatus();
fetchMetrics(); fetchMetrics();
intervalId = window.setInterval(() => { intervalId = window.setInterval(() => {
fetchStatus(); fetchStatus();
fetchMetrics(); fetchMetrics();
}, 10_000); }, 10_000);
} }
return () => { return () => {
cancelled = true; cancelled = true;
if (intervalId) window.clearInterval(intervalId); if (intervalId) window.clearInterval(intervalId);
}; };
}, [currentHostConfig?.id]); }, [currentHostConfig?.id]);
const topMarginPx = isTopbarOpen ? 74 : 16; const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8; const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
const bottomMarginPx = 8; const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = embedded const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: '100%', width: '100%' } ? {opacity: isVisible ? 1 : 0, height: '100%', width: '100%'}
: { : {
opacity: isVisible ? 1 : 0, opacity: isVisible ? 1 : 0,
marginLeft: leftMarginPx, marginLeft: leftMarginPx,
marginRight: 17, marginRight: 17,
marginTop: topMarginPx, marginTop: topMarginPx,
marginBottom: bottomMarginPx, marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
}; };
const containerClass = embedded const containerClass = embedded
? "h-full w-full text-white overflow-hidden bg-transparent" ? "h-full w-full text-white overflow-hidden bg-transparent"
: "bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden"; : "bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden";
return ( return (
<div style={wrapperStyle} className={containerClass}> <div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col"> <div className="h-full w-full flex flex-col">
{/* Top Header */} {/* Top Header */}
<div className="flex items-center justify-between px-3 pt-2 pb-2"> <div className="flex items-center justify-between px-3 pt-2 pb-2">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<h1 className="font-bold text-lg"> <h1 className="font-bold text-lg">
{currentHostConfig?.folder} / {title} {currentHostConfig?.folder} / {title}
</h1> </h1>
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0"> <Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
<StatusIndicator/> <StatusIndicator/>
</Status> </Status>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
{currentHostConfig?.enableFileManager && ( {currentHostConfig?.enableFileManager && (
<Button <Button
variant="outline" variant="outline"
className="font-semibold" className="font-semibold"
onClick={() => { onClick={() => {
if (!currentHostConfig) return; if (!currentHostConfig) return;
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== '' const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
? currentHostConfig.name.trim() ? currentHostConfig.name.trim()
: `${currentHostConfig.username}@${currentHostConfig.ip}`; : `${currentHostConfig.username}@${currentHostConfig.ip}`;
addTab({ addTab({
type: 'file_manager', type: 'file_manager',
title: titleBase, title: titleBase,
hostConfig: currentHostConfig, hostConfig: currentHostConfig,
}); });
}} }}
> >
File Manager File Manager
</Button> </Button>
)} )}
</div> </div>
</div> </div>
<Separator className="p-0.25 w-full"/> <Separator className="p-0.25 w-full"/>
{/* Stats */} {/* Stats */}
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] flex flex-row items-stretch"> <div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] flex flex-row items-stretch">
{/* CPU */} {/* CPU */}
<div className="flex-1 min-w-0 px-2 py-2"> <div className="flex-1 min-w-0 px-2 py-2">
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1"> <h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
<Cpu/> <Cpu/>
{(() => { {(() => {
const pct = metrics?.cpu?.percent; const pct = metrics?.cpu?.percent;
const cores = metrics?.cpu?.cores; const cores = metrics?.cpu?.cores;
const la = metrics?.cpu?.load; const la = metrics?.cpu?.load;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A'; const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const coresText = (typeof cores === 'number') ? `${cores} CPU(s)` : 'N/A CPU(s)'; const coresText = (typeof cores === 'number') ? `${cores} CPU(s)` : 'N/A CPU(s)';
const laText = (la && la.length === 3) const laText = (la && la.length === 3)
? `Avg: ${la[0].toFixed(2)}, ${la[1].toFixed(2)}, ${la[2].toFixed(2)}` ? `Avg: ${la[0].toFixed(2)}, ${la[1].toFixed(2)}, ${la[2].toFixed(2)}`
: 'Avg: N/A'; : 'Avg: N/A';
return `CPU Usage - ${pctText} of ${coresText} (${laText})`; return `CPU Usage - ${pctText} of ${coresText} (${laText})`;
})()} })()}
</h1> </h1>
<Progress value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0} /> <Progress value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0}/>
</div> </div>
<Separator className="p-0.5 self-stretch" orientation="vertical"/> <Separator className="p-0.5 self-stretch" orientation="vertical"/>
{/* Memory */} {/* Memory */}
<div className="flex-1 min-w-0 px-2 py-2"> <div className="flex-1 min-w-0 px-2 py-2">
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-1"> <h1 className="font-bold xt-lg flex flex-row gap-2 mb-1">
<MemoryStick/> <MemoryStick/>
{(() => { {(() => {
const pct = metrics?.memory?.percent; const pct = metrics?.memory?.percent;
const used = metrics?.memory?.usedGiB; const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB; const total = metrics?.memory?.totalGiB;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A'; const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A'; const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A';
const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A'; const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A';
return `Memory Usage - ${pctText} (${usedText} of ${totalText})`; return `Memory Usage - ${pctText} (${usedText} of ${totalText})`;
})()} })()}
</h1> </h1>
<Progress value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0} /> <Progress value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0}/>
</div> </div>
<Separator className="p-0.5 self-stretch" orientation="vertical"/> <Separator className="p-0.5 self-stretch" orientation="vertical"/>
{/* HDD */} {/* HDD */}
<div className="flex-1 min-w-0 px-2 py-2"> <div className="flex-1 min-w-0 px-2 py-2">
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1"> <h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
<HardDrive/> <HardDrive/>
{(() => { {(() => {
const pct = metrics?.disk?.percent; const pct = metrics?.disk?.percent;
const used = metrics?.disk?.usedHuman; const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman; const total = metrics?.disk?.totalHuman;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A'; const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const usedText = used ?? 'N/A'; const usedText = used ?? 'N/A';
const totalText = total ?? 'N/A'; const totalText = total ?? 'N/A';
return `HD Space - ${pctText} (${usedText} of ${totalText})`; return `HD Space - ${pctText} (${usedText} of ${totalText})`;
})()} })()}
</h1> </h1>
<Progress value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0} /> <Progress value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0}/>
</div> </div>
</div> </div>
{/* SSH Tunnels */} {/* SSH Tunnels */}
{(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && ( {(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && (
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] h-[360px] overflow-hidden flex flex-col min-h-0"> <div
<Tunnel filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/> className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] h-[360px] overflow-hidden flex flex-col min-h-0">
</div> <Tunnel
)} filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/>
</div>
)}
<p className="px-4 pt-2 pb-2 text-sm text-gray-500"> <p className="px-4 pt-2 pb-2 text-sm text-gray-500">
Have ideas for what should come next for server management? Share them on{" "} Have ideas for what should come next for server management? Share them on{" "}
<a <a
href="https://github.com/LukeGus/Termix/issues/new" href="https://github.com/LukeGus/Termix/issues/new"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-500 hover:underline" className="text-blue-500 hover:underline"
> >
GitHub GitHub
</a> </a>
! !
</p> </p>
</div> </div>
</div> </div>
); );
} }

View File

@@ -17,7 +17,6 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
{hostConfig, isVisible, splitScreen = false}, {hostConfig, isVisible, splitScreen = false},
ref ref
) { ) {
console.log('TerminalComponent rendered with:', { hostConfig, isVisible, splitScreen });
const {instance: terminal, ref: xtermRef} = useXTerm(); const {instance: terminal, ref: xtermRef} = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null); const fitAddonRef = useRef<FitAddon | null>(null);
const webSocketRef = useRef<WebSocket | null>(null); const webSocketRef = useRef<WebSocket | null>(null);
@@ -27,20 +26,22 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const isVisibleRef = useRef<boolean>(false); const isVisibleRef = useRef<boolean>(false);
// Debounce/stabilize resize notifications const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const lastSentSizeRef = useRef<{cols:number; rows:number} | null>(null); const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const pendingSizeRef = useRef<{cols:number; rows:number} | null>(null);
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null); const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
const DEBOUNCE_MS = 140; const DEBOUNCE_MS = 140;
useEffect(() => { isVisibleRef.current = isVisible; }, [isVisible]); useEffect(() => {
isVisibleRef.current = isVisible;
}, [isVisible]);
function hardRefresh() { function hardRefresh() {
try { try {
if (terminal && typeof (terminal as any).refresh === 'function') { if (terminal && typeof (terminal as any).refresh === 'function') {
(terminal as any).refresh(0, terminal.rows - 1); (terminal as any).refresh(0, terminal.rows - 1);
} }
} catch (_) {} } catch (_) {
}
} }
function scheduleNotify(cols: number, rows: number) { function scheduleNotify(cols: number, rows: number) {
@@ -85,7 +86,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
scheduleNotify(cols, rows); scheduleNotify(cols, rows);
hardRefresh(); hardRefresh();
} }
} catch (_) {} } catch (_) {
}
}, },
refresh: () => hardRefresh(), refresh: () => hardRefresh(),
}), [terminal]); }), [terminal]);
@@ -119,7 +121,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
return; return;
} }
} catch (_) {} } catch (_) {
}
const textarea = document.createElement('textarea'); const textarea = document.createElement('textarea');
textarea.value = text; textarea.value = text;
textarea.style.position = 'fixed'; textarea.style.position = 'fixed';
@@ -127,7 +130,11 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
document.body.appendChild(textarea); document.body.appendChild(textarea);
textarea.focus(); textarea.focus();
textarea.select(); textarea.select();
try { document.execCommand('copy'); } finally { document.body.removeChild(textarea); } try {
document.execCommand('copy');
} finally {
document.body.removeChild(textarea);
}
} }
async function readTextFromClipboard(): Promise<string> { async function readTextFromClipboard(): Promise<string> {
@@ -135,7 +142,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
if (navigator.clipboard && navigator.clipboard.readText) { if (navigator.clipboard && navigator.clipboard.readText) {
return await navigator.clipboard.readText(); return await navigator.clipboard.readText();
} }
} catch (_) {} } catch (_) {
}
return ''; return '';
} }
@@ -148,7 +156,7 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
scrollback: 10000, scrollback: 10000,
fontSize: 14, fontSize: 14,
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace', fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
theme: { background: '#18181b', foreground: '#f7f7f7' }, theme: {background: '#18181b', foreground: '#f7f7f7'},
allowTransparency: true, allowTransparency: true,
convertEol: true, convertEol: true,
windowsMode: false, windowsMode: false,
@@ -175,16 +183,21 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
const element = xtermRef.current; const element = xtermRef.current;
const handleContextMenu = async (e: MouseEvent) => { const handleContextMenu = async (e: MouseEvent) => {
if (!getUseRightClickCopyPaste()) return; if (!getUseRightClickCopyPaste()) return;
e.preventDefault(); e.stopPropagation(); e.preventDefault();
e.stopPropagation();
try { try {
if (terminal.hasSelection()) { if (terminal.hasSelection()) {
const selection = terminal.getSelection(); const selection = terminal.getSelection();
if (selection) { await writeTextToClipboard(selection); terminal.clearSelection(); } if (selection) {
await writeTextToClipboard(selection);
terminal.clearSelection();
}
} else { } else {
const pasteText = await readTextFromClipboard(); const pasteText = await readTextFromClipboard();
if (pasteText) terminal.paste(pasteText); if (pasteText) terminal.paste(pasteText);
} }
} catch (_) {} } catch (_) {
}
}; };
element?.addEventListener('contextmenu', handleContextMenu); element?.addEventListener('contextmenu', handleContextMenu);
@@ -221,8 +234,14 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
ws.addEventListener('open', () => { ws.addEventListener('open', () => {
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}})); ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
terminal.onData((data) => { ws.send(JSON.stringify({type: 'input', data})); }); terminal.onData((data) => {
pingIntervalRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({type: 'ping'})); } }, 30000); ws.send(JSON.stringify({type: 'input', data}));
});
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({type: 'ping'}));
}
}, 30000);
}); });
ws.addEventListener('message', (event) => { ws.addEventListener('message', (event) => {
@@ -230,13 +249,21 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.type === 'data') terminal.write(msg.data); if (msg.type === 'data') terminal.write(msg.data);
else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`); else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
else if (msg.type === 'connected') { } else if (msg.type === 'connected') {
else if (msg.type === 'disconnected') { wasDisconnectedBySSH.current = true; terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`); } } else if (msg.type === 'disconnected') {
} catch (error) { console.error('Error parsing WebSocket message:', error); } wasDisconnectedBySSH.current = true;
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
}
} catch (error) {
}
}); });
ws.addEventListener('close', () => { if (!wasDisconnectedBySSH.current) terminal.writeln('\r\n[Connection closed]'); }); ws.addEventListener('close', () => {
ws.addEventListener('error', () => { terminal.writeln('\r\n[Connection error]'); }); if (!wasDisconnectedBySSH.current) terminal.writeln('\r\n[Connection closed]');
});
ws.addEventListener('error', () => {
terminal.writeln('\r\n[Connection error]');
});
}, 300); }, 300);
}); });
@@ -245,7 +272,10 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
element?.removeEventListener('contextmenu', handleContextMenu); element?.removeEventListener('contextmenu', handleContextMenu);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
if (resizeTimeout.current) clearTimeout(resizeTimeout.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; } if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
webSocketRef.current?.close(); webSocketRef.current?.close();
}; };
}, [xtermRef, terminal, hostConfig]); }, [xtermRef, terminal, hostConfig]);
@@ -260,7 +290,6 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
} }
}, [isVisible]); }, [isVisible]);
// Ensure a fit when split mode toggles to account for new pane geometry
useEffect(() => { useEffect(() => {
if (!fitAddonRef.current) return; if (!fitAddonRef.current) return;
setTimeout(() => { setTimeout(() => {
@@ -271,7 +300,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
}, [splitScreen]); }, [splitScreen]);
return ( return (
<div ref={xtermRef} className="h-full w-full m-1" style={{ opacity: visible && isVisible ? 1 : 0, overflow: 'hidden' }} /> <div ref={xtermRef} className="h-full w-full m-1"
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}/>
); );
}); });

View File

@@ -48,8 +48,7 @@ interface SSHTunnelProps {
filterHostKey?: string; filterHostKey?: string;
} }
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement { export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
// Keep full list for endpoint lookups; keep a separate visible list for UI
const [allHosts, setAllHosts] = useState<SSHHost[]>([]); const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]); const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({}); const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
@@ -86,7 +85,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
}) })
: hostsData; : hostsData;
// Silent update: only set state if meaningful changes
const prev = prevVisibleHostRef.current; const prev = prevVisibleHostRef.current;
const curr = nextVisible[0] ?? null; const curr = nextVisible[0] ?? null;
let changed = false; let changed = false;
@@ -120,7 +118,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
fetchHosts(); fetchHosts();
const interval = setInterval(fetchHosts, 5000); const interval = setInterval(fetchHosts, 5000);
// Refresh immediately when hosts are changed elsewhere (e.g., SSH Manager)
const handleHostsChanged = () => { const handleHostsChanged = () => {
fetchHosts(); fetchHosts();
}; };

View File

@@ -76,17 +76,17 @@ interface SSHTunnelObjectProps {
tunnelActions: Record<string, boolean>; tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>; onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
compact?: boolean; compact?: boolean;
bare?: boolean; // when true, render without Card wrapper/background bare?: boolean;
} }
export function TunnelObject({ export function TunnelObject({
host, host,
tunnelStatuses, tunnelStatuses,
tunnelActions, tunnelActions,
onTunnelAction, onTunnelAction,
compact = false, compact = false,
bare = false bare = false
}: SSHTunnelObjectProps): React.ReactElement { }: SSHTunnelObjectProps): React.ReactElement {
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => { const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
const tunnel = host.tunnelConnections[tunnelIndex]; const tunnel = host.tunnelConnections[tunnelIndex];
@@ -168,7 +168,6 @@ export function TunnelObject({
if (bare) { if (bare) {
return ( return (
<div className="w-full min-w-0"> <div className="w-full min-w-0">
{/* Tunnel Connections (bare) */}
<div className="space-y-3"> <div className="space-y-3">
{host.tunnelConnections && host.tunnelConnections.length > 0 ? ( {host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
@@ -187,7 +186,6 @@ export function TunnelObject({
return ( return (
<div key={tunnelIndex} <div key={tunnelIndex}
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}> className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
{/* Tunnel Header */}
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0"> <div className="flex items-start gap-2 flex-1 min-w-0">
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}> <span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
@@ -203,7 +201,6 @@ export function TunnelObject({
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]"> <div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
{/* Action Buttons */}
{!isActionLoading ? ( {!isActionLoading ? (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{isConnected ? ( {isConnected ? (
@@ -255,7 +252,6 @@ export function TunnelObject({
</div> </div>
</div> </div>
{/* Error/Status Reason */}
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && ( {(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
<div <div
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20"> className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
@@ -280,7 +276,6 @@ export function TunnelObject({
</div> </div>
)} )}
{/* Retry Info */}
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && ( {(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
<div <div
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20"> className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
@@ -313,7 +308,6 @@ export function TunnelObject({
return ( return (
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0"> <Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
<div className="p-4"> <div className="p-4">
{/* Host Header */}
{!compact && ( {!compact && (
<div className="flex items-center justify-between gap-2 mb-3"> <div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
@@ -330,7 +324,6 @@ export function TunnelObject({
</div> </div>
)} )}
{/* Tags */}
{!compact && host.tags && host.tags.length > 0 && ( {!compact && host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3"> <div className="flex flex-wrap gap-1 mb-3">
{host.tags.slice(0, 3).map((tag, index) => ( {host.tags.slice(0, 3).map((tag, index) => (
@@ -349,7 +342,6 @@ export function TunnelObject({
{!compact && <Separator className="mb-3"/>} {!compact && <Separator className="mb-3"/>}
{/* Tunnel Connections */}
<div className="space-y-3"> <div className="space-y-3">
{!compact && ( {!compact && (
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2"> <h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
@@ -374,7 +366,6 @@ export function TunnelObject({
return ( return (
<div key={tunnelIndex} <div key={tunnelIndex}
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}> className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
{/* Tunnel Header */}
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0"> <div className="flex items-start gap-2 flex-1 min-w-0">
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}> <span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
@@ -390,7 +381,6 @@ export function TunnelObject({
</div> </div>
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
{/* Action Buttons */}
{!isActionLoading && ( {!isActionLoading && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{isConnected ? ( {isConnected ? (
@@ -443,7 +433,6 @@ export function TunnelObject({
</div> </div>
</div> </div>
{/* Error/Status Reason */}
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && ( {(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
<div <div
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20"> className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
@@ -468,7 +457,6 @@ export function TunnelObject({
</div> </div>
)} )}
{/* Retry Info */}
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && ( {(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
<div <div
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20"> className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">

View File

@@ -47,12 +47,11 @@ interface SSHTunnelViewerProps {
} }
export function TunnelViewer({ export function TunnelViewer({
hosts = [], hosts = [],
tunnelStatuses = {}, tunnelStatuses = {},
tunnelActions = {}, tunnelActions = {},
onTunnelAction onTunnelAction
}: SSHTunnelViewerProps): React.ReactElement { }: SSHTunnelViewerProps): React.ReactElement {
// Single-host view: use first host if present
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined; const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) { if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
@@ -60,7 +59,8 @@ export function TunnelViewer({
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3"> <div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
<h3 className="text-lg font-semibold text-foreground mb-2">No SSH Tunnels</h3> <h3 className="text-lg font-semibold text-foreground mb-2">No SSH Tunnels</h3>
<p className="text-muted-foreground max-w-md"> <p className="text-muted-foreground max-w-md">
Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections. Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel
connections.
</p> </p>
</div> </div>
); );
@@ -72,7 +72,8 @@ export function TunnelViewer({
<h1 className="text-xl font-semibold text-foreground">SSH Tunnels</h1> <h1 className="text-xl font-semibold text-foreground">SSH Tunnels</h1>
</div> </div>
<div className="min-h-0 flex-1 overflow-auto pr-1"> <div className="min-h-0 flex-1 overflow-auto pr-1">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full"> <div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{activeHost.tunnelConnections.map((t, idx) => ( {activeHost.tunnelConnections.map((t, idx) => (
<TunnelObject <TunnelObject
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`} key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}