Format code
This commit is contained in:
30
src/App.tsx
30
src/App.tsx
@@ -49,7 +49,6 @@ function AppContent() {
|
|||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
setUsername(null);
|
setUsername(null);
|
||||||
// Clear invalid JWT
|
|
||||||
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
})
|
})
|
||||||
.finally(() => setAuthLoading(false));
|
.finally(() => setAuthLoading(false));
|
||||||
@@ -85,27 +84,19 @@ function AppContent() {
|
|||||||
setUsername(authData.username)
|
setUsername(authData.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine what to show based on current tab
|
|
||||||
const currentTabData = tabs.find(tab => tab.id === currentTab);
|
const currentTabData = tabs.find(tab => tab.id === currentTab);
|
||||||
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager';
|
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager';
|
||||||
const showHome = currentTabData?.type === 'home';
|
const showHome = currentTabData?.type === 'home';
|
||||||
const showSshManager = currentTabData?.type === 'ssh_manager';
|
const showSshManager = currentTabData?.type === 'ssh_manager';
|
||||||
const showAdmin = currentTabData?.type === 'admin';
|
const showAdmin = currentTabData?.type === 'admin';
|
||||||
|
|
||||||
console.log('Current tab:', currentTab);
|
|
||||||
console.log('Current tab data:', currentTabData);
|
|
||||||
console.log('Show terminal view:', showTerminalView);
|
|
||||||
console.log('All tabs:', tabs);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Enhanced background overlay - detailed pattern when not authenticated */}
|
|
||||||
{!isAuthenticated && !authLoading && (
|
{!isAuthenticated && !authLoading && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-gradient-to-br from-background via-muted/20 to-background z-[9999]"
|
className="fixed inset-0 bg-gradient-to-br from-background via-muted/20 to-background z-[9999]"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{/* Diagonal stripes pattern */}
|
|
||||||
<div className="absolute inset-0 opacity-20">
|
<div className="absolute inset-0 opacity-20">
|
||||||
<div className="absolute inset-0" style={{
|
<div className="absolute inset-0" style={{
|
||||||
backgroundImage: `repeating-linear-gradient(
|
backgroundImage: `repeating-linear-gradient(
|
||||||
@@ -117,8 +108,7 @@ function AppContent() {
|
|||||||
)`
|
)`
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtle grid pattern */}
|
|
||||||
<div className="absolute inset-0 opacity-10">
|
<div className="absolute inset-0 opacity-10">
|
||||||
<div className="absolute inset-0" style={{
|
<div className="absolute inset-0" style={{
|
||||||
backgroundImage: `linear-gradient(hsl(var(--border) / 0.3) 1px, transparent 1px),
|
backgroundImage: `linear-gradient(hsl(var(--border) / 0.3) 1px, transparent 1px),
|
||||||
@@ -126,13 +116,11 @@ function AppContent() {
|
|||||||
backgroundSize: '40px 40px'
|
backgroundSize: '40px 40px'
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Radial gradient overlay */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-background/60" />
|
<div className="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-background/60" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show login form directly when not authenticated */}
|
|
||||||
{!isAuthenticated && !authLoading && (
|
{!isAuthenticated && !authLoading && (
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
|
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
|
||||||
<Homepage
|
<Homepage
|
||||||
@@ -144,8 +132,7 @@ function AppContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show sidebar layout only when authenticated */}
|
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<LeftSidebar
|
<LeftSidebar
|
||||||
onSelectView={handleSelectView}
|
onSelectView={handleSelectView}
|
||||||
@@ -153,7 +140,6 @@ function AppContent() {
|
|||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
username={username}
|
username={username}
|
||||||
>
|
>
|
||||||
{/* Always render TerminalView to maintain terminal persistence */}
|
|
||||||
<div
|
<div
|
||||||
className="h-screen w-full"
|
className="h-screen w-full"
|
||||||
style={{
|
style={{
|
||||||
@@ -167,8 +153,7 @@ function AppContent() {
|
|||||||
>
|
>
|
||||||
<AppView isTopbarOpen={isTopbarOpen} />
|
<AppView isTopbarOpen={isTopbarOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Always render Homepage to keep it mounted */}
|
|
||||||
<div
|
<div
|
||||||
className="h-screen w-full"
|
className="h-screen w-full"
|
||||||
style={{
|
style={{
|
||||||
@@ -189,7 +174,6 @@ function AppContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Always render SSH Manager but toggle visibility for persistence */}
|
|
||||||
<div
|
<div
|
||||||
className="h-screen w-full"
|
className="h-screen w-full"
|
||||||
style={{
|
style={{
|
||||||
@@ -204,7 +188,6 @@ function AppContent() {
|
|||||||
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
|
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin Settings tab */}
|
|
||||||
<div
|
<div
|
||||||
className="h-screen w-full"
|
className="h-screen w-full"
|
||||||
style={{
|
style={{
|
||||||
@@ -218,8 +201,7 @@ function AppContent() {
|
|||||||
>
|
>
|
||||||
<AdminSettings isTopbarOpen={isTopbarOpen} />
|
<AdminSettings isTopbarOpen={isTopbarOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legacy views removed; tab system controls main content */}
|
|
||||||
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
|
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
|
||||||
</LeftSidebar>
|
</LeftSidebar>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -41,94 +41,340 @@ const dbPath = path.join(dataDir, 'db.sqlite');
|
|||||||
const sqlite = new Database(dbPath);
|
const sqlite = new Database(dbPath);
|
||||||
|
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users
|
||||||
id TEXT PRIMARY KEY,
|
(
|
||||||
username TEXT NOT NULL,
|
id
|
||||||
password_hash TEXT NOT NULL,
|
TEXT
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
PRIMARY
|
||||||
|
KEY,
|
||||||
is_oidc INTEGER NOT NULL DEFAULT 0,
|
username
|
||||||
client_id TEXT NOT NULL,
|
TEXT
|
||||||
client_secret TEXT NOT NULL,
|
NOT
|
||||||
issuer_url TEXT NOT NULL,
|
NULL,
|
||||||
authorization_url TEXT NOT NULL,
|
password_hash
|
||||||
token_url TEXT NOT NULL,
|
TEXT
|
||||||
redirect_uri TEXT,
|
NOT
|
||||||
identifier_path TEXT NOT NULL,
|
NULL,
|
||||||
name_path TEXT NOT NULL,
|
is_admin
|
||||||
scopes TEXT NOT NULL
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
0,
|
||||||
|
|
||||||
|
is_oidc
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
0,
|
||||||
|
client_id
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
client_secret
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
issuer_url
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
authorization_url
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
token_url
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
redirect_uri
|
||||||
|
TEXT,
|
||||||
|
identifier_path
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
name_path
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
scopes
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings
|
||||||
key TEXT PRIMARY KEY,
|
(
|
||||||
value TEXT NOT NULL
|
key
|
||||||
|
TEXT
|
||||||
|
PRIMARY
|
||||||
|
KEY,
|
||||||
|
value
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
CREATE TABLE IF NOT EXISTS ssh_data
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
(
|
||||||
user_id TEXT NOT NULL,
|
id
|
||||||
name TEXT,
|
INTEGER
|
||||||
ip TEXT NOT NULL,
|
PRIMARY
|
||||||
port INTEGER NOT NULL,
|
KEY
|
||||||
username TEXT NOT NULL,
|
AUTOINCREMENT,
|
||||||
folder TEXT,
|
user_id
|
||||||
tags TEXT,
|
TEXT
|
||||||
pin INTEGER NOT NULL DEFAULT 0,
|
NOT
|
||||||
auth_type TEXT NOT NULL,
|
NULL,
|
||||||
password TEXT,
|
name
|
||||||
key TEXT,
|
TEXT,
|
||||||
key_password TEXT,
|
ip
|
||||||
key_type TEXT,
|
TEXT
|
||||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
NOT
|
||||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
NULL,
|
||||||
tunnel_connections TEXT,
|
port
|
||||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
INTEGER
|
||||||
default_path TEXT,
|
NOT
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
NULL,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
username
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
TEXT
|
||||||
);
|
NOT
|
||||||
|
NULL,
|
||||||
|
folder
|
||||||
|
TEXT,
|
||||||
|
tags
|
||||||
|
TEXT,
|
||||||
|
pin
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
0,
|
||||||
|
auth_type
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
password
|
||||||
|
TEXT,
|
||||||
|
key
|
||||||
|
TEXT,
|
||||||
|
key_password
|
||||||
|
TEXT,
|
||||||
|
key_type
|
||||||
|
TEXT,
|
||||||
|
enable_terminal
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
1,
|
||||||
|
enable_tunnel
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
1,
|
||||||
|
tunnel_connections
|
||||||
|
TEXT,
|
||||||
|
enable_file_manager
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
1,
|
||||||
|
default_path
|
||||||
|
TEXT,
|
||||||
|
created_at
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
updated_at
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN
|
||||||
|
KEY
|
||||||
|
(
|
||||||
|
user_id
|
||||||
|
) REFERENCES users
|
||||||
|
(
|
||||||
|
id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS file_manager_recent (
|
CREATE TABLE IF NOT EXISTS file_manager_recent
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
(
|
||||||
user_id TEXT NOT NULL,
|
id
|
||||||
host_id INTEGER NOT NULL,
|
INTEGER
|
||||||
name TEXT NOT NULL,
|
PRIMARY
|
||||||
path TEXT NOT NULL,
|
KEY
|
||||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
AUTOINCREMENT,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
user_id
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
|
TEXT
|
||||||
);
|
NOT
|
||||||
|
NULL,
|
||||||
|
host_id
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
name
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
path
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
last_opened
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN
|
||||||
|
KEY
|
||||||
|
(
|
||||||
|
user_id
|
||||||
|
) REFERENCES users
|
||||||
|
(
|
||||||
|
id
|
||||||
|
),
|
||||||
|
FOREIGN KEY
|
||||||
|
(
|
||||||
|
host_id
|
||||||
|
) REFERENCES ssh_data
|
||||||
|
(
|
||||||
|
id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS file_manager_pinned (
|
CREATE TABLE IF NOT EXISTS file_manager_pinned
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
(
|
||||||
user_id TEXT NOT NULL,
|
id
|
||||||
host_id INTEGER NOT NULL,
|
INTEGER
|
||||||
name TEXT NOT NULL,
|
PRIMARY
|
||||||
path TEXT NOT NULL,
|
KEY
|
||||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
AUTOINCREMENT,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
user_id
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
|
TEXT
|
||||||
);
|
NOT
|
||||||
|
NULL,
|
||||||
|
host_id
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
name
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
path
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
pinned_at
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN
|
||||||
|
KEY
|
||||||
|
(
|
||||||
|
user_id
|
||||||
|
) REFERENCES users
|
||||||
|
(
|
||||||
|
id
|
||||||
|
),
|
||||||
|
FOREIGN KEY
|
||||||
|
(
|
||||||
|
host_id
|
||||||
|
) REFERENCES ssh_data
|
||||||
|
(
|
||||||
|
id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
|
CREATE TABLE IF NOT EXISTS file_manager_shortcuts
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
(
|
||||||
user_id TEXT NOT NULL,
|
id
|
||||||
host_id INTEGER NOT NULL,
|
INTEGER
|
||||||
name TEXT NOT NULL,
|
PRIMARY
|
||||||
path TEXT NOT NULL,
|
KEY
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
AUTOINCREMENT,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
user_id
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
|
TEXT
|
||||||
);
|
NOT
|
||||||
|
NULL,
|
||||||
|
host_id
|
||||||
|
INTEGER
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
name
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
path
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
created_at
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN
|
||||||
|
KEY
|
||||||
|
(
|
||||||
|
user_id
|
||||||
|
) REFERENCES users
|
||||||
|
(
|
||||||
|
id
|
||||||
|
),
|
||||||
|
FOREIGN KEY
|
||||||
|
(
|
||||||
|
host_id
|
||||||
|
) REFERENCES ssh_data
|
||||||
|
(
|
||||||
|
id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
CREATE TABLE IF NOT EXISTS dismissed_alerts
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
(
|
||||||
user_id TEXT NOT NULL,
|
id
|
||||||
alert_id TEXT NOT NULL,
|
INTEGER
|
||||||
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
PRIMARY
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
KEY
|
||||||
);
|
AUTOINCREMENT,
|
||||||
|
user_id
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
alert_id
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL,
|
||||||
|
dismissed_at
|
||||||
|
TEXT
|
||||||
|
NOT
|
||||||
|
NULL
|
||||||
|
DEFAULT
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN
|
||||||
|
KEY
|
||||||
|
(
|
||||||
|
user_id
|
||||||
|
) REFERENCES users
|
||||||
|
(
|
||||||
|
id
|
||||||
|
)
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
|
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
|
||||||
@@ -162,7 +408,7 @@ const migrateSchema = () => {
|
|||||||
logger.info('Removed redirect_uri column from users table');
|
logger.info('Removed redirect_uri column from users table');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
|
|
||||||
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
|
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
|
||||||
addColumnIfNotExists('users', 'name_path', 'TEXT');
|
addColumnIfNotExists('users', 'name_path', 'TEXT');
|
||||||
addColumnIfNotExists('users', 'scopes', 'TEXT');
|
addColumnIfNotExists('users', 'scopes', 'TEXT');
|
||||||
|
|||||||
@@ -90,10 +90,10 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
|||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
return cachedData;
|
return cachedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
@@ -108,13 +108,13 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
|||||||
const alerts: TermixAlert[] = await response.json() as TermixAlert[];
|
const alerts: TermixAlert[] = await response.json() as TermixAlert[];
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const validAlerts = alerts.filter(alert => {
|
const validAlerts = alerts.filter(alert => {
|
||||||
const expiryDate = new Date(alert.expiresAt);
|
const expiryDate = new Date(alert.expiresAt);
|
||||||
const isValid = expiryDate > now;
|
const isValid = expiryDate > now;
|
||||||
return isValid;
|
return isValid;
|
||||||
});
|
});
|
||||||
|
|
||||||
alertCache.set(cacheKey, validAlerts);
|
alertCache.set(cacheKey, validAlerts);
|
||||||
return validAlerts;
|
return validAlerts;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -146,11 +146,11 @@ router.get('/', async (req, res) => {
|
|||||||
router.get('/user/:userId', async (req, res) => {
|
router.get('/user/:userId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {userId} = req.params;
|
const {userId} = req.params;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return res.status(400).json({error: 'User ID is required'});
|
return res.status(400).json({error: 'User ID is required'});
|
||||||
}
|
}
|
||||||
|
|
||||||
const allAlerts = await fetchAlertsFromGitHub();
|
const allAlerts = await fetchAlertsFromGitHub();
|
||||||
|
|
||||||
const dismissedAlertRecords = await db
|
const dismissedAlertRecords = await db
|
||||||
@@ -215,7 +215,7 @@ router.post('/dismiss', async (req, res) => {
|
|||||||
router.get('/dismissed/:userId', async (req, res) => {
|
router.get('/dismissed/:userId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {userId} = req.params;
|
const {userId} = req.params;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return res.status(400).json({error: 'User ID is required'});
|
return res.status(400).json({error: 'User ID is required'});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -695,7 +695,7 @@ router.delete('/file_manager/shortcuts', authenticateJWT, async (req: Request, r
|
|||||||
// POST /ssh/bulk-import
|
// POST /ssh/bulk-import
|
||||||
router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => {
|
router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const { hosts } = req.body;
|
const {hosts} = req.body;
|
||||||
|
|
||||||
if (!Array.isArray(hosts) || hosts.length === 0) {
|
if (!Array.isArray(hosts) || hosts.length === 0) {
|
||||||
logger.warn('Invalid bulk import data - hosts array is required and must not be empty');
|
logger.warn('Invalid bulk import data - hosts array is required and must not be empty');
|
||||||
@@ -715,7 +715,7 @@ router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response)
|
|||||||
|
|
||||||
for (let i = 0; i < hosts.length; i++) {
|
for (let i = 0; i < hosts.length; i++) {
|
||||||
const hostData = hosts[i];
|
const hostData = hosts[i];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) {
|
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) {
|
||||||
results.failed++;
|
results.failed++;
|
||||||
|
|||||||
@@ -10,13 +10,9 @@ app.use(cors({
|
|||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
allowedHeaders: ['Content-Type', 'Authorization']
|
allowedHeaders: ['Content-Type', 'Authorization']
|
||||||
}));
|
}));
|
||||||
|
app.use(express.json({limit: '100mb'}));
|
||||||
// Increase JSON body parser limit for larger file uploads
|
app.use(express.urlencoded({limit: '100mb', extended: true}));
|
||||||
app.use(express.json({ limit: '100mb' }));
|
app.use(express.raw({limit: '200mb', type: 'application/octet-stream'}));
|
||||||
app.use(express.urlencoded({ limit: '100mb', extended: true }));
|
|
||||||
|
|
||||||
// Add raw body parser for very large files
|
|
||||||
app.use(express.raw({ limit: '200mb', type: 'application/octet-stream' }));
|
|
||||||
|
|
||||||
const sshIconSymbol = '📁';
|
const sshIconSymbol = '📁';
|
||||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
@@ -314,9 +310,8 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: 'SSH command timed out'});
|
res.status(500).json({error: 'SSH command timed out'});
|
||||||
}
|
}
|
||||||
}, 60000); // Increased timeout to 60 seconds
|
}, 60000);
|
||||||
|
|
||||||
// Try SFTP first, fallback to command line if it fails
|
|
||||||
const trySFTP = () => {
|
const trySFTP = () => {
|
||||||
try {
|
try {
|
||||||
sshConn.client.sftp((err, sftp) => {
|
sshConn.client.sftp((err, sftp) => {
|
||||||
@@ -326,7 +321,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert content to buffer
|
|
||||||
let fileBuffer;
|
let fileBuffer;
|
||||||
try {
|
try {
|
||||||
if (typeof content === 'string') {
|
if (typeof content === 'string') {
|
||||||
@@ -345,9 +339,8 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create write stream with error handling
|
|
||||||
const writeStream = sftp.createWriteStream(filePath);
|
const writeStream = sftp.createWriteStream(filePath);
|
||||||
|
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
let hasFinished = false;
|
let hasFinished = false;
|
||||||
|
|
||||||
@@ -378,7 +371,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Write the buffer to the stream
|
|
||||||
try {
|
try {
|
||||||
writeStream.write(fileBuffer);
|
writeStream.write(fileBuffer);
|
||||||
writeStream.end();
|
writeStream.end();
|
||||||
@@ -395,14 +387,13 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fallback method using command line
|
|
||||||
const tryFallbackMethod = () => {
|
const tryFallbackMethod = () => {
|
||||||
try {
|
try {
|
||||||
const base64Content = Buffer.from(content, 'utf8').toString('base64');
|
const base64Content = Buffer.from(content, 'utf8').toString('base64');
|
||||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
|
const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
|
||||||
|
|
||||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
clearTimeout(commandTimeout);
|
||||||
@@ -426,7 +417,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
clearTimeout(commandTimeout);
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
logger.success(`File written successfully via fallback: ${filePath}`);
|
logger.success(`File written successfully via fallback: ${filePath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
@@ -457,11 +448,9 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start with SFTP
|
|
||||||
trySFTP();
|
trySFTP();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload file route
|
|
||||||
app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
||||||
const {sessionId, path: filePath, content, fileName} = req.body;
|
const {sessionId, path: filePath, content, fileName} = req.body;
|
||||||
const sshConn = sshSessions[sessionId];
|
const sshConn = sshSessions[sessionId];
|
||||||
@@ -488,9 +477,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: 'SSH command timed out'});
|
res.status(500).json({error: 'SSH command timed out'});
|
||||||
}
|
}
|
||||||
}, 60000); // Increased timeout to 60 seconds
|
}, 60000);
|
||||||
|
|
||||||
// Try SFTP first, fallback to command line if it fails
|
|
||||||
const trySFTP = () => {
|
const trySFTP = () => {
|
||||||
try {
|
try {
|
||||||
sshConn.client.sftp((err, sftp) => {
|
sshConn.client.sftp((err, sftp) => {
|
||||||
@@ -500,7 +488,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert content to buffer
|
|
||||||
let fileBuffer;
|
let fileBuffer;
|
||||||
try {
|
try {
|
||||||
if (typeof content === 'string') {
|
if (typeof content === 'string') {
|
||||||
@@ -519,9 +506,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create write stream with error handling
|
|
||||||
const writeStream = sftp.createWriteStream(fullPath);
|
const writeStream = sftp.createWriteStream(fullPath);
|
||||||
|
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
let hasFinished = false;
|
let hasFinished = false;
|
||||||
|
|
||||||
@@ -552,7 +538,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Write the buffer to the stream
|
|
||||||
try {
|
try {
|
||||||
writeStream.write(fileBuffer);
|
writeStream.write(fileBuffer);
|
||||||
writeStream.end();
|
writeStream.end();
|
||||||
@@ -569,26 +554,23 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fallback method using command line with chunked approach
|
|
||||||
const tryFallbackMethod = () => {
|
const tryFallbackMethod = () => {
|
||||||
try {
|
try {
|
||||||
// Convert content to base64 and split into smaller chunks if needed
|
|
||||||
const base64Content = Buffer.from(content, 'utf8').toString('base64');
|
const base64Content = Buffer.from(content, 'utf8').toString('base64');
|
||||||
const chunkSize = 1000000; // 1MB chunks
|
const chunkSize = 1000000;
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
|
|
||||||
for (let i = 0; i < base64Content.length; i += chunkSize) {
|
for (let i = 0; i < base64Content.length; i += chunkSize) {
|
||||||
chunks.push(base64Content.slice(i, i + chunkSize));
|
chunks.push(base64Content.slice(i, i + chunkSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chunks.length === 1) {
|
if (chunks.length === 1) {
|
||||||
// Single chunk - use simple approach
|
|
||||||
const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
|
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
|
||||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
|
const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
|
||||||
|
|
||||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
clearTimeout(commandTimeout);
|
||||||
@@ -612,7 +594,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
clearTimeout(commandTimeout);
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
logger.success(`File uploaded successfully via fallback: ${fullPath}`);
|
logger.success(`File uploaded successfully via fallback: ${fullPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
@@ -635,17 +617,16 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Multiple chunks - use chunked approach
|
|
||||||
const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
|
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
|
||||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
let writeCommand = `> '${escapedPath}'`; // Start with empty file
|
let writeCommand = `> '${escapedPath}'`;
|
||||||
|
|
||||||
chunks.forEach((chunk, index) => {
|
chunks.forEach((chunk, index) => {
|
||||||
writeCommand += ` && echo '${chunk}' | base64 -d >> '${escapedPath}'`;
|
writeCommand += ` && echo '${chunk}' | base64 -d >> '${escapedPath}'`;
|
||||||
});
|
});
|
||||||
|
|
||||||
writeCommand += ` && echo "SUCCESS"`;
|
writeCommand += ` && echo "SUCCESS"`;
|
||||||
|
|
||||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||||
@@ -671,7 +652,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
clearTimeout(commandTimeout);
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
logger.success(`File uploaded successfully via chunked fallback: ${fullPath}`);
|
logger.success(`File uploaded successfully via chunked fallback: ${fullPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
@@ -703,11 +684,9 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start with SFTP
|
|
||||||
trySFTP();
|
trySFTP();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create new file route
|
|
||||||
app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
||||||
const {sessionId, path: filePath, fileName, content = ''} = req.body;
|
const {sessionId, path: filePath, fileName, content = ''} = req.body;
|
||||||
const sshConn = sshSessions[sessionId];
|
const sshConn = sshSessions[sessionId];
|
||||||
@@ -804,7 +783,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create folder route
|
|
||||||
app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
||||||
const {sessionId, path: folderPath, folderName} = req.body;
|
const {sessionId, path: folderPath, folderName} = req.body;
|
||||||
const sshConn = sshSessions[sessionId];
|
const sshConn = sshSessions[sessionId];
|
||||||
@@ -901,7 +879,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete file/folder route
|
|
||||||
app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
||||||
const {sessionId, path: itemPath, isDirectory} = req.body;
|
const {sessionId, path: itemPath, isDirectory} = req.body;
|
||||||
const sshConn = sshSessions[sessionId];
|
const sshConn = sshSessions[sessionId];
|
||||||
@@ -930,7 +907,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
|||||||
}
|
}
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
const deleteCommand = isDirectory
|
const deleteCommand = isDirectory
|
||||||
? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0`
|
? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0`
|
||||||
: `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
: `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||||
|
|
||||||
@@ -999,7 +976,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rename file/folder route
|
|
||||||
app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
||||||
const {sessionId, oldPath, newName} = req.body;
|
const {sessionId, oldPath, newName} = req.body;
|
||||||
const sshConn = sshSessions[sessionId];
|
const sshConn = sshSessions[sessionId];
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import chalk from 'chalk';
|
|||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { Client, type ConnectConfig } from 'ssh2';
|
import {Client, type ConnectConfig} from 'ssh2';
|
||||||
|
|
||||||
type HostRecord = {
|
type HostRecord = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -21,7 +21,7 @@ type HostStatus = 'online' | 'offline';
|
|||||||
|
|
||||||
type StatusEntry = {
|
type StatusEntry = {
|
||||||
status: HostStatus;
|
status: HostStatus;
|
||||||
lastChecked: string; // ISO string
|
lastChecked: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -30,7 +30,6 @@ app.use(cors({
|
|||||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
allowedHeaders: ['Content-Type', 'Authorization']
|
allowedHeaders: ['Content-Type', 'Authorization']
|
||||||
}));
|
}));
|
||||||
// Fallback explicit CORS headers to cover any edge cases
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.header('Access-Control-Allow-Origin', '*');
|
res.header('Access-Control-Allow-Origin', '*');
|
||||||
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
@@ -42,7 +41,6 @@ app.use((req, res, next) => {
|
|||||||
});
|
});
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Logger (customized for Server Stats)
|
|
||||||
const statsIconSymbol = '📡';
|
const statsIconSymbol = '📡';
|
||||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||||
@@ -69,15 +67,13 @@ const logger = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// In-memory state of last known statuses
|
|
||||||
const hostStatuses: Map<number, StatusEntry> = new Map();
|
const hostStatuses: Map<number, StatusEntry> = new Map();
|
||||||
|
|
||||||
// Fetch all hosts from the database service (internal endpoint, no JWT)
|
|
||||||
async function fetchAllHosts(): Promise<HostRecord[]> {
|
async function fetchAllHosts(): Promise<HostRecord[]> {
|
||||||
const url = 'http://localhost:8081/ssh/db/host/internal';
|
const url = 'http://localhost:8081/ssh/db/host/internal';
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
headers: { 'x-internal-request': '1' }
|
headers: {'x-internal-request': '1'}
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
|
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
|
||||||
@@ -112,9 +108,7 @@ function buildSshConfig(host: HostRecord): ConnectConfig {
|
|||||||
port: host.port || 22,
|
port: host.port || 22,
|
||||||
username: host.username || 'root',
|
username: host.username || 'root',
|
||||||
readyTimeout: 10_000,
|
readyTimeout: 10_000,
|
||||||
algorithms: {
|
algorithms: {}
|
||||||
// keep defaults minimal to avoid negotiation issues
|
|
||||||
}
|
|
||||||
} as ConnectConfig;
|
} as ConnectConfig;
|
||||||
|
|
||||||
if (host.authType === 'password') {
|
if (host.authType === 'password') {
|
||||||
@@ -138,7 +132,10 @@ async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Pr
|
|||||||
const onError = (err: Error) => {
|
const onError = (err: Error) => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true;
|
settled = true;
|
||||||
try { client.end(); } catch {}
|
try {
|
||||||
|
client.end();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -148,7 +145,10 @@ async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Pr
|
|||||||
const result = await fn(client);
|
const result = await fn(client);
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true;
|
settled = true;
|
||||||
try { client.end(); } catch {}
|
try {
|
||||||
|
client.end();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
resolve(result);
|
resolve(result);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -166,16 +166,20 @@ async function withSshConnection<T>(host: HostRecord, fn: (client: Client) => Pr
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function execCommand(client: Client, command: string): Promise<{ stdout: string; stderr: string; code: number | null; }> {
|
function execCommand(client: Client, command: string): Promise<{
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
code: number | null;
|
||||||
|
}> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
client.exec(command, { pty: false }, (err, stream) => {
|
client.exec(command, {pty: false}, (err, stream) => {
|
||||||
if (err) return reject(err);
|
if (err) return reject(err);
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
let exitCode: number | null = null;
|
let exitCode: number | null = null;
|
||||||
stream.on('close', (code: number | undefined) => {
|
stream.on('close', (code: number | undefined) => {
|
||||||
exitCode = typeof code === 'number' ? code : null;
|
exitCode = typeof code === 'number' ? code : null;
|
||||||
resolve({ stdout, stderr, code: exitCode });
|
resolve({stdout, stderr, code: exitCode});
|
||||||
}).on('data', (data: Buffer) => {
|
}).on('data', (data: Buffer) => {
|
||||||
stdout += data.toString('utf8');
|
stdout += data.toString('utf8');
|
||||||
}).stderr.on('data', (data: Buffer) => {
|
}).stderr.on('data', (data: Buffer) => {
|
||||||
@@ -190,9 +194,9 @@ function parseCpuLine(cpuLine: string): { total: number; idle: number } | undefi
|
|||||||
if (parts[0] !== 'cpu') return undefined;
|
if (parts[0] !== 'cpu') return undefined;
|
||||||
const nums = parts.slice(1).map(n => Number(n)).filter(n => Number.isFinite(n));
|
const nums = parts.slice(1).map(n => Number(n)).filter(n => Number.isFinite(n));
|
||||||
if (nums.length < 4) return undefined;
|
if (nums.length < 4) return undefined;
|
||||||
const idle = (nums[3] ?? 0) + (nums[4] ?? 0); // idle + iowait
|
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
|
||||||
const total = nums.reduce((a, b) => a + b, 0);
|
const total = nums.reduce((a, b) => a + b, 0);
|
||||||
return { total, idle };
|
return {total, idle};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
|
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
|
||||||
@@ -210,7 +214,6 @@ async function collectMetrics(host: HostRecord): Promise<{
|
|||||||
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
|
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
|
||||||
}> {
|
}> {
|
||||||
return withSshConnection(host, async (client) => {
|
return withSshConnection(host, async (client) => {
|
||||||
// CPU
|
|
||||||
let cpuPercent: number | null = null;
|
let cpuPercent: number | null = null;
|
||||||
let cores: number | null = null;
|
let cores: number | null = null;
|
||||||
let loadTriplet: [number, number, number] | null = null;
|
let loadTriplet: [number, number, number] | null = null;
|
||||||
@@ -245,7 +248,6 @@ async function collectMetrics(host: HostRecord): Promise<{
|
|||||||
loadTriplet = null;
|
loadTriplet = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memory
|
|
||||||
let memPercent: number | null = null;
|
let memPercent: number | null = null;
|
||||||
let usedGiB: number | null = null;
|
let usedGiB: number | null = null;
|
||||||
let totalGiB: number | null = null;
|
let totalGiB: number | null = null;
|
||||||
@@ -256,7 +258,7 @@ async function collectMetrics(host: HostRecord): Promise<{
|
|||||||
const line = lines.find(l => l.startsWith(key));
|
const line = lines.find(l => l.startsWith(key));
|
||||||
if (!line) return null;
|
if (!line) return null;
|
||||||
const m = line.match(/\d+/);
|
const m = line.match(/\d+/);
|
||||||
return m ? Number(m[0]) : null; // in kB
|
return m ? Number(m[0]) : null;
|
||||||
};
|
};
|
||||||
const totalKb = getVal('MemTotal:');
|
const totalKb = getVal('MemTotal:');
|
||||||
const availKb = getVal('MemAvailable:');
|
const availKb = getVal('MemAvailable:');
|
||||||
@@ -272,14 +274,12 @@ async function collectMetrics(host: HostRecord): Promise<{
|
|||||||
totalGiB = null;
|
totalGiB = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disk
|
|
||||||
let diskPercent: number | null = null;
|
let diskPercent: number | null = null;
|
||||||
let usedHuman: string | null = null;
|
let usedHuman: string | null = null;
|
||||||
let totalHuman: string | null = null;
|
let totalHuman: string | null = null;
|
||||||
try {
|
try {
|
||||||
const diskOut = await execCommand(client, 'df -h -P / | tail -n +2');
|
const diskOut = await execCommand(client, 'df -h -P / | tail -n +2');
|
||||||
const line = diskOut.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
const line = diskOut.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
||||||
// Expected columns: Filesystem Size Used Avail Use% Mounted
|
|
||||||
const parts = line.split(/\s+/);
|
const parts = line.split(/\s+/);
|
||||||
if (parts.length >= 6) {
|
if (parts.length >= 6) {
|
||||||
totalHuman = parts[1] || null;
|
totalHuman = parts[1] || null;
|
||||||
@@ -295,9 +295,13 @@ async function collectMetrics(host: HostRecord): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet },
|
cpu: {percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet},
|
||||||
memory: { percent: toFixedNum(memPercent, 0), usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null },
|
memory: {
|
||||||
disk: { percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman },
|
percent: toFixedNum(memPercent, 0),
|
||||||
|
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
||||||
|
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null
|
||||||
|
},
|
||||||
|
disk: {percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -310,7 +314,10 @@ function tcpPing(host: string, port: number, timeoutMs = 5000): Promise<boolean>
|
|||||||
const onDone = (result: boolean) => {
|
const onDone = (result: boolean) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
try { socket.destroy(); } catch {}
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
resolve(result);
|
resolve(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -334,7 +341,7 @@ async function pollStatusesOnce(): Promise<void> {
|
|||||||
|
|
||||||
const checks = hosts.map(async (h) => {
|
const checks = hosts.map(async (h) => {
|
||||||
const isOnline = await tcpPing(h.ip, h.port, 5000);
|
const isOnline = await tcpPing(h.ip, h.port, 5000);
|
||||||
hostStatuses.set(h.id, { status: isOnline ? 'online' : 'offline', lastChecked: now });
|
hostStatuses.set(h.id, {status: isOnline ? 'online' : 'offline', lastChecked: now});
|
||||||
return isOnline;
|
return isOnline;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -344,7 +351,6 @@ async function pollStatusesOnce(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.get('/status', async (req, res) => {
|
app.get('/status', async (req, res) => {
|
||||||
// Return current cached statuses; if empty, trigger a poll
|
|
||||||
if (hostStatuses.size === 0) {
|
if (hostStatuses.size === 0) {
|
||||||
await pollStatusesOnce();
|
await pollStatusesOnce();
|
||||||
}
|
}
|
||||||
@@ -358,7 +364,7 @@ app.get('/status', async (req, res) => {
|
|||||||
app.get('/status/:id', async (req, res) => {
|
app.get('/status/:id', async (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return res.status(400).json({ error: 'Invalid id' });
|
return res.status(400).json({error: 'Invalid id'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hostStatuses.has(id)) {
|
if (!hostStatuses.has(id)) {
|
||||||
@@ -367,34 +373,34 @@ app.get('/status/:id', async (req, res) => {
|
|||||||
|
|
||||||
const entry = hostStatuses.get(id);
|
const entry = hostStatuses.get(id);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return res.status(404).json({ error: 'Host not found' });
|
return res.status(404).json({error: 'Host not found'});
|
||||||
}
|
}
|
||||||
res.json(entry);
|
res.json(entry);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/refresh', async (req, res) => {
|
app.post('/refresh', async (req, res) => {
|
||||||
await pollStatusesOnce();
|
await pollStatusesOnce();
|
||||||
res.json({ message: 'Refreshed' });
|
res.json({message: 'Refreshed'});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/metrics/:id', async (req, res) => {
|
app.get('/metrics/:id', async (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return res.status(400).json({ error: 'Invalid id' });
|
return res.status(400).json({error: 'Invalid id'});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const host = await fetchHostById(id);
|
const host = await fetchHostById(id);
|
||||||
if (!host) {
|
if (!host) {
|
||||||
return res.status(404).json({ error: 'Host not found' });
|
return res.status(404).json({error: 'Host not found'});
|
||||||
}
|
}
|
||||||
const metrics = await collectMetrics(host);
|
const metrics = await collectMetrics(host);
|
||||||
res.json({ ...metrics, lastChecked: new Date().toISOString() });
|
res.json({...metrics, lastChecked: new Date().toISOString()});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to collect metrics', err);
|
logger.error('Failed to collect metrics', err);
|
||||||
return res.json({
|
return res.json({
|
||||||
cpu: { percent: null, cores: null, load: null },
|
cpu: {percent: null, cores: null, load: null},
|
||||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
memory: {percent: null, usedGiB: null, totalGiB: null},
|
||||||
disk: { percent: null, usedHuman: null, totalHuman: null },
|
disk: {percent: null, usedHuman: null, totalHuman: null},
|
||||||
lastChecked: new Date().toISOString()
|
lastChecked: new Date().toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -409,7 +415,6 @@ app.listen(PORT, async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Background polling every minute
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
pollStatusesOnce().catch(err => logger.error('Background poll failed', err));
|
pollStatusesOnce().catch(err => logger.error('Background poll failed', err));
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|||||||
@@ -132,15 +132,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Thin scrollbar utility for scrollable containers */
|
|
||||||
.thin-scrollbar {
|
.thin-scrollbar {
|
||||||
scrollbar-width: thin; /* Firefox */
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #303032 transparent; /* Firefox */
|
scrollbar-color: #303032 transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar::-webkit-scrollbar {
|
.thin-scrollbar::-webkit-scrollbar {
|
||||||
height: 6px; /* horizontal */
|
height: 6px;
|
||||||
width: 6px; /* vertical, if any */
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar::-webkit-scrollbar-track {
|
.thin-scrollbar::-webkit-scrollbar-track {
|
||||||
@@ -154,7 +153,6 @@
|
|||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Thin scrollbar styles */
|
|
||||||
.thin-scrollbar::-webkit-scrollbar {
|
.thin-scrollbar::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -173,7 +171,6 @@
|
|||||||
background: #5a5a5d;
|
background: #5a5a5d;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar for Firefox */
|
|
||||||
.thin-scrollbar {
|
.thin-scrollbar {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #434345 #18181b;
|
scrollbar-color: #434345 #18181b;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {Shield, Trash2, Users} from "lucide-react";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
||||||
const API = axios.create({ baseURL: apiBase });
|
const API = axios.create({baseURL: apiBase});
|
||||||
|
|
||||||
function getCookie(name: string) {
|
function getCookie(name: string) {
|
||||||
return document.cookie.split('; ').reduce((r, v) => {
|
return document.cookie.split('; ').reduce((r, v) => {
|
||||||
@@ -32,14 +32,12 @@ interface AdminSettingsProps {
|
|||||||
isTopbarOpen?: boolean;
|
isTopbarOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): React.ReactElement {
|
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
|
||||||
const { state: sidebarState } = useSidebar();
|
const {state: sidebarState} = useSidebar();
|
||||||
|
|
||||||
// Registration toggle
|
|
||||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||||
const [regLoading, setRegLoading] = React.useState(false);
|
const [regLoading, setRegLoading] = React.useState(false);
|
||||||
|
|
||||||
// OIDC config
|
|
||||||
const [oidcConfig, setOidcConfig] = React.useState({
|
const [oidcConfig, setOidcConfig] = React.useState({
|
||||||
client_id: '',
|
client_id: '',
|
||||||
client_secret: '',
|
client_secret: '',
|
||||||
@@ -54,8 +52,12 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
||||||
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
|
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
|
||||||
|
|
||||||
// Users/admins
|
const [users, setUsers] = React.useState<Array<{
|
||||||
const [users, setUsers] = React.useState<Array<{ id: string; username: string; is_admin: boolean; is_oidc: boolean }>>([]);
|
id: string;
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
is_oidc: boolean
|
||||||
|
}>>([]);
|
||||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||||
const [newAdminUsername, setNewAdminUsername] = React.useState("");
|
const [newAdminUsername, setNewAdminUsername] = React.useState("");
|
||||||
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
|
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
|
||||||
@@ -65,15 +67,15 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
if (!jwt) return;
|
if (!jwt) return;
|
||||||
// Preload OIDC config and users
|
API.get("/oidc-config", {headers: {Authorization: `Bearer ${jwt}`}})
|
||||||
API.get("/oidc-config", { headers: { Authorization: `Bearer ${jwt}` } })
|
.then(res => {
|
||||||
.then(res => { if (res.data) setOidcConfig(res.data); })
|
if (res.data) setOidcConfig(res.data);
|
||||||
.catch(() => {});
|
})
|
||||||
|
.catch(() => {
|
||||||
|
});
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load initial registration toggle status
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
API.get("/registration-allowed")
|
API.get("/registration-allowed")
|
||||||
.then(res => {
|
.then(res => {
|
||||||
@@ -81,7 +83,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
setAllowRegistration(res.data.allowed);
|
setAllowRegistration(res.data.allowed);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
@@ -89,7 +92,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
if (!jwt) return;
|
if (!jwt) return;
|
||||||
setUsersLoading(true);
|
setUsersLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await API.get("/list", { headers: { Authorization: `Bearer ${jwt}` } });
|
const response = await API.get("/list", {headers: {Authorization: `Bearer ${jwt}`}});
|
||||||
setUsers(response.data.users);
|
setUsers(response.data.users);
|
||||||
} finally {
|
} finally {
|
||||||
setUsersLoading(false);
|
setUsersLoading(false);
|
||||||
@@ -100,7 +103,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
setRegLoading(true);
|
setRegLoading(true);
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await API.patch("/registration-allowed", { allowed: checked }, { headers: { Authorization: `Bearer ${jwt}` } });
|
await API.patch("/registration-allowed", {allowed: checked}, {headers: {Authorization: `Bearer ${jwt}`}});
|
||||||
setAllowRegistration(checked);
|
setAllowRegistration(checked);
|
||||||
} finally {
|
} finally {
|
||||||
setRegLoading(false);
|
setRegLoading(false);
|
||||||
@@ -113,7 +116,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
setOidcError(null);
|
setOidcError(null);
|
||||||
setOidcSuccess(null);
|
setOidcSuccess(null);
|
||||||
|
|
||||||
const required = ['client_id','client_secret','issuer_url','authorization_url','token_url'];
|
const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
|
||||||
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
|
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
setOidcError(`Missing required fields: ${missing.join(', ')}`);
|
setOidcError(`Missing required fields: ${missing.join(', ')}`);
|
||||||
@@ -123,7 +126,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
|
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await API.post("/oidc-config", oidcConfig, { headers: { Authorization: `Bearer ${jwt}` } });
|
await API.post("/oidc-config", oidcConfig, {headers: {Authorization: `Bearer ${jwt}`}});
|
||||||
setOidcSuccess("OIDC configuration updated successfully!");
|
setOidcSuccess("OIDC configuration updated successfully!");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
|
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
|
||||||
@@ -133,7 +136,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOIDCConfigChange = (field: string, value: string) => {
|
const handleOIDCConfigChange = (field: string, value: string) => {
|
||||||
setOidcConfig(prev => ({ ...prev, [field]: value }));
|
setOidcConfig(prev => ({...prev, [field]: value}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeUserAdmin = async (e: React.FormEvent) => {
|
const makeUserAdmin = async (e: React.FormEvent) => {
|
||||||
@@ -144,7 +147,7 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
setMakeAdminSuccess(null);
|
setMakeAdminSuccess(null);
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await API.post("/make-admin", { username: newAdminUsername.trim() }, { headers: { Authorization: `Bearer ${jwt}` } });
|
await API.post("/make-admin", {username: newAdminUsername.trim()}, {headers: {Authorization: `Bearer ${jwt}`}});
|
||||||
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
||||||
setNewAdminUsername("");
|
setNewAdminUsername("");
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
@@ -159,18 +162,20 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
if (!confirm(`Remove admin status from ${username}?`)) return;
|
if (!confirm(`Remove admin status from ${username}?`)) return;
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await API.post("/remove-admin", { username }, { headers: { Authorization: `Bearer ${jwt}` } });
|
await API.post("/remove-admin", {username}, {headers: {Authorization: `Bearer ${jwt}`}});
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch {}
|
} catch {
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteUser = async (username: string) => {
|
const deleteUser = async (username: string) => {
|
||||||
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
|
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
try {
|
try {
|
||||||
await API.delete("/delete-user", { headers: { Authorization: `Bearer ${jwt}` }, data: { username } });
|
await API.delete("/delete-user", {headers: {Authorization: `Bearer ${jwt}`}, data: {username}});
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch {}
|
} catch {
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
@@ -185,7 +190,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={wrapperStyle} className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
|
<div style={wrapperStyle}
|
||||||
|
className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
|
||||||
<div className="h-full w-full flex flex-col">
|
<div className="h-full w-full flex flex-col">
|
||||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||||
<h1 className="font-bold text-lg">Admin Settings</h1>
|
<h1 className="font-bold text-lg">Admin Settings</h1>
|
||||||
@@ -217,7 +223,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">User Registration</h3>
|
<h3 className="text-lg font-semibold">User Registration</h3>
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration} disabled={regLoading}/>
|
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration}
|
||||||
|
disabled={regLoading}/>
|
||||||
Allow new account registration
|
Allow new account registration
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,7 +233,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
<TabsContent value="oidc" className="space-y-6">
|
<TabsContent value="oidc" className="space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
|
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
|
||||||
<p className="text-sm text-muted-foreground">Configure external identity provider for OIDC/OAuth2 authentication.</p>
|
<p className="text-sm text-muted-foreground">Configure external identity provider for
|
||||||
|
OIDC/OAuth2 authentication.</p>
|
||||||
|
|
||||||
{oidcError && (
|
{oidcError && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
@@ -238,39 +246,66 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="client_id">Client ID</Label>
|
<Label htmlFor="client_id">Client ID</Label>
|
||||||
<Input id="client_id" value={oidcConfig.client_id} onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)} placeholder="your-client-id" required />
|
<Input id="client_id" value={oidcConfig.client_id}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
|
||||||
|
placeholder="your-client-id" required/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="client_secret">Client Secret</Label>
|
<Label htmlFor="client_secret">Client Secret</Label>
|
||||||
<Input id="client_secret" type="password" value={oidcConfig.client_secret} onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)} placeholder="your-client-secret" required />
|
<Input id="client_secret" type="password" value={oidcConfig.client_secret}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
||||||
|
placeholder="your-client-secret" required/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="authorization_url">Authorization URL</Label>
|
<Label htmlFor="authorization_url">Authorization URL</Label>
|
||||||
<Input id="authorization_url" value={oidcConfig.authorization_url} onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)} placeholder="https://your-provider.com/application/o/authorize/" required />
|
<Input id="authorization_url" value={oidcConfig.authorization_url}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
|
||||||
|
placeholder="https://your-provider.com/application/o/authorize/"
|
||||||
|
required/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="issuer_url">Issuer URL</Label>
|
<Label htmlFor="issuer_url">Issuer URL</Label>
|
||||||
<Input id="issuer_url" value={oidcConfig.issuer_url} onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)} placeholder="https://your-provider.com/application/o/termix/" required />
|
<Input id="issuer_url" value={oidcConfig.issuer_url}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
|
||||||
|
placeholder="https://your-provider.com/application/o/termix/" required/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="token_url">Token URL</Label>
|
<Label htmlFor="token_url">Token URL</Label>
|
||||||
<Input id="token_url" value={oidcConfig.token_url} onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)} placeholder="https://your-provider.com/application/o/token/" required />
|
<Input id="token_url" value={oidcConfig.token_url}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
|
||||||
|
placeholder="https://your-provider.com/application/o/token/" required/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="identifier_path">User Identifier Path</Label>
|
<Label htmlFor="identifier_path">User Identifier Path</Label>
|
||||||
<Input id="identifier_path" value={oidcConfig.identifier_path} onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)} placeholder="sub" required />
|
<Input id="identifier_path" value={oidcConfig.identifier_path}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
|
||||||
|
placeholder="sub" required/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name_path">Display Name Path</Label>
|
<Label htmlFor="name_path">Display Name Path</Label>
|
||||||
<Input id="name_path" value={oidcConfig.name_path} onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)} placeholder="name" required />
|
<Input id="name_path" value={oidcConfig.name_path}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
|
||||||
|
placeholder="name" required/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="scopes">Scopes</Label>
|
<Label htmlFor="scopes">Scopes</Label>
|
||||||
<Input id="scopes" value={oidcConfig.scopes} onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)} placeholder="openid email profile" required />
|
<Input id="scopes" value={oidcConfig.scopes}
|
||||||
|
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
|
||||||
|
placeholder="openid email profile" required/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button type="submit" className="flex-1" disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
|
<Button type="submit" className="flex-1"
|
||||||
<Button type="button" variant="outline" onClick={() => setOidcConfig({ client_id: '', client_secret: '', issuer_url: '', authorization_url: '', token_url: '', identifier_path: 'sub', name_path: 'name', scopes: 'openid email profile' })}>Reset</Button>
|
disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOidcConfig({
|
||||||
|
client_id: '',
|
||||||
|
client_secret: '',
|
||||||
|
issuer_url: '',
|
||||||
|
authorization_url: '',
|
||||||
|
token_url: '',
|
||||||
|
identifier_path: 'sub',
|
||||||
|
name_path: 'name',
|
||||||
|
scopes: 'openid email profile'
|
||||||
|
})}>Reset</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{oidcSuccess && (
|
{oidcSuccess && (
|
||||||
@@ -287,7 +322,8 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold">User Management</h3>
|
<h3 className="text-lg font-semibold">User Management</h3>
|
||||||
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline" size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
|
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
|
||||||
|
size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
|
||||||
</div>
|
</div>
|
||||||
{usersLoading ? (
|
{usersLoading ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">Loading users...</div>
|
<div className="text-center py-8 text-muted-foreground">Loading users...</div>
|
||||||
@@ -307,12 +343,17 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
<TableCell className="px-4 font-medium">
|
<TableCell className="px-4 font-medium">
|
||||||
{user.username}
|
{user.username}
|
||||||
{user.is_admin && (
|
{user.is_admin && (
|
||||||
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
|
<span
|
||||||
|
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
|
<TableCell
|
||||||
|
className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
|
||||||
<TableCell className="px-4">
|
<TableCell className="px-4">
|
||||||
<Button variant="ghost" size="sm" onClick={() => deleteUser(user.username)} className="text-red-600 hover:text-red-700 hover:bg-red-50" disabled={user.is_admin}>
|
<Button variant="ghost" size="sm"
|
||||||
|
onClick={() => deleteUser(user.username)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
disabled={user.is_admin}>
|
||||||
<Trash2 className="h-4 w-4"/>
|
<Trash2 className="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -334,8 +375,11 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="new-admin-username">Username</Label>
|
<Label htmlFor="new-admin-username">Username</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input id="new-admin-username" value={newAdminUsername} onChange={(e) => setNewAdminUsername(e.target.value)} placeholder="Enter username to make admin" required />
|
<Input id="new-admin-username" value={newAdminUsername}
|
||||||
<Button type="submit" disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
|
onChange={(e) => setNewAdminUsername(e.target.value)}
|
||||||
|
placeholder="Enter username to make admin" required/>
|
||||||
|
<Button type="submit"
|
||||||
|
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{makeAdminError && (
|
{makeAdminError && (
|
||||||
@@ -369,11 +413,15 @@ export function AdminSettings({ isTopbarOpen = true }: AdminSettingsProps): Reac
|
|||||||
<TableRow key={admin.id}>
|
<TableRow key={admin.id}>
|
||||||
<TableCell className="px-4 font-medium">
|
<TableCell className="px-4 font-medium">
|
||||||
{admin.username}
|
{admin.username}
|
||||||
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
|
<span
|
||||||
|
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
|
<TableCell
|
||||||
|
className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
|
||||||
<TableCell className="px-4">
|
<TableCell className="px-4">
|
||||||
<Button variant="ghost" size="sm" onClick={() => removeAdminStatus(admin.username)} className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
|
<Button variant="ghost" size="sm"
|
||||||
|
onClick={() => removeAdminStatus(admin.username)}
|
||||||
|
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
|
||||||
<Shield className="h-4 w-4"/>
|
<Shield className="h-4 w-4"/>
|
||||||
Remove Admin
|
Remove Admin
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export function Homepage({
|
|||||||
const [userId, setUserId] = useState<string | null>(null);
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
const [dbError, setDbError] = useState<string | null>(null);
|
const [dbError, setDbError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Update local state when props change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoggedIn(isAuthenticated);
|
setLoggedIn(isAuthenticated);
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|||||||
@@ -42,17 +42,17 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await API.get(`/user/${userId}`);
|
const response = await API.get(`/user/${userId}`);
|
||||||
|
|
||||||
const userAlerts = response.data.alerts || [];
|
const userAlerts = response.data.alerts || [];
|
||||||
|
|
||||||
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
||||||
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
|
const priorityOrder = {critical: 4, high: 3, medium: 2, low: 1};
|
||||||
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
|
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
|
||||||
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
|
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
|
||||||
|
|
||||||
if (aPriority !== bPriority) {
|
if (aPriority !== bPriority) {
|
||||||
return bPriority - aPriority;
|
return bPriority - aPriority;
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
|
|||||||
|
|
||||||
const handleDismissAlert = async (alertId: string) => {
|
const handleDismissAlert = async (alertId: string) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await API.post('/dismiss', {
|
const response = await API.post('/dismiss', {
|
||||||
userId,
|
userId,
|
||||||
@@ -130,8 +130,8 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
|
|||||||
if (!currentAlert) {
|
if (!currentAlert) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const priorityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
|
const priorityCounts = {critical: 0, high: 0, medium: 0, low: 0};
|
||||||
alerts.forEach(alert => {
|
alerts.forEach(alert => {
|
||||||
const priority = alert.priority || 'low';
|
const priority = alert.priority || 'low';
|
||||||
priorityCounts[priority as keyof typeof priorityCounts]++;
|
priorityCounts[priority as keyof typeof priorityCounts]++;
|
||||||
|
|||||||
@@ -149,13 +149,11 @@ export function HomepageAuth({
|
|||||||
setUsername(meRes.data.username || null);
|
setUsername(meRes.data.username || null);
|
||||||
setUserId(meRes.data.id || null);
|
setUserId(meRes.data.id || null);
|
||||||
setDbError(null);
|
setDbError(null);
|
||||||
// Call onAuthSuccess to update App state
|
|
||||||
onAuthSuccess({
|
onAuthSuccess({
|
||||||
isAdmin: !!meRes.data.is_admin,
|
isAdmin: !!meRes.data.is_admin,
|
||||||
username: meRes.data.username || null,
|
username: meRes.data.username || null,
|
||||||
userId: meRes.data.id || null
|
userId: meRes.data.id || null
|
||||||
});
|
});
|
||||||
// Update internal state immediately
|
|
||||||
setInternalLoggedIn(true);
|
setInternalLoggedIn(true);
|
||||||
if (tab === "signup") {
|
if (tab === "signup") {
|
||||||
setSignupConfirmPassword("");
|
setSignupConfirmPassword("");
|
||||||
@@ -309,13 +307,11 @@ export function HomepageAuth({
|
|||||||
setUsername(meRes.data.username || null);
|
setUsername(meRes.data.username || null);
|
||||||
setUserId(meRes.data.id || null);
|
setUserId(meRes.data.id || null);
|
||||||
setDbError(null);
|
setDbError(null);
|
||||||
// Call onAuthSuccess to update App state
|
|
||||||
onAuthSuccess({
|
onAuthSuccess({
|
||||||
isAdmin: !!meRes.data.is_admin,
|
isAdmin: !!meRes.data.is_admin,
|
||||||
username: meRes.data.username || null,
|
username: meRes.data.username || null,
|
||||||
userId: meRes.data.id || null
|
userId: meRes.data.id || null
|
||||||
});
|
});
|
||||||
// Update internal state immediately
|
|
||||||
setInternalLoggedIn(true);
|
setInternalLoggedIn(true);
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
})
|
})
|
||||||
@@ -347,341 +343,341 @@ export function HomepageAuth({
|
|||||||
className={`w-[420px] max-w-full p-6 flex flex-col ${className || ''}`}
|
className={`w-[420px] max-w-full p-6 flex flex-col ${className || ''}`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{dbError && (
|
{dbError && (
|
||||||
<Alert variant="destructive" className="mb-4">
|
<Alert variant="destructive" className="mb-4">
|
||||||
<AlertTitle>Error</AlertTitle>
|
<AlertTitle>Error</AlertTitle>
|
||||||
<AlertDescription>{dbError}</AlertDescription>
|
<AlertDescription>{dbError}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{firstUser && !dbError && !internalLoggedIn && (
|
{firstUser && !dbError && !internalLoggedIn && (
|
||||||
<Alert variant="default" className="mb-4">
|
<Alert variant="default" className="mb-4">
|
||||||
<AlertTitle>First User</AlertTitle>
|
<AlertTitle>First User</AlertTitle>
|
||||||
<AlertDescription className="inline">
|
<AlertDescription className="inline">
|
||||||
You are the first user and will be made an admin. You can view admin settings in the sidebar
|
You are the first user and will be made an admin. You can view admin settings in the sidebar
|
||||||
user dropdown. If you think this is a mistake, check the docker logs, or create a{" "}
|
user dropdown. If you think this is a mistake, check the docker logs, or create a{" "}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/LukeGus/Termix/issues/new"
|
href="https://github.com/LukeGus/Termix/issues/new"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 underline hover:text-blue-800 inline"
|
className="text-blue-600 underline hover:text-blue-800 inline"
|
||||||
>
|
>
|
||||||
GitHub issue
|
GitHub issue
|
||||||
</a>.
|
</a>.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{!registrationAllowed && !internalLoggedIn && (
|
{!registrationAllowed && !internalLoggedIn && (
|
||||||
<Alert variant="destructive" className="mb-4">
|
<Alert variant="destructive" className="mb-4">
|
||||||
<AlertTitle>Registration Disabled</AlertTitle>
|
<AlertTitle>Registration Disabled</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
New account registration is currently disabled by an admin. Please log in or contact an
|
New account registration is currently disabled by an admin. Please log in or contact an
|
||||||
administrator.
|
administrator.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && (
|
{(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-2 mb-6">
|
<div className="flex gap-2 mb-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||||
tab === "login"
|
tab === "login"
|
||||||
? "bg-primary text-primary-foreground shadow"
|
? "bg-primary text-primary-foreground shadow"
|
||||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setTab("login");
|
|
||||||
if (tab === "reset") resetPasswordState();
|
|
||||||
if (tab === "signup") clearFormFields();
|
|
||||||
}}
|
|
||||||
aria-selected={tab === "login"}
|
|
||||||
disabled={loading || firstUser}
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
|
||||||
tab === "signup"
|
|
||||||
? "bg-primary text-primary-foreground shadow"
|
|
||||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setTab("signup");
|
|
||||||
if (tab === "reset") resetPasswordState();
|
|
||||||
if (tab === "login") clearFormFields();
|
|
||||||
}}
|
|
||||||
aria-selected={tab === "signup"}
|
|
||||||
disabled={loading || !registrationAllowed}
|
|
||||||
>
|
|
||||||
Sign Up
|
|
||||||
</button>
|
|
||||||
{oidcConfigured && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
|
||||||
tab === "external"
|
|
||||||
? "bg-primary text-primary-foreground shadow"
|
|
||||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setTab("external");
|
|
||||||
if (tab === "reset") resetPasswordState();
|
|
||||||
if (tab === "login" || tab === "signup") clearFormFields();
|
|
||||||
}}
|
|
||||||
aria-selected={tab === "external"}
|
|
||||||
disabled={oidcLoading}
|
|
||||||
>
|
|
||||||
External
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
onClick={() => {
|
||||||
<div className="mb-6 text-center">
|
setTab("login");
|
||||||
<h2 className="text-xl font-bold mb-1">
|
if (tab === "reset") resetPasswordState();
|
||||||
{tab === "login" ? "Login to your account" :
|
if (tab === "signup") clearFormFields();
|
||||||
tab === "signup" ? "Create a new account" :
|
}}
|
||||||
tab === "external" ? "Login with external provider" :
|
aria-selected={tab === "login"}
|
||||||
"Reset your password"}
|
disabled={loading || firstUser}
|
||||||
</h2>
|
>
|
||||||
</div>
|
Login
|
||||||
|
</button>
|
||||||
{tab === "external" || tab === "reset" ? (
|
<button
|
||||||
<div className="flex flex-col gap-5">
|
type="button"
|
||||||
{tab === "external" && (
|
className={cn(
|
||||||
<>
|
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||||
<div className="text-center text-muted-foreground mb-4">
|
tab === "signup"
|
||||||
<p>Login using your configured external identity provider</p>
|
? "bg-primary text-primary-foreground shadow"
|
||||||
</div>
|
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||||
<Button
|
)}
|
||||||
type="button"
|
onClick={() => {
|
||||||
className="w-full h-11 mt-2 text-base font-semibold"
|
setTab("signup");
|
||||||
disabled={oidcLoading}
|
if (tab === "reset") resetPasswordState();
|
||||||
onClick={handleOIDCLogin}
|
if (tab === "login") clearFormFields();
|
||||||
>
|
}}
|
||||||
{oidcLoading ? Spinner : "Login with External Provider"}
|
aria-selected={tab === "signup"}
|
||||||
</Button>
|
disabled={loading || !registrationAllowed}
|
||||||
</>
|
>
|
||||||
|
Sign Up
|
||||||
|
</button>
|
||||||
|
{oidcConfigured && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||||
|
tab === "external"
|
||||||
|
? "bg-primary text-primary-foreground shadow"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||||
)}
|
)}
|
||||||
{tab === "reset" && (
|
onClick={() => {
|
||||||
<>
|
setTab("external");
|
||||||
{resetStep === "initiate" && (
|
if (tab === "reset") resetPasswordState();
|
||||||
<>
|
if (tab === "login" || tab === "signup") clearFormFields();
|
||||||
<div className="text-center text-muted-foreground mb-4">
|
}}
|
||||||
<p>Enter your username to receive a password reset code. The code
|
aria-selected={tab === "external"}
|
||||||
will be logged in the docker container logs.</p>
|
disabled={oidcLoading}
|
||||||
</div>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
External
|
||||||
<div className="flex flex-col gap-2">
|
</button>
|
||||||
<Label htmlFor="reset-username">Username</Label>
|
)}
|
||||||
<Input
|
</div>
|
||||||
id="reset-username"
|
<div className="mb-6 text-center">
|
||||||
type="text"
|
<h2 className="text-xl font-bold mb-1">
|
||||||
required
|
{tab === "login" ? "Login to your account" :
|
||||||
className="h-11 text-base"
|
tab === "signup" ? "Create a new account" :
|
||||||
value={localUsername}
|
tab === "external" ? "Login with external provider" :
|
||||||
onChange={e => setLocalUsername(e.target.value)}
|
"Reset your password"}
|
||||||
disabled={resetLoading}
|
</h2>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full h-11 text-base font-semibold"
|
|
||||||
disabled={resetLoading || !localUsername.trim()}
|
|
||||||
onClick={initiatePasswordReset}
|
|
||||||
>
|
|
||||||
{resetLoading ? Spinner : "Send Reset Code"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{resetStep === "verify" && (
|
{tab === "external" || tab === "reset" ? (
|
||||||
<>
|
<div className="flex flex-col gap-5">
|
||||||
<div className="text-center text-muted-foreground mb-4">
|
{tab === "external" && (
|
||||||
<p>Enter the 6-digit code from the docker container logs for
|
<>
|
||||||
user: <strong>{localUsername}</strong></p>
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
</div>
|
<p>Login using your configured external identity provider</p>
|
||||||
<div className="flex flex-col gap-4">
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<Button
|
||||||
<Label htmlFor="reset-code">Reset Code</Label>
|
type="button"
|
||||||
<Input
|
className="w-full h-11 mt-2 text-base font-semibold"
|
||||||
id="reset-code"
|
disabled={oidcLoading}
|
||||||
type="text"
|
onClick={handleOIDCLogin}
|
||||||
required
|
>
|
||||||
maxLength={6}
|
{oidcLoading ? Spinner : "Login with External Provider"}
|
||||||
className="h-11 text-base text-center text-lg tracking-widest"
|
</Button>
|
||||||
value={resetCode}
|
</>
|
||||||
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
|
)}
|
||||||
disabled={resetLoading}
|
{tab === "reset" && (
|
||||||
placeholder="000000"
|
<>
|
||||||
/>
|
{resetStep === "initiate" && (
|
||||||
</div>
|
<>
|
||||||
<Button
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
type="button"
|
<p>Enter your username to receive a password reset code. The code
|
||||||
className="w-full h-11 text-base font-semibold"
|
will be logged in the docker container logs.</p>
|
||||||
disabled={resetLoading || resetCode.length !== 6}
|
</div>
|
||||||
onClick={verifyResetCode}
|
<div className="flex flex-col gap-4">
|
||||||
>
|
<div className="flex flex-col gap-2">
|
||||||
{resetLoading ? Spinner : "Verify Code"}
|
<Label htmlFor="reset-username">Username</Label>
|
||||||
</Button>
|
<Input
|
||||||
<Button
|
id="reset-username"
|
||||||
type="button"
|
type="text"
|
||||||
variant="outline"
|
required
|
||||||
className="w-full h-11 text-base font-semibold"
|
className="h-11 text-base"
|
||||||
|
value={localUsername}
|
||||||
|
onChange={e => setLocalUsername(e.target.value)}
|
||||||
disabled={resetLoading}
|
disabled={resetLoading}
|
||||||
onClick={() => {
|
/>
|
||||||
setResetStep("initiate");
|
|
||||||
setResetCode("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{resetSuccess && (
|
|
||||||
<>
|
|
||||||
<Alert className="mb-4">
|
|
||||||
<AlertTitle>Success!</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Your password has been successfully reset! You can now log in
|
|
||||||
with your new password.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full h-11 text-base font-semibold"
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={resetLoading || !localUsername.trim()}
|
||||||
|
onClick={initiatePasswordReset}
|
||||||
|
>
|
||||||
|
{resetLoading ? Spinner : "Send Reset Code"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resetStep === "verify" && (
|
||||||
|
<>
|
||||||
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
|
<p>Enter the 6-digit code from the docker container logs for
|
||||||
|
user: <strong>{localUsername}</strong></p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="reset-code">Reset Code</Label>
|
||||||
|
<Input
|
||||||
|
id="reset-code"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
maxLength={6}
|
||||||
|
className="h-11 text-base text-center text-lg tracking-widest"
|
||||||
|
value={resetCode}
|
||||||
|
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
disabled={resetLoading}
|
||||||
|
placeholder="000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={resetLoading || resetCode.length !== 6}
|
||||||
|
onClick={verifyResetCode}
|
||||||
|
>
|
||||||
|
{resetLoading ? Spinner : "Verify Code"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={resetLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTab("login");
|
setResetStep("initiate");
|
||||||
resetPasswordState();
|
setResetCode("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Go to Login
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
)}
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{resetStep === "newPassword" && !resetSuccess && (
|
{resetSuccess && (
|
||||||
<>
|
<>
|
||||||
<div className="text-center text-muted-foreground mb-4">
|
<Alert className="mb-4">
|
||||||
<p>Enter your new password for
|
<AlertTitle>Success!</AlertTitle>
|
||||||
user: <strong>{localUsername}</strong></p>
|
<AlertDescription>
|
||||||
</div>
|
Your password has been successfully reset! You can now log in
|
||||||
<div className="flex flex-col gap-5">
|
with your new password.
|
||||||
<div className="flex flex-col gap-2">
|
</AlertDescription>
|
||||||
<Label htmlFor="new-password">New Password</Label>
|
</Alert>
|
||||||
<Input
|
<Button
|
||||||
id="new-password"
|
type="button"
|
||||||
type="password"
|
className="w-full h-11 text-base font-semibold"
|
||||||
required
|
onClick={() => {
|
||||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
setTab("login");
|
||||||
value={newPassword}
|
resetPasswordState();
|
||||||
onChange={e => setNewPassword(e.target.value)}
|
}}
|
||||||
disabled={resetLoading}
|
>
|
||||||
autoComplete="new-password"
|
Go to Login
|
||||||
/>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
<div className="flex flex-col gap-2">
|
)}
|
||||||
<Label htmlFor="confirm-password">Confirm Password</Label>
|
|
||||||
<Input
|
{resetStep === "newPassword" && !resetSuccess && (
|
||||||
id="confirm-password"
|
<>
|
||||||
type="password"
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
required
|
<p>Enter your new password for
|
||||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
user: <strong>{localUsername}</strong></p>
|
||||||
value={confirmPassword}
|
</div>
|
||||||
onChange={e => setConfirmPassword(e.target.value)}
|
<div className="flex flex-col gap-5">
|
||||||
disabled={resetLoading}
|
<div className="flex flex-col gap-2">
|
||||||
autoComplete="new-password"
|
<Label htmlFor="new-password">New Password</Label>
|
||||||
/>
|
<Input
|
||||||
</div>
|
id="new-password"
|
||||||
<Button
|
type="password"
|
||||||
type="button"
|
required
|
||||||
className="w-full h-11 text-base font-semibold"
|
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||||
disabled={resetLoading || !newPassword || !confirmPassword}
|
value={newPassword}
|
||||||
onClick={completePasswordReset}
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
>
|
|
||||||
{resetLoading ? Spinner : "Reset Password"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full h-11 text-base font-semibold"
|
|
||||||
disabled={resetLoading}
|
disabled={resetLoading}
|
||||||
onClick={() => {
|
autoComplete="new-password"
|
||||||
setResetStep("verify");
|
/>
|
||||||
setNewPassword("");
|
|
||||||
setConfirmPassword("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div className="flex flex-col gap-2">
|
||||||
)}
|
<Label htmlFor="confirm-password">Confirm Password</Label>
|
||||||
</>
|
<Input
|
||||||
)}
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={resetLoading}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={resetLoading || !newPassword || !confirmPassword}
|
||||||
|
onClick={completePasswordReset}
|
||||||
|
>
|
||||||
|
{resetLoading ? Spinner : "Reset Password"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-11 text-base font-semibold"
|
||||||
|
disabled={resetLoading}
|
||||||
|
onClick={() => {
|
||||||
|
setResetStep("verify");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="h-11 text-base"
|
||||||
|
value={localUsername}
|
||||||
|
onChange={e => setLocalUsername(e.target.value)}
|
||||||
|
disabled={loading || internalLoggedIn}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="flex flex-col gap-2">
|
||||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input id="password" type="password" required className="h-11 text-base"
|
||||||
|
value={password} onChange={e => setPassword(e.target.value)}
|
||||||
|
disabled={loading || internalLoggedIn}/>
|
||||||
|
</div>
|
||||||
|
{tab === "signup" && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="username">Username</Label>
|
<Label htmlFor="signup-confirm-password">Confirm Password</Label>
|
||||||
<Input
|
<Input id="signup-confirm-password" type="password" required
|
||||||
id="username"
|
className="h-11 text-base"
|
||||||
type="text"
|
value={signupConfirmPassword}
|
||||||
required
|
onChange={e => setSignupConfirmPassword(e.target.value)}
|
||||||
className="h-11 text-base"
|
|
||||||
value={localUsername}
|
|
||||||
onChange={e => setLocalUsername(e.target.value)}
|
|
||||||
disabled={loading || internalLoggedIn}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input id="password" type="password" required className="h-11 text-base"
|
|
||||||
value={password} onChange={e => setPassword(e.target.value)}
|
|
||||||
disabled={loading || internalLoggedIn}/>
|
disabled={loading || internalLoggedIn}/>
|
||||||
</div>
|
</div>
|
||||||
{tab === "signup" && (
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
|
||||||
<Label htmlFor="signup-confirm-password">Confirm Password</Label>
|
disabled={loading || internalLoggedIn}>
|
||||||
<Input id="signup-confirm-password" type="password" required
|
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
|
||||||
className="h-11 text-base"
|
</Button>
|
||||||
value={signupConfirmPassword}
|
{tab === "login" && (
|
||||||
onChange={e => setSignupConfirmPassword(e.target.value)}
|
<Button type="button" variant="outline"
|
||||||
disabled={loading || internalLoggedIn}/>
|
className="w-full h-11 text-base font-semibold"
|
||||||
</div>
|
disabled={loading || internalLoggedIn}
|
||||||
)}
|
onClick={() => {
|
||||||
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
|
setTab("reset");
|
||||||
disabled={loading || internalLoggedIn}>
|
resetPasswordState();
|
||||||
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
|
clearFormFields();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Password
|
||||||
</Button>
|
</Button>
|
||||||
{tab === "login" && (
|
)}
|
||||||
<Button type="button" variant="outline"
|
</form>
|
||||||
className="w-full h-11 text-base font-semibold"
|
)}
|
||||||
disabled={loading || internalLoggedIn}
|
</>
|
||||||
onClick={() => {
|
)}
|
||||||
setTab("reset");
|
{error && (
|
||||||
resetPasswordState();
|
<Alert variant="destructive" className="mt-4">
|
||||||
clearFormFields();
|
<AlertTitle>Error</AlertTitle>
|
||||||
}}
|
<AlertDescription>{error}</AlertDescription>
|
||||||
>
|
</Alert>
|
||||||
Reset Password
|
)}
|
||||||
</Button>
|
</div>
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive" className="mt-4">
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, {useEffect, useRef, useState} from "react";
|
||||||
import {TerminalComponent} from "@/ui/apps/Terminal/TerminalComponent.tsx";
|
import {TerminalComponent} from "@/ui/apps/Terminal/TerminalComponent.tsx";
|
||||||
import {Server as ServerView} from "@/ui/apps/Server/Server.tsx";
|
import {Server as ServerView} from "@/ui/apps/Server/Server.tsx";
|
||||||
import {FileManager} from "@/ui/apps/File Manager/FileManager.tsx";
|
import {FileManager} from "@/ui/apps/File Manager/FileManager.tsx";
|
||||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||||
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
|
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
|
||||||
import * as ResizablePrimitive from "react-resizable-panels";
|
import * as ResizablePrimitive from "react-resizable-panels";
|
||||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||||
import {LucideRefreshCcw, LucideRefreshCw, RefreshCcw, RefreshCcwDot} from "lucide-react";
|
import {LucideRefreshCcw, LucideRefreshCw, RefreshCcw, RefreshCcwDot} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
|
|
||||||
interface TerminalViewProps {
|
interface TerminalViewProps {
|
||||||
isTopbarOpen?: boolean;
|
isTopbarOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.ReactElement {
|
export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactElement {
|
||||||
const {tabs, currentTab, allSplitScreenTab} = useTabs() as any;
|
const {tabs, currentTab, allSplitScreenTab} = useTabs() as any;
|
||||||
const { state: sidebarState } = useSidebar();
|
const {state: sidebarState} = useSidebar();
|
||||||
|
|
||||||
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal' || tab.type === 'server' || tab.type === 'file_manager');
|
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal' || tab.type === 'server' || tab.type === 'file_manager');
|
||||||
|
|
||||||
@@ -51,7 +51,6 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Coalesce layout → measure → fit callbacks
|
|
||||||
const layoutScheduleRef = useRef<number | null>(null);
|
const layoutScheduleRef = useRef<number | null>(null);
|
||||||
const scheduleMeasureAndFit = () => {
|
const scheduleMeasureAndFit = () => {
|
||||||
if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current);
|
if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current);
|
||||||
@@ -63,7 +62,6 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hide terminals until layout → rects → fit applied to prevent first-frame wrapping
|
|
||||||
const hideThenFit = () => {
|
const hideThenFit = () => {
|
||||||
setReady(false);
|
setReady(false);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -77,13 +75,10 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hideThenFit();
|
hideThenFit();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]);
|
}, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]);
|
||||||
|
|
||||||
// When split layout toggles on/off, topbar toggles, or sidebar state changes → measure+fit
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scheduleMeasureAndFit();
|
scheduleMeasureAndFit();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
|
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -93,14 +88,15 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
|||||||
}) : null;
|
}) : null;
|
||||||
if (containerRef.current && roContainer) roContainer.observe(containerRef.current);
|
if (containerRef.current && roContainer) roContainer.observe(containerRef.current);
|
||||||
return () => roContainer?.disconnect();
|
return () => roContainer?.disconnect();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onWinResize = () => { updatePanelRects(); fitActiveAndNotify(); };
|
const onWinResize = () => {
|
||||||
|
updatePanelRects();
|
||||||
|
fitActiveAndNotify();
|
||||||
|
};
|
||||||
window.addEventListener('resize', onWinResize);
|
window.addEventListener('resize', onWinResize);
|
||||||
return () => window.removeEventListener('resize', onWinResize);
|
return () => window.removeEventListener('resize', onWinResize);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const HEADER_H = 28;
|
const HEADER_H = 28;
|
||||||
@@ -109,24 +105,34 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
|||||||
const styles: Record<number, React.CSSProperties> = {};
|
const styles: Record<number, React.CSSProperties> = {};
|
||||||
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
|
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
|
||||||
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
|
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
|
||||||
const layoutTabs = [mainTab, ...splitTabs.filter((t:any)=> t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
|
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
|
||||||
|
|
||||||
if (allSplitScreenTab.length === 0 && mainTab) {
|
if (allSplitScreenTab.length === 0 && mainTab) {
|
||||||
styles[mainTab.id] = { position:'absolute', top:2, left:2, right:2, bottom:2, zIndex: 20, display: 'block', pointerEvents:'auto', opacity: ready ? 1 : 0 };
|
styles[mainTab.id] = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
left: 2,
|
||||||
|
right: 2,
|
||||||
|
bottom: 2,
|
||||||
|
zIndex: 20,
|
||||||
|
display: 'block',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
opacity: ready ? 1 : 0
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
layoutTabs.forEach((t: any) => {
|
layoutTabs.forEach((t: any) => {
|
||||||
const rect = panelRects[String(t.id)];
|
const rect = panelRects[String(t.id)];
|
||||||
const parentRect = containerRef.current?.getBoundingClientRect();
|
const parentRect = containerRef.current?.getBoundingClientRect();
|
||||||
if (rect && parentRect) {
|
if (rect && parentRect) {
|
||||||
styles[t.id] = {
|
styles[t.id] = {
|
||||||
position:'absolute',
|
position: 'absolute',
|
||||||
top: (rect.top - parentRect.top) + HEADER_H + 2,
|
top: (rect.top - parentRect.top) + HEADER_H + 2,
|
||||||
left: (rect.left - parentRect.left) + 2,
|
left: (rect.left - parentRect.left) + 2,
|
||||||
width: rect.width - 4,
|
width: rect.width - 4,
|
||||||
height: rect.height - HEADER_H - 4,
|
height: rect.height - HEADER_H - 4,
|
||||||
zIndex: 20,
|
zIndex: 20,
|
||||||
display: 'block',
|
display: 'block',
|
||||||
pointerEvents:'auto',
|
pointerEvents: 'auto',
|
||||||
opacity: ready ? 1 : 0,
|
opacity: ready ? 1 : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -134,22 +140,21 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{position:'absolute', inset:0, zIndex:1}}>
|
<div style={{position: 'absolute', inset: 0, zIndex: 1}}>
|
||||||
{terminalTabs.map((t:any) => {
|
{terminalTabs.map((t: any) => {
|
||||||
const hasStyle = !!styles[t.id];
|
const hasStyle = !!styles[t.id];
|
||||||
const isVisible = hasStyle || (allSplitScreenTab.length===0 && t.id===currentTab);
|
const isVisible = hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
|
||||||
|
|
||||||
// Visible style from computed positions; otherwise keep mounted but hidden and non-interactive
|
|
||||||
const finalStyle: React.CSSProperties = hasStyle
|
const finalStyle: React.CSSProperties = hasStyle
|
||||||
? {...styles[t.id], overflow:'hidden'}
|
? {...styles[t.id], overflow: 'hidden'}
|
||||||
: {
|
: {
|
||||||
position:'absolute', inset:0, visibility:'hidden', pointerEvents:'none', zIndex:0,
|
position: 'absolute', inset: 0, visibility: 'hidden', pointerEvents: 'none', zIndex: 0,
|
||||||
} as React.CSSProperties;
|
} as React.CSSProperties;
|
||||||
|
|
||||||
const effectiveVisible = isVisible && ready;
|
const effectiveVisible = isVisible && ready;
|
||||||
return (
|
return (
|
||||||
<div key={t.id} style={finalStyle}>
|
<div key={t.id} style={finalStyle}>
|
||||||
<div className="absolute inset-0 rounded-md" style={{background:'#18181b'}}>
|
<div className="absolute inset-0 rounded-md" style={{background: '#18181b'}}>
|
||||||
{t.type === 'terminal' ? (
|
{t.type === 'terminal' ? (
|
||||||
<TerminalComponent
|
<TerminalComponent
|
||||||
ref={t.terminalRef}
|
ref={t.terminalRef}
|
||||||
@@ -157,7 +162,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
|||||||
isVisible={effectiveVisible}
|
isVisible={effectiveVisible}
|
||||||
title={t.title}
|
title={t.title}
|
||||||
showTitle={false}
|
showTitle={false}
|
||||||
splitScreen={allSplitScreenTab.length>0}
|
splitScreen={allSplitScreenTab.length > 0}
|
||||||
/>
|
/>
|
||||||
) : t.type === 'server' ? (
|
) : t.type === 'server' ? (
|
||||||
<ServerView
|
<ServerView
|
||||||
@@ -181,7 +186,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ResetButton = ({ onClick }: { onClick: () => void }) => (
|
const ResetButton = ({onClick}: { onClick: () => void }) => (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -189,7 +194,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
|||||||
aria-label="Reset split sizes"
|
aria-label="Reset split sizes"
|
||||||
className="absolute top-0 right-0 h-[28px] w-[28px] !rounded-none border-l-1 border-b-1 border-[#222224] bg-[#1b1b1e] hover:bg-[#232327] text-white flex items-center justify-center p-0"
|
className="absolute top-0 right-0 h-[28px] w-[28px] !rounded-none border-l-1 border-b-1 border-[#222224] bg-[#1b1b1e] hover:bg-[#232327] text-white flex items-center justify-center p-0"
|
||||||
>
|
>
|
||||||
<RefreshCcw className="h-4 w-4" />
|
<RefreshCcw className="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -201,28 +206,75 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
|||||||
const renderSplitOverlays = () => {
|
const renderSplitOverlays = () => {
|
||||||
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
|
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
|
||||||
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
|
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
|
||||||
const layoutTabs = [mainTab, ...splitTabs.filter((t:any)=> t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
|
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
|
||||||
if (allSplitScreenTab.length === 0) return null;
|
if (allSplitScreenTab.length === 0) return null;
|
||||||
|
|
||||||
const handleStyle = { pointerEvents:'auto', zIndex:12, background:'#303032' } as React.CSSProperties;
|
const handleStyle = {pointerEvents: 'auto', zIndex: 12, background: '#303032'} as React.CSSProperties;
|
||||||
const commonGroupProps = { onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit } as any;
|
const commonGroupProps = {onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit} as any;
|
||||||
|
|
||||||
if (layoutTabs.length === 2) {
|
if (layoutTabs.length === 2) {
|
||||||
const [a,b] = layoutTabs as any[];
|
const [a, b] = layoutTabs as any[];
|
||||||
return (
|
return (
|
||||||
<div style={{ position:'absolute', inset:0, zIndex:10, pointerEvents:'none' }}>
|
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
|
||||||
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal" className="h-full w-full" {...commonGroupProps}>
|
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal"
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${a.id}`} order={1}>
|
className="h-full w-full" {...commonGroupProps}>
|
||||||
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',background:'transparent',position:'relative'}}>
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{a.title}</div>
|
id={`panel-${a.id}`} order={1}>
|
||||||
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(a.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
background: 'transparent',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>{a.title}</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle style={handleStyle}/>
|
<ResizableHandle style={handleStyle}/>
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${b.id}`} order={2}>
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||||
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',background:'transparent',position:'relative'}}>
|
id={`panel-${b.id}`} order={2}>
|
||||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(b.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
background: 'transparent',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
{b.title}
|
{b.title}
|
||||||
<ResetButton onClick={handleReset} />
|
<ResetButton onClick={handleReset}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
@@ -231,32 +283,101 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (layoutTabs.length === 3) {
|
if (layoutTabs.length === 3) {
|
||||||
const [a,b,c] = layoutTabs as any[];
|
const [a, b, c] = layoutTabs as any[];
|
||||||
return (
|
return (
|
||||||
<div style={{ position:'absolute', inset:0, zIndex:10, pointerEvents:'none' }}>
|
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
|
||||||
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full" id="main-vertical" {...commonGroupProps}>
|
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
|
id="main-vertical" {...commonGroupProps}>
|
||||||
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal" className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${a.id}`} order={1}>
|
id="top-panel" order={1}>
|
||||||
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
|
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
|
||||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{a.title}</div>
|
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||||
|
id={`panel-${a.id}`} order={1}>
|
||||||
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(a.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>{a.title}</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle style={handleStyle}/>
|
<ResizableHandle style={handleStyle}/>
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${b.id}`} order={2}>
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||||
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
|
id={`panel-${b.id}`} order={2}>
|
||||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(b.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
{b.title}
|
{b.title}
|
||||||
<ResetButton onClick={handleReset} />
|
<ResetButton onClick={handleReset}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle style={handleStyle}/>
|
<ResizableHandle style={handleStyle}/>
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}>
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||||
<div ref={el => { panelRefs.current[String(c.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
|
id="bottom-panel" order={2}>
|
||||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{c.title}</div>
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(c.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>{c.title}</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePrimitive.PanelGroup>
|
</ResizablePrimitive.PanelGroup>
|
||||||
@@ -264,40 +385,133 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (layoutTabs.length === 4) {
|
if (layoutTabs.length === 4) {
|
||||||
const [a,b,c,d] = layoutTabs as any[];
|
const [a, b, c, d] = layoutTabs as any[];
|
||||||
return (
|
return (
|
||||||
<div style={{ position:'absolute', inset:0, zIndex:10, pointerEvents:'none' }}>
|
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
|
||||||
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full" id="main-vertical" {...commonGroupProps}>
|
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
|
id="main-vertical" {...commonGroupProps}>
|
||||||
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal" className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${a.id}`} order={1}>
|
id="top-panel" order={1}>
|
||||||
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
|
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
|
||||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{a.title}</div>
|
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||||
|
id={`panel-${a.id}`} order={1}>
|
||||||
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(a.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>{a.title}</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle style={handleStyle}/>
|
<ResizableHandle style={handleStyle}/>
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${b.id}`} order={2}>
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||||
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
|
id={`panel-${b.id}`} order={2}>
|
||||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(b.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
{b.title}
|
{b.title}
|
||||||
<ResetButton onClick={handleReset} />
|
<ResetButton onClick={handleReset}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle style={handleStyle}/>
|
<ResizableHandle style={handleStyle}/>
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id="bottom-panel" order={2}>
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||||
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal" className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
|
id="bottom-panel" order={2}>
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${c.id}`} order={1}>
|
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal"
|
||||||
<div ref={el => { panelRefs.current[String(c.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
|
className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
|
||||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{c.title}</div>
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||||
|
id={`panel-${c.id}`} order={1}>
|
||||||
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(c.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>{c.title}</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle style={handleStyle}/>
|
<ResizableHandle style={handleStyle}/>
|
||||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${d.id}`} order={2}>
|
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||||
<div ref={el => { panelRefs.current[String(d.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
|
id={`panel-${d.id}`} order={2}>
|
||||||
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11, position:'relative'}}>{d.title}</div>
|
<div ref={el => {
|
||||||
|
panelRefs.current[String(d.id)] = el;
|
||||||
|
}} style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1b1b1e',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
height: HEADER_H,
|
||||||
|
lineHeight: `${HEADER_H}px`,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderBottom: '1px solid #222224',
|
||||||
|
letterSpacing: 1,
|
||||||
|
margin: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 11,
|
||||||
|
position: 'relative'
|
||||||
|
}}>{d.title}</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
@@ -318,8 +532,8 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
|
|||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
|
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
|
||||||
style={{
|
style={{
|
||||||
position:'relative',
|
position: 'relative',
|
||||||
background:'#18181b',
|
background: '#18181b',
|
||||||
marginLeft: leftMarginPx,
|
marginLeft: leftMarginPx,
|
||||||
marginRight: 17,
|
marginRight: 17,
|
||||||
marginTop: topMarginPx,
|
marginTop: topMarginPx,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, {useState} from "react";
|
||||||
import {CardTitle} from "@/components/ui/card.tsx";
|
import {CardTitle} from "@/components/ui/card.tsx";
|
||||||
import {ChevronDown, Folder} from "lucide-react";
|
import {ChevronDown, Folder} from "lucide-react";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
@@ -35,7 +35,7 @@ interface FolderCardProps {
|
|||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardProps): React.ReactElement {
|
export function FolderCard({folderName, hosts, isFirst, isLast}: FolderCardProps): React.ReactElement {
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
const toggleExpanded = () => {
|
const toggleExpanded = () => {
|
||||||
@@ -43,7 +43,8 @@ export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden" style={{padding: '0', margin: '0'}}>
|
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden"
|
||||||
|
style={{padding: '0', margin: '0'}}>
|
||||||
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
|
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
|
||||||
<div className="flex gap-2 pr-10">
|
<div className="flex gap-2 pr-10">
|
||||||
<div className="flex-shrink-0 flex items-center">
|
<div className="flex-shrink-0 flex items-center">
|
||||||
@@ -65,10 +66,10 @@ export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardPro
|
|||||||
<div className="flex flex-col p-2 gap-y-3">
|
<div className="flex flex-col p-2 gap-y-3">
|
||||||
{hosts.map((host, index) => (
|
{hosts.map((host, index) => (
|
||||||
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
|
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
|
||||||
<Host host={host} />
|
<Host host={host}/>
|
||||||
{index < hosts.length - 1 && (
|
{index < hosts.length - 1 && (
|
||||||
<div className="relative -mx-2">
|
<div className="relative -mx-2">
|
||||||
<Separator className="p-0.25 absolute inset-x-0" />
|
<Separator className="p-0.25 absolute inset-x-0"/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||||
import {Server, Terminal} from "lucide-react";
|
import {Server, Terminal} from "lucide-react";
|
||||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||||
import { getServerStatusById } from "@/ui/main-axios.ts";
|
import {getServerStatusById} from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -33,12 +33,12 @@ interface HostProps {
|
|||||||
host: SSHHost;
|
host: SSHHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Host({ host }: HostProps): React.ReactElement {
|
export function Host({host}: HostProps): React.ReactElement {
|
||||||
const { addTab } = useTabs();
|
const {addTab} = useTabs();
|
||||||
const [serverStatus, setServerStatus] = useState<'online' | 'offline'>('offline');
|
const [serverStatus, setServerStatus] = useState<'online' | 'offline'>('offline');
|
||||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||||
const hasTags = tags.length > 0;
|
const hasTags = tags.length > 0;
|
||||||
|
|
||||||
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
|
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -66,13 +66,13 @@ export function Host({ host }: HostProps): React.ReactElement {
|
|||||||
}, [host.id]);
|
}, [host.id]);
|
||||||
|
|
||||||
const handleTerminalClick = () => {
|
const handleTerminalClick = () => {
|
||||||
addTab({ type: 'terminal', title, hostConfig: host });
|
addTab({type: 'terminal', title, hostConfig: host});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleServerClick = () => {
|
const handleServerClick = () => {
|
||||||
addTab({ type: 'server', title, hostConfig: host });
|
addTab({type: 'server', title, hostConfig: host});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -87,8 +87,8 @@ export function Host({ host }: HostProps): React.ReactElement {
|
|||||||
<Server/>
|
<Server/>
|
||||||
</Button>
|
</Button>
|
||||||
{host.enableTerminal && (
|
{host.enableTerminal && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="!px-2 border-1 border-[#303032]"
|
className="!px-2 border-1 border-[#303032]"
|
||||||
onClick={handleTerminalClick}
|
onClick={handleTerminalClick}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import axios from "axios";
|
|||||||
import {Card} from "@/components/ui/card.tsx";
|
import {Card} from "@/components/ui/card.tsx";
|
||||||
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
|
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
|
||||||
import {getSSHHosts} from "@/ui/main-axios.ts";
|
import {getSSHHosts} from "@/ui/main-axios.ts";
|
||||||
import { useTabs } from "@/ui/Navigation/Tabs/TabContext.tsx";
|
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -102,29 +102,14 @@ const API = axios.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function LeftSidebar({
|
export function LeftSidebar({
|
||||||
onSelectView,
|
onSelectView,
|
||||||
getView,
|
getView,
|
||||||
disabled,
|
disabled,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
username,
|
username,
|
||||||
children,
|
children,
|
||||||
}: SidebarProps): React.ReactElement {
|
}: SidebarProps): React.ReactElement {
|
||||||
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
|
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
|
||||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
|
||||||
const [regLoading, setRegLoading] = React.useState(false);
|
|
||||||
const [oidcConfig, setOidcConfig] = React.useState({
|
|
||||||
client_id: '',
|
|
||||||
client_secret: '',
|
|
||||||
issuer_url: '',
|
|
||||||
authorization_url: '',
|
|
||||||
token_url: '',
|
|
||||||
identifier_path: 'sub',
|
|
||||||
name_path: 'name',
|
|
||||||
scopes: 'openid email profile'
|
|
||||||
});
|
|
||||||
const [oidcLoading, setOidcLoading] = React.useState(false);
|
|
||||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
|
||||||
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
|
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
|
||||||
const [deletePassword, setDeletePassword] = React.useState("");
|
const [deletePassword, setDeletePassword] = React.useState("");
|
||||||
@@ -138,21 +123,21 @@ export function LeftSidebar({
|
|||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
is_oidc: boolean;
|
is_oidc: boolean;
|
||||||
}>>([]);
|
}>>([]);
|
||||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
|
||||||
const [newAdminUsername, setNewAdminUsername] = React.useState("");
|
const [newAdminUsername, setNewAdminUsername] = React.useState("");
|
||||||
|
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||||
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
|
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
|
||||||
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
|
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
|
||||||
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
|
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
|
||||||
|
const [oidcConfig, setOidcConfig] = React.useState<any>(null);
|
||||||
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
|
||||||
|
|
||||||
// Tabs context for opening SSH Manager tab
|
const {tabs: tabList, addTab, setCurrentTab, allSplitScreenTab} = useTabs() as any;
|
||||||
const { tabs: tabList, addTab, setCurrentTab, allSplitScreenTab } = useTabs() as any;
|
|
||||||
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
|
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
|
||||||
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
|
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
|
||||||
const openSshManagerTab = () => {
|
const openSshManagerTab = () => {
|
||||||
if (sshManagerTab || isSplitScreenActive) return;
|
if (sshManagerTab || isSplitScreenActive) return;
|
||||||
const id = addTab({ type: 'ssh_manager', title: 'SSH Manager' } as any);
|
const id = addTab({type: 'ssh_manager', title: 'SSH Manager'} as any);
|
||||||
setCurrentTab(id);
|
setCurrentTab(id);
|
||||||
};
|
};
|
||||||
const adminTab = tabList.find((t) => t.type === 'admin');
|
const adminTab = tabList.find((t) => t.type === 'admin');
|
||||||
@@ -162,11 +147,10 @@ export function LeftSidebar({
|
|||||||
setCurrentTab(adminTab.id);
|
setCurrentTab(adminTab.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = addTab({ type: 'admin', title: 'Admin' } as any);
|
const id = addTab({type: 'admin', title: 'Admin'} as any);
|
||||||
setCurrentTab(id);
|
setCurrentTab(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// SSH Hosts state management
|
|
||||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||||
const [hostsLoading, setHostsLoading] = useState(false);
|
const [hostsLoading, setHostsLoading] = useState(false);
|
||||||
const [hostsError, setHostsError] = useState<string | null>(null);
|
const [hostsError, setHostsError] = useState<string | null>(null);
|
||||||
@@ -202,23 +186,16 @@ export function LeftSidebar({
|
|||||||
}
|
}
|
||||||
}, [isAdmin]);
|
}, [isAdmin]);
|
||||||
|
|
||||||
// SSH Hosts data fetching
|
|
||||||
const fetchHosts = React.useCallback(async () => {
|
const fetchHosts = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const newHosts = await getSSHHosts();
|
const newHosts = await getSSHHosts();
|
||||||
// Show all hosts in sidebar, regardless of terminal setting
|
|
||||||
// Terminal visibility is handled in the UI components
|
|
||||||
|
|
||||||
const prevHosts = prevHostsRef.current;
|
const prevHosts = prevHostsRef.current;
|
||||||
|
|
||||||
// Create a stable map of existing hosts by ID for comparison
|
|
||||||
const existingHostsMap = new Map(prevHosts.map(h => [h.id, h]));
|
const existingHostsMap = new Map(prevHosts.map(h => [h.id, h]));
|
||||||
const newHostsMap = new Map(newHosts.map(h => [h.id, h]));
|
const newHostsMap = new Map(newHosts.map(h => [h.id, h]));
|
||||||
|
|
||||||
// Check if there are any meaningful changes
|
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
|
|
||||||
// Check for new hosts, removed hosts, or changed hosts
|
|
||||||
if (newHosts.length !== prevHosts.length) {
|
if (newHosts.length !== prevHosts.length) {
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -228,8 +205,7 @@ export function LeftSidebar({
|
|||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only check fields that affect the display
|
|
||||||
if (
|
if (
|
||||||
newHost.name !== existingHost.name ||
|
newHost.name !== existingHost.name ||
|
||||||
newHost.folder !== existingHost.folder ||
|
newHost.folder !== existingHost.folder ||
|
||||||
@@ -245,9 +221,8 @@ export function LeftSidebar({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
// Use a small delay to batch updates and reduce jittering
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setHosts(newHosts);
|
setHosts(newHosts);
|
||||||
prevHostsRef.current = newHosts;
|
prevHostsRef.current = newHosts;
|
||||||
@@ -264,7 +239,6 @@ export function LeftSidebar({
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchHosts]);
|
}, [fetchHosts]);
|
||||||
|
|
||||||
// Immediate refresh when SSH hosts are changed elsewhere in the app
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleHostsChanged = () => {
|
const handleHostsChanged = () => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
@@ -273,13 +247,11 @@ export function LeftSidebar({
|
|||||||
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||||
}, [fetchHosts]);
|
}, [fetchHosts]);
|
||||||
|
|
||||||
// Search debouncing
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||||
return () => clearTimeout(handler);
|
return () => clearTimeout(handler);
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
// Filter and organize hosts with stable references
|
|
||||||
const filteredHosts = React.useMemo(() => {
|
const filteredHosts = React.useMemo(() => {
|
||||||
if (!debouncedSearch.trim()) return hosts;
|
if (!debouncedSearch.trim()) return hosts;
|
||||||
const q = debouncedSearch.trim().toLowerCase();
|
const q = debouncedSearch.trim().toLowerCase();
|
||||||
@@ -323,68 +295,6 @@ export function LeftSidebar({
|
|||||||
return [...pinned, ...rest];
|
return [...pinned, ...rest];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggle = async (checked: boolean) => {
|
|
||||||
if (!isAdmin) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRegLoading(true);
|
|
||||||
const jwt = getCookie("jwt");
|
|
||||||
try {
|
|
||||||
await API.patch(
|
|
||||||
"/registration-allowed",
|
|
||||||
{allowed: checked},
|
|
||||||
{headers: {Authorization: `Bearer ${jwt}`}}
|
|
||||||
);
|
|
||||||
setAllowRegistration(checked);
|
|
||||||
} catch (e) {
|
|
||||||
} finally {
|
|
||||||
setRegLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOidcLoading(true);
|
|
||||||
setOidcError(null);
|
|
||||||
setOidcSuccess(null);
|
|
||||||
|
|
||||||
const requiredFields = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
|
|
||||||
const missingFields = requiredFields.filter(field => !oidcConfig[field as keyof typeof oidcConfig]);
|
|
||||||
|
|
||||||
if (missingFields.length > 0) {
|
|
||||||
setOidcError(`Missing required fields: ${missingFields.join(', ')}`);
|
|
||||||
setOidcLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const jwt = getCookie("jwt");
|
|
||||||
try {
|
|
||||||
await API.post(
|
|
||||||
"/oidc-config",
|
|
||||||
oidcConfig,
|
|
||||||
{headers: {Authorization: `Bearer ${jwt}`}}
|
|
||||||
);
|
|
||||||
setOidcSuccess("OIDC configuration updated successfully!");
|
|
||||||
} catch (err: any) {
|
|
||||||
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
|
|
||||||
} finally {
|
|
||||||
setOidcLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOIDCConfigChange = (field: string, value: string) => {
|
|
||||||
setOidcConfig(prev => ({
|
|
||||||
...prev,
|
|
||||||
[field]: value
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAccount = async (e: React.FormEvent) => {
|
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDeleteLoading(true);
|
setDeleteLoading(true);
|
||||||
@@ -416,7 +326,7 @@ export function LeftSidebar({
|
|||||||
if (!jwt || !isAdmin) {
|
if (!jwt || !isAdmin) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUsersLoading(true);
|
setUsersLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await API.get("/list", {
|
const response = await API.get("/list", {
|
||||||
@@ -427,7 +337,6 @@ export function LeftSidebar({
|
|||||||
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
|
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
|
||||||
setAdminCount(adminUsers.length);
|
setAdminCount(adminUsers.length);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to fetch users:", err);
|
|
||||||
} finally {
|
} finally {
|
||||||
setUsersLoading(false);
|
setUsersLoading(false);
|
||||||
}
|
}
|
||||||
@@ -439,7 +348,7 @@ export function LeftSidebar({
|
|||||||
if (!jwt || !isAdmin) {
|
if (!jwt || !isAdmin) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await API.get("/list", {
|
const response = await API.get("/list", {
|
||||||
headers: {Authorization: `Bearer ${jwt}`}
|
headers: {Authorization: `Bearer ${jwt}`}
|
||||||
@@ -447,7 +356,6 @@ export function LeftSidebar({
|
|||||||
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
|
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
|
||||||
setAdminCount(adminUsers.length);
|
setAdminCount(adminUsers.length);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to fetch admin count:", err);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -494,7 +402,6 @@ export function LeftSidebar({
|
|||||||
);
|
);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to remove admin status:", err);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -513,7 +420,6 @@ export function LeftSidebar({
|
|||||||
});
|
});
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to delete user:", err);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -536,14 +442,15 @@ export function LeftSidebar({
|
|||||||
<Separator className="p-0.25"/>
|
<Separator className="p-0.25"/>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarGroup className="!m-0 !p-0 !-mb-2">
|
<SidebarGroup className="!m-0 !p-0 !-mb-2">
|
||||||
<Button className="m-2 flex flex-row font-semibold" variant="outline" onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive} title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
|
<Button className="m-2 flex flex-row font-semibold" variant="outline"
|
||||||
|
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
|
||||||
|
title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
|
||||||
<HardDrive strokeWidth="2.5"/>
|
<HardDrive strokeWidth="2.5"/>
|
||||||
Host Manager
|
Host Manager
|
||||||
</Button>
|
</Button>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
<Separator className="p-0.25"/>
|
<Separator className="p-0.25"/>
|
||||||
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
|
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
|
||||||
{/* Search Input */}
|
|
||||||
<div className="bg-[#131316] rounded-lg">
|
<div className="bg-[#131316] rounded-lg">
|
||||||
<Input
|
<Input
|
||||||
value={search}
|
value={search}
|
||||||
@@ -553,17 +460,16 @@ export function LeftSidebar({
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Display */}
|
|
||||||
{hostsError && (
|
{hostsError && (
|
||||||
<div className="px-1">
|
<div className="px-1">
|
||||||
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
<div
|
||||||
|
className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
||||||
{hostsError}
|
{hostsError}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
{hostsLoading && (
|
{hostsLoading && (
|
||||||
<div className="px-4 pb-2">
|
<div className="px-4 pb-2">
|
||||||
<div className="text-xs text-muted-foreground text-center">
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
@@ -572,7 +478,6 @@ export function LeftSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hosts by Folder */}
|
|
||||||
{sortedFolders.map((folder, idx) => (
|
{sortedFolders.map((folder, idx) => (
|
||||||
<FolderCard
|
<FolderCard
|
||||||
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
|
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
|
||||||
@@ -608,7 +513,7 @@ export function LeftSidebar({
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||||
onSelect={() => {
|
onClick={() => {
|
||||||
if (isAdmin) openAdminTab();
|
if (isAdmin) openAdminTab();
|
||||||
}}>
|
}}>
|
||||||
<span>Admin Settings</span>
|
<span>Admin Settings</span>
|
||||||
@@ -616,12 +521,12 @@ export function LeftSidebar({
|
|||||||
)}
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||||
onSelect={handleLogout}>
|
onClick={handleLogout}>
|
||||||
<span>Sign out</span>
|
<span>Sign out</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||||
onSelect={() => setDeleteAccountOpen(true)}
|
onClick={() => setDeleteAccountOpen(true)}
|
||||||
disabled={isAdmin && adminCount <= 1}
|
disabled={isAdmin && adminCount <= 1}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -635,383 +540,74 @@ export function LeftSidebar({
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
{/* Admin Settings Sheet */}
|
|
||||||
{isAdmin && (
|
|
||||||
<Sheet modal={false} open={adminSheetOpen && isAdmin} onOpenChange={(open) => {
|
|
||||||
if (open && !isAdmin) return;
|
|
||||||
setAdminSheetOpen(open);
|
|
||||||
}}>
|
|
||||||
<SheetContent side="left" className="w-[700px] max-h-screen overflow-y-auto">
|
|
||||||
<SheetHeader className="px-6 pb-4">
|
|
||||||
<SheetTitle>Admin Settings</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<div className="px-6">
|
|
||||||
<Tabs defaultValue="registration" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-4 mb-6">
|
|
||||||
<TabsTrigger value="registration" className="flex items-center gap-2">
|
|
||||||
<Users className="h-4 w-4"/>
|
|
||||||
Reg
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="oidc" className="flex items-center gap-2">
|
|
||||||
<Shield className="h-4 w-4"/>
|
|
||||||
OIDC
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="users" className="flex items-center gap-2">
|
|
||||||
<Users className="h-4 w-4"/>
|
|
||||||
Users
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="admins" className="flex items-center gap-2">
|
|
||||||
<Shield className="h-4 w-4"/>
|
|
||||||
Admins
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Registration Settings Tab */}
|
</Sidebar>
|
||||||
<TabsContent value="registration" className="space-y-6">
|
<SidebarInset>
|
||||||
<div className="space-y-4">
|
{children}
|
||||||
<h3 className="text-lg font-semibold">User Registration</h3>
|
</SidebarInset>
|
||||||
<label className="flex items-center gap-2">
|
</SidebarProvider>
|
||||||
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
|
|
||||||
disabled={regLoading}/>
|
|
||||||
Allow new account registration
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* OIDC Configuration Tab */}
|
{!isSidebarOpen && (
|
||||||
<TabsContent value="oidc" className="space-y-6">
|
<div
|
||||||
<div className="space-y-4">
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
<h3 className="text-lg font-semibold">External Authentication
|
className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md">
|
||||||
(OIDC)</h3>
|
<ChevronRight size={10}/>
|
||||||
<p className="text-sm text-muted-foreground">
|
</div>
|
||||||
Configure external identity provider for OIDC/OAuth2 authentication.
|
)}
|
||||||
Users will see an "External" login option once configured.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{oidcError && (
|
{deleteAccountOpen && (
|
||||||
<Alert variant="destructive">
|
<div
|
||||||
<AlertTitle>Error</AlertTitle>
|
className="fixed inset-0 z-[999999] flex"
|
||||||
<AlertDescription>{oidcError}</AlertDescription>
|
style={{
|
||||||
</Alert>
|
position: 'fixed',
|
||||||
)}
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 999999,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
isolation: 'isolate',
|
||||||
|
transform: 'translateZ(0)',
|
||||||
|
willChange: 'z-index'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-[400px] h-full bg-[#18181b] border-r-2 border-[#303032] flex flex-col shadow-2xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#18181b',
|
||||||
|
boxShadow: '4px 0 20px rgba(0, 0, 0, 0.5)',
|
||||||
|
zIndex: 9999999,
|
||||||
|
position: 'relative',
|
||||||
|
isolation: 'isolate',
|
||||||
|
transform: 'translateZ(0)'
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Delete Account</h2>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteAccountOpen(false);
|
||||||
|
setDeletePassword("");
|
||||||
|
setDeleteError(null);
|
||||||
|
}}
|
||||||
|
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||||
|
title="Close Delete Account"
|
||||||
|
>
|
||||||
|
<span className="text-lg font-bold leading-none">×</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<Label htmlFor="client_id">Client ID</Label>
|
<div className="text-sm text-gray-300">
|
||||||
<Input
|
|
||||||
id="client_id"
|
|
||||||
value={oidcConfig.client_id}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
|
|
||||||
placeholder="your-client-id"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="client_secret">Client Secret</Label>
|
|
||||||
<Input
|
|
||||||
id="client_secret"
|
|
||||||
type="password"
|
|
||||||
value={oidcConfig.client_secret}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
|
||||||
placeholder="your-client-secret"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="authorization_url">Authorization URL</Label>
|
|
||||||
<Input
|
|
||||||
id="authorization_url"
|
|
||||||
value={oidcConfig.authorization_url}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
|
|
||||||
placeholder="https://your-provider.com/application/o/authorize/"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="issuer_url">Issuer URL</Label>
|
|
||||||
<Input
|
|
||||||
id="issuer_url"
|
|
||||||
value={oidcConfig.issuer_url}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
|
|
||||||
placeholder="https://your-provider.com/application/o/termix/"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="token_url">Token URL</Label>
|
|
||||||
<Input
|
|
||||||
id="token_url"
|
|
||||||
value={oidcConfig.token_url}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
|
|
||||||
placeholder="https://your-provider.com/application/o/token/"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="identifier_path">User Identifier Path</Label>
|
|
||||||
<Input
|
|
||||||
id="identifier_path"
|
|
||||||
value={oidcConfig.identifier_path}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
|
|
||||||
placeholder="sub"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
JSON path to extract user ID from JWT (e.g., "sub", "email",
|
|
||||||
"preferred_username")
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name_path">Display Name Path</Label>
|
|
||||||
<Input
|
|
||||||
id="name_path"
|
|
||||||
value={oidcConfig.name_path}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
|
|
||||||
placeholder="name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
JSON path to extract display name from JWT (e.g., "name",
|
|
||||||
"preferred_username")
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="scopes">Scopes</Label>
|
|
||||||
<Input
|
|
||||||
id="scopes"
|
|
||||||
value={oidcConfig.scopes}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
|
|
||||||
placeholder="openid email profile"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Space-separated list of OAuth2 scopes to request
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="flex-1"
|
|
||||||
disabled={oidcLoading}
|
|
||||||
>
|
|
||||||
{oidcLoading ? "Saving..." : "Save Configuration"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setOidcConfig({
|
|
||||||
client_id: '',
|
|
||||||
client_secret: '',
|
|
||||||
issuer_url: '',
|
|
||||||
authorization_url: '',
|
|
||||||
token_url: '',
|
|
||||||
identifier_path: 'sub',
|
|
||||||
name_path: 'name',
|
|
||||||
scopes: 'openid email profile'
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{oidcSuccess && (
|
|
||||||
<Alert>
|
|
||||||
<AlertTitle>Success</AlertTitle>
|
|
||||||
<AlertDescription>{oidcSuccess}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Users Management Tab */}
|
|
||||||
<TabsContent value="users" className="space-y-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-semibold">User Management</h3>
|
|
||||||
<Button
|
|
||||||
onClick={fetchUsers}
|
|
||||||
disabled={usersLoading}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{usersLoading ? "Loading..." : "Refresh"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{usersLoading ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
Loading users...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="border rounded-md overflow-hidden">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="px-4">Username</TableHead>
|
|
||||||
<TableHead className="px-4">Type</TableHead>
|
|
||||||
<TableHead className="px-4">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{users.map((user) => (
|
|
||||||
<TableRow key={user.id}>
|
|
||||||
<TableCell className="px-4 font-medium">
|
|
||||||
{user.username}
|
|
||||||
{user.is_admin && (
|
|
||||||
<span
|
|
||||||
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
|
||||||
Admin
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-4">
|
|
||||||
{user.is_oidc ? "External" : "Local"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => deleteUser(user.username)}
|
|
||||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
||||||
disabled={user.is_admin}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4"/>
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Admins Management Tab */}
|
|
||||||
<TabsContent value="admins" className="space-y-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-lg font-semibold">Admin Management</h3>
|
|
||||||
|
|
||||||
{/* Add New Admin Form */}
|
|
||||||
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
|
|
||||||
<h4 className="font-medium">Make User Admin</h4>
|
|
||||||
<form onSubmit={makeUserAdmin} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="new-admin-username">Username</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="new-admin-username"
|
|
||||||
value={newAdminUsername}
|
|
||||||
onChange={(e) => setNewAdminUsername(e.target.value)}
|
|
||||||
placeholder="Enter username to make admin"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={makeAdminLoading || !newAdminUsername.trim()}
|
|
||||||
>
|
|
||||||
{makeAdminLoading ? "Adding..." : "Make Admin"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{makeAdminError && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{makeAdminError}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{makeAdminSuccess && (
|
|
||||||
<Alert>
|
|
||||||
<AlertTitle>Success</AlertTitle>
|
|
||||||
<AlertDescription>{makeAdminSuccess}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current Admins Table */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="font-medium">Current Admins</h4>
|
|
||||||
<div className="border rounded-md overflow-hidden">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="px-4">Username</TableHead>
|
|
||||||
<TableHead className="px-4">Type</TableHead>
|
|
||||||
<TableHead className="px-4">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{users.filter(user => user.is_admin).map((admin) => (
|
|
||||||
<TableRow key={admin.id}>
|
|
||||||
<TableCell className="px-4 font-medium">
|
|
||||||
{admin.username}
|
|
||||||
<span
|
|
||||||
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
|
||||||
Admin
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-4">
|
|
||||||
{admin.is_oidc ? "External" : "Local"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => removeAdminStatus(admin.username)}
|
|
||||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
|
||||||
disabled={admin.username === username}
|
|
||||||
>
|
|
||||||
<Shield className="h-4 w-4"/>
|
|
||||||
Remove Admin
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SheetFooter className="px-6 pt-6 pb-6">
|
|
||||||
<Separator className="p-0.25 mt-2 mb-2"/>
|
|
||||||
<SheetClose asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</SheetClose>
|
|
||||||
</SheetFooter>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete Account Confirmation Sheet */}
|
|
||||||
<Sheet modal={false} open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
|
||||||
<SheetContent side="left" className="w-[400px]">
|
|
||||||
<SheetHeader className="pb-0">
|
|
||||||
<SheetTitle>Delete Account</SheetTitle>
|
|
||||||
<SheetDescription>
|
|
||||||
This action cannot be undone. This will permanently delete your account and all
|
This action cannot be undone. This will permanently delete your account and all
|
||||||
associated data.
|
associated data.
|
||||||
</SheetDescription>
|
</div>
|
||||||
</SheetHeader>
|
|
||||||
<div className="pb-4 px-4 flex flex-col gap-4">
|
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTitle>Warning</AlertTitle>
|
<AlertTitle>Warning</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
@@ -1076,19 +672,18 @@ export function LeftSidebar({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</div>
|
||||||
</Sheet>
|
</div>
|
||||||
</Sidebar>
|
|
||||||
<SidebarInset>
|
|
||||||
{children}
|
|
||||||
</SidebarInset>
|
|
||||||
</SidebarProvider>
|
|
||||||
|
|
||||||
{!isSidebarOpen && (
|
<div
|
||||||
<div
|
className="flex-1"
|
||||||
onClick={() => setIsSidebarOpen(true)}
|
onClick={() => {
|
||||||
className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md">
|
setDeleteAccountOpen(false);
|
||||||
<ChevronRight size={10} />
|
setDeletePassword("");
|
||||||
|
setDeleteError(null);
|
||||||
|
}}
|
||||||
|
style={{cursor: 'pointer'}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import {Home, SeparatorVertical, X, Terminal as TerminalIcon, Server as ServerIcon, Folder as FolderIcon} from "lucide-react";
|
import {
|
||||||
|
Home,
|
||||||
|
SeparatorVertical,
|
||||||
|
X,
|
||||||
|
Terminal as TerminalIcon,
|
||||||
|
Server as ServerIcon,
|
||||||
|
Folder as FolderIcon
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
interface TabProps {
|
interface TabProps {
|
||||||
tabType: string;
|
tabType: string;
|
||||||
@@ -17,7 +24,19 @@ interface TabProps {
|
|||||||
disableClose?: boolean;
|
disableClose?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, canSplit = false, canClose = false, disableActivate = false, disableSplit = false, disableClose = false}: TabProps): React.ReactElement {
|
export function Tab({
|
||||||
|
tabType,
|
||||||
|
title,
|
||||||
|
isActive,
|
||||||
|
onActivate,
|
||||||
|
onClose,
|
||||||
|
onSplit,
|
||||||
|
canSplit = false,
|
||||||
|
canClose = false,
|
||||||
|
disableActivate = false,
|
||||||
|
disableSplit = false,
|
||||||
|
disableClose = false
|
||||||
|
}: TabProps): React.ReactElement {
|
||||||
if (tabType === "home") {
|
if (tabType === "home") {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -42,7 +61,8 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can
|
|||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
disabled={disableActivate}
|
disabled={disableActivate}
|
||||||
>
|
>
|
||||||
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ? <FolderIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>}
|
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ?
|
||||||
|
<FolderIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>}
|
||||||
{title || (isServer ? 'Server' : isFileManager ? 'file_manager' : 'Terminal')}
|
{title || (isServer ? 'Server' : isFileManager ? 'file_manager' : 'Terminal')}
|
||||||
</Button>
|
</Button>
|
||||||
{canSplit && (
|
{canSplit && (
|
||||||
@@ -53,7 +73,7 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can
|
|||||||
disabled={disableSplit}
|
disabled={disableSplit}
|
||||||
title={disableSplit ? 'Cannot split this tab' : 'Split'}
|
title={disableSplit ? 'Cannot split this tab' : 'Split'}
|
||||||
>
|
>
|
||||||
<SeparatorVertical className="w-[28px] h-[28px]" />
|
<SeparatorVertical className="w-[28px] h-[28px]"/>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canClose && (
|
{canClose && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { createContext, useContext, useState, useRef, type ReactNode } from 'react';
|
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
||||||
|
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -33,9 +33,9 @@ interface TabProviderProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabProvider({ children }: TabProviderProps) {
|
export function TabProvider({children}: TabProviderProps) {
|
||||||
const [tabs, setTabs] = useState<Tab[]>([
|
const [tabs, setTabs] = useState<Tab[]>([
|
||||||
{ id: 1, type: 'home', title: 'Home' }
|
{id: 1, type: 'home', title: 'Home'}
|
||||||
]);
|
]);
|
||||||
const [currentTab, setCurrentTab] = useState<number>(1);
|
const [currentTab, setCurrentTab] = useState<number>(1);
|
||||||
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
||||||
@@ -44,7 +44,6 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
|
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
|
||||||
const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal');
|
const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal');
|
||||||
const baseTitle = (desiredTitle || defaultTitle).trim();
|
const baseTitle = (desiredTitle || defaultTitle).trim();
|
||||||
// Extract base name without trailing " (n)"
|
|
||||||
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
|
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
|
||||||
const root = match ? match[1] : baseTitle;
|
const root = match ? match[1] : baseTitle;
|
||||||
|
|
||||||
@@ -64,7 +63,6 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!rootUsed) return root;
|
if (!rootUsed) return root;
|
||||||
// Start at (2) for the second instance
|
|
||||||
let n = 2;
|
let n = 2;
|
||||||
while (usedNumbers.has(n)) n += 1;
|
while (usedNumbers.has(n)) n += 1;
|
||||||
return `${root} (${n})`;
|
return `${root} (${n})`;
|
||||||
@@ -74,8 +72,8 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
const id = nextTabId.current++;
|
const id = nextTabId.current++;
|
||||||
const needsUniqueTitle = tabData.type === 'terminal' || tabData.type === 'server' || tabData.type === 'file_manager';
|
const needsUniqueTitle = tabData.type === 'terminal' || tabData.type === 'server' || tabData.type === 'file_manager';
|
||||||
const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : (tabData.title || '');
|
const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : (tabData.title || '');
|
||||||
const newTab: Tab = {
|
const newTab: Tab = {
|
||||||
...tabData,
|
...tabData,
|
||||||
id,
|
id,
|
||||||
title: effectiveTitle,
|
title: effectiveTitle,
|
||||||
terminalRef: tabData.type === 'terminal' ? React.createRef<any>() : undefined
|
terminalRef: tabData.type === 'terminal' ? React.createRef<any>() : undefined
|
||||||
@@ -92,10 +90,10 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") {
|
if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") {
|
||||||
tab.terminalRef.current.disconnect();
|
tab.terminalRef.current.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
setTabs(prev => prev.filter(tab => tab.id !== tabId));
|
setTabs(prev => prev.filter(tab => tab.id !== tabId));
|
||||||
setAllSplitScreenTab(prev => prev.filter(id => id !== tabId));
|
setAllSplitScreenTab(prev => prev.filter(id => id !== tabId));
|
||||||
|
|
||||||
if (currentTab === tabId) {
|
if (currentTab === tabId) {
|
||||||
const remainingTabs = tabs.filter(tab => tab.id !== tabId);
|
const remainingTabs = tabs.filter(tab => tab.id !== tabId);
|
||||||
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1);
|
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1);
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any;
|
const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any;
|
||||||
const leftPosition = state === "collapsed" ? "26px" : "264px";
|
const leftPosition = state === "collapsed" ? "26px" : "264px";
|
||||||
|
|
||||||
// SSH Tools state
|
|
||||||
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
||||||
@@ -47,7 +46,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
|
|
||||||
const handleStartRecording = () => {
|
const handleStartRecording = () => {
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
// Focus on the input when recording starts
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const input = document.getElementById('ssh-tools-input') as HTMLInputElement;
|
const input = document.getElementById('ssh-tools-input') as HTMLInputElement;
|
||||||
if (input) input.focus();
|
if (input) input.focus();
|
||||||
@@ -60,7 +58,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
// Don't handle input change for special keys - let onKeyDown handle them
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
@@ -69,9 +66,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
const value = e.currentTarget.value;
|
const value = e.currentTarget.value;
|
||||||
let commandToSend = '';
|
let commandToSend = '';
|
||||||
|
|
||||||
// Handle special keys and control sequences
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
// Control sequences
|
|
||||||
if (e.key === 'c') {
|
if (e.key === 'c') {
|
||||||
commandToSend = '\x03'; // Ctrl+C (SIGINT)
|
commandToSend = '\x03'; // Ctrl+C (SIGINT)
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -177,7 +172,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the command to all selected terminals
|
|
||||||
if (commandToSend) {
|
if (commandToSend) {
|
||||||
selectedTabIds.forEach(tabId => {
|
selectedTabIds.forEach(tabId => {
|
||||||
const tab = tabs.find((t: any) => t.id === tabId);
|
const tab = tabs.find((t: any) => t.id === tabId);
|
||||||
@@ -190,8 +184,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (selectedTabIds.length === 0) return;
|
if (selectedTabIds.length === 0) return;
|
||||||
|
|
||||||
// Handle regular character input
|
|
||||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
||||||
const char = e.key;
|
const char = e.key;
|
||||||
selectedTabIds.forEach(tabId => {
|
selectedTabIds.forEach(tabId => {
|
||||||
@@ -209,7 +202,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
|
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
|
||||||
const currentTabIsAdmin = currentTabObj?.type === 'admin';
|
const currentTabIsAdmin = currentTabObj?.type === 'admin';
|
||||||
|
|
||||||
// Get terminal tabs for selection
|
|
||||||
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal');
|
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal');
|
||||||
|
|
||||||
function getCookie(name: string) {
|
function getCookie(name: string) {
|
||||||
@@ -237,7 +229,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
padding: "0"
|
padding: "0"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
|
<div
|
||||||
|
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
|
||||||
{tabs.map((tab: any) => {
|
{tabs.map((tab: any) => {
|
||||||
const isActive = tab.id === currentTab;
|
const isActive = tab.id === currentTab;
|
||||||
const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id);
|
const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id);
|
||||||
@@ -246,9 +239,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
const isFileManager = tab.type === 'file_manager';
|
const isFileManager = tab.type === 'file_manager';
|
||||||
const isSshManager = tab.type === 'ssh_manager';
|
const isSshManager = tab.type === 'ssh_manager';
|
||||||
const isAdmin = tab.type === 'admin';
|
const isAdmin = tab.type === 'admin';
|
||||||
// Split availability
|
|
||||||
const isSplittable = isTerminal || isServer || isFileManager;
|
const isSplittable = isTerminal || isServer || isFileManager;
|
||||||
// Disable split entirely when on Home or SSH Manager
|
|
||||||
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
|
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
|
||||||
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
|
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
|
||||||
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive);
|
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive);
|
||||||
@@ -296,13 +287,12 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
<div
|
<div
|
||||||
onClick={() => setIsTopbarOpen(true)}
|
onClick={() => setIsTopbarOpen(true)}
|
||||||
className="absolute top-0 left-0 w-full h-[10px] bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md">
|
className="absolute top-0 left-0 w-full h-[10px] bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md">
|
||||||
<ChevronDown size={10} />
|
<ChevronDown size={10}/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Custom SSH Tools Overlay */}
|
|
||||||
{toolsSheetOpen && (
|
{toolsSheetOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[999999] flex justify-end"
|
className="fixed inset-0 z-[999999] flex justify-end"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
@@ -316,13 +306,13 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
transform: 'translateZ(0)'
|
transform: 'translateZ(0)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => setToolsSheetOpen(false)}
|
onClick={() => setToolsSheetOpen(false)}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{cursor: 'pointer'}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="w-[400px] h-full bg-[#18181b] border-l-2 border-[#303032] flex flex-col shadow-2xl"
|
className="w-[400px] h-full bg-[#18181b] border-l-2 border-[#303032] flex flex-col shadow-2xl"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#18181b',
|
backgroundColor: '#18181b',
|
||||||
@@ -346,7 +336,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
<span className="text-lg font-bold leading-none">×</span>
|
<span className="text-lg font-bold leading-none">×</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="font-semibold">
|
<h1 className="font-semibold">
|
||||||
@@ -374,11 +364,12 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isRecording && (
|
{isRecording && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white">Select terminals:</label>
|
<label className="text-sm font-medium text-white">Select
|
||||||
|
terminals:</label>
|
||||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
|
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
|
||||||
{terminalTabs.map(tab => (
|
{terminalTabs.map(tab => (
|
||||||
<Button
|
<Button
|
||||||
@@ -387,8 +378,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
|
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
|
||||||
selectedTabIds.includes(tab.id)
|
selectedTabIds.includes(tab.id)
|
||||||
? 'bg-blue-600 text-white border-blue-700 hover:bg-blue-700'
|
? 'bg-blue-600 text-white border-blue-700 hover:bg-blue-700'
|
||||||
: 'bg-transparent text-gray-300 border-gray-500 hover:bg-gray-700'
|
: 'bg-transparent text-gray-300 border-gray-500 hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleTabToggle(tab.id)}
|
onClick={() => handleTabToggle(tab.id)}
|
||||||
@@ -398,9 +389,10 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white">Type commands (all keys supported):</label>
|
<label className="text-sm font-medium text-white">Type commands (all
|
||||||
|
keys supported):</label>
|
||||||
<Input
|
<Input
|
||||||
id="ssh-tools-input"
|
id="ssh-tools-input"
|
||||||
placeholder="Type here"
|
placeholder="Type here"
|
||||||
@@ -411,7 +403,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Commands will be sent to {selectedTabIds.length} selected terminal(s).
|
Commands will be sent to {selectedTabIds.length} selected
|
||||||
|
terminal(s).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -426,8 +419,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="enable-copy-paste"
|
id="enable-copy-paste"
|
||||||
onCheckedChange={updateRightClickCopyPaste}
|
onCheckedChange={updateRightClickCopyPaste}
|
||||||
defaultChecked={getCookie("rightClickCopyPaste") === "true"}
|
defaultChecked={getCookie("rightClickCopyPaste") === "true"}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -74,11 +74,9 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
|
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
// New state for operations
|
|
||||||
const [showOperations, setShowOperations] = useState(false);
|
const [showOperations, setShowOperations] = useState(false);
|
||||||
const [currentPath, setCurrentPath] = useState('/');
|
const [currentPath, setCurrentPath] = useState('/');
|
||||||
|
|
||||||
// Delete modal state
|
|
||||||
const [deletingItem, setDeletingItem] = useState<any | null>(null);
|
const [deletingItem, setDeletingItem] = useState<any | null>(null);
|
||||||
|
|
||||||
const sidebarRef = useRef<any>(null);
|
const sidebarRef = useRef<any>(null);
|
||||||
@@ -86,7 +84,6 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) {
|
if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) {
|
||||||
setCurrentHost(initialHost);
|
setCurrentHost(initialHost);
|
||||||
// Defer to ensure sidebar is mounted
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
const path = initialHost.defaultPath || '/';
|
const path = initialHost.defaultPath || '/';
|
||||||
@@ -448,16 +445,13 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Host is locked; no external host change from UI
|
|
||||||
const handleHostChange = (_host: SSHHost | null) => {
|
const handleHostChange = (_host: SSHHost | null) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOperationComplete = () => {
|
const handleOperationComplete = () => {
|
||||||
// Refresh the sidebar files
|
|
||||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
||||||
sidebarRef.current.fetchFiles();
|
sidebarRef.current.fetchFiles();
|
||||||
}
|
}
|
||||||
// Refresh home data
|
|
||||||
if (currentHost) {
|
if (currentHost) {
|
||||||
fetchHomeData();
|
fetchHomeData();
|
||||||
}
|
}
|
||||||
@@ -471,22 +465,19 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
toast.error(error);
|
toast.error(error);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to update current path from sidebar
|
|
||||||
const updateCurrentPath = (newPath: string) => {
|
const updateCurrentPath = (newPath: string) => {
|
||||||
setCurrentPath(newPath);
|
setCurrentPath(newPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to handle delete from sidebar
|
|
||||||
const handleDeleteFromSidebar = (item: any) => {
|
const handleDeleteFromSidebar = (item: any) => {
|
||||||
setDeletingItem(item);
|
setDeletingItem(item);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to perform the actual delete
|
|
||||||
const performDelete = async (item: any) => {
|
const performDelete = async (item: any) => {
|
||||||
if (!currentHost?.id) return;
|
if (!currentHost?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { deleteSSHItem } = await import('@/ui/main-axios.ts');
|
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
||||||
await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
|
await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
|
||||||
toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`);
|
toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`);
|
||||||
setDeletingItem(null);
|
setDeletingItem(null);
|
||||||
@@ -552,8 +543,8 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
</div>
|
</div>
|
||||||
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 50, zIndex: 30}}>
|
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 50, zIndex: 30}}>
|
||||||
<div className="flex items-center w-full bg-[#18181b] border-b-2 border-[#303032] h-[50px] relative">
|
<div className="flex items-center w-full bg-[#18181b] border-b-2 border-[#303032] h-[50px] relative">
|
||||||
{/* Tab list scrollable area */}
|
<div
|
||||||
<div className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
|
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
|
||||||
<FIleManagerTopNavbar
|
<FIleManagerTopNavbar
|
||||||
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
|
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
@@ -577,7 +568,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
)}
|
)}
|
||||||
title="File Operations"
|
title="File Operations"
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -591,7 +582,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : ''
|
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSaving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
{isSaving ? <RefreshCw className="h-4 w-4 animate-spin"/> : <Save className="h-4 w-4"/>}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -608,9 +599,6 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column'
|
flexDirection: 'column'
|
||||||
}}>
|
}}>
|
||||||
{/* Success/Error Messages */}
|
|
||||||
{/* The custom alert divs are removed, so this block is no longer needed. */}
|
|
||||||
|
|
||||||
{activeTab === 'home' ? (
|
{activeTab === 'home' ? (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -658,17 +646,14 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
|
||||||
{deletingItem && (
|
{deletingItem && (
|
||||||
<div className="fixed inset-0 z-[99999]">
|
<div className="fixed inset-0 z-[99999]">
|
||||||
{/* Backdrop */}
|
|
||||||
<div className="absolute inset-0 bg-black/60"></div>
|
<div className="absolute inset-0 bg-black/60"></div>
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
<div className="relative h-full flex items-center justify-center">
|
<div className="relative h-full flex items-center justify-center">
|
||||||
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 max-w-md mx-4 shadow-2xl">
|
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 max-w-md mx-4 shadow-2xl">
|
||||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
<Trash2 className="w-5 h-5 text-red-400" />
|
<Trash2 className="w-5 h-5 text-red-400"/>
|
||||||
Confirm Delete
|
Confirm Delete
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-white mb-4">
|
<p className="text-white mb-4">
|
||||||
|
|||||||
@@ -32,17 +32,17 @@ interface FileManagerHomeViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FileManagerHomeView({
|
export function FileManagerHomeView({
|
||||||
recent,
|
recent,
|
||||||
pinned,
|
pinned,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
onRemoveRecent,
|
onRemoveRecent,
|
||||||
onPinFile,
|
onPinFile,
|
||||||
onUnpinFile,
|
onUnpinFile,
|
||||||
onOpenShortcut,
|
onOpenShortcut,
|
||||||
onRemoveShortcut,
|
onRemoveShortcut,
|
||||||
onAddShortcut
|
onAddShortcut
|
||||||
}: FileManagerHomeViewProps) {
|
}: FileManagerHomeViewProps) {
|
||||||
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
||||||
const [newShortcut, setNewShortcut] = useState('');
|
const [newShortcut, setNewShortcut] = useState('');
|
||||||
|
|
||||||
@@ -128,7 +128,8 @@ export function FileManagerHomeView({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="recent" className="mt-0">
|
<TabsContent value="recent" className="mt-0">
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
<div
|
||||||
|
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||||
{recent.length === 0 ? (
|
{recent.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-8 col-span-full">
|
<div className="flex items-center justify-center py-8 col-span-full">
|
||||||
<span className="text-sm text-muted-foreground">No recent files.</span>
|
<span className="text-sm text-muted-foreground">No recent files.</span>
|
||||||
@@ -145,7 +146,8 @@ export function FileManagerHomeView({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="pinned" className="mt-0">
|
<TabsContent value="pinned" className="mt-0">
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
<div
|
||||||
|
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||||
{pinned.length === 0 ? (
|
{pinned.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-8 col-span-full">
|
<div className="flex items-center justify-center py-8 col-span-full">
|
||||||
<span className="text-sm text-muted-foreground">No pinned files.</span>
|
<span className="text-sm text-muted-foreground">No pinned files.</span>
|
||||||
@@ -190,7 +192,8 @@ export function FileManagerHomeView({
|
|||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
<div
|
||||||
|
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||||
{shortcuts.length === 0 ? (
|
{shortcuts.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-4 col-span-full">
|
<div className="flex items-center justify-center py-4 col-span-full">
|
||||||
<span className="text-sm text-muted-foreground">No shortcuts.</span>
|
<span className="text-sm text-muted-foreground">No shortcuts.</span>
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|||||||
}>>({});
|
}>>({});
|
||||||
const [fetchingFiles, setFetchingFiles] = useState(false);
|
const [fetchingFiles, setFetchingFiles] = useState(false);
|
||||||
|
|
||||||
// Context menu state
|
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
x: number;
|
x: number;
|
||||||
@@ -96,21 +95,18 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|||||||
item: null
|
item: null
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rename state
|
|
||||||
const [renamingItem, setRenamingItem] = useState<{
|
const [renamingItem, setRenamingItem] = useState<{
|
||||||
item: any;
|
item: any;
|
||||||
newName: string;
|
newName: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// when host changes, set path and connect
|
|
||||||
const nextPath = host?.defaultPath || '/';
|
const nextPath = host?.defaultPath || '/';
|
||||||
setCurrentPath(nextPath);
|
setCurrentPath(nextPath);
|
||||||
onPathChange?.(nextPath);
|
onPathChange?.(nextPath);
|
||||||
(async () => {
|
(async () => {
|
||||||
await connectToSSH(host);
|
await connectToSSH(host);
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [host?.id]);
|
}, [host?.id]);
|
||||||
|
|
||||||
async function connectToSSH(server: SSHHost): Promise<string | null> {
|
async function connectToSSH(server: SSHHost): Promise<string | null> {
|
||||||
@@ -282,35 +278,28 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|||||||
|
|
||||||
const handleContextMenu = (e: React.MouseEvent, item: any) => {
|
const handleContextMenu = (e: React.MouseEvent, item: any) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Get viewport dimensions
|
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const viewportHeight = window.innerHeight;
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
// Context menu dimensions (approximate)
|
const menuWidth = 160;
|
||||||
const menuWidth = 160; // min-w-[160px]
|
const menuHeight = 80;
|
||||||
const menuHeight = 80; // Approximate height for 2 menu items
|
|
||||||
|
|
||||||
// Calculate position
|
|
||||||
let x = e.clientX;
|
let x = e.clientX;
|
||||||
let y = e.clientY;
|
let y = e.clientY;
|
||||||
|
|
||||||
// Adjust X position if menu would go off right edge
|
|
||||||
if (x + menuWidth > viewportWidth) {
|
if (x + menuWidth > viewportWidth) {
|
||||||
x = e.clientX - menuWidth;
|
x = e.clientX - menuWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust Y position if menu would go off bottom edge
|
|
||||||
if (y + menuHeight > viewportHeight) {
|
if (y + menuHeight > viewportHeight) {
|
||||||
y = e.clientY - menuHeight;
|
y = e.clientY - menuHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure menu doesn't go off left edge
|
|
||||||
if (x < 0) {
|
if (x < 0) {
|
||||||
x = 0;
|
x = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure menu doesn't go off top edge
|
|
||||||
if (y < 0) {
|
if (y < 0) {
|
||||||
y = 0;
|
y = 0;
|
||||||
}
|
}
|
||||||
@@ -369,12 +358,10 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startDelete = (item: any) => {
|
const startDelete = (item: any) => {
|
||||||
// Call the parent's delete handler instead of managing locally
|
|
||||||
onDeleteItem?.(item);
|
onDeleteItem?.(item);
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close context menu when clicking outside
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = () => closeContextMenu();
|
const handleClickOutside = () => closeContextMenu();
|
||||||
document.addEventListener('click', handleClickOutside);
|
document.addEventListener('click', handleClickOutside);
|
||||||
@@ -392,7 +379,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|||||||
<div className="flex-1 w-full h-full flex flex-col bg-[#09090b] border-r-2 border-[#303032] overflow-hidden p-0 relative min-h-0">
|
<div className="flex-1 w-full h-full flex flex-col bg-[#09090b] border-r-2 border-[#303032] overflow-hidden p-0 relative min-h-0">
|
||||||
{host && (
|
{host && (
|
||||||
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
|
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
|
||||||
<div className="flex items-center gap-2 px-2 py-2 border-b-2 border-[#303032] bg-[#18181b] z-20" style={{maxWidth: 260}}>
|
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-[#303032] bg-[#18181b] z-20" style={{maxWidth: 260}}>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -440,7 +427,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|||||||
{filteredFiles.map((item: any) => {
|
{filteredFiles.map((item: any) => {
|
||||||
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
|
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
|
||||||
const isRenaming = renamingItem?.item?.path === item.path;
|
const isRenaming = renamingItem?.item?.path === item.path;
|
||||||
const isDeleting = false; // Deletion is handled by parent
|
const isDeleting = false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -519,7 +506,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to pin/unpin file:', err);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -555,7 +541,6 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context Menu */}
|
|
||||||
{contextMenu.visible && contextMenu.item && (
|
{contextMenu.visible && contextMenu.item && (
|
||||||
<div
|
<div
|
||||||
className="fixed z-[99998] bg-[#18181b] border-2 border-[#303032] rounded-lg shadow-xl py-1 min-w-[160px]"
|
className="fixed z-[99998] bg-[#18181b] border-2 border-[#303032] rounded-lg shadow-xl py-1 min-w-[160px]"
|
||||||
|
|||||||
@@ -42,69 +42,27 @@ interface FileManagerLeftSidebarVileViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FileManagerLeftSidebarFileViewer({
|
export function FileManagerLeftSidebarFileViewer({
|
||||||
sshConnections,
|
sshConnections,
|
||||||
onAddSSH,
|
onAddSSH,
|
||||||
onConnectSSH,
|
onConnectSSH,
|
||||||
onEditSSH,
|
onEditSSH,
|
||||||
onDeleteSSH,
|
onDeleteSSH,
|
||||||
onPinSSH,
|
onPinSSH,
|
||||||
currentPath,
|
currentPath,
|
||||||
files,
|
files,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
onOpenFolder,
|
onOpenFolder,
|
||||||
onStarFile,
|
onStarFile,
|
||||||
onDeleteFile,
|
onDeleteFile,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
isSSHMode,
|
isSSHMode,
|
||||||
onSwitchToLocal,
|
onSwitchToLocal,
|
||||||
onSwitchToSSH,
|
onSwitchToSSH,
|
||||||
currentSSH,
|
currentSSH,
|
||||||
}: FileManagerLeftSidebarVileViewerProps) {
|
}: FileManagerLeftSidebarVileViewerProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* SSH Connections */}
|
|
||||||
<div className="p-2 bg-[#18181b] border-b-2 border-[#303032]">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-xs text-muted-foreground font-semibold">SSH Connections</span>
|
|
||||||
<Button size="icon" variant="outline" onClick={onAddSSH} className="ml-2 h-7 w-7">
|
|
||||||
<Plus className="w-4 h-4"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<Button
|
|
||||||
variant={!isSSHMode ? 'secondary' : 'ghost'}
|
|
||||||
className="w-full justify-start text-left px-2 py-1.5 rounded"
|
|
||||||
onClick={onSwitchToLocal}
|
|
||||||
>
|
|
||||||
<Server className="w-4 h-4 mr-2"/> Local Files
|
|
||||||
</Button>
|
|
||||||
{sshConnections.map((conn) => (
|
|
||||||
<div key={conn.id} className="flex items-center gap-1 group">
|
|
||||||
<Button
|
|
||||||
variant={isSSHMode && currentSSH?.id === conn.id ? 'secondary' : 'ghost'}
|
|
||||||
className="flex-1 justify-start text-left px-2 py-1.5 rounded"
|
|
||||||
onClick={() => onSwitchToSSH(conn)}
|
|
||||||
>
|
|
||||||
<Link2 className="w-4 h-4 mr-2"/>
|
|
||||||
{conn.name || conn.ip}
|
|
||||||
{conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400"/>}
|
|
||||||
</Button>
|
|
||||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}>
|
|
||||||
<Pin
|
|
||||||
className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
|
|
||||||
</Button>
|
|
||||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}>
|
|
||||||
<Edit className="w-4 h-4"/>
|
|
||||||
</Button>
|
|
||||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteSSH(conn)}>
|
|
||||||
<Trash2 className="w-4 h-4 text-red-500"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* File/Folder Viewer */}
|
|
||||||
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
|
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, {useState, useRef} from 'react';
|
||||||
import { Button } from '@/components/ui/button.tsx';
|
import {Button} from '@/components/ui/button.tsx';
|
||||||
import { Input } from '@/components/ui/input.tsx';
|
import {Input} from '@/components/ui/input.tsx';
|
||||||
import { Card } from '@/components/ui/card.tsx';
|
import {Card} from '@/components/ui/card.tsx';
|
||||||
import { Separator } from '@/components/ui/separator.tsx';
|
import {Separator} from '@/components/ui/separator.tsx';
|
||||||
import {
|
import {
|
||||||
Upload,
|
Upload,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Trash2,
|
Trash2,
|
||||||
Edit3,
|
Edit3,
|
||||||
X,
|
X,
|
||||||
Check,
|
Check,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
FileText,
|
FileText,
|
||||||
Folder
|
Folder
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils.ts';
|
import {cn} from '@/lib/utils.ts';
|
||||||
|
|
||||||
interface FileManagerOperationsProps {
|
interface FileManagerOperationsProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
@@ -26,18 +26,18 @@ interface FileManagerOperationsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FileManagerOperations({
|
export function FileManagerOperations({
|
||||||
currentPath,
|
currentPath,
|
||||||
sshSessionId,
|
sshSessionId,
|
||||||
onOperationComplete,
|
onOperationComplete,
|
||||||
onError,
|
onError,
|
||||||
onSuccess
|
onSuccess
|
||||||
}: FileManagerOperationsProps) {
|
}: FileManagerOperationsProps) {
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
const [showCreateFile, setShowCreateFile] = useState(false);
|
const [showCreateFile, setShowCreateFile] = useState(false);
|
||||||
const [showCreateFolder, setShowCreateFolder] = useState(false);
|
const [showCreateFolder, setShowCreateFolder] = useState(false);
|
||||||
const [showDelete, setShowDelete] = useState(false);
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
const [showRename, setShowRename] = useState(false);
|
const [showRename, setShowRename] = useState(false);
|
||||||
|
|
||||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||||
const [newFileName, setNewFileName] = useState('');
|
const [newFileName, setNewFileName] = useState('');
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
@@ -46,18 +46,18 @@ export function FileManagerOperations({
|
|||||||
const [renamePath, setRenamePath] = useState('');
|
const [renamePath, setRenamePath] = useState('');
|
||||||
const [renameIsDirectory, setRenameIsDirectory] = useState(false);
|
const [renameIsDirectory, setRenameIsDirectory] = useState(false);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleFileUpload = async () => {
|
const handleFileUpload = async () => {
|
||||||
if (!uploadFile || !sshSessionId) return;
|
if (!uploadFile || !sshSessionId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const content = await uploadFile.text();
|
const content = await uploadFile.text();
|
||||||
const { uploadSSHFile } = await import('@/ui/main-axios.ts');
|
const {uploadSSHFile} = await import('@/ui/main-axios.ts');
|
||||||
|
|
||||||
await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
|
await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
|
||||||
onSuccess(`File "${uploadFile.name}" uploaded successfully`);
|
onSuccess(`File "${uploadFile.name}" uploaded successfully`);
|
||||||
setShowUpload(false);
|
setShowUpload(false);
|
||||||
@@ -72,11 +72,11 @@ export function FileManagerOperations({
|
|||||||
|
|
||||||
const handleCreateFile = async () => {
|
const handleCreateFile = async () => {
|
||||||
if (!newFileName.trim() || !sshSessionId) return;
|
if (!newFileName.trim() || !sshSessionId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { createSSHFile } = await import('@/ui/main-axios.ts');
|
const {createSSHFile} = await import('@/ui/main-axios.ts');
|
||||||
|
|
||||||
await createSSHFile(sshSessionId, currentPath, newFileName.trim());
|
await createSSHFile(sshSessionId, currentPath, newFileName.trim());
|
||||||
onSuccess(`File "${newFileName.trim()}" created successfully`);
|
onSuccess(`File "${newFileName.trim()}" created successfully`);
|
||||||
setShowCreateFile(false);
|
setShowCreateFile(false);
|
||||||
@@ -91,11 +91,11 @@ export function FileManagerOperations({
|
|||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
if (!newFolderName.trim() || !sshSessionId) return;
|
if (!newFolderName.trim() || !sshSessionId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { createSSHFolder } = await import('@/ui/main-axios.ts');
|
const {createSSHFolder} = await import('@/ui/main-axios.ts');
|
||||||
|
|
||||||
await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
|
await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
|
||||||
onSuccess(`Folder "${newFolderName.trim()}" created successfully`);
|
onSuccess(`Folder "${newFolderName.trim()}" created successfully`);
|
||||||
setShowCreateFolder(false);
|
setShowCreateFolder(false);
|
||||||
@@ -110,11 +110,11 @@ export function FileManagerOperations({
|
|||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!deletePath || !sshSessionId) return;
|
if (!deletePath || !sshSessionId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { deleteSSHItem } = await import('@/ui/main-axios.ts');
|
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
||||||
|
|
||||||
await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
|
await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
|
||||||
onSuccess(`${deleteIsDirectory ? 'Folder' : 'File'} deleted successfully`);
|
onSuccess(`${deleteIsDirectory ? 'Folder' : 'File'} deleted successfully`);
|
||||||
setShowDelete(false);
|
setShowDelete(false);
|
||||||
@@ -130,11 +130,11 @@ export function FileManagerOperations({
|
|||||||
|
|
||||||
const handleRename = async () => {
|
const handleRename = async () => {
|
||||||
if (!renamePath || !newName.trim() || !sshSessionId) return;
|
if (!renamePath || !newName.trim() || !sshSessionId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { renameSSHItem } = await import('@/ui/main-axios.ts');
|
const {renameSSHItem} = await import('@/ui/main-axios.ts');
|
||||||
|
|
||||||
await renameSSHItem(sshSessionId, renamePath, newName.trim());
|
await renameSSHItem(sshSessionId, renamePath, newName.trim());
|
||||||
onSuccess(`${renameIsDirectory ? 'Folder' : 'File'} renamed successfully`);
|
onSuccess(`${renameIsDirectory ? 'Folder' : 'File'} renamed successfully`);
|
||||||
setShowRename(false);
|
setShowRename(false);
|
||||||
@@ -179,7 +179,7 @@ export function FileManagerOperations({
|
|||||||
if (!sshSessionId) {
|
if (!sshSessionId) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-center">
|
<div className="p-4 text-center">
|
||||||
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2" />
|
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2"/>
|
||||||
<p className="text-sm text-muted-foreground">Connect to SSH to use file operations</p>
|
<p className="text-sm text-muted-foreground">Connect to SSH to use file operations</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -187,7 +187,6 @@ export function FileManagerOperations({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
{/* Operation Buttons */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -195,7 +194,7 @@ export function FileManagerOperations({
|
|||||||
onClick={() => setShowUpload(true)}
|
onClick={() => setShowUpload(true)}
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
<Upload className="w-4 h-4 mr-2"/>
|
||||||
Upload File
|
Upload File
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -204,7 +203,7 @@ export function FileManagerOperations({
|
|||||||
onClick={() => setShowCreateFile(true)}
|
onClick={() => setShowCreateFile(true)}
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
>
|
>
|
||||||
<FilePlus className="w-4 h-4 mr-2" />
|
<FilePlus className="w-4 h-4 mr-2"/>
|
||||||
New File
|
New File
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -213,7 +212,7 @@ export function FileManagerOperations({
|
|||||||
onClick={() => setShowCreateFolder(true)}
|
onClick={() => setShowCreateFolder(true)}
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
>
|
>
|
||||||
<FolderPlus className="w-4 h-4 mr-2" />
|
<FolderPlus className="w-4 h-4 mr-2"/>
|
||||||
New Folder
|
New Folder
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -222,7 +221,7 @@ export function FileManagerOperations({
|
|||||||
onClick={() => setShowRename(true)}
|
onClick={() => setShowRename(true)}
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
>
|
>
|
||||||
<Edit3 className="w-4 h-4 mr-2" />
|
<Edit3 className="w-4 h-4 mr-2"/>
|
||||||
Rename
|
Rename
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -231,29 +230,27 @@ export function FileManagerOperations({
|
|||||||
onClick={() => setShowDelete(true)}
|
onClick={() => setShowDelete(true)}
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-4 h-4 mr-2"/>
|
||||||
Delete Item
|
Delete Item
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current Path Display */}
|
|
||||||
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-3">
|
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-3">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Folder className="w-4 h-4 text-blue-400" />
|
<Folder className="w-4 h-4 text-blue-400"/>
|
||||||
<span className="text-muted-foreground">Current Path:</span>
|
<span className="text-muted-foreground">Current Path:</span>
|
||||||
<span className="text-white font-mono truncate">{currentPath}</span>
|
<span className="text-white font-mono truncate">{currentPath}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="p-0.25 bg-[#303032]" />
|
<Separator className="p-0.25 bg-[#303032]"/>
|
||||||
|
|
||||||
{/* Upload File Modal */}
|
|
||||||
{showUpload && (
|
{showUpload && (
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
<Upload className="w-5 h-5" />
|
<Upload className="w-5 h-5"/>
|
||||||
Upload File
|
Upload File
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
@@ -266,15 +263,15 @@ export function FileManagerOperations({
|
|||||||
onClick={() => setShowUpload(false)}
|
onClick={() => setShowUpload(false)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="border-2 border-dashed border-[#434345] rounded-lg p-6 text-center">
|
<div className="border-2 border-dashed border-[#434345] rounded-lg p-6 text-center">
|
||||||
{uploadFile ? (
|
{uploadFile ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FileText className="w-8 h-8 text-blue-400 mx-auto" />
|
<FileText className="w-8 h-8 text-blue-400 mx-auto"/>
|
||||||
<p className="text-white font-medium">{uploadFile.name}</p>
|
<p className="text-white font-medium">{uploadFile.name}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{(uploadFile.size / 1024).toFixed(2)} KB
|
{(uploadFile.size / 1024).toFixed(2)} KB
|
||||||
@@ -290,7 +287,7 @@ export function FileManagerOperations({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Upload className="w-8 h-8 text-muted-foreground mx-auto" />
|
<Upload className="w-8 h-8 text-muted-foreground mx-auto"/>
|
||||||
<p className="text-white">Click to select a file</p>
|
<p className="text-white">Click to select a file</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -302,7 +299,7 @@ export function FileManagerOperations({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -310,7 +307,7 @@ export function FileManagerOperations({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
accept="*/*"
|
accept="*/*"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleFileUpload}
|
onClick={handleFileUpload}
|
||||||
@@ -331,12 +328,11 @@ export function FileManagerOperations({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create File Modal */}
|
|
||||||
{showCreateFile && (
|
{showCreateFile && (
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
<FilePlus className="w-5 h-5" />
|
<FilePlus className="w-5 h-5"/>
|
||||||
Create New File
|
Create New File
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
@@ -345,10 +341,10 @@ export function FileManagerOperations({
|
|||||||
onClick={() => setShowCreateFile(false)}
|
onClick={() => setShowCreateFile(false)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
@@ -362,7 +358,7 @@ export function FileManagerOperations({
|
|||||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()}
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateFile}
|
onClick={handleCreateFile}
|
||||||
@@ -383,12 +379,11 @@ export function FileManagerOperations({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create Folder Modal */}
|
|
||||||
{showCreateFolder && (
|
{showCreateFolder && (
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
<FolderPlus className="w-5 h-5" />
|
<FolderPlus className="w-5 h-5"/>
|
||||||
Create New Folder
|
Create New Folder
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
@@ -397,10 +392,10 @@ export function FileManagerOperations({
|
|||||||
onClick={() => setShowCreateFolder(false)}
|
onClick={() => setShowCreateFolder(false)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
@@ -414,7 +409,7 @@ export function FileManagerOperations({
|
|||||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateFolder}
|
onClick={handleCreateFolder}
|
||||||
@@ -435,12 +430,11 @@ export function FileManagerOperations({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Modal */}
|
|
||||||
{showDelete && (
|
{showDelete && (
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
<Trash2 className="w-5 h-5 text-red-400" />
|
<Trash2 className="w-5 h-5 text-red-400"/>
|
||||||
Delete Item
|
Delete Item
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
@@ -449,18 +443,18 @@ export function FileManagerOperations({
|
|||||||
onClick={() => setShowDelete(false)}
|
onClick={() => setShowDelete(false)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
||||||
<div className="flex items-center gap-2 text-red-300">
|
<div className="flex items-center gap-2 text-red-300">
|
||||||
<AlertCircle className="w-4 h-4" />
|
<AlertCircle className="w-4 h-4"/>
|
||||||
<span className="text-sm font-medium">Warning: This action cannot be undone</span>
|
<span className="text-sm font-medium">Warning: This action cannot be undone</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
Item Path
|
Item Path
|
||||||
@@ -472,7 +466,7 @@ export function FileManagerOperations({
|
|||||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -485,7 +479,7 @@ export function FileManagerOperations({
|
|||||||
This is a directory (will delete recursively)
|
This is a directory (will delete recursively)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
@@ -507,12 +501,11 @@ export function FileManagerOperations({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rename Modal */}
|
|
||||||
{showRename && (
|
{showRename && (
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
<Edit3 className="w-5 h-5" />
|
<Edit3 className="w-5 h-5"/>
|
||||||
Rename Item
|
Rename Item
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
@@ -521,10 +514,10 @@ export function FileManagerOperations({
|
|||||||
onClick={() => setShowRename(false)}
|
onClick={() => setShowRename(false)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
@@ -537,7 +530,7 @@ export function FileManagerOperations({
|
|||||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
New Name
|
New Name
|
||||||
@@ -550,7 +543,7 @@ export function FileManagerOperations({
|
|||||||
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -563,7 +556,7 @@ export function FileManagerOperations({
|
|||||||
This is a directory
|
This is a directory
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleRename}
|
onClick={handleRename}
|
||||||
|
|||||||
@@ -6,97 +6,96 @@ import {HostManagerHostEditor} from "@/ui/apps/Host Manager/HostManagerHostEdito
|
|||||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||||
|
|
||||||
interface HostManagerProps {
|
interface HostManagerProps {
|
||||||
onSelectView: (view: string) => void;
|
onSelectView: (view: string) => void;
|
||||||
isTopbarOpen?: boolean;
|
isTopbarOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
folder: string;
|
folder: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
pin: boolean;
|
pin: boolean;
|
||||||
authType: string;
|
authType: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
enableTerminal: boolean;
|
enableTerminal: boolean;
|
||||||
enableTunnel: boolean;
|
enableTunnel: boolean;
|
||||||
enableFileManager: boolean;
|
enableFileManager: boolean;
|
||||||
defaultPath: string;
|
defaultPath: string;
|
||||||
tunnelConnections: any[];
|
tunnelConnections: any[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
|
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
|
||||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||||
const {state: sidebarState} = useSidebar();
|
const {state: sidebarState} = useSidebar();
|
||||||
|
|
||||||
const handleEditHost = (host: SSHHost) => {
|
const handleEditHost = (host: SSHHost) => {
|
||||||
setEditingHost(host);
|
setEditingHost(host);
|
||||||
setActiveTab("add_host");
|
setActiveTab("add_host");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormSubmit = () => {
|
const handleFormSubmit = () => {
|
||||||
setEditingHost(null);
|
setEditingHost(null);
|
||||||
setActiveTab("host_viewer");
|
setActiveTab("host_viewer");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTabChange = (value: string) => {
|
const handleTabChange = (value: string) => {
|
||||||
setActiveTab(value);
|
setActiveTab(value);
|
||||||
if (value === "host_viewer") {
|
if (value === "host_viewer") {
|
||||||
setEditingHost(null);
|
setEditingHost(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dynamic margins similar to TerminalView but with 16px gaps when retracted
|
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
||||||
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
const bottomMarginPx = 8;
|
||||||
const bottomMarginPx = 8;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div
|
<div
|
||||||
className="bg-[#18181b] text-white p-4 pt-0 rounded-lg border-2 border-[#303032] flex flex-col min-h-0 overflow-hidden"
|
className="bg-[#18181b] text-white p-4 pt-0 rounded-lg border-2 border-[#303032] flex flex-col min-h-0 overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
marginLeft: leftMarginPx,
|
marginLeft: leftMarginPx,
|
||||||
marginRight: 17,
|
marginRight: 17,
|
||||||
marginTop: topMarginPx,
|
marginTop: topMarginPx,
|
||||||
marginBottom: bottomMarginPx,
|
marginBottom: bottomMarginPx,
|
||||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
|
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange}
|
<Tabs value={activeTab} onValueChange={handleTabChange}
|
||||||
className="flex-1 flex flex-col h-full min-h-0">
|
className="flex-1 flex flex-col h-full min-h-0">
|
||||||
<TabsList className="bg-[#18181b] border-2 border-[#303032] mt-1.5">
|
<TabsList className="bg-[#18181b] border-2 border-[#303032] mt-1.5">
|
||||||
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
|
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
|
||||||
<TabsTrigger value="add_host">
|
<TabsTrigger value="add_host">
|
||||||
{editingHost ? "Edit Host" : "Add Host"}
|
{editingHost ? "Edit Host" : "Add Host"}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
||||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||||
<HostManagerHostViewer onEditHost={handleEditHost}/>
|
<HostManagerHostViewer onEditHost={handleEditHost}/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
|
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
|
||||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<HostManagerHostEditor
|
<HostManagerHostEditor
|
||||||
editingHost={editingHost}
|
editingHost={editingHost}
|
||||||
onFormSubmit={handleFormSubmit}
|
onFormSubmit={handleFormSubmit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -811,7 +811,9 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
render={({field: sourcePortField}) => (
|
render={({field: sourcePortField}) => (
|
||||||
<FormItem className="col-span-4">
|
<FormItem className="col-span-4">
|
||||||
<FormLabel>Source Port
|
<FormLabel>Source Port
|
||||||
(Source refers to the Current Connection Details in the General tab)</FormLabel>
|
(Source refers to the Current
|
||||||
|
Connection Details in the
|
||||||
|
General tab)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="22" {...sourcePortField} />
|
placeholder="22" {...sourcePortField} />
|
||||||
@@ -1029,8 +1031,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<footer className="shrink-0 w-full pb-0">
|
<footer className="shrink-0 w-full pb-0">
|
||||||
<Separator className="p-0.25"/>
|
<Separator className="p-0.25"/>
|
||||||
<Button
|
<Button
|
||||||
className=""
|
className=""
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -581,7 +581,7 @@ EXAMPLE STRUCTURE:
|
|||||||
Format Guide
|
Format Guide
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="w-px h-6 bg-border mx-2" />
|
<div className="w-px h-6 bg-border mx-2"/>
|
||||||
|
|
||||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||||
Refresh
|
Refresh
|
||||||
|
|||||||
@@ -1,256 +1,256 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useSidebar } from "@/components/ui/sidebar";
|
import {useSidebar} from "@/components/ui/sidebar";
|
||||||
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import { Progress } from "@/components/ui/progress"
|
import {Progress} from "@/components/ui/progress"
|
||||||
import {Cpu, HardDrive, MemoryStick} from "lucide-react";
|
import {Cpu, HardDrive, MemoryStick} from "lucide-react";
|
||||||
import {Tunnel} from "@/ui/apps/Tunnel/Tunnel.tsx";
|
import {Tunnel} from "@/ui/apps/Tunnel/Tunnel.tsx";
|
||||||
import { getServerStatusById, getServerMetricsById, ServerMetrics } from "@/ui/main-axios.ts";
|
import {getServerStatusById, getServerMetricsById, ServerMetrics} from "@/ui/main-axios.ts";
|
||||||
import { useTabs } from "@/ui/Navigation/Tabs/TabContext.tsx";
|
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||||
|
|
||||||
interface ServerProps {
|
interface ServerProps {
|
||||||
hostConfig?: any;
|
hostConfig?: any;
|
||||||
title?: string;
|
title?: string;
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
isTopbarOpen?: boolean;
|
isTopbarOpen?: boolean;
|
||||||
embedded?: boolean; // when rendered inside a pane in TerminalView
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = true, embedded = false }: ServerProps): React.ReactElement {
|
export function Server({
|
||||||
const { state: sidebarState } = useSidebar();
|
hostConfig,
|
||||||
const { addTab } = useTabs() as any;
|
title,
|
||||||
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
isVisible = true,
|
||||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
isTopbarOpen = true,
|
||||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
embedded = false
|
||||||
|
}: ServerProps): React.ReactElement {
|
||||||
|
const {state: sidebarState} = useSidebar();
|
||||||
|
const {addTab} = useTabs() as any;
|
||||||
|
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
||||||
|
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||||
|
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||||
|
|
||||||
// Listen for host configuration changes
|
React.useEffect(() => {
|
||||||
React.useEffect(() => {
|
setCurrentHostConfig(hostConfig);
|
||||||
setCurrentHostConfig(hostConfig);
|
}, [hostConfig]);
|
||||||
}, [hostConfig]);
|
|
||||||
|
|
||||||
// Always fetch latest host config when component mounts or hostConfig changes
|
React.useEffect(() => {
|
||||||
React.useEffect(() => {
|
const fetchLatestHostConfig = async () => {
|
||||||
const fetchLatestHostConfig = async () => {
|
if (hostConfig?.id) {
|
||||||
if (hostConfig?.id) {
|
try {
|
||||||
try {
|
const {getSSHHosts} = await import('@/ui/main-axios.ts');
|
||||||
// Import the getSSHHosts function to fetch updated host data
|
const hosts = await getSSHHosts();
|
||||||
const { getSSHHosts } = await import('@/ui/main-axios.ts');
|
const updatedHost = hosts.find(h => h.id === hostConfig.id);
|
||||||
const hosts = await getSSHHosts();
|
if (updatedHost) {
|
||||||
const updatedHost = hosts.find(h => h.id === hostConfig.id);
|
setCurrentHostConfig(updatedHost);
|
||||||
if (updatedHost) {
|
}
|
||||||
setCurrentHostConfig(updatedHost);
|
} catch (error) {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Failed to fetch latest host config:', error);
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch immediately when component mounts or hostConfig changes
|
fetchLatestHostConfig();
|
||||||
fetchLatestHostConfig();
|
|
||||||
|
|
||||||
// Also listen for SSH hosts changed event to refresh host config
|
const handleHostsChanged = async () => {
|
||||||
const handleHostsChanged = async () => {
|
if (hostConfig?.id) {
|
||||||
if (hostConfig?.id) {
|
try {
|
||||||
try {
|
const {getSSHHosts} = await import('@/ui/main-axios.ts');
|
||||||
// Import the getSSHHosts function to fetch updated host data
|
const hosts = await getSSHHosts();
|
||||||
const { getSSHHosts } = await import('@/ui/main-axios.ts');
|
const updatedHost = hosts.find(h => h.id === hostConfig.id);
|
||||||
const hosts = await getSSHHosts();
|
if (updatedHost) {
|
||||||
const updatedHost = hosts.find(h => h.id === hostConfig.id);
|
setCurrentHostConfig(updatedHost);
|
||||||
if (updatedHost) {
|
}
|
||||||
setCurrentHostConfig(updatedHost);
|
} catch (error) {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Failed to refresh host config:', error);
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('ssh-hosts:changed', handleHostsChanged);
|
window.addEventListener('ssh-hosts:changed', handleHostsChanged);
|
||||||
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged);
|
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged);
|
||||||
}, [hostConfig?.id]);
|
}, [hostConfig?.id]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let intervalId: number | undefined;
|
let intervalId: number | undefined;
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
const fetchStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getServerStatusById(currentHostConfig?.id);
|
const res = await getServerStatusById(currentHostConfig?.id);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setServerStatus('offline');
|
if (!cancelled) setServerStatus('offline');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchMetrics = async () => {
|
const fetchMetrics = async () => {
|
||||||
if (!currentHostConfig?.id) return;
|
if (!currentHostConfig?.id) return;
|
||||||
try {
|
try {
|
||||||
const data = await getServerMetricsById(currentHostConfig.id);
|
const data = await getServerMetricsById(currentHostConfig.id);
|
||||||
if (!cancelled) setMetrics(data);
|
if (!cancelled) setMetrics(data);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setMetrics(null);
|
if (!cancelled) setMetrics(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (currentHostConfig?.id) {
|
if (currentHostConfig?.id) {
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
fetchMetrics();
|
fetchMetrics();
|
||||||
intervalId = window.setInterval(() => {
|
intervalId = window.setInterval(() => {
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
fetchMetrics();
|
fetchMetrics();
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (intervalId) window.clearInterval(intervalId);
|
if (intervalId) window.clearInterval(intervalId);
|
||||||
};
|
};
|
||||||
}, [currentHostConfig?.id]);
|
}, [currentHostConfig?.id]);
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||||
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
|
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
|
||||||
const bottomMarginPx = 8;
|
const bottomMarginPx = 8;
|
||||||
|
|
||||||
const wrapperStyle: React.CSSProperties = embedded
|
const wrapperStyle: React.CSSProperties = embedded
|
||||||
? { opacity: isVisible ? 1 : 0, height: '100%', width: '100%' }
|
? {opacity: isVisible ? 1 : 0, height: '100%', width: '100%'}
|
||||||
: {
|
: {
|
||||||
opacity: isVisible ? 1 : 0,
|
opacity: isVisible ? 1 : 0,
|
||||||
marginLeft: leftMarginPx,
|
marginLeft: leftMarginPx,
|
||||||
marginRight: 17,
|
marginRight: 17,
|
||||||
marginTop: topMarginPx,
|
marginTop: topMarginPx,
|
||||||
marginBottom: bottomMarginPx,
|
marginBottom: bottomMarginPx,
|
||||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const containerClass = embedded
|
const containerClass = embedded
|
||||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
? "h-full w-full text-white overflow-hidden bg-transparent"
|
||||||
: "bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden";
|
: "bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={wrapperStyle} className={containerClass}>
|
<div style={wrapperStyle} className={containerClass}>
|
||||||
<div className="h-full w-full flex flex-col">
|
<div className="h-full w-full flex flex-col">
|
||||||
|
|
||||||
{/* Top Header */}
|
{/* Top Header */}
|
||||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="font-bold text-lg">
|
<h1 className="font-bold text-lg">
|
||||||
{currentHostConfig?.folder} / {title}
|
{currentHostConfig?.folder} / {title}
|
||||||
</h1>
|
</h1>
|
||||||
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||||
<StatusIndicator/>
|
<StatusIndicator/>
|
||||||
</Status>
|
</Status>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{currentHostConfig?.enableFileManager && (
|
{currentHostConfig?.enableFileManager && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="font-semibold"
|
className="font-semibold"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!currentHostConfig) return;
|
if (!currentHostConfig) return;
|
||||||
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
|
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
|
||||||
? currentHostConfig.name.trim()
|
? currentHostConfig.name.trim()
|
||||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||||
addTab({
|
addTab({
|
||||||
type: 'file_manager',
|
type: 'file_manager',
|
||||||
title: titleBase,
|
title: titleBase,
|
||||||
hostConfig: currentHostConfig,
|
hostConfig: currentHostConfig,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
File Manager
|
File Manager
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="p-0.25 w-full"/>
|
<Separator className="p-0.25 w-full"/>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] flex flex-row items-stretch">
|
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] flex flex-row items-stretch">
|
||||||
{/* CPU */}
|
{/* CPU */}
|
||||||
<div className="flex-1 min-w-0 px-2 py-2">
|
<div className="flex-1 min-w-0 px-2 py-2">
|
||||||
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
|
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
|
||||||
<Cpu/>
|
<Cpu/>
|
||||||
{(() => {
|
{(() => {
|
||||||
const pct = metrics?.cpu?.percent;
|
const pct = metrics?.cpu?.percent;
|
||||||
const cores = metrics?.cpu?.cores;
|
const cores = metrics?.cpu?.cores;
|
||||||
const la = metrics?.cpu?.load;
|
const la = metrics?.cpu?.load;
|
||||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||||
const coresText = (typeof cores === 'number') ? `${cores} CPU(s)` : 'N/A CPU(s)';
|
const coresText = (typeof cores === 'number') ? `${cores} CPU(s)` : 'N/A CPU(s)';
|
||||||
const laText = (la && la.length === 3)
|
const laText = (la && la.length === 3)
|
||||||
? `Avg: ${la[0].toFixed(2)}, ${la[1].toFixed(2)}, ${la[2].toFixed(2)}`
|
? `Avg: ${la[0].toFixed(2)}, ${la[1].toFixed(2)}, ${la[2].toFixed(2)}`
|
||||||
: 'Avg: N/A';
|
: 'Avg: N/A';
|
||||||
return `CPU Usage - ${pctText} of ${coresText} (${laText})`;
|
return `CPU Usage - ${pctText} of ${coresText} (${laText})`;
|
||||||
})()}
|
})()}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<Progress value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0} />
|
<Progress value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
||||||
|
|
||||||
{/* Memory */}
|
{/* Memory */}
|
||||||
<div className="flex-1 min-w-0 px-2 py-2">
|
<div className="flex-1 min-w-0 px-2 py-2">
|
||||||
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-1">
|
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-1">
|
||||||
<MemoryStick/>
|
<MemoryStick/>
|
||||||
{(() => {
|
{(() => {
|
||||||
const pct = metrics?.memory?.percent;
|
const pct = metrics?.memory?.percent;
|
||||||
const used = metrics?.memory?.usedGiB;
|
const used = metrics?.memory?.usedGiB;
|
||||||
const total = metrics?.memory?.totalGiB;
|
const total = metrics?.memory?.totalGiB;
|
||||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||||
const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A';
|
const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A';
|
||||||
const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A';
|
const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A';
|
||||||
return `Memory Usage - ${pctText} (${usedText} of ${totalText})`;
|
return `Memory Usage - ${pctText} (${usedText} of ${totalText})`;
|
||||||
})()}
|
})()}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<Progress value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0} />
|
<Progress value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
||||||
|
|
||||||
{/* HDD */}
|
{/* HDD */}
|
||||||
<div className="flex-1 min-w-0 px-2 py-2">
|
<div className="flex-1 min-w-0 px-2 py-2">
|
||||||
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
|
<h1 className="font-bold text-lg flex flex-row gap-2 mb-1">
|
||||||
<HardDrive/>
|
<HardDrive/>
|
||||||
{(() => {
|
{(() => {
|
||||||
const pct = metrics?.disk?.percent;
|
const pct = metrics?.disk?.percent;
|
||||||
const used = metrics?.disk?.usedHuman;
|
const used = metrics?.disk?.usedHuman;
|
||||||
const total = metrics?.disk?.totalHuman;
|
const total = metrics?.disk?.totalHuman;
|
||||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||||
const usedText = used ?? 'N/A';
|
const usedText = used ?? 'N/A';
|
||||||
const totalText = total ?? 'N/A';
|
const totalText = total ?? 'N/A';
|
||||||
return `HD Space - ${pctText} (${usedText} of ${totalText})`;
|
return `HD Space - ${pctText} (${usedText} of ${totalText})`;
|
||||||
})()}
|
})()}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<Progress value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0} />
|
<Progress value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SSH Tunnels */}
|
{/* SSH Tunnels */}
|
||||||
{(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && (
|
{(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && (
|
||||||
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] h-[360px] overflow-hidden flex flex-col min-h-0">
|
<div
|
||||||
<Tunnel filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/>
|
className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] h-[360px] overflow-hidden flex flex-col min-h-0">
|
||||||
</div>
|
<Tunnel
|
||||||
)}
|
filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
||||||
Have ideas for what should come next for server management? Share them on{" "}
|
Have ideas for what should come next for server management? Share them on{" "}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/LukeGus/Termix/issues/new"
|
href="https://github.com/LukeGus/Termix/issues/new"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-500 hover:underline"
|
className="text-blue-500 hover:underline"
|
||||||
>
|
>
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
!
|
!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
{hostConfig, isVisible, splitScreen = false},
|
{hostConfig, isVisible, splitScreen = false},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
console.log('TerminalComponent rendered with:', { hostConfig, isVisible, splitScreen });
|
|
||||||
const {instance: terminal, ref: xtermRef} = useXTerm();
|
const {instance: terminal, ref: xtermRef} = useXTerm();
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
const webSocketRef = useRef<WebSocket | null>(null);
|
const webSocketRef = useRef<WebSocket | null>(null);
|
||||||
@@ -27,20 +26,22 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const isVisibleRef = useRef<boolean>(false);
|
const isVisibleRef = useRef<boolean>(false);
|
||||||
|
|
||||||
// Debounce/stabilize resize notifications
|
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
const lastSentSizeRef = useRef<{cols:number; rows:number} | null>(null);
|
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
const pendingSizeRef = useRef<{cols:number; rows:number} | null>(null);
|
|
||||||
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const DEBOUNCE_MS = 140;
|
const DEBOUNCE_MS = 140;
|
||||||
|
|
||||||
useEffect(() => { isVisibleRef.current = isVisible; }, [isVisible]);
|
useEffect(() => {
|
||||||
|
isVisibleRef.current = isVisible;
|
||||||
|
}, [isVisible]);
|
||||||
|
|
||||||
function hardRefresh() {
|
function hardRefresh() {
|
||||||
try {
|
try {
|
||||||
if (terminal && typeof (terminal as any).refresh === 'function') {
|
if (terminal && typeof (terminal as any).refresh === 'function') {
|
||||||
(terminal as any).refresh(0, terminal.rows - 1);
|
(terminal as any).refresh(0, terminal.rows - 1);
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleNotify(cols: number, rows: number) {
|
function scheduleNotify(cols: number, rows: number) {
|
||||||
@@ -85,7 +86,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
scheduleNotify(cols, rows);
|
scheduleNotify(cols, rows);
|
||||||
hardRefresh();
|
hardRefresh();
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {
|
||||||
|
}
|
||||||
},
|
},
|
||||||
refresh: () => hardRefresh(),
|
refresh: () => hardRefresh(),
|
||||||
}), [terminal]);
|
}), [terminal]);
|
||||||
@@ -119,7 +121,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {
|
||||||
|
}
|
||||||
const textarea = document.createElement('textarea');
|
const textarea = document.createElement('textarea');
|
||||||
textarea.value = text;
|
textarea.value = text;
|
||||||
textarea.style.position = 'fixed';
|
textarea.style.position = 'fixed';
|
||||||
@@ -127,7 +130,11 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
document.body.appendChild(textarea);
|
document.body.appendChild(textarea);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
textarea.select();
|
textarea.select();
|
||||||
try { document.execCommand('copy'); } finally { document.body.removeChild(textarea); }
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readTextFromClipboard(): Promise<string> {
|
async function readTextFromClipboard(): Promise<string> {
|
||||||
@@ -135,7 +142,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||||
return await navigator.clipboard.readText();
|
return await navigator.clipboard.readText();
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {
|
||||||
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +156,7 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
|
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
|
||||||
theme: { background: '#18181b', foreground: '#f7f7f7' },
|
theme: {background: '#18181b', foreground: '#f7f7f7'},
|
||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
windowsMode: false,
|
windowsMode: false,
|
||||||
@@ -175,16 +183,21 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
const element = xtermRef.current;
|
const element = xtermRef.current;
|
||||||
const handleContextMenu = async (e: MouseEvent) => {
|
const handleContextMenu = async (e: MouseEvent) => {
|
||||||
if (!getUseRightClickCopyPaste()) return;
|
if (!getUseRightClickCopyPaste()) return;
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
try {
|
try {
|
||||||
if (terminal.hasSelection()) {
|
if (terminal.hasSelection()) {
|
||||||
const selection = terminal.getSelection();
|
const selection = terminal.getSelection();
|
||||||
if (selection) { await writeTextToClipboard(selection); terminal.clearSelection(); }
|
if (selection) {
|
||||||
|
await writeTextToClipboard(selection);
|
||||||
|
terminal.clearSelection();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const pasteText = await readTextFromClipboard();
|
const pasteText = await readTextFromClipboard();
|
||||||
if (pasteText) terminal.paste(pasteText);
|
if (pasteText) terminal.paste(pasteText);
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {
|
||||||
|
}
|
||||||
};
|
};
|
||||||
element?.addEventListener('contextmenu', handleContextMenu);
|
element?.addEventListener('contextmenu', handleContextMenu);
|
||||||
|
|
||||||
@@ -221,8 +234,14 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
|
|
||||||
ws.addEventListener('open', () => {
|
ws.addEventListener('open', () => {
|
||||||
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
||||||
terminal.onData((data) => { ws.send(JSON.stringify({type: 'input', data})); });
|
terminal.onData((data) => {
|
||||||
pingIntervalRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({type: 'ping'})); } }, 30000);
|
ws.send(JSON.stringify({type: 'input', data}));
|
||||||
|
});
|
||||||
|
pingIntervalRef.current = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({type: 'ping'}));
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener('message', (event) => {
|
ws.addEventListener('message', (event) => {
|
||||||
@@ -230,13 +249,21 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
if (msg.type === 'data') terminal.write(msg.data);
|
if (msg.type === 'data') terminal.write(msg.data);
|
||||||
else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
||||||
else if (msg.type === 'connected') { }
|
else if (msg.type === 'connected') {
|
||||||
else if (msg.type === 'disconnected') { wasDisconnectedBySSH.current = true; terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`); }
|
} else if (msg.type === 'disconnected') {
|
||||||
} catch (error) { console.error('Error parsing WebSocket message:', error); }
|
wasDisconnectedBySSH.current = true;
|
||||||
|
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener('close', () => { if (!wasDisconnectedBySSH.current) terminal.writeln('\r\n[Connection closed]'); });
|
ws.addEventListener('close', () => {
|
||||||
ws.addEventListener('error', () => { terminal.writeln('\r\n[Connection error]'); });
|
if (!wasDisconnectedBySSH.current) terminal.writeln('\r\n[Connection closed]');
|
||||||
|
});
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
terminal.writeln('\r\n[Connection error]');
|
||||||
|
});
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -245,7 +272,10 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
element?.removeEventListener('contextmenu', handleContextMenu);
|
element?.removeEventListener('contextmenu', handleContextMenu);
|
||||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||||
if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; }
|
if (pingIntervalRef.current) {
|
||||||
|
clearInterval(pingIntervalRef.current);
|
||||||
|
pingIntervalRef.current = null;
|
||||||
|
}
|
||||||
webSocketRef.current?.close();
|
webSocketRef.current?.close();
|
||||||
};
|
};
|
||||||
}, [xtermRef, terminal, hostConfig]);
|
}, [xtermRef, terminal, hostConfig]);
|
||||||
@@ -260,7 +290,6 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
}
|
}
|
||||||
}, [isVisible]);
|
}, [isVisible]);
|
||||||
|
|
||||||
// Ensure a fit when split mode toggles to account for new pane geometry
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fitAddonRef.current) return;
|
if (!fitAddonRef.current) return;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -271,7 +300,8 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
|
|||||||
}, [splitScreen]);
|
}, [splitScreen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={xtermRef} className="h-full w-full m-1" style={{ opacity: visible && isVisible ? 1 : 0, overflow: 'hidden' }} />
|
<div ref={xtermRef} className="h-full w-full m-1"
|
||||||
|
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,7 @@ interface SSHTunnelProps {
|
|||||||
filterHostKey?: string;
|
filterHostKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
|
||||||
// Keep full list for endpoint lookups; keep a separate visible list for UI
|
|
||||||
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
||||||
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
|
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
|
||||||
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
|
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
|
||||||
@@ -86,7 +85,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
|||||||
})
|
})
|
||||||
: hostsData;
|
: hostsData;
|
||||||
|
|
||||||
// Silent update: only set state if meaningful changes
|
|
||||||
const prev = prevVisibleHostRef.current;
|
const prev = prevVisibleHostRef.current;
|
||||||
const curr = nextVisible[0] ?? null;
|
const curr = nextVisible[0] ?? null;
|
||||||
let changed = false;
|
let changed = false;
|
||||||
@@ -120,7 +118,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
|||||||
fetchHosts();
|
fetchHosts();
|
||||||
const interval = setInterval(fetchHosts, 5000);
|
const interval = setInterval(fetchHosts, 5000);
|
||||||
|
|
||||||
// Refresh immediately when hosts are changed elsewhere (e.g., SSH Manager)
|
|
||||||
const handleHostsChanged = () => {
|
const handleHostsChanged = () => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -76,17 +76,17 @@ interface SSHTunnelObjectProps {
|
|||||||
tunnelActions: Record<string, boolean>;
|
tunnelActions: Record<string, boolean>;
|
||||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
bare?: boolean; // when true, render without Card wrapper/background
|
bare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TunnelObject({
|
export function TunnelObject({
|
||||||
host,
|
host,
|
||||||
tunnelStatuses,
|
tunnelStatuses,
|
||||||
tunnelActions,
|
tunnelActions,
|
||||||
onTunnelAction,
|
onTunnelAction,
|
||||||
compact = false,
|
compact = false,
|
||||||
bare = false
|
bare = false
|
||||||
}: SSHTunnelObjectProps): React.ReactElement {
|
}: SSHTunnelObjectProps): React.ReactElement {
|
||||||
|
|
||||||
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
||||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||||
@@ -168,7 +168,6 @@ export function TunnelObject({
|
|||||||
if (bare) {
|
if (bare) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-w-0">
|
<div className="w-full min-w-0">
|
||||||
{/* Tunnel Connections (bare) */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -187,7 +186,6 @@ export function TunnelObject({
|
|||||||
return (
|
return (
|
||||||
<div key={tunnelIndex}
|
<div key={tunnelIndex}
|
||||||
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
||||||
{/* Tunnel Header */}
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||||
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
|
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
|
||||||
@@ -203,7 +201,6 @@ export function TunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
|
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
|
||||||
{/* Action Buttons */}
|
|
||||||
{!isActionLoading ? (
|
{!isActionLoading ? (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
@@ -255,7 +252,6 @@ export function TunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error/Status Reason */}
|
|
||||||
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
||||||
<div
|
<div
|
||||||
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||||
@@ -280,7 +276,6 @@ export function TunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Retry Info */}
|
|
||||||
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
|
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
|
||||||
<div
|
<div
|
||||||
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||||
@@ -313,7 +308,6 @@ export function TunnelObject({
|
|||||||
return (
|
return (
|
||||||
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{/* Host Header */}
|
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<div className="flex items-center justify-between gap-2 mb-3">
|
<div className="flex items-center justify-between gap-2 mb-3">
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
@@ -330,7 +324,6 @@ export function TunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
{!compact && host.tags && host.tags.length > 0 && (
|
{!compact && host.tags && host.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mb-3">
|
<div className="flex flex-wrap gap-1 mb-3">
|
||||||
{host.tags.slice(0, 3).map((tag, index) => (
|
{host.tags.slice(0, 3).map((tag, index) => (
|
||||||
@@ -349,7 +342,6 @@ export function TunnelObject({
|
|||||||
|
|
||||||
{!compact && <Separator className="mb-3"/>}
|
{!compact && <Separator className="mb-3"/>}
|
||||||
|
|
||||||
{/* Tunnel Connections */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||||
@@ -374,7 +366,6 @@ export function TunnelObject({
|
|||||||
return (
|
return (
|
||||||
<div key={tunnelIndex}
|
<div key={tunnelIndex}
|
||||||
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
||||||
{/* Tunnel Header */}
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||||
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
|
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
|
||||||
@@ -390,7 +381,6 @@ export function TunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{/* Action Buttons */}
|
|
||||||
{!isActionLoading && (
|
{!isActionLoading && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
@@ -443,7 +433,6 @@ export function TunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error/Status Reason */}
|
|
||||||
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
||||||
<div
|
<div
|
||||||
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||||
@@ -468,7 +457,6 @@ export function TunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Retry Info */}
|
|
||||||
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
|
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
|
||||||
<div
|
<div
|
||||||
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||||
|
|||||||
@@ -47,12 +47,11 @@ interface SSHTunnelViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TunnelViewer({
|
export function TunnelViewer({
|
||||||
hosts = [],
|
hosts = [],
|
||||||
tunnelStatuses = {},
|
tunnelStatuses = {},
|
||||||
tunnelActions = {},
|
tunnelActions = {},
|
||||||
onTunnelAction
|
onTunnelAction
|
||||||
}: SSHTunnelViewerProps): React.ReactElement {
|
}: SSHTunnelViewerProps): React.ReactElement {
|
||||||
// Single-host view: use first host if present
|
|
||||||
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
|
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
|
||||||
|
|
||||||
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
|
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
|
||||||
@@ -60,7 +59,8 @@ export function TunnelViewer({
|
|||||||
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
|
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-2">No SSH Tunnels</h3>
|
<h3 className="text-lg font-semibold text-foreground mb-2">No SSH Tunnels</h3>
|
||||||
<p className="text-muted-foreground max-w-md">
|
<p className="text-muted-foreground max-w-md">
|
||||||
Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.
|
Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel
|
||||||
|
connections.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -72,7 +72,8 @@ export function TunnelViewer({
|
|||||||
<h1 className="text-xl font-semibold text-foreground">SSH Tunnels</h1>
|
<h1 className="text-xl font-semibold text-foreground">SSH Tunnels</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-auto pr-1">
|
<div className="min-h-0 flex-1 overflow-auto pr-1">
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
<div
|
||||||
|
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||||
{activeHost.tunnelConnections.map((t, idx) => (
|
{activeHost.tunnelConnections.map((t, idx) => (
|
||||||
<TunnelObject
|
<TunnelObject
|
||||||
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
|
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
|
||||||
|
|||||||
Reference in New Issue
Block a user