Format code

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

View File

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

View File

@@ -41,93 +41,339 @@ const dbPath = path.join(dataDir, 'db.sqlite');
const sqlite = new Database(dbPath); const sqlite = new Database(dbPath);
sqlite.exec(` sqlite.exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users
id TEXT PRIMARY KEY, (
username TEXT NOT NULL, id
password_hash TEXT NOT NULL, TEXT
is_admin INTEGER NOT NULL DEFAULT 0, PRIMARY
KEY,
username
TEXT
NOT
NULL,
password_hash
TEXT
NOT
NULL,
is_admin
INTEGER
NOT
NULL
DEFAULT
0,
is_oidc INTEGER NOT NULL DEFAULT 0, is_oidc
client_id TEXT NOT NULL, INTEGER
client_secret TEXT NOT NULL, NOT
issuer_url TEXT NOT NULL, NULL
authorization_url TEXT NOT NULL, DEFAULT
token_url TEXT NOT NULL, 0,
redirect_uri TEXT, client_id
identifier_path TEXT NOT NULL, TEXT
name_path TEXT NOT NULL, NOT
scopes TEXT NOT NULL NULL,
client_secret
TEXT
NOT
NULL,
issuer_url
TEXT
NOT
NULL,
authorization_url
TEXT
NOT
NULL,
token_url
TEXT
NOT
NULL,
redirect_uri
TEXT,
identifier_path
TEXT
NOT
NULL,
name_path
TEXT
NOT
NULL,
scopes
TEXT
NOT
NULL
); );
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings
key TEXT PRIMARY KEY, (
value TEXT NOT NULL key
TEXT
PRIMARY
KEY,
value
TEXT
NOT
NULL
); );
CREATE TABLE IF NOT EXISTS ssh_data ( CREATE TABLE IF NOT EXISTS ssh_data
id INTEGER PRIMARY KEY AUTOINCREMENT, (
user_id TEXT NOT NULL, id
name TEXT, INTEGER
ip TEXT NOT NULL, PRIMARY
port INTEGER NOT NULL, KEY
username TEXT NOT NULL, AUTOINCREMENT,
folder TEXT, user_id
tags TEXT, TEXT
pin INTEGER NOT NULL DEFAULT 0, NOT
auth_type TEXT NOT NULL, NULL,
password TEXT, name
key TEXT, TEXT,
key_password TEXT, ip
key_type TEXT, TEXT
enable_terminal INTEGER NOT NULL DEFAULT 1, NOT
enable_tunnel INTEGER NOT NULL DEFAULT 1, NULL,
tunnel_connections TEXT, port
enable_file_manager INTEGER NOT NULL DEFAULT 1, INTEGER
default_path TEXT, NOT
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, username
FOREIGN KEY (user_id) REFERENCES users(id) TEXT
NOT
NULL,
folder
TEXT,
tags
TEXT,
pin
INTEGER
NOT
NULL
DEFAULT
0,
auth_type
TEXT
NOT
NULL,
password
TEXT,
key
TEXT,
key_password
TEXT,
key_type
TEXT,
enable_terminal
INTEGER
NOT
NULL
DEFAULT
1,
enable_tunnel
INTEGER
NOT
NULL
DEFAULT
1,
tunnel_connections
TEXT,
enable_file_manager
INTEGER
NOT
NULL
DEFAULT
1,
default_path
TEXT,
created_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
updated_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
)
); );
CREATE TABLE IF NOT EXISTS file_manager_recent ( CREATE TABLE IF NOT EXISTS file_manager_recent
id INTEGER PRIMARY KEY AUTOINCREMENT, (
user_id TEXT NOT NULL, id
host_id INTEGER NOT NULL, INTEGER
name TEXT NOT NULL, PRIMARY
path TEXT NOT NULL, KEY
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, AUTOINCREMENT,
FOREIGN KEY (user_id) REFERENCES users(id), user_id
FOREIGN KEY (host_id) REFERENCES ssh_data(id) TEXT
NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
last_opened
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
); );
CREATE TABLE IF NOT EXISTS file_manager_pinned ( CREATE TABLE IF NOT EXISTS file_manager_pinned
id INTEGER PRIMARY KEY AUTOINCREMENT, (
user_id TEXT NOT NULL, id
host_id INTEGER NOT NULL, INTEGER
name TEXT NOT NULL, PRIMARY
path TEXT NOT NULL, KEY
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, AUTOINCREMENT,
FOREIGN KEY (user_id) REFERENCES users(id), user_id
FOREIGN KEY (host_id) REFERENCES ssh_data(id) TEXT
NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
pinned_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
); );
CREATE TABLE IF NOT EXISTS file_manager_shortcuts ( CREATE TABLE IF NOT EXISTS file_manager_shortcuts
id INTEGER PRIMARY KEY AUTOINCREMENT, (
user_id TEXT NOT NULL, id
host_id INTEGER NOT NULL, INTEGER
name TEXT NOT NULL, PRIMARY
path TEXT NOT NULL, KEY
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, AUTOINCREMENT,
FOREIGN KEY (user_id) REFERENCES users(id), user_id
FOREIGN KEY (host_id) REFERENCES ssh_data(id) TEXT
NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
created_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
); );
CREATE TABLE IF NOT EXISTS dismissed_alerts ( CREATE TABLE IF NOT EXISTS dismissed_alerts
id INTEGER PRIMARY KEY AUTOINCREMENT, (
user_id TEXT NOT NULL, id
alert_id TEXT NOT NULL, INTEGER
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY
FOREIGN KEY (user_id) REFERENCES users(id) KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
alert_id
TEXT
NOT
NULL,
dismissed_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
)
); );
`); `);

View File

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

View File

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

View File

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

View File

@@ -35,11 +35,9 @@ interface AdminSettingsProps {
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement { export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
const {state: sidebarState} = useSidebar(); const {state: sidebarState} = useSidebar();
// Registration toggle
const [allowRegistration, setAllowRegistration] = React.useState(true); const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false); const [regLoading, setRegLoading] = React.useState(false);
// OIDC config
const [oidcConfig, setOidcConfig] = React.useState({ const [oidcConfig, setOidcConfig] = React.useState({
client_id: '', client_id: '',
client_secret: '', client_secret: '',
@@ -54,8 +52,12 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
const [oidcError, setOidcError] = React.useState<string | null>(null); const [oidcError, setOidcError] = React.useState<string | null>(null);
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null); const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
// Users/admins const [users, setUsers] = React.useState<Array<{
const [users, setUsers] = React.useState<Array<{ id: string; username: string; is_admin: boolean; is_oidc: boolean }>>([]); id: string;
username: string;
is_admin: boolean;
is_oidc: boolean
}>>([]);
const [usersLoading, setUsersLoading] = React.useState(false); const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState(""); const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false); const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
@@ -65,15 +67,15 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
React.useEffect(() => { React.useEffect(() => {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
if (!jwt) return; if (!jwt) return;
// Preload OIDC config and users
API.get("/oidc-config", {headers: {Authorization: `Bearer ${jwt}`}}) API.get("/oidc-config", {headers: {Authorization: `Bearer ${jwt}`}})
.then(res => { if (res.data) setOidcConfig(res.data); }) .then(res => {
.catch(() => {}); if (res.data) setOidcConfig(res.data);
})
.catch(() => {
});
fetchUsers(); fetchUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Load initial registration toggle status
React.useEffect(() => { React.useEffect(() => {
API.get("/registration-allowed") API.get("/registration-allowed")
.then(res => { .then(res => {
@@ -81,7 +83,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
setAllowRegistration(res.data.allowed); setAllowRegistration(res.data.allowed);
} }
}) })
.catch(() => {}); .catch(() => {
});
}, []); }, []);
const fetchUsers = async () => { const fetchUsers = async () => {
@@ -161,7 +164,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
try { try {
await API.post("/remove-admin", {username}, {headers: {Authorization: `Bearer ${jwt}`}}); await API.post("/remove-admin", {username}, {headers: {Authorization: `Bearer ${jwt}`}});
fetchUsers(); fetchUsers();
} catch {} } catch {
}
}; };
const deleteUser = async (username: string) => { const deleteUser = async (username: string) => {
@@ -170,7 +174,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
try { try {
await API.delete("/delete-user", {headers: {Authorization: `Bearer ${jwt}`}, data: {username}}); await API.delete("/delete-user", {headers: {Authorization: `Bearer ${jwt}`}, data: {username}});
fetchUsers(); fetchUsers();
} catch {} } catch {
}
}; };
const topMarginPx = isTopbarOpen ? 74 : 26; const topMarginPx = isTopbarOpen ? 74 : 26;
@@ -185,7 +190,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
}; };
return ( return (
<div style={wrapperStyle} className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden"> <div style={wrapperStyle}
className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
<div className="h-full w-full flex flex-col"> <div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2"> <div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">Admin Settings</h1> <h1 className="font-bold text-lg">Admin Settings</h1>
@@ -217,7 +223,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-semibold">User Registration</h3> <h3 className="text-lg font-semibold">User Registration</h3>
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration} disabled={regLoading}/> <Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration}
disabled={regLoading}/>
Allow new account registration Allow new account registration
</label> </label>
</div> </div>
@@ -226,7 +233,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<TabsContent value="oidc" className="space-y-6"> <TabsContent value="oidc" className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3> <h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
<p className="text-sm text-muted-foreground">Configure external identity provider for OIDC/OAuth2 authentication.</p> <p className="text-sm text-muted-foreground">Configure external identity provider for
OIDC/OAuth2 authentication.</p>
{oidcError && ( {oidcError && (
<Alert variant="destructive"> <Alert variant="destructive">
@@ -238,39 +246,66 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4"> <form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="client_id">Client ID</Label> <Label htmlFor="client_id">Client ID</Label>
<Input id="client_id" value={oidcConfig.client_id} onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)} placeholder="your-client-id" required /> <Input id="client_id" value={oidcConfig.client_id}
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
placeholder="your-client-id" required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="client_secret">Client Secret</Label> <Label htmlFor="client_secret">Client Secret</Label>
<Input id="client_secret" type="password" value={oidcConfig.client_secret} onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)} placeholder="your-client-secret" required /> <Input id="client_secret" type="password" value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder="your-client-secret" required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="authorization_url">Authorization URL</Label> <Label htmlFor="authorization_url">Authorization URL</Label>
<Input id="authorization_url" value={oidcConfig.authorization_url} onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)} placeholder="https://your-provider.com/application/o/authorize/" required /> <Input id="authorization_url" value={oidcConfig.authorization_url}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
placeholder="https://your-provider.com/application/o/authorize/"
required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="issuer_url">Issuer URL</Label> <Label htmlFor="issuer_url">Issuer URL</Label>
<Input id="issuer_url" value={oidcConfig.issuer_url} onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)} placeholder="https://your-provider.com/application/o/termix/" required /> <Input id="issuer_url" value={oidcConfig.issuer_url}
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
placeholder="https://your-provider.com/application/o/termix/" required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="token_url">Token URL</Label> <Label htmlFor="token_url">Token URL</Label>
<Input id="token_url" value={oidcConfig.token_url} onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)} placeholder="https://your-provider.com/application/o/token/" required /> <Input id="token_url" value={oidcConfig.token_url}
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
placeholder="https://your-provider.com/application/o/token/" required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="identifier_path">User Identifier Path</Label> <Label htmlFor="identifier_path">User Identifier Path</Label>
<Input id="identifier_path" value={oidcConfig.identifier_path} onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)} placeholder="sub" required /> <Input id="identifier_path" value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder="sub" required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name_path">Display Name Path</Label> <Label htmlFor="name_path">Display Name Path</Label>
<Input id="name_path" value={oidcConfig.name_path} onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)} placeholder="name" required /> <Input id="name_path" value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder="name" required/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label> <Label htmlFor="scopes">Scopes</Label>
<Input id="scopes" value={oidcConfig.scopes} onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)} placeholder="openid email profile" required /> <Input id="scopes" value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
placeholder="openid email profile" required/>
</div> </div>
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<Button type="submit" className="flex-1" disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button> <Button type="submit" className="flex-1"
<Button type="button" variant="outline" onClick={() => setOidcConfig({ client_id: '', client_secret: '', issuer_url: '', authorization_url: '', token_url: '', identifier_path: 'sub', name_path: 'name', scopes: 'openid email profile' })}>Reset</Button> disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
<Button type="button" variant="outline" onClick={() => setOidcConfig({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
})}>Reset</Button>
</div> </div>
{oidcSuccess && ( {oidcSuccess && (
@@ -287,7 +322,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">User Management</h3> <h3 className="text-lg font-semibold">User Management</h3>
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline" size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button> <Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
</div> </div>
{usersLoading ? ( {usersLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading users...</div> <div className="text-center py-8 text-muted-foreground">Loading users...</div>
@@ -307,12 +343,17 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<TableCell className="px-4 font-medium"> <TableCell className="px-4 font-medium">
{user.username} {user.username}
{user.is_admin && ( {user.is_admin && (
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span> <span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
)} )}
</TableCell> </TableCell>
<TableCell className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell> <TableCell
className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4"> <TableCell className="px-4">
<Button variant="ghost" size="sm" onClick={() => deleteUser(user.username)} className="text-red-600 hover:text-red-700 hover:bg-red-50" disabled={user.is_admin}> <Button variant="ghost" size="sm"
onClick={() => deleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}>
<Trash2 className="h-4 w-4"/> <Trash2 className="h-4 w-4"/>
</Button> </Button>
</TableCell> </TableCell>
@@ -334,8 +375,11 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="new-admin-username">Username</Label> <Label htmlFor="new-admin-username">Username</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input id="new-admin-username" value={newAdminUsername} onChange={(e) => setNewAdminUsername(e.target.value)} placeholder="Enter username to make admin" required /> <Input id="new-admin-username" value={newAdminUsername}
<Button type="submit" disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button> onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder="Enter username to make admin" required/>
<Button type="submit"
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
</div> </div>
</div> </div>
{makeAdminError && ( {makeAdminError && (
@@ -369,11 +413,15 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
<TableRow key={admin.id}> <TableRow key={admin.id}>
<TableCell className="px-4 font-medium"> <TableCell className="px-4 font-medium">
{admin.username} {admin.username}
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span> <span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
</TableCell> </TableCell>
<TableCell className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell> <TableCell
className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4"> <TableCell className="px-4">
<Button variant="ghost" size="sm" onClick={() => removeAdminStatus(admin.username)} className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"> <Button variant="ghost" size="sm"
onClick={() => removeAdminStatus(admin.username)}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
<Shield className="h-4 w-4"/> <Shield className="h-4 w-4"/>
Remove Admin Remove Admin
</Button> </Button>

View File

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

View File

@@ -149,13 +149,11 @@ export function HomepageAuth({
setUsername(meRes.data.username || null); setUsername(meRes.data.username || null);
setUserId(meRes.data.id || null); setUserId(meRes.data.id || null);
setDbError(null); setDbError(null);
// Call onAuthSuccess to update App state
onAuthSuccess({ onAuthSuccess({
isAdmin: !!meRes.data.is_admin, isAdmin: !!meRes.data.is_admin,
username: meRes.data.username || null, username: meRes.data.username || null,
userId: meRes.data.id || null userId: meRes.data.id || null
}); });
// Update internal state immediately
setInternalLoggedIn(true); setInternalLoggedIn(true);
if (tab === "signup") { if (tab === "signup") {
setSignupConfirmPassword(""); setSignupConfirmPassword("");
@@ -309,13 +307,11 @@ export function HomepageAuth({
setUsername(meRes.data.username || null); setUsername(meRes.data.username || null);
setUserId(meRes.data.id || null); setUserId(meRes.data.id || null);
setDbError(null); setDbError(null);
// Call onAuthSuccess to update App state
onAuthSuccess({ onAuthSuccess({
isAdmin: !!meRes.data.is_admin, isAdmin: !!meRes.data.is_admin,
username: meRes.data.username || null, username: meRes.data.username || null,
userId: meRes.data.id || null userId: meRes.data.id || null
}); });
// Update internal state immediately
setInternalLoggedIn(true); setInternalLoggedIn(true);
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
}) })

View File

@@ -51,7 +51,6 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
}); });
}; };
// Coalesce layout → measure → fit callbacks
const layoutScheduleRef = useRef<number | null>(null); const layoutScheduleRef = useRef<number | null>(null);
const scheduleMeasureAndFit = () => { const scheduleMeasureAndFit = () => {
if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current); if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current);
@@ -63,7 +62,6 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
}); });
}; };
// Hide terminals until layout → rects → fit applied to prevent first-frame wrapping
const hideThenFit = () => { const hideThenFit = () => {
setReady(false); setReady(false);
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -77,13 +75,10 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
useEffect(() => { useEffect(() => {
hideThenFit(); hideThenFit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]); }, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]);
// When split layout toggles on/off, topbar toggles, or sidebar state changes → measure+fit
useEffect(() => { useEffect(() => {
scheduleMeasureAndFit(); scheduleMeasureAndFit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]); }, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
useEffect(() => { useEffect(() => {
@@ -93,14 +88,15 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
}) : null; }) : null;
if (containerRef.current && roContainer) roContainer.observe(containerRef.current); if (containerRef.current && roContainer) roContainer.observe(containerRef.current);
return () => roContainer?.disconnect(); return () => roContainer?.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => { useEffect(() => {
const onWinResize = () => { updatePanelRects(); fitActiveAndNotify(); }; const onWinResize = () => {
updatePanelRects();
fitActiveAndNotify();
};
window.addEventListener('resize', onWinResize); window.addEventListener('resize', onWinResize);
return () => window.removeEventListener('resize', onWinResize); return () => window.removeEventListener('resize', onWinResize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const HEADER_H = 28; const HEADER_H = 28;
@@ -112,7 +108,17 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[]; const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
if (allSplitScreenTab.length === 0 && mainTab) { if (allSplitScreenTab.length === 0 && mainTab) {
styles[mainTab.id] = { position:'absolute', top:2, left:2, right:2, bottom:2, zIndex: 20, display: 'block', pointerEvents:'auto', opacity: ready ? 1 : 0 }; styles[mainTab.id] = {
position: 'absolute',
top: 2,
left: 2,
right: 2,
bottom: 2,
zIndex: 20,
display: 'block',
pointerEvents: 'auto',
opacity: ready ? 1 : 0
};
} else { } else {
layoutTabs.forEach((t: any) => { layoutTabs.forEach((t: any) => {
const rect = panelRects[String(t.id)]; const rect = panelRects[String(t.id)];
@@ -139,7 +145,6 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
const hasStyle = !!styles[t.id]; const hasStyle = !!styles[t.id];
const isVisible = hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab); const isVisible = hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
// Visible style from computed positions; otherwise keep mounted but hidden and non-interactive
const finalStyle: React.CSSProperties = hasStyle const finalStyle: React.CSSProperties = hasStyle
? {...styles[t.id], overflow: 'hidden'} ? {...styles[t.id], overflow: 'hidden'}
: { : {
@@ -211,16 +216,63 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
const [a, b] = layoutTabs as any[]; const [a, b] = layoutTabs as any[];
return ( return (
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}> <div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal" className="h-full w-full" {...commonGroupProps}> <ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal"
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${a.id}`} order={1}> className="h-full w-full" {...commonGroupProps}>
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',background:'transparent',position:'relative'}}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{a.title}</div> id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{a.title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${b.id}`} order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',background:'transparent',position:'relative'}}> id={`panel-${b.id}`} order={2}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}> <div ref={el => {
panelRefs.current[String(b.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>
{b.title} {b.title}
<ResetButton onClick={handleReset}/> <ResetButton onClick={handleReset}/>
</div> </div>
@@ -234,18 +286,65 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
const [a, b, c] = layoutTabs as any[]; const [a, b, c] = layoutTabs as any[];
return ( return (
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}> <div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full" id="main-vertical" {...commonGroupProps}> <ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}> id="main-vertical" {...commonGroupProps}>
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal" className="h-full w-full" id="top-horizontal" {...commonGroupProps}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${a.id}`} order={1}> id="top-panel" order={1}>
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}> <ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{a.title}</div> className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{a.title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${b.id}`} order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}> id={`panel-${b.id}`} order={2}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}> <div ref={el => {
panelRefs.current[String(b.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>
{b.title} {b.title}
<ResetButton onClick={handleReset}/> <ResetButton onClick={handleReset}/>
</div> </div>
@@ -254,9 +353,31 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
</ResizablePanelGroup> </ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<div ref={el => { panelRefs.current[String(c.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}> id="bottom-panel" order={2}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{c.title}</div> <div ref={el => {
panelRefs.current[String(c.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{c.title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePrimitive.PanelGroup> </ResizablePrimitive.PanelGroup>
@@ -267,18 +388,65 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
const [a, b, c, d] = layoutTabs as any[]; const [a, b, c, d] = layoutTabs as any[];
return ( return (
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}> <div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full" id="main-vertical" {...commonGroupProps}> <ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}> id="main-vertical" {...commonGroupProps}>
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal" className="h-full w-full" id="top-horizontal" {...commonGroupProps}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${a.id}`} order={1}> id="top-panel" order={1}>
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}> <ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{a.title}</div> className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{a.title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${b.id}`} order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}> id={`panel-${b.id}`} order={2}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}> <div ref={el => {
panelRefs.current[String(b.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>
{b.title} {b.title}
<ResetButton onClick={handleReset}/> <ResetButton onClick={handleReset}/>
</div> </div>
@@ -287,17 +455,63 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
</ResizablePanelGroup> </ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id="bottom-panel" order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal" className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}> id="bottom-panel" order={2}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${c.id}`} order={1}> <ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal"
<div ref={el => { panelRefs.current[String(c.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}> className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{c.title}</div> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${c.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(c.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{c.title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle}/> <ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${d.id}`} order={2}> <ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
<div ref={el => { panelRefs.current[String(d.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}> id={`panel-${d.id}`} order={2}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{d.title}</div> <div ref={el => {
panelRefs.current[String(d.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{d.title}</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>

View File

@@ -43,7 +43,8 @@ export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardPro
}; };
return ( return (
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden" style={{padding: '0', margin: '0'}}> <div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden"
style={{padding: '0', margin: '0'}}>
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}> <div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
<div className="flex gap-2 pr-10"> <div className="flex gap-2 pr-10">
<div className="flex-shrink-0 flex items-center"> <div className="flex-shrink-0 flex items-center">

View File

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

View File

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

View File

@@ -44,7 +44,6 @@ export function TabProvider({ children }: TabProviderProps) {
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string { function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal'); const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal');
const baseTitle = (desiredTitle || defaultTitle).trim(); const baseTitle = (desiredTitle || defaultTitle).trim();
// Extract base name without trailing " (n)"
const match = baseTitle.match(/^(.*) \((\d+)\)$/); const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle; const root = match ? match[1] : baseTitle;
@@ -64,7 +63,6 @@ export function TabProvider({ children }: TabProviderProps) {
}); });
if (!rootUsed) return root; if (!rootUsed) return root;
// Start at (2) for the second instance
let n = 2; let n = 2;
while (usedNumbers.has(n)) n += 1; while (usedNumbers.has(n)) n += 1;
return `${root} (${n})`; return `${root} (${n})`;

View File

@@ -24,7 +24,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any; const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any;
const leftPosition = state === "collapsed" ? "26px" : "264px"; const leftPosition = state === "collapsed" ? "26px" : "264px";
// SSH Tools state
const [toolsSheetOpen, setToolsSheetOpen] = useState(false); const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]); const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
@@ -47,7 +46,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const handleStartRecording = () => { const handleStartRecording = () => {
setIsRecording(true); setIsRecording(true);
// Focus on the input when recording starts
setTimeout(() => { setTimeout(() => {
const input = document.getElementById('ssh-tools-input') as HTMLInputElement; const input = document.getElementById('ssh-tools-input') as HTMLInputElement;
if (input) input.focus(); if (input) input.focus();
@@ -60,7 +58,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
}; };
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Don't handle input change for special keys - let onKeyDown handle them
}; };
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -69,9 +66,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const value = e.currentTarget.value; const value = e.currentTarget.value;
let commandToSend = ''; let commandToSend = '';
// Handle special keys and control sequences
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
// Control sequences
if (e.key === 'c') { if (e.key === 'c') {
commandToSend = '\x03'; // Ctrl+C (SIGINT) commandToSend = '\x03'; // Ctrl+C (SIGINT)
e.preventDefault(); e.preventDefault();
@@ -177,7 +172,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
e.preventDefault(); e.preventDefault();
} }
// Send the command to all selected terminals
if (commandToSend) { if (commandToSend) {
selectedTabIds.forEach(tabId => { selectedTabIds.forEach(tabId => {
const tab = tabs.find((t: any) => t.id === tabId); const tab = tabs.find((t: any) => t.id === tabId);
@@ -191,7 +185,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (selectedTabIds.length === 0) return; if (selectedTabIds.length === 0) return;
// Handle regular character input
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) { if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
const char = e.key; const char = e.key;
selectedTabIds.forEach(tabId => { selectedTabIds.forEach(tabId => {
@@ -209,7 +202,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager'; const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
const currentTabIsAdmin = currentTabObj?.type === 'admin'; const currentTabIsAdmin = currentTabObj?.type === 'admin';
// Get terminal tabs for selection
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal'); const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal');
function getCookie(name: string) { function getCookie(name: string) {
@@ -237,7 +229,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
padding: "0" padding: "0"
}} }}
> >
<div className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar"> <div
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
{tabs.map((tab: any) => { {tabs.map((tab: any) => {
const isActive = tab.id === currentTab; const isActive = tab.id === currentTab;
const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id); const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id);
@@ -246,9 +239,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const isFileManager = tab.type === 'file_manager'; const isFileManager = tab.type === 'file_manager';
const isSshManager = tab.type === 'ssh_manager'; const isSshManager = tab.type === 'ssh_manager';
const isAdmin = tab.type === 'admin'; const isAdmin = tab.type === 'admin';
// Split availability
const isSplittable = isTerminal || isServer || isFileManager; const isSplittable = isTerminal || isServer || isFileManager;
// Disable split entirely when on Home or SSH Manager
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit); const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin; const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive); const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive);
@@ -300,7 +291,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
</div> </div>
)} )}
{/* Custom SSH Tools Overlay */}
{toolsSheetOpen && ( {toolsSheetOpen && (
<div <div
className="fixed inset-0 z-[999999] flex justify-end" className="fixed inset-0 z-[999999] flex justify-end"
@@ -378,7 +368,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
{isRecording && ( {isRecording && (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-white">Select terminals:</label> <label className="text-sm font-medium text-white">Select
terminals:</label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2"> <div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
{terminalTabs.map(tab => ( {terminalTabs.map(tab => (
<Button <Button
@@ -400,7 +391,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-white">Type commands (all keys supported):</label> <label className="text-sm font-medium text-white">Type commands (all
keys supported):</label>
<Input <Input
id="ssh-tools-input" id="ssh-tools-input"
placeholder="Type here" placeholder="Type here"
@@ -411,7 +403,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
readOnly readOnly
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Commands will be sent to {selectedTabIds.length} selected terminal(s). Commands will be sent to {selectedTabIds.length} selected
terminal(s).
</p> </p>
</div> </div>
</> </>

View File

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

View File

@@ -128,7 +128,8 @@ export function FileManagerHomeView({
</TabsList> </TabsList>
<TabsContent value="recent" className="mt-0"> <TabsContent value="recent" className="mt-0">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full"> <div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{recent.length === 0 ? ( {recent.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full"> <div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No recent files.</span> <span className="text-sm text-muted-foreground">No recent files.</span>
@@ -145,7 +146,8 @@ export function FileManagerHomeView({
</TabsContent> </TabsContent>
<TabsContent value="pinned" className="mt-0"> <TabsContent value="pinned" className="mt-0">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full"> <div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{pinned.length === 0 ? ( {pinned.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full"> <div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">No pinned files.</span> <span className="text-sm text-muted-foreground">No pinned files.</span>
@@ -190,7 +192,8 @@ export function FileManagerHomeView({
Add Add
</Button> </Button>
</div> </div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full"> <div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{shortcuts.length === 0 ? ( {shortcuts.length === 0 ? (
<div className="flex items-center justify-center py-4 col-span-full"> <div className="flex items-center justify-center py-4 col-span-full">
<span className="text-sm text-muted-foreground">No shortcuts.</span> <span className="text-sm text-muted-foreground">No shortcuts.</span>

View File

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

View File

@@ -63,48 +63,6 @@ export function FileManagerLeftSidebarFileViewer({
}: FileManagerLeftSidebarVileViewerProps) { }: FileManagerLeftSidebarVileViewerProps) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* SSH Connections */}
<div className="p-2 bg-[#18181b] border-b-2 border-[#303032]">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground font-semibold">SSH Connections</span>
<Button size="icon" variant="outline" onClick={onAddSSH} className="ml-2 h-7 w-7">
<Plus className="w-4 h-4"/>
</Button>
</div>
<div className="flex flex-col gap-1">
<Button
variant={!isSSHMode ? 'secondary' : 'ghost'}
className="w-full justify-start text-left px-2 py-1.5 rounded"
onClick={onSwitchToLocal}
>
<Server className="w-4 h-4 mr-2"/> Local Files
</Button>
{sshConnections.map((conn) => (
<div key={conn.id} className="flex items-center gap-1 group">
<Button
variant={isSSHMode && currentSSH?.id === conn.id ? 'secondary' : 'ghost'}
className="flex-1 justify-start text-left px-2 py-1.5 rounded"
onClick={() => onSwitchToSSH(conn)}
>
<Link2 className="w-4 h-4 mr-2"/>
{conn.name || conn.ip}
{conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400"/>}
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}>
<Pin
className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}>
<Edit className="w-4 h-4"/>
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteSSH(conn)}>
<Trash2 className="w-4 h-4 text-red-500"/>
</Button>
</div>
))}
</div>
</div>
{/* File/Folder Viewer */}
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto"> <div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span <span

View File

@@ -187,7 +187,6 @@ export function FileManagerOperations({
return ( return (
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
{/* Operation Buttons */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Button <Button
variant="outline" variant="outline"
@@ -236,7 +235,6 @@ export function FileManagerOperations({
</Button> </Button>
</div> </div>
{/* Current Path Display */}
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-3"> <div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-3">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Folder className="w-4 h-4 text-blue-400"/> <Folder className="w-4 h-4 text-blue-400"/>
@@ -247,7 +245,6 @@ export function FileManagerOperations({
<Separator className="p-0.25 bg-[#303032]"/> <Separator className="p-0.25 bg-[#303032]"/>
{/* Upload File Modal */}
{showUpload && ( {showUpload && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4"> <Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@@ -331,7 +328,6 @@ export function FileManagerOperations({
</Card> </Card>
)} )}
{/* Create File Modal */}
{showCreateFile && ( {showCreateFile && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4"> <Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -383,7 +379,6 @@ export function FileManagerOperations({
</Card> </Card>
)} )}
{/* Create Folder Modal */}
{showCreateFolder && ( {showCreateFolder && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4"> <Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -435,7 +430,6 @@ export function FileManagerOperations({
</Card> </Card>
)} )}
{/* Delete Modal */}
{showDelete && ( {showDelete && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4"> <Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -507,7 +501,6 @@ export function FileManagerOperations({
</Card> </Card>
)} )}
{/* Rename Modal */}
{showRename && ( {showRename && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-4"> <Card className="bg-[#18181b] border-2 border-[#303032] p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">

View File

@@ -55,7 +55,6 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
} }
}; };
// Dynamic margins similar to TerminalView but with 16px gaps when retracted
const topMarginPx = isTopbarOpen ? 74 : 26; const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8; const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
const bottomMarginPx = 8; const bottomMarginPx = 8;

View File

@@ -811,7 +811,9 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
render={({field: sourcePortField}) => ( render={({field: sourcePortField}) => (
<FormItem className="col-span-4"> <FormItem className="col-span-4">
<FormLabel>Source Port <FormLabel>Source Port
(Source refers to the Current Connection Details in the General tab)</FormLabel> (Source refers to the Current
Connection Details in the
General tab)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="22" {...sourcePortField} /> placeholder="22" {...sourcePortField} />

View File

@@ -14,27 +14,30 @@ interface ServerProps {
title?: string; title?: string;
isVisible?: boolean; isVisible?: boolean;
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
embedded?: boolean; // when rendered inside a pane in TerminalView embedded?: boolean;
} }
export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = true, embedded = false }: ServerProps): React.ReactElement { export function Server({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false
}: ServerProps): React.ReactElement {
const {state: sidebarState} = useSidebar(); const {state: sidebarState} = useSidebar();
const {addTab} = useTabs() as any; const {addTab} = useTabs() as any;
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline'); const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null); const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig); const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
// Listen for host configuration changes
React.useEffect(() => { React.useEffect(() => {
setCurrentHostConfig(hostConfig); setCurrentHostConfig(hostConfig);
}, [hostConfig]); }, [hostConfig]);
// Always fetch latest host config when component mounts or hostConfig changes
React.useEffect(() => { React.useEffect(() => {
const fetchLatestHostConfig = async () => { const fetchLatestHostConfig = async () => {
if (hostConfig?.id) { if (hostConfig?.id) {
try { try {
// Import the getSSHHosts function to fetch updated host data
const {getSSHHosts} = await import('@/ui/main-axios.ts'); const {getSSHHosts} = await import('@/ui/main-axios.ts');
const hosts = await getSSHHosts(); const hosts = await getSSHHosts();
const updatedHost = hosts.find(h => h.id === hostConfig.id); const updatedHost = hosts.find(h => h.id === hostConfig.id);
@@ -42,19 +45,15 @@ export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = tru
setCurrentHostConfig(updatedHost); setCurrentHostConfig(updatedHost);
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch latest host config:', error);
} }
} }
}; };
// Fetch immediately when component mounts or hostConfig changes
fetchLatestHostConfig(); fetchLatestHostConfig();
// Also listen for SSH hosts changed event to refresh host config
const handleHostsChanged = async () => { const handleHostsChanged = async () => {
if (hostConfig?.id) { if (hostConfig?.id) {
try { try {
// Import the getSSHHosts function to fetch updated host data
const {getSSHHosts} = await import('@/ui/main-axios.ts'); const {getSSHHosts} = await import('@/ui/main-axios.ts');
const hosts = await getSSHHosts(); const hosts = await getSSHHosts();
const updatedHost = hosts.find(h => h.id === hostConfig.id); const updatedHost = hosts.find(h => h.id === hostConfig.id);
@@ -62,7 +61,6 @@ export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = tru
setCurrentHostConfig(updatedHost); setCurrentHostConfig(updatedHost);
} }
} catch (error) { } catch (error) {
console.error('Failed to refresh host config:', error);
} }
} }
}; };
@@ -233,8 +231,10 @@ export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = tru
{/* SSH Tunnels */} {/* SSH Tunnels */}
{(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && ( {(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && (
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] h-[360px] overflow-hidden flex flex-col min-h-0"> <div
<Tunnel filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/> className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] h-[360px] overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/>
</div> </div>
)} )}

View File

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

View File

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

View File

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

View File

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