Skip to content

Commit bc26d94

Browse files
ozgesolidkeyclaude
andcommitted
Add SSH remote log collection with live tail and SFTP browsing
Enables real-time log streaming from remote servers via SSH (tail -f) and remote file system browsing via SFTP, following the existing serial/logcat streaming patterns. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3e75e70 commit bc26d94

10 files changed

Lines changed: 1711 additions & 15 deletions

File tree

package-lock.json

Lines changed: 420 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@electron/rebuild": "^3.7.2",
3434
"@types/diff": "^7.0.2",
3535
"@types/node": "^20.10.0",
36+
"@types/ssh2": "^1.15.5",
3637
"electron": "^28.0.0",
3738
"electron-builder": "^24.9.1",
3839
"typescript": "^5.3.0",
@@ -44,6 +45,7 @@
4445
"marked": "^17.0.1",
4546
"node-pty": "^1.1.0",
4647
"serialport": "^12.0.0",
48+
"ssh2": "^1.17.0",
4749
"xterm": "^5.3.0",
4850
"xterm-addon-fit": "^0.8.0",
4951
"zod": "^4.3.6"

src/main/index.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { loadDatadogConfig, saveDatadogConfig, clearDatadogConfig, fetchDatadogL
1212
import { startApiServer, stopApiServer, ApiContext } from './api-server';
1313
import { SerialHandler } from './serialHandler';
1414
import { LogcatHandler } from './logcatHandler';
15+
import { SshHandler } from './sshHandler';
16+
import { SshProfile } from '../shared/types';
1517

1618
let mainWindow: BrowserWindow | null = null;
1719
let searchSignal: { cancelled: boolean } = { cancelled: false };
@@ -24,6 +26,9 @@ const serialHandler = new SerialHandler();
2426
// Logcat handler
2527
const logcatHandler = new LogcatHandler();
2628

29+
// SSH handler
30+
const sshHandler = new SshHandler();
31+
2732
// Filter state - maps file path to array of visible line indices
2833
const filterState = new Map<string, number[] | null>();
2934

@@ -698,6 +703,7 @@ app.on('window-all-closed', () => {
698703
app.on('will-quit', () => {
699704
serialHandler.cleanupTempFile();
700705
logcatHandler.cleanupTempFile();
706+
sshHandler.cleanupTempFile();
701707
stopApiServer();
702708
});
703709

@@ -916,6 +922,175 @@ ipcMain.handle(IPC.LOGCAT_SAVE_SESSION, async () => {
916922
}
917923
});
918924

925+
// === SSH ===
926+
927+
const getSshProfilesPath = () => path.join(getConfigDir(), 'ssh-profiles.json');
928+
929+
function loadSshProfiles(): SshProfile[] {
930+
try {
931+
const p = getSshProfilesPath();
932+
if (fs.existsSync(p)) {
933+
return JSON.parse(fs.readFileSync(p, 'utf-8'));
934+
}
935+
} catch { /* */ }
936+
return [];
937+
}
938+
939+
function saveSshProfiles(profiles: SshProfile[]): void {
940+
try {
941+
ensureConfigDir();
942+
fs.writeFileSync(getSshProfilesPath(), JSON.stringify(profiles, null, 2), 'utf-8');
943+
} catch (error) {
944+
console.error('Failed to save SSH profiles:', error);
945+
}
946+
}
947+
948+
ipcMain.handle(IPC.SSH_PARSE_CONFIG, async () => {
949+
try {
950+
const hosts = sshHandler.parseSSHConfig();
951+
return { success: true, hosts };
952+
} catch (error) {
953+
return { success: false, error: String(error) };
954+
}
955+
});
956+
957+
ipcMain.handle(IPC.SSH_LIST_PROFILES, async () => {
958+
try {
959+
return { success: true, profiles: loadSshProfiles() };
960+
} catch (error) {
961+
return { success: false, error: String(error) };
962+
}
963+
});
964+
965+
ipcMain.handle(IPC.SSH_SAVE_PROFILE, async (_, profile: SshProfile) => {
966+
try {
967+
const profiles = loadSshProfiles();
968+
const idx = profiles.findIndex(p => p.id === profile.id);
969+
if (idx >= 0) {
970+
profiles[idx] = profile;
971+
} else {
972+
profiles.push(profile);
973+
}
974+
saveSshProfiles(profiles);
975+
return { success: true };
976+
} catch (error) {
977+
return { success: false, error: String(error) };
978+
}
979+
});
980+
981+
ipcMain.handle(IPC.SSH_DELETE_PROFILE, async (_, id: string) => {
982+
try {
983+
const profiles = loadSshProfiles().filter(p => p.id !== id);
984+
saveSshProfiles(profiles);
985+
return { success: true };
986+
} catch (error) {
987+
return { success: false, error: String(error) };
988+
}
989+
});
990+
991+
ipcMain.handle(IPC.SSH_CONNECT, async (_, config: { host: string; port: number; username: string; identityFile?: string; remotePath: string; passphrase?: string }) => {
992+
try {
993+
const tempFilePath = await sshHandler.connect(config);
994+
995+
// Open temp file with FileHandler
996+
const fileHandler = new FileHandler();
997+
const info = await fileHandler.open(tempFilePath, () => {});
998+
addToCache(tempFilePath, fileHandler);
999+
currentFilePath = tempFilePath;
1000+
1001+
// Forward events to renderer
1002+
const onLinesAdded = (count: number) => {
1003+
const handler = fileHandlerCache.get(tempFilePath);
1004+
if (handler) {
1005+
const newLines = handler.indexNewLines();
1006+
if (newLines > 0) {
1007+
mainWindow?.webContents.send(IPC.SSH_LINES_ADDED, {
1008+
totalLines: handler.getTotalLines(),
1009+
newLines,
1010+
});
1011+
}
1012+
}
1013+
};
1014+
1015+
const onError = (message: string) => {
1016+
mainWindow?.webContents.send(IPC.SSH_ERROR, message);
1017+
};
1018+
1019+
const onDisconnected = () => {
1020+
mainWindow?.webContents.send(IPC.SSH_DISCONNECTED);
1021+
sshHandler.removeListener('lines-added', onLinesAdded);
1022+
sshHandler.removeListener('error', onError);
1023+
sshHandler.removeListener('disconnected', onDisconnected);
1024+
};
1025+
1026+
sshHandler.on('lines-added', onLinesAdded);
1027+
sshHandler.on('error', onError);
1028+
sshHandler.on('disconnected', onDisconnected);
1029+
1030+
return { success: true, info, tempFilePath };
1031+
} catch (error) {
1032+
return { success: false, error: String(error) };
1033+
}
1034+
});
1035+
1036+
ipcMain.handle(IPC.SSH_DISCONNECT, async () => {
1037+
try {
1038+
sshHandler.disconnect();
1039+
return { success: true };
1040+
} catch (error) {
1041+
return { success: false, error: String(error) };
1042+
}
1043+
});
1044+
1045+
ipcMain.handle(IPC.SSH_STATUS, async () => {
1046+
return sshHandler.getStatus();
1047+
});
1048+
1049+
ipcMain.handle(IPC.SSH_SAVE_SESSION, async () => {
1050+
const tempPath = sshHandler.getTempFilePath();
1051+
if (!tempPath || !fs.existsSync(tempPath)) {
1052+
return { success: false, error: 'No SSH session data' };
1053+
}
1054+
1055+
const result = await dialog.showSaveDialog(mainWindow!, {
1056+
title: 'Save SSH Session',
1057+
defaultPath: path.basename(tempPath),
1058+
filters: [
1059+
{ name: 'Log Files', extensions: ['log', 'txt'] },
1060+
{ name: 'All Files', extensions: ['*'] },
1061+
],
1062+
});
1063+
1064+
if (result.canceled || !result.filePath) {
1065+
return { success: false, error: 'Cancelled' };
1066+
}
1067+
1068+
try {
1069+
fs.copyFileSync(tempPath, result.filePath);
1070+
return { success: true, filePath: result.filePath };
1071+
} catch (error) {
1072+
return { success: false, error: String(error) };
1073+
}
1074+
});
1075+
1076+
ipcMain.handle(IPC.SSH_LIST_REMOTE_DIR, async (_, remotePath: string) => {
1077+
try {
1078+
const files = await sshHandler.listRemoteDir(remotePath);
1079+
return { success: true, files };
1080+
} catch (error) {
1081+
return { success: false, error: String(error) };
1082+
}
1083+
});
1084+
1085+
ipcMain.handle(IPC.SSH_DOWNLOAD_FILE, async (_, remotePath: string) => {
1086+
try {
1087+
const localPath = await sshHandler.downloadRemoteFile(remotePath);
1088+
return { success: true, localPath };
1089+
} catch (error) {
1090+
return { success: false, error: String(error) };
1091+
}
1092+
});
1093+
9191094
// === File Operations ===
9201095

9211096
ipcMain.handle(IPC.OPEN_FILE_DIALOG, async () => {

0 commit comments

Comments
 (0)