Improve logging frontend/backend, fix host form being reversed.

This commit is contained in:
LukeGus
2025-09-09 15:38:29 -05:00
parent 67dd87fc55
commit 4c33b43a0f
21 changed files with 658 additions and 669 deletions

View File

@@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"build:backend": "tsc -p tsconfig.node.json", "build:backend": "tsc -p tsconfig.node.json",
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/starter.js", "dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },

View File

@@ -1,6 +1,6 @@
{ {
"credentials": { "credentials": {
"credentialsManager": "Credentials Manager", "credentialsViewer": "Credentials Viewer",
"manageYourSSHCredentials": "Manage your SSH credentials securely", "manageYourSSHCredentials": "Manage your SSH credentials securely",
"addCredential": "Add Credential", "addCredential": "Add Credential",
"createCredential": "Create Credential", "createCredential": "Create Credential",

View File

@@ -35,23 +35,20 @@ class GitHubCache {
timestamp: now, timestamp: now,
expiresAt: now + this.CACHE_DURATION expiresAt: now + this.CACHE_DURATION
}); });
databaseLogger.debug(`Cache entry set`, { operation: 'cache_set', key, expiresIn: this.CACHE_DURATION }); // Cache entry set
} }
get(key: string): any | null { get(key: string): any | null {
const entry = this.cache.get(key); const entry = this.cache.get(key);
if (!entry) { if (!entry) {
databaseLogger.debug(`Cache miss`, { operation: 'cache_get', key });
return null; return null;
} }
if (Date.now() > entry.expiresAt) { if (Date.now() > entry.expiresAt) {
this.cache.delete(key); this.cache.delete(key);
databaseLogger.debug(`Cache entry expired`, { operation: 'cache_get', key, expired: true });
return null; return null;
} }
databaseLogger.debug(`Cache hit`, { operation: 'cache_get', key, age: Date.now() - entry.timestamp });
return entry.data; return entry.data;
} }
} }
@@ -83,7 +80,6 @@ interface GitHubRelease {
async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> { async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> {
const cachedData = githubCache.get(cacheKey); const cachedData = githubCache.get(cacheKey);
if (cachedData) { if (cachedData) {
databaseLogger.debug(`Using cached GitHub API data`, { operation: 'github_api', endpoint, cached: true });
return { return {
data: cachedData, data: cachedData,
cached: true, cached: true,
@@ -92,7 +88,6 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any>
} }
try { try {
databaseLogger.info(`Fetching from GitHub API`, { operation: 'github_api', endpoint });
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, { const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
headers: { headers: {
'Accept': 'application/vnd.github+json', 'Accept': 'application/vnd.github+json',
@@ -108,7 +103,6 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any>
const data = await response.json(); const data = await response.json();
githubCache.set(cacheKey, data); githubCache.set(cacheKey, data);
databaseLogger.success(`GitHub API data fetched successfully`, { operation: 'github_api', endpoint, dataSize: JSON.stringify(data).length });
return { return {
data: data, data: data,
cached: false cached: false
@@ -122,12 +116,10 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any>
app.use(bodyParser.json()); app.use(bodyParser.json());
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
apiLogger.info(`Health check requested`, { operation: 'health_check' });
res.json({status: 'ok'}); res.json({status: 'ok'});
}); });
app.get('/version', async (req, res) => { app.get('/version', async (req, res) => {
apiLogger.info(`Version check requested`, { operation: 'version_check' });
let localVersion = process.env.VERSION; let localVersion = process.env.VERSION;
if (!localVersion) { if (!localVersion) {
@@ -135,7 +127,6 @@ app.get('/version', async (req, res) => {
const packagePath = path.resolve(process.cwd(), 'package.json'); const packagePath = path.resolve(process.cwd(), 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
localVersion = packageJson.version; localVersion = packageJson.version;
databaseLogger.debug(`Version read from package.json`, { operation: 'version_check', localVersion });
} catch (error) { } catch (error) {
databaseLogger.error('Failed to read version from package.json', error, { operation: 'version_check' }); databaseLogger.error('Failed to read version from package.json', error, { operation: 'version_check' });
} }
@@ -163,13 +154,6 @@ app.get('/version', async (req, res) => {
} }
const isUpToDate = localVersion === remoteVersion; const isUpToDate = localVersion === remoteVersion;
databaseLogger.info(`Version comparison completed`, {
operation: 'version_check',
localVersion,
remoteVersion,
isUpToDate,
cached: releaseData.cached
});
const response = { const response = {
status: isUpToDate ? 'up_to_date' : 'requires_update', status: isUpToDate ? 'up_to_date' : 'requires_update',
@@ -198,7 +182,7 @@ app.get('/releases/rss', async (req, res) => {
const per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100); const per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100);
const cacheKey = `releases_rss_${page}_${per_page}`; const cacheKey = `releases_rss_${page}_${per_page}`;
apiLogger.info(`RSS releases requested`, { operation: 'rss_releases', page, per_page }); // RSS releases requested
const releasesData = await fetchGitHubAPI( const releasesData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`, `/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
@@ -235,13 +219,7 @@ app.get('/releases/rss', async (req, res) => {
cache_age: releasesData.cache_age cache_age: releasesData.cache_age
}; };
databaseLogger.success(`RSS releases generated successfully`, { // RSS releases generated successfully
operation: 'rss_releases',
itemCount: rssItems.length,
page,
per_page,
cached: releasesData.cached
});
res.json(response); res.json(response);
} catch (error) { } catch (error) {

View File

@@ -165,13 +165,6 @@ const migrateSchema = () => {
addColumnIfNotExists('users', 'issuer_url', 'TEXT'); addColumnIfNotExists('users', 'issuer_url', 'TEXT');
addColumnIfNotExists('users', 'authorization_url', 'TEXT'); addColumnIfNotExists('users', 'authorization_url', 'TEXT');
addColumnIfNotExists('users', 'token_url', 'TEXT'); addColumnIfNotExists('users', 'token_url', 'TEXT');
try {
databaseLogger.debug('Attempting to drop redirect_uri column', { operation: 'schema_migration', table: 'users' });
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
databaseLogger.success('redirect_uri column dropped', { operation: 'schema_migration', table: 'users' });
} catch (e) {
databaseLogger.debug('redirect_uri column does not exist or could not be dropped', { operation: 'schema_migration', table: 'users' });
}
addColumnIfNotExists('users', 'identifier_path', 'TEXT'); addColumnIfNotExists('users', 'identifier_path', 'TEXT');
addColumnIfNotExists('users', 'name_path', 'TEXT'); addColumnIfNotExists('users', 'name_path', 'TEXT');

View File

@@ -63,11 +63,8 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
const cacheKey = 'termix_alerts'; const cacheKey = 'termix_alerts';
const cachedData = alertCache.get(cacheKey); const cachedData = alertCache.get(cacheKey);
if (cachedData) { if (cachedData) {
authLogger.info('Returning cached alerts from GitHub', { operation: 'alerts_fetch', cacheKey, alertCount: cachedData.length });
return cachedData; return cachedData;
} }
authLogger.info('Fetching alerts from GitHub', { operation: 'alerts_fetch', url: `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}` });
try { try {
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`; const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
@@ -84,7 +81,6 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
} }
const alerts: TermixAlert[] = await response.json() as TermixAlert[]; const alerts: TermixAlert[] = await response.json() as TermixAlert[];
authLogger.info('Successfully fetched alerts from GitHub', { operation: 'alerts_fetch', totalAlerts: alerts.length });
const now = new Date(); const now = new Date();
@@ -94,9 +90,7 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
return isValid; return isValid;
}); });
authLogger.info('Filtered alerts by expiry date', { operation: 'alerts_fetch', totalAlerts: alerts.length, validAlerts: validAlerts.length });
alertCache.set(cacheKey, validAlerts); alertCache.set(cacheKey, validAlerts);
authLogger.success('Alerts cached successfully', { operation: 'alerts_fetch', alertCount: validAlerts.length });
return validAlerts; return validAlerts;
} catch (error) { } catch (error) {
authLogger.error('Failed to fetch alerts from GitHub', { operation: 'alerts_fetch', error: error instanceof Error ? error.message : 'Unknown error' }); authLogger.error('Failed to fetch alerts from GitHub', { operation: 'alerts_fetch', error: error instanceof Error ? error.message : 'Unknown error' });

View File

@@ -76,16 +76,12 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
// POST /ssh/host // POST /ssh/host
router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => { router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
sshLogger.info('SSH host creation request received', { operation: 'host_create', userId, contentType: req.headers['content-type'] });
let hostData: any; let hostData: any;
if (req.headers['content-type']?.includes('multipart/form-data')) { if (req.headers['content-type']?.includes('multipart/form-data')) {
sshLogger.info('Processing multipart form data for SSH host creation', { operation: 'host_create', userId });
if (req.body.data) { if (req.body.data) {
try { try {
hostData = JSON.parse(req.body.data); hostData = JSON.parse(req.body.data);
sshLogger.info('Successfully parsed JSON data from multipart request', { operation: 'host_create', userId, hasKey: !!req.file });
} catch (err) { } catch (err) {
sshLogger.warn('Invalid JSON data in multipart request', { operation: 'host_create', userId, error: err }); sshLogger.warn('Invalid JSON data in multipart request', { operation: 'host_create', userId, error: err });
return res.status(400).json({error: 'Invalid JSON data'}); return res.status(400).json({error: 'Invalid JSON data'});
@@ -97,11 +93,9 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
if (req.file) { if (req.file) {
hostData.key = req.file.buffer.toString('utf8'); hostData.key = req.file.buffer.toString('utf8');
sshLogger.info('SSH key file processed from multipart request', { operation: 'host_create', userId, keySize: req.file.size });
} }
} else { } else {
hostData = req.body; hostData = req.body;
sshLogger.info('Processing JSON data for SSH host creation', { operation: 'host_create', userId });
} }
const { const {
@@ -147,11 +141,11 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
username, username,
authType: effectiveAuthType, authType: effectiveAuthType,
credentialId: credentialId || null, credentialId: credentialId || null,
pin: !pin ? 1 : 0, pin: pin ? 1 : 0,
enableTerminal: !enableTerminal ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: !enableTunnel ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0,
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null, tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
enableFileManager: !!enableFileManager ? 1 : 0, enableFileManager: enableFileManager ? 1 : 0,
defaultPath: defaultPath || null, defaultPath: defaultPath || null,
}; };
@@ -160,20 +154,35 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
sshDataObj.key = null; sshDataObj.key = null;
sshDataObj.keyPassword = null; sshDataObj.keyPassword = null;
sshDataObj.keyType = null; sshDataObj.keyType = null;
sshLogger.info('SSH host configured for password authentication', { operation: 'host_create', userId, name, ip, port });
} else if (effectiveAuthType === 'key') { } else if (effectiveAuthType === 'key') {
sshDataObj.key = key || null; sshDataObj.key = key || null;
sshDataObj.keyPassword = keyPassword || null; sshDataObj.keyPassword = keyPassword || null;
sshDataObj.keyType = keyType; sshDataObj.keyType = keyType;
sshDataObj.password = null; sshDataObj.password = null;
sshLogger.info('SSH host configured for key authentication', { operation: 'host_create', userId, name, ip, port, keyType });
} }
try { try {
sshLogger.info('Attempting to save SSH host to database', { operation: 'host_create', userId, name, ip, port, authType: effectiveAuthType }); const result = await db.insert(sshData).values(sshDataObj).returning();
await db.insert(sshData).values(sshDataObj);
sshLogger.success('SSH host created successfully', { operation: 'host_create', userId, name, ip, port, authType: effectiveAuthType, enableTerminal, enableTunnel, enableFileManager }); if (result.length === 0) {
res.json({message: 'SSH data created'}); sshLogger.warn('No host returned after creation', { operation: 'host_create', userId, name, ip, port });
return res.status(500).json({error: 'Failed to create host'});
}
const createdHost = result[0];
const baseHost = {
...createdHost,
tags: typeof createdHost.tags === 'string' ? (createdHost.tags ? createdHost.tags.split(',').filter(Boolean) : []) : [],
pin: !!createdHost.pin,
enableTerminal: !!createdHost.enableTerminal,
enableTunnel: !!createdHost.enableTunnel,
tunnelConnections: createdHost.tunnelConnections ? JSON.parse(createdHost.tunnelConnections) : [],
enableFileManager: !!createdHost.enableFileManager,
};
const resolvedHost = await resolveHostCredentials(baseHost) || baseHost;
res.json(resolvedHost);
} catch (err) { } catch (err) {
sshLogger.error('Failed to save SSH host to database', err, { operation: 'host_create', userId, name, ip, port, authType: effectiveAuthType }); sshLogger.error('Failed to save SSH host to database', err, { operation: 'host_create', userId, name, ip, port, authType: effectiveAuthType });
res.status(500).json({error: 'Failed to save SSH data'}); res.status(500).json({error: 'Failed to save SSH data'});
@@ -185,16 +194,12 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque
router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => { router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
const hostId = req.params.id; const hostId = req.params.id;
const userId = (req as any).userId; const userId = (req as any).userId;
sshLogger.info('SSH host update request received', { operation: 'host_update', hostId: parseInt(hostId), userId, contentType: req.headers['content-type'] });
let hostData: any; let hostData: any;
if (req.headers['content-type']?.includes('multipart/form-data')) { if (req.headers['content-type']?.includes('multipart/form-data')) {
sshLogger.info('Processing multipart form data for SSH host update', { operation: 'host_update', hostId: parseInt(hostId), userId });
if (req.body.data) { if (req.body.data) {
try { try {
hostData = JSON.parse(req.body.data); hostData = JSON.parse(req.body.data);
sshLogger.info('Successfully parsed JSON data from multipart request', { operation: 'host_update', hostId: parseInt(hostId), userId, hasKey: !!req.file });
} catch (err) { } catch (err) {
sshLogger.warn('Invalid JSON data in multipart request', { operation: 'host_update', hostId: parseInt(hostId), userId, error: err }); sshLogger.warn('Invalid JSON data in multipart request', { operation: 'host_update', hostId: parseInt(hostId), userId, error: err });
return res.status(400).json({error: 'Invalid JSON data'}); return res.status(400).json({error: 'Invalid JSON data'});
@@ -206,11 +211,9 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
if (req.file) { if (req.file) {
hostData.key = req.file.buffer.toString('utf8'); hostData.key = req.file.buffer.toString('utf8');
sshLogger.info('SSH key file processed from multipart request', { operation: 'host_update', hostId: parseInt(hostId), userId, keySize: req.file.size });
} }
} else { } else {
hostData = req.body; hostData = req.body;
sshLogger.info('Processing JSON data for SSH host update', { operation: 'host_update', hostId: parseInt(hostId), userId });
} }
const { const {
@@ -256,11 +259,11 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
username, username,
authType: effectiveAuthType, authType: effectiveAuthType,
credentialId: credentialId || null, credentialId: credentialId || null,
pin: !pin ? 1 : 0, pin: pin ? 1 : 0,
enableTerminal: !enableTerminal ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: !enableTunnel ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0,
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null, tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
enableFileManager: !enableFileManager ? 1 : 0, enableFileManager: enableFileManager ? 1 : 0,
defaultPath: defaultPath || null, defaultPath: defaultPath || null,
}; };
@@ -271,7 +274,6 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
sshDataObj.key = null; sshDataObj.key = null;
sshDataObj.keyPassword = null; sshDataObj.keyPassword = null;
sshDataObj.keyType = null; sshDataObj.keyType = null;
sshLogger.info('SSH host update configured for password authentication', { operation: 'host_update', hostId: parseInt(hostId), userId, name, ip, port });
} else if (effectiveAuthType === 'key') { } else if (effectiveAuthType === 'key') {
if (key) { if (key) {
sshDataObj.key = key; sshDataObj.key = key;
@@ -283,16 +285,37 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
sshDataObj.keyType = keyType; sshDataObj.keyType = keyType;
} }
sshDataObj.password = null; sshDataObj.password = null;
sshLogger.info('SSH host update configured for key authentication', { operation: 'host_update', hostId: parseInt(hostId), userId, name, ip, port, keyType });
} }
try { try {
sshLogger.info('Attempting to update SSH host in database', { operation: 'host_update', hostId: parseInt(hostId), userId, name, ip, port, authType: effectiveAuthType });
await db.update(sshData) await db.update(sshData)
.set(sshDataObj) .set(sshDataObj)
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
sshLogger.success('SSH host updated successfully', { operation: 'host_update', hostId: parseInt(hostId), userId, name, ip, port, authType: effectiveAuthType, enableTerminal, enableTunnel, enableFileManager });
res.json({message: 'SSH data updated'}); const updatedHosts = await db
.select()
.from(sshData)
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
if (updatedHosts.length === 0) {
sshLogger.warn('Updated host not found after update', { operation: 'host_update', hostId: parseInt(hostId), userId });
return res.status(404).json({error: 'Host not found after update'});
}
const updatedHost = updatedHosts[0];
const baseHost = {
...updatedHost,
tags: typeof updatedHost.tags === 'string' ? (updatedHost.tags ? updatedHost.tags.split(',').filter(Boolean) : []) : [],
pin: !!updatedHost.pin,
enableTerminal: !!updatedHost.enableTerminal,
enableTunnel: !!updatedHost.enableTunnel,
tunnelConnections: updatedHost.tunnelConnections ? JSON.parse(updatedHost.tunnelConnections) : [],
enableFileManager: !!updatedHost.enableFileManager,
};
const resolvedHost = await resolveHostCredentials(baseHost) || baseHost;
res.json(resolvedHost);
} catch (err) { } catch (err) {
sshLogger.error('Failed to update SSH host in database', err, { operation: 'host_update', hostId: parseInt(hostId), userId, name, ip, port, authType: effectiveAuthType }); sshLogger.error('Failed to update SSH host in database', err, { operation: 'host_update', hostId: parseInt(hostId), userId, name, ip, port, authType: effectiveAuthType });
res.status(500).json({error: 'Failed to update SSH data'}); res.status(500).json({error: 'Failed to update SSH data'});
@@ -303,19 +326,16 @@ router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Re
// GET /ssh/host // GET /ssh/host
router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => { router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
sshLogger.info('SSH hosts fetch request received', { operation: 'host_fetch', userId });
if (!isNonEmptyString(userId)) { if (!isNonEmptyString(userId)) {
sshLogger.warn('Invalid userId for SSH data fetch', { operation: 'host_fetch', userId }); sshLogger.warn('Invalid userId for SSH data fetch', { operation: 'host_fetch', userId });
return res.status(400).json({error: 'Invalid userId'}); return res.status(400).json({error: 'Invalid userId'});
} }
try { try {
sshLogger.info('Fetching SSH hosts from database', { operation: 'host_fetch', userId });
const data = await db const data = await db
.select() .select()
.from(sshData) .from(sshData)
.where(eq(sshData.userId, userId)); .where(eq(sshData.userId, userId));
sshLogger.info('Processing SSH hosts and resolving credentials', { operation: 'host_fetch', userId, hostCount: data.length });
const result = await Promise.all(data.map(async (row: any) => { const result = await Promise.all(data.map(async (row: any) => {
const baseHost = { const baseHost = {
...row, ...row,
@@ -330,7 +350,6 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
return await resolveHostCredentials(baseHost) || baseHost; return await resolveHostCredentials(baseHost) || baseHost;
})); }));
sshLogger.success('SSH hosts fetched successfully', { operation: 'host_fetch', userId, hostCount: result.length });
res.json(result); res.json(result);
} catch (err) { } catch (err) {
sshLogger.error('Failed to fetch SSH hosts from database', err, { operation: 'host_fetch', userId }); sshLogger.error('Failed to fetch SSH hosts from database', err, { operation: 'host_fetch', userId });
@@ -343,14 +362,12 @@ router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => { router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
const hostId = req.params.id; const hostId = req.params.id;
const userId = (req as any).userId; const userId = (req as any).userId;
sshLogger.info('SSH host fetch by ID request received', { operation: 'host_fetch_by_id', hostId: parseInt(hostId), userId });
if (!isNonEmptyString(userId) || !hostId) { if (!isNonEmptyString(userId) || !hostId) {
sshLogger.warn('Invalid userId or hostId for SSH host fetch by ID', { operation: 'host_fetch_by_id', hostId: parseInt(hostId), userId }); sshLogger.warn('Invalid userId or hostId for SSH host fetch by ID', { operation: 'host_fetch_by_id', hostId: parseInt(hostId), userId });
return res.status(400).json({error: 'Invalid userId or hostId'}); return res.status(400).json({error: 'Invalid userId or hostId'});
} }
try { try {
sshLogger.info('Fetching SSH host by ID from database', { operation: 'host_fetch_by_id', hostId: parseInt(hostId), userId });
const data = await db const data = await db
.select() .select()
.from(sshData) .from(sshData)
@@ -372,7 +389,6 @@ router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response)
enableFileManager: !!host.enableFileManager, enableFileManager: !!host.enableFileManager,
}; };
sshLogger.success('SSH host fetched by ID successfully', { operation: 'host_fetch_by_id', hostId: parseInt(hostId), userId, hostName: result.name });
res.json(await resolveHostCredentials(result) || result); res.json(await resolveHostCredentials(result) || result);
} catch (err) { } catch (err) {
sshLogger.error('Failed to fetch SSH host by ID from database', err, { operation: 'host_fetch_by_id', hostId: parseInt(hostId), userId }); sshLogger.error('Failed to fetch SSH host by ID from database', err, { operation: 'host_fetch_by_id', hostId: parseInt(hostId), userId });
@@ -385,17 +401,14 @@ router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response)
router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => { router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const hostId = req.params.id; const hostId = req.params.id;
sshLogger.info('SSH host deletion request received', { operation: 'host_delete', hostId: parseInt(hostId), userId });
if (!isNonEmptyString(userId) || !hostId) { if (!isNonEmptyString(userId) || !hostId) {
sshLogger.warn('Invalid userId or hostId for SSH host delete', { operation: 'host_delete', hostId: parseInt(hostId), userId }); sshLogger.warn('Invalid userId or hostId for SSH host delete', { operation: 'host_delete', hostId: parseInt(hostId), userId });
return res.status(400).json({error: 'Invalid userId or id'}); return res.status(400).json({error: 'Invalid userId or id'});
} }
try { try {
sshLogger.info('Attempting to delete SSH host from database', { operation: 'host_delete', hostId: parseInt(hostId), userId });
const result = await db.delete(sshData) const result = await db.delete(sshData)
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId))); .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
sshLogger.success('SSH host deleted successfully', { operation: 'host_delete', hostId: parseInt(hostId), userId });
res.json({message: 'SSH host deleted'}); res.json({message: 'SSH host deleted'});
} catch (err) { } catch (err) {
sshLogger.error('Failed to delete SSH host from database', err, { operation: 'host_delete', hostId: parseInt(hostId), userId }); sshLogger.error('Failed to delete SSH host from database', err, { operation: 'host_delete', hostId: parseInt(hostId), userId });
@@ -722,4 +735,66 @@ async function resolveHostCredentials(host: any): Promise<any> {
} }
} }
// Route: Rename folder (requires JWT)
// PUT /ssh/db/folders/rename
router.put('/folders/rename', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { oldName, newName } = req.body;
if (!isNonEmptyString(userId) || !oldName || !newName) {
sshLogger.warn('Invalid data for folder rename');
return res.status(400).json({error: 'Old name and new name are required'});
}
if (oldName === newName) {
return res.json({message: 'Folder name unchanged'});
}
try {
// Update all hosts with the old folder name
const updatedHosts = await db
.update(sshData)
.set({
folder: newName,
updatedAt: new Date().toISOString()
})
.where(and(
eq(sshData.userId, userId),
eq(sshData.folder, oldName)
))
.returning();
// Update all credentials with the old folder name
const updatedCredentials = await db
.update(sshCredentials)
.set({
folder: newName,
updatedAt: new Date().toISOString()
})
.where(and(
eq(sshCredentials.userId, userId),
eq(sshCredentials.folder, oldName)
))
.returning();
sshLogger.success('Folder renamed successfully', {
operation: 'folder_rename',
userId,
oldName,
newName,
updatedHosts: updatedHosts.length,
updatedCredentials: updatedCredentials.length
});
res.json({
message: 'Folder renamed successfully',
updatedHosts: updatedHosts.length,
updatedCredentials: updatedCredentials.length
});
} catch (err) {
sshLogger.error('Failed to rename folder', err, { operation: 'folder_rename', userId, oldName, newName });
res.status(500).json({error: 'Failed to rename folder'});
}
});
export default router; export default router;

View File

@@ -119,7 +119,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
try { try {
const payload = jwt.verify(token, jwtSecret) as JWTPayload; const payload = jwt.verify(token, jwtSecret) as JWTPayload;
(req as any).userId = payload.userId; (req as any).userId = payload.userId;
authLogger.debug('JWT authentication successful', { operation: 'auth', userId: payload.userId, method: req.method, url: req.url }); // JWT authentication successful
next(); next();
} catch (err) { } catch (err) {
authLogger.warn('Invalid or expired token', { operation: 'auth', method: req.method, url: req.url, error: err }); authLogger.warn('Invalid or expired token', { operation: 'auth', method: req.method, url: req.url, error: err });

View File

@@ -49,7 +49,7 @@ function scheduleSessionCleanup(sessionId: string) {
app.post('/ssh/file_manager/ssh/connect', async (req, res) => { app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
const {sessionId, hostId, ip, port, username, password, sshKey, keyPassword, authType, credentialId, userId} = req.body; const {sessionId, hostId, ip, port, username, password, sshKey, keyPassword, authType, credentialId, userId} = req.body;
fileLogger.info('File manager SSH connection request received', { operation: 'file_connect', sessionId, hostId, ip, port, username, authType, hasCredentialId: !!credentialId }); // Connection request received
if (!sessionId || !ip || !username || !port) { if (!sessionId || !ip || !username || !port) {
fileLogger.warn('Missing SSH connection parameters for file manager', { operation: 'file_connect', sessionId, hasIp: !!ip, hasUsername: !!username, hasPort: !!port }); fileLogger.warn('Missing SSH connection parameters for file manager', { operation: 'file_connect', sessionId, hasIp: !!ip, hasUsername: !!username, hasPort: !!port });
@@ -57,14 +57,12 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
} }
if (sshSessions[sessionId]?.isConnected) { if (sshSessions[sessionId]?.isConnected) {
fileLogger.info('Cleaning up existing SSH session', { operation: 'file_connect', sessionId });
cleanupSession(sessionId); cleanupSession(sessionId);
} }
const client = new SSHClient(); const client = new SSHClient();
let resolvedCredentials = {password, sshKey, keyPassword, authType}; let resolvedCredentials = {password, sshKey, keyPassword, authType};
if (credentialId && hostId && userId) { if (credentialId && hostId && userId) {
fileLogger.info('Resolving credentials from database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, userId });
try { try {
const credentials = await db const credentials = await db
.select() .select()
@@ -82,15 +80,14 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
keyPassword: credential.keyPassword, keyPassword: credential.keyPassword,
authType: credential.authType authType: credential.authType
}; };
fileLogger.success('Credentials resolved successfully for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, authType: credential.authType });
} else { } else {
fileLogger.warn('No credentials found in database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId }); fileLogger.warn('No credentials found in database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, userId });
} }
} catch (error) { } catch (error) {
fileLogger.warn('Failed to resolve credentials from database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, error: error instanceof Error ? error.message : 'Unknown error' }); fileLogger.warn('Failed to resolve credentials from database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, error: error instanceof Error ? error.message : 'Unknown error' });
} }
} else { } else if (credentialId && hostId) {
fileLogger.info('Using direct credentials for file manager connection', { operation: 'file_connect', sessionId, hostId, authType }); fileLogger.warn('Missing userId for credential resolution in file manager', { operation: 'file_connect', sessionId, hostId, credentialId, hasUserId: !!userId });
} }
const config: any = { const config: any = {
@@ -137,7 +134,6 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
}; };
if (resolvedCredentials.sshKey && resolvedCredentials.sshKey.trim()) { if (resolvedCredentials.sshKey && resolvedCredentials.sshKey.trim()) {
fileLogger.info('Configuring SSH key authentication for file manager', { operation: 'file_connect', sessionId, hostId, hasKeyPassword: !!resolvedCredentials.keyPassword });
try { try {
if (!resolvedCredentials.sshKey.includes('-----BEGIN') || !resolvedCredentials.sshKey.includes('-----END')) { if (!resolvedCredentials.sshKey.includes('-----BEGIN') || !resolvedCredentials.sshKey.includes('-----END')) {
throw new Error('Invalid private key format'); throw new Error('Invalid private key format');
@@ -149,13 +145,11 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
if (resolvedCredentials.keyPassword) config.passphrase = resolvedCredentials.keyPassword; if (resolvedCredentials.keyPassword) config.passphrase = resolvedCredentials.keyPassword;
fileLogger.success('SSH key authentication configured successfully for file manager', { operation: 'file_connect', sessionId, hostId });
} catch (keyError) { } catch (keyError) {
fileLogger.error('SSH key format error for file manager', { operation: 'file_connect', sessionId, hostId, error: keyError.message }); fileLogger.error('SSH key format error for file manager', { operation: 'file_connect', sessionId, hostId, error: keyError.message });
return res.status(400).json({error: 'Invalid SSH key format'}); return res.status(400).json({error: 'Invalid SSH key format'});
} }
} else if (resolvedCredentials.password && resolvedCredentials.password.trim()) { } else if (resolvedCredentials.password && resolvedCredentials.password.trim()) {
fileLogger.info('Configuring password authentication for file manager', { operation: 'file_connect', sessionId, hostId });
config.password = resolvedCredentials.password; config.password = resolvedCredentials.password;
} else { } else {
fileLogger.warn('No authentication method provided for file manager', { operation: 'file_connect', sessionId, hostId }); fileLogger.warn('No authentication method provided for file manager', { operation: 'file_connect', sessionId, hostId });
@@ -167,7 +161,6 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
client.on('ready', () => { client.on('ready', () => {
if (responseSent) return; if (responseSent) return;
responseSent = true; responseSent = true;
fileLogger.success('SSH connection established for file manager', { operation: 'file_connect', sessionId, hostId, ip, port, username, authType: resolvedCredentials.authType });
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()}; sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
res.json({status: 'success', message: 'SSH connection established'}); res.json({status: 'success', message: 'SSH connection established'});
}); });
@@ -377,7 +370,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
writeStream.on('finish', () => { writeStream.on('finish', () => {
if (hasError || hasFinished) return; if (hasError || hasFinished) return;
hasFinished = true; hasFinished = true;
fileLogger.success(`File written successfully via SFTP: ${filePath}`);
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath}); res.json({message: 'File written successfully', path: filePath});
} }
@@ -386,7 +378,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
writeStream.on('close', () => { writeStream.on('close', () => {
if (hasError || hasFinished) return; if (hasError || hasFinished) return;
hasFinished = true; hasFinished = true;
fileLogger.success(`File written successfully via SFTP: ${filePath}`);
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath}); res.json({message: 'File written successfully', path: filePath});
} }
@@ -440,7 +431,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
if (outputData.includes('SUCCESS')) { if (outputData.includes('SUCCESS')) {
fileLogger.success(`File written successfully via fallback: ${filePath}`);
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath}); res.json({message: 'File written successfully', path: filePath});
} }
@@ -536,8 +526,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
writeStream.on('finish', () => { writeStream.on('finish', () => {
if (hasError || hasFinished) return; if (hasError || hasFinished) return;
hasFinished = true; hasFinished = true;
fileLogger.success(`File uploaded successfully via SFTP: ${fullPath}`);
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'File uploaded successfully', path: fullPath}); res.json({message: 'File uploaded successfully', path: fullPath});
} }
@@ -546,8 +534,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
writeStream.on('close', () => { writeStream.on('close', () => {
if (hasError || hasFinished) return; if (hasError || hasFinished) return;
hasFinished = true; hasFinished = true;
fileLogger.success(`File uploaded successfully via SFTP: ${fullPath}`);
if (!res.headersSent) { if (!res.headersSent) {
res.json({message: 'File uploaded successfully', path: fullPath}); res.json({message: 'File uploaded successfully', path: fullPath});
} }

View File

@@ -101,8 +101,6 @@ async function fetchHostById(id: number): Promise<SSHHostWithCredentials | undef
async function resolveHostCredentials(host: any): Promise<SSHHostWithCredentials | undefined> { async function resolveHostCredentials(host: any): Promise<SSHHostWithCredentials | undefined> {
try { try {
statsLogger.info('Resolving credentials for host', { operation: 'host_credential_resolve', hostId: host.id, hostName: host.name, hasCredentialId: !!host.credentialId });
const baseHost: any = { const baseHost: any = {
id: host.id, id: host.id,
name: host.name, name: host.name,
@@ -124,7 +122,6 @@ async function resolveHostCredentials(host: any): Promise<SSHHostWithCredentials
}; };
if (host.credentialId) { if (host.credentialId) {
statsLogger.info('Fetching credentials from database', { operation: 'host_credential_resolve', hostId: host.id, credentialId: host.credentialId, userId: host.userId });
try { try {
const credentials = await db const credentials = await db
.select() .select()
@@ -152,6 +149,7 @@ async function resolveHostCredentials(host: any): Promise<SSHHostWithCredentials
if (credential.keyType) { if (credential.keyType) {
baseHost.keyType = credential.keyType; baseHost.keyType = credential.keyType;
} }
} else { } else {
statsLogger.warn(`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`); statsLogger.warn(`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`);
addLegacyCredentials(baseHost, host); addLegacyCredentials(baseHost, host);
@@ -437,23 +435,19 @@ function tcpPing(host: string, port: number, timeoutMs = 5000): Promise<boolean>
} }
async function pollStatusesOnce(): Promise<void> { async function pollStatusesOnce(): Promise<void> {
statsLogger.info('Starting status polling for all hosts', { operation: 'status_poll' });
const hosts = await fetchAllHosts(); const hosts = await fetchAllHosts();
if (hosts.length === 0) { if (hosts.length === 0) {
statsLogger.warn('No hosts retrieved for status polling', { operation: 'status_poll' }); statsLogger.warn('No hosts retrieved for status polling', { operation: 'status_poll' });
return; return;
} }
statsLogger.info('Polling status for hosts', { operation: 'status_poll', hostCount: hosts.length, hostIds: hosts.map(h => h.id) });
const now = new Date().toISOString(); const now = new Date().toISOString();
const checks = hosts.map(async (h) => { const checks = hosts.map(async (h) => {
statsLogger.info('Checking host status', { operation: 'status_poll', hostId: h.id, hostName: h.name, ip: h.ip, port: h.port });
const isOnline = await tcpPing(h.ip, h.port, 5000); const isOnline = await tcpPing(h.ip, h.port, 5000);
const now = new Date().toISOString(); const now = new Date().toISOString();
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now}; const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now};
hostStatuses.set(h.id, statusEntry); hostStatuses.set(h.id, statusEntry);
statsLogger.info('Host status check completed', { operation: 'status_poll', hostId: h.id, hostName: h.name, status: isOnline ? 'online' : 'offline' });
return isOnline; return isOnline;
}); });

View File

@@ -21,7 +21,6 @@ wss.on('connection', (ws: WebSocket) => {
ws.on('close', () => { ws.on('close', () => {
sshLogger.info('WebSocket connection closed', { operation: 'websocket_disconnect' });
cleanupSSH(); cleanupSSH();
}); });
@@ -53,7 +52,6 @@ wss.on('connection', (ws: WebSocket) => {
break; break;
case 'disconnect': case 'disconnect':
sshLogger.info('SSH disconnect requested', { operation: 'ssh_disconnect' });
cleanupSSH(); cleanupSSH();
break; break;
@@ -127,14 +125,14 @@ wss.on('connection', (ws: WebSocket) => {
}, 60000); }, 60000);
let resolvedCredentials = {password, key, keyPassword, keyType, authType}; let resolvedCredentials = {password, key, keyPassword, keyType, authType};
if (credentialId && id) { if (credentialId && id && hostConfig.userId) {
try { try {
const credentials = await db const credentials = await db
.select() .select()
.from(sshCredentials) .from(sshCredentials)
.where(and( .where(and(
eq(sshCredentials.id, credentialId), eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, hostConfig.userId || '') eq(sshCredentials.userId, hostConfig.userId)
)); ));
if (credentials.length > 0) { if (credentials.length > 0) {
@@ -146,15 +144,18 @@ wss.on('connection', (ws: WebSocket) => {
keyType: credential.keyType, keyType: credential.keyType,
authType: credential.authType authType: credential.authType
}; };
} else {
sshLogger.warn(`No credentials found for host ${id}`, { operation: 'ssh_credentials', hostId: id, credentialId, userId: hostConfig.userId });
} }
} catch (error) { } catch (error) {
sshLogger.warn(`Failed to resolve credentials for host ${id}`, { operation: 'ssh_credentials', hostId: id, credentialId, error: error instanceof Error ? error.message : 'Unknown error' }); sshLogger.warn(`Failed to resolve credentials for host ${id}`, { operation: 'ssh_credentials', hostId: id, credentialId, error: error instanceof Error ? error.message : 'Unknown error' });
} }
} else if (credentialId && id) {
sshLogger.warn('Missing userId for credential resolution in terminal', { operation: 'ssh_credentials', hostId: id, credentialId, hasUserId: !!hostConfig.userId });
} }
sshConn.on('ready', () => { sshConn.on('ready', () => {
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
sshLogger.success('SSH connection established', { operation: 'ssh_connect', hostId: id, ip, port, username, authType: resolvedCredentials.authType });
sshConn!.shell({ sshConn!.shell({
@@ -175,7 +176,6 @@ wss.on('connection', (ws: WebSocket) => {
}); });
stream.on('close', () => { stream.on('close', () => {
sshLogger.info('SSH stream closed', { operation: 'ssh_stream', hostId: id, ip, port, username });
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'})); ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
}); });
@@ -219,7 +219,6 @@ wss.on('connection', (ws: WebSocket) => {
sshConn.on('close', () => { sshConn.on('close', () => {
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
cleanupSSH(connectionTimeout); cleanupSSH(connectionTimeout);
}); });

View File

@@ -382,7 +382,9 @@ async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): P
const tunnelName = tunnelConfig.name; const tunnelName = tunnelConfig.name;
const tunnelMarker = getTunnelMarker(tunnelName); const tunnelMarker = getTunnelMarker(tunnelName);
tunnelLogger.info('SSH tunnel connection attempt started', { operation: 'tunnel_connect', tunnelName, retryAttempt, sourceIP: tunnelConfig.sourceIP, sourcePort: tunnelConfig.sourceSSHPort }); if (retryAttempt === 0) {
tunnelLogger.info('SSH tunnel connection attempt started', { operation: 'tunnel_connect', tunnelName, sourceIP: tunnelConfig.sourceIP, sourcePort: tunnelConfig.sourceSSHPort });
}
if (manualDisconnects.has(tunnelName)) { if (manualDisconnects.has(tunnelName)) {
tunnelLogger.info('Tunnel connection cancelled due to manual disconnect', { operation: 'tunnel_connect', tunnelName }); tunnelLogger.info('Tunnel connection cancelled due to manual disconnect', { operation: 'tunnel_connect', tunnelName });
@@ -394,14 +396,10 @@ async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): P
if (retryAttempt === 0) { if (retryAttempt === 0) {
retryExhaustedTunnels.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName);
retryCounters.delete(tunnelName); retryCounters.delete(tunnelName);
tunnelLogger.info('Reset retry state for tunnel', { operation: 'tunnel_connect', tunnelName });
} else {
tunnelLogger.warn('Tunnel connection retry attempt', { operation: 'tunnel_connect', tunnelName, retryAttempt });
} }
const currentStatus = connectionStatus.get(tunnelName); const currentStatus = connectionStatus.get(tunnelName);
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) { if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
tunnelLogger.info('Broadcasting tunnel connecting status', { operation: 'tunnel_connect', tunnelName, retryAttempt });
broadcastTunnelStatus(tunnelName, { broadcastTunnelStatus(tunnelName, {
connected: false, connected: false,
status: CONNECTION_STATES.CONNECTING, status: CONNECTION_STATES.CONNECTING,
@@ -428,7 +426,6 @@ async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): P
}; };
if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) { if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) {
tunnelLogger.info('Resolving source credentials from database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId, userId: tunnelConfig.sourceUserId });
try { try {
const credentials = await db const credentials = await db
.select() .select()
@@ -447,15 +444,12 @@ async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): P
keyType: credential.keyType, keyType: credential.keyType,
authMethod: credential.authType authMethod: credential.authType
}; };
tunnelLogger.success('Source credentials resolved successfully', { operation: 'tunnel_connect', tunnelName, credentialId: credential.id, authType: credential.authType });
} else { } else {
tunnelLogger.warn('No source credentials found in database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId }); tunnelLogger.warn('No source credentials found in database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId });
} }
} catch (error) { } catch (error) {
tunnelLogger.warn('Failed to resolve source credentials from database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId, error: error instanceof Error ? error.message : 'Unknown error' }); tunnelLogger.warn('Failed to resolve source credentials from database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId, error: error instanceof Error ? error.message : 'Unknown error' });
} }
} else {
tunnelLogger.info('Using direct source credentials from tunnel config', { operation: 'tunnel_connect', tunnelName, authMethod: tunnelConfig.sourceAuthMethod });
} }
// Resolve endpoint credentials if tunnel config has endpointCredentialId // Resolve endpoint credentials if tunnel config has endpointCredentialId
@@ -486,10 +480,14 @@ async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): P
keyType: credential.keyType, keyType: credential.keyType,
authMethod: credential.authType authMethod: credential.authType
}; };
} else {
tunnelLogger.warn('No endpoint credentials found in database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.endpointCredentialId });
} }
} catch (error) { } catch (error) {
tunnelLogger.warn(`Failed to resolve endpoint credentials for tunnel ${tunnelName}: ${error instanceof Error ? error.message : 'Unknown error'}`); tunnelLogger.warn(`Failed to resolve endpoint credentials for tunnel ${tunnelName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
} else if (tunnelConfig.endpointCredentialId) {
tunnelLogger.warn('Missing userId for endpoint credential resolution', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.endpointCredentialId, hasUserId: !!tunnelConfig.endpointUserId });
} }
const conn = new Client(); const conn = new Client();

View File

@@ -12,12 +12,6 @@ import { systemLogger } from './utils/logger.js';
try { try {
systemLogger.info("Initializing backend services...", { operation: 'startup' }); systemLogger.info("Initializing backend services...", { operation: 'startup' });
systemLogger.info("Loading database service...", { operation: 'database_init' });
systemLogger.info("Loading SSH terminal service...", { operation: 'terminal_init' });
systemLogger.info("Loading SSH tunnel service...", { operation: 'tunnel_init' });
systemLogger.info("Loading file manager service...", { operation: 'file_manager_init' });
systemLogger.info("Loading server stats service...", { operation: 'stats_init' });
systemLogger.success("All backend services initialized successfully", { systemLogger.success("All backend services initialized successfully", {
operation: 'startup_complete', operation: 'startup_complete',
services: ['database', 'terminal', 'tunnel', 'file_manager', 'stats'] services: ['database', 'terminal', 'tunnel', 'file_manager', 'stats']
@@ -25,13 +19,11 @@ import { systemLogger } from './utils/logger.js';
process.on('SIGINT', () => { process.on('SIGINT', () => {
systemLogger.info("Received SIGINT signal, initiating graceful shutdown...", { operation: 'shutdown' }); systemLogger.info("Received SIGINT signal, initiating graceful shutdown...", { operation: 'shutdown' });
systemLogger.info("Shutting down all services...", { operation: 'shutdown' });
process.exit(0); process.exit(0);
}); });
process.on('SIGTERM', () => { process.on('SIGTERM', () => {
systemLogger.info("Received SIGTERM signal, initiating graceful shutdown...", { operation: 'shutdown' }); systemLogger.info("Received SIGTERM signal, initiating graceful shutdown...", { operation: 'shutdown' });
systemLogger.info("Shutting down all services...", { operation: 'shutdown' });
process.exit(0); process.exit(0);
}); });

View File

@@ -1,6 +1,6 @@
/** /**
* Frontend Logger - A comprehensive logging utility for the frontend * Frontend Logger - A comprehensive logging utility for the frontend
* Based on the backend logger patterns but adapted for browser environment * Enhanced with better formatting, readability, and request/response grouping
*/ */
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success';
@@ -45,7 +45,7 @@ class FrontendLogger {
private formatMessage(level: LogLevel, message: string, context?: LogContext): string { private formatMessage(level: LogLevel, message: string, context?: LogContext): string {
const timestamp = this.getTimeStamp(); const timestamp = this.getTimeStamp();
const levelTag = this.getLevelTag(level); const levelTag = this.getLevelTag(level);
const serviceTag = this.serviceIcon; const serviceTag = this.getServiceTag();
let contextStr = ''; let contextStr = '';
if (context && this.isDevelopment) { if (context && this.isDevelopment) {
@@ -78,6 +78,10 @@ class FrontendLogger {
return `${symbols[level]} [${level.toUpperCase()}]`; return `${symbols[level]} [${level.toUpperCase()}]`;
} }
private getServiceTag(): string {
return `${this.serviceIcon} [${this.serviceName}]`;
}
private shouldLog(level: LogLevel): boolean { private shouldLog(level: LogLevel): boolean {
if (level === 'debug' && !this.isDevelopment) { if (level === 'debug' && !this.isDevelopment) {
return false; return false;
@@ -181,64 +185,136 @@ class FrontendLogger {
this.warn(`SECURITY: ${message}`, { ...context, operation: 'security' }); this.warn(`SECURITY: ${message}`, { ...context, operation: 'security' });
} }
// Specialized logging methods for different scenarios // Enhanced API request/response logging methods
requestStart(method: string, url: string, context?: LogContext): void { requestStart(method: string, url: string, context?: LogContext): void {
this.request(`Starting ${method.toUpperCase()} request`, { const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
console.group(`🚀 ${method.toUpperCase()} ${shortUrl}`);
this.request(`→ Starting request to ${cleanUrl}`, {
...context, ...context,
method: method.toUpperCase(), method: method.toUpperCase(),
url: this.sanitizeUrl(url) url: cleanUrl
}); });
} }
requestSuccess(method: string, url: string, status: number, responseTime: number, context?: LogContext): void { requestSuccess(method: string, url: string, status: number, responseTime: number, context?: LogContext): void {
this.response(`Request completed successfully`, { const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status);
const performanceIcon = this.getPerformanceIcon(responseTime);
this.response(`${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`, {
...context, ...context,
method: method.toUpperCase(), method: method.toUpperCase(),
url: this.sanitizeUrl(url), url: cleanUrl,
status, status,
responseTime responseTime
}); });
console.groupEnd();
} }
requestError(method: string, url: string, status: number, errorMessage: string, responseTime?: number, context?: LogContext): void { requestError(method: string, url: string, status: number, errorMessage: string, responseTime?: number, context?: LogContext): void {
this.error(`Request failed`, undefined, { const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status);
this.error(`${statusIcon} ${status} ${errorMessage}`, undefined, {
...context, ...context,
method: method.toUpperCase(), method: method.toUpperCase(),
url: this.sanitizeUrl(url), url: cleanUrl,
status, status,
errorMessage, errorMessage,
responseTime responseTime
}); });
console.groupEnd();
} }
networkError(method: string, url: string, errorMessage: string, context?: LogContext): void { networkError(method: string, url: string, errorMessage: string, context?: LogContext): void {
this.error(`Network error occurred`, undefined, { const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
this.error(`🌐 Network Error: ${errorMessage}`, undefined, {
...context, ...context,
method: method.toUpperCase(), method: method.toUpperCase(),
url: this.sanitizeUrl(url), url: cleanUrl,
errorMessage, errorMessage,
errorCode: 'NETWORK_ERROR' errorCode: 'NETWORK_ERROR'
}); });
console.groupEnd();
} }
authError(method: string, url: string, context?: LogContext): void { authError(method: string, url: string, context?: LogContext): void {
this.security(`Authentication failed`, { const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
this.security(`🔐 Authentication Required`, {
...context, ...context,
method: method.toUpperCase(), method: method.toUpperCase(),
url: this.sanitizeUrl(url), url: cleanUrl,
errorCode: 'AUTH_REQUIRED' errorCode: 'AUTH_REQUIRED'
}); });
console.groupEnd();
} }
retryAttempt(method: string, url: string, attempt: number, maxAttempts: number, context?: LogContext): void { retryAttempt(method: string, url: string, attempt: number, maxAttempts: number, context?: LogContext): void {
this.retry(`Retry attempt ${attempt}/${maxAttempts}`, { const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
this.retry(`🔄 Retry ${attempt}/${maxAttempts}`, {
...context, ...context,
method: method.toUpperCase(), method: method.toUpperCase(),
url: this.sanitizeUrl(url), url: cleanUrl,
retryCount: attempt retryCount: attempt
}); });
} }
// Enhanced logging for API operations
apiOperation(operation: string, details: string, context?: LogContext): void {
this.info(`🔧 ${operation}: ${details}`, { ...context, operation: 'api_operation' });
}
// Log request summary for better debugging
requestSummary(method: string, url: string, status: number, responseTime: number, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status);
const performanceIcon = this.getPerformanceIcon(responseTime);
console.log(`%c📊 ${method} ${shortUrl} ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
'color: #666; font-style: italic; font-size: 0.9em;',
context
);
}
// New helper methods for better formatting
private getShortUrl(url: string): string {
try {
const urlObj = new URL(url);
const path = urlObj.pathname;
const query = urlObj.search;
return `${urlObj.hostname}${path}${query}`;
} catch {
return url.length > 50 ? url.substring(0, 47) + '...' : url;
}
}
private getStatusIcon(status: number): string {
if (status >= 200 && status < 300) return '✅';
if (status >= 300 && status < 400) return '↩️';
if (status >= 400 && status < 500) return '⚠️';
if (status >= 500) return '❌';
return '❓';
}
private getPerformanceIcon(responseTime: number): string {
if (responseTime < 100) return '⚡';
if (responseTime < 500) return '🚀';
if (responseTime < 1000) return '🏃';
if (responseTime < 3000) return '🚶';
return '🐌';
}
private sanitizeUrl(url: string): string { private sanitizeUrl(url: string): string {
// Remove sensitive information from URLs for logging // Remove sensitive information from URLs for logging
try { try {

View File

@@ -297,7 +297,7 @@ export interface CredentialViewerProps {
export interface CredentialSelectorProps { export interface CredentialSelectorProps {
value?: number | null; value?: number | null;
onChange: (value: number | null) => void; onValueChange: (value: number | null) => void;
} }
export interface HostManagerProps { export interface HostManagerProps {
@@ -384,10 +384,6 @@ export interface FolderStats {
}>; }>;
} }
export interface FolderManagerProps {
onFolderChanged?: () => void;
}
// ============================================================================ // ============================================================================
// BACKEND TYPES // BACKEND TYPES
// ============================================================================ // ============================================================================

View File

@@ -21,7 +21,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert"
import { toast } from "sonner" import { toast } from "sonner"
import { createCredential, updateCredential, getCredentials, getCredentialDetails } from '@/ui/main-axios' import { createCredential, updateCredential, getCredentials, getCredentialDetails } from '@/ui/main-axios'
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import type { Credential, CredentialEditorProps } from '../../../types/index.js' import type { Credential, CredentialEditorProps, CredentialData } from '../../../types/index.js'
export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) { export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -31,6 +31,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
const [fullCredentialDetails, setFullCredentialDetails] = useState<Credential | null>(null); const [fullCredentialDetails, setFullCredentialDetails] = useState<Credential | null>(null);
const [authTab, setAuthTab] = useState<'password' | 'key'>('password'); const [authTab, setAuthTab] = useState<'password' | 'key'>('password');
const [keyInputMethod, setKeyInputMethod] = useState<'upload' | 'paste'>('upload');
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@@ -84,9 +85,15 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
key: z.any().optional().nullable(), key: z.any().optional().nullable(),
keyPassword: z.string().optional(), keyPassword: z.string().optional(),
keyType: z.enum([ keyType: z.enum([
'rsa', 'auto',
'ecdsa', 'ssh-rsa',
'ed25519' 'ssh-ed25519',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
'ssh-dss',
'ssh-rsa-sha2-256',
'ssh-rsa-sha2-512',
]).optional(), ]).optional(),
}).superRefine((data, ctx) => { }).superRefine((data, ctx) => {
if (data.authType === 'password') { if (data.authType === 'password') {
@@ -122,14 +129,13 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
password: "", password: "",
key: null, key: null,
keyPassword: "", keyPassword: "",
keyType: "rsa", keyType: "auto",
} }
}); });
useEffect(() => { useEffect(() => {
if (editingCredential && fullCredentialDetails) { if (editingCredential && fullCredentialDetails) {
const defaultAuthType = fullCredentialDetails.authType; const defaultAuthType = fullCredentialDetails.authType;
setAuthTab(defaultAuthType); setAuthTab(defaultAuthType);
form.reset({ form.reset({
@@ -142,11 +148,10 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
password: fullCredentialDetails.password || "", password: fullCredentialDetails.password || "",
key: null, key: null,
keyPassword: fullCredentialDetails.keyPassword || "", keyPassword: fullCredentialDetails.keyPassword || "",
keyType: (fullCredentialDetails.keyType as any) || "rsa", keyType: (fullCredentialDetails.keyType as any) || "auto",
}); });
} else if (!editingCredential) { } else if (!editingCredential) {
setAuthTab('password'); setAuthTab('password');
form.reset({ form.reset({
name: "", name: "",
description: "", description: "",
@@ -157,52 +162,43 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
password: "", password: "",
key: null, key: null,
keyPassword: "", keyPassword: "",
keyType: "rsa", keyType: "auto",
}); });
} }
}, [editingCredential, fullCredentialDetails, form]); }, [editingCredential?.id, fullCredentialDetails]);
const onSubmit = async (data: any) => { const onSubmit = async (data: FormData) => {
try { try {
const formData = data as FormData; if (!data.name || data.name.trim() === '') {
data.name = data.username;
if (!formData.name || formData.name.trim() === '') {
formData.name = formData.username;
} }
const submitData: any = { const submitData: CredentialData = {
name: formData.name, name: data.name,
description: formData.description, description: data.description,
folder: formData.folder, folder: data.folder,
tags: formData.tags, tags: data.tags,
authType: formData.authType, authType: data.authType,
username: formData.username, username: data.username,
keyType: formData.keyType keyType: data.keyType
}; };
if (formData.password !== undefined) { if (data.authType === 'password') {
submitData.password = formData.password; submitData.password = data.password;
} submitData.key = undefined;
submitData.keyPassword = undefined;
if (formData.key !== undefined) { } else if (data.authType === 'key') {
if (formData.key instanceof File) { submitData.key = data.key instanceof File ? await data.key.text() : data.key;
const keyContent = await formData.key.text(); submitData.keyPassword = data.keyPassword;
submitData.key = keyContent; submitData.password = undefined;
} else {
submitData.key = formData.key;
}
}
if (formData.keyPassword !== undefined) {
submitData.keyPassword = formData.keyPassword;
} }
if (editingCredential) { if (editingCredential) {
await updateCredential(editingCredential.id, submitData); await updateCredential(editingCredential.id, submitData);
toast.success(t('credentials.credentialUpdatedSuccessfully', { name: formData.name })); toast.success(t('credentials.credentialUpdatedSuccessfully', { name: data.name }));
} else { } else {
await createCredential(submitData); await createCredential(submitData);
toast.success(t('credentials.credentialAddedSuccessfully', { name: formData.name })); toast.success(t('credentials.credentialAddedSuccessfully', { name: data.name }));
} }
if (onFormSubmit) { if (onFormSubmit) {
@@ -256,9 +252,15 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
}, [folderDropdownOpen]); }, [folderDropdownOpen]);
const keyTypeOptions = [ const keyTypeOptions = [
{ value: 'rsa', label: t('credentials.keyTypeRSA') }, { value: 'auto', label: t('hosts.autoDetect') },
{ value: 'ecdsa', label: t('credentials.keyTypeECDSA') }, { value: 'ssh-rsa', label: t('hosts.rsa') },
{ value: 'ed25519', label: t('credentials.keyTypeEd25519') }, { value: 'ssh-ed25519', label: t('hosts.ed25519') },
{ value: 'ecdsa-sha2-nistp256', label: t('hosts.ecdsaNistP256') },
{ value: 'ecdsa-sha2-nistp384', label: t('hosts.ecdsaNistP384') },
{ value: 'ecdsa-sha2-nistp521', label: t('hosts.ecdsaNistP521') },
{ value: 'ssh-dss', label: t('hosts.dsa') },
{ value: 'ssh-rsa-sha2-256', label: t('hosts.rsaSha2256') },
{ value: 'ssh-rsa-sha2-512', label: t('hosts.rsaSha2512') },
]; ];
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false); const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
@@ -436,13 +438,16 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
<Tabs <Tabs
value={authTab} value={authTab}
onValueChange={(value) => { onValueChange={(value) => {
setAuthTab(value as 'password' | 'key'); const newAuthType = value as 'password' | 'key';
form.setValue('authType', value as 'password' | 'key'); setAuthTab(newAuthType);
form.setValue('authType', newAuthType);
// Clear other auth fields when switching // Clear other auth fields when switching
if (value === 'password') { if (newAuthType === 'password') {
form.setValue('key', null); form.setValue('key', null);
form.setValue('keyPassword', ''); form.setValue('keyPassword', '');
} else if (value === 'key') { form.setValue('keyType', 'auto');
} else if (newAuthType === 'key') {
form.setValue('password', ''); form.setValue('password', '');
} }
}} }}
@@ -467,103 +472,206 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
/> />
</TabsContent> </TabsContent>
<TabsContent value="key"> <TabsContent value="key">
<div className="grid grid-cols-15 gap-4"> <Tabs
<Controller value={keyInputMethod}
control={form.control} onValueChange={(value) => {
name="key" setKeyInputMethod(value as 'upload' | 'paste');
render={({ field }) => ( // Clear the other field when switching
<FormItem className="col-span-4 overflow-hidden min-w-0"> if (value === 'upload') {
<FormLabel>{t('credentials.sshPrivateKey')}</FormLabel> form.setValue('key', null);
<FormControl> } else {
<div className="relative min-w-0"> form.setValue('key', '');
<input }
id="key-upload" }}
type="file" className="w-full"
accept=".pem,.key,.txt,.ppk" >
onChange={(e) => { <TabsList className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
const file = e.target.files?.[0]; <TabsTrigger value="upload">{t('hosts.uploadFile')}</TabsTrigger>
field.onChange(file || null); <TabsTrigger value="paste">{t('hosts.pasteKey')}</TabsTrigger>
}} </TabsList>
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" <TabsContent value="upload" className="mt-4">
/> <div className="grid grid-cols-15 gap-4">
<Button <Controller
type="button" control={form.control}
variant="outline" name="key"
className="w-full min-w-0 overflow-hidden px-3 py-2 text-left" render={({ field }) => (
> <FormItem className="col-span-4 overflow-hidden min-w-0">
<span className="block w-full truncate" <FormLabel>{t('credentials.sshPrivateKey')}</FormLabel>
title={field.value?.name || t('credentials.upload')}> <FormControl>
{field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')} <div className="relative min-w-0">
</span> <input
</Button> id="key-upload"
</div> type="file"
</FormControl> accept=".pem,.key,.txt,.ppk"
</FormItem> onChange={(e) => {
)} const file = e.target.files?.[0];
/> field.onChange(file || null);
<FormField }}
control={form.control} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
name="keyPassword" />
render={({ field }) => ( <Button
<FormItem className="col-span-8"> type="button"
<FormLabel>{t('credentials.keyPassword')}</FormLabel> variant="outline"
<FormControl> className="w-full min-w-0 overflow-hidden px-3 py-2 text-left"
<Input >
placeholder={t('placeholders.keyPassword')} <span className="block w-full truncate"
type="password" title={field.value?.name || t('credentials.upload')}>
{...field} {field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')}
/> </span>
</FormControl> </Button>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyType"
render={({ field }) => (
<FormItem className="relative col-span-3">
<FormLabel>{t('credentials.keyType')}</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={keyTypeButtonRef}
type="button"
variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-[#18181b] border border-input text-foreground"
onClick={() => setKeyTypeDropdownOpen((open) => !open)}
>
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || t('credentials.keyTypeRSA')}
</Button>
{keyTypeDropdownOpen && (
<div
ref={keyTypeDropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map((opt) => (
<Button
key={opt.value}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-[#18181b] text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => {
field.onChange(opt.value);
setKeyTypeDropdownOpen(false);
}}
>
{opt.label}
</Button>
))}
</div>
</div> </div>
)} </FormControl>
</div> </FormItem>
</FormControl> )}
</FormItem> />
)} <FormField
/> control={form.control}
</div> name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>{t('credentials.keyPassword')}</FormLabel>
<FormControl>
<Input
placeholder={t('placeholders.keyPassword')}
type="password"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyType"
render={({ field }) => (
<FormItem className="relative col-span-3">
<FormLabel>{t('credentials.keyType')}</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={keyTypeButtonRef}
type="button"
variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-[#18181b] border border-input text-foreground"
onClick={() => setKeyTypeDropdownOpen((open) => !open)}
>
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || t('credentials.keyTypeRSA')}
</Button>
{keyTypeDropdownOpen && (
<div
ref={keyTypeDropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map((opt) => (
<Button
key={opt.value}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-[#18181b] text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => {
field.onChange(opt.value);
setKeyTypeDropdownOpen(false);
}}
>
{opt.label}
</Button>
))}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
<TabsContent value="paste" className="mt-4">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>{t('credentials.sshPrivateKey')}</FormLabel>
<FormControl>
<textarea
placeholder={t('placeholders.pastePrivateKey')}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={typeof field.value === 'string' ? field.value : ''}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
</FormItem>
)}
/>
<div className="grid grid-cols-15 gap-4 mt-4">
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>{t('credentials.keyPassword')}</FormLabel>
<FormControl>
<Input
placeholder={t('placeholders.keyPassword')}
type="password"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyType"
render={({ field }) => (
<FormItem className="relative col-span-3">
<FormLabel>{t('credentials.keyType')}</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={keyTypeButtonRef}
type="button"
variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-[#18181b] border border-input text-foreground"
onClick={() => setKeyTypeDropdownOpen((open) => !open)}
>
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || t('credentials.keyTypeRSA')}
</Button>
{keyTypeDropdownOpen && (
<div
ref={keyTypeDropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map((opt) => (
<Button
key={opt.value}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-[#18181b] text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => {
field.onChange(opt.value);
setKeyTypeDropdownOpen(false);
}}
>
{opt.label}
</Button>
))}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
</Tabs>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</TabsContent> </TabsContent>

View File

@@ -19,7 +19,6 @@ import {
import { getCredentials, deleteCredential } from '@/ui/main-axios'; import { getCredentials, deleteCredential } from '@/ui/main-axios';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import {CredentialEditor} from './CredentialEditor';
import CredentialViewer from './CredentialViewer'; import CredentialViewer from './CredentialViewer';
import type { Credential, CredentialsManagerProps } from '../../../types/index.js'; import type { Credential, CredentialsManagerProps } from '../../../types/index.js';

View File

@@ -1,243 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Folder,
Edit,
Search,
Trash2,
Users
} from 'lucide-react';
import { getFoldersWithStats, renameFolder } from '@/ui/main-axios';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import type { FolderManagerProps } from '../../../types/index.js';
interface FolderStats {
name: string;
hostCount: number;
hosts: Array<{
id: number;
name?: string;
ip: string;
}>;
}
export function FolderManager({ onFolderChanged }: FolderManagerProps) {
const { t } = useTranslation();
const [folders, setFolders] = useState<FolderStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
// Rename state
const [renameLoading, setRenameLoading] = useState(false);
useEffect(() => {
fetchFolders();
}, []);
const fetchFolders = async () => {
try {
setLoading(true);
const data = await getFoldersWithStats();
setFolders(data || []);
setError(null);
} catch (err) {
setError('Failed to fetch folder statistics');
} finally {
setLoading(false);
}
};
const handleRename = async (folder: FolderStats) => {
const newName = prompt(
`Enter new name for folder "${folder.name}":\n\nThis will update ${folder.hostCount} host(s) that use this folder.`,
folder.name
);
if (!newName || newName.trim() === '' || newName === folder.name) {
return;
}
if (window.confirm(
`Are you sure you want to rename folder "${folder.name}" to "${newName.trim()}"?\n\n` +
`This will update ${folder.hostCount} host(s) that currently use this folder.`
)) {
try {
setRenameLoading(true);
await renameFolder(folder.name, newName.trim());
toast.success(`Folder renamed from "${folder.name}" to "${newName.trim()}"`, {
description: `Updated ${folder.hostCount} host(s)`
});
// Refresh folder list
await fetchFolders();
// Notify parent component about folder change
if (onFolderChanged) {
onFolderChanged();
}
// Emit event for other components to refresh
window.dispatchEvent(new CustomEvent('folders:changed'));
} catch (err) {
toast.error('Failed to rename folder');
} finally {
setRenameLoading(false);
}
}
};
const filteredFolders = useMemo(() => {
if (!searchQuery.trim()) {
return folders;
}
const query = searchQuery.toLowerCase();
return folders.filter(folder =>
folder.name.toLowerCase().includes(query) ||
folder.hosts.some(host =>
(host.name?.toLowerCase().includes(query)) ||
host.ip.toLowerCase().includes(query)
)
);
}, [folders, searchQuery]);
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading folders...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">{error}</p>
<Button onClick={fetchFolders} variant="outline">
Retry
</Button>
</div>
</div>
);
}
if (folders.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<Folder className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
<h3 className="text-lg font-semibold mb-2">No Folders Found</h3>
<p className="text-muted-foreground mb-4">
Create some hosts with folders to manage them here
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-2">
<div>
<h2 className="text-xl font-semibold">Folder Management</h2>
<p className="text-muted-foreground">
{filteredFolders.length} folder(s) found
</p>
</div>
<div className="flex items-center gap-2">
<Button onClick={fetchFolders} variant="outline" size="sm">
Refresh
</Button>
</div>
</div>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input
placeholder="Search folders..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-3 pb-20">
{filteredFolders.map((folder) => (
<div
key={folder.name}
className="bg-[#222225] border border-input rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Folder className="h-5 w-5 text-blue-500" />
<h3 className="font-medium text-lg truncate">
{folder.name}
</h3>
<Badge variant="secondary" className="ml-auto">
<Users className="h-3 w-3 mr-1" />
{folder.hostCount} host(s)
</Badge>
</div>
</div>
<div className="flex gap-1 flex-shrink-0 ml-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleRename(folder)}
className="h-8 w-8 p-0"
title="Rename folder"
disabled={renameLoading}
>
{renameLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
) : (
<Edit className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground mb-2">
Hosts using this folder:
</p>
<div className="grid grid-cols-1 gap-1 max-h-32 overflow-y-auto">
{folder.hosts.slice(0, 5).map((host) => (
<div key={host.id} className="flex items-center gap-2 text-sm bg-muted/20 rounded px-2 py-1">
<span className="font-medium">
{host.name || host.ip}
</span>
{host.name && (
<span className="text-muted-foreground">
({host.ip})
</span>
)}
</div>
))}
{folder.hosts.length > 5 && (
<div className="text-sm text-muted-foreground px-2 py-1">
... and {folder.hosts.length - 5} more host(s)
</div>
)}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -1,18 +1,19 @@
import React, {useState} from "react"; import React, {useState} from "react";
import {HostManagerHostViewer} from "@/ui/Desktop/Apps/Host Manager/HostManagerHostViewer.tsx" import {HostManagerViewer} from "@/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx"; import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {Separator} from "@/components/ui/separator.tsx"; import {Separator} from "@/components/ui/separator.tsx";
import {HostManagerHostEditor} from "@/ui/Desktop/Apps/Host Manager/HostManagerHostEditor.tsx"; import {HostManagerEditor} from "@/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx";
import {CredentialsManager} from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx"; import {CredentialsManager} from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx";
import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx"; import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
import {useSidebar} from "@/components/ui/sidebar.tsx"; import {useSidebar} from "@/components/ui/sidebar.tsx";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import type { SSHHost, HostManagerProps } from '../../../types/index.js'; import type { SSHHost, HostManagerProps } from '../../../types/index';
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement { export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
const {t} = useTranslation(); const {t} = useTranslation();
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 [editingCredential, setEditingCredential] = useState<any | null>(null); const [editingCredential, setEditingCredential] = useState<any | null>(null);
const {state: sidebarState} = useSidebar(); const {state: sidebarState} = useSidebar();
@@ -21,8 +22,12 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
setActiveTab("add_host"); setActiveTab("add_host");
}; };
const handleFormSubmit = () => { const handleFormSubmit = (updatedHost?: SSHHost) => {
setEditingHost(null); if (updatedHost) {
setEditingHost(updatedHost);
} else {
setEditingHost(null);
}
setActiveTab("host_viewer"); setActiveTab("host_viewer");
}; };
@@ -71,19 +76,20 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
<TabsTrigger value="add_host"> <TabsTrigger value="add_host">
{editingHost ? t('hosts.editHost') : t('hosts.addHost')} {editingHost ? t('hosts.editHost') : t('hosts.addHost')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="credentials">{t('credentials.credentialsManager')}</TabsTrigger> <div className="h-6 w-px bg-[#303032] mx-1"></div>
<TabsTrigger value="credentials">{t('credentials.credentialsViewer')}</TabsTrigger>
<TabsTrigger value="add_credential"> <TabsTrigger value="add_credential">
{editingCredential ? t('credentials.editCredential') : t('credentials.addCredential')} {editingCredential ? t('credentials.editCredential') : t('credentials.addCredential')}
</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}/> <HostManagerViewer 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 <HostManagerEditor
editingHost={editingHost} editingHost={editingHost}
onFormSubmit={handleFormSubmit} onFormSubmit={handleFormSubmit}
/> />

View File

@@ -50,10 +50,10 @@ interface SSHHost {
interface SSHManagerHostEditorProps { interface SSHManagerHostEditorProps {
editingHost?: SSHHost | null; editingHost?: SSHHost | null;
onFormSubmit?: () => void; onFormSubmit?: (updatedHost?: SSHHost) => void;
} }
export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) { export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
const {t} = useTranslation(); const {t} = useTranslation();
const [hosts, setHosts] = useState<SSHHost[]>([]); const [hosts, setHosts] = useState<SSHHost[]>([]);
const [folders, setFolders] = useState<string[]>([]); const [folders, setFolders] = useState<string[]>([]);
@@ -62,6 +62,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
const [authTab, setAuthTab] = useState<'password' | 'key' | 'credential'>('password'); const [authTab, setAuthTab] = useState<'password' | 'key' | 'credential'>('password');
const [keyInputMethod, setKeyInputMethod] = useState<'upload' | 'paste'>('upload'); const [keyInputMethod, setKeyInputMethod] = useState<'upload' | 'paste'>('upload');
const isSubmittingRef = useRef(false);
// Ref for the IP address input to manage focus // Ref for the IP address input to manage focus
const ipInputRef = useRef<HTMLInputElement>(null); const ipInputRef = useRef<HTMLInputElement>(null);
@@ -182,24 +183,24 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
const form = useForm<FormData>({ const form = useForm<FormData>({
resolver: zodResolver(formSchema) as any, resolver: zodResolver(formSchema) as any,
defaultValues: { defaultValues: {
name: editingHost?.name || "", name: "",
ip: editingHost?.ip || "", ip: "",
port: editingHost?.port || 22, port: 22,
username: editingHost?.username || "", username: "",
folder: editingHost?.folder || "", folder: "",
tags: editingHost?.tags || [], tags: [],
pin: editingHost?.pin || false, pin: false,
authType: (editingHost?.authType as 'password' | 'key' | 'credential') || "password", authType: "password" as const,
credentialId: editingHost?.credentialId || null, credentialId: null,
password: "", password: "",
key: null, key: null,
keyPassword: "", keyPassword: "",
keyType: "auto", keyType: "auto" as const,
enableTerminal: editingHost?.enableTerminal !== false, enableTerminal: true,
enableTunnel: editingHost?.enableTunnel !== false, enableTunnel: true,
enableFileManager: editingHost?.enableFileManager !== false, enableFileManager: true,
defaultPath: editingHost?.defaultPath || "/", defaultPath: "/",
tunnelConnections: editingHost?.tunnelConnections || [], tunnelConnections: [],
} }
}); });
@@ -208,29 +209,31 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
const defaultAuthType = editingHost.credentialId ? 'credential' : (editingHost.key ? 'key' : 'password'); const defaultAuthType = editingHost.credentialId ? 'credential' : (editingHost.key ? 'key' : 'password');
setAuthTab(defaultAuthType); setAuthTab(defaultAuthType);
form.reset({ const formData = {
name: editingHost.name || "", name: editingHost.name || "",
ip: editingHost.ip || "", ip: editingHost.ip || "",
port: editingHost.port || 22, port: editingHost.port || 22,
username: editingHost.username || "", username: editingHost.username || "",
folder: editingHost.folder || "", folder: editingHost.folder || "",
tags: editingHost.tags || [], tags: editingHost.tags || [],
pin: editingHost.pin || false, pin: Boolean(editingHost.pin),
authType: defaultAuthType as 'password' | 'key' | 'credential', authType: defaultAuthType as 'password' | 'key' | 'credential',
credentialId: editingHost.credentialId || null, credentialId: editingHost.credentialId || null,
password: editingHost.password || "", password: editingHost.password || "",
key: null, key: null,
keyPassword: editingHost.keyPassword || "", keyPassword: editingHost.keyPassword || "",
keyType: (editingHost.keyType as any) || "auto", keyType: (editingHost.keyType as any) || "auto",
enableTerminal: editingHost.enableTerminal !== false, enableTerminal: Boolean(editingHost.enableTerminal),
enableTunnel: editingHost.enableTunnel !== false, enableTunnel: Boolean(editingHost.enableTunnel),
enableFileManager: editingHost.enableFileManager !== false, enableFileManager: Boolean(editingHost.enableFileManager),
defaultPath: editingHost.defaultPath || "/", defaultPath: editingHost.defaultPath || "/",
tunnelConnections: editingHost.tunnelConnections || [], tunnelConnections: editingHost.tunnelConnections || [],
}); };
form.reset(formData);
} else { } else {
setAuthTab('password'); setAuthTab('password');
form.reset({ const defaultFormData = {
name: "", name: "",
ip: "", ip: "",
port: 22, port: 22,
@@ -238,22 +241,23 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
folder: "", folder: "",
tags: [], tags: [],
pin: false, pin: false,
authType: "password", authType: "password" as const,
credentialId: null, credentialId: null,
password: "", password: "",
key: null, key: null,
keyPassword: "", keyPassword: "",
keyType: "auto", keyType: "auto" as const,
enableTerminal: true, enableTerminal: true,
enableTunnel: true, enableTunnel: true,
enableFileManager: true, enableFileManager: true,
defaultPath: "/", defaultPath: "/",
tunnelConnections: [], tunnelConnections: [],
}); };
form.reset(defaultFormData);
} }
}, [editingHost, form]); }, [editingHost?.id]);
// Focus the IP address field when the component mounts or when editingHost changes
useEffect(() => { useEffect(() => {
const focusTimer = setTimeout(() => { const focusTimer = setTimeout(() => {
if (ipInputRef.current) { if (ipInputRef.current) {
@@ -261,83 +265,79 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
} }
}, 300); }, 300);
return () => clearTimeout(focusTimer);
}, []); // Focus on mount
// Also focus when editingHost changes (for tab switching)
useEffect(() => {
const focusTimer = setTimeout(() => {
if (ipInputRef.current) {
ipInputRef.current.focus();
}
}, 300);
return () => clearTimeout(focusTimer); return () => clearTimeout(focusTimer);
}, [editingHost]); }, [editingHost]);
const onSubmit = async (data: any) => { const onSubmit = async (data: FormData) => {
try { try {
const formData = data as FormData; isSubmittingRef.current = true;
if (!formData.name || formData.name.trim() === '') { if (!data.name || data.name.trim() === '') {
formData.name = `${formData.username}@${formData.ip}`; data.name = `${data.username}@${data.ip}`;
} }
const submitData: any = { const submitData: any = {
name: formData.name, name: data.name,
ip: formData.ip, ip: data.ip,
port: formData.port, port: data.port,
username: formData.username, username: data.username,
folder: formData.folder, folder: data.folder || "",
tags: formData.tags, tags: data.tags || [],
pin: formData.pin, pin: Boolean(data.pin),
authType: formData.authType, authType: data.authType,
enableTerminal: formData.enableTerminal, enableTerminal: Boolean(data.enableTerminal),
enableTunnel: formData.enableTunnel, enableTunnel: Boolean(data.enableTunnel),
enableFileManager: formData.enableFileManager, enableFileManager: Boolean(data.enableFileManager),
defaultPath: formData.defaultPath, defaultPath: data.defaultPath || "/",
tunnelConnections: formData.tunnelConnections tunnelConnections: data.tunnelConnections || []
}; };
if (formData.authType === 'credential') { if (data.authType === 'credential') {
submitData.credentialId = formData.credentialId; submitData.credentialId = data.credentialId;
submitData.password = null; submitData.password = null;
submitData.key = null; submitData.key = null;
submitData.keyPassword = null; submitData.keyPassword = null;
submitData.keyType = null; submitData.keyType = null;
} else if (formData.authType === 'password') { } else if (data.authType === 'password') {
submitData.credentialId = null; submitData.credentialId = null;
submitData.password = formData.password; submitData.password = data.password;
submitData.key = null; submitData.key = null;
submitData.keyPassword = null; submitData.keyPassword = null;
submitData.keyType = null; submitData.keyType = null;
} else if (formData.authType === 'key') { } else if (data.authType === 'key') {
submitData.credentialId = null; submitData.credentialId = null;
submitData.password = null; submitData.password = null;
if (formData.key instanceof File) { if (data.key instanceof File) {
const keyContent = await formData.key.text(); const keyContent = await data.key.text();
submitData.key = keyContent; submitData.key = keyContent;
} else { } else {
submitData.key = formData.key; submitData.key = data.key;
} }
submitData.keyPassword = formData.keyPassword; submitData.keyPassword = data.keyPassword;
submitData.keyType = formData.keyType; submitData.keyType = data.keyType;
} }
if (editingHost) { if (editingHost) {
await updateSSHHost(editingHost.id, submitData); const updatedHost = await updateSSHHost(editingHost.id, submitData);
toast.success(t('hosts.hostUpdatedSuccessfully', { name: formData.name })); toast.success(t('hosts.hostUpdatedSuccessfully', { name: data.name }));
} else {
await createSSHHost(submitData);
toast.success(t('hosts.hostAddedSuccessfully', { name: formData.name }));
}
if (onFormSubmit) { if (onFormSubmit) {
onFormSubmit(); onFormSubmit(updatedHost);
}
} else {
const newHost = await createSSHHost(submitData);
toast.success(t('hosts.hostAddedSuccessfully', { name: data.name }));
if (onFormSubmit) {
onFormSubmit(newHost);
}
} }
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (error) { } catch (error) {
toast.error(t('hosts.failedToSaveHost')); toast.error(t('hosts.failedToSaveHost'));
} finally {
isSubmittingRef.current = false;
} }
}; };
@@ -659,20 +659,24 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
<Tabs <Tabs
value={authTab} value={authTab}
onValueChange={(value) => { onValueChange={(value) => {
setAuthTab(value as 'password' | 'key' | 'credential'); const newAuthType = value as 'password' | 'key' | 'credential';
form.setValue('authType', value as 'password' | 'key' | 'credential'); setAuthTab(newAuthType);
form.setValue('authType', newAuthType);
// Clear other auth fields when switching // Clear other auth fields when switching
if (value === 'password') { if (newAuthType === 'password') {
form.setValue('key', null); form.setValue('key', null);
form.setValue('keyPassword', ''); form.setValue('keyPassword', '');
form.setValue('keyType', 'auto');
form.setValue('credentialId', null); form.setValue('credentialId', null);
} else if (value === 'key') { } else if (newAuthType === 'key') {
form.setValue('password', ''); form.setValue('password', '');
form.setValue('credentialId', null); form.setValue('credentialId', null);
} else if (value === 'credential') { } else if (newAuthType === 'credential') {
form.setValue('password', ''); form.setValue('password', '');
form.setValue('key', null); form.setValue('key', null);
form.setValue('keyPassword', ''); form.setValue('keyPassword', '');
form.setValue('keyType', 'auto');
} }
}} }}
className="flex-1 flex flex-col h-full min-h-0" className="flex-1 flex flex-col h-full min-h-0"
@@ -710,7 +714,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
}} }}
className="w-full" className="w-full"
> >
<TabsList className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground"> <TabsList className="inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
<TabsTrigger value="upload">{t('hosts.uploadFile')}</TabsTrigger> <TabsTrigger value="upload">{t('hosts.uploadFile')}</TabsTrigger>
<TabsTrigger value="paste">{t('hosts.pasteKey')}</TabsTrigger> <TabsTrigger value="paste">{t('hosts.pasteKey')}</TabsTrigger>
</TabsList> </TabsList>

View File

@@ -29,7 +29,7 @@ import {
import {Separator} from "@/components/ui/separator.tsx"; import {Separator} from "@/components/ui/separator.tsx";
import type { SSHHost, SSHManagerHostViewerProps } from '../../../types/index.js'; import type { SSHHost, SSHManagerHostViewerProps } from '../../../types/index.js';
export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
const {t} = useTranslation(); const {t} = useTranslation();
const [hosts, setHosts] = useState<SSHHost[]>([]); const [hosts, setHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);

View File

@@ -75,6 +75,22 @@ interface OIDCAuthorize {
// UTILITY FUNCTIONS // UTILITY FUNCTIONS
// ============================================================================ // ============================================================================
function getLoggerForService(serviceName: string) {
if (serviceName.includes('SSH') || serviceName.includes('ssh')) {
return sshLogger;
} else if (serviceName.includes('TUNNEL') || serviceName.includes('tunnel')) {
return tunnelLogger;
} else if (serviceName.includes('FILE') || serviceName.includes('file')) {
return fileLogger;
} else if (serviceName.includes('STATS') || serviceName.includes('stats')) {
return statsLogger;
} else if (serviceName.includes('AUTH') || serviceName.includes('auth')) {
return authLogger;
} else {
return apiLogger;
}
}
export function setCookie(name: string, value: string, days = 7): void { export function setCookie(name: string, value: string, days = 7): void {
const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true; const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
@@ -116,13 +132,25 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
(config as any).requestId = requestId; (config as any).requestId = requestId;
const token = getCookie('jwt'); const token = getCookie('jwt');
const method = config.method?.toUpperCase() || 'UNKNOWN';
const url = config.url || 'UNKNOWN';
const fullUrl = `${config.baseURL}${url}`;
const context: LogContext = { const context: LogContext = {
requestId, requestId,
method: config.method?.toUpperCase(), method,
url: config.url, url: fullUrl,
operation: 'request_start' operation: 'request_start'
}; };
// Get the appropriate logger for this service
const logger = getLoggerForService(serviceName);
// Log request start with grouping
if (process.env.NODE_ENV === 'development') {
logger.requestStart(method, fullUrl, context);
}
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} else if (process.env.NODE_ENV === 'development') { } else if (process.env.NODE_ENV === 'development') {
@@ -140,38 +168,31 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
const requestId = (response.config as any).requestId; const requestId = (response.config as any).requestId;
const responseTime = Math.round(endTime - startTime); const responseTime = Math.round(endTime - startTime);
const method = response.config.method?.toUpperCase() || 'UNKNOWN';
const url = response.config.url || 'UNKNOWN';
const fullUrl = `${response.config.baseURL}${url}`;
const context: LogContext = { const context: LogContext = {
requestId, requestId,
method: response.config.method?.toUpperCase(), method,
url: response.config.url, url: fullUrl,
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
responseTime, responseTime,
operation: 'request_success' operation: 'request_success'
}; };
// Only log successful requests in development and for slow requests // Get the appropriate logger for this service
if (process.env.NODE_ENV === 'development') { const logger = getLoggerForService(serviceName);
const method = response.config.method?.toUpperCase() || 'UNKNOWN';
const url = response.config.url || 'UNKNOWN';
// Log based on service type // Log successful requests in development
if (serviceName.includes('SSH') || serviceName.includes('ssh')) { if (process.env.NODE_ENV === 'development') {
sshLogger.info(`${method} ${url} - ${response.status} (${responseTime}ms)`, context); logger.requestSuccess(method, fullUrl, response.status, responseTime, context);
} else if (serviceName.includes('TUNNEL') || serviceName.includes('tunnel')) {
tunnelLogger.info(`${method} ${url} - ${response.status} (${responseTime}ms)`, context);
} else if (serviceName.includes('FILE') || serviceName.includes('file')) {
fileLogger.info(`${method} ${url} - ${response.status} (${responseTime}ms)`, context);
} else if (serviceName.includes('STATS') || serviceName.includes('stats')) {
statsLogger.info(`${method} ${url} - ${response.status} (${responseTime}ms)`, context);
} else {
apiLogger.info(`${method} ${url} - ${response.status} (${responseTime}ms)`, context);
}
} }
// Performance logging for slow requests // Performance logging for slow requests
if (responseTime > 3000) { if (responseTime > 3000) {
apiLogger.warn(`Slow request: ${responseTime}ms`, context); logger.warn(`🐌 Slow request: ${responseTime}ms`, context);
} }
return response; return response;
@@ -184,6 +205,7 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
const method = error.config?.method?.toUpperCase() || 'UNKNOWN'; const method = error.config?.method?.toUpperCase() || 'UNKNOWN';
const url = error.config?.url || 'UNKNOWN'; const url = error.config?.url || 'UNKNOWN';
const fullUrl = error.config ? `${error.config.baseURL}${url}` : url;
const status = error.response?.status; const status = error.response?.status;
const message = (error.response?.data as any)?.error || (error as Error).message || 'Unknown error'; const message = (error.response?.data as any)?.error || (error as Error).message || 'Unknown error';
const errorCode = (error.response?.data as any)?.code || error.code; const errorCode = (error.response?.data as any)?.code || error.code;
@@ -191,7 +213,7 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
const context: LogContext = { const context: LogContext = {
requestId, requestId,
method, method,
url, url: fullUrl,
status, status,
responseTime, responseTime,
errorCode, errorCode,
@@ -199,7 +221,21 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
operation: 'request_error' operation: 'request_error'
}; };
// Only handle auth token clearing here, let handleApiError do the logging // Get the appropriate logger for this service
const logger = getLoggerForService(serviceName);
// Log errors with appropriate method based on error type
if (process.env.NODE_ENV === 'development') {
if (status === 401) {
logger.authError(method, fullUrl, context);
} else if (status === 0 || !status) {
logger.networkError(method, fullUrl, message, context);
} else {
logger.requestError(method, fullUrl, status || 0, message, responseTime, context);
}
}
// Handle auth token clearing
if (status === 401) { if (status === 401) {
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=/;';
localStorage.removeItem('jwt'); localStorage.removeItem('jwt');
@@ -385,17 +421,16 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
username: hostData.username, username: hostData.username,
folder: hostData.folder || '', folder: hostData.folder || '',
tags: hostData.tags || [], tags: hostData.tags || [],
pin: hostData.pin || false, pin: Boolean(hostData.pin),
authMethod: hostData.authType,
authType: hostData.authType, authType: hostData.authType,
password: hostData.authType === 'password' ? hostData.password : '', password: hostData.authType === 'password' ? hostData.password : null,
key: hostData.authType === 'key' ? hostData.key : null, key: hostData.authType === 'key' ? hostData.key : null,
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '', keyPassword: hostData.authType === 'key' ? hostData.keyPassword : null,
keyType: hostData.authType === 'key' ? hostData.keyType : '', keyType: hostData.authType === 'key' ? hostData.keyType : null,
credentialId: hostData.authType === 'credential' ? hostData.credentialId : null, credentialId: hostData.authType === 'credential' ? hostData.credentialId : null,
enableTerminal: hostData.enableTerminal !== false, enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: hostData.enableTunnel !== false, enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: hostData.enableFileManager !== false, enableFileManager: Boolean(hostData.enableFileManager),
defaultPath: hostData.defaultPath || '/', defaultPath: hostData.defaultPath || '/',
tunnelConnections: hostData.tunnelConnections || [], tunnelConnections: hostData.tunnelConnections || [],
}; };
@@ -438,17 +473,16 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
username: hostData.username, username: hostData.username,
folder: hostData.folder || '', folder: hostData.folder || '',
tags: hostData.tags || [], tags: hostData.tags || [],
pin: hostData.pin || false, pin: Boolean(hostData.pin),
authMethod: hostData.authType,
authType: hostData.authType, authType: hostData.authType,
password: hostData.authType === 'password' ? hostData.password : '', password: hostData.authType === 'password' ? hostData.password : null,
key: hostData.authType === 'key' ? hostData.key : null, key: hostData.authType === 'key' ? hostData.key : null,
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '', keyPassword: hostData.authType === 'key' ? hostData.keyPassword : null,
keyType: hostData.authType === 'key' ? hostData.keyType : '', keyType: hostData.authType === 'key' ? hostData.keyType : null,
credentialId: hostData.authType === 'credential' ? hostData.credentialId : null, credentialId: hostData.authType === 'credential' ? hostData.credentialId : null,
enableTerminal: hostData.enableTerminal !== false, enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: hostData.enableTunnel !== false, enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: hostData.enableFileManager !== false, enableFileManager: Boolean(hostData.enableFileManager),
defaultPath: hostData.defaultPath || '/', defaultPath: hostData.defaultPath || '/',
tunnelConnections: hostData.tunnelConnections || [], tunnelConnections: hostData.tunnelConnections || [],
}; };
@@ -1234,7 +1268,7 @@ export async function getFoldersWithStats(): Promise<any> {
export async function renameFolder(oldName: string, newName: string): Promise<any> { export async function renameFolder(oldName: string, newName: string): Promise<any> {
try { try {
const response = await authApi.put('/ssh/db/folders/rename', { const response = await authApi.put('/ssh/folders/rename', {
oldName, oldName,
newName newName
}); });