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

View File

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

View File

@@ -90,10 +90,10 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
if (cachedData) {
return cachedData;
}
try {
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
@@ -108,13 +108,13 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
const alerts: TermixAlert[] = await response.json() as TermixAlert[];
const now = new Date();
const validAlerts = alerts.filter(alert => {
const expiryDate = new Date(alert.expiresAt);
const isValid = expiryDate > now;
return isValid;
});
alertCache.set(cacheKey, validAlerts);
return validAlerts;
} catch (error) {
@@ -146,11 +146,11 @@ router.get('/', async (req, res) => {
router.get('/user/:userId', async (req, res) => {
try {
const {userId} = req.params;
if (!userId) {
return res.status(400).json({error: 'User ID is required'});
}
const allAlerts = await fetchAlertsFromGitHub();
const dismissedAlertRecords = await db
@@ -215,7 +215,7 @@ router.post('/dismiss', async (req, res) => {
router.get('/dismissed/:userId', async (req, res) => {
try {
const {userId} = req.params;
if (!userId) {
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
router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { hosts } = req.body;
const {hosts} = req.body;
if (!Array.isArray(hosts) || hosts.length === 0) {
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++) {
const hostData = hosts[i];
try {
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) {
results.failed++;

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ import {Shield, Trash2, Users} from "lucide-react";
import axios from "axios";
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) {
return document.cookie.split('; ').reduce((r, v) => {
@@ -32,14 +32,12 @@ interface AdminSettingsProps {
isTopbarOpen?: boolean;
}
export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): React.ReactElement {
const { state: sidebarState } = useSidebar();
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
const {state: sidebarState} = useSidebar();
// Registration toggle
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
// OIDC config
const [oidcConfig, setOidcConfig] = React.useState({
client_id: '',
client_secret: '',
@@ -54,8 +52,12 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
const [oidcError, setOidcError] = React.useState<string | null>(null);
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
// Users/admins
const [users, setUsers] = React.useState<Array<{ id: string; username: string; is_admin: boolean; is_oidc: boolean }>>([]);
const [users, setUsers] = React.useState<Array<{
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean
}>>([]);
const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
@@ -65,15 +67,15 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
React.useEffect(() => {
const jwt = getCookie("jwt");
if (!jwt) return;
// Preload OIDC config and users
API.get("/oidc-config", { headers: { Authorization: `Bearer ${jwt}` } })
.then(res => { if (res.data) setOidcConfig(res.data); })
.catch(() => {});
API.get("/oidc-config", {headers: {Authorization: `Bearer ${jwt}`}})
.then(res => {
if (res.data) setOidcConfig(res.data);
})
.catch(() => {
});
fetchUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load initial registration toggle status
React.useEffect(() => {
API.get("/registration-allowed")
.then(res => {
@@ -81,7 +83,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
setAllowRegistration(res.data.allowed);
}
})
.catch(() => {});
.catch(() => {
});
}, []);
const fetchUsers = async () => {
@@ -89,7 +92,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
if (!jwt) return;
setUsersLoading(true);
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);
} finally {
setUsersLoading(false);
@@ -100,7 +103,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
setRegLoading(true);
const jwt = getCookie("jwt");
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);
} finally {
setRegLoading(false);
@@ -113,7 +116,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
setOidcError(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]);
if (missing.length > 0) {
setOidcError(`Missing required fields: ${missing.join(', ')}`);
@@ -123,7 +126,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
const jwt = getCookie("jwt");
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!");
} catch (err: any) {
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) => {
setOidcConfig(prev => ({ ...prev, [field]: value }));
setOidcConfig(prev => ({...prev, [field]: value}));
};
const makeUserAdmin = async (e: React.FormEvent) => {
@@ -144,7 +147,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
setMakeAdminSuccess(null);
const jwt = getCookie("jwt");
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`);
setNewAdminUsername("");
fetchUsers();
@@ -159,18 +162,20 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
if (!confirm(`Remove admin status from ${username}?`)) return;
const jwt = getCookie("jwt");
try {
await API.post("/remove-admin", { username }, { headers: { Authorization: `Bearer ${jwt}` } });
await API.post("/remove-admin", {username}, {headers: {Authorization: `Bearer ${jwt}`}});
fetchUsers();
} catch {}
} catch {
}
};
const deleteUser = async (username: string) => {
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
const jwt = getCookie("jwt");
try {
await API.delete("/delete-user", { headers: { Authorization: `Bearer ${jwt}` }, data: { username } });
await API.delete("/delete-user", {headers: {Authorization: `Bearer ${jwt}`}, data: {username}});
fetchUsers();
} catch {}
} catch {
}
};
const topMarginPx = isTopbarOpen ? 74 : 26;
@@ -185,7 +190,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
};
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="flex items-center justify-between px-3 pt-2 pb-2">
<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">
<h3 className="text-lg font-semibold">User Registration</h3>
<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
</label>
</div>
@@ -226,7 +233,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<TabsContent value="oidc" className="space-y-6">
<div className="space-y-4">
<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 && (
<Alert variant="destructive">
@@ -238,39 +246,66 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2">
<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 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 />
<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 />
<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 />
<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 />
<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 />
<Input id="identifier_path" value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder="sub" required/>
</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 />
<Input id="name_path" value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder="name" required/>
</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 />
<Input id="scopes" value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
placeholder="openid email profile" required/>
</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>
<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 && (
@@ -287,7 +322,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<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>
<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>
@@ -307,12 +343,17 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<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>
<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">{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}>
<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>
@@ -334,8 +375,11 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<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>
<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 && (
@@ -369,11 +413,15 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<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>
<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">{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">
<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"/>
Remove Admin
</Button>

View File

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

View File

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

View File

@@ -149,13 +149,11 @@ export function HomepageAuth({
setUsername(meRes.data.username || null);
setUserId(meRes.data.id || null);
setDbError(null);
// Call onAuthSuccess to update App state
onAuthSuccess({
isAdmin: !!meRes.data.is_admin,
username: meRes.data.username || null,
userId: meRes.data.id || null
});
// Update internal state immediately
setInternalLoggedIn(true);
if (tab === "signup") {
setSignupConfirmPassword("");
@@ -309,13 +307,11 @@ export function HomepageAuth({
setUsername(meRes.data.username || null);
setUserId(meRes.data.id || null);
setDbError(null);
// Call onAuthSuccess to update App state
onAuthSuccess({
isAdmin: !!meRes.data.is_admin,
username: meRes.data.username || null,
userId: meRes.data.id || null
});
// Update internal state immediately
setInternalLoggedIn(true);
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 || ''}`}
{...props}
>
{dbError && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{dbError}</AlertDescription>
</Alert>
)}
{firstUser && !dbError && !internalLoggedIn && (
<Alert variant="default" className="mb-4">
<AlertTitle>First User</AlertTitle>
<AlertDescription className="inline">
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{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800 inline"
>
GitHub issue
</a>.
</AlertDescription>
</Alert>
)}
{!registrationAllowed && !internalLoggedIn && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Registration Disabled</AlertTitle>
<AlertDescription>
New account registration is currently disabled by an admin. Please log in or contact an
administrator.
</AlertDescription>
</Alert>
)}
{(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && (
<>
<div className="flex gap-2 mb-6">
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "login"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => {
setTab("login");
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>
{dbError && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{dbError}</AlertDescription>
</Alert>
)}
{firstUser && !dbError && !internalLoggedIn && (
<Alert variant="default" className="mb-4">
<AlertTitle>First User</AlertTitle>
<AlertDescription className="inline">
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{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800 inline"
>
GitHub issue
</a>.
</AlertDescription>
</Alert>
)}
{!registrationAllowed && !internalLoggedIn && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Registration Disabled</AlertTitle>
<AlertDescription>
New account registration is currently disabled by an admin. Please log in or contact an
administrator.
</AlertDescription>
</Alert>
)}
{(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && (
<>
<div className="flex gap-2 mb-6">
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "login"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login" ? "Login to your account" :
tab === "signup" ? "Create a new account" :
tab === "external" ? "Login with external provider" :
"Reset your password"}
</h2>
</div>
{tab === "external" || tab === "reset" ? (
<div className="flex flex-col gap-5">
{tab === "external" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Login using your configured external identity provider</p>
</div>
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading ? Spinner : "Login with External Provider"}
</Button>
</>
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"
)}
{tab === "reset" && (
<>
{resetStep === "initiate" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter your username to receive a password reset code. The code
will be logged in the docker container logs.</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-username">Username</Label>
<Input
id="reset-username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={e => setLocalUsername(e.target.value)}
disabled={resetLoading}
/>
</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>
</>
)}
onClick={() => {
setTab("external");
if (tab === "reset") resetPasswordState();
if (tab === "login" || tab === "signup") clearFormFields();
}}
aria-selected={tab === "external"}
disabled={oidcLoading}
>
External
</button>
)}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login" ? "Login to your account" :
tab === "signup" ? "Create a new account" :
tab === "external" ? "Login with external provider" :
"Reset your password"}
</h2>
</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"
{tab === "external" || tab === "reset" ? (
<div className="flex flex-col gap-5">
{tab === "external" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Login using your configured external identity provider</p>
</div>
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading ? Spinner : "Login with External Provider"}
</Button>
</>
)}
{tab === "reset" && (
<>
{resetStep === "initiate" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter your username to receive a password reset code. The code
will be logged in the docker container logs.</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-username">Username</Label>
<Input
id="reset-username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={e => setLocalUsername(e.target.value)}
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
Back
</Button>
/>
</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
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" && (
<>
<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={() => {
setTab("login");
resetPasswordState();
setResetStep("initiate");
setResetCode("");
}}
>
Go to Login
Back
</Button>
</>
)}
</div>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter your new password for
user: <strong>{localUsername}</strong></p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</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"
{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
type="button"
className="w-full h-11 text-base font-semibold"
onClick={() => {
setTab("login");
resetPasswordState();
}}
>
Go to Login
</Button>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter your new password for
user: <strong>{localUsername}</strong></p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
Back
</Button>
autoComplete="new-password"
/>
</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>
) : (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required className="h-11 text-base"
value={password} onChange={e => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}/>
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={e => setLocalUsername(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required className="h-11 text-base"
value={password} onChange={e => setPassword(e.target.value)}
<Label htmlFor="signup-confirm-password">Confirm Password</Label>
<Input id="signup-confirm-password" type="password" required
className="h-11 text-base"
value={signupConfirmPassword}
onChange={e => setSignupConfirmPassword(e.target.value)}
disabled={loading || internalLoggedIn}/>
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">Confirm Password</Label>
<Input id="signup-confirm-password" type="password" required
className="h-11 text-base"
value={signupConfirmPassword}
onChange={e => setSignupConfirmPassword(e.target.value)}
disabled={loading || internalLoggedIn}/>
</div>
)}
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}>
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
)}
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}>
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
</Button>
{tab === "login" && (
<Button type="button" variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={loading || internalLoggedIn}
onClick={() => {
setTab("reset");
resetPasswordState();
clearFormFields();
}}
>
Reset Password
</Button>
{tab === "login" && (
<Button type="button" variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={loading || internalLoggedIn}
onClick={() => {
setTab("reset");
resetPasswordState();
clearFormFields();
}}
>
Reset Password
</Button>
)}
</form>
)}
</>
)}
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</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 {Server as ServerView} from "@/ui/apps/Server/Server.tsx";
import {FileManager} from "@/ui/apps/File Manager/FileManager.tsx";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
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 { Button } from "@/components/ui/button.tsx";
import {Button} from "@/components/ui/button.tsx";
interface TerminalViewProps {
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 { state: sidebarState } = useSidebar();
const {state: sidebarState} = useSidebar();
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 scheduleMeasureAndFit = () => {
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 = () => {
setReady(false);
requestAnimationFrame(() => {
@@ -77,13 +75,10 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
useEffect(() => {
hideThenFit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]);
// When split layout toggles on/off, topbar toggles, or sidebar state changes → measure+fit
useEffect(() => {
scheduleMeasureAndFit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
useEffect(() => {
@@ -93,14 +88,15 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
}) : null;
if (containerRef.current && roContainer) roContainer.observe(containerRef.current);
return () => roContainer?.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const onWinResize = () => { updatePanelRects(); fitActiveAndNotify(); };
const onWinResize = () => {
updatePanelRects();
fitActiveAndNotify();
};
window.addEventListener('resize', onWinResize);
return () => window.removeEventListener('resize', onWinResize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const HEADER_H = 28;
@@ -109,24 +105,34 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
const styles: Record<number, React.CSSProperties> = {};
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
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) {
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 {
layoutTabs.forEach((t: any) => {
const rect = panelRects[String(t.id)];
const parentRect = containerRef.current?.getBoundingClientRect();
if (rect && parentRect) {
styles[t.id] = {
position:'absolute',
position: 'absolute',
top: (rect.top - parentRect.top) + HEADER_H + 2,
left: (rect.left - parentRect.left) + 2,
width: rect.width - 4,
height: rect.height - HEADER_H - 4,
zIndex: 20,
display: 'block',
pointerEvents:'auto',
pointerEvents: 'auto',
opacity: ready ? 1 : 0,
};
}
@@ -134,22 +140,21 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
}
return (
<div style={{position:'absolute', inset:0, zIndex:1}}>
{terminalTabs.map((t:any) => {
<div style={{position: 'absolute', inset: 0, zIndex: 1}}>
{terminalTabs.map((t: any) => {
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
? {...styles[t.id], overflow:'hidden'}
? {...styles[t.id], overflow: 'hidden'}
: {
position:'absolute', inset:0, visibility:'hidden', pointerEvents:'none', zIndex:0,
} as React.CSSProperties;
position: 'absolute', inset: 0, visibility: 'hidden', pointerEvents: 'none', zIndex: 0,
} as React.CSSProperties;
const effectiveVisible = isVisible && ready;
return (
<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' ? (
<TerminalComponent
ref={t.terminalRef}
@@ -157,7 +162,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
isVisible={effectiveVisible}
title={t.title}
showTitle={false}
splitScreen={allSplitScreenTab.length>0}
splitScreen={allSplitScreenTab.length > 0}
/>
) : t.type === 'server' ? (
<ServerView
@@ -181,7 +186,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
);
};
const ResetButton = ({ onClick }: { onClick: () => void }) => (
const ResetButton = ({onClick}: { onClick: () => void }) => (
<Button
type="button"
variant="ghost"
@@ -189,7 +194,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
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"
>
<RefreshCcw className="h-4 w-4" />
<RefreshCcw className="h-4 w-4"/>
</Button>
);
@@ -201,28 +206,75 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
const renderSplitOverlays = () => {
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
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;
const handleStyle = { pointerEvents:'auto', zIndex:12, background:'#303032' } as React.CSSProperties;
const commonGroupProps = { onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit } as any;
const handleStyle = {pointerEvents: 'auto', zIndex: 12, background: '#303032'} as React.CSSProperties;
const commonGroupProps = {onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit} as any;
if (layoutTabs.length === 2) {
const [a,b] = layoutTabs as any[];
const [a, b] = layoutTabs as any[];
return (
<div style={{ position:'absolute', inset:0, zIndex:10, pointerEvents:'none' }}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal" className="h-full w-full" {...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',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 style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal"
className="h-full w-full" {...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',
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>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${b.id}`} order={2}>
<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'}}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${b.id}`} order={2}>
<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}
<ResetButton onClick={handleReset} />
<ResetButton onClick={handleReset}/>
</div>
</div>
</ResizablePanel>
@@ -231,32 +283,101 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
);
}
if (layoutTabs.length === 3) {
const [a,b,c] = layoutTabs as any[];
const [a, b, c] = layoutTabs as any[];
return (
<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}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
<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" 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 style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
id="main-vertical" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<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"
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>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${b.id}`} order={2}>
<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'}}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${b.id}`} order={2}>
<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}
<ResetButton onClick={handleReset} />
<ResetButton onClick={handleReset}/>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}>
<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>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="bottom-panel" order={2}>
<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>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
@@ -264,40 +385,133 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
);
}
if (layoutTabs.length === 4) {
const [a,b,c,d] = layoutTabs as any[];
const [a, b, c, d] = layoutTabs as any[];
return (
<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}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
<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" 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 style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
id="main-vertical" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<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"
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>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${b.id}`} order={2}>
<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'}}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${b.id}`} order={2}>
<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}
<ResetButton onClick={handleReset} />
<ResetButton onClick={handleReset}/>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id="bottom-panel" order={2}>
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal" className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
<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>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id="bottom-panel" order={2}>
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal"
className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
<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>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${d.id}`} order={2}>
<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>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${d.id}`} order={2}>
<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>
</ResizablePanel>
</ResizablePanelGroup>
@@ -318,8 +532,8 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
ref={containerRef}
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
style={{
position:'relative',
background:'#18181b',
position: 'relative',
background: '#18181b',
marginLeft: leftMarginPx,
marginRight: 17,
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 {ChevronDown, Folder} from "lucide-react";
import {Button} from "@/components/ui/button.tsx";
@@ -35,7 +35,7 @@ interface FolderCardProps {
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 toggleExpanded = () => {
@@ -43,7 +43,8 @@ export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardPro
};
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="flex gap-2 pr-10">
<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">
{hosts.map((host, index) => (
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
<Host host={host} />
<Host host={host}/>
{index < hosts.length - 1 && (
<div className="relative -mx-2">
<Separator className="p-0.25 absolute inset-x-0" />
<Separator className="p-0.25 absolute inset-x-0"/>
</div>
)}
</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 {Button} from "@/components/ui/button.tsx";
import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Server, Terminal} from "lucide-react";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
import { getServerStatusById } from "@/ui/main-axios.ts";
import {getServerStatusById} from "@/ui/main-axios.ts";
interface SSHHost {
id: number;
@@ -33,12 +33,12 @@ interface HostProps {
host: SSHHost;
}
export function Host({ host }: HostProps): React.ReactElement {
const { addTab } = useTabs();
export function Host({host}: HostProps): React.ReactElement {
const {addTab} = useTabs();
const [serverStatus, setServerStatus] = useState<'online' | 'offline'>('offline');
const tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0;
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
useEffect(() => {
@@ -66,13 +66,13 @@ export function Host({ host }: HostProps): React.ReactElement {
}, [host.id]);
const handleTerminalClick = () => {
addTab({ type: 'terminal', title, hostConfig: host });
addTab({type: 'terminal', title, hostConfig: host});
};
const handleServerClick = () => {
addTab({ type: 'server', title, hostConfig: host });
addTab({type: 'server', title, hostConfig: host});
};
return (
<div>
<div className="flex items-center gap-2">
@@ -87,8 +87,8 @@ export function Host({ host }: HostProps): React.ReactElement {
<Server/>
</Button>
{host.enableTerminal && (
<Button
variant="outline"
<Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={handleTerminalClick}
>

View File

@@ -49,7 +49,7 @@ import axios from "axios";
import {Card} from "@/components/ui/card.tsx";
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
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 {
id: number;
@@ -102,29 +102,14 @@ const API = axios.create({
});
export function LeftSidebar({
onSelectView,
getView,
disabled,
isAdmin,
username,
children,
}: SidebarProps): React.ReactElement {
onSelectView,
getView,
disabled,
isAdmin,
username,
children,
}: SidebarProps): React.ReactElement {
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 [deletePassword, setDeletePassword] = React.useState("");
@@ -138,21 +123,21 @@ export function LeftSidebar({
is_admin: boolean;
is_oidc: boolean;
}>>([]);
const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [usersLoading, setUsersLoading] = React.useState(false);
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
const [makeAdminError, setMakeAdminError] = 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);
// 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 sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
const openSshManagerTab = () => {
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);
};
const adminTab = tabList.find((t) => t.type === 'admin');
@@ -162,11 +147,10 @@ export function LeftSidebar({
setCurrentTab(adminTab.id);
return;
}
const id = addTab({ type: 'admin', title: 'Admin' } as any);
const id = addTab({type: 'admin', title: 'Admin'} as any);
setCurrentTab(id);
};
// SSH Hosts state management
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(false);
const [hostsError, setHostsError] = useState<string | null>(null);
@@ -202,23 +186,16 @@ export function LeftSidebar({
}
}, [isAdmin]);
// SSH Hosts data fetching
const fetchHosts = React.useCallback(async () => {
try {
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;
// Create a stable map of existing hosts by ID for comparison
const existingHostsMap = new Map(prevHosts.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;
// Check for new hosts, removed hosts, or changed hosts
if (newHosts.length !== prevHosts.length) {
hasChanges = true;
} else {
@@ -228,8 +205,7 @@ export function LeftSidebar({
hasChanges = true;
break;
}
// Only check fields that affect the display
if (
newHost.name !== existingHost.name ||
newHost.folder !== existingHost.folder ||
@@ -245,9 +221,8 @@ export function LeftSidebar({
}
}
}
if (hasChanges) {
// Use a small delay to batch updates and reduce jittering
setTimeout(() => {
setHosts(newHosts);
prevHostsRef.current = newHosts;
@@ -264,7 +239,6 @@ export function LeftSidebar({
return () => clearInterval(interval);
}, [fetchHosts]);
// Immediate refresh when SSH hosts are changed elsewhere in the app
React.useEffect(() => {
const handleHostsChanged = () => {
fetchHosts();
@@ -273,13 +247,11 @@ export function LeftSidebar({
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
}, [fetchHosts]);
// Search debouncing
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
// Filter and organize hosts with stable references
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase();
@@ -323,68 +295,6 @@ export function LeftSidebar({
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) => {
e.preventDefault();
setDeleteLoading(true);
@@ -416,7 +326,7 @@ export function LeftSidebar({
if (!jwt || !isAdmin) {
return;
}
setUsersLoading(true);
try {
const response = await API.get("/list", {
@@ -427,7 +337,6 @@ export function LeftSidebar({
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
setAdminCount(adminUsers.length);
} catch (err: any) {
console.error("Failed to fetch users:", err);
} finally {
setUsersLoading(false);
}
@@ -439,7 +348,7 @@ export function LeftSidebar({
if (!jwt || !isAdmin) {
return;
}
try {
const response = await API.get("/list", {
headers: {Authorization: `Bearer ${jwt}`}
@@ -447,7 +356,6 @@ export function LeftSidebar({
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
setAdminCount(adminUsers.length);
} catch (err: any) {
console.error("Failed to fetch admin count:", err);
}
};
@@ -494,7 +402,6 @@ export function LeftSidebar({
);
fetchUsers();
} catch (err: any) {
console.error("Failed to remove admin status:", err);
}
};
@@ -513,7 +420,6 @@ export function LeftSidebar({
});
fetchUsers();
} catch (err: any) {
console.error("Failed to delete user:", err);
}
};
@@ -536,14 +442,15 @@ export function LeftSidebar({
<Separator className="p-0.25"/>
<SidebarContent>
<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"/>
Host Manager
</Button>
</SidebarGroup>
<Separator className="p-0.25"/>
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
{/* Search Input */}
<div className="bg-[#131316] rounded-lg">
<Input
value={search}
@@ -553,17 +460,16 @@ export function LeftSidebar({
autoComplete="off"
/>
</div>
{/* Error Display */}
{hostsError && (
<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}
</div>
</div>
)}
{/* Loading State */}
{hostsLoading && (
<div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center">
@@ -572,7 +478,6 @@ export function LeftSidebar({
</div>
)}
{/* Hosts by Folder */}
{sortedFolders.map((folder, idx) => (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
@@ -608,7 +513,7 @@ export function LeftSidebar({
{isAdmin && (
<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"
onSelect={() => {
onClick={() => {
if (isAdmin) openAdminTab();
}}>
<span>Admin Settings</span>
@@ -616,12 +521,12 @@ export function LeftSidebar({
)}
<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"
onSelect={handleLogout}>
onClick={handleLogout}>
<span>Sign out</span>
</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"
onSelect={() => setDeleteAccountOpen(true)}
onClick={() => setDeleteAccountOpen(true)}
disabled={isAdmin && adminCount <= 1}
>
<span
@@ -635,383 +540,74 @@ export function LeftSidebar({
</SidebarMenuItem>
</SidebarMenu>
</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 */}
<TabsContent value="registration" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">User Registration</h3>
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
disabled={regLoading}/>
Allow new account registration
</label>
</div>
</TabsContent>
</Sidebar>
<SidebarInset>
{children}
</SidebarInset>
</SidebarProvider>
{/* OIDC Configuration Tab */}
<TabsContent value="oidc" className="space-y-6">
<div className="space-y-4">
<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.
Users will see an "External" login option once configured.
</p>
{!isSidebarOpen && (
<div
onClick={() => setIsSidebarOpen(true)}
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">
<ChevronRight size={10}/>
</div>
)}
{oidcError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{oidcError}</AlertDescription>
</Alert>
)}
{deleteAccountOpen && (
<div
className="fixed inset-0 z-[999999] flex"
style={{
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="space-y-2">
<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
/>
</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>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<div className="text-sm text-gray-300">
This action cannot be undone. This will permanently delete your account and all
associated data.
</SheetDescription>
</SheetHeader>
<div className="pb-4 px-4 flex flex-col gap-4">
</div>
<Alert variant="destructive">
<AlertTitle>Warning</AlertTitle>
<AlertDescription>
@@ -1076,19 +672,18 @@ export function LeftSidebar({
</div>
</form>
</div>
</SheetContent>
</Sheet>
</Sidebar>
<SidebarInset>
{children}
</SidebarInset>
</SidebarProvider>
</div>
</div>
{!isSidebarOpen && (
<div
onClick={() => setIsSidebarOpen(true)}
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">
<ChevronRight size={10} />
<div
className="flex-1"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
style={{cursor: 'pointer'}}
/>
</div>
)}
</div>

View File

@@ -1,7 +1,14 @@
import React from "react";
import {ButtonGroup} from "@/components/ui/button-group.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 {
tabType: string;
@@ -17,7 +24,19 @@ interface TabProps {
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") {
return (
<Button
@@ -42,7 +61,8 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can
onClick={onActivate}
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')}
</Button>
{canSplit && (
@@ -53,7 +73,7 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can
disabled={disableSplit}
title={disableSplit ? 'Cannot split this tab' : 'Split'}
>
<SeparatorVertical className="w-[28px] h-[28px]" />
<SeparatorVertical className="w-[28px] h-[28px]"/>
</Button>
)}
{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 {
id: number;
@@ -33,9 +33,9 @@ interface TabProviderProps {
children: ReactNode;
}
export function TabProvider({ children }: TabProviderProps) {
export function TabProvider({children}: TabProviderProps) {
const [tabs, setTabs] = useState<Tab[]>([
{ id: 1, type: 'home', title: 'Home' }
{id: 1, type: 'home', title: 'Home'}
]);
const [currentTab, setCurrentTab] = useState<number>(1);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
@@ -44,7 +44,6 @@ export function TabProvider({ children }: TabProviderProps) {
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal');
const baseTitle = (desiredTitle || defaultTitle).trim();
// Extract base name without trailing " (n)"
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle;
@@ -64,7 +63,6 @@ export function TabProvider({ children }: TabProviderProps) {
});
if (!rootUsed) return root;
// Start at (2) for the second instance
let n = 2;
while (usedNumbers.has(n)) n += 1;
return `${root} (${n})`;
@@ -74,8 +72,8 @@ export function TabProvider({ children }: TabProviderProps) {
const id = nextTabId.current++;
const needsUniqueTitle = tabData.type === 'terminal' || tabData.type === 'server' || tabData.type === 'file_manager';
const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : (tabData.title || '');
const newTab: Tab = {
...tabData,
const newTab: Tab = {
...tabData,
id,
title: effectiveTitle,
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") {
tab.terminalRef.current.disconnect();
}
setTabs(prev => prev.filter(tab => tab.id !== tabId));
setAllSplitScreenTab(prev => prev.filter(id => id !== tabId));
if (currentTab === tabId) {
const remainingTabs = tabs.filter(tab => tab.id !== tabId);
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 leftPosition = state === "collapsed" ? "26px" : "264px";
// SSH Tools state
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
@@ -47,7 +46,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const handleStartRecording = () => {
setIsRecording(true);
// Focus on the input when recording starts
setTimeout(() => {
const input = document.getElementById('ssh-tools-input') as HTMLInputElement;
if (input) input.focus();
@@ -60,7 +58,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Don't handle input change for special keys - let onKeyDown handle them
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -69,9 +66,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const value = e.currentTarget.value;
let commandToSend = '';
// Handle special keys and control sequences
if (e.ctrlKey || e.metaKey) {
// Control sequences
if (e.key === 'c') {
commandToSend = '\x03'; // Ctrl+C (SIGINT)
e.preventDefault();
@@ -177,7 +172,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
e.preventDefault();
}
// Send the command to all selected terminals
if (commandToSend) {
selectedTabIds.forEach(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>) => {
if (selectedTabIds.length === 0) return;
// Handle regular character input
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
const char = e.key;
selectedTabIds.forEach(tabId => {
@@ -209,7 +202,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
const currentTabIsAdmin = currentTabObj?.type === 'admin';
// Get terminal tabs for selection
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal');
function getCookie(name: string) {
@@ -237,7 +229,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
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) => {
const isActive = tab.id === currentTab;
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 isSshManager = tab.type === 'ssh_manager';
const isAdmin = tab.type === 'admin';
// Split availability
const isSplittable = isTerminal || isServer || isFileManager;
// Disable split entirely when on Home or SSH Manager
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
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
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">
<ChevronDown size={10} />
<ChevronDown size={10}/>
</div>
)}
{/* Custom SSH Tools Overlay */}
{toolsSheetOpen && (
<div
<div
className="fixed inset-0 z-[999999] flex justify-end"
style={{
position: 'fixed',
@@ -316,13 +306,13 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
transform: 'translateZ(0)'
}}
>
<div
<div
className="flex-1"
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"
style={{
backgroundColor: '#18181b',
@@ -346,7 +336,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
<span className="text-lg font-bold leading-none">×</span>
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<h1 className="font-semibold">
@@ -374,11 +364,12 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
</Button>
)}
</div>
{isRecording && (
<>
<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">
{terminalTabs.map(tab => (
<Button
@@ -387,8 +378,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
variant="outline"
size="sm"
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
selectedTabIds.includes(tab.id)
? 'bg-blue-600 text-white border-blue-700 hover:bg-blue-700'
selectedTabIds.includes(tab.id)
? 'bg-blue-600 text-white border-blue-700 hover:bg-blue-700'
: 'bg-transparent text-gray-300 border-gray-500 hover:bg-gray-700'
}`}
onClick={() => handleTabToggle(tab.id)}
@@ -398,9 +389,10 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
))}
</div>
</div>
<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
id="ssh-tools-input"
placeholder="Type here"
@@ -411,7 +403,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
readOnly
/>
<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>
</div>
</>
@@ -426,8 +419,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
</h1>
<div className="flex items-center space-x-2">
<Checkbox
id="enable-copy-paste"
<Checkbox
id="enable-copy-paste"
onCheckedChange={updateRightClickCopyPaste}
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 [isSaving, setIsSaving] = useState(false);
// New state for operations
const [showOperations, setShowOperations] = useState(false);
const [currentPath, setCurrentPath] = useState('/');
// Delete modal state
const [deletingItem, setDeletingItem] = useState<any | null>(null);
const sidebarRef = useRef<any>(null);
@@ -86,7 +84,6 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
useEffect(() => {
if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) {
setCurrentHost(initialHost);
// Defer to ensure sidebar is mounted
setTimeout(() => {
try {
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 handleOperationComplete = () => {
// Refresh the sidebar files
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
sidebarRef.current.fetchFiles();
}
// Refresh home data
if (currentHost) {
fetchHomeData();
}
@@ -471,22 +465,19 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
toast.error(error);
};
// Function to update current path from sidebar
const updateCurrentPath = (newPath: string) => {
setCurrentPath(newPath);
};
// Function to handle delete from sidebar
const handleDeleteFromSidebar = (item: any) => {
setDeletingItem(item);
};
// Function to perform the actual delete
const performDelete = async (item: any) => {
if (!currentHost?.id) return;
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');
toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`);
setDeletingItem(null);
@@ -552,8 +543,8 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
</div>
<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">
{/* Tab list scrollable area */}
<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">
<FIleManagerTopNavbar
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
activeTab={activeTab}
@@ -577,7 +568,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
)}
title="File Operations"
>
<Settings className="h-4 w-4" />
<Settings className="h-4 w-4"/>
</Button>
<Button
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' : ''
)}
>
{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>
</div>
</div>
@@ -608,9 +599,6 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
display: 'flex',
flexDirection: 'column'
}}>
{/* Success/Error Messages */}
{/* The custom alert divs are removed, so this block is no longer needed. */}
{activeTab === 'home' ? (
<div className="flex h-full">
<div className="flex-1">
@@ -658,17 +646,14 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
)}
</div>
{/* Delete Confirmation Modal */}
{deletingItem && (
<div className="fixed inset-0 z-[99999]">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60"></div>
{/* Modal */}
<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">
<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
</h3>
<p className="text-white mb-4">

View File

@@ -32,17 +32,17 @@ interface FileManagerHomeViewProps {
}
export function FileManagerHomeView({
recent,
pinned,
shortcuts,
onOpenFile,
onRemoveRecent,
onPinFile,
onUnpinFile,
onOpenShortcut,
onRemoveShortcut,
onAddShortcut
}: FileManagerHomeViewProps) {
recent,
pinned,
shortcuts,
onOpenFile,
onRemoveRecent,
onPinFile,
onUnpinFile,
onOpenShortcut,
onRemoveShortcut,
onAddShortcut
}: FileManagerHomeViewProps) {
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
const [newShortcut, setNewShortcut] = useState('');
@@ -128,7 +128,8 @@ export function FileManagerHomeView({
</TabsList>
<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 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No recent files.</span>
@@ -145,7 +146,8 @@ export function FileManagerHomeView({
</TabsContent>
<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 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No pinned files.</span>
@@ -190,7 +192,8 @@ export function FileManagerHomeView({
Add
</Button>
</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 ? (
<div className="flex items-center justify-center py-4 col-span-full">
<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);
// Context menu state
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
x: number;
@@ -96,21 +95,18 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
item: null
});
// Rename state
const [renamingItem, setRenamingItem] = useState<{
item: any;
newName: string;
} | null>(null);
useEffect(() => {
// when host changes, set path and connect
const nextPath = host?.defaultPath || '/';
setCurrentPath(nextPath);
onPathChange?.(nextPath);
(async () => {
await connectToSSH(host);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [host?.id]);
async function connectToSSH(server: SSHHost): Promise<string | null> {
@@ -282,35 +278,28 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
const handleContextMenu = (e: React.MouseEvent, item: any) => {
e.preventDefault();
// Get viewport dimensions
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Context menu dimensions (approximate)
const menuWidth = 160; // min-w-[160px]
const menuHeight = 80; // Approximate height for 2 menu items
// Calculate position
const menuWidth = 160;
const menuHeight = 80;
let x = e.clientX;
let y = e.clientY;
// Adjust X position if menu would go off right edge
if (x + menuWidth > viewportWidth) {
x = e.clientX - menuWidth;
}
// Adjust Y position if menu would go off bottom edge
if (y + menuHeight > viewportHeight) {
y = e.clientY - menuHeight;
}
// Ensure menu doesn't go off left edge
if (x < 0) {
x = 0;
}
// Ensure menu doesn't go off top edge
if (y < 0) {
y = 0;
}
@@ -369,12 +358,10 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
};
const startDelete = (item: any) => {
// Call the parent's delete handler instead of managing locally
onDeleteItem?.(item);
closeContextMenu();
};
// Close context menu when clicking outside
useEffect(() => {
const handleClickOutside = () => closeContextMenu();
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">
{host && (
<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
size="icon"
variant="outline"
@@ -440,7 +427,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
{filteredFiles.map((item: any) => {
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
const isRenaming = renamingItem?.item?.path === item.path;
const isDeleting = false; // Deletion is handled by parent
const isDeleting = false;
return (
<div
@@ -519,7 +506,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
));
}
} catch (err) {
console.error('Failed to pin/unpin file:', err);
}
}}
>
@@ -555,7 +541,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
</div>
</div>
{/* Context Menu */}
{contextMenu.visible && contextMenu.item && (
<div
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({
sshConnections,
onAddSSH,
onConnectSSH,
onEditSSH,
onDeleteSSH,
onPinSSH,
currentPath,
files,
onOpenFile,
onOpenFolder,
onStarFile,
onDeleteFile,
isLoading,
error,
isSSHMode,
onSwitchToLocal,
onSwitchToSSH,
currentSSH,
}: FileManagerLeftSidebarVileViewerProps) {
sshConnections,
onAddSSH,
onConnectSSH,
onEditSSH,
onDeleteSSH,
onPinSSH,
currentPath,
files,
onOpenFile,
onOpenFolder,
onStarFile,
onDeleteFile,
isLoading,
error,
isSSHMode,
onSwitchToLocal,
onSwitchToSSH,
currentSSH,
}: FileManagerLeftSidebarVileViewerProps) {
return (
<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="mb-2 flex items-center gap-2">
<span

View File

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

View File

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

View File

@@ -811,7 +811,9 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
render={({field: sourcePortField}) => (
<FormItem className="col-span-4">
<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>
<Input
placeholder="22" {...sourcePortField} />
@@ -1029,8 +1031,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
</ScrollArea>
<footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25"/>
<Button
className=""
<Button
className=""
type="submit"
variant="outline"
style={{

View File

@@ -581,7 +581,7 @@ EXAMPLE STRUCTURE:
Format Guide
</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">
Refresh

View File

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

View File

@@ -17,7 +17,6 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
{hostConfig, isVisible, splitScreen = false},
ref
) {
console.log('TerminalComponent rendered with:', { hostConfig, isVisible, splitScreen });
const {instance: terminal, ref: xtermRef} = useXTerm();
const fitAddonRef = useRef<FitAddon | 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 isVisibleRef = useRef<boolean>(false);
// Debounce/stabilize resize notifications
const lastSentSizeRef = useRef<{cols:number; rows:number} | null>(null);
const pendingSizeRef = 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 notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
const DEBOUNCE_MS = 140;
useEffect(() => { isVisibleRef.current = isVisible; }, [isVisible]);
useEffect(() => {
isVisibleRef.current = isVisible;
}, [isVisible]);
function hardRefresh() {
try {
if (terminal && typeof (terminal as any).refresh === 'function') {
(terminal as any).refresh(0, terminal.rows - 1);
}
} catch (_) {}
} catch (_) {
}
}
function scheduleNotify(cols: number, rows: number) {
@@ -85,7 +86,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
scheduleNotify(cols, rows);
hardRefresh();
}
} catch (_) {}
} catch (_) {
}
},
refresh: () => hardRefresh(),
}), [terminal]);
@@ -119,7 +121,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
await navigator.clipboard.writeText(text);
return;
}
} catch (_) {}
} catch (_) {
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
@@ -127,7 +130,11 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
document.body.appendChild(textarea);
textarea.focus();
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> {
@@ -135,7 +142,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
if (navigator.clipboard && navigator.clipboard.readText) {
return await navigator.clipboard.readText();
}
} catch (_) {}
} catch (_) {
}
return '';
}
@@ -148,7 +156,7 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
scrollback: 10000,
fontSize: 14,
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,
convertEol: true,
windowsMode: false,
@@ -175,16 +183,21 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
const element = xtermRef.current;
const handleContextMenu = async (e: MouseEvent) => {
if (!getUseRightClickCopyPaste()) return;
e.preventDefault(); e.stopPropagation();
e.preventDefault();
e.stopPropagation();
try {
if (terminal.hasSelection()) {
const selection = terminal.getSelection();
if (selection) { await writeTextToClipboard(selection); terminal.clearSelection(); }
if (selection) {
await writeTextToClipboard(selection);
terminal.clearSelection();
}
} else {
const pasteText = await readTextFromClipboard();
if (pasteText) terminal.paste(pasteText);
}
} catch (_) {}
} catch (_) {
}
};
element?.addEventListener('contextmenu', handleContextMenu);
@@ -221,8 +234,14 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
ws.addEventListener('open', () => {
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
terminal.onData((data) => { ws.send(JSON.stringify({type: 'input', data})); });
pingIntervalRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({type: 'ping'})); } }, 30000);
terminal.onData((data) => {
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) => {
@@ -230,13 +249,21 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
const msg = JSON.parse(event.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 === 'connected') { }
else if (msg.type === 'disconnected') { wasDisconnectedBySSH.current = true; terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`); }
} catch (error) { console.error('Error parsing WebSocket message:', error); }
else if (msg.type === 'connected') {
} else if (msg.type === 'disconnected') {
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('error', () => { terminal.writeln('\r\n[Connection error]'); });
ws.addEventListener('close', () => {
if (!wasDisconnectedBySSH.current) terminal.writeln('\r\n[Connection closed]');
});
ws.addEventListener('error', () => {
terminal.writeln('\r\n[Connection error]');
});
}, 300);
});
@@ -245,7 +272,10 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
element?.removeEventListener('contextmenu', handleContextMenu);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.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();
};
}, [xtermRef, terminal, hostConfig]);
@@ -260,7 +290,6 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
}
}, [isVisible]);
// Ensure a fit when split mode toggles to account for new pane geometry
useEffect(() => {
if (!fitAddonRef.current) return;
setTimeout(() => {
@@ -271,7 +300,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
}, [splitScreen]);
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;
}
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
// Keep full list for endpoint lookups; keep a separate visible list for UI
export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
@@ -86,7 +85,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
})
: hostsData;
// Silent update: only set state if meaningful changes
const prev = prevVisibleHostRef.current;
const curr = nextVisible[0] ?? null;
let changed = false;
@@ -120,7 +118,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
fetchHosts();
const interval = setInterval(fetchHosts, 5000);
// Refresh immediately when hosts are changed elsewhere (e.g., SSH Manager)
const handleHostsChanged = () => {
fetchHosts();
};

View File

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

View File

@@ -47,12 +47,11 @@ interface SSHTunnelViewerProps {
}
export function TunnelViewer({
hosts = [],
tunnelStatuses = {},
tunnelActions = {},
onTunnelAction
}: SSHTunnelViewerProps): React.ReactElement {
// Single-host view: use first host if present
hosts = [],
tunnelStatuses = {},
tunnelActions = {},
onTunnelAction
}: SSHTunnelViewerProps): React.ReactElement {
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
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">
<h3 className="text-lg font-semibold text-foreground mb-2">No SSH Tunnels</h3>
<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>
</div>
);
@@ -72,7 +72,8 @@ export function TunnelViewer({
<h1 className="text-xl font-semibold text-foreground">SSH Tunnels</h1>
</div>
<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) => (
<TunnelObject
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}