Format code
This commit is contained in:
30
src/App.tsx
30
src/App.tsx
@@ -49,7 +49,6 @@ function AppContent() {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
// Clear invalid JWT
|
||||
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
})
|
||||
.finally(() => setAuthLoading(false));
|
||||
@@ -85,27 +84,19 @@ function AppContent() {
|
||||
setUsername(authData.username)
|
||||
}
|
||||
|
||||
// Determine what to show based on current tab
|
||||
const currentTabData = tabs.find(tab => tab.id === currentTab);
|
||||
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager';
|
||||
const showHome = currentTabData?.type === 'home';
|
||||
const showSshManager = currentTabData?.type === 'ssh_manager';
|
||||
const showAdmin = currentTabData?.type === 'admin';
|
||||
|
||||
console.log('Current tab:', currentTab);
|
||||
console.log('Current tab data:', currentTabData);
|
||||
console.log('Show terminal view:', showTerminalView);
|
||||
console.log('All tabs:', tabs);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Enhanced background overlay - detailed pattern when not authenticated */}
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div
|
||||
className="fixed inset-0 bg-gradient-to-br from-background via-muted/20 to-background z-[9999]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Diagonal stripes pattern */}
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
@@ -117,8 +108,7 @@ function AppContent() {
|
||||
)`
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Subtle grid pattern */}
|
||||
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `linear-gradient(hsl(var(--border) / 0.3) 1px, transparent 1px),
|
||||
@@ -126,13 +116,11 @@ function AppContent() {
|
||||
backgroundSize: '40px 40px'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Radial gradient overlay */}
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-background/60" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show login form directly when not authenticated */}
|
||||
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
|
||||
<Homepage
|
||||
@@ -144,8 +132,7 @@ function AppContent() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show sidebar layout only when authenticated */}
|
||||
|
||||
{isAuthenticated && (
|
||||
<LeftSidebar
|
||||
onSelectView={handleSelectView}
|
||||
@@ -153,7 +140,6 @@ function AppContent() {
|
||||
isAdmin={isAdmin}
|
||||
username={username}
|
||||
>
|
||||
{/* Always render TerminalView to maintain terminal persistence */}
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
style={{
|
||||
@@ -167,8 +153,7 @@ function AppContent() {
|
||||
>
|
||||
<AppView isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
|
||||
{/* Always render Homepage to keep it mounted */}
|
||||
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
style={{
|
||||
@@ -189,7 +174,6 @@ function AppContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Always render SSH Manager but toggle visibility for persistence */}
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
style={{
|
||||
@@ -204,7 +188,6 @@ function AppContent() {
|
||||
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
|
||||
{/* Admin Settings tab */}
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
style={{
|
||||
@@ -218,8 +201,7 @@ function AppContent() {
|
||||
>
|
||||
<AdminSettings isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
|
||||
{/* Legacy views removed; tab system controls main content */}
|
||||
|
||||
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
|
||||
</LeftSidebar>
|
||||
)}
|
||||
|
||||
@@ -41,94 +41,340 @@ const dbPath = path.join(dataDir, 'db.sqlite');
|
||||
const sqlite = new Database(dbPath);
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0,
|
||||
client_id TEXT NOT NULL,
|
||||
client_secret TEXT NOT NULL,
|
||||
issuer_url TEXT NOT NULL,
|
||||
authorization_url TEXT NOT NULL,
|
||||
token_url TEXT NOT NULL,
|
||||
redirect_uri TEXT,
|
||||
identifier_path TEXT NOT NULL,
|
||||
name_path TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
id
|
||||
TEXT
|
||||
PRIMARY
|
||||
KEY,
|
||||
username
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
password_hash
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
is_admin
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
0,
|
||||
|
||||
is_oidc
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
0,
|
||||
client_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
client_secret
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
issuer_url
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
authorization_url
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
token_url
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
redirect_uri
|
||||
TEXT,
|
||||
identifier_path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
name_path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
scopes
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
CREATE TABLE IF NOT EXISTS settings
|
||||
(
|
||||
key
|
||||
TEXT
|
||||
PRIMARY
|
||||
KEY,
|
||||
value
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
pin INTEGER NOT NULL DEFAULT 0,
|
||||
auth_type TEXT NOT NULL,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
default_path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ssh_data
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT,
|
||||
ip
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
port
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
username
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
folder
|
||||
TEXT,
|
||||
tags
|
||||
TEXT,
|
||||
pin
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
0,
|
||||
auth_type
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
password
|
||||
TEXT,
|
||||
key
|
||||
TEXT,
|
||||
key_password
|
||||
TEXT,
|
||||
key_type
|
||||
TEXT,
|
||||
enable_terminal
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
1,
|
||||
enable_tunnel
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
1,
|
||||
tunnel_connections
|
||||
TEXT,
|
||||
enable_file_manager
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
1,
|
||||
default_path
|
||||
TEXT,
|
||||
created_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
updated_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS file_manager_recent
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
host_id
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
last_opened
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
),
|
||||
FOREIGN KEY
|
||||
(
|
||||
host_id
|
||||
) REFERENCES ssh_data
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_pinned (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS file_manager_pinned
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
host_id
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
pinned_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
),
|
||||
FOREIGN KEY
|
||||
(
|
||||
host_id
|
||||
) REFERENCES ssh_data
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS file_manager_shortcuts
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
host_id
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
created_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
),
|
||||
FOREIGN KEY
|
||||
(
|
||||
host_id
|
||||
) REFERENCES ssh_data
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
alert_id TEXT NOT NULL,
|
||||
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS dismissed_alerts
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
alert_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
dismissed_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
`);
|
||||
|
||||
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
|
||||
@@ -162,7 +408,7 @@ const migrateSchema = () => {
|
||||
logger.info('Removed redirect_uri column from users table');
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
|
||||
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
|
||||
addColumnIfNotExists('users', 'name_path', 'TEXT');
|
||||
addColumnIfNotExists('users', 'scopes', 'TEXT');
|
||||
|
||||
@@ -90,10 +90,10 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
||||
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
@@ -108,13 +108,13 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
const alerts: TermixAlert[] = await response.json() as TermixAlert[];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
|
||||
const validAlerts = alerts.filter(alert => {
|
||||
const expiryDate = new Date(alert.expiresAt);
|
||||
const isValid = expiryDate > now;
|
||||
return isValid;
|
||||
});
|
||||
|
||||
|
||||
alertCache.set(cacheKey, validAlerts);
|
||||
return validAlerts;
|
||||
} catch (error) {
|
||||
@@ -146,11 +146,11 @@ router.get('/', async (req, res) => {
|
||||
router.get('/user/:userId', async (req, res) => {
|
||||
try {
|
||||
const {userId} = req.params;
|
||||
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({error: 'User ID is required'});
|
||||
}
|
||||
|
||||
|
||||
const allAlerts = await fetchAlertsFromGitHub();
|
||||
|
||||
const dismissedAlertRecords = await db
|
||||
@@ -215,7 +215,7 @@ router.post('/dismiss', async (req, res) => {
|
||||
router.get('/dismissed/:userId', async (req, res) => {
|
||||
try {
|
||||
const {userId} = req.params;
|
||||
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({error: 'User ID is required'});
|
||||
}
|
||||
|
||||
@@ -695,7 +695,7 @@ router.delete('/file_manager/shortcuts', authenticateJWT, async (req: Request, r
|
||||
// POST /ssh/bulk-import
|
||||
router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { hosts } = req.body;
|
||||
const {hosts} = req.body;
|
||||
|
||||
if (!Array.isArray(hosts) || hosts.length === 0) {
|
||||
logger.warn('Invalid bulk import data - hosts array is required and must not be empty');
|
||||
@@ -715,7 +715,7 @@ router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response)
|
||||
|
||||
for (let i = 0; i < hosts.length; i++) {
|
||||
const hostData = hosts[i];
|
||||
|
||||
|
||||
try {
|
||||
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) {
|
||||
results.failed++;
|
||||
|
||||
@@ -10,13 +10,9 @@ app.use(cors({
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
|
||||
// Increase JSON body parser limit for larger file uploads
|
||||
app.use(express.json({ limit: '100mb' }));
|
||||
app.use(express.urlencoded({ limit: '100mb', extended: true }));
|
||||
|
||||
// Add raw body parser for very large files
|
||||
app.use(express.raw({ limit: '200mb', type: 'application/octet-stream' }));
|
||||
app.use(express.json({limit: '100mb'}));
|
||||
app.use(express.urlencoded({limit: '100mb', extended: true}));
|
||||
app.use(express.raw({limit: '200mb', type: 'application/octet-stream'}));
|
||||
|
||||
const sshIconSymbol = '📁';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
@@ -314,9 +310,8 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: 'SSH command timed out'});
|
||||
}
|
||||
}, 60000); // Increased timeout to 60 seconds
|
||||
}, 60000);
|
||||
|
||||
// Try SFTP first, fallback to command line if it fails
|
||||
const trySFTP = () => {
|
||||
try {
|
||||
sshConn.client.sftp((err, sftp) => {
|
||||
@@ -326,7 +321,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert content to buffer
|
||||
let fileBuffer;
|
||||
try {
|
||||
if (typeof content === 'string') {
|
||||
@@ -345,9 +339,8 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create write stream with error handling
|
||||
const writeStream = sftp.createWriteStream(filePath);
|
||||
|
||||
|
||||
let hasError = false;
|
||||
let hasFinished = false;
|
||||
|
||||
@@ -378,7 +371,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Write the buffer to the stream
|
||||
try {
|
||||
writeStream.write(fileBuffer);
|
||||
writeStream.end();
|
||||
@@ -395,14 +387,13 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Fallback method using command line
|
||||
const tryFallbackMethod = () => {
|
||||
try {
|
||||
const base64Content = Buffer.from(content, 'utf8').toString('base64');
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
|
||||
|
||||
|
||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
@@ -426,7 +417,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
|
||||
stream.on('close', (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
logger.success(`File written successfully via fallback: ${filePath}`);
|
||||
if (!res.headersSent) {
|
||||
@@ -457,11 +448,9 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Start with SFTP
|
||||
trySFTP();
|
||||
});
|
||||
|
||||
// Upload file route
|
||||
app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
const {sessionId, path: filePath, content, fileName} = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
@@ -488,9 +477,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: 'SSH command timed out'});
|
||||
}
|
||||
}, 60000); // Increased timeout to 60 seconds
|
||||
}, 60000);
|
||||
|
||||
// Try SFTP first, fallback to command line if it fails
|
||||
const trySFTP = () => {
|
||||
try {
|
||||
sshConn.client.sftp((err, sftp) => {
|
||||
@@ -500,7 +488,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert content to buffer
|
||||
let fileBuffer;
|
||||
try {
|
||||
if (typeof content === 'string') {
|
||||
@@ -519,9 +506,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create write stream with error handling
|
||||
const writeStream = sftp.createWriteStream(fullPath);
|
||||
|
||||
|
||||
let hasError = false;
|
||||
let hasFinished = false;
|
||||
|
||||
@@ -552,7 +538,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Write the buffer to the stream
|
||||
try {
|
||||
writeStream.write(fileBuffer);
|
||||
writeStream.end();
|
||||
@@ -569,26 +554,23 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Fallback method using command line with chunked approach
|
||||
const tryFallbackMethod = () => {
|
||||
try {
|
||||
// Convert content to base64 and split into smaller chunks if needed
|
||||
const base64Content = Buffer.from(content, 'utf8').toString('base64');
|
||||
const chunkSize = 1000000; // 1MB chunks
|
||||
const chunkSize = 1000000;
|
||||
const chunks = [];
|
||||
|
||||
|
||||
for (let i = 0; i < base64Content.length; i += chunkSize) {
|
||||
chunks.push(base64Content.slice(i, i + chunkSize));
|
||||
}
|
||||
|
||||
if (chunks.length === 1) {
|
||||
// Single chunk - use simple approach
|
||||
const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
|
||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
|
||||
|
||||
|
||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
@@ -612,7 +594,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
|
||||
stream.on('close', (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
logger.success(`File uploaded successfully via fallback: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
@@ -635,17 +617,16 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Multiple chunks - use chunked approach
|
||||
const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
|
||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
let writeCommand = `> '${escapedPath}'`; // Start with empty file
|
||||
|
||||
let writeCommand = `> '${escapedPath}'`;
|
||||
|
||||
chunks.forEach((chunk, index) => {
|
||||
writeCommand += ` && echo '${chunk}' | base64 -d >> '${escapedPath}'`;
|
||||
});
|
||||
|
||||
|
||||
writeCommand += ` && echo "SUCCESS"`;
|
||||
|
||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||
@@ -671,7 +652,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
|
||||
stream.on('close', (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
logger.success(`File uploaded successfully via chunked fallback: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
@@ -703,11 +684,9 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Start with SFTP
|
||||
trySFTP();
|
||||
});
|
||||
|
||||
// Create new file route
|
||||
app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
||||
const {sessionId, path: filePath, fileName, content = ''} = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
@@ -804,7 +783,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Create folder route
|
||||
app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
||||
const {sessionId, path: folderPath, folderName} = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
@@ -901,7 +879,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Delete file/folder route
|
||||
app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
||||
const {sessionId, path: itemPath, isDirectory} = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
@@ -930,7 +907,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
const deleteCommand = isDirectory
|
||||
const deleteCommand = isDirectory
|
||||
? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0`
|
||||
: `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
@@ -999,7 +976,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Rename file/folder route
|
||||
app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
||||
const {sessionId, oldPath, newName} = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
@@ -3,7 +3,7 @@ import chalk from 'chalk';
|
||||
import fetch from 'node-fetch';
|
||||
import net from 'net';
|
||||
import cors from 'cors';
|
||||
import { Client, type ConnectConfig } from 'ssh2';
|
||||
import {Client, type ConnectConfig} from 'ssh2';
|
||||
|
||||
type HostRecord = {
|
||||
id: number;
|
||||
@@ -21,7 +21,7 @@ type HostStatus = 'online' | 'offline';
|
||||
|
||||
type StatusEntry = {
|
||||
status: HostStatus;
|
||||
lastChecked: string; // ISO string
|
||||
lastChecked: string;
|
||||
};
|
||||
|
||||
const app = express();
|
||||
@@ -30,7 +30,6 @@ app.use(cors({
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
// Fallback explicit CORS headers to cover any edge cases
|
||||
app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
@@ -42,7 +41,6 @@ app.use((req, res, next) => {
|
||||
});
|
||||
app.use(express.json());
|
||||
|
||||
// Logger (customized for Server Stats)
|
||||
const statsIconSymbol = '📡';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
@@ -69,15 +67,13 @@ const logger = {
|
||||
}
|
||||
};
|
||||
|
||||
// In-memory state of last known statuses
|
||||
const hostStatuses: Map<number, StatusEntry> = new Map();
|
||||
|
||||
// Fetch all hosts from the database service (internal endpoint, no JWT)
|
||||
async function fetchAllHosts(): Promise<HostRecord[]> {
|
||||
const url = 'http://localhost:8081/ssh/db/host/internal';
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
headers: { 'x-internal-request': '1' }
|
||||
headers: {'x-internal-request': '1'}
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
|
||||
@@ -112,9 +108,7 @@ function buildSshConfig(host: HostRecord): ConnectConfig {
|
||||
port: host.port || 22,
|
||||
username: host.username || 'root',
|
||||
readyTimeout: 10_000,
|
||||
algorithms: {
|
||||
// keep defaults minimal to avoid negotiation issues
|
||||
}
|
||||
algorithms: {}
|
||||
} as ConnectConfig;
|
||||
|
||||
if (host.authType === 'password') {
|
||||
@@ -138,7 +132,10 @@ async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Pr
|
||||
const onError = (err: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
try { client.end(); } catch {}
|
||||
try {
|
||||
client.end();
|
||||
} catch {
|
||||
}
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
@@ -148,7 +145,10 @@ async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Pr
|
||||
const result = await fn(client);
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
try { client.end(); } catch {}
|
||||
try {
|
||||
client.end();
|
||||
} catch {
|
||||
}
|
||||
resolve(result);
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -166,16 +166,20 @@ async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Pr
|
||||
});
|
||||
}
|
||||
|
||||
function execCommand(client: Client, command: string): Promise<{ stdout: string; stderr: string; code: number | null; }> {
|
||||
function execCommand(client: Client, command: string): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.exec(command, { pty: false }, (err, stream) => {
|
||||
client.exec(command, {pty: false}, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let exitCode: number | null = null;
|
||||
stream.on('close', (code: number | undefined) => {
|
||||
exitCode = typeof code === 'number' ? code : null;
|
||||
resolve({ stdout, stderr, code: exitCode });
|
||||
resolve({stdout, stderr, code: exitCode});
|
||||
}).on('data', (data: Buffer) => {
|
||||
stdout += data.toString('utf8');
|
||||
}).stderr.on('data', (data: Buffer) => {
|
||||
@@ -190,9 +194,9 @@ function parseCpuLine(cpuLine: string): { total: number; idle: number } | undefi
|
||||
if (parts[0] !== 'cpu') return undefined;
|
||||
const nums = parts.slice(1).map(n => Number(n)).filter(n => Number.isFinite(n));
|
||||
if (nums.length < 4) return undefined;
|
||||
const idle = (nums[3] ?? 0) + (nums[4] ?? 0); // idle + iowait
|
||||
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
|
||||
const total = nums.reduce((a, b) => a + b, 0);
|
||||
return { total, idle };
|
||||
return {total, idle};
|
||||
}
|
||||
|
||||
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
|
||||
@@ -210,7 +214,6 @@ async function collectMetrics(host: HostRecord): Promise<{
|
||||
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
|
||||
}> {
|
||||
return withSshConnection(host, async (client) => {
|
||||
// CPU
|
||||
let cpuPercent: number | null = null;
|
||||
let cores: number | null = null;
|
||||
let loadTriplet: [number, number, number] | null = null;
|
||||
@@ -245,7 +248,6 @@ async function collectMetrics(host: HostRecord): Promise<{
|
||||
loadTriplet = null;
|
||||
}
|
||||
|
||||
// Memory
|
||||
let memPercent: number | null = null;
|
||||
let usedGiB: number | null = null;
|
||||
let totalGiB: number | null = null;
|
||||
@@ -256,7 +258,7 @@ async function collectMetrics(host: HostRecord): Promise<{
|
||||
const line = lines.find(l => l.startsWith(key));
|
||||
if (!line) return null;
|
||||
const m = line.match(/\d+/);
|
||||
return m ? Number(m[0]) : null; // in kB
|
||||
return m ? Number(m[0]) : null;
|
||||
};
|
||||
const totalKb = getVal('MemTotal:');
|
||||
const availKb = getVal('MemAvailable:');
|
||||
@@ -272,14 +274,12 @@ async function collectMetrics(host: HostRecord): Promise<{
|
||||
totalGiB = null;
|
||||
}
|
||||
|
||||
// Disk
|
||||
let diskPercent: number | null = null;
|
||||
let usedHuman: string | null = null;
|
||||
let totalHuman: string | null = null;
|
||||
try {
|
||||
const diskOut = await execCommand(client, 'df -h -P / | tail -n +2');
|
||||
const line = diskOut.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
||||
// Expected columns: Filesystem Size Used Avail Use% Mounted
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 6) {
|
||||
totalHuman = parts[1] || null;
|
||||
@@ -295,9 +295,13 @@ async function collectMetrics(host: HostRecord): Promise<{
|
||||
}
|
||||
|
||||
return {
|
||||
cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet },
|
||||
memory: { percent: toFixedNum(memPercent, 0), usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null },
|
||||
disk: { percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman },
|
||||
cpu: {percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet},
|
||||
memory: {
|
||||
percent: toFixedNum(memPercent, 0),
|
||||
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
||||
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null
|
||||
},
|
||||
disk: {percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -310,7 +314,10 @@ function tcpPing(host: string, port: number, timeoutMs = 5000): Promise<boolean>
|
||||
const onDone = (result: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { socket.destroy(); } catch {}
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
@@ -334,7 +341,7 @@ async function pollStatusesOnce(): Promise<void> {
|
||||
|
||||
const checks = hosts.map(async (h) => {
|
||||
const isOnline = await tcpPing(h.ip, h.port, 5000);
|
||||
hostStatuses.set(h.id, { status: isOnline ? 'online' : 'offline', lastChecked: now });
|
||||
hostStatuses.set(h.id, {status: isOnline ? 'online' : 'offline', lastChecked: now});
|
||||
return isOnline;
|
||||
});
|
||||
|
||||
@@ -344,7 +351,6 @@ async function pollStatusesOnce(): Promise<void> {
|
||||
}
|
||||
|
||||
app.get('/status', async (req, res) => {
|
||||
// Return current cached statuses; if empty, trigger a poll
|
||||
if (hostStatuses.size === 0) {
|
||||
await pollStatusesOnce();
|
||||
}
|
||||
@@ -358,7 +364,7 @@ app.get('/status', async (req, res) => {
|
||||
app.get('/status/:id', async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
return res.status(400).json({error: 'Invalid id'});
|
||||
}
|
||||
|
||||
if (!hostStatuses.has(id)) {
|
||||
@@ -367,34 +373,34 @@ app.get('/status/:id', async (req, res) => {
|
||||
|
||||
const entry = hostStatuses.get(id);
|
||||
if (!entry) {
|
||||
return res.status(404).json({ error: 'Host not found' });
|
||||
return res.status(404).json({error: 'Host not found'});
|
||||
}
|
||||
res.json(entry);
|
||||
});
|
||||
|
||||
app.post('/refresh', async (req, res) => {
|
||||
await pollStatusesOnce();
|
||||
res.json({ message: 'Refreshed' });
|
||||
res.json({message: 'Refreshed'});
|
||||
});
|
||||
|
||||
app.get('/metrics/:id', async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
return res.status(400).json({error: 'Invalid id'});
|
||||
}
|
||||
try {
|
||||
const host = await fetchHostById(id);
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: 'Host not found' });
|
||||
return res.status(404).json({error: 'Host not found'});
|
||||
}
|
||||
const metrics = await collectMetrics(host);
|
||||
res.json({ ...metrics, lastChecked: new Date().toISOString() });
|
||||
res.json({...metrics, lastChecked: new Date().toISOString()});
|
||||
} catch (err) {
|
||||
logger.error('Failed to collect metrics', err);
|
||||
return res.json({
|
||||
cpu: { percent: null, cores: null, load: null },
|
||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
||||
disk: { percent: null, usedHuman: null, totalHuman: null },
|
||||
cpu: {percent: null, cores: null, load: null},
|
||||
memory: {percent: null, usedGiB: null, totalGiB: null},
|
||||
disk: {percent: null, usedHuman: null, totalHuman: null},
|
||||
lastChecked: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
@@ -409,7 +415,6 @@ app.listen(PORT, async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Background polling every minute
|
||||
setInterval(() => {
|
||||
pollStatusesOnce().catch(err => logger.error('Background poll failed', err));
|
||||
}, 60_000);
|
||||
|
||||
@@ -132,15 +132,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Thin scrollbar utility for scrollable containers */
|
||||
.thin-scrollbar {
|
||||
scrollbar-width: thin; /* Firefox */
|
||||
scrollbar-color: #303032 transparent; /* Firefox */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #303032 transparent;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar {
|
||||
height: 6px; /* horizontal */
|
||||
width: 6px; /* vertical, if any */
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-track {
|
||||
@@ -154,7 +153,6 @@
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
/* Thin scrollbar styles */
|
||||
.thin-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -173,7 +171,6 @@
|
||||
background: #5a5a5d;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for Firefox */
|
||||
.thin-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #434345 #18181b;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {Shield, Trash2, Users} from "lucide-react";
|
||||
import axios from "axios";
|
||||
|
||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
||||
const API = axios.create({ baseURL: apiBase });
|
||||
const API = axios.create({baseURL: apiBase});
|
||||
|
||||
function getCookie(name: string) {
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
@@ -32,14 +32,12 @@ interface AdminSettingsProps {
|
||||
isTopbarOpen?: boolean;
|
||||
}
|
||||
|
||||
export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): React.ReactElement {
|
||||
const { state: sidebarState } = useSidebar();
|
||||
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
|
||||
const {state: sidebarState} = useSidebar();
|
||||
|
||||
// Registration toggle
|
||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||
const [regLoading, setRegLoading] = React.useState(false);
|
||||
|
||||
// OIDC config
|
||||
const [oidcConfig, setOidcConfig] = React.useState({
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
@@ -54,8 +52,12 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
||||
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
|
||||
|
||||
// Users/admins
|
||||
const [users, setUsers] = React.useState<Array<{ id: string; username: string; is_admin: boolean; is_oidc: boolean }>>([]);
|
||||
const [users, setUsers] = React.useState<Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean
|
||||
}>>([]);
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
const [newAdminUsername, setNewAdminUsername] = React.useState("");
|
||||
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
|
||||
@@ -65,15 +67,15 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
React.useEffect(() => {
|
||||
const jwt = getCookie("jwt");
|
||||
if (!jwt) return;
|
||||
// Preload OIDC config and users
|
||||
API.get("/oidc-config", { headers: { Authorization: `Bearer ${jwt}` } })
|
||||
.then(res => { if (res.data) setOidcConfig(res.data); })
|
||||
.catch(() => {});
|
||||
API.get("/oidc-config", {headers: {Authorization: `Bearer ${jwt}`}})
|
||||
.then(res => {
|
||||
if (res.data) setOidcConfig(res.data);
|
||||
})
|
||||
.catch(() => {
|
||||
});
|
||||
fetchUsers();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Load initial registration toggle status
|
||||
React.useEffect(() => {
|
||||
API.get("/registration-allowed")
|
||||
.then(res => {
|
||||
@@ -81,7 +83,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
setAllowRegistration(res.data.allowed);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
@@ -89,7 +92,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
if (!jwt) return;
|
||||
setUsersLoading(true);
|
||||
try {
|
||||
const response = await API.get("/list", { headers: { Authorization: `Bearer ${jwt}` } });
|
||||
const response = await API.get("/list", {headers: {Authorization: `Bearer ${jwt}`}});
|
||||
setUsers(response.data.users);
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
@@ -100,7 +103,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
setRegLoading(true);
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.patch("/registration-allowed", { allowed: checked }, { headers: { Authorization: `Bearer ${jwt}` } });
|
||||
await API.patch("/registration-allowed", {allowed: checked}, {headers: {Authorization: `Bearer ${jwt}`}});
|
||||
setAllowRegistration(checked);
|
||||
} finally {
|
||||
setRegLoading(false);
|
||||
@@ -113,7 +116,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
setOidcError(null);
|
||||
setOidcSuccess(null);
|
||||
|
||||
const required = ['client_id','client_secret','issuer_url','authorization_url','token_url'];
|
||||
const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
|
||||
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
|
||||
if (missing.length > 0) {
|
||||
setOidcError(`Missing required fields: ${missing.join(', ')}`);
|
||||
@@ -123,7 +126,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.post("/oidc-config", oidcConfig, { headers: { Authorization: `Bearer ${jwt}` } });
|
||||
await API.post("/oidc-config", oidcConfig, {headers: {Authorization: `Bearer ${jwt}`}});
|
||||
setOidcSuccess("OIDC configuration updated successfully!");
|
||||
} catch (err: any) {
|
||||
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
|
||||
@@ -133,7 +136,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
};
|
||||
|
||||
const handleOIDCConfigChange = (field: string, value: string) => {
|
||||
setOidcConfig(prev => ({ ...prev, [field]: value }));
|
||||
setOidcConfig(prev => ({...prev, [field]: value}));
|
||||
};
|
||||
|
||||
const makeUserAdmin = async (e: React.FormEvent) => {
|
||||
@@ -144,7 +147,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
setMakeAdminSuccess(null);
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.post("/make-admin", { username: newAdminUsername.trim() }, { headers: { Authorization: `Bearer ${jwt}` } });
|
||||
await API.post("/make-admin", {username: newAdminUsername.trim()}, {headers: {Authorization: `Bearer ${jwt}`}});
|
||||
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
||||
setNewAdminUsername("");
|
||||
fetchUsers();
|
||||
@@ -159,18 +162,20 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
if (!confirm(`Remove admin status from ${username}?`)) return;
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.post("/remove-admin", { username }, { headers: { Authorization: `Bearer ${jwt}` } });
|
||||
await API.post("/remove-admin", {username}, {headers: {Authorization: `Bearer ${jwt}`}});
|
||||
fetchUsers();
|
||||
} catch {}
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (username: string) => {
|
||||
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.delete("/delete-user", { headers: { Authorization: `Bearer ${jwt}` }, data: { username } });
|
||||
await API.delete("/delete-user", {headers: {Authorization: `Bearer ${jwt}`}, data: {username}});
|
||||
fetchUsers();
|
||||
} catch {}
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
@@ -185,7 +190,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
|
||||
<div style={wrapperStyle}
|
||||
className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">Admin Settings</h1>
|
||||
@@ -217,7 +223,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">User Registration</h3>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration} disabled={regLoading}/>
|
||||
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration}
|
||||
disabled={regLoading}/>
|
||||
Allow new account registration
|
||||
</label>
|
||||
</div>
|
||||
@@ -226,7 +233,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
<TabsContent value="oidc" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
|
||||
<p className="text-sm text-muted-foreground">Configure external identity provider for OIDC/OAuth2 authentication.</p>
|
||||
<p className="text-sm text-muted-foreground">Configure external identity provider for
|
||||
OIDC/OAuth2 authentication.</p>
|
||||
|
||||
{oidcError && (
|
||||
<Alert variant="destructive">
|
||||
@@ -238,39 +246,66 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_id">Client ID</Label>
|
||||
<Input id="client_id" value={oidcConfig.client_id} onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)} placeholder="your-client-id" required />
|
||||
<Input id="client_id" value={oidcConfig.client_id}
|
||||
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
|
||||
placeholder="your-client-id" required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_secret">Client Secret</Label>
|
||||
<Input id="client_secret" type="password" value={oidcConfig.client_secret} onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)} placeholder="your-client-secret" required />
|
||||
<Input id="client_secret" type="password" value={oidcConfig.client_secret}
|
||||
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
||||
placeholder="your-client-secret" required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authorization_url">Authorization URL</Label>
|
||||
<Input id="authorization_url" value={oidcConfig.authorization_url} onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)} placeholder="https://your-provider.com/application/o/authorize/" required />
|
||||
<Input id="authorization_url" value={oidcConfig.authorization_url}
|
||||
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/authorize/"
|
||||
required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer_url">Issuer URL</Label>
|
||||
<Input id="issuer_url" value={oidcConfig.issuer_url} onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)} placeholder="https://your-provider.com/application/o/termix/" required />
|
||||
<Input id="issuer_url" value={oidcConfig.issuer_url}
|
||||
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/termix/" required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token_url">Token URL</Label>
|
||||
<Input id="token_url" value={oidcConfig.token_url} onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)} placeholder="https://your-provider.com/application/o/token/" required />
|
||||
<Input id="token_url" value={oidcConfig.token_url}
|
||||
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/token/" required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier_path">User Identifier Path</Label>
|
||||
<Input id="identifier_path" value={oidcConfig.identifier_path} onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)} placeholder="sub" required />
|
||||
<Input id="identifier_path" value={oidcConfig.identifier_path}
|
||||
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
|
||||
placeholder="sub" required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name_path">Display Name Path</Label>
|
||||
<Input id="name_path" value={oidcConfig.name_path} onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)} placeholder="name" required />
|
||||
<Input id="name_path" value={oidcConfig.name_path}
|
||||
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
|
||||
placeholder="name" required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scopes">Scopes</Label>
|
||||
<Input id="scopes" value={oidcConfig.scopes} onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)} placeholder="openid email profile" required />
|
||||
<Input id="scopes" value={oidcConfig.scopes}
|
||||
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
|
||||
placeholder="openid email profile" required/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" className="flex-1" disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setOidcConfig({ client_id: '', client_secret: '', issuer_url: '', authorization_url: '', token_url: '', identifier_path: 'sub', name_path: 'name', scopes: 'openid email profile' })}>Reset</Button>
|
||||
<Button type="submit" className="flex-1"
|
||||
disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setOidcConfig({
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
issuer_url: '',
|
||||
authorization_url: '',
|
||||
token_url: '',
|
||||
identifier_path: 'sub',
|
||||
name_path: 'name',
|
||||
scopes: 'openid email profile'
|
||||
})}>Reset</Button>
|
||||
</div>
|
||||
|
||||
{oidcSuccess && (
|
||||
@@ -287,7 +322,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">User Management</h3>
|
||||
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline" size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
|
||||
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
|
||||
size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
|
||||
</div>
|
||||
{usersLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Loading users...</div>
|
||||
@@ -307,12 +343,17 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
<TableCell className="px-4 font-medium">
|
||||
{user.username}
|
||||
{user.is_admin && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
|
||||
<span
|
||||
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
|
||||
<TableCell
|
||||
className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => deleteUser(user.username)} className="text-red-600 hover:text-red-700 hover:bg-red-50" disabled={user.is_admin}>
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => deleteUser(user.username)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={user.is_admin}>
|
||||
<Trash2 className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TableCell>
|
||||
@@ -334,8 +375,11 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-admin-username">Username</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="new-admin-username" value={newAdminUsername} onChange={(e) => setNewAdminUsername(e.target.value)} placeholder="Enter username to make admin" required />
|
||||
<Button type="submit" disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
|
||||
<Input id="new-admin-username" value={newAdminUsername}
|
||||
onChange={(e) => setNewAdminUsername(e.target.value)}
|
||||
placeholder="Enter username to make admin" required/>
|
||||
<Button type="submit"
|
||||
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{makeAdminError && (
|
||||
@@ -369,11 +413,15 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
||||
<TableRow key={admin.id}>
|
||||
<TableCell className="px-4 font-medium">
|
||||
{admin.username}
|
||||
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
|
||||
<span
|
||||
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
|
||||
<TableCell
|
||||
className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => removeAdminStatus(admin.username)} className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => removeAdminStatus(admin.username)}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
|
||||
<Shield className="h-4 w-4"/>
|
||||
Remove Admin
|
||||
</Button>
|
||||
|
||||
@@ -44,7 +44,6 @@ export function Homepage({
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [dbError, setDbError] = useState<string | null>(null);
|
||||
|
||||
// Update local state when props change
|
||||
useEffect(() => {
|
||||
setLoggedIn(isAuthenticated);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
@@ -42,17 +42,17 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
const response = await API.get(`/user/${userId}`);
|
||||
|
||||
|
||||
const userAlerts = response.data.alerts || [];
|
||||
|
||||
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
||||
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
|
||||
const priorityOrder = {critical: 4, high: 3, medium: 2, low: 1};
|
||||
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
|
||||
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
|
||||
|
||||
|
||||
if (aPriority !== bPriority) {
|
||||
return bPriority - aPriority;
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
|
||||
|
||||
const handleDismissAlert = async (alertId: string) => {
|
||||
if (!userId) return;
|
||||
|
||||
|
||||
try {
|
||||
const response = await API.post('/dismiss', {
|
||||
userId,
|
||||
@@ -130,8 +130,8 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
|
||||
if (!currentAlert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const priorityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||
|
||||
const priorityCounts = {critical: 0, high: 0, medium: 0, low: 0};
|
||||
alerts.forEach(alert => {
|
||||
const priority = alert.priority || 'low';
|
||||
priorityCounts[priority as keyof typeof priorityCounts]++;
|
||||
|
||||
@@ -149,13 +149,11 @@ export function HomepageAuth({
|
||||
setUsername(meRes.data.username || null);
|
||||
setUserId(meRes.data.id || null);
|
||||
setDbError(null);
|
||||
// Call onAuthSuccess to update App state
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.data.is_admin,
|
||||
username: meRes.data.username || null,
|
||||
userId: meRes.data.id || null
|
||||
});
|
||||
// Update internal state immediately
|
||||
setInternalLoggedIn(true);
|
||||
if (tab === "signup") {
|
||||
setSignupConfirmPassword("");
|
||||
@@ -309,13 +307,11 @@ export function HomepageAuth({
|
||||
setUsername(meRes.data.username || null);
|
||||
setUserId(meRes.data.id || null);
|
||||
setDbError(null);
|
||||
// Call onAuthSuccess to update App state
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.data.is_admin,
|
||||
username: meRes.data.username || null,
|
||||
userId: meRes.data.id || null
|
||||
});
|
||||
// Update internal state immediately
|
||||
setInternalLoggedIn(true);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
})
|
||||
@@ -347,341 +343,341 @@ export function HomepageAuth({
|
||||
className={`w-[420px] max-w-full p-6 flex flex-col ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
{dbError && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{dbError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{firstUser && !dbError && !internalLoggedIn && (
|
||||
<Alert variant="default" className="mb-4">
|
||||
<AlertTitle>First User</AlertTitle>
|
||||
<AlertDescription className="inline">
|
||||
You are the first user and will be made an admin. You can view admin settings in the sidebar
|
||||
user dropdown. If you think this is a mistake, check the docker logs, or create a{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline hover:text-blue-800 inline"
|
||||
>
|
||||
GitHub issue
|
||||
</a>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!registrationAllowed && !internalLoggedIn && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Registration Disabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
New account registration is currently disabled by an admin. Please log in or contact an
|
||||
administrator.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && (
|
||||
<>
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "login"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("login");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "login"}
|
||||
disabled={loading || firstUser}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "signup"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("signup");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "signup"}
|
||||
disabled={loading || !registrationAllowed}
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
{oidcConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "external"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("external");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login" || tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "external"}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
External
|
||||
</button>
|
||||
{dbError && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{dbError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{firstUser && !dbError && !internalLoggedIn && (
|
||||
<Alert variant="default" className="mb-4">
|
||||
<AlertTitle>First User</AlertTitle>
|
||||
<AlertDescription className="inline">
|
||||
You are the first user and will be made an admin. You can view admin settings in the sidebar
|
||||
user dropdown. If you think this is a mistake, check the docker logs, or create a{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline hover:text-blue-800 inline"
|
||||
>
|
||||
GitHub issue
|
||||
</a>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!registrationAllowed && !internalLoggedIn && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Registration Disabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
New account registration is currently disabled by an admin. Please log in or contact an
|
||||
administrator.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && (
|
||||
<>
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "login"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{tab === "login" ? "Login to your account" :
|
||||
tab === "signup" ? "Create a new account" :
|
||||
tab === "external" ? "Login with external provider" :
|
||||
"Reset your password"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{tab === "external" || tab === "reset" ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
{tab === "external" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Login using your configured external identity provider</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading ? Spinner : "Login with External Provider"}
|
||||
</Button>
|
||||
</>
|
||||
onClick={() => {
|
||||
setTab("login");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "login"}
|
||||
disabled={loading || firstUser}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "signup"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("signup");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "signup"}
|
||||
disabled={loading || !registrationAllowed}
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
{oidcConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "external"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
{tab === "reset" && (
|
||||
<>
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter your username to receive a password reset code. The code
|
||||
will be logged in the docker container logs.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-username">Username</Label>
|
||||
<Input
|
||||
id="reset-username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={e => setLocalUsername(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !localUsername.trim()}
|
||||
onClick={initiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Send Reset Code"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("external");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login" || tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "external"}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
External
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{tab === "login" ? "Login to your account" :
|
||||
tab === "signup" ? "Create a new account" :
|
||||
tab === "external" ? "Login with external provider" :
|
||||
"Reset your password"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter the 6-digit code from the docker container logs for
|
||||
user: <strong>{localUsername}</strong></p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">Reset Code</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
className="h-11 text-base text-center text-lg tracking-widest"
|
||||
value={resetCode}
|
||||
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
|
||||
disabled={resetLoading}
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={verifyResetCode}
|
||||
>
|
||||
{resetLoading ? Spinner : "Verify Code"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
{tab === "external" || tab === "reset" ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
{tab === "external" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Login using your configured external identity provider</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading ? Spinner : "Login with External Provider"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{tab === "reset" && (
|
||||
<>
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter your username to receive a password reset code. The code
|
||||
will be logged in the docker container logs.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-username">Username</Label>
|
||||
<Input
|
||||
id="reset-username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={e => setLocalUsername(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetSuccess && (
|
||||
<>
|
||||
<Alert className="mb-4">
|
||||
<AlertTitle>Success!</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your password has been successfully reset! You can now log in
|
||||
with your new password.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !localUsername.trim()}
|
||||
onClick={initiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Send Reset Code"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter the 6-digit code from the docker container logs for
|
||||
user: <strong>{localUsername}</strong></p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">Reset Code</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
className="h-11 text-base text-center text-lg tracking-widest"
|
||||
value={resetCode}
|
||||
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
|
||||
disabled={resetLoading}
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={verifyResetCode}
|
||||
>
|
||||
{resetLoading ? Spinner : "Verify Code"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setTab("login");
|
||||
resetPasswordState();
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
Go to Login
|
||||
Back
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter your new password for
|
||||
user: <strong>{localUsername}</strong></p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !newPassword || !confirmPassword}
|
||||
onClick={completePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Reset Password"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
{resetSuccess && (
|
||||
<>
|
||||
<Alert className="mb-4">
|
||||
<AlertTitle>Success!</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your password has been successfully reset! You can now log in
|
||||
with your new password.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
onClick={() => {
|
||||
setTab("login");
|
||||
resetPasswordState();
|
||||
}}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter your new password for
|
||||
user: <strong>{localUsername}</strong></p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("verify");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !newPassword || !confirmPassword}
|
||||
onClick={completePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Reset Password"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("verify");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={e => setLocalUsername(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" required className="h-11 text-base"
|
||||
value={password} onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
</div>
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={e => setLocalUsername(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" required className="h-11 text-base"
|
||||
value={password} onChange={e => setPassword(e.target.value)}
|
||||
<Label htmlFor="signup-confirm-password">Confirm Password</Label>
|
||||
<Input id="signup-confirm-password" type="password" required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={e => setSignupConfirmPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
</div>
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">Confirm Password</Label>
|
||||
<Input id="signup-confirm-password" type="password" required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={e => setSignupConfirmPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}>
|
||||
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
|
||||
)}
|
||||
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}>
|
||||
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
|
||||
</Button>
|
||||
{tab === "login" && (
|
||||
<Button type="button" variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}
|
||||
onClick={() => {
|
||||
setTab("reset");
|
||||
resetPasswordState();
|
||||
clearFormFields();
|
||||
}}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
{tab === "login" && (
|
||||
<Button type="button" variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}
|
||||
onClick={() => {
|
||||
setTab("reset");
|
||||
resetPasswordState();
|
||||
clearFormFields();
|
||||
}}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {TerminalComponent} from "@/ui/apps/Terminal/TerminalComponent.tsx";
|
||||
import {Server as ServerView} from "@/ui/apps/Server/Server.tsx";
|
||||
import {FileManager} from "@/ui/apps/File Manager/FileManager.tsx";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||
import {LucideRefreshCcw, LucideRefreshCw, RefreshCcw, RefreshCcwDot} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
|
||||
interface TerminalViewProps {
|
||||
isTopbarOpen?: boolean;
|
||||
}
|
||||
|
||||
export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.ReactElement {
|
||||
export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactElement {
|
||||
const {tabs, currentTab, allSplitScreenTab} = useTabs() as any;
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const {state: sidebarState} = useSidebar();
|
||||
|
||||
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal' || tab.type === 'server' || tab.type === 'file_manager');
|
||||
|
||||
@@ -51,7 +51,6 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
||||
});
|
||||
};
|
||||
|
||||
// Coalesce layout → measure → fit callbacks
|
||||
const layoutScheduleRef = useRef<number | null>(null);
|
||||
const scheduleMeasureAndFit = () => {
|
||||
if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current);
|
||||
@@ -63,7 +62,6 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
||||
});
|
||||
};
|
||||
|
||||
// Hide terminals until layout → rects → fit applied to prevent first-frame wrapping
|
||||
const hideThenFit = () => {
|
||||
setReady(false);
|
||||
requestAnimationFrame(() => {
|
||||
@@ -77,13 +75,10 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
||||
|
||||
useEffect(() => {
|
||||
hideThenFit();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]);
|
||||
|
||||
// When split layout toggles on/off, topbar toggles, or sidebar state changes → measure+fit
|
||||
useEffect(() => {
|
||||
scheduleMeasureAndFit();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -93,14 +88,15 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
||||
}) : null;
|
||||
if (containerRef.current && roContainer) roContainer.observe(containerRef.current);
|
||||
return () => roContainer?.disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onWinResize = () => { updatePanelRects(); fitActiveAndNotify(); };
|
||||
const onWinResize = () => {
|
||||
updatePanelRects();
|
||||
fitActiveAndNotify();
|
||||
};
|
||||
window.addEventListener('resize', onWinResize);
|
||||
return () => window.removeEventListener('resize', onWinResize);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const HEADER_H = 28;
|
||||
@@ -109,24 +105,34 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
||||
const styles: Record<number, React.CSSProperties> = {};
|
||||
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
|
||||
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
|
||||
const layoutTabs = [mainTab, ...splitTabs.filter((t:any)=> t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
|
||||
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
|
||||
|
||||
if (allSplitScreenTab.length === 0 && mainTab) {
|
||||
styles[mainTab.id] = { position:'absolute', top:2, left:2, right:2, bottom:2, zIndex: 20, display: 'block', pointerEvents:'auto', opacity: ready ? 1 : 0 };
|
||||
styles[mainTab.id] = {
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: 2,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
zIndex: 20,
|
||||
display: 'block',
|
||||
pointerEvents: 'auto',
|
||||
opacity: ready ? 1 : 0
|
||||
};
|
||||
} else {
|
||||
layoutTabs.forEach((t: any) => {
|
||||
const rect = panelRects[String(t.id)];
|
||||
const parentRect = containerRef.current?.getBoundingClientRect();
|
||||
if (rect && parentRect) {
|
||||
styles[t.id] = {
|
||||
position:'absolute',
|
||||
position: 'absolute',
|
||||
top: (rect.top - parentRect.top) + HEADER_H + 2,
|
||||
left: (rect.left - parentRect.left) + 2,
|
||||
width: rect.width - 4,
|
||||
height: rect.height - HEADER_H - 4,
|
||||
zIndex: 20,
|
||||
display: 'block',
|
||||
pointerEvents:'auto',
|
||||
pointerEvents: 'auto',
|
||||
opacity: ready ? 1 : 0,
|
||||
};
|
||||
}
|
||||
@@ -134,22 +140,21 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, zIndex:1}}>
|
||||
{terminalTabs.map((t:any) => {
|
||||
<div style={{position: 'absolute', inset: 0, zIndex: 1}}>
|
||||
{terminalTabs.map((t: any) => {
|
||||
const hasStyle = !!styles[t.id];
|
||||
const isVisible = hasStyle || (allSplitScreenTab.length===0 && t.id===currentTab);
|
||||
const isVisible = hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
|
||||
|
||||
// Visible style from computed positions; otherwise keep mounted but hidden and non-interactive
|
||||
const finalStyle: React.CSSProperties = hasStyle
|
||||
? {...styles[t.id], overflow:'hidden'}
|
||||
? {...styles[t.id], overflow: 'hidden'}
|
||||
: {
|
||||
position:'absolute', inset:0, visibility:'hidden', pointerEvents:'none', zIndex:0,
|
||||
} as React.CSSProperties;
|
||||
position: 'absolute', inset: 0, visibility: 'hidden', pointerEvents: 'none', zIndex: 0,
|
||||
} as React.CSSProperties;
|
||||
|
||||
const effectiveVisible = isVisible && ready;
|
||||
return (
|
||||
<div key={t.id} style={finalStyle}>
|
||||
<div className="absolute inset-0 rounded-md" style={{background:'#18181b'}}>
|
||||
<div className="absolute inset-0 rounded-md" style={{background: '#18181b'}}>
|
||||
{t.type === 'terminal' ? (
|
||||
<TerminalComponent
|
||||
ref={t.terminalRef}
|
||||
@@ -157,7 +162,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
||||
isVisible={effectiveVisible}
|
||||
title={t.title}
|
||||
showTitle={false}
|
||||
splitScreen={allSplitScreenTab.length>0}
|
||||
splitScreen={allSplitScreenTab.length > 0}
|
||||
/>
|
||||
) : t.type === 'server' ? (
|
||||
<ServerView
|
||||
@@ -181,7 +186,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
||||
);
|
||||
};
|
||||
|
||||
const ResetButton = ({ onClick }: { onClick: () => void }) => (
|
||||
const ResetButton = ({onClick}: { onClick: () => void }) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -189,7 +194,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
||||
aria-label="Reset split sizes"
|
||||
className="absolute top-0 right-0 h-[28px] w-[28px] !rounded-none border-l-1 border-b-1 border-[#222224] bg-[#1b1b1e] hover:bg-[#232327] text-white flex items-center justify-center p-0"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
<RefreshCcw className="h-4 w-4"/>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -201,28 +206,75 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
||||
const renderSplitOverlays = () => {
|
||||
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
|
||||
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
|
||||
const layoutTabs = [mainTab, ...splitTabs.filter((t:any)=> t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
|
||||
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
|
||||
if (allSplitScreenTab.length === 0) return null;
|
||||
|
||||
const handleStyle = { pointerEvents:'auto', zIndex:12, background:'#303032' } as React.CSSProperties;
|
||||
const commonGroupProps = { onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit } as any;
|
||||
const handleStyle = {pointerEvents: 'auto', zIndex: 12, background: '#303032'} as React.CSSProperties;
|
||||
const commonGroupProps = {onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit} as any;
|
||||
|
||||
if (layoutTabs.length === 2) {
|
||||
const [a,b] = layoutTabs as any[];
|
||||
const [a, b] = layoutTabs as any[];
|
||||
return (
|
||||
<div style={{ position:'absolute', inset:0, zIndex:10, pointerEvents:'none' }}>
|
||||
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal" className="h-full w-full" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${a.id}`} order={1}>
|
||||
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',background:'transparent',position:'relative'}}>
|
||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{a.title}</div>
|
||||
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
|
||||
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal"
|
||||
className="h-full w-full" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${a.id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(a.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>{a.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${b.id}`} order={2}>
|
||||
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',background:'transparent',position:'relative'}}>
|
||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${b.id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(b.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>
|
||||
{b.title}
|
||||
<ResetButton onClick={handleReset} />
|
||||
<ResetButton onClick={handleReset}/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
@@ -231,32 +283,101 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
||||
);
|
||||
}
|
||||
if (layoutTabs.length === 3) {
|
||||
const [a,b,c] = layoutTabs as any[];
|
||||
const [a, b, c] = layoutTabs as any[];
|
||||
return (
|
||||
<div style={{ position:'absolute', inset:0, zIndex:10, pointerEvents:'none' }}>
|
||||
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full" id="main-vertical" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
|
||||
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal" className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${a.id}`} order={1}>
|
||||
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
|
||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{a.title}</div>
|
||||
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
|
||||
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
|
||||
id="main-vertical" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id="top-panel" order={1}>
|
||||
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
|
||||
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${a.id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(a.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>{a.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${b.id}`} order={2}>
|
||||
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
|
||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${b.id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(b.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>
|
||||
{b.title}
|
||||
<ResetButton onClick={handleReset} />
|
||||
<ResetButton onClick={handleReset}/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}>
|
||||
<div ref={el => { panelRefs.current[String(c.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
|
||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{c.title}</div>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id="bottom-panel" order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(c.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>{c.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePrimitive.PanelGroup>
|
||||
@@ -264,40 +385,133 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
||||
);
|
||||
}
|
||||
if (layoutTabs.length === 4) {
|
||||
const [a,b,c,d] = layoutTabs as any[];
|
||||
const [a, b, c, d] = layoutTabs as any[];
|
||||
return (
|
||||
<div style={{ position:'absolute', inset:0, zIndex:10, pointerEvents:'none' }}>
|
||||
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full" id="main-vertical" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
|
||||
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal" className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${a.id}`} order={1}>
|
||||
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
|
||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{a.title}</div>
|
||||
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
|
||||
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
|
||||
id="main-vertical" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id="top-panel" order={1}>
|
||||
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
|
||||
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||
id={`panel-${a.id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(a.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>{a.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${b.id}`} order={2}>
|
||||
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
|
||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||
id={`panel-${b.id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(b.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>
|
||||
{b.title}
|
||||
<ResetButton onClick={handleReset} />
|
||||
<ResetButton onClick={handleReset}/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id="bottom-panel" order={2}>
|
||||
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal" className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${c.id}`} order={1}>
|
||||
<div ref={el => { panelRefs.current[String(c.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
|
||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{c.title}</div>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||
id="bottom-panel" order={2}>
|
||||
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal"
|
||||
className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||
id={`panel-${c.id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(c.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>{c.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${d.id}`} order={2}>
|
||||
<div ref={el => { panelRefs.current[String(d.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
|
||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{d.title}</div>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||
id={`panel-${d.id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(d.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>{d.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
@@ -318,8 +532,8 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
||||
ref={containerRef}
|
||||
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
|
||||
style={{
|
||||
position:'relative',
|
||||
background:'#18181b',
|
||||
position: 'relative',
|
||||
background: '#18181b',
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, {useState} from "react";
|
||||
import {CardTitle} from "@/components/ui/card.tsx";
|
||||
import {ChevronDown, Folder} from "lucide-react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
@@ -35,7 +35,7 @@ interface FolderCardProps {
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardProps): React.ReactElement {
|
||||
export function FolderCard({folderName, hosts, isFirst, isLast}: FolderCardProps): React.ReactElement {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
@@ -43,7 +43,8 @@ export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardPro
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden" style={{padding: '0', margin: '0'}}>
|
||||
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden"
|
||||
style={{padding: '0', margin: '0'}}>
|
||||
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
|
||||
<div className="flex gap-2 pr-10">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
@@ -65,10 +66,10 @@ export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardPro
|
||||
<div className="flex flex-col p-2 gap-y-3">
|
||||
{hosts.map((host, index) => (
|
||||
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
|
||||
<Host host={host} />
|
||||
<Host host={host}/>
|
||||
{index < hosts.length - 1 && (
|
||||
<div className="relative -mx-2">
|
||||
<Separator className="p-0.25 absolute inset-x-0" />
|
||||
<Separator className="p-0.25 absolute inset-x-0"/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||
import {Server, Terminal} from "lucide-react";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
import { getServerStatusById } from "@/ui/main-axios.ts";
|
||||
import {getServerStatusById} from "@/ui/main-axios.ts";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -33,12 +33,12 @@ interface HostProps {
|
||||
host: SSHHost;
|
||||
}
|
||||
|
||||
export function Host({ host }: HostProps): React.ReactElement {
|
||||
const { addTab } = useTabs();
|
||||
export function Host({host}: HostProps): React.ReactElement {
|
||||
const {addTab} = useTabs();
|
||||
const [serverStatus, setServerStatus] = useState<'online' | 'offline'>('offline');
|
||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||
const hasTags = tags.length > 0;
|
||||
|
||||
|
||||
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,13 +66,13 @@ export function Host({ host }: HostProps): React.ReactElement {
|
||||
}, [host.id]);
|
||||
|
||||
const handleTerminalClick = () => {
|
||||
addTab({ type: 'terminal', title, hostConfig: host });
|
||||
addTab({type: 'terminal', title, hostConfig: host});
|
||||
};
|
||||
|
||||
const handleServerClick = () => {
|
||||
addTab({ type: 'server', title, hostConfig: host });
|
||||
addTab({type: 'server', title, hostConfig: host});
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -87,8 +87,8 @@ export function Host({ host }: HostProps): React.ReactElement {
|
||||
<Server/>
|
||||
</Button>
|
||||
{host.enableTerminal && (
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-[#303032]"
|
||||
onClick={handleTerminalClick}
|
||||
>
|
||||
|
||||
@@ -49,7 +49,7 @@ import axios from "axios";
|
||||
import {Card} from "@/components/ui/card.tsx";
|
||||
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
|
||||
import {getSSHHosts} from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -102,29 +102,14 @@ const API = axios.create({
|
||||
});
|
||||
|
||||
export function LeftSidebar({
|
||||
onSelectView,
|
||||
getView,
|
||||
disabled,
|
||||
isAdmin,
|
||||
username,
|
||||
children,
|
||||
}: SidebarProps): React.ReactElement {
|
||||
onSelectView,
|
||||
getView,
|
||||
disabled,
|
||||
isAdmin,
|
||||
username,
|
||||
children,
|
||||
}: SidebarProps): React.ReactElement {
|
||||
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
|
||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||
const [regLoading, setRegLoading] = React.useState(false);
|
||||
const [oidcConfig, setOidcConfig] = React.useState({
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
issuer_url: '',
|
||||
authorization_url: '',
|
||||
token_url: '',
|
||||
identifier_path: 'sub',
|
||||
name_path: 'name',
|
||||
scopes: 'openid email profile'
|
||||
});
|
||||
const [oidcLoading, setOidcLoading] = React.useState(false);
|
||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
||||
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
|
||||
|
||||
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
|
||||
const [deletePassword, setDeletePassword] = React.useState("");
|
||||
@@ -138,21 +123,21 @@ export function LeftSidebar({
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
}>>([]);
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
const [newAdminUsername, setNewAdminUsername] = React.useState("");
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
|
||||
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
|
||||
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
|
||||
const [oidcConfig, setOidcConfig] = React.useState<any>(null);
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
|
||||
|
||||
// Tabs context for opening SSH Manager tab
|
||||
const { tabs: tabList, addTab, setCurrentTab, allSplitScreenTab } = useTabs() as any;
|
||||
const {tabs: tabList, addTab, setCurrentTab, allSplitScreenTab} = useTabs() as any;
|
||||
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
|
||||
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
|
||||
const openSshManagerTab = () => {
|
||||
if (sshManagerTab || isSplitScreenActive) return;
|
||||
const id = addTab({ type: 'ssh_manager', title: 'SSH Manager' } as any);
|
||||
const id = addTab({type: 'ssh_manager', title: 'SSH Manager'} as any);
|
||||
setCurrentTab(id);
|
||||
};
|
||||
const adminTab = tabList.find((t) => t.type === 'admin');
|
||||
@@ -162,11 +147,10 @@ export function LeftSidebar({
|
||||
setCurrentTab(adminTab.id);
|
||||
return;
|
||||
}
|
||||
const id = addTab({ type: 'admin', title: 'Admin' } as any);
|
||||
const id = addTab({type: 'admin', title: 'Admin'} as any);
|
||||
setCurrentTab(id);
|
||||
};
|
||||
|
||||
// SSH Hosts state management
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [hostsLoading, setHostsLoading] = useState(false);
|
||||
const [hostsError, setHostsError] = useState<string | null>(null);
|
||||
@@ -202,23 +186,16 @@ export function LeftSidebar({
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
// SSH Hosts data fetching
|
||||
const fetchHosts = React.useCallback(async () => {
|
||||
try {
|
||||
const newHosts = await getSSHHosts();
|
||||
// Show all hosts in sidebar, regardless of terminal setting
|
||||
// Terminal visibility is handled in the UI components
|
||||
|
||||
const prevHosts = prevHostsRef.current;
|
||||
|
||||
// Create a stable map of existing hosts by ID for comparison
|
||||
|
||||
const existingHostsMap = new Map(prevHosts.map(h => [h.id, h]));
|
||||
const newHostsMap = new Map(newHosts.map(h => [h.id, h]));
|
||||
|
||||
// Check if there are any meaningful changes
|
||||
|
||||
let hasChanges = false;
|
||||
|
||||
// Check for new hosts, removed hosts, or changed hosts
|
||||
|
||||
if (newHosts.length !== prevHosts.length) {
|
||||
hasChanges = true;
|
||||
} else {
|
||||
@@ -228,8 +205,7 @@ export function LeftSidebar({
|
||||
hasChanges = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Only check fields that affect the display
|
||||
|
||||
if (
|
||||
newHost.name !== existingHost.name ||
|
||||
newHost.folder !== existingHost.folder ||
|
||||
@@ -245,9 +221,8 @@ export function LeftSidebar({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (hasChanges) {
|
||||
// Use a small delay to batch updates and reduce jittering
|
||||
setTimeout(() => {
|
||||
setHosts(newHosts);
|
||||
prevHostsRef.current = newHosts;
|
||||
@@ -264,7 +239,6 @@ export function LeftSidebar({
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchHosts]);
|
||||
|
||||
// Immediate refresh when SSH hosts are changed elsewhere in the app
|
||||
React.useEffect(() => {
|
||||
const handleHostsChanged = () => {
|
||||
fetchHosts();
|
||||
@@ -273,13 +247,11 @@ export function LeftSidebar({
|
||||
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||
}, [fetchHosts]);
|
||||
|
||||
// Search debouncing
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [search]);
|
||||
|
||||
// Filter and organize hosts with stable references
|
||||
const filteredHosts = React.useMemo(() => {
|
||||
if (!debouncedSearch.trim()) return hosts;
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
@@ -323,68 +295,6 @@ export function LeftSidebar({
|
||||
return [...pinned, ...rest];
|
||||
}, []);
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRegLoading(true);
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.patch(
|
||||
"/registration-allowed",
|
||||
{allowed: checked},
|
||||
{headers: {Authorization: `Bearer ${jwt}`}}
|
||||
);
|
||||
setAllowRegistration(checked);
|
||||
} catch (e) {
|
||||
} finally {
|
||||
setRegLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOidcLoading(true);
|
||||
setOidcError(null);
|
||||
setOidcSuccess(null);
|
||||
|
||||
const requiredFields = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
|
||||
const missingFields = requiredFields.filter(field => !oidcConfig[field as keyof typeof oidcConfig]);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
setOidcError(`Missing required fields: ${missingFields.join(', ')}`);
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.post(
|
||||
"/oidc-config",
|
||||
oidcConfig,
|
||||
{headers: {Authorization: `Bearer ${jwt}`}}
|
||||
);
|
||||
setOidcSuccess("OIDC configuration updated successfully!");
|
||||
} catch (err: any) {
|
||||
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOIDCConfigChange = (field: string, value: string) => {
|
||||
setOidcConfig(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setDeleteLoading(true);
|
||||
@@ -416,7 +326,7 @@ export function LeftSidebar({
|
||||
if (!jwt || !isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setUsersLoading(true);
|
||||
try {
|
||||
const response = await API.get("/list", {
|
||||
@@ -427,7 +337,6 @@ export function LeftSidebar({
|
||||
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
|
||||
setAdminCount(adminUsers.length);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to fetch users:", err);
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
@@ -439,7 +348,7 @@ export function LeftSidebar({
|
||||
if (!jwt || !isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await API.get("/list", {
|
||||
headers: {Authorization: `Bearer ${jwt}`}
|
||||
@@ -447,7 +356,6 @@ export function LeftSidebar({
|
||||
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
|
||||
setAdminCount(adminUsers.length);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to fetch admin count:", err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -494,7 +402,6 @@ export function LeftSidebar({
|
||||
);
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
console.error("Failed to remove admin status:", err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -513,7 +420,6 @@ export function LeftSidebar({
|
||||
});
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
console.error("Failed to delete user:", err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -536,14 +442,15 @@ export function LeftSidebar({
|
||||
<Separator className="p-0.25"/>
|
||||
<SidebarContent>
|
||||
<SidebarGroup className="!m-0 !p-0 !-mb-2">
|
||||
<Button className="m-2 flex flex-row font-semibold" variant="outline" onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive} title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
|
||||
<Button className="m-2 flex flex-row font-semibold" variant="outline"
|
||||
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
|
||||
title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
|
||||
<HardDrive strokeWidth="2.5"/>
|
||||
Host Manager
|
||||
</Button>
|
||||
</SidebarGroup>
|
||||
<Separator className="p-0.25"/>
|
||||
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
|
||||
{/* Search Input */}
|
||||
<div className="bg-[#131316] rounded-lg">
|
||||
<Input
|
||||
value={search}
|
||||
@@ -553,17 +460,16 @@ export function LeftSidebar({
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
|
||||
{hostsError && (
|
||||
<div className="px-1">
|
||||
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
||||
<div
|
||||
className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
||||
{hostsError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{hostsLoading && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
@@ -572,7 +478,6 @@ export function LeftSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hosts by Folder */}
|
||||
{sortedFolders.map((folder, idx) => (
|
||||
<FolderCard
|
||||
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
|
||||
@@ -608,7 +513,7 @@ export function LeftSidebar({
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onSelect={() => {
|
||||
onClick={() => {
|
||||
if (isAdmin) openAdminTab();
|
||||
}}>
|
||||
<span>Admin Settings</span>
|
||||
@@ -616,12 +521,12 @@ export function LeftSidebar({
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onSelect={handleLogout}>
|
||||
onClick={handleLogout}>
|
||||
<span>Sign out</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onSelect={() => setDeleteAccountOpen(true)}
|
||||
onClick={() => setDeleteAccountOpen(true)}
|
||||
disabled={isAdmin && adminCount <= 1}
|
||||
>
|
||||
<span
|
||||
@@ -635,383 +540,74 @@ export function LeftSidebar({
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
{/* Admin Settings Sheet */}
|
||||
{isAdmin && (
|
||||
<Sheet modal={false} open={adminSheetOpen && isAdmin} onOpenChange={(open) => {
|
||||
if (open && !isAdmin) return;
|
||||
setAdminSheetOpen(open);
|
||||
}}>
|
||||
<SheetContent side="left" className="w-[700px] max-h-screen overflow-y-auto">
|
||||
<SheetHeader className="px-6 pb-4">
|
||||
<SheetTitle>Admin Settings</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="px-6">
|
||||
<Tabs defaultValue="registration" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4 mb-6">
|
||||
<TabsTrigger value="registration" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4"/>
|
||||
Reg
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="oidc" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4"/>
|
||||
OIDC
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4"/>
|
||||
Users
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="admins" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4"/>
|
||||
Admins
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Registration Settings Tab */}
|
||||
<TabsContent value="registration" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">User Registration</h3>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
|
||||
disabled={regLoading}/>
|
||||
Allow new account registration
|
||||
</label>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
{/* OIDC Configuration Tab */}
|
||||
<TabsContent value="oidc" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">External Authentication
|
||||
(OIDC)</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure external identity provider for OIDC/OAuth2 authentication.
|
||||
Users will see an "External" login option once configured.
|
||||
</p>
|
||||
{!isSidebarOpen && (
|
||||
<div
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md">
|
||||
<ChevronRight size={10}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oidcError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{oidcError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{deleteAccountOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[999999] flex"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 999999,
|
||||
pointerEvents: 'auto',
|
||||
isolation: 'isolate',
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'z-index'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-[400px] h-full bg-[#18181b] border-r-2 border-[#303032] flex flex-col shadow-2xl"
|
||||
style={{
|
||||
backgroundColor: '#18181b',
|
||||
boxShadow: '4px 0 20px rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 9999999,
|
||||
position: 'relative',
|
||||
isolation: 'isolate',
|
||||
transform: 'translateZ(0)'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
|
||||
<h2 className="text-lg font-semibold text-white">Delete Account</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDeleteAccountOpen(false);
|
||||
setDeletePassword("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||
title="Close Delete Account"
|
||||
>
|
||||
<span className="text-lg font-bold leading-none">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_id">Client ID</Label>
|
||||
<Input
|
||||
id="client_id"
|
||||
value={oidcConfig.client_id}
|
||||
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
|
||||
placeholder="your-client-id"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_secret">Client Secret</Label>
|
||||
<Input
|
||||
id="client_secret"
|
||||
type="password"
|
||||
value={oidcConfig.client_secret}
|
||||
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
||||
placeholder="your-client-secret"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authorization_url">Authorization URL</Label>
|
||||
<Input
|
||||
id="authorization_url"
|
||||
value={oidcConfig.authorization_url}
|
||||
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/authorize/"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer_url">Issuer URL</Label>
|
||||
<Input
|
||||
id="issuer_url"
|
||||
value={oidcConfig.issuer_url}
|
||||
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/termix/"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token_url">Token URL</Label>
|
||||
<Input
|
||||
id="token_url"
|
||||
value={oidcConfig.token_url}
|
||||
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/token/"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier_path">User Identifier Path</Label>
|
||||
<Input
|
||||
id="identifier_path"
|
||||
value={oidcConfig.identifier_path}
|
||||
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
|
||||
placeholder="sub"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
JSON path to extract user ID from JWT (e.g., "sub", "email",
|
||||
"preferred_username")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name_path">Display Name Path</Label>
|
||||
<Input
|
||||
id="name_path"
|
||||
value={oidcConfig.name_path}
|
||||
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
|
||||
placeholder="name"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
JSON path to extract display name from JWT (e.g., "name",
|
||||
"preferred_username")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scopes">Scopes</Label>
|
||||
<Input
|
||||
id="scopes"
|
||||
value={oidcConfig.scopes}
|
||||
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
|
||||
placeholder="openid email profile"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Space-separated list of OAuth2 scopes to request
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
{oidcLoading ? "Saving..." : "Save Configuration"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setOidcConfig({
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
issuer_url: '',
|
||||
authorization_url: '',
|
||||
token_url: '',
|
||||
identifier_path: 'sub',
|
||||
name_path: 'name',
|
||||
scopes: 'openid email profile'
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oidcSuccess && (
|
||||
<Alert>
|
||||
<AlertTitle>Success</AlertTitle>
|
||||
<AlertDescription>{oidcSuccess}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Users Management Tab */}
|
||||
<TabsContent value="users" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">User Management</h3>
|
||||
<Button
|
||||
onClick={fetchUsers}
|
||||
disabled={usersLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{usersLoading ? "Loading..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{usersLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Loading users...
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4">Username</TableHead>
|
||||
<TableHead className="px-4">Type</TableHead>
|
||||
<TableHead className="px-4">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="px-4 font-medium">
|
||||
{user.username}
|
||||
{user.is_admin && (
|
||||
<span
|
||||
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{user.is_oidc ? "External" : "Local"}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteUser(user.username)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={user.is_admin}
|
||||
>
|
||||
<Trash2 className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Admins Management Tab */}
|
||||
<TabsContent value="admins" className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold">Admin Management</h3>
|
||||
|
||||
{/* Add New Admin Form */}
|
||||
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
|
||||
<h4 className="font-medium">Make User Admin</h4>
|
||||
<form onSubmit={makeUserAdmin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-admin-username">Username</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="new-admin-username"
|
||||
value={newAdminUsername}
|
||||
onChange={(e) => setNewAdminUsername(e.target.value)}
|
||||
placeholder="Enter username to make admin"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={makeAdminLoading || !newAdminUsername.trim()}
|
||||
>
|
||||
{makeAdminLoading ? "Adding..." : "Make Admin"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{makeAdminError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{makeAdminError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{makeAdminSuccess && (
|
||||
<Alert>
|
||||
<AlertTitle>Success</AlertTitle>
|
||||
<AlertDescription>{makeAdminSuccess}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Current Admins Table */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">Current Admins</h4>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4">Username</TableHead>
|
||||
<TableHead className="px-4">Type</TableHead>
|
||||
<TableHead className="px-4">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.filter(user => user.is_admin).map((admin) => (
|
||||
<TableRow key={admin.id}>
|
||||
<TableCell className="px-4 font-medium">
|
||||
{admin.username}
|
||||
<span
|
||||
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
Admin
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{admin.is_oidc ? "External" : "Local"}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeAdminStatus(admin.username)}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
disabled={admin.username === username}
|
||||
>
|
||||
<Shield className="h-4 w-4"/>
|
||||
Remove Admin
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-6 pt-6 pb-6">
|
||||
<Separator className="p-0.25 mt-2 mb-2"/>
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{/* Delete Account Confirmation Sheet */}
|
||||
<Sheet modal={false} open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
||||
<SheetContent side="left" className="w-[400px]">
|
||||
<SheetHeader className="pb-0">
|
||||
<SheetTitle>Delete Account</SheetTitle>
|
||||
<SheetDescription>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-300">
|
||||
This action cannot be undone. This will permanently delete your account and all
|
||||
associated data.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="pb-4 px-4 flex flex-col gap-4">
|
||||
</div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertDescription>
|
||||
@@ -1076,19 +672,18 @@ export function LeftSidebar({
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isSidebarOpen && (
|
||||
<div
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md">
|
||||
<ChevronRight size={10} />
|
||||
<div
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setDeleteAccountOpen(false);
|
||||
setDeletePassword("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
style={{cursor: 'pointer'}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import React from "react";
|
||||
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Home, SeparatorVertical, X, Terminal as TerminalIcon, Server as ServerIcon, Folder as FolderIcon} from "lucide-react";
|
||||
import {
|
||||
Home,
|
||||
SeparatorVertical,
|
||||
X,
|
||||
Terminal as TerminalIcon,
|
||||
Server as ServerIcon,
|
||||
Folder as FolderIcon
|
||||
} from "lucide-react";
|
||||
|
||||
interface TabProps {
|
||||
tabType: string;
|
||||
@@ -17,7 +24,19 @@ interface TabProps {
|
||||
disableClose?: boolean;
|
||||
}
|
||||
|
||||
export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, canSplit = false, canClose = false, disableActivate = false, disableSplit = false, disableClose = false}: TabProps): React.ReactElement {
|
||||
export function Tab({
|
||||
tabType,
|
||||
title,
|
||||
isActive,
|
||||
onActivate,
|
||||
onClose,
|
||||
onSplit,
|
||||
canSplit = false,
|
||||
canClose = false,
|
||||
disableActivate = false,
|
||||
disableSplit = false,
|
||||
disableClose = false
|
||||
}: TabProps): React.ReactElement {
|
||||
if (tabType === "home") {
|
||||
return (
|
||||
<Button
|
||||
@@ -42,7 +61,8 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can
|
||||
onClick={onActivate}
|
||||
disabled={disableActivate}
|
||||
>
|
||||
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ? <FolderIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>}
|
||||
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ?
|
||||
<FolderIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>}
|
||||
{title || (isServer ? 'Server' : isFileManager ? 'file_manager' : 'Terminal')}
|
||||
</Button>
|
||||
{canSplit && (
|
||||
@@ -53,7 +73,7 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can
|
||||
disabled={disableSplit}
|
||||
title={disableSplit ? 'Cannot split this tab' : 'Split'}
|
||||
>
|
||||
<SeparatorVertical className="w-[28px] h-[28px]" />
|
||||
<SeparatorVertical className="w-[28px] h-[28px]"/>
|
||||
</Button>
|
||||
)}
|
||||
{canClose && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useRef, type ReactNode } from 'react';
|
||||
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
||||
|
||||
export interface Tab {
|
||||
id: number;
|
||||
@@ -33,9 +33,9 @@ interface TabProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function TabProvider({ children }: TabProviderProps) {
|
||||
export function TabProvider({children}: TabProviderProps) {
|
||||
const [tabs, setTabs] = useState<Tab[]>([
|
||||
{ id: 1, type: 'home', title: 'Home' }
|
||||
{id: 1, type: 'home', title: 'Home'}
|
||||
]);
|
||||
const [currentTab, setCurrentTab] = useState<number>(1);
|
||||
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
||||
@@ -44,7 +44,6 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
|
||||
const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal');
|
||||
const baseTitle = (desiredTitle || defaultTitle).trim();
|
||||
// Extract base name without trailing " (n)"
|
||||
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
|
||||
const root = match ? match[1] : baseTitle;
|
||||
|
||||
@@ -64,7 +63,6 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
});
|
||||
|
||||
if (!rootUsed) return root;
|
||||
// Start at (2) for the second instance
|
||||
let n = 2;
|
||||
while (usedNumbers.has(n)) n += 1;
|
||||
return `${root} (${n})`;
|
||||
@@ -74,8 +72,8 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const id = nextTabId.current++;
|
||||
const needsUniqueTitle = tabData.type === 'terminal' || tabData.type === 'server' || tabData.type === 'file_manager';
|
||||
const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : (tabData.title || '');
|
||||
const newTab: Tab = {
|
||||
...tabData,
|
||||
const newTab: Tab = {
|
||||
...tabData,
|
||||
id,
|
||||
title: effectiveTitle,
|
||||
terminalRef: tabData.type === 'terminal' ? React.createRef<any>() : undefined
|
||||
@@ -92,10 +90,10 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") {
|
||||
tab.terminalRef.current.disconnect();
|
||||
}
|
||||
|
||||
|
||||
setTabs(prev => prev.filter(tab => tab.id !== tabId));
|
||||
setAllSplitScreenTab(prev => prev.filter(id => id !== tabId));
|
||||
|
||||
|
||||
if (currentTab === tabId) {
|
||||
const remainingTabs = tabs.filter(tab => tab.id !== tabId);
|
||||
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1);
|
||||
|
||||
@@ -24,7 +24,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any;
|
||||
const leftPosition = state === "collapsed" ? "26px" : "264px";
|
||||
|
||||
// SSH Tools state
|
||||
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
||||
@@ -47,7 +46,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
|
||||
const handleStartRecording = () => {
|
||||
setIsRecording(true);
|
||||
// Focus on the input when recording starts
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById('ssh-tools-input') as HTMLInputElement;
|
||||
if (input) input.focus();
|
||||
@@ -60,7 +58,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// Don't handle input change for special keys - let onKeyDown handle them
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -69,9 +66,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
const value = e.currentTarget.value;
|
||||
let commandToSend = '';
|
||||
|
||||
// Handle special keys and control sequences
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Control sequences
|
||||
if (e.key === 'c') {
|
||||
commandToSend = '\x03'; // Ctrl+C (SIGINT)
|
||||
e.preventDefault();
|
||||
@@ -177,7 +172,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Send the command to all selected terminals
|
||||
if (commandToSend) {
|
||||
selectedTabIds.forEach(tabId => {
|
||||
const tab = tabs.find((t: any) => t.id === tabId);
|
||||
@@ -190,8 +184,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (selectedTabIds.length === 0) return;
|
||||
|
||||
// Handle regular character input
|
||||
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
||||
const char = e.key;
|
||||
selectedTabIds.forEach(tabId => {
|
||||
@@ -209,7 +202,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
|
||||
const currentTabIsAdmin = currentTabObj?.type === 'admin';
|
||||
|
||||
// Get terminal tabs for selection
|
||||
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal');
|
||||
|
||||
function getCookie(name: string) {
|
||||
@@ -237,7 +229,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
padding: "0"
|
||||
}}
|
||||
>
|
||||
<div className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
|
||||
<div
|
||||
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
|
||||
{tabs.map((tab: any) => {
|
||||
const isActive = tab.id === currentTab;
|
||||
const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id);
|
||||
@@ -246,9 +239,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
const isFileManager = tab.type === 'file_manager';
|
||||
const isSshManager = tab.type === 'ssh_manager';
|
||||
const isAdmin = tab.type === 'admin';
|
||||
// Split availability
|
||||
const isSplittable = isTerminal || isServer || isFileManager;
|
||||
// Disable split entirely when on Home or SSH Manager
|
||||
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
|
||||
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
|
||||
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive);
|
||||
@@ -296,13 +287,12 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
<div
|
||||
onClick={() => setIsTopbarOpen(true)}
|
||||
className="absolute top-0 left-0 w-full h-[10px] bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md">
|
||||
<ChevronDown size={10} />
|
||||
<ChevronDown size={10}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom SSH Tools Overlay */}
|
||||
{toolsSheetOpen && (
|
||||
<div
|
||||
<div
|
||||
className="fixed inset-0 z-[999999] flex justify-end"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -316,13 +306,13 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
transform: 'translateZ(0)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
<div
|
||||
className="flex-1"
|
||||
onClick={() => setToolsSheetOpen(false)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
style={{cursor: 'pointer'}}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
<div
|
||||
className="w-[400px] h-full bg-[#18181b] border-l-2 border-[#303032] flex flex-col shadow-2xl"
|
||||
style={{
|
||||
backgroundColor: '#18181b',
|
||||
@@ -346,7 +336,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
<span className="text-lg font-bold leading-none">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<h1 className="font-semibold">
|
||||
@@ -374,11 +364,12 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{isRecording && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Select terminals:</label>
|
||||
<label className="text-sm font-medium text-white">Select
|
||||
terminals:</label>
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
|
||||
{terminalTabs.map(tab => (
|
||||
<Button
|
||||
@@ -387,8 +378,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
|
||||
selectedTabIds.includes(tab.id)
|
||||
? 'bg-blue-600 text-white border-blue-700 hover:bg-blue-700'
|
||||
selectedTabIds.includes(tab.id)
|
||||
? 'bg-blue-600 text-white border-blue-700 hover:bg-blue-700'
|
||||
: 'bg-transparent text-gray-300 border-gray-500 hover:bg-gray-700'
|
||||
}`}
|
||||
onClick={() => handleTabToggle(tab.id)}
|
||||
@@ -398,9 +389,10 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Type commands (all keys supported):</label>
|
||||
<label className="text-sm font-medium text-white">Type commands (all
|
||||
keys supported):</label>
|
||||
<Input
|
||||
id="ssh-tools-input"
|
||||
placeholder="Type here"
|
||||
@@ -411,7 +403,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
readOnly
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Commands will be sent to {selectedTabIds.length} selected terminal(s).
|
||||
Commands will be sent to {selectedTabIds.length} selected
|
||||
terminal(s).
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -426,8 +419,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-copy-paste"
|
||||
<Checkbox
|
||||
id="enable-copy-paste"
|
||||
onCheckedChange={updateRightClickCopyPaste}
|
||||
defaultChecked={getCookie("rightClickCopyPaste") === "true"}
|
||||
/>
|
||||
|
||||
@@ -74,11 +74,9 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// New state for operations
|
||||
const [showOperations, setShowOperations] = useState(false);
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
|
||||
// Delete modal state
|
||||
const [deletingItem, setDeletingItem] = useState<any | null>(null);
|
||||
|
||||
const sidebarRef = useRef<any>(null);
|
||||
@@ -86,7 +84,6 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
useEffect(() => {
|
||||
if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) {
|
||||
setCurrentHost(initialHost);
|
||||
// Defer to ensure sidebar is mounted
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const path = initialHost.defaultPath || '/';
|
||||
@@ -448,16 +445,13 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
}
|
||||
};
|
||||
|
||||
// Host is locked; no external host change from UI
|
||||
const handleHostChange = (_host: SSHHost | null) => {
|
||||
};
|
||||
|
||||
const handleOperationComplete = () => {
|
||||
// Refresh the sidebar files
|
||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
||||
sidebarRef.current.fetchFiles();
|
||||
}
|
||||
// Refresh home data
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
}
|
||||
@@ -471,22 +465,19 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
toast.error(error);
|
||||
};
|
||||
|
||||
// Function to update current path from sidebar
|
||||
const updateCurrentPath = (newPath: string) => {
|
||||
setCurrentPath(newPath);
|
||||
};
|
||||
|
||||
// Function to handle delete from sidebar
|
||||
const handleDeleteFromSidebar = (item: any) => {
|
||||
setDeletingItem(item);
|
||||
};
|
||||
|
||||
// Function to perform the actual delete
|
||||
const performDelete = async (item: any) => {
|
||||
if (!currentHost?.id) return;
|
||||
|
||||
try {
|
||||
const { deleteSSHItem } = await import('@/ui/main-axios.ts');
|
||||
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
||||
await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
|
||||
toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`);
|
||||
setDeletingItem(null);
|
||||
@@ -552,8 +543,8 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
</div>
|
||||
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 50, zIndex: 30}}>
|
||||
<div className="flex items-center w-full bg-[#18181b] border-b-2 border-[#303032] h-[50px] relative">
|
||||
{/* Tab list scrollable area */}
|
||||
<div className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
|
||||
<div
|
||||
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
|
||||
<FIleManagerTopNavbar
|
||||
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
|
||||
activeTab={activeTab}
|
||||
@@ -577,7 +568,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
)}
|
||||
title="File Operations"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<Settings className="h-4 w-4"/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -591,7 +582,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : ''
|
||||
)}
|
||||
>
|
||||
{isSaving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
{isSaving ? <RefreshCw className="h-4 w-4 animate-spin"/> : <Save className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -608,9 +599,6 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{/* Success/Error Messages */}
|
||||
{/* The custom alert divs are removed, so this block is no longer needed. */}
|
||||
|
||||
{activeTab === 'home' ? (
|
||||
<div className="flex h-full">
|
||||
<div className="flex-1">
|
||||
@@ -658,17 +646,14 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deletingItem && (
|
||||
<div className="fixed inset-0 z-[99999]">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60"></div>
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
<div className="relative h-full flex items-center justify-center">
|
||||
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 max-w-md mx-4 shadow-2xl">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Trash2 className="w-5 h-5 text-red-400" />
|
||||
<Trash2 className="w-5 h-5 text-red-400"/>
|
||||
Confirm Delete
|
||||
</h3>
|
||||
<p className="text-white mb-4">
|
||||
|
||||
@@ -32,17 +32,17 @@ interface FileManagerHomeViewProps {
|
||||
}
|
||||
|
||||
export function FileManagerHomeView({
|
||||
recent,
|
||||
pinned,
|
||||
shortcuts,
|
||||
onOpenFile,
|
||||
onRemoveRecent,
|
||||
onPinFile,
|
||||
onUnpinFile,
|
||||
onOpenShortcut,
|
||||
onRemoveShortcut,
|
||||
onAddShortcut
|
||||
}: FileManagerHomeViewProps) {
|
||||
recent,
|
||||
pinned,
|
||||
shortcuts,
|
||||
onOpenFile,
|
||||
onRemoveRecent,
|
||||
onPinFile,
|
||||
onUnpinFile,
|
||||
onOpenShortcut,
|
||||
onRemoveShortcut,
|
||||
onAddShortcut
|
||||
}: FileManagerHomeViewProps) {
|
||||
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
||||
const [newShortcut, setNewShortcut] = useState('');
|
||||
|
||||
@@ -128,7 +128,8 @@ export function FileManagerHomeView({
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{recent.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No recent files.</span>
|
||||
@@ -145,7 +146,8 @@ export function FileManagerHomeView({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pinned" className="mt-0">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{pinned.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No pinned files.</span>
|
||||
@@ -190,7 +192,8 @@ export function FileManagerHomeView({
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{shortcuts.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-4 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No shortcuts.</span>
|
||||
|
||||
@@ -83,7 +83,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
}>>({});
|
||||
const [fetchingFiles, setFetchingFiles] = useState(false);
|
||||
|
||||
// Context menu state
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
@@ -96,21 +95,18 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
item: null
|
||||
});
|
||||
|
||||
// Rename state
|
||||
const [renamingItem, setRenamingItem] = useState<{
|
||||
item: any;
|
||||
newName: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// when host changes, set path and connect
|
||||
const nextPath = host?.defaultPath || '/';
|
||||
setCurrentPath(nextPath);
|
||||
onPathChange?.(nextPath);
|
||||
(async () => {
|
||||
await connectToSSH(host);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [host?.id]);
|
||||
|
||||
async function connectToSSH(server: SSHHost): Promise<string | null> {
|
||||
@@ -282,35 +278,28 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, item: any) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Get viewport dimensions
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Context menu dimensions (approximate)
|
||||
const menuWidth = 160; // min-w-[160px]
|
||||
const menuHeight = 80; // Approximate height for 2 menu items
|
||||
|
||||
// Calculate position
|
||||
|
||||
const menuWidth = 160;
|
||||
const menuHeight = 80;
|
||||
|
||||
let x = e.clientX;
|
||||
let y = e.clientY;
|
||||
|
||||
// Adjust X position if menu would go off right edge
|
||||
|
||||
if (x + menuWidth > viewportWidth) {
|
||||
x = e.clientX - menuWidth;
|
||||
}
|
||||
|
||||
// Adjust Y position if menu would go off bottom edge
|
||||
|
||||
if (y + menuHeight > viewportHeight) {
|
||||
y = e.clientY - menuHeight;
|
||||
}
|
||||
|
||||
// Ensure menu doesn't go off left edge
|
||||
|
||||
if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
// Ensure menu doesn't go off top edge
|
||||
|
||||
if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
@@ -369,12 +358,10 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
};
|
||||
|
||||
const startDelete = (item: any) => {
|
||||
// Call the parent's delete handler instead of managing locally
|
||||
onDeleteItem?.(item);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
// Close context menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => closeContextMenu();
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
@@ -392,7 +379,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
<div className="flex-1 w-full h-full flex flex-col bg-[#09090b] border-r-2 border-[#303032] overflow-hidden p-0 relative min-h-0">
|
||||
{host && (
|
||||
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
|
||||
<div className="flex items-center gap-2 px-2 py-2 border-b-2 border-[#303032] bg-[#18181b] z-20" style={{maxWidth: 260}}>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-[#303032] bg-[#18181b] z-20" style={{maxWidth: 260}}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
@@ -440,7 +427,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
{filteredFiles.map((item: any) => {
|
||||
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
|
||||
const isRenaming = renamingItem?.item?.path === item.path;
|
||||
const isDeleting = false; // Deletion is handled by parent
|
||||
const isDeleting = false;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -519,7 +506,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to pin/unpin file:', err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -555,7 +541,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu.visible && contextMenu.item && (
|
||||
<div
|
||||
className="fixed z-[99998] bg-[#18181b] border-2 border-[#303032] rounded-lg shadow-xl py-1 min-w-[160px]"
|
||||
|
||||
@@ -42,69 +42,27 @@ interface FileManagerLeftSidebarVileViewerProps {
|
||||
}
|
||||
|
||||
export function FileManagerLeftSidebarFileViewer({
|
||||
sshConnections,
|
||||
onAddSSH,
|
||||
onConnectSSH,
|
||||
onEditSSH,
|
||||
onDeleteSSH,
|
||||
onPinSSH,
|
||||
currentPath,
|
||||
files,
|
||||
onOpenFile,
|
||||
onOpenFolder,
|
||||
onStarFile,
|
||||
onDeleteFile,
|
||||
isLoading,
|
||||
error,
|
||||
isSSHMode,
|
||||
onSwitchToLocal,
|
||||
onSwitchToSSH,
|
||||
currentSSH,
|
||||
}: FileManagerLeftSidebarVileViewerProps) {
|
||||
sshConnections,
|
||||
onAddSSH,
|
||||
onConnectSSH,
|
||||
onEditSSH,
|
||||
onDeleteSSH,
|
||||
onPinSSH,
|
||||
currentPath,
|
||||
files,
|
||||
onOpenFile,
|
||||
onOpenFolder,
|
||||
onStarFile,
|
||||
onDeleteFile,
|
||||
isLoading,
|
||||
error,
|
||||
isSSHMode,
|
||||
onSwitchToLocal,
|
||||
onSwitchToSSH,
|
||||
currentSSH,
|
||||
}: FileManagerLeftSidebarVileViewerProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* SSH Connections */}
|
||||
<div className="p-2 bg-[#18181b] border-b-2 border-[#303032]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground font-semibold">SSH Connections</span>
|
||||
<Button size="icon" variant="outline" onClick={onAddSSH} className="ml-2 h-7 w-7">
|
||||
<Plus className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant={!isSSHMode ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start text-left px-2 py-1.5 rounded"
|
||||
onClick={onSwitchToLocal}
|
||||
>
|
||||
<Server className="w-4 h-4 mr-2"/> Local Files
|
||||
</Button>
|
||||
{sshConnections.map((conn) => (
|
||||
<div key={conn.id} className="flex items-center gap-1 group">
|
||||
<Button
|
||||
variant={isSSHMode && currentSSH?.id === conn.id ? 'secondary' : 'ghost'}
|
||||
className="flex-1 justify-start text-left px-2 py-1.5 rounded"
|
||||
onClick={() => onSwitchToSSH(conn)}
|
||||
>
|
||||
<Link2 className="w-4 h-4 mr-2"/>
|
||||
{conn.name || conn.ip}
|
||||
{conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400"/>}
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}>
|
||||
<Pin
|
||||
className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}>
|
||||
<Edit className="w-4 h-4"/>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteSSH(conn)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500"/>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* File/Folder Viewer */}
|
||||
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button.tsx';
|
||||
import { Input } from '@/components/ui/input.tsx';
|
||||
import { Card } from '@/components/ui/card.tsx';
|
||||
import { Separator } from '@/components/ui/separator.tsx';
|
||||
import {
|
||||
Upload,
|
||||
FilePlus,
|
||||
FolderPlus,
|
||||
Trash2,
|
||||
Edit3,
|
||||
X,
|
||||
import React, {useState, useRef} from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {Card} from '@/components/ui/card.tsx';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {
|
||||
Upload,
|
||||
FilePlus,
|
||||
FolderPlus,
|
||||
Trash2,
|
||||
Edit3,
|
||||
X,
|
||||
Check,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
Folder
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils.ts';
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
|
||||
interface FileManagerOperationsProps {
|
||||
currentPath: string;
|
||||
@@ -26,18 +26,18 @@ interface FileManagerOperationsProps {
|
||||
}
|
||||
|
||||
export function FileManagerOperations({
|
||||
currentPath,
|
||||
sshSessionId,
|
||||
onOperationComplete,
|
||||
onError,
|
||||
onSuccess
|
||||
}: FileManagerOperationsProps) {
|
||||
currentPath,
|
||||
sshSessionId,
|
||||
onOperationComplete,
|
||||
onError,
|
||||
onSuccess
|
||||
}: FileManagerOperationsProps) {
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showCreateFile, setShowCreateFile] = useState(false);
|
||||
const [showCreateFolder, setShowCreateFolder] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [showRename, setShowRename] = useState(false);
|
||||
|
||||
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const [newFileName, setNewFileName] = useState('');
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
@@ -46,18 +46,18 @@ export function FileManagerOperations({
|
||||
const [renamePath, setRenamePath] = useState('');
|
||||
const [renameIsDirectory, setRenameIsDirectory] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileUpload = async () => {
|
||||
if (!uploadFile || !sshSessionId) return;
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const content = await uploadFile.text();
|
||||
const { uploadSSHFile } = await import('@/ui/main-axios.ts');
|
||||
|
||||
const {uploadSSHFile} = await import('@/ui/main-axios.ts');
|
||||
|
||||
await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
|
||||
onSuccess(`File "${uploadFile.name}" uploaded successfully`);
|
||||
setShowUpload(false);
|
||||
@@ -72,11 +72,11 @@ export function FileManagerOperations({
|
||||
|
||||
const handleCreateFile = async () => {
|
||||
if (!newFileName.trim() || !sshSessionId) return;
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { createSSHFile } = await import('@/ui/main-axios.ts');
|
||||
|
||||
const {createSSHFile} = await import('@/ui/main-axios.ts');
|
||||
|
||||
await createSSHFile(sshSessionId, currentPath, newFileName.trim());
|
||||
onSuccess(`File "${newFileName.trim()}" created successfully`);
|
||||
setShowCreateFile(false);
|
||||
@@ -91,11 +91,11 @@ export function FileManagerOperations({
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!newFolderName.trim() || !sshSessionId) return;
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { createSSHFolder } = await import('@/ui/main-axios.ts');
|
||||
|
||||
const {createSSHFolder} = await import('@/ui/main-axios.ts');
|
||||
|
||||
await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
|
||||
onSuccess(`Folder "${newFolderName.trim()}" created successfully`);
|
||||
setShowCreateFolder(false);
|
||||
@@ -110,11 +110,11 @@ export function FileManagerOperations({
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deletePath || !sshSessionId) return;
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { deleteSSHItem } = await import('@/ui/main-axios.ts');
|
||||
|
||||
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
||||
|
||||
await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
|
||||
onSuccess(`${deleteIsDirectory ? 'Folder' : 'File'} deleted successfully`);
|
||||
setShowDelete(false);
|
||||
@@ -130,11 +130,11 @@ export function FileManagerOperations({
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!renamePath || !newName.trim() || !sshSessionId) return;
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { renameSSHItem } = await import('@/ui/main-axios.ts');
|
||||
|
||||
const {renameSSHItem} = await import('@/ui/main-axios.ts');
|
||||
|
||||
await renameSSHItem(sshSessionId, renamePath, newName.trim());
|
||||
onSuccess(`${renameIsDirectory ? 'Folder' : 'File'} renamed successfully`);
|
||||
setShowRename(false);
|
||||
@@ -179,7 +179,7 @@ export function FileManagerOperations({
|
||||
if (!sshSessionId) {
|
||||
return (
|
||||
<div className="p-4 text-center">
|
||||
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2" />
|
||||
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2"/>
|
||||
<p className="text-sm text-muted-foreground">Connect to SSH to use file operations</p>
|
||||
</div>
|
||||
);
|
||||
@@ -187,7 +187,6 @@ export function FileManagerOperations({
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Operation Buttons */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -195,7 +194,7 @@ export function FileManagerOperations({
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
<Upload className="w-4 h-4 mr-2"/>
|
||||
Upload File
|
||||
</Button>
|
||||
<Button
|
||||
@@ -204,7 +203,7 @@ export function FileManagerOperations({
|
||||
onClick={() => setShowCreateFile(true)}
|
||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
<FilePlus className="w-4 h-4 mr-2"/>
|
||||
New File
|
||||
</Button>
|
||||
<Button
|
||||
@@ -213,7 +212,7 @@ export function FileManagerOperations({
|
||||
onClick={() => setShowCreateFolder(true)}
|
||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||
>
|
||||
<FolderPlus className="w-4 h-4 mr-2" />
|
||||
<FolderPlus className="w-4 h-4 mr-2"/>
|
||||
New Folder
|
||||
</Button>
|
||||
<Button
|
||||
@@ -222,7 +221,7 @@ export function FileManagerOperations({
|
||||
onClick={() => setShowRename(true)}
|
||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||
>
|
||||
<Edit3 className="w-4 h-4 mr-2" />
|
||||
<Edit3 className="w-4 h-4 mr-2"/>
|
||||
Rename
|
||||
</Button>
|
||||
<Button
|
||||
@@ -231,29 +230,27 @@ export function FileManagerOperations({
|
||||
onClick={() => setShowDelete(true)}
|
||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
<Trash2 className="w-4 h-4 mr-2"/>
|
||||
Delete Item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Current Path Display */}
|
||||
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Folder className="w-4 h-4 text-blue-400" />
|
||||
<Folder className="w-4 h-4 text-blue-400"/>
|
||||
<span className="text-muted-foreground">Current Path:</span>
|
||||
<span className="text-white font-mono truncate">{currentPath}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="p-0.25 bg-[#303032]" />
|
||||
<Separator className="p-0.25 bg-[#303032]"/>
|
||||
|
||||
{/* Upload File Modal */}
|
||||
{showUpload && (
|
||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Upload className="w-5 h-5" />
|
||||
<Upload className="w-5 h-5"/>
|
||||
Upload File
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
@@ -266,15 +263,15 @@ export function FileManagerOperations({
|
||||
onClick={() => setShowUpload(false)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-dashed border-[#434345] rounded-lg p-6 text-center">
|
||||
{uploadFile ? (
|
||||
<div className="space-y-2">
|
||||
<FileText className="w-8 h-8 text-blue-400 mx-auto" />
|
||||
<FileText className="w-8 h-8 text-blue-400 mx-auto"/>
|
||||
<p className="text-white font-medium">{uploadFile.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(uploadFile.size / 1024).toFixed(2)} KB
|
||||
@@ -290,7 +287,7 @@ export function FileManagerOperations({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Upload className="w-8 h-8 text-muted-foreground mx-auto" />
|
||||
<Upload className="w-8 h-8 text-muted-foreground mx-auto"/>
|
||||
<p className="text-white">Click to select a file</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -302,7 +299,7 @@ export function FileManagerOperations({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -310,7 +307,7 @@ export function FileManagerOperations({
|
||||
className="hidden"
|
||||
accept="*/*"
|
||||
/>
|
||||
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleFileUpload}
|
||||
@@ -331,12 +328,11 @@ export function FileManagerOperations({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create File Modal */}
|
||||
{showCreateFile && (
|
||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<FilePlus className="w-5 h-5" />
|
||||
<FilePlus className="w-5 h-5"/>
|
||||
Create New File
|
||||
</h3>
|
||||
<Button
|
||||
@@ -345,10 +341,10 @@ export function FileManagerOperations({
|
||||
onClick={() => setShowCreateFile(false)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
@@ -362,7 +358,7 @@ export function FileManagerOperations({
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleCreateFile}
|
||||
@@ -383,12 +379,11 @@ export function FileManagerOperations({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create Folder Modal */}
|
||||
{showCreateFolder && (
|
||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<FolderPlus className="w-5 h-5" />
|
||||
<FolderPlus className="w-5 h-5"/>
|
||||
Create New Folder
|
||||
</h3>
|
||||
<Button
|
||||
@@ -397,10 +392,10 @@ export function FileManagerOperations({
|
||||
onClick={() => setShowCreateFolder(false)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
@@ -414,7 +409,7 @@ export function FileManagerOperations({
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleCreateFolder}
|
||||
@@ -435,12 +430,11 @@ export function FileManagerOperations({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete Modal */}
|
||||
{showDelete && (
|
||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Trash2 className="w-5 h-5 text-red-400" />
|
||||
<Trash2 className="w-5 h-5 text-red-400"/>
|
||||
Delete Item
|
||||
</h3>
|
||||
<Button
|
||||
@@ -449,18 +443,18 @@ export function FileManagerOperations({
|
||||
onClick={() => setShowDelete(false)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 text-red-300">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<AlertCircle className="w-4 h-4"/>
|
||||
<span className="text-sm font-medium">Warning: This action cannot be undone</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
Item Path
|
||||
@@ -472,7 +466,7 @@ export function FileManagerOperations({
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -485,7 +479,7 @@ export function FileManagerOperations({
|
||||
This is a directory (will delete recursively)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
@@ -507,12 +501,11 @@ export function FileManagerOperations({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Rename Modal */}
|
||||
{showRename && (
|
||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Edit3 className="w-5 h-5" />
|
||||
<Edit3 className="w-5 h-5"/>
|
||||
Rename Item
|
||||
</h3>
|
||||
<Button
|
||||
@@ -521,10 +514,10 @@ export function FileManagerOperations({
|
||||
onClick={() => setShowRename(false)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
@@ -537,7 +530,7 @@ export function FileManagerOperations({
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
New Name
|
||||
@@ -550,7 +543,7 @@ export function FileManagerOperations({
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -563,7 +556,7 @@ export function FileManagerOperations({
|
||||
This is a directory
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleRename}
|
||||
|
||||
@@ -6,97 +6,96 @@ import {HostManagerHostEditor} from "@/ui/apps/Host Manager/HostManagerHostEdito
|
||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||
|
||||
interface HostManagerProps {
|
||||
onSelectView: (view: string) => void;
|
||||
isTopbarOpen?: boolean;
|
||||
onSelectView: (view: string) => void;
|
||||
isTopbarOpen?: boolean;
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||
const {state: sidebarState} = useSidebar();
|
||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||
const {state: sidebarState} = useSidebar();
|
||||
|
||||
const handleEditHost = (host: SSHHost) => {
|
||||
setEditingHost(host);
|
||||
setActiveTab("add_host");
|
||||
};
|
||||
const handleEditHost = (host: SSHHost) => {
|
||||
setEditingHost(host);
|
||||
setActiveTab("add_host");
|
||||
};
|
||||
|
||||
const handleFormSubmit = () => {
|
||||
setEditingHost(null);
|
||||
setActiveTab("host_viewer");
|
||||
};
|
||||
const handleFormSubmit = () => {
|
||||
setEditingHost(null);
|
||||
setActiveTab("host_viewer");
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
if (value === "host_viewer") {
|
||||
setEditingHost(null);
|
||||
}
|
||||
};
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
if (value === "host_viewer") {
|
||||
setEditingHost(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Dynamic margins similar to TerminalView but with 16px gaps when retracted
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="bg-[#18181b] text-white p-4 pt-0 rounded-lg border-2 border-[#303032] flex flex-col min-h-0 overflow-hidden"
|
||||
style={{
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
|
||||
}}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}
|
||||
className="flex-1 flex flex-col h-full min-h-0">
|
||||
<TabsList className="bg-[#18181b] border-2 border-[#303032] mt-1.5">
|
||||
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
|
||||
<TabsTrigger value="add_host">
|
||||
{editingHost ? "Edit Host" : "Add Host"}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||
<HostManagerHostViewer onEditHost={handleEditHost}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<HostManagerHostEditor
|
||||
editingHost={editingHost}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="bg-[#18181b] text-white p-4 pt-0 rounded-lg border-2 border-[#303032] flex flex-col min-h-0 overflow-hidden"
|
||||
style={{
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
|
||||
}}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}
|
||||
className="flex-1 flex flex-col h-full min-h-0">
|
||||
<TabsList className="bg-[#18181b] border-2 border-[#303032] mt-1.5">
|
||||
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
|
||||
<TabsTrigger value="add_host">
|
||||
{editingHost ? "Edit Host" : "Add Host"}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||
<HostManagerHostViewer onEditHost={handleEditHost}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<HostManagerHostEditor
|
||||
editingHost={editingHost}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -811,7 +811,9 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
render={({field: sourcePortField}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Source Port
|
||||
(Source refers to the Current Connection Details in the General tab)</FormLabel>
|
||||
(Source refers to the Current
|
||||
Connection Details in the
|
||||
General tab)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="22" {...sourcePortField} />
|
||||
@@ -1029,8 +1031,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
</ScrollArea>
|
||||
<footer className="shrink-0 w-full pb-0">
|
||||
<Separator className="p-0.25"/>
|
||||
<Button
|
||||
className=""
|
||||
<Button
|
||||
className=""
|
||||
type="submit"
|
||||
variant="outline"
|
||||
style={{
|
||||
|
||||
@@ -581,7 +581,7 @@ EXAMPLE STRUCTURE:
|
||||
Format Guide
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-2" />
|
||||
<div className="w-px h-6 bg-border mx-2"/>
|
||||
|
||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||
Refresh
|
||||
|
||||
@@ -1,256 +1,256 @@
|
||||
import React from "react";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import {useSidebar} from "@/components/ui/sidebar";
|
||||
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import {Progress} from "@/components/ui/progress"
|
||||
import {Cpu, HardDrive, MemoryStick} from "lucide-react";
|
||||
import {Tunnel} from "@/ui/apps/Tunnel/Tunnel.tsx";
|
||||
import { getServerStatusById, getServerMetricsById, ServerMetrics } from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
import {getServerStatusById, getServerMetricsById, ServerMetrics} from "@/ui/main-axios.ts";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
|
||||
interface ServerProps {
|
||||
hostConfig?: any;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean; // when rendered inside a pane in TerminalView
|
||||
hostConfig?: any;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = true, embedded = false }: ServerProps): React.ReactElement {
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const { addTab } = useTabs() as any;
|
||||
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
export function Server({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false
|
||||
}: ServerProps): React.ReactElement {
|
||||
const {state: sidebarState} = useSidebar();
|
||||
const {addTab} = useTabs() as any;
|
||||
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
|
||||
// Listen for host configuration changes
|
||||
React.useEffect(() => {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig]);
|
||||
React.useEffect(() => {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig]);
|
||||
|
||||
// Always fetch latest host config when component mounts or hostConfig changes
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
// Import the getSSHHosts function to fetch updated host data
|
||||
const { getSSHHosts } = await import('@/ui/main-axios.ts');
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find(h => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch latest host config:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const {getSSHHosts} = await import('@/ui/main-axios.ts');
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find(h => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch immediately when component mounts or hostConfig changes
|
||||
fetchLatestHostConfig();
|
||||
fetchLatestHostConfig();
|
||||
|
||||
// Also listen for SSH hosts changed event to refresh host config
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
// Import the getSSHHosts function to fetch updated host data
|
||||
const { getSSHHosts } = await import('@/ui/main-axios.ts');
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find(h => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh host config:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const {getSSHHosts} = await import('@/ui/main-axios.ts');
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find(h => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('ssh-hosts:changed', handleHostsChanged);
|
||||
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
window.addEventListener('ssh-hosts:changed', handleHostsChanged);
|
||||
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let intervalId: number | undefined;
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let intervalId: number | undefined;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(currentHostConfig?.id);
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setServerStatus('offline');
|
||||
}
|
||||
};
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(currentHostConfig?.id);
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setServerStatus('offline');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
try {
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!cancelled) setMetrics(data);
|
||||
} catch {
|
||||
if (!cancelled) setMetrics(null);
|
||||
}
|
||||
};
|
||||
const fetchMetrics = async () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
try {
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!cancelled) setMetrics(data);
|
||||
} catch {
|
||||
if (!cancelled) setMetrics(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (currentHostConfig?.id) {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
intervalId = window.setInterval(() => {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
}, 10_000);
|
||||
}
|
||||
if (currentHostConfig?.id) {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
intervalId = window.setInterval(() => {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [currentHostConfig?.id]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [currentHostConfig?.id]);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: '100%', width: '100%' }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
};
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? {opacity: isVisible ? 1 : 0, height: '100%', width: '100%'}
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
};
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
||||
: "bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden";
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
||||
: "bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden";
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
|
||||
{/* Top Header */}
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="font-bold text-lg">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||
<StatusIndicator/>
|
||||
</Status>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{currentHostConfig?.enableFileManager && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-semibold"
|
||||
onClick={() => {
|
||||
if (!currentHostConfig) return;
|
||||
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
|
||||
? currentHostConfig.name.trim()
|
||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
addTab({
|
||||
type: 'file_manager',
|
||||
title: titleBase,
|
||||
hostConfig: currentHostConfig,
|
||||
});
|
||||
}}
|
||||
>
|
||||
File Manager
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full"/>
|
||||
{/* Top Header */}
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="font-bold text-lg">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||
<StatusIndicator/>
|
||||
</Status>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{currentHostConfig?.enableFileManager && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-semibold"
|
||||
onClick={() => {
|
||||
if (!currentHostConfig) return;
|
||||
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
|
||||
? currentHostConfig.name.trim()
|
||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
addTab({
|
||||
type: 'file_manager',
|
||||
title: titleBase,
|
||||
hostConfig: currentHostConfig,
|
||||
});
|
||||
}}
|
||||
>
|
||||
File Manager
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full"/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] flex flex-row items-stretch">
|
||||
{/* CPU */}
|
||||
<div className="flex-1 min-w-0 px-2 py-2">
|
||||
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
|
||||
<Cpu/>
|
||||
{(() => {
|
||||
const pct = metrics?.cpu?.percent;
|
||||
const cores = metrics?.cpu?.cores;
|
||||
const la = metrics?.cpu?.load;
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const coresText = (typeof cores === 'number') ? `${cores} CPU(s)` : 'N/A CPU(s)';
|
||||
const laText = (la && la.length === 3)
|
||||
? `Avg: ${la[0].toFixed(2)}, ${la[1].toFixed(2)}, ${la[2].toFixed(2)}`
|
||||
: 'Avg: N/A';
|
||||
return `CPU Usage - ${pctText} of ${coresText} (${laText})`;
|
||||
})()}
|
||||
</h1>
|
||||
{/* Stats */}
|
||||
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] flex flex-row items-stretch">
|
||||
{/* CPU */}
|
||||
<div className="flex-1 min-w-0 px-2 py-2">
|
||||
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
|
||||
<Cpu/>
|
||||
{(() => {
|
||||
const pct = metrics?.cpu?.percent;
|
||||
const cores = metrics?.cpu?.cores;
|
||||
const la = metrics?.cpu?.load;
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const coresText = (typeof cores === 'number') ? `${cores} CPU(s)` : 'N/A CPU(s)';
|
||||
const laText = (la && la.length === 3)
|
||||
? `Avg: ${la[0].toFixed(2)}, ${la[1].toFixed(2)}, ${la[2].toFixed(2)}`
|
||||
: 'Avg: N/A';
|
||||
return `CPU Usage - ${pctText} of ${coresText} (${laText})`;
|
||||
})()}
|
||||
</h1>
|
||||
|
||||
<Progress value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0} />
|
||||
</div>
|
||||
<Progress value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0}/>
|
||||
</div>
|
||||
|
||||
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
||||
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
||||
|
||||
{/* Memory */}
|
||||
<div className="flex-1 min-w-0 px-2 py-2">
|
||||
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-1">
|
||||
<MemoryStick/>
|
||||
{(() => {
|
||||
const pct = metrics?.memory?.percent;
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A';
|
||||
const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A';
|
||||
return `Memory Usage - ${pctText} (${usedText} of ${totalText})`;
|
||||
})()}
|
||||
</h1>
|
||||
{/* Memory */}
|
||||
<div className="flex-1 min-w-0 px-2 py-2">
|
||||
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-1">
|
||||
<MemoryStick/>
|
||||
{(() => {
|
||||
const pct = metrics?.memory?.percent;
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A';
|
||||
const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A';
|
||||
return `Memory Usage - ${pctText} (${usedText} of ${totalText})`;
|
||||
})()}
|
||||
</h1>
|
||||
|
||||
<Progress value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0} />
|
||||
</div>
|
||||
<Progress value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0}/>
|
||||
</div>
|
||||
|
||||
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
||||
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
||||
|
||||
{/* HDD */}
|
||||
<div className="flex-1 min-w-0 px-2 py-2">
|
||||
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
|
||||
<HardDrive/>
|
||||
{(() => {
|
||||
const pct = metrics?.disk?.percent;
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const usedText = used ?? 'N/A';
|
||||
const totalText = total ?? 'N/A';
|
||||
return `HD Space - ${pctText} (${usedText} of ${totalText})`;
|
||||
})()}
|
||||
</h1>
|
||||
{/* HDD */}
|
||||
<div className="flex-1 min-w-0 px-2 py-2">
|
||||
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
|
||||
<HardDrive/>
|
||||
{(() => {
|
||||
const pct = metrics?.disk?.percent;
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const usedText = used ?? 'N/A';
|
||||
const totalText = total ?? 'N/A';
|
||||
return `HD Space - ${pctText} (${usedText} of ${totalText})`;
|
||||
})()}
|
||||
</h1>
|
||||
|
||||
<Progress value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0} />
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSH Tunnels */}
|
||||
{(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && (
|
||||
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] h-[360px] overflow-hidden flex flex-col min-h-0">
|
||||
<Tunnel filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/>
|
||||
</div>
|
||||
)}
|
||||
{/* SSH Tunnels */}
|
||||
{(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && (
|
||||
<div
|
||||
className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] h-[360px] overflow-hidden flex flex-col min-h-0">
|
||||
<Tunnel
|
||||
filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
||||
Have ideas for what should come next for server management? Share them on{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
||||
Have ideas for what should come next for server management? Share them on{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
{hostConfig, isVisible, splitScreen = false},
|
||||
ref
|
||||
) {
|
||||
console.log('TerminalComponent rendered with:', { hostConfig, isVisible, splitScreen });
|
||||
const {instance: terminal, ref: xtermRef} = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
@@ -27,20 +26,22 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
const [visible, setVisible] = useState(false);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
|
||||
// Debounce/stabilize resize notifications
|
||||
const lastSentSizeRef = useRef<{cols:number; rows:number} | null>(null);
|
||||
const pendingSizeRef = useRef<{cols:number; rows:number} | null>(null);
|
||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const DEBOUNCE_MS = 140;
|
||||
|
||||
useEffect(() => { isVisibleRef.current = isVisible; }, [isVisible]);
|
||||
useEffect(() => {
|
||||
isVisibleRef.current = isVisible;
|
||||
}, [isVisible]);
|
||||
|
||||
function hardRefresh() {
|
||||
try {
|
||||
if (terminal && typeof (terminal as any).refresh === 'function') {
|
||||
(terminal as any).refresh(0, terminal.rows - 1);
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNotify(cols: number, rows: number) {
|
||||
@@ -85,7 +86,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
scheduleNotify(cols, rows);
|
||||
hardRefresh();
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (_) {
|
||||
}
|
||||
},
|
||||
refresh: () => hardRefresh(),
|
||||
}), [terminal]);
|
||||
@@ -119,7 +121,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (_) {
|
||||
}
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
@@ -127,7 +130,11 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
try { document.execCommand('copy'); } finally { document.body.removeChild(textarea); }
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
async function readTextFromClipboard(): Promise<string> {
|
||||
@@ -135,7 +142,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (_) {
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -148,7 +156,7 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
scrollback: 10000,
|
||||
fontSize: 14,
|
||||
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
|
||||
theme: { background: '#18181b', foreground: '#f7f7f7' },
|
||||
theme: {background: '#18181b', foreground: '#f7f7f7'},
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
windowsMode: false,
|
||||
@@ -175,16 +183,21 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
const element = xtermRef.current;
|
||||
const handleContextMenu = async (e: MouseEvent) => {
|
||||
if (!getUseRightClickCopyPaste()) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (terminal.hasSelection()) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) { await writeTextToClipboard(selection); terminal.clearSelection(); }
|
||||
if (selection) {
|
||||
await writeTextToClipboard(selection);
|
||||
terminal.clearSelection();
|
||||
}
|
||||
} else {
|
||||
const pasteText = await readTextFromClipboard();
|
||||
if (pasteText) terminal.paste(pasteText);
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (_) {
|
||||
}
|
||||
};
|
||||
element?.addEventListener('contextmenu', handleContextMenu);
|
||||
|
||||
@@ -221,8 +234,14 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
||||
terminal.onData((data) => { ws.send(JSON.stringify({type: 'input', data})); });
|
||||
pingIntervalRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({type: 'ping'})); } }, 30000);
|
||||
terminal.onData((data) => {
|
||||
ws.send(JSON.stringify({type: 'input', data}));
|
||||
});
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({type: 'ping'}));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
@@ -230,13 +249,21 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'data') terminal.write(msg.data);
|
||||
else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
||||
else if (msg.type === 'connected') { }
|
||||
else if (msg.type === 'disconnected') { wasDisconnectedBySSH.current = true; terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`); }
|
||||
} catch (error) { console.error('Error parsing WebSocket message:', error); }
|
||||
else if (msg.type === 'connected') {
|
||||
} else if (msg.type === 'disconnected') {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => { if (!wasDisconnectedBySSH.current) terminal.writeln('\r\n[Connection closed]'); });
|
||||
ws.addEventListener('error', () => { terminal.writeln('\r\n[Connection error]'); });
|
||||
ws.addEventListener('close', () => {
|
||||
if (!wasDisconnectedBySSH.current) terminal.writeln('\r\n[Connection closed]');
|
||||
});
|
||||
ws.addEventListener('error', () => {
|
||||
terminal.writeln('\r\n[Connection error]');
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
|
||||
@@ -245,7 +272,10 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
element?.removeEventListener('contextmenu', handleContextMenu);
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; }
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig]);
|
||||
@@ -260,7 +290,6 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
// Ensure a fit when split mode toggles to account for new pane geometry
|
||||
useEffect(() => {
|
||||
if (!fitAddonRef.current) return;
|
||||
setTimeout(() => {
|
||||
@@ -271,7 +300,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
||||
}, [splitScreen]);
|
||||
|
||||
return (
|
||||
<div ref={xtermRef} className="h-full w-full m-1" style={{ opacity: visible && isVisible ? 1 : 0, overflow: 'hidden' }} />
|
||||
<div ref={xtermRef} className="h-full w-full m-1"
|
||||
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}/>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -48,8 +48,7 @@ interface SSHTunnelProps {
|
||||
filterHostKey?: string;
|
||||
}
|
||||
|
||||
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
// Keep full list for endpoint lookups; keep a separate visible list for UI
|
||||
export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
|
||||
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
||||
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
|
||||
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
|
||||
@@ -86,7 +85,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
})
|
||||
: hostsData;
|
||||
|
||||
// Silent update: only set state if meaningful changes
|
||||
const prev = prevVisibleHostRef.current;
|
||||
const curr = nextVisible[0] ?? null;
|
||||
let changed = false;
|
||||
@@ -120,7 +118,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 5000);
|
||||
|
||||
// Refresh immediately when hosts are changed elsewhere (e.g., SSH Manager)
|
||||
const handleHostsChanged = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
|
||||
@@ -76,17 +76,17 @@ interface SSHTunnelObjectProps {
|
||||
tunnelActions: Record<string, boolean>;
|
||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||
compact?: boolean;
|
||||
bare?: boolean; // when true, render without Card wrapper/background
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
export function TunnelObject({
|
||||
host,
|
||||
tunnelStatuses,
|
||||
tunnelActions,
|
||||
onTunnelAction,
|
||||
compact = false,
|
||||
bare = false
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
host,
|
||||
tunnelStatuses,
|
||||
tunnelActions,
|
||||
onTunnelAction,
|
||||
compact = false,
|
||||
bare = false
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
|
||||
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||
@@ -168,7 +168,6 @@ export function TunnelObject({
|
||||
if (bare) {
|
||||
return (
|
||||
<div className="w-full min-w-0">
|
||||
{/* Tunnel Connections (bare) */}
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
@@ -187,7 +186,6 @@ export function TunnelObject({
|
||||
return (
|
||||
<div key={tunnelIndex}
|
||||
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
||||
{/* Tunnel Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
|
||||
@@ -203,7 +201,6 @@ export function TunnelObject({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
|
||||
{/* Action Buttons */}
|
||||
{!isActionLoading ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{isConnected ? (
|
||||
@@ -255,7 +252,6 @@ export function TunnelObject({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error/Status Reason */}
|
||||
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
||||
<div
|
||||
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
@@ -280,7 +276,6 @@ export function TunnelObject({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Retry Info */}
|
||||
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
|
||||
<div
|
||||
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
@@ -313,7 +308,6 @@ export function TunnelObject({
|
||||
return (
|
||||
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
||||
<div className="p-4">
|
||||
{/* Host Header */}
|
||||
{!compact && (
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
@@ -330,7 +324,6 @@ export function TunnelObject({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{!compact && host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{host.tags.slice(0, 3).map((tag, index) => (
|
||||
@@ -349,7 +342,6 @@ export function TunnelObject({
|
||||
|
||||
{!compact && <Separator className="mb-3"/>}
|
||||
|
||||
{/* Tunnel Connections */}
|
||||
<div className="space-y-3">
|
||||
{!compact && (
|
||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||
@@ -374,7 +366,6 @@ export function TunnelObject({
|
||||
return (
|
||||
<div key={tunnelIndex}
|
||||
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
||||
{/* Tunnel Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
|
||||
@@ -390,7 +381,6 @@ export function TunnelObject({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Action Buttons */}
|
||||
{!isActionLoading && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{isConnected ? (
|
||||
@@ -443,7 +433,6 @@ export function TunnelObject({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error/Status Reason */}
|
||||
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
||||
<div
|
||||
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
@@ -468,7 +457,6 @@ export function TunnelObject({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Retry Info */}
|
||||
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
|
||||
<div
|
||||
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
|
||||
@@ -47,12 +47,11 @@ interface SSHTunnelViewerProps {
|
||||
}
|
||||
|
||||
export function TunnelViewer({
|
||||
hosts = [],
|
||||
tunnelStatuses = {},
|
||||
tunnelActions = {},
|
||||
onTunnelAction
|
||||
}: SSHTunnelViewerProps): React.ReactElement {
|
||||
// Single-host view: use first host if present
|
||||
hosts = [],
|
||||
tunnelStatuses = {},
|
||||
tunnelActions = {},
|
||||
onTunnelAction
|
||||
}: SSHTunnelViewerProps): React.ReactElement {
|
||||
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
|
||||
|
||||
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
|
||||
@@ -60,7 +59,8 @@ export function TunnelViewer({
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">No SSH Tunnels</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.
|
||||
Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel
|
||||
connections.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -72,7 +72,8 @@ export function TunnelViewer({
|
||||
<h1 className="text-xl font-semibold text-foreground">SSH Tunnels</h1>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto pr-1">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{activeHost.tunnelConnections.map((t, idx) => (
|
||||
<TunnelObject
|
||||
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
|
||||
|
||||
Reference in New Issue
Block a user