From 934809c46c4ec39dfe9b494baa8682b7bf405693 Mon Sep 17 00:00:00 2001 From: Meanski Date: Fri, 10 Jul 2026 19:27:18 +1200 Subject: [PATCH 1/6] refactor: streamline component props and improve readability - Refactored StatusBar to use a utility function for counting active tunnels. - Updated TabBar to use Readonly types for props and consolidated tab icon rendering logic. - Simplified DisconnectedOverlay by extracting reconnect label logic into a separate function. - Introduced StatusDot component in HostHeader for better status representation. - Enhanced SnippetRunner with a VarInput component for variable input handling. - Improved TerminalSearchBar by extracting results label logic into a dedicated function. - Added cooldown management in TerminalView for connection retries. - Resolved SSH credentials in a separate function for clarity. - Refactored TunnelsView to improve toggle icon logic and component structure. - Updated UnlockScreen components to use Readonly types for props. - Enhanced color generation logic in colors.ts for better performance. - Improved memory formatting in format.ts for better type handling. - Simplified store logic in index.ts for session type handling and tab view mapping. --- native/rdp-spike/sidecar.c | 163 +++- src/main/index.ts | 4 +- src/main/ipc/__tests__/keychain.test.ts | 13 +- src/main/ipc/__tests__/security.test.ts | 85 +- src/main/ipc/__tests__/socks.test.ts | 2 +- src/main/ipc/database.ts | 24 +- src/main/ipc/docker.ts | 46 +- src/main/ipc/k8s.ts | 92 +- src/main/ipc/keychain.ts | 2 +- src/main/ipc/localTerminal.ts | 4 +- src/main/ipc/localfs.ts | 8 +- src/main/ipc/metrics.ts | 12 +- src/main/ipc/rdp.ts | 83 +- src/main/ipc/redis.ts | 2 +- src/main/ipc/runner.ts | 2 +- src/main/ipc/security.ts | 8 +- src/main/ipc/sessionTransfer.ts | 34 +- src/main/ipc/sessions.ts | 4 +- src/main/ipc/sftp.ts | 94 +- src/main/ipc/ssh.ts | 139 +-- src/main/ipc/sshClients.ts | 2 +- src/main/ipc/sshConfig.ts | 76 +- src/main/ipc/tunnels.ts | 48 +- src/renderer/src/App.tsx | 2 +- .../CommandPalette/CommandPalette.tsx | 10 +- .../ConnectionManager/AddConnectionModal.tsx | 815 ++++++++++-------- .../ConnectionManager/ConnectionManager.tsx | 36 +- .../src/components/Dashboard/Dashboard.tsx | 13 +- .../src/components/Dashboard/ServerViews.tsx | 57 +- .../components/Database/DatabaseExplorer.tsx | 385 ++++++--- .../src/components/Docker/DockerDashboard.tsx | 12 +- .../src/components/Docker/DockerLogsModal.tsx | 2 +- .../src/components/Editor/EditorTab.tsx | 51 +- .../src/components/K8s/K8sDashboard.tsx | 131 +-- .../components/K8s/ResourceDetailModal.tsx | 33 +- src/renderer/src/components/K8sIcon.tsx | 15 +- src/renderer/src/components/MainContent.tsx | 234 ++--- .../Notifications/NotificationHost.tsx | 2 +- src/renderer/src/components/RDP/RdpView.tsx | 57 +- .../src/components/Redis/RedisExplorer.tsx | 346 ++++---- .../src/components/Runner/RunnerView.tsx | 70 +- .../src/components/SFTP/FilesDrawer.tsx | 32 +- .../src/components/SFTP/SftpBrowser.tsx | 84 +- .../src/components/ServerContextMenu.tsx | 8 +- .../src/components/Settings/Settings.tsx | 247 +++--- .../src/components/Sidebar/Sidebar.tsx | 30 +- .../src/components/StatusBar/StatusBar.tsx | 4 +- src/renderer/src/components/TabBar/TabBar.tsx | 40 +- .../Terminal/DisconnectedOverlay.tsx | 16 +- .../src/components/Terminal/HostHeader.tsx | 37 +- .../src/components/Terminal/SnippetRunner.tsx | 46 +- .../components/Terminal/TerminalSearchBar.tsx | 13 +- .../src/components/Terminal/TerminalView.tsx | 64 +- .../src/components/Tunnels/TunnelsView.tsx | 27 +- src/renderer/src/components/UnlockScreen.tsx | 24 +- src/renderer/src/lib/colors.ts | 3 +- src/renderer/src/lib/format.ts | 4 +- src/renderer/src/store/index.ts | 18 +- 58 files changed, 2216 insertions(+), 1699 deletions(-) diff --git a/native/rdp-spike/sidecar.c b/native/rdp-spike/sidecar.c index 2f87d65..d632bd7 100644 --- a/native/rdp-spike/sidecar.c +++ b/native/rdp-spike/sidecar.c @@ -25,15 +25,23 @@ * This milestone is output-only (read-only desktop view). Input injection * (mouse/keyboard over stdin) is the next milestone. * - * Usage: rdp-sidecar [width] [height] + * Usage: rdp-sidecar [width] [height] + * The password is read as the first line of stdin so it never appears in the + * process list. */ #include #include #include +#ifdef _WIN32 +#include +#include +#endif + #include #include +#include #include #include #include @@ -151,6 +159,88 @@ static BOOL sidecar_end_paint(rdpContext* context) return TRUE; } +/* Certificate handling. The default client callbacks + * (client_cli_verify_certificate_ex) are interactive: they print the cert + * details to *stdout* — corrupting the binary frame channel — and then read a + * Y/N answer from *stdin*, which rdp.ts closes right after the password. The + * EOF rejects the certificate, so any host not already in + * ~/.config/freerdp/known_hosts2 failed to connect. This was the "works some + * of the time" bug: only hosts trusted during earlier interactive testing + * connected, and they broke again whenever Windows rotated its self-signed + * cert. + * + * Windows RDP hosts almost universally present self-signed certs, so we + * accept for the session (return 2 = temporary trust: nothing persisted, no + * stale known_hosts state to go bad later) and log the fingerprint to stderr. + * Same trust-on-use posture as the app's SSH host-key handling; an in-app + * verification UI is a later milestone for both. */ +static DWORD sidecar_verify_certificate(freerdp* instance, const char* host, UINT16 port, + const char* common_name, const char* subject, + const char* issuer, const char* fingerprint, DWORD flags) +{ + (void)instance; + (void)subject; + (void)issuer; + fprintf(stderr, "[sidecar] accepting certificate for %s:%u (CN=%s)\n", host, (unsigned)port, + common_name ? common_name : "?"); + if (fingerprint && !(flags & VERIFY_CERT_FLAG_FP_IS_PEM)) + fprintf(stderr, "[sidecar] fingerprint: %s\n", fingerprint); + return 2; /* trust for this session only */ +} + +static DWORD sidecar_verify_changed_certificate(freerdp* instance, const char* host, UINT16 port, + const char* common_name, const char* subject, + const char* issuer, const char* new_fingerprint, + const char* old_subject, const char* old_issuer, + const char* old_fingerprint, DWORD flags) +{ + (void)old_subject; + (void)old_issuer; + (void)old_fingerprint; + return sidecar_verify_certificate(instance, host, port, common_name, subject, issuer, + new_fingerprint, flags); +} + +/* Map the common connect failures to messages a person can act on. rdp.ts + * surfaces the last "[sidecar] error: ..." stderr line in the RDP tab, so this + * is what the user sees when a connect fails. */ +static const char* connect_error_message(UINT32 code) +{ + switch (code) + { + case FREERDP_ERROR_CONNECT_LOGON_FAILURE: + case FREERDP_ERROR_AUTHENTICATION_FAILED: + return "Sign-in failed: the username or password is incorrect."; + case FREERDP_ERROR_CONNECT_ACCOUNT_LOCKED_OUT: + return "Sign-in failed: the account is locked out."; + case FREERDP_ERROR_CONNECT_ACCOUNT_DISABLED: + return "Sign-in failed: the account is disabled."; + case FREERDP_ERROR_CONNECT_ACCOUNT_EXPIRED: + return "Sign-in failed: the account has expired."; + case FREERDP_ERROR_CONNECT_ACCOUNT_RESTRICTION: + return "Sign-in failed: an account restriction blocked the logon."; + case FREERDP_ERROR_CONNECT_PASSWORD_EXPIRED: + case FREERDP_ERROR_CONNECT_PASSWORD_CERTAINLY_EXPIRED: + return "Sign-in failed: the password has expired and must be changed."; + case FREERDP_ERROR_CONNECT_PASSWORD_MUST_CHANGE: + return "Sign-in failed: the password must be changed before signing in."; + case FREERDP_ERROR_CONNECT_FAILED: + case FREERDP_ERROR_CONNECT_TRANSPORT_FAILED: + return "Could not reach the host. Check the address, port, and that Remote Desktop is enabled."; + case FREERDP_ERROR_DNS_NAME_NOT_FOUND: + case FREERDP_ERROR_DNS_ERROR: + return "Could not resolve the hostname. Check the address."; + case FREERDP_ERROR_TLS_CONNECT_FAILED: + return "TLS negotiation with the host failed."; + case FREERDP_ERROR_SECURITY_NEGO_CONNECT_FAILED: + return "Security negotiation failed. The host may require NLA settings this client did not offer."; + case FREERDP_ERROR_CONNECT_CANCELLED: + return "The connection was cancelled."; + default: + return NULL; + } +} + static BOOL sidecar_post_connect(freerdp* instance) { if (!gdi_init(instance, PIXEL_FORMAT_BGRA32)) @@ -164,6 +254,10 @@ static BOOL sidecar_client_new(freerdp* instance, rdpContext* context) { (void)context; instance->PostConnect = sidecar_post_connect; + /* Replace the interactive CLI cert prompts (stdout/stdin) — see + * sidecar_verify_certificate above. */ + instance->VerifyCertificateEx = sidecar_verify_certificate; + instance->VerifyChangedCertificateEx = sidecar_verify_changed_certificate; return TRUE; } @@ -208,12 +302,32 @@ int main(int argc, char* argv[]) return 2; } +#ifdef _WIN32 + /* stdout defaults to text mode on Windows and translates \n -> \r\n, + * which corrupts the binary frame stream. */ + _setmode(_fileno(stdout), _O_BINARY); +#endif + const char* host = argv[1]; const UINT32 port = (UINT32)strtoul(argv[2], NULL, 10); const char* user = argv[3]; const UINT32 width = (argc >= 5) ? (UINT32)strtoul(argv[4], NULL, 10) : 1280; const UINT32 height = (argc >= 6) ? (UINT32)strtoul(argv[5], NULL, 10) : 800; + /* "DOMAIN\user" must go into separate Domain/Username settings for NLA; + * xfreerdp does this split in its command-line layer, so we mirror it. UPN + * form ("user@domain") is understood natively and passes through as-is. */ + const char* domain = NULL; + char userbuf[256] = { 0 }; + const char* backslash = strchr(user, '\\'); + if (backslash && backslash != user && (size_t)(backslash - user) < sizeof(userbuf)) + { + memcpy(userbuf, user, (size_t)(backslash - user)); + userbuf[backslash - user] = '\0'; + domain = userbuf; + user = backslash + 1; + } + /* Read password from stdin to avoid exposing it in process list */ char pass[256]; if (!fgets(pass, sizeof(pass), stdin)) { @@ -240,18 +354,45 @@ int main(int argc, char* argv[]) freerdp_settings_set_string(settings, FreeRDP_ServerHostname, host); freerdp_settings_set_uint32(settings, FreeRDP_ServerPort, port); freerdp_settings_set_string(settings, FreeRDP_Username, user); + if (domain) + freerdp_settings_set_string(settings, FreeRDP_Domain, domain); freerdp_settings_set_string(settings, FreeRDP_Password, pass); freerdp_settings_set_bool(settings, FreeRDP_IgnoreCertificate, FALSE); freerdp_settings_set_uint32(settings, FreeRDP_DesktopWidth, width); freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight, height); freerdp_settings_set_uint32(settings, FreeRDP_ColorDepth, 32); + /* The static FreeRDP we ship is trimmed: channel addins (rdpgfx, rdpdr, + * rdpsnd, cliprdr, ...) are not built in. FreeRDP's defaults still enable + * the features backed by those channels, so freerdp_client_load_addins + * tries to load them, fails, and pre-connect aborts before a TCP + * connection is even attempted. Turn every channel-backed feature off — + * this viewer is a plain GDI framebuffer and needs none of them. */ + freerdp_settings_set_bool(settings, FreeRDP_SupportGraphicsPipeline, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_NetworkAutoDetect, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_SupportHeartbeatPdu, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_SupportMultitransport, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_DeviceRedirection, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_RedirectClipboard, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_AudioPlayback, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_AudioCapture, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_SupportDisplayControl, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_SupportGeometryTracking, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_SupportVideoOptimized, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_MultiTouchInput, FALSE); + freerdp* instance = context->instance; int rc = 0; if (!freerdp_connect(instance)) { - fprintf(stderr, "[sidecar] connect failed err=0x%08X\n", freerdp_get_last_error(context)); + const UINT32 err = freerdp_get_last_error(context); + const char* friendly = connect_error_message(err); + if (friendly) + fprintf(stderr, "[sidecar] error: %s\n", friendly); + else + fprintf(stderr, "[sidecar] error: connect failed — %s (0x%08X)\n", + freerdp_get_last_error_string(err), err); rc = 1; goto cleanup; } @@ -279,6 +420,24 @@ int main(int argc, char* argv[]) break; } + /* If the server ended the session, surface why instead of a silent drop. + * A deliberate sign-out/disconnect is a normal end; everything else + * (kicked by another connection, idle timeout, license/protocol errors) + * exits nonzero so rdp.ts shows the reason in the tab. */ + { + const UINT32 info = freerdp_error_info(instance); + const BOOL normalEnd = info == ERRINFO_SUCCESS || + info == ERRINFO_RPC_INITIATED_DISCONNECT || + info == ERRINFO_RPC_INITIATED_LOGOFF || + info == ERRINFO_LOGOFF_BY_USER; + if (!normalEnd) + { + fprintf(stderr, "[sidecar] error: session ended by server — %s\n", + freerdp_get_error_info_string(info)); + rc = 1; + } + } + freerdp_disconnect(instance); cleanup: diff --git a/src/main/index.ts b/src/main/index.ts index 65f69a5..ed78a7d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,8 +10,8 @@ process.on('uncaughtException', (err) => { console.error('[uncaughtException]', err) }) -import { join } from 'path' -import { readFileSync } from 'fs' +import { join } from 'node:path' +import { readFileSync } from 'node:fs' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { registerSshHandlers, disposeSshStreamsForSender } from './ipc/ssh' import { registerSftpHandlers, disposeSftpClientsForSender } from './ipc/sftp' diff --git a/src/main/ipc/__tests__/keychain.test.ts b/src/main/ipc/__tests__/keychain.test.ts index b2dcdab..39cb4e1 100644 --- a/src/main/ipc/__tests__/keychain.test.ts +++ b/src/main/ipc/__tests__/keychain.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { pbkdf2Sync, randomBytes } from 'crypto' +import { pbkdf2Sync, randomBytes } from 'node:crypto' // Extracted pure functions mirroring keychain.ts implementation for testing @@ -153,13 +153,6 @@ describe('Keychain — brute-force protection', () => { }) describe('Keychain — auth mode verification logic', () => { - it('none mode always succeeds', () => { - // mode === 'none' → always { success: true } - // This is a direct mapping of the production logic - const mode = 'none' - expect(mode === 'none').toBe(true) - }) - it('pin/password mode requires a credential', () => { const credential = undefined const result = !credential ? { success: false, error: 'Credential required' } : { success: true } @@ -179,13 +172,13 @@ describe('Keychain — auth mode verification logic', () => { const salt = randomBytes(16).toString('hex') const storedHash = hashCredential('1234', salt) const inputHash = hashCredential('1234', salt) - expect(inputHash === storedHash).toBe(true) + expect(inputHash).toBe(storedHash) }) it('pin/password mode rejects incorrect credential', () => { const salt = randomBytes(16).toString('hex') const storedHash = hashCredential('1234', salt) const wrongHash = hashCredential('5678', salt) - expect(wrongHash === storedHash).toBe(false) + expect(wrongHash).not.toBe(storedHash) }) }) diff --git a/src/main/ipc/__tests__/security.test.ts b/src/main/ipc/__tests__/security.test.ts index 08722a6..f94dc0d 100644 --- a/src/main/ipc/__tests__/security.test.ts +++ b/src/main/ipc/__tests__/security.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' -import { homedir } from 'os' -import { resolve, normalize } from 'path' +import { homedir } from 'node:os' +import { resolve, normalize } from 'node:path' // We test isLikelyTextFile directly (pure function, no FS dependency) import { isLikelyTextFile } from '../security' @@ -39,70 +39,33 @@ function validateKeyPathLogic(rawPath: string): { ok: true; resolved: string } | describe('Security — path traversal protection', () => { describe('validateKeyPathLogic', () => { - it('allows ~/.ssh/id_rsa', () => { + it('resolves ~ to the home directory', () => { const result = validateKeyPathLogic('~/.ssh/id_rsa') expect(result.ok).toBe(true) if (result.ok) expect(result.resolved).toBe(resolve(SSH_DIR, 'id_rsa')) }) - it('allows ~/.ssh/id_ed25519', () => { - const result = validateKeyPathLogic('~/.ssh/id_ed25519') - expect(result.ok).toBe(true) - }) - - it('allows absolute path inside .ssh', () => { - const result = validateKeyPathLogic(`${home}/.ssh/my_key`) - expect(result.ok).toBe(true) - }) - - it('allows ~/.config/ssh/ paths', () => { - const result = validateKeyPathLogic('~/.config/ssh/deploy_key') - expect(result.ok).toBe(true) - }) - - it('allows ~/.pem/ paths', () => { - const result = validateKeyPathLogic('~/.pem/server.pem') - expect(result.ok).toBe(true) - }) - - it('BLOCKS /etc/passwd', () => { - const result = validateKeyPathLogic('/etc/passwd') - expect(result.ok).toBe(false) - }) - - it('BLOCKS /etc/shadow', () => { - const result = validateKeyPathLogic('/etc/shadow') - expect(result.ok).toBe(false) - }) - - it('BLOCKS reading arbitrary home directory files', () => { - const result = validateKeyPathLogic('~/.env') - expect(result.ok).toBe(false) - }) - - it('BLOCKS path traversal via ../', () => { - const result = validateKeyPathLogic('~/.ssh/../../etc/passwd') - expect(result.ok).toBe(false) - }) - - it('BLOCKS path traversal via encoded sequences', () => { - const result = validateKeyPathLogic('~/.ssh/../.env') - expect(result.ok).toBe(false) - }) - - it('BLOCKS absolute paths outside allowed dirs', () => { - const result = validateKeyPathLogic('/tmp/evil_key') - expect(result.ok).toBe(false) - }) - - it('BLOCKS empty string', () => { - const result = validateKeyPathLogic('') - expect(result.ok).toBe(false) - }) - - it('BLOCKS null-byte injection', () => { - const result = validateKeyPathLogic('~/.ssh/id_rsa\0/../../etc/passwd') - expect(result.ok).toBe(false) + it.each([ + ['~/.ssh/id_rsa', 'ssh key by tilde path'], + ['~/.ssh/id_ed25519', 'ed25519 key'], + [`${home}/.ssh/my_key`, 'absolute path inside .ssh'], + ['~/.config/ssh/deploy_key', '~/.config/ssh/ path'], + ['~/.pem/server.pem', '~/.pem/ path'], + ])('allows %s (%s)', (path) => { + expect(validateKeyPathLogic(path).ok).toBe(true) + }) + + it.each([ + ['/etc/passwd', 'system file'], + ['/etc/shadow', 'system file'], + ['~/.env', 'arbitrary home directory file'], + ['~/.ssh/../../etc/passwd', 'path traversal via ../'], + ['~/.ssh/../.env', 'path traversal to dotfile'], + ['/tmp/evil_key', 'absolute path outside allowed dirs'], + ['', 'empty string'], + ['~/.ssh/id_rsa\0/../../etc/passwd', 'null-byte injection'], + ])('BLOCKS %s (%s)', (path) => { + expect(validateKeyPathLogic(path).ok).toBe(false) }) it('BLOCKS reading the .ssh directory itself (not a file)', () => { diff --git a/src/main/ipc/__tests__/socks.test.ts b/src/main/ipc/__tests__/socks.test.ts index 3baab33..08dd336 100644 --- a/src/main/ipc/__tests__/socks.test.ts +++ b/src/main/ipc/__tests__/socks.test.ts @@ -29,7 +29,7 @@ describe('socks replies', () => { it('builds a ten-byte connect reply with the given code', () => { const reply = socksConnectReply(SOCKS_REPLY.success) - expect(reply.length).toBe(10) + expect(reply).toHaveLength(10) expect(reply[0]).toBe(5) expect(reply[1]).toBe(0) }) diff --git a/src/main/ipc/database.ts b/src/main/ipc/database.ts index 9a1e42e..8c5fbe9 100644 --- a/src/main/ipc/database.ts +++ b/src/main/ipc/database.ts @@ -1,12 +1,12 @@ import { ipcMain, IpcMainInvokeEvent } from 'electron' -import { randomUUID } from 'crypto' +import { randomUUID } from 'node:crypto' import { Pool as PgPool } from 'pg' import mysql from 'mysql2/promise' import { ConnectionError, NotFoundError, OwnershipError, ValidationError, toMessage } from './errors' import { validateHost, validatePort } from './security' type DbType = 'postgresql' | 'mysql' | 'mariadb' -type SslMode = 'disable' | 'require' | 'verify-ca' | 'verify-full' | undefined +type SslMode = 'disable' | 'require' | 'verify-ca' | 'verify-full' interface QueryResult { columns: string[]; rows: unknown[]; rowCount: number; duration: number } @@ -66,7 +66,10 @@ function validateConnectConfig(raw: unknown): DbConnectConfig { if (typeof c.database !== 'string' || c.database.length === 0 || c.database.length > MAX_DATABASE_LENGTH) { throw new ValidationError('Database name is required') } - const ssl = c.ssl === undefined ? undefined : String(c.ssl) + if (c.ssl !== undefined && typeof c.ssl !== 'string') { + throw new ValidationError('Invalid SSL mode') + } + const ssl = c.ssl as string | undefined if (ssl !== undefined && !['disable', 'require', 'verify-ca', 'verify-full'].includes(ssl)) { throw new ValidationError(`Invalid SSL mode: ${ssl}`) } @@ -77,17 +80,12 @@ function validateConnectConfig(raw: unknown): DbConnectConfig { username: c.username, password: (c.password as string | undefined) || undefined, database: c.database, - ssl: ssl as SslMode, + ssl: ssl as SslMode | undefined, } } -function pgSslOption(mode: SslMode): boolean | { rejectUnauthorized: boolean } | undefined { - if (mode === 'verify-full' || mode === 'verify-ca') return { rejectUnauthorized: true } - if (mode === 'require') return { rejectUnauthorized: false } - return undefined -} - -function mysqlSslOption(mode: SslMode): Record | undefined { +// Shared by pg and mysql2 — both accept { rejectUnauthorized } for their ssl option. +function sslOption(mode: SslMode | undefined): { rejectUnauthorized: boolean } | undefined { if (mode === 'verify-full' || mode === 'verify-ca') return { rejectUnauthorized: true } if (mode === 'require') return { rejectUnauthorized: false } return undefined @@ -100,7 +98,7 @@ async function connectPostgres(config: DbConnectConfig): Promise { user: config.username, password: config.password, database: config.database, - ssl: pgSslOption(config.ssl), + ssl: sslOption(config.ssl), connectionTimeoutMillis: 20_000, idleTimeoutMillis: 30_000, max: 4, @@ -161,7 +159,7 @@ async function connectMysql(config: DbConnectConfig): Promise { user: config.username, password: config.password, database: config.database, - ssl: mysqlSslOption(config.ssl), + ssl: sslOption(config.ssl), connectionLimit: 4, connectTimeout: 20_000, enableKeepAlive: true, diff --git a/src/main/ipc/docker.ts b/src/main/ipc/docker.ts index bcd50b8..2abd5a6 100644 --- a/src/main/ipc/docker.ts +++ b/src/main/ipc/docker.ts @@ -1,6 +1,6 @@ import { ipcMain, IpcMainInvokeEvent, WebContents } from 'electron' import { ClientChannel } from 'ssh2' -import { randomUUID } from 'crypto' +import { randomUUID } from 'node:crypto' import { connectSessionClient, ManagedSshConnection } from './sshClients' import { ConnectionError, NotFoundError, OwnershipError, ValidationError, toMessage } from './errors' @@ -60,6 +60,27 @@ export function parseJsonLines(output: string): Record[] { return rows } +// Wires a `docker logs -f` channel to the renderer and returns the stream id. +function attachLogStream(sender: WebContents, stream: ClientChannel): string { + const logId = randomUUID() + logStreams.set(logId, { channel: stream, senderId: sender.id }) + + const send = (data: Buffer) => { + if (!sender.isDestroyed()) sender.send('docker:logChunk', logId, data.toString('utf8')) + } + stream.on('data', send) + stream.stderr.on('data', send) + stream.on('close', () => { + logStreams.delete(logId) + if (!sender.isDestroyed()) sender.send('docker:logEnd', logId, null) + }) + stream.on('error', (e: unknown) => { + logStreams.delete(logId) + if (!sender.isDestroyed()) sender.send('docker:logEnd', logId, toMessage(e)) + }) + return logId +} + function requireSession(event: IpcMainInvokeEvent, rawId: unknown): DockerSession { if (typeof rawId !== 'string') throw new ValidationError('Invalid Docker session id') const entry = sessions.get(rawId) @@ -75,15 +96,14 @@ function execCollect(entry: DockerSession, command: string): Promise { let stdout = '' let stderr = '' - let truncated = false const timer = setTimeout(() => { stream.close() reject(new ConnectionError('Remote command timed out')) }, EXEC_TIMEOUT_MS) stream.on('data', (d: Buffer) => { + // Over-limit output is dropped; parseJsonLines skips the partial tail line. if (stdout.length < MAX_OUTPUT_BYTES) stdout += d.toString('utf8') - else truncated = true }) stream.stderr.on('data', (d: Buffer) => { if (stderr.length < 16 * 1024) stderr += d.toString('utf8') @@ -91,7 +111,7 @@ function execCollect(entry: DockerSession, command: string): Promise { stream.on('close', (code: number | null) => { clearTimeout(timer) if (code === 0 || (code === null && stdout)) { - resolve(truncated ? stdout : stdout) + resolve(stdout) } else if (code === 127) { reject(new ConnectionError('Docker CLI not found on this host')) } else { @@ -187,23 +207,7 @@ export function registerDockerHandlers(): void { return new Promise((resolve, reject) => { entry.conn.client.exec(`docker logs --tail ${tail} -f ${container} 2>&1`, (err, stream) => { if (err) return reject(new ConnectionError(toMessage(err))) - const logId = randomUUID() - logStreams.set(logId, { channel: stream, senderId: event.sender.id }) - - const send = (data: Buffer) => { - if (!event.sender.isDestroyed()) event.sender.send('docker:logChunk', logId, data.toString('utf8')) - } - stream.on('data', send) - stream.stderr.on('data', send) - stream.on('close', () => { - logStreams.delete(logId) - if (!event.sender.isDestroyed()) event.sender.send('docker:logEnd', logId, null) - }) - stream.on('error', (e: unknown) => { - logStreams.delete(logId) - if (!event.sender.isDestroyed()) event.sender.send('docker:logEnd', logId, toMessage(e)) - }) - resolve(logId) + resolve(attachLogStream(event.sender, stream)) }) }) }) diff --git a/src/main/ipc/k8s.ts b/src/main/ipc/k8s.ts index ce3ae45..3834842 100644 --- a/src/main/ipc/k8s.ts +++ b/src/main/ipc/k8s.ts @@ -1,11 +1,11 @@ import { app, ipcMain, dialog, BrowserWindow, IpcMainInvokeEvent, IpcMainEvent } from 'electron' import * as k8s from '@kubernetes/client-node' -import * as net from 'net' -import { PassThrough } from 'stream' -import { copyFileSync, mkdirSync, statSync } from 'fs' -import { join, basename, resolve as resolvePath } from 'path' -import { homedir } from 'os' -import { createHash, randomUUID } from 'crypto' +import * as net from 'node:net' +import { PassThrough } from 'node:stream' +import { copyFileSync, mkdirSync, statSync } from 'node:fs' +import { join, basename, resolve as resolvePath } from 'node:path' +import { homedir } from 'node:os' +import { createHash, randomUUID } from 'node:crypto' import { isUnlocked } from './keychain' import { isAllowedKubeconfigPath, registerAllowedKubeconfigDir, validateK8sName, validatePort } from './security' import { ConnectionError, NotFoundError, OwnershipError, ValidationError, toMessage } from './errors' @@ -152,6 +152,43 @@ export function disposeK8sSessionsForSender(senderId: number): void { // ── Handler registration ────────────────────────────────────────────────────── +function startPodForward( + kc: k8s.KubeConfig, + namespace: string, + podName: string, + targetPort: number, + localPort: unknown, + senderId: number, + meta: Omit, +): Promise<{ id: string; localPort: number }> { + if (localPort !== undefined && localPort !== null && localPort !== 0) { + validatePort(localPort, 'local port') + } + const sessionId = makeId() + const forward = new k8s.PortForward(kc) + const sockets = new Set() + + return new Promise<{ id: string; localPort: number }>((resolve, reject) => { + const server = net.createServer((socket) => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + socket.on('error', (err) => safeLog(`pf socket ${sessionId}`, err)) + forward.portForward(namespace, podName, [targetPort], socket, null, socket) + .catch((err) => { + safeLog(`pf forward ${sessionId}`, err) + socket.destroy() + }) + }) + + server.on('error', (err) => reject(new ConnectionError(toMessage(err)))) + server.listen(localPort && Number(localPort) > 0 ? Number(localPort) : 0, '127.0.0.1', () => { + const addr = server.address() as net.AddressInfo + pfSessions.set(sessionId, { server, sockets, senderId, meta: { ...meta, localPort: addr.port } }) + resolve({ id: sessionId, localPort: addr.port }) + }) + }) +} + export function registerK8sHandlers(): void { registerAllowedKubeconfigDir(managedKubeconfigDir()) @@ -475,7 +512,7 @@ export function registerK8sHandlers(): void { ipcMain.handle('k8s:events', async (_e, context: unknown, namespace: unknown, kubeconfigPath?: unknown) => { const { core } = makeClient(context, validateOptionalKubeconfig(kubeconfigPath)) const res = await core.listNamespacedEvent(validateNamespace(namespace)) - return res.body.items + return [...res.body.items] .sort((a, b) => { const ta = a.lastTimestamp ? new Date(a.lastTimestamp as unknown as string).getTime() : 0 const tb = b.lastTimestamp ? new Date(b.lastTimestamp as unknown as string).getTime() : 0 @@ -644,7 +681,7 @@ export function registerK8sHandlers(): void { ipcMain.on('k8s:execSend', (event: IpcMainEvent, sessionId: unknown, data: unknown) => { if (typeof sessionId !== 'string' || typeof data !== 'string') return const entry = execSessions.get(sessionId) - if (!entry || entry.senderId !== event.sender.id) return + if (entry?.senderId !== event.sender.id) return if (Buffer.byteLength(data, 'utf8') > 64 * 1024) return entry.stdin.write(data) }) @@ -654,7 +691,7 @@ export function registerK8sHandlers(): void { if (!Number.isInteger(cols) || !Number.isInteger(rows)) return if ((cols as number) < 1 || (cols as number) > 1000 || (rows as number) < 1 || (rows as number) > 1000) return const entry = execSessions.get(sessionId) - if (!entry || entry.senderId !== event.sender.id) return + if (entry?.senderId !== event.sender.id) return if (!entry.ws?.send) return try { const buf = Buffer.alloc(5) @@ -677,43 +714,6 @@ export function registerK8sHandlers(): void { // ── Port forwarding ─────────────────────────────────────────────────────────── - function startPodForward( - kc: k8s.KubeConfig, - namespace: string, - podName: string, - targetPort: number, - localPort: unknown, - senderId: number, - meta: Omit, - ): Promise<{ id: string; localPort: number }> { - if (localPort !== undefined && localPort !== null && localPort !== 0) { - validatePort(localPort, 'local port') - } - const sessionId = makeId() - const forward = new k8s.PortForward(kc) - const sockets = new Set() - - return new Promise<{ id: string; localPort: number }>((resolve, reject) => { - const server = net.createServer((socket) => { - sockets.add(socket) - socket.on('close', () => sockets.delete(socket)) - socket.on('error', (err) => safeLog(`pf socket ${sessionId}`, err)) - forward.portForward(namespace, podName, [targetPort], socket, null, socket) - .catch((err) => { - safeLog(`pf forward ${sessionId}`, err) - socket.destroy() - }) - }) - - server.on('error', (err) => reject(new ConnectionError(toMessage(err)))) - server.listen(localPort && Number(localPort) > 0 ? Number(localPort) : 0, '127.0.0.1', () => { - const addr = server.address() as net.AddressInfo - pfSessions.set(sessionId, { server, sockets, senderId, meta: { ...meta, localPort: addr.port } }) - resolve({ id: sessionId, localPort: addr.port }) - }) - }) - } - ipcMain.handle('k8s:portForwardStart', async (event: IpcMainInvokeEvent, context: unknown, namespace: unknown, podName: unknown, targetPort: unknown, localPort: unknown, kubeconfigPath?: unknown) => { const safePath = validateOptionalKubeconfig(kubeconfigPath) const safeNs = validateNamespace(namespace) diff --git a/src/main/ipc/keychain.ts b/src/main/ipc/keychain.ts index b0e9a5e..13aaf56 100644 --- a/src/main/ipc/keychain.ts +++ b/src/main/ipc/keychain.ts @@ -1,7 +1,7 @@ import { ipcMain, systemPreferences, BrowserWindow } from 'electron' import Store from 'electron-store' import keytar from 'keytar' -import { pbkdf2Sync, randomBytes } from 'crypto' +import { pbkdf2Sync, randomBytes } from 'node:crypto' export type AuthMode = 'none' | 'pin' | 'password' | 'biometrics' diff --git a/src/main/ipc/localTerminal.ts b/src/main/ipc/localTerminal.ts index 7a087cb..51debb8 100644 --- a/src/main/ipc/localTerminal.ts +++ b/src/main/ipc/localTerminal.ts @@ -1,7 +1,7 @@ import { ipcMain, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' import { spawn, IPty } from 'node-pty' -import { homedir, platform } from 'os' -import { randomUUID } from 'crypto' +import { homedir, platform } from 'node:os' +import { randomUUID } from 'node:crypto' import { NotFoundError, OwnershipError, ValidationError, toMessage } from './errors' // Local shell sessions via node-pty: the user's default shell on diff --git a/src/main/ipc/localfs.ts b/src/main/ipc/localfs.ts index 89d7284..f843a07 100644 --- a/src/main/ipc/localfs.ts +++ b/src/main/ipc/localfs.ts @@ -1,8 +1,8 @@ import { ipcMain } from 'electron' -import { readdirSync, statSync } from 'fs' -import { readFile, writeFile } from 'fs/promises' -import { homedir } from 'os' -import { join } from 'path' +import { readdirSync, statSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' import { isInsideHome, isLikelyTextFile } from './security' import { ValidationError } from './errors' diff --git a/src/main/ipc/metrics.ts b/src/main/ipc/metrics.ts index 133fcd3..8550cea 100644 --- a/src/main/ipc/metrics.ts +++ b/src/main/ipc/metrics.ts @@ -60,16 +60,16 @@ export function parseMetricsOutput( } const cpuStat = total > 0 ? { idle, total } : undefined - const memTotal = parseInt(memStr.match(/MemTotal:\s+(\d+)/)?.[1] ?? '0') * 1024 - const memAvail = parseInt(memStr.match(/MemAvailable:\s+(\d+)/)?.[1] ?? '0') * 1024 + const memTotal = Number.parseInt(/MemTotal:\s+(\d+)/.exec(memStr)?.[1] ?? '0') * 1024 + const memAvail = Number.parseInt(/MemAvailable:\s+(\d+)/.exec(memStr)?.[1] ?? '0') * 1024 // df -kP: filesystem, 1k-blocks, used, available, capacity, mount const diskParts = diskStr.trim().split(/\s+/) - const diskTotal = (parseInt(diskParts[1] ?? '0') || 0) * 1024 - const diskUsed = (parseInt(diskParts[2] ?? '0') || 0) * 1024 + const diskTotal = (Number.parseInt(diskParts[1] ?? '0') || 0) * 1024 + const diskUsed = (Number.parseInt(diskParts[2] ?? '0') || 0) * 1024 - const load1 = parseFloat(loadStr.trim().split(/\s+/)[0] ?? '0') || 0 - const uptimeSec = Math.floor(parseFloat(upStr.trim().split(/\s+/)[0] ?? '0')) || 0 + const load1 = Number.parseFloat(loadStr.trim().split(/\s+/)[0] ?? '0') || 0 + const uptimeSec = Math.floor(Number.parseFloat(upStr.trim().split(/\s+/)[0] ?? '0')) || 0 return { metrics: { diff --git a/src/main/ipc/rdp.ts b/src/main/ipc/rdp.ts index 5b028a9..fe10b42 100644 --- a/src/main/ipc/rdp.ts +++ b/src/main/ipc/rdp.ts @@ -1,8 +1,8 @@ import { ipcMain, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' -import { spawn, ChildProcessWithoutNullStreams } from 'child_process' -import { join } from 'path' -import { existsSync } from 'fs' -import { randomUUID } from 'crypto' +import { spawn, ChildProcessWithoutNullStreams } from 'node:child_process' +import { join } from 'node:path' +import { existsSync } from 'node:fs' +import { randomUUID } from 'node:crypto' import { is } from '@electron-toolkit/utils' import { NotFoundError, OwnershipError, ValidationError, ConnectionError, toMessage } from './errors' @@ -102,44 +102,38 @@ function logStray(id: string, entry: RdpSession, stray: Buffer): void { ) } +// Validates the header at the buffer head. Returns null when it is not a +// plausible frame (bad magic, absurd dimensions, or a dataLen mismatch — +// e.g. a false "NXF1" matched inside pixel data) so the caller can resync. +function parseFrameHeader(buffer: Buffer): { width: number; height: number; dataLen: number } | null { + if (buffer.toString('ascii', 0, 4) !== MAGIC) return null + const width = buffer.readUInt32LE(4) + const height = buffer.readUInt32LE(8) + const dataLen = buffer.readUInt32LE(12) + if (width === 0 || height === 0 || width > 7680 || height > 7680) return null + if (dataLen !== width * height * 4 || dataLen > MAX_FRAME_BYTES) return null + return { width, height, dataLen } +} + // Pull every complete frame out of the accumulated buffer and forward it. function drainFrames(id: string, entry: RdpSession): void { for (;;) { if (entry.buffer.length < HEADER_BYTES) return - if (entry.buffer.toString('ascii', 0, 4) !== MAGIC) { + const header = parseFrameHeader(entry.buffer) + if (!header) { // Non-frame bytes on stdout — recover by skipping to the next frame. if (!resyncToMagic(id, entry)) return continue } - const width = entry.buffer.readUInt32LE(4) - const height = entry.buffer.readUInt32LE(8) - const dataLen = entry.buffer.readUInt32LE(12) - - // Validate width/height are reasonable and dataLen matches - if (width === 0 || height === 0 || width > 7680 || height > 7680) { - if (!resyncToMagic(id, entry)) return - continue - } - if (dataLen !== width * height * 4) { - if (!resyncToMagic(id, entry)) return - continue - } - - if (dataLen > MAX_FRAME_BYTES) { - // Almost certainly a false "NXF1" matched inside pixel data — skip past it. - if (!resyncToMagic(id, entry)) return - continue - } - - const total = HEADER_BYTES + dataLen + const total = HEADER_BYTES + header.dataLen if (entry.buffer.length < total) return // wait for more chunks const pixels = entry.buffer.subarray(HEADER_BYTES, total) if (!entry.sender.isDestroyed()) { // Copy out: the backing buffer is about to be sliced/reused. - entry.sender.send('rdp:frame', id, width, height, Buffer.from(pixels)) + entry.sender.send('rdp:frame', id, header.width, header.height, Buffer.from(pixels)) } entry.buffer = entry.buffer.subarray(total) } @@ -155,9 +149,11 @@ export function registerRdpHandlers(): void { if (typeof username !== 'string' || !username) throw new ValidationError('Username is required') if (typeof password !== 'string') throw new ValidationError('Password is required') - const port = typeof config.port === 'number' ? config.port : 3389 - const width = typeof config.width === 'number' ? config.width : 1280 - const height = typeof config.height === 'number' ? config.height : 800 + const clamp = (v: unknown, lo: number, hi: number, dflt: number): number => + typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, Math.round(v))) : dflt + const port = clamp(config.port, 1, 65535, 3389) + const width = clamp(config.width, 640, 7680, 1280) + const height = clamp(config.height, 480, 7680, 800) const bin = sidecarPath() if (!existsSync(bin)) { @@ -172,7 +168,13 @@ export function registerRdpHandlers(): void { { stdio: ['pipe', 'pipe', 'pipe'] }, ) - // Write password to stdin instead of passing as command-line argument + // Write password to stdin instead of passing as command-line argument. + // The error handler matters: if the sidecar dies before reading (bad + // binary, instant crash), the write EPIPEs, and an unhandled stream + // 'error' would take down the whole main process. + proc.stdin.on('error', (err) => { + console.error(`[rdp] stdin write failed: ${toMessage(err)}`) + }) proc.stdin.write(password + '\n') proc.stdin.end() @@ -180,10 +182,12 @@ export function registerRdpHandlers(): void { const entry: RdpSession = { proc, sender: event.sender, buffer: Buffer.alloc(0), resyncs: 0 } sessions.set(id, entry) - // The sidecar prints an authoritative outcome line ("[sidecar] ...") to - // stderr alongside FreeRDP's verbose logs. Keep the last one so a failed - // connect surfaces *why* in the tab instead of a bare exit code. + // The sidecar prints outcome lines ("[sidecar] ...") to stderr alongside + // FreeRDP's verbose logs. Failures use a "[sidecar] error: ..." prefix — + // prefer those for the close reason so an informational line (cert + // fingerprint, "connected") never masks the actual failure. let lastSidecarMsg: string | null = null + let lastSidecarError: string | null = null proc.stdout.on('data', (chunk: Buffer) => { entry.buffer = entry.buffer.length ? Buffer.concat([entry.buffer, chunk]) : chunk @@ -195,8 +199,13 @@ export function registerRdpHandlers(): void { const text = chunk.toString() console.error(`[rdp-sidecar ${id}] ${text.trimEnd()}`) for (const line of text.split('\n')) { - const m = line.match(/\[sidecar\]\s*(.+?)\s*$/) - if (m) lastSidecarMsg = m[1] + const m = /\[sidecar\](.*)/.exec(line) + if (!m) continue + const msg = m[1].trim() + if (!msg) continue + lastSidecarMsg = msg + const e = /^error:\s*(.+)$/.exec(msg) + if (e) lastSidecarError = e[1] } }) @@ -209,7 +218,7 @@ export function registerRdpHandlers(): void { sessions.delete(id) if (!event.sender.isDestroyed()) { const reason = - code === 0 ? null : lastSidecarMsg ?? `sidecar exited (${code})` + code === 0 ? null : lastSidecarError ?? lastSidecarMsg ?? `sidecar exited (${code})` event.sender.send('rdp:closed', id, reason) } }) diff --git a/src/main/ipc/redis.ts b/src/main/ipc/redis.ts index 9ac7ae2..e0726ed 100644 --- a/src/main/ipc/redis.ts +++ b/src/main/ipc/redis.ts @@ -1,6 +1,6 @@ import { ipcMain, IpcMainInvokeEvent } from 'electron' import Redis from 'ioredis' -import { randomUUID } from 'crypto' +import { randomUUID } from 'node:crypto' import { isBlockedRedisCommand, validateHost, validatePort } from './security' import { ConnectionError, NotFoundError, OwnershipError, ValidationError, toMessage } from './errors' diff --git a/src/main/ipc/runner.ts b/src/main/ipc/runner.ts index 15519eb..373529f 100644 --- a/src/main/ipc/runner.ts +++ b/src/main/ipc/runner.ts @@ -1,5 +1,5 @@ import { ipcMain, IpcMainInvokeEvent, WebContents } from 'electron' -import { randomUUID } from 'crypto' +import { randomUUID } from 'node:crypto' import { connectSessionClient, ManagedSshConnection } from './sshClients' import { getSessionById } from './sessions' import { NotFoundError, OwnershipError, ValidationError, toMessage } from './errors' diff --git a/src/main/ipc/security.ts b/src/main/ipc/security.ts index 4728529..ee5910c 100644 --- a/src/main/ipc/security.ts +++ b/src/main/ipc/security.ts @@ -1,6 +1,6 @@ -import { homedir } from 'os' -import { resolve, normalize } from 'path' -import { statSync } from 'fs' +import { homedir } from 'node:os' +import { resolve, normalize } from 'node:path' +import { statSync } from 'node:fs' const MAX_KEY_FILE_SIZE = 64 * 1024 const MAX_KUBECONFIG_FILE_SIZE = 1024 * 1024 @@ -90,7 +90,7 @@ export function isBlockedRedisCommand(command: string): boolean { } export function getBlockedRedisCommands(): string[] { - return [...BLOCKED_REDIS_COMMANDS].sort() + return [...BLOCKED_REDIS_COMMANDS].sort((a, b) => a.localeCompare(b)) } // Extension check is only a cheap pre-filter to avoid streaming large binaries; diff --git a/src/main/ipc/sessionTransfer.ts b/src/main/ipc/sessionTransfer.ts index a9e7d25..4403a78 100644 --- a/src/main/ipc/sessionTransfer.ts +++ b/src/main/ipc/sessionTransfer.ts @@ -31,6 +31,24 @@ function readString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value : undefined } +function applyOptionalFields(session: ImportedSession, r: Record): void { + session.keyPath = readString(r.keyPath) + session.group = readString(r.group) + session.color = readString(r.color) + session.dbType = readString(r.dbType) + session.databaseName = readString(r.databaseName) + session.sslMode = readString(r.sslMode) + session.kubeconfigPath = readString(r.kubeconfigPath) + if (Array.isArray(r.tags)) session.tags = r.tags.filter((t): t is string => typeof t === 'string') + if (typeof r.isFavorite === 'boolean') session.isFavorite = r.isFavorite + if (typeof r.pollingEnabled === 'boolean') session.pollingEnabled = r.pollingEnabled + if (typeof r.connectOnStart === 'boolean') session.connectOnStart = r.connectOnStart + if (typeof r.pollingIntervalSeconds === 'number' && Number.isFinite(r.pollingIntervalSeconds)) { + session.pollingIntervalSeconds = r.pollingIntervalSeconds + } + if (typeof r.redisDb === 'number' && Number.isInteger(r.redisDb)) session.redisDb = r.redisDb +} + function sanitizeConnection(raw: unknown): ImportedSession | null { if (typeof raw !== 'object' || raw === null) return null const r = raw as Record @@ -53,22 +71,8 @@ function sanitizeConnection(raw: unknown): ImportedSession | null { type, } - session.keyPath = readString(r.keyPath) - session.group = readString(r.group) - session.color = readString(r.color) - session.dbType = readString(r.dbType) - session.databaseName = readString(r.databaseName) - session.sslMode = readString(r.sslMode) session.contextName = contextName - session.kubeconfigPath = readString(r.kubeconfigPath) - if (Array.isArray(r.tags)) session.tags = r.tags.filter((t): t is string => typeof t === 'string') - if (typeof r.isFavorite === 'boolean') session.isFavorite = r.isFavorite - if (typeof r.pollingEnabled === 'boolean') session.pollingEnabled = r.pollingEnabled - if (typeof r.connectOnStart === 'boolean') session.connectOnStart = r.connectOnStart - if (typeof r.pollingIntervalSeconds === 'number' && Number.isFinite(r.pollingIntervalSeconds)) { - session.pollingIntervalSeconds = r.pollingIntervalSeconds - } - if (typeof r.redisDb === 'number' && Number.isInteger(r.redisDb)) session.redisDb = r.redisDb + applyOptionalFields(session, r) return session } diff --git a/src/main/ipc/sessions.ts b/src/main/ipc/sessions.ts index a38cdbb..056a3a2 100644 --- a/src/main/ipc/sessions.ts +++ b/src/main/ipc/sessions.ts @@ -1,7 +1,7 @@ import { ipcMain, dialog, BrowserWindow } from 'electron' import Store from 'electron-store' -import { randomUUID } from 'crypto' -import { readFile, writeFile } from 'fs/promises' +import { randomUUID } from 'node:crypto' +import { readFile, writeFile } from 'node:fs/promises' import { saveCredential, getCredential, deleteCredentials, isUnlocked } from './keychain' import { serializeSessions, parseSessionsExport, connectionDedupKey } from './sessionTransfer' diff --git a/src/main/ipc/sftp.ts b/src/main/ipc/sftp.ts index db5a5ea..3e20381 100644 --- a/src/main/ipc/sftp.ts +++ b/src/main/ipc/sftp.ts @@ -1,10 +1,9 @@ import { ipcMain, IpcMainInvokeEvent } from 'electron' import { Client, ClientChannel, SFTPWrapper, Stats } from 'ssh2' -import { randomUUID } from 'crypto' +import { randomUUID } from 'node:crypto' import { ConnectionError, NotFoundError, OwnershipError, ValidationError, toMessage } from './errors' -import { getOwnedSshClient } from './ssh' +import { getOwnedSshClient, SSH_CONNECT_DEFAULTS, sshConnectOptions } from './ssh' import { isInsideHome, isLikelyTextFile, validateHost, validatePort } from './security' -import { SSH_CONNECT_DEFAULTS, sshConnectOptions } from './ssh' import { connectSessionClient, openJumpSocket, ManagedSshConnection } from './sshClients' interface SftpClient { @@ -153,7 +152,11 @@ async function openSftp(event: IpcMainInvokeEvent, config: SftpConnectConfig): P const client = new Client() const clientId = randomUUID() let settled = false - const settle = (fn: () => void) => { if (settled) return; settled = true; fn() } + const settle = (fn: () => void) => { + if (settled) return + settled = true + fn() + } client.on('ready', () => { createSftpChannel(client, clientId, true, event.sender.id, upstream).then( @@ -181,6 +184,47 @@ async function openSftp(event: IpcMainInvokeEvent, config: SftpConnectConfig): P }) } +function listDir(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.readdir(path, (err, list) => { + if (err) return reject(new ConnectionError(toMessage(err))) + resolve(list.map((f) => ({ + name: f.filename, + size: f.attrs.size, + mtime: f.attrs.mtime * 1000, + permissions: f.attrs.mode, + isDirectory: (f.attrs.mode & 0o170000) === 0o040000, + }))) + }) + }) +} + +function readTextFile(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.stat(remotePath, (statErr, stats: Stats) => { + if (statErr) return reject(new ConnectionError(toMessage(statErr))) + + const filename = remotePath.split('/').pop() ?? '' + if (!isLikelyTextFile(filename, stats.size ?? 0)) { + return reject(new ValidationError('Cannot open binary file in editor. Use download instead.')) + } + + const chunks: Buffer[] = [] + const stream = sftp.createReadStream(remotePath) + stream.on('data', (c: Buffer) => chunks.push(c)) + stream.on('end', () => { + const buf = Buffer.concat(chunks) + const sample = buf.subarray(0, 8192) + if (sample.includes(0)) { + return reject(new ValidationError('File appears to be binary. Use download instead.')) + } + resolve(buf.toString('utf8')) + }) + stream.on('error', (err: unknown) => reject(new ConnectionError(toMessage(err)))) + }) + }) +} + function disposeClient(clientId: string): void { const entry = sftpClients.get(clientId) if (!entry) return @@ -205,48 +249,14 @@ export function registerSftpHandlers(): void { ipcMain.handle('sftp:list', (event, rawClientId: unknown, rawPath: unknown) => { const path = validateRemotePath(rawPath) - return new Promise((resolve, reject) => { - const entry = requireClient(event, rawClientId) - entry.sftp.readdir(path, (err, list) => { - if (err) return reject(new ConnectionError(toMessage(err))) - resolve(list.map((f) => ({ - name: f.filename, - size: f.attrs.size, - mtime: f.attrs.mtime * 1000, - permissions: f.attrs.mode, - isDirectory: (f.attrs.mode & 0o170000) === 0o040000, - }))) - }) - }) + const entry = requireClient(event, rawClientId) + return listDir(entry.sftp, path) }) ipcMain.handle('sftp:readFile', (event, rawClientId: unknown, rawRemotePath: unknown) => { const remotePath = validateRemotePath(rawRemotePath) - return new Promise((resolve, reject) => { - const entry = requireClient(event, rawClientId) - - entry.sftp.stat(remotePath, (statErr, stats: Stats) => { - if (statErr) return reject(new ConnectionError(toMessage(statErr))) - - const filename = remotePath.split('/').pop() ?? '' - if (!isLikelyTextFile(filename, stats.size ?? 0)) { - return reject(new ValidationError('Cannot open binary file in editor. Use download instead.')) - } - - const chunks: Buffer[] = [] - const stream = entry.sftp.createReadStream(remotePath) - stream.on('data', (c: Buffer) => chunks.push(c)) - stream.on('end', () => { - const buf = Buffer.concat(chunks) - const sample = buf.subarray(0, 8192) - if (sample.includes(0)) { - return reject(new ValidationError('File appears to be binary. Use download instead.')) - } - resolve(buf.toString('utf8')) - }) - stream.on('error', (err: unknown) => reject(new ConnectionError(toMessage(err)))) - }) - }) + const entry = requireClient(event, rawClientId) + return readTextFile(entry.sftp, remotePath) }) ipcMain.handle('sftp:writeFile', (event, rawClientId: unknown, rawRemotePath: unknown, rawContent: unknown) => { diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index c1f43fe..67750f3 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -1,6 +1,6 @@ import { ipcMain, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' import { Client, ClientChannel } from 'ssh2' -import { randomUUID } from 'crypto' +import { randomUUID } from 'node:crypto' import { ConnectionError, OwnershipError, ValidationError, toMessage } from './errors' import { validateHost, validatePort } from './security' import { @@ -146,6 +146,41 @@ function stopMetrics(streamId: string): void { prevCpuStats.delete(streamId) } +function fetchAndEmit(streamId: string): void { + const live = streams.get(streamId) + if (!live) { stopMetrics(streamId); return } + const timers = metricsTimers.get(streamId) + if (!timers || timers.inFlight) return + timers.inFlight = true + + live.client.exec(METRICS_COMMAND, (err, s) => { + if (err) { timers.inFlight = false; return } + let out = '' + s.on('data', (d: Buffer) => { out += d.toString() }) + // stderr is intentionally drained to keep the channel from buffering; + // remote `cat`/`grep` may report 'no such file' on non-Linux hosts. + s.stderr.on('data', () => {}) + s.on('close', () => { + const t = metricsTimers.get(streamId) + if (t) t.inFlight = false + try { + const { metrics, cpuStat } = parseMetricsOutput(out, prevCpuStats.get(streamId)) + if (cpuStat) prevCpuStats.set(streamId, cpuStat) + sendMetrics(streamId, metrics) + } catch (parseErr) { + // Best-effort metrics: malformed remote output (non-Linux, BSD, etc.) + // must not crash or stop the shell stream. + console.error(`[ssh] metrics parse ${streamId}: ${toMessage(parseErr)}`) + } + }) + s.on('error', (e: unknown) => { + const t = metricsTimers.get(streamId) + if (t) t.inFlight = false + console.error(`[ssh] metrics exec ${streamId}: ${toMessage(e)}`) + }) + }) +} + function disposeStream(streamId: string): void { stopMetrics(streamId) const entry = streams.get(streamId) @@ -162,6 +197,42 @@ export function disposeSshStreamsForSender(senderId: number): void { } } +// Opens the interactive shell once the client is ready and wires its stream +// events through to the renderer. `settle` guards against double-settling the +// connect promise when 'error' fires after 'ready'. +function openShell( + client: Client, + streamId: string, + sender: WebContents, + upstream: ManagedSshConnection | undefined, + settle: (fn: () => void) => void, + resolve: (id: string) => void, + reject: (err: Error) => void, +): void { + client.setNoDelay(true) + client.shell({ term: 'xterm-256color' }, (err, stream) => { + if (err) { + client.end() + upstream?.dispose() + settle(() => reject(new ConnectionError(toMessage(err)))) + return + } + streams.set(streamId, { client, stream, sender, upstream }) + + stream.on('data', (data: Buffer) => sendData(streamId, data.toString('utf8'))) + stream.stderr.on('data', (data: Buffer) => sendData(streamId, data.toString('utf8'))) + + stream.on('close', () => { + stopMetrics(streamId) + sendClosed(streamId) + streams.delete(streamId) + upstream?.dispose() + }) + + settle(() => resolve(streamId)) + }) +} + export function registerSshHandlers(): void { ipcMain.handle( 'ssh:connect', @@ -186,37 +257,18 @@ export function registerSshHandlers(): void { const client = new Client() const streamId = randomUUID() let settled = false - const settle = (fn: () => void) => { if (settled) return; settled = true; fn() } + const settle = (fn: () => void) => { + if (settled) return + settled = true + fn() + } client.on('keyboard-interactive', (_name, _instructions, _instructionsLang, prompts, finish) => { if (!config.password) { finish([]); return } finish(prompts.map(() => config.password ?? '')) }) - client.on('ready', () => { - client.setNoDelay(true) - client.shell({ term: 'xterm-256color' }, (err, stream) => { - if (err) { - client.end() - upstream?.dispose() - settle(() => reject(new ConnectionError(toMessage(err)))) - return - } - streams.set(streamId, { client, stream, sender: event.sender, upstream }) - - stream.on('data', (data: Buffer) => sendData(streamId, data.toString('utf8'))) - stream.stderr.on('data', (data: Buffer) => sendData(streamId, data.toString('utf8'))) - - stream.on('close', () => { - stopMetrics(streamId) - sendClosed(streamId) - streams.delete(streamId) - upstream?.dispose() - }) - - settle(() => resolve(streamId)) - }) - }) + client.on('ready', () => openShell(client, streamId, event.sender, upstream, settle, resolve, reject)) client.on('error', (err) => { if (!settled) { @@ -290,41 +342,6 @@ export function registerSshHandlers(): void { // ── Metrics ──────────────────────────────────────────────────────────────── - function fetchAndEmit(streamId: string): void { - const live = streams.get(streamId) - if (!live) { stopMetrics(streamId); return } - const timers = metricsTimers.get(streamId) - if (!timers || timers.inFlight) return - timers.inFlight = true - - live.client.exec(METRICS_COMMAND, (err, s) => { - if (err) { timers.inFlight = false; return } - let out = '' - s.on('data', (d: Buffer) => { out += d.toString() }) - // stderr is intentionally drained to keep the channel from buffering; - // remote `cat`/`grep` may report 'no such file' on non-Linux hosts. - s.stderr.on('data', () => {}) - s.on('close', () => { - const t = metricsTimers.get(streamId) - if (t) t.inFlight = false - try { - const { metrics, cpuStat } = parseMetricsOutput(out, prevCpuStats.get(streamId)) - if (cpuStat) prevCpuStats.set(streamId, cpuStat) - sendMetrics(streamId, metrics) - } catch (parseErr) { - // Best-effort metrics: malformed remote output (non-Linux, BSD, etc.) - // must not crash or stop the shell stream. - console.error(`[ssh] metrics parse ${streamId}: ${toMessage(parseErr)}`) - } - }) - s.on('error', (e: unknown) => { - const t = metricsTimers.get(streamId) - if (t) t.inFlight = false - console.error(`[ssh] metrics exec ${streamId}: ${toMessage(e)}`) - }) - }) - } - ipcMain.handle('ssh:metrics-start', (event: IpcMainInvokeEvent, rawStreamId: unknown) => { const streamId = validateStreamId(rawStreamId) const entry = getOwnedStream(event, streamId) diff --git a/src/main/ipc/sshClients.ts b/src/main/ipc/sshClients.ts index bd2cb69..041fee6 100644 --- a/src/main/ipc/sshClients.ts +++ b/src/main/ipc/sshClients.ts @@ -1,5 +1,5 @@ import { Client, Algorithms, ClientChannel, ConnectConfig } from 'ssh2' -import { readFileSync } from 'fs' +import { readFileSync } from 'node:fs' import { getSessionById, Session } from './sessions' import { getCredential, isUnlocked } from './keychain' import { isAllowedKeyPath } from './security' diff --git a/src/main/ipc/sshConfig.ts b/src/main/ipc/sshConfig.ts index 784db28..b665f11 100644 --- a/src/main/ipc/sshConfig.ts +++ b/src/main/ipc/sshConfig.ts @@ -1,7 +1,7 @@ import { ipcMain } from 'electron' -import { homedir } from 'os' -import { join } from 'path' -import { readFile } from 'fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { readFile } from 'node:fs/promises' export interface SshConfigHost { alias: string @@ -31,7 +31,7 @@ function unquote(value: string): string { // Splits a config line into its keyword and argument. ssh_config accepts both // "Key Value" and "Key=Value" forms. function splitDirective(line: string): { key: string; value: string } | null { - const match = line.match(/^(\S+?)(?:\s+|\s*=\s*)(.+)$/) + const match = /^([^\s=]+)(?:\s*=\s*|\s+)(.+)$/.exec(line) if (!match) return null return { key: match[1].toLowerCase(), value: match[2].trim() } } @@ -49,6 +49,41 @@ function finalizeBlock(block: HostBlock, out: SshConfigHost[]): void { } } +// Wildcard and negated Host patterns describe defaults, not servers. +function parseHostAliases(value: string): string[] { + return value + .split(/\s+/) + .map(unquote) + .filter(pattern => pattern && !/[*?!]/.test(pattern)) +} + +function applyDirective(block: HostBlock, key: string, value: string): void { + switch (key) { + case 'hostname': + block.hostname = unquote(value) + break + case 'port': { + const port = Number(unquote(value)) + if (Number.isInteger(port) && port >= 1 && port <= 65535) block.port = port + break + } + case 'user': + block.username = unquote(value) + break + case 'identityfile': + // ssh tries identity files in order; the first listed is the primary + block.keyPath ??= unquote(value) + break + case 'proxyjump': { + // Multi-hop chains (a,b,c) reduce to the first hop here; deeper + // chains resolve recursively if that hop is itself imported. + const firstHop = unquote(value).split(',')[0]?.trim() + if (firstHop && firstHop.toLowerCase() !== 'none') block.proxyJump = firstHop + break + } + } +} + /** * Extracts concrete host entries from ssh_config content. Wildcard and * negated Host patterns are skipped (they describe defaults, not servers), @@ -70,11 +105,7 @@ export function parseSshConfig(content: string): SshConfigHost[] { if (key === 'host') { if (block) finalizeBlock(block, hosts) inMatchBlock = false - const aliases = value - .split(/\s+/) - .map(unquote) - .filter(pattern => pattern && !/[*?!]/.test(pattern)) - block = { aliases } + block = { aliases: parseHostAliases(value) } continue } @@ -85,32 +116,7 @@ export function parseSshConfig(content: string): SshConfigHost[] { continue } - if (inMatchBlock || !block) continue - - switch (key) { - case 'hostname': - block.hostname = unquote(value) - break - case 'port': { - const port = Number(unquote(value)) - if (Number.isInteger(port) && port >= 1 && port <= 65535) block.port = port - break - } - case 'user': - block.username = unquote(value) - break - case 'identityfile': - // ssh tries identity files in order; the first listed is the primary - block.keyPath ??= unquote(value) - break - case 'proxyjump': { - // Multi-hop chains (a,b,c) reduce to the first hop here; deeper - // chains resolve recursively if that hop is itself imported. - const firstHop = unquote(value).split(',')[0]?.trim() - if (firstHop && firstHop.toLowerCase() !== 'none') block.proxyJump = firstHop - break - } - } + if (!inMatchBlock && block) applyDirective(block, key, value) } if (block) finalizeBlock(block, hosts) diff --git a/src/main/ipc/tunnels.ts b/src/main/ipc/tunnels.ts index 847e0b1..b22c39c 100644 --- a/src/main/ipc/tunnels.ts +++ b/src/main/ipc/tunnels.ts @@ -1,8 +1,8 @@ import { ipcMain, BrowserWindow } from 'electron' import Store from 'electron-store' -import { createServer, connect as netConnect, Server, Socket } from 'net' -import { Duplex } from 'stream' -import { randomUUID } from 'crypto' +import { createServer, connect as netConnect, Server, Socket } from 'node:net' +import { Duplex } from 'node:stream' +import { randomUUID } from 'node:crypto' import { connectSessionClient, ManagedSshConnection } from './sshClients' import { getSessionById } from './sessions' import { NotFoundError, ValidationError, toMessage } from './errors' @@ -136,30 +136,34 @@ async function startLocalForward(def: TunnelDef, entry: ActiveTunnel): Promise { + if (err) { socket.end(socksConnectReply(SOCKS_REPLY.connectionRefused)); return } + socket.write(socksConnectReply(SOCKS_REPLY.success)) + pipeBoth(socket, stream) + }, + ) +} + async function startDynamicForward(def: TunnelDef, entry: ActiveTunnel): Promise { const server = createServer((socket) => { trackSocket(entry, socket) socket.once('data', (greeting: Buffer) => { if (!isSocksGreeting(greeting)) { socket.destroy(); return } socket.write(socksGreetingReply()) - socket.once('data', (request: Buffer) => { - const parsed = parseSocksConnectRequest(request) - if ('errorCode' in parsed) { - socket.end(socksConnectReply(parsed.errorCode)) - return - } - entry.conn.client.forwardOut( - socket.remoteAddress ?? '127.0.0.1', - socket.remotePort ?? 0, - parsed.host, - parsed.port, - (err, stream) => { - if (err) { socket.end(socksConnectReply(SOCKS_REPLY.connectionRefused)); return } - socket.write(socksConnectReply(SOCKS_REPLY.success)) - pipeBoth(socket, stream) - }, - ) - }) + socket.once('data', (request: Buffer) => handleSocksConnect(entry, socket, request)) }) socket.on('error', () => socket.destroy()) }) @@ -237,7 +241,7 @@ export function listTunnels(): Array) { return (
@@ -441,7 +441,7 @@ const CommandRow = forwardRef< )) -function Pip({ connected, connecting }: { connected: boolean; connecting: boolean }) { +function Pip({ connected, connecting }: Readonly<{ connected: boolean; connecting: boolean }>) { if (connecting) { return ( @@ -456,7 +456,7 @@ function Pip({ connected, connecting }: { connected: boolean; connecting: boolea return } -function GroupChip({ name }: { name: string }) { +function GroupChip({ name }: Readonly<{ name: string }>) { const groupColors = useAppStore(s => s.groupColors) const color = groupColor(name, groupColors) return ( @@ -476,12 +476,12 @@ function GroupChip({ name }: { name: string }) { function HintKey({ icon, secondIcon, text, label, -}: { +}: Readonly<{ icon?: React.ReactNode secondIcon?: React.ReactNode text?: string label: string -}) { +}>) { return (
diff --git a/src/renderer/src/components/ConnectionManager/AddConnectionModal.tsx b/src/renderer/src/components/ConnectionManager/AddConnectionModal.tsx index 766ffb5..962c9ea 100644 --- a/src/renderer/src/components/ConnectionManager/AddConnectionModal.tsx +++ b/src/renderer/src/components/ConnectionManager/AddConnectionModal.tsx @@ -10,7 +10,7 @@ import { ipcErrorMessage } from '../../lib/format' interface K8sContextEntry { name: string server: string - source: 'default' | string // 'default' or file path + source: string // 'default' or a kubeconfig file path } type ConnectionType = 'ssh' | 'sftp' | 'database' | 'kubernetes' | 'redis' | 'rdp' @@ -138,7 +138,7 @@ export default function AddConnectionModal({ onClose }: Props) { switch (type) { case 'ssh': return '22' case 'sftp': return '22' - case 'database': return form.dbType === 'postgresql' ? '5432' : form.dbType === 'mysql' ? '3306' : '5432' + case 'database': return form.dbType === 'mysql' ? '3306' : '5432' case 'redis': return '6379' case 'rdp': return '3389' default: return '443' @@ -193,12 +193,35 @@ export default function AddConnectionModal({ onClose }: Props) { const parsedPort = (): number => { const raw = form.port.trim() - if (!raw) return parseInt(getDefaultPort(selectedType)) - return parseInt(raw) + if (!raw) return Number.parseInt(getDefaultPort(selectedType)) + return Number.parseInt(raw) } // One source of truth for "is this form submittable" — Test and Save must // never disagree about what a valid connection looks like. + const validateTypeFields = (): string | null => { + switch (selectedType) { + case 'ssh': + case 'sftp': + if (!form.username.trim()) return 'Username is required' + if (form.authType === 'key' && !form.keyPath.trim()) return 'Private key path is required' + return null + case 'database': + if (!form.username.trim()) return 'Username is required' + if (!form.databaseName.trim()) return 'Database name is required' + return null + case 'redis': { + const db = Number.parseInt(form.redisDb) + if (!Number.isInteger(db) || db < 0 || db > 15) return 'DB index must be 0–15' + return null + } + case 'rdp': + return form.username.trim() ? null : 'Username is required' + default: + return null + } + } + const validateConfigForm = (): string | null => { if (selectedType === 'kubernetes') { return k8sSelected ? null : 'Select a context to continue' @@ -206,22 +229,7 @@ export default function AddConnectionModal({ onClose }: Props) { if (!form.host.trim()) return 'Host is required' const port = parsedPort() if (!Number.isInteger(port) || port < 1 || port > 65535) return 'Port must be between 1 and 65535' - if (selectedType === 'ssh' || selectedType === 'sftp') { - if (!form.username.trim()) return 'Username is required' - if (form.authType === 'key' && !form.keyPath.trim()) return 'Private key path is required' - } - if (selectedType === 'database') { - if (!form.username.trim()) return 'Username is required' - if (!form.databaseName.trim()) return 'Database name is required' - } - if (selectedType === 'redis') { - const db = parseInt(form.redisDb) - if (!Number.isInteger(db) || db < 0 || db > 15) return 'DB index must be 0–15' - } - if (selectedType === 'rdp') { - if (!form.username.trim()) return 'Username is required' - } - return null + return validateTypeFields() } // When editing without retyping the password, fall back to the stored one @@ -279,7 +287,7 @@ export default function AddConnectionModal({ onClose }: Props) { await window.api.sftp.disconnect(clientId) } } else if (selectedType === 'redis') { - const id = await window.api.redis.connect({ host, port, password, db: parseInt(form.redisDb) }) + const id = await window.api.redis.connect({ host, port, password, db: Number.parseInt(form.redisDb) }) await window.api.redis.disconnect(id) } else if (selectedType === 'database') { const id = await window.api.database.connect({ @@ -303,6 +311,57 @@ export default function AddConnectionModal({ onClose }: Props) { } } + const parsedTags = (): string[] => + form.tags ? form.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : [] + + const k8sSessionData = (): any => ({ + type: 'kubernetes', + label: form.label.trim() || k8sSelected!.name, + host: k8sSelected!.server || k8sSelected!.name, + port: 0, + username: '', + authType: 'password', + color: form.color, + tags: parsedTags(), + contextName: k8sSelected!.name, + kubeconfigPath: k8sSelected!.source !== 'default' ? k8sSelected!.source : undefined, + }) + + const sessionData = (): any => { + const includePassword = !editingConnectionId || passwordDirty + return { + type: selectedType, + label: form.label.trim() || undefined, + host: form.host.trim(), + port: parsedPort(), + username: form.username.trim() || undefined, + authType: form.authType, + password: includePassword && form.authType === 'password' ? form.password : undefined, + keyPath: form.authType === 'key' ? form.keyPath.trim() : undefined, + jumpHostId: (selectedType === 'ssh' || selectedType === 'sftp') && form.jumpHostId ? form.jumpHostId : undefined, + group: form.group || undefined, + color: form.color, + tags: parsedTags(), + pollingEnabled: selectedType === 'ssh' ? form.pollingEnabled : undefined, + pollingIntervalSeconds: selectedType === 'ssh' ? Number.parseInt(form.pollingIntervalSeconds) : undefined, + connectOnStart: selectedType === 'ssh' ? form.connectOnStart : undefined, + dbType: selectedType === 'database' ? form.dbType : undefined, + databaseName: selectedType === 'database' ? form.databaseName : undefined, + sslMode: selectedType === 'database' ? form.sslMode : undefined, + redisDb: selectedType === 'redis' ? Number.parseInt(form.redisDb) : undefined, + } + } + + const persistSession = async (data: any) => { + if (editingConnectionId) { + const updated = await window.api.sessions.update(editingConnectionId, data) + useAppStore.getState().updateSession(editingConnectionId, updated) + } else { + const session = await window.api.sessions.create(data) + addSession(session) + } + } + const handleSave = async (e: React.FormEvent) => { e.preventDefault() @@ -312,70 +371,9 @@ export default function AddConnectionModal({ onClose }: Props) { return setError(invalid) } - if (selectedType === 'kubernetes') { - if (!k8sSelected) return setError('Select a context to continue') - setSaving(true) - try { - const data: any = { - type: 'kubernetes', - label: form.label.trim() || k8sSelected.name, - host: k8sSelected.server || k8sSelected.name, - port: 0, - username: '', - authType: 'password', - color: form.color, - tags: form.tags ? form.tags.split(',').map(t => t.trim()).filter(Boolean) : [], - contextName: k8sSelected.name, - kubeconfigPath: k8sSelected.source !== 'default' ? k8sSelected.source : undefined, - } - if (editingConnectionId) { - const updated = await window.api.sessions.update(editingConnectionId, data) - useAppStore.getState().updateSession(editingConnectionId, updated) - } else { - const session = await window.api.sessions.create(data) - addSession(session) - } - handleClose() - } catch (err: any) { - setError(ipcErrorMessage(err, 'Failed to save')) - } finally { - setSaving(false) - } - return - } - setSaving(true) try { - const includePassword = !editingConnectionId || passwordDirty - const data: any = { - type: selectedType, - label: form.label.trim() || undefined, - host: form.host.trim(), - port: parsedPort(), - username: form.username.trim() || undefined, - authType: form.authType, - password: includePassword && form.authType === 'password' ? form.password : undefined, - keyPath: form.authType === 'key' ? form.keyPath.trim() : undefined, - jumpHostId: (selectedType === 'ssh' || selectedType === 'sftp') && form.jumpHostId ? form.jumpHostId : undefined, - group: form.group || undefined, - color: form.color, - tags: form.tags ? form.tags.split(',').map(t => t.trim()).filter(Boolean) : [], - pollingEnabled: selectedType === 'ssh' ? form.pollingEnabled : undefined, - pollingIntervalSeconds: selectedType === 'ssh' ? parseInt(form.pollingIntervalSeconds) : undefined, - connectOnStart: selectedType === 'ssh' ? form.connectOnStart : undefined, - dbType: selectedType === 'database' ? form.dbType : undefined, - databaseName: selectedType === 'database' ? form.databaseName : undefined, - sslMode: selectedType === 'database' ? form.sslMode : undefined, - redisDb: selectedType === 'redis' ? parseInt(form.redisDb) : undefined, - } - - if (editingConnectionId) { - const updated = await window.api.sessions.update(editingConnectionId, data) - useAppStore.getState().updateSession(editingConnectionId, updated) - } else { - const session = await window.api.sessions.create(data) - addSession(session) - } + await persistSession(selectedType === 'kubernetes' ? k8sSessionData() : sessionData()) handleClose() } catch (err: any) { setTestResult(null) @@ -439,7 +437,7 @@ export default function AddConnectionModal({ onClose }: Props) {
@@ -543,7 +541,7 @@ export default function AddConnectionModal({ onClose }: Props) { onMouseLeave={e => { e.currentTarget.style.background = '#3B5CCC' }} > - {saving ? 'Saving…' : editingConnectionId ? 'Save Changes' : 'Save Connection'} + {saveButtonLabel(saving, !!editingConnectionId)}
@@ -555,10 +553,10 @@ export default function AddConnectionModal({ onClose }: Props) { } /* ── Type selector ───────────────────────────────────────────────────────── */ -function TypeSelector({ selected, onSelect }: { +function TypeSelector({ selected, onSelect }: Readonly<{ selected: ConnectionType onSelect: (t: ConnectionType) => void -}) { +}>) { // RDP needs the bundled FreeRDP sidecar, which currently ships on macOS only. // Hide the type elsewhere so we never offer a connection that can't run. const options = TYPE_OPTIONS.filter(o => o.type !== 'rdp' || window.api.platform === 'darwin') @@ -598,176 +596,368 @@ function TypeSelector({ selected, onSelect }: { } /* ── Config form ─────────────────────────────────────────────────────────── */ -function ConfigForm({ type, form, set, error, testResult, isEditing, hasExistingPassword, k8sContexts, k8sLoading, k8sSelected, onK8sSelect, onK8sImport, onK8sRefresh }: { - type: ConnectionType +function saveButtonLabel(saving: boolean, editing: boolean): string { + if (saving) return 'Saving…' + return editing ? 'Save Changes' : 'Save Connection' +} + +function storedPasswordPlaceholder(isEditing: boolean, hasExistingPassword: boolean, fallback: string): string { + return isEditing && hasExistingPassword ? '•••••••• (leave blank to keep)' : fallback +} + +function PasswordInput({ form, set, placeholder }: Readonly<{ form: any; set: (f: string, v: any) => void; placeholder: string }>) { + return ( +
+ set('password', e.target.value)} + /> + +
+ ) +} + +function SshFields({ form, set, isEditing, hasExistingPassword, jumpHostCandidates }: Readonly<{ + form: any + set: (f: string, v: any) => void + isEditing: boolean + hasExistingPassword: boolean + jumpHostCandidates: { id: string; label?: string; host: string }[] +}>) { + return ( + <> + + set('username', e.target.value)} + /> + + +
+ +
+ + +
+
+ + {form.authType === 'password' && ( + + + + )} + + {form.authType === 'key' && ( + + set('keyPath', e.target.value)} + /> + + )} + + {jumpHostCandidates.length > 0 && ( + + set('jumpHostId', e.target.value)}> + + {jumpHostCandidates.map(s => ( + + ))} + + + )} + + ) +} + +function DatabaseFields({ form, set, isEditing, hasExistingPassword }: Readonly<{ form: any set: (f: string, v: any) => void - error: string - testResult: 'success' | 'error' | null isEditing: boolean hasExistingPassword: boolean +}>) { + return ( + <> + + set('dbType', e.target.value)}> + + + + + + + set('databaseName', e.target.value)} + /> + + + set('username', e.target.value)} + /> + + + set('password', e.target.value)} + /> + + + set('sslMode', e.target.value)}> + + + + + + + + ) +} + +function K8sContextList({ k8sContexts, k8sLoading, k8sSelected, dropActive, onK8sSelect }: Readonly<{ + k8sContexts: K8sContextEntry[] + k8sLoading: boolean + k8sSelected: K8sContextEntry | null + dropActive: boolean + onK8sSelect: (ctx: K8sContextEntry) => void +}>) { + if (k8sLoading && k8sContexts.length === 0) { + return ( +
+ + Reading kubeconfig… +
+ ) + } + if (k8sContexts.length === 0) { + return ( +
+ {dropActive ? ( + <> + +

Drop to import

+ + ) : ( + <> +

No contexts found

+

+ Click "Import file" or drag a kubeconfig here +

+ + )} +
+ ) + } + const defaultContexts = k8sContexts.filter(c => c.source === 'default') + const importedFiles = [...new Set(k8sContexts.filter(c => c.source !== 'default').map(c => c.source))] + return ( + <> + {dropActive && ( +
+ + Drop to import file +
+ )} + {defaultContexts.length > 0 && ( + + )} + {importedFiles.map(filePath => ( + c.source === filePath)} + selected={k8sSelected} + onSelect={onK8sSelect} + /> + ))} + + ) +} + +function K8sContextForm({ form, set, error, k8sContexts, k8sLoading, k8sSelected, onK8sSelect, onK8sImport, onK8sRefresh }: Readonly<{ + form: any + set: (f: string, v: any) => void + error: string k8sContexts: K8sContextEntry[] k8sLoading: boolean k8sSelected: K8sContextEntry | null onK8sSelect: (ctx: K8sContextEntry) => void onK8sImport: (filePath?: string) => void onK8sRefresh: () => void -}) { +}>) { const [dropActive, setDropActive] = useState(false) - const sessions = useAppStore(s => s.sessions) - const editingConnectionId = useAppStore(s => s.editingConnectionId) - const jumpHostCandidates = sessions.filter(s => (s.type ?? 'ssh') === 'ssh' && s.id !== editingConnectionId) - const projectNames = [...new Set(sessions.map(s => s.group).filter(Boolean))] as string[] - if (type === 'kubernetes') { - const defaultContexts = k8sContexts.filter(c => c.source === 'default') - const importedFiles = [...new Set(k8sContexts.filter(c => c.source !== 'default').map(c => c.source))] + return ( +
{ e.preventDefault(); setDropActive(true) }} + onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDropActive(false) }} + onDrop={e => { + e.preventDefault() + setDropActive(false) + const file = e.dataTransfer.files[0] + if (!file) return + const filePath = (file as any).path as string | undefined + if (filePath) onK8sImport(filePath) + }} + style={dropActive ? { outline: '2px dashed #3B5CCC', outlineOffset: -2, borderRadius: 6 } : undefined} + > + {/* Header */} +
+
+

Select a context

+

+ Auto-discovered from ~/.kube/config +

+
+
+ + +
+
- return ( + {/* Context list / drop zone */}
{ e.preventDefault(); setDropActive(true) }} - onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDropActive(false) }} - onDrop={async e => { - e.preventDefault() - setDropActive(false) - const file = e.dataTransfer.files[0] - if (!file) return - const filePath = (file as any).path as string | undefined - if (filePath) onK8sImport(filePath) - }} - style={dropActive ? { outline: '2px dashed #3B5CCC', outlineOffset: -2, borderRadius: 6 } : undefined} + className="rounded-md overflow-hidden" + style={{ border: dropActive ? '1px dashed #3B5CCC' : '1px solid var(--nox-border)', maxHeight: 280, overflowY: 'auto' }} > - {/* Header */} -
-
-

Select a context

-

- Auto-discovered from ~/.kube/config -

-
-
- - -
+ +
+ + {error && ( +
+ +

{error}

+ )} - {/* Context list / drop zone */} -
- {k8sLoading && k8sContexts.length === 0 ? ( -
- - Reading kubeconfig… -
- ) : k8sContexts.length === 0 ? ( -
- {dropActive ? ( - <> - -

Drop to import

- - ) : ( - <> -

No contexts found

-

- Click "Import file" or drag a kubeconfig here -

- - )} -
- ) : ( - <> - {dropActive && ( -
- - Drop to import file -
- )} - {defaultContexts.length > 0 && ( - - )} - {importedFiles.map(filePath => ( - c.source === filePath)} - selected={k8sSelected} - onSelect={onK8sSelect} + {/* Optional label + color for selected context */} + {k8sSelected && ( +
+ + set('label', e.target.value)} + /> + + +
+ {COLORS.map(c => ( +
+
+ )} +
+ ) +} - {error && ( -
- -

{error}

-
- )} +function ConfigForm({ type, form, set, error, testResult, isEditing, hasExistingPassword, k8sContexts, k8sLoading, k8sSelected, onK8sSelect, onK8sImport, onK8sRefresh }: Readonly<{ + type: ConnectionType + form: any + set: (f: string, v: any) => void + error: string + testResult: 'success' | 'error' | null + isEditing: boolean + hasExistingPassword: boolean + k8sContexts: K8sContextEntry[] + k8sLoading: boolean + k8sSelected: K8sContextEntry | null + onK8sSelect: (ctx: K8sContextEntry) => void + onK8sImport: (filePath?: string) => void + onK8sRefresh: () => void +}>) { + const sessions = useAppStore(s => s.sessions) + const editingConnectionId = useAppStore(s => s.editingConnectionId) + const jumpHostCandidates = sessions.filter(s => (s.type ?? 'ssh') === 'ssh' && s.id !== editingConnectionId) + const projectNames = [...new Set(sessions.map(s => s.group).filter(Boolean))] as string[] - {/* Optional label + color for selected context */} - {k8sSelected && ( -
- - set('label', e.target.value)} - /> - - -
- {COLORS.map(c => ( -
-
-
- )} -
+ if (type === 'kubernetes') { + return ( + ) } @@ -805,129 +995,11 @@ function ConfigForm({ type, form, set, error, testResult, isEditing, hasExisting {/* Type-specific fields */} {(type === 'ssh' || type === 'sftp') && ( - <> - - set('username', e.target.value)} - /> - - -
- -
- - -
-
- - {form.authType === 'password' && ( - -
- set('password', e.target.value)} - /> - -
-
- )} - - {form.authType === 'key' && ( - - set('keyPath', e.target.value)} - /> - - )} - - {jumpHostCandidates.length > 0 && ( - - set('jumpHostId', e.target.value)}> - - {jumpHostCandidates.map(s => ( - - ))} - - - )} - + )} {type === 'database' && ( - <> - - set('dbType', e.target.value)}> - - - - - - - set('databaseName', e.target.value)} - /> - - - set('username', e.target.value)} - /> - - - set('password', e.target.value)} - /> - - - set('sslMode', e.target.value)}> - - - - - - - + )} {type === 'rdp' && ( @@ -940,24 +1012,7 @@ function ConfigForm({ type, form, set, error, testResult, isEditing, hasExisting /> -
- set('password', e.target.value)} - /> - -
+
)} @@ -967,7 +1022,7 @@ function ConfigForm({ type, form, set, error, testResult, isEditing, hasExisting set('password', e.target.value)} /> @@ -1077,13 +1132,13 @@ function ConfigForm({ type, form, set, error, testResult, isEditing, hasExisting ) } -function ContextGroup({ label, labelTitle, contexts, selected, onSelect }: { +function ContextGroup({ label, labelTitle, contexts, selected, onSelect }: Readonly<{ label: string labelTitle?: string contexts: K8sContextEntry[] selected: K8sContextEntry | null onSelect: (ctx: K8sContextEntry) => void -}) { +}>) { return (
@@ -1128,9 +1183,9 @@ function ContextGroup({ label, labelTitle, contexts, selected, onSelect }: { ) } -function MiniToggleRow({ on, onToggle, label, description }: { +function MiniToggleRow({ on, onToggle, label, description }: Readonly<{ on: boolean; onToggle: () => void; label: string; description: string -}) { +}>) { return (
) { return (
- {live - ? `cpu ${metrics!.cpu}% · mem ${memPct}%` - : isConnected - ? (metricCapable ? 'waiting for metrics' : 'Connected') - : reach.label} + {compactStatus(live, isConnected, metricCapable, `cpu ${metrics?.cpu}% · mem ${memPct}%`, reach.label)}
@@ -237,12 +254,10 @@ export function ServerListRow({ session, metrics, isConnected, onConnect, onCont ) } -function StatusBox({ metricCapable, isConnected }: { metricCapable: boolean; isConnected: boolean }) { - const text = isConnected - ? 'Connected — click to open' - : metricCapable - ? 'Connect to start live metrics' - : 'Click to open' +function StatusBox({ metricCapable, isConnected }: Readonly<{ metricCapable: boolean; isConnected: boolean }>) { + let text = 'Click to open' + if (isConnected) text = 'Connected — click to open' + else if (metricCapable) text = 'Connect to start live metrics' return (
) { return ( {value !== null ? ( @@ -270,8 +285,8 @@ function MetricCell({ label, value, pct }: { label: string; value: string | null ) } -function MetricBar({ label, value, color, sublabel }: { label: string; value: number; color: string; sublabel?: string }) { - const barColor = value >= 80 ? '#EF4444' : value >= 60 ? '#F59E0B' : color +function MetricBar({ label, value, color, sublabel }: Readonly<{ label: string; value: number; color: string; sublabel?: string }>) { + const barColor = thresholdColor(value, color) return (
@@ -292,7 +307,7 @@ function MetricBar({ label, value, color, sublabel }: { label: string; value: nu ) } -function ConnectionTypeIcon({ type }: { type?: string }) { +function ConnectionTypeIcon({ type }: Readonly<{ type?: string }>) { const s = { className: 'w-3.5 h-3.5 flex-shrink-0' } switch (type) { case 'database': return diff --git a/src/renderer/src/components/Database/DatabaseExplorer.tsx b/src/renderer/src/components/Database/DatabaseExplorer.tsx index 7de8c02..f054d52 100644 --- a/src/renderer/src/components/Database/DatabaseExplorer.tsx +++ b/src/renderer/src/components/Database/DatabaseExplorer.tsx @@ -22,7 +22,7 @@ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/ function isJsonString(s: string): boolean { if (s.length < 2) return false - return (s[0] === '{' && s[s.length - 1] === '}') || (s[0] === '[' && s[s.length - 1] === ']') + return (s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']')) } /* ── Explain plan parsing ─────────────────────────────────────────────── */ @@ -81,7 +81,7 @@ function computeRowDiff(prev: QueryResult | null, next: QueryResult): Set) { const sessions = useAppStore(s => s.sessions) const updateTab = useAppStore(s => s.updateTab) const session = sessions.find(s => s.id === tab.sessionId) @@ -217,18 +217,36 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) { function exportCsv() { if (!results) return const h = results.columns.join(',') - const rows = results.rows.map(r => results.columns.map(c => { const v = r[c]; if (v == null) return ''; const s = String(v); return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s }).join(',')).join('\n') + const rows = results.rows.map(r => results.columns.map(c => { + const v = r[c] + if (v == null) return '' + const s = String(v) + return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replaceAll('"', '""')}"` : s + }).join(',')).join('\n') const blob = new Blob([`${h}\n${rows}`], { type: 'text/csv' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${browsingTable || 'query'}-results.csv`; a.click(); URL.revokeObjectURL(a.href); showToast('Exported') } + function pickQuery(q: string) { + setSql(q) + setActivePanel('results') + editorRef.current?.focus() + } + + function startCellEdit(row: number, col: string, val: unknown) { + if (!browsingTable) return + setEditingCell({ row, col }) + setEditValue(val == null ? '' : String(val)) + setTimeout(() => editInputRef.current?.focus(), 0) + } + async function commitEdit() { if (!clientId || !editingCell || !results || !browsingTable) { setEditingCell(null); return } const row = results.rows[editingCell.row]; const col = editingCell.col if (String(row[col] ?? '') === editValue) { setEditingCell(null); return } const pk = results.columns.includes('id') ? 'id' : results.columns[0] const pkVal = row[pk]; if (pkVal == null) { showToast('No primary key to identify row'); setEditingCell(null); return } - const newVal = editValue === '' ? 'NULL' : `'${editValue.replace(/'/g, "''")}'` - const pkLit = typeof pkVal === 'number' ? pkVal : `'${String(pkVal).replace(/'/g, "''")}'` + const newVal = editValue === '' ? 'NULL' : `'${editValue.replaceAll("'", "''")}'` + const pkLit = typeof pkVal === 'number' ? pkVal : `'${String(pkVal).replaceAll("'", "''")}'` try { await window.api.database.query(clientId, `UPDATE "${browsingTable}" SET "${col}" = ${newVal} WHERE "${pk}" = ${pkLit}`) const updated = [...results.rows]; updated[editingCell.row] = { ...row, [col]: editValue === '' ? null : editValue } @@ -275,7 +293,10 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) { function startResize(e: React.MouseEvent) { e.preventDefault(); resizingRef.current = true; const startY = e.clientY; const startH = editorHeight - const onMove = (ev: MouseEvent) => { if (!resizingRef.current) return; setEditorHeight(Math.max(40, Math.min(400, startH + (ev.clientY - startY)))) } + const onMove = (ev: MouseEvent) => { + if (!resizingRef.current) return + setEditorHeight(Math.max(40, Math.min(400, startH + (ev.clientY - startY)))) + } const onUp = () => { resizingRef.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp) } window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp) } @@ -286,7 +307,9 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) { const { col, dir } = resultSort return [...results.rows].sort((a, b) => { const av = a[col], bv = b[col] - if (av == null && bv == null) return 0; if (av == null) return 1; if (bv == null) return -1 + if (av == null && bv == null) return 0 + if (av == null) return 1 + if (bv == null) return -1 const cmp = typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av).localeCompare(String(bv)) return dir === 'asc' ? cmp : -cmp }) @@ -297,7 +320,7 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) { function toggleJsonExpand(key: string) { setExpandedJson(s => { const n = new Set(s); n.has(key) ? n.delete(key) : n.add(key); return n }) } const filteredTables = tableFilter ? tables.filter(t => t.toLowerCase().includes(tableFilter.toLowerCase())) : tables - const dbType = session?.dbType === 'mysql' ? 'MySQL' : session?.dbType === 'mariadb' ? 'MariaDB' : 'PostgreSQL' + const dbType = DB_TYPE_LABELS[session?.dbType ?? ''] ?? 'PostgreSQL' const detailRow = selectedRow !== null && results ? sortedRows[selectedRow] : null const resultsTableWidth = results ? 48 + results.columns.length * 160 : 0 const resultsGridColumns = results ? `48px repeat(${results.columns.length}, 160px)` : undefined @@ -307,49 +330,17 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) { return (
- {/* Schema sidebar */} -
-
- -

{session?.databaseName || 'Database'}

- -
-
-
- - setTableFilter(e.target.value)} placeholder="Filter tables…" className="flex-1 bg-transparent text-[10px] font-mono focus:outline-none" style={{ color: 'var(--nox-text)' }} /> - {tableFilter && } -
-
-
Tables ({filteredTables.length})
-
- {filteredTables.map(table => ( -
- - {activeTable === table && tableColumns[table] && ( -
{tableColumns[table].map(col => ( -
- - {col.name} - {col.type} -
- ))}
- )} -
- ))} -
-
-
{dbType} · {session?.host}:{session?.port}
-
-
+ refreshTables()} + /> {/* Main area */}
@@ -434,23 +425,15 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) { {sortedRows.map((row, i) => (
setSelectedRow(i === selectedRow ? null : i)} className="grid cursor-default transition-colors" style={{ gridTemplateColumns: resultsGridColumns, background: selectedRow === i ? 'rgba(59,92,204,0.06)' : undefined }} onMouseEnter={e => { if (selectedRow !== i) e.currentTarget.style.background = 'var(--nox-hover)' }} onMouseLeave={e => { if (selectedRow !== i) e.currentTarget.style.background = '' }}>
{i + 1}
- {results.columns.map(col => { - const val = row[col]; const isNull = val == null - const isEditing = editingCell?.row === i && editingCell?.col === col - const cellKey = `${i}-${col}` - return ( -
{ e.stopPropagation(); if (browsingTable) { setEditingCell({ row: i, col }); setEditValue(isNull ? '' : String(val)); setTimeout(() => editInputRef.current?.focus(), 0) } }}> - {isEditing ? ( - setEditValue(e.target.value)} - onBlur={commitEdit} onKeyDown={e => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingCell(null) }} - className="bg-transparent text-[11px] font-mono px-0 py-0 focus:outline-none w-full" style={{ color: 'var(--nox-text)', borderBottom: '1px solid #3B5CCC' }} /> - ) : ( - - )} -
- ) - })} + {results.columns.map(col => ( + setEditingCell(null)} + startCellEdit={startCellEdit} + expandedJson={expandedJson} onToggleJson={toggleJsonExpand} /> + ))}
))}
@@ -466,25 +449,7 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) { {/* Row detail panel */} {detailOpen && detailRow && results && ( -
-
- Row {selectedRow! + 1} - -
-
- {results.columns.map(col => { - const val = detailRow[col]; const isNull = val == null - const str = isNull ? null : typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val) - return ( -
-

{col}

- {isNull ?

NULL

- :
{str}
} -
- ) - })} -
-
+ setDetailOpen(false)} /> )} } @@ -499,35 +464,9 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) {
)} - {/* ── History ──────────────────────────────────────────── */} - {activePanel === 'history' && ( -
- {history.length === 0 ?

No history

- : history.map((h, i) => ( - - ))} -
- )} + {activePanel === 'history' && } - {/* ── Saved ────────────────────────────────────────────── */} - {activePanel === 'saved' && ( -
- {savedQueries.length === 0 ?

Save queries with the Pin button

- : savedQueries.map((q, i) => ( - - ))} -
- )} + {activePanel === 'saved' && }
@@ -536,34 +475,190 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) { ) } +const DB_TYPE_LABELS: Record = { mysql: 'MySQL', mariadb: 'MariaDB', postgresql: 'PostgreSQL' } + +function SchemaSidebar({ dbLabel, footer, tables, tableFilter, setTableFilter, activeTable, tableColumns, onSelect, onRefresh }: Readonly<{ + dbLabel: string; footer: string; tables: string[]; tableFilter: string; setTableFilter: (v: string) => void + activeTable: string | null; tableColumns: Record; onSelect: (t: string) => void; onRefresh: () => void +}>) { + return ( +
+
+ +

{dbLabel}

+ +
+
+
+ + setTableFilter(e.target.value)} placeholder="Filter tables…" className="flex-1 bg-transparent text-[10px] font-mono focus:outline-none" style={{ color: 'var(--nox-text)' }} /> + {tableFilter && } +
+
+
Tables ({tables.length})
+
+ {tables.map(table => ( +
+ + {activeTable === table && tableColumns[table] && ( +
{tableColumns[table].map(col => ( +
+ + {col.name} + {col.type} +
+ ))}
+ )} +
+ ))} +
+
+
{footer}
+
+
+ ) +} + +function ResultCell({ row, rowIndex, col, editing, changed, editValue, setEditValue, editInputRef, commitEdit, cancelEdit, startCellEdit, expandedJson, onToggleJson }: Readonly<{ + row: any; rowIndex: number; col: string; editing: boolean; changed: boolean + editValue: string; setEditValue: (v: string) => void; editInputRef: React.Ref + commitEdit: () => void; cancelEdit: () => void; startCellEdit: (row: number, col: string, val: unknown) => void + expandedJson: Set; onToggleJson: (k: string) => void +}>) { + const val = row[col] + const isNull = val == null + return ( +
{ e.stopPropagation(); startCellEdit(rowIndex, col, val) }}> + {editing ? ( + setEditValue(e.target.value)} + onBlur={commitEdit} onKeyDown={e => { + if (e.key === 'Enter') commitEdit() + if (e.key === 'Escape') cancelEdit() + }} + className="bg-transparent text-[11px] font-mono px-0 py-0 focus:outline-none w-full" style={{ color: 'var(--nox-text)', borderBottom: '1px solid #3B5CCC' }} /> + ) : ( + + )} +
+ ) +} + +function RowDetailPanel({ columns, row, rowNumber, onClose }: Readonly<{ + columns: string[]; row: any; rowNumber: number; onClose: () => void +}>) { + return ( +
+
+ Row {rowNumber} + +
+
+ {columns.map(col => { + const val = row[col] + if (val == null) { + return ( +
+

{col}

+

NULL

+
+ ) + } + const str = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val) + return ( +
+

{col}

+
{str}
+
+ ) + })} +
+
+ ) +} + +function HistoryPanel({ history, onPick }: Readonly<{ + history: { sql: string; ts: number; duration?: number; rows?: number }[]; onPick: (sql: string) => void +}>) { + if (history.length === 0) { + return

No history

+ } + return ( +
+ {history.map((h, i) => ( + + ))} +
+ ) +} + +function SavedPanel({ savedQueries, onPick }: Readonly<{ savedQueries: SavedQuery[]; onPick: (sql: string) => void }>) { + if (savedQueries.length === 0) { + return

Save queries with the Pin button

+ } + return ( +
+ {savedQueries.map((q) => ( + + ))} +
+ ) +} + /* ── Smart cell renderer ───────────────────────────────────────────────── */ -function SmartCell({ value, cellKey, expandedJson, onToggleJson }: { +// Returns the parsed object for object values / JSON-looking strings, else null. +function tryParseJsonCell(value: any, str: string): any { + if (typeof value === 'object') return value + if (!isJsonString(str)) return null + try { return JSON.parse(str) } catch { return null } +} + +// JSON object/array — expandable inline +function JsonCell({ parsed, expanded, onToggle }: Readonly<{ parsed: any; expanded: boolean; onToggle: () => void }>) { + return ( + + + {expanded && ( +
{JSON.stringify(parsed, null, 2)}
+ )} +
+ ) +} + +function SmartCell({ value, cellKey, expandedJson, onToggleJson }: Readonly<{ value: any; cellKey: string; expandedJson: Set; onToggleJson: (k: string) => void -}) { +}>) { if (value == null) return NULL const str = typeof value === 'object' ? JSON.stringify(value) : String(value) - // JSON object/array — expandable inline - if (typeof value === 'object' || isJsonString(str)) { - const expanded = expandedJson.has(cellKey) - const parsed = typeof value === 'object' ? value : (() => { try { return JSON.parse(str) } catch { return null } })() - if (parsed) { - return ( - - - {expanded && ( -
{JSON.stringify(parsed, null, 2)}
- )} -
- ) - } + const parsed = tryParseJsonCell(value, str) + if (parsed) { + return onToggleJson(cellKey)} /> } // URL — clickable link @@ -589,7 +684,7 @@ function SmartCell({ value, cellKey, expandedJson, onToggleJson }: { // ISO timestamp — show relative time tooltip if (ISO_DATE_RE.test(str)) { const d = new Date(str) - if (!isNaN(d.getTime())) { + if (!Number.isNaN(d.getTime())) { return ( {d.toLocaleString()} ({relativeTime(d)}) @@ -613,10 +708,16 @@ function SmartCell({ value, cellKey, expandedJson, onToggleJson }: { /* ── Explain tree visualizer ───────────────────────────────────────────── */ -function ExplainTreeView({ node, maxCost, depth }: { node: ExplainNode; maxCost: number; depth: number }) { +function explainCostColor(costPct: number): string { + if (costPct > 70) return '#EF4444' + if (costPct > 40) return '#F59E0B' + return '#10B981' +} + +function ExplainTreeView({ node, maxCost, depth }: Readonly<{ node: ExplainNode; maxCost: number; depth: number }>) { const [expanded, setExpanded] = useState(true) const costPct = maxCost > 0 ? Math.max(2, (node.cost / maxCost) * 100) : 0 - const costColor = costPct > 70 ? '#EF4444' : costPct > 40 ? '#F59E0B' : '#10B981' + const costColor = explainCostColor(costPct) return (
0 ? 24 : 0 }}> @@ -652,7 +753,7 @@ function ExplainTreeView({ node, maxCost, depth }: { node: ExplainNode; maxCost:
{expanded && node.children.map((child, i) => ( - + ))}
) @@ -660,7 +761,7 @@ function ExplainTreeView({ node, maxCost, depth }: { node: ExplainNode; maxCost: /* ── Helpers ────────────────────────────────────────────────────────────── */ -function PanelTab({ active, onClick, badge, children }: { active: boolean; onClick: () => void; badge?: number; children: React.ReactNode }) { +function PanelTab({ active, onClick, badge, children }: Readonly<{ active: boolean; onClick: () => void; badge?: number; children: React.ReactNode }>) { return ( } diff --git a/src/renderer/src/components/Docker/DockerDashboard.tsx b/src/renderer/src/components/Docker/DockerDashboard.tsx index 6073b9b..d5852cd 100644 --- a/src/renderer/src/components/Docker/DockerDashboard.tsx +++ b/src/renderer/src/components/Docker/DockerDashboard.tsx @@ -259,16 +259,18 @@ export default function DockerDashboard({ tab }: Props) { ) } -function ContainerTableRow({ container, stat, last, busy, onAction, onLogs }: { +function ContainerTableRow({ container, stat, last, busy, onAction, onLogs }: Readonly<{ container: ContainerRow stat?: StatsRow last: boolean busy: boolean onAction: (action: 'start' | 'stop' | 'restart' | 'rm') => void onLogs: () => void -}) { +}>) { const isRunning = container.State === 'running' - const stateColor = isRunning ? '#10B981' : container.State === 'exited' ? 'var(--nox-text-3)' : '#F59E0B' + let stateColor = '#F59E0B' + if (isRunning) stateColor = '#10B981' + else if (container.State === 'exited') stateColor = 'var(--nox-text-3)' return ( void danger?: boolean children: React.ReactNode -}) { +}>) { return (
- -
-
+ + setPattern(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') onQuery() }} + placeholder="Pattern (e.g. user:*)" + className="flex-1 bg-transparent outline-none font-['Inter'] text-[11.5px]" + style={{ color: 'var(--nox-text)' }} + /> +
+ +
+
-
- {keys.length === 0 ? ( -
-

- {loadingKeys ? 'Loading…' : 'No keys found'} -

-
- ) : ( - keys.map(({ key }) => ( -
selectKey(key)} - className="group flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors" - style={{ - background: selectedKey === key ? '#DC382D18' : 'transparent', - borderLeft: selectedKey === key ? '2px solid #DC382D' : '2px solid transparent', - }} - onMouseEnter={e => { if (selectedKey !== key) (e.currentTarget as HTMLElement).style.background = 'var(--nox-hover)' }} - onMouseLeave={e => { if (selectedKey !== key) (e.currentTarget as HTMLElement).style.background = 'transparent' }} - > - - {key} - - -
- )) - )} -
+
+ {keys.length === 0 ? ( +
+

+ {loadingKeys ? 'Loading…' : 'No keys found'} +

+ ) : ( + keys.map(({ key }) => ( +
onSelect(key)} + className="group flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors" + style={{ + background: selectedKey === key ? '#DC382D18' : 'transparent', + borderLeft: selectedKey === key ? '2px solid #DC382D' : '2px solid transparent', + }} + onMouseEnter={e => { if (selectedKey !== key) (e.currentTarget as HTMLElement).style.background = 'var(--nox-hover)' }} + onMouseLeave={e => { if (selectedKey !== key) (e.currentTarget as HTMLElement).style.background = 'transparent' }} + > + + {key} + + +
+ )) + )} +
+
+ ) +} - {/* Value viewer */} -
- {!selectedKey ? ( -
-

- Select a key to view its value -

-
- ) : ( -
-
void +}>) { + if (!selectedKey) { + return ( +
+
+

+ Select a key to view its value +

+
+
+ ) + } + return ( +
+
+
+
+ {selectedKey} + {keyValue && ( + <> + -
- {selectedKey} - {keyValue && ( - <> - - {keyValue.type} - - - TTL: {keyValue.ttl === -1 ? 'No expiry' : `${keyValue.ttl}s`} - - - )} -
- -
-
- {loadingValue ? ( -
- - Loading… -
- ) : keyValue ? ( - - ) : null} -
-
+ {keyValue.type} + + + TTL: {keyValue.ttl === -1 ? 'No expiry' : `${keyValue.ttl}s`} + + )}
+
- ) : ( - /* CLI tab */ -
-
- {cmdHistory.length === 0 ? ( -

Type a Redis command below (e.g. INFO, DBSIZE, SET key value)

- ) : ( - cmdHistory.map((h, i) => ( -
-
- {'>'} {h.cmd} -
-
{h.result}
-
- )) - )} -
-
- {'>'} - setCmdInput(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleCommand() }} - placeholder="Enter command…" - className="flex-1 bg-transparent outline-none font-mono text-[12px]" - style={{ color: '#E6EDF3' }} - /> -
+
+ {loadingValue && ( +
+ + Loading… +
+ )} + {!loadingValue && keyValue && }
- )} +
+
+ ) +} + +function CliPane({ cmdHistory, cmdInput, setCmdInput, onSubmit, inputRef }: Readonly<{ + cmdHistory: { cmd: string; result: string }[] + cmdInput: string + setCmdInput: (v: string) => void + onSubmit: () => void + inputRef: React.Ref +}>) { + return ( +
+
+ {cmdHistory.length === 0 ? ( +

Type a Redis command below (e.g. INFO, DBSIZE, SET key value)

+ ) : ( + cmdHistory.map((h, i) => ( +
+
+ {'>'} {h.cmd} +
+
{h.result}
+
+ )) + )} +
+
+ {'>'} + setCmdInput(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') onSubmit() }} + placeholder="Enter command…" + className="flex-1 bg-transparent outline-none font-mono text-[12px]" + style={{ color: '#E6EDF3' }} + /> +
) } -function ValueViewer({ value }: { value: KeyValue }) { +function ValueViewer({ value }: Readonly<{ value: KeyValue }>) { if (value.type === 'string') { return (
(null) + const applyOutput = (sessionId: string, data: string) => { + setResults(prev => { + const next = new Map(prev) + const r = next.get(sessionId) + if (r) next.set(sessionId, { ...r, output: r.output + data }) + return next + }) + } + + const applyDone = (sessionId: string, exitCode: number | null, error: string | null) => { + setResults(prev => { + const next = new Map(prev) + const r = next.get(sessionId) + if (r) { + next.set(sessionId, { + ...r, + state: error || exitCode !== 0 ? 'failed' : 'done', + exitCode, + error, + }) + } + setRunning([...next.values()].some(v => v.state === 'running')) + return next + }) + } + useEffect(() => { const offOutput = window.api.runner.onOutput((runId, sessionId, data) => { if (runId !== runIdRef.current) return - setResults(prev => { - const next = new Map(prev) - const r = next.get(sessionId) - if (r) next.set(sessionId, { ...r, output: r.output + data }) - return next - }) + applyOutput(sessionId, data) }) const offDone = window.api.runner.onDone((runId, sessionId, exitCode, error) => { if (runId !== runIdRef.current) return - setResults(prev => { - const next = new Map(prev) - const r = next.get(sessionId) - if (r) { - next.set(sessionId, { - ...r, - state: error || exitCode !== 0 ? 'failed' : 'done', - exitCode, - error, - }) - } - setRunning([...next.values()].some(v => v.state === 'running')) - return next - }) + applyDone(sessionId, exitCode, error) }) return () => { offOutput() @@ -218,15 +226,23 @@ export default function RunnerView() { ) } -function HostResultCard({ session, result }: { session?: Session; result: HostResult }) { +function resultBadge(result: HostResult): { text: string; color: string } { + if (result.state === 'running') return { text: 'running', color: '#3B5CCC' } + if (result.state === 'done') return { text: 'exit 0', color: '#10B981' } + return { text: result.error ? 'error' : `exit ${result.exitCode}`, color: '#EF4444' } +} + +function resultText(result: HostResult): string { + if (result.error) return result.error + if (result.output) return result.output + return result.state === 'running' ? '…' : '(no output)' +} + +function HostResultCard({ session, result }: Readonly<{ session?: Session; result: HostResult }>) { const [open, setOpen] = useState(true) const label = session ? (session.label || session.host) : 'unknown host' - const badge = result.state === 'running' - ? { text: 'running', color: '#3B5CCC' } - : result.state === 'done' - ? { text: 'exit 0', color: '#10B981' } - : { text: result.error ? 'error' : `exit ${result.exitCode}`, color: '#EF4444' } + const badge = resultBadge(result) return (
@@ -252,7 +268,7 @@ function HostResultCard({ session, result }: { session?: Session; result: HostRe className="m-0 px-4 py-3 font-mono text-[11.5px] leading-relaxed whitespace-pre-wrap break-all max-h-72 overflow-y-auto select-text" style={{ background: 'var(--nox-bg)', color: 'var(--nox-text-2)', borderTop: '1px solid var(--nox-border)' }} > - {result.error ? result.error : result.output || (result.state === 'running' ? '…' : '(no output)')} + {resultText(result)} )}
diff --git a/src/renderer/src/components/SFTP/FilesDrawer.tsx b/src/renderer/src/components/SFTP/FilesDrawer.tsx index 3ad6166..0451ea7 100644 --- a/src/renderer/src/components/SFTP/FilesDrawer.tsx +++ b/src/renderer/src/components/SFTP/FilesDrawer.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { Tab } from '../../store' -import { useAppStore } from '../../store' +import { Tab, useAppStore } from '../../store' import { formatFileSize, ipcErrorMessage, joinPath } from '../../lib/format' import { connectSftp } from '../../lib/sftpConnect' import { @@ -77,7 +76,7 @@ export default function FilesDrawer({ tab, onClose }: Props) { setError(null) try { const result: SftpEntry[] = await window.api.sftp.list(id, dir) - setEntries(result.sort((a, b) => { + setEntries([...result].sort((a, b) => { if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 return a.name.localeCompare(b.name) })) @@ -307,23 +306,22 @@ export default function FilesDrawer({ tab, onClose }: Props) { className="flex items-center px-3 py-2 flex-shrink-0" style={{ borderTop: '1px solid var(--nox-border)', minHeight: 32, background: 'var(--nox-shell)' }} > - {toast ? ( -

{toast}

- ) : !connecting && !error ? ( + {toast &&

{toast}

} + {!toast && !connecting && !error && ( {entries.length} item{entries.length !== 1 ? 's' : ''} - ) : null} + )}
) } /* ── Breadcrumb ───────────────────────────────────────────────────────────── */ -function Breadcrumb({ segments, onNavigate }: { +function Breadcrumb({ segments, onNavigate }: Readonly<{ segments: string[] onNavigate: (idx: number) => void -}) { +}>) { if (segments.length === 0) { return ( / @@ -368,11 +366,11 @@ function Breadcrumb({ segments, onNavigate }: { } /* ── Header button ────────────────────────────────────────────────────────── */ -function HeaderButton({ title, onClick, children }: { +function HeaderButton({ title, onClick, children }: Readonly<{ title: string onClick: () => void children: React.ReactNode -}) { +}>) { return ( {onDelete && }} - ) - })} + {visible.map(entry => ( + + ))}
) } -function Th({ label, sk, pane, onSort, align }: { label: string; sk: SortKey; pane: PaneState; onSort: (k: SortKey) => void; align?: 'right' }) { +const DIFF_BG: Record = { + 'local-only': 'rgba(16,185,129,0.06)', 'remote-only': 'rgba(139,92,246,0.06)', different: 'rgba(245,158,11,0.06)', +} +const DIFF_DOT: Record = { + 'local-only': '#10B981', 'remote-only': '#8B5CF6', different: '#F59E0B', +} + +function FileRow({ entry, sel, diff, dateFormat, onSelect, onNavInto, onDoubleClickFile, onDragStart, onRename, onDelete }: Readonly<{ + entry: FileEntry; sel: boolean; diff: string | undefined; dateFormat: string + onSelect: (n: string, e: React.MouseEvent) => void; onNavInto: (e: FileEntry) => void; onDoubleClickFile: (e: FileEntry) => void + onDragStart: (e: FileEntry, ev: React.DragEvent) => void; onRename?: (e: FileEntry) => void; onDelete?: (e: FileEntry[]) => void +}>) { + const diffBg = diff ? DIFF_BG[diff] : undefined + const diffDot = diff ? DIFF_DOT[diff] : undefined + return ( + onDragStart(entry, e)} onClick={e => { e.stopPropagation(); onSelect(entry.name, e) }} onDoubleClick={() => entry.isDirectory ? onNavInto(entry) : onDoubleClickFile(entry)} className="group cursor-default transition-colors" style={{ background: sel ? 'rgba(59,92,204,0.08)' : diffBg }} onMouseEnter={e => { if (!sel) e.currentTarget.style.background = 'var(--nox-hover)' }} onMouseLeave={e => { if (!sel) e.currentTarget.style.background = diffBg || '' }}> + {diffDot && }{entry.isDirectory ? : }{entry.name} + {entry.isDirectory ? '—' : formatFileSize(entry.size)} + {formatDate(entry.mtime, dateFormat)} + {onRename && {onDelete && }} + + ) +} + +function Th({ label, sk, pane, onSort, align }: Readonly<{ label: string; sk: SortKey; pane: PaneState; onSort: (k: SortKey) => void; align?: 'right' }>) { const active = pane.sortKey === sk return onSort(sk)}>{label}{active && } } -function Btn({ title, onClick, disabled, active, children }: { title: string; onClick: () => void; disabled?: boolean; active?: boolean; children: React.ReactNode }) { +function Btn({ title, onClick, disabled, active, children }: Readonly<{ title: string; onClick: () => void; disabled?: boolean; active?: boolean; children: React.ReactNode }>) { return } diff --git a/src/renderer/src/components/ServerContextMenu.tsx b/src/renderer/src/components/ServerContextMenu.tsx index 7ea69c4..c9a569e 100644 --- a/src/renderer/src/components/ServerContextMenu.tsx +++ b/src/renderer/src/components/ServerContextMenu.tsx @@ -35,9 +35,9 @@ export function useMenuBehavior(x: number, y: number, onClose: () => void) { return { menuRef, pos } } -export function MenuItem({ icon, label, onClick, danger, hasSubmenu }: { +export function MenuItem({ icon, label, onClick, danger, hasSubmenu }: Readonly<{ icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean; hasSubmenu?: boolean -}) { +}>) { return ( + + onConfirmChange(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') onSubmit() }} + placeholder="Confirm password" + className="w-full rounded-lg px-4 py-2.5 font-['Inter'] text-[13px] focus:outline-none focus:ring-2 focus:ring-[#3B5CCC]" + style={{ background: 'var(--nox-bg)', border: '1px solid var(--nox-border)', color: 'var(--nox-text)' }} + /> + {error &&

{error}

} + + + ) +} + function ChangeAuthModal({ currentMode, onClose, onDone, -}: { +}: Readonly<{ currentMode: AuthMode onClose: () => void onDone: (mode: AuthMode) => void -}) { +}>) { // step: 'verify' (verify current) → 'select' (pick new mode) → 'set' (enter new credential) const [step, setStep] = useState<'verify' | 'select' | 'set'>( currentMode === 'none' ? 'select' : 'verify' @@ -804,40 +897,17 @@ function ChangeAuthModal({ {/* Header */}

- {step === 'verify' ? 'Confirm Your Identity' : step === 'select' ? 'Choose Lock Method' : 'Set New ' + (selectedMode === 'pin' ? 'PIN' : 'Password')} + {authModalTitle(step, selectedMode)}

- {step === 'verify' ? 'Enter your current credential to continue' : step === 'select' ? 'How should noxed lock on startup?' : selectedMode === 'pin' ? 'Choose a 4-digit PIN' : 'Choose a strong password'} + {authModalSubtitle(step, selectedMode)}

{/* ── VERIFY STEP ── */} {step === 'verify' && currentMode === 'biometrics' && ( -
-
- -
-

- {loading ? 'Waiting for Touch ID…' : 'Place your finger on the sensor'} -

- {error &&

{error}

} - {!loading && ( - - )} -
+ )} {step === 'verify' && currentMode === 'pin' && ( @@ -912,40 +982,17 @@ function ChangeAuthModal({ )} {step === 'set' && selectedMode === 'password' && ( -
-
- setNewCredential(e.target.value)} - placeholder="New password" - autoFocus - className="w-full rounded-lg px-4 py-2.5 font-['Inter'] text-[13px] focus:outline-none focus:ring-2 focus:ring-[#3B5CCC]" - style={{ background: 'var(--nox-bg)', border: '1px solid var(--nox-border)', color: 'var(--nox-text)' }} - /> - -
- setConfirmCredential(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleSetSubmit() }} - placeholder="Confirm password" - className="w-full rounded-lg px-4 py-2.5 font-['Inter'] text-[13px] focus:outline-none focus:ring-2 focus:ring-[#3B5CCC]" - style={{ background: 'var(--nox-bg)', border: '1px solid var(--nox-border)', color: 'var(--nox-text)' }} - /> - {error &&

{error}

} - -
+ setShowNew(s => !s)} + onSubmit={handleSetSubmit} + /> )}
@@ -967,15 +1014,28 @@ function ChangeAuthModal({ } /* ── Verify PIN input (compact numpad for modal) ─────────────────────────── */ -function VerifyPinInput({ loading, error, onSubmit }: { +function VerifyPinInput({ loading, error, onSubmit }: Readonly<{ loading: boolean; error: string; onSubmit: (pin: string) => void -}) { +}>) { const [digits, setDigits] = useState([]) + const pressKey = (key: string) => { + if (key === '⌫') { + setDigits(d => d.slice(0, -1)) + return + } + if (loading || digits.length >= 4) return + const next = [...digits, key] + setDigits(next) + if (next.length === 4) { + setTimeout(() => { onSubmit(next.join('')); setDigits([]) }, 80) + } + } + useEffect(() => { const handler = (e: KeyboardEvent) => { if (loading) return - if (/^[0-9]$/.test(e.key) && digits.length < 4) { + if (/^\d$/.test(e.key) && digits.length < 4) { const next = [...digits, e.key] setDigits(next) if (next.length === 4) { onSubmit(next.join('')); setDigits([]) } @@ -996,17 +1056,12 @@ function VerifyPinInput({ loading, error, onSubmit }: { {error &&

{error}

}
- {['1','2','3','4','5','6','7','8','9','','0','⌫'].map((key, i) => { - if (key === '') return
+ {['1','2','3','4','5','6','7','8','9','','0','⌫'].map((key) => { + if (key === '') return
const isDel = key === '⌫' return (
{error &&

{error}

}
- {['1','2','3','4','5','6','7','8','9','','0','⌫'].map((key, i) => { - if (key === '') return
+ {['1','2','3','4','5','6','7','8','9','','0','⌫'].map((key) => { + if (key === '') return
const isDel = key === '⌫' return (
{/* Context menu */} - {ctxMenu && ctxMenu.session && ( + {ctxMenu?.session && ( @@ -321,7 +321,7 @@ function DraggableSection({ label, sessions, connectedIds, renamingId, onContext onRename: (s: Session, label: string) => void onRenameCancel: () => void onReorder: (ids: string[]) => void -}) { +}>) { const dragSrc = useRef(null) const [dragOver, setDragOver] = useState(null) const [order, setOrder] = useState([]) @@ -383,7 +383,7 @@ function DraggableSection({ label, sessions, connectedIds, renamingId, onContext } /* ── Nav item ────────────────────────────────────────────────────────────── */ -function NavItem({ icon, label, active, onClick }: { icon: React.ReactNode; label: string; active: boolean; onClick: () => void }) { +function NavItem({ icon, label, active, onClick }: Readonly<{ icon: React.ReactNode; label: string; active: boolean; onClick: () => void }>) { return (
) { return (
@@ -414,7 +414,7 @@ function SectionGroup({ label, icon, children }: { label: string; icon?: React.R } /* ── Connection item ─────────────────────────────────────────────────────── */ -function ConnectionItem({ session, connected, isRenaming, onContextMenu, onClick, onRename, onRenameCancel }: { +function ConnectionItem({ session, connected, isRenaming, onContextMenu, onClick, onRename, onRenameCancel }: Readonly<{ session: Session connected: boolean isRenaming: boolean @@ -422,7 +422,7 @@ function ConnectionItem({ session, connected, isRenaming, onContextMenu, onClick onClick: () => void onRename: (label: string) => void onRenameCancel: () => void -}) { +}>) { const inputRef = useRef(null) const [draft, setDraft] = useState(session.label || session.host) @@ -472,14 +472,14 @@ function ConnectionItem({ session, connected, isRenaming, onContextMenu, onClick /* ── Project view ────────────────────────────────────────────────────────── */ -function ProjectView({ sessions, onConnect, onContextMenu, onMoveToProject, groupOrder, onGroupOrderChange }: { +function ProjectView({ sessions, onConnect, onContextMenu, onMoveToProject, groupOrder, onGroupOrderChange }: Readonly<{ sessions: Session[] onConnect: (s: Session) => void onContextMenu: (e: React.MouseEvent, s: Session) => void onMoveToProject: (session: Session, group: string) => void groupOrder: string[] onGroupOrderChange: (order: string[]) => void -}) { +}>) { const [expanded, setExpanded] = useState>(new Set()) const [groupMenu, setGroupMenu] = useState<{ group: string; x: number; y: number } | null>(null) const groupColors = useAppStore(s => s.groupColors) @@ -705,7 +705,7 @@ function ProjectView({ sessions, onConnect, onContextMenu, onMoveToProject, grou } /* ── Project context menu ────────────────────────────────────────────────── */ -function GroupContextMenu({ x, y, group, current, onNewConnection, onPick, onClose }: { +function GroupContextMenu({ x, y, group, current, onNewConnection, onPick, onClose }: Readonly<{ x: number y: number group: string @@ -713,7 +713,7 @@ function GroupContextMenu({ x, y, group, current, onNewConnection, onPick, onClo onNewConnection: () => void onPick: (color: string | null) => void onClose: () => void -}) { +}>) { const { menuRef, pos } = useMenuBehavior(x, y, onClose) useEffect(() => { @@ -742,7 +742,7 @@ function GroupContextMenu({ x, y, group, current, onNewConnection, onPick, onClo } - label={`New Connection in ${group === 'Ungrouped' ? 'Ungrouped' : `"${group}"`}`} + label={group === 'Ungrouped' ? 'New Connection in Ungrouped' : `New Connection in "${group}"`} onClick={onNewConnection} /> @@ -781,7 +781,7 @@ function GroupContextMenu({ x, y, group, current, onNewConnection, onPick, onClo } /* ── Connection type icon ────────────────────────────────────────────────── */ -function ConnectionTypeIcon({ type, size = 12 }: { type?: string; size?: number }) { +function ConnectionTypeIcon({ type, size = 12 }: Readonly<{ type?: string; size?: number }>) { const color = 'var(--nox-text-3)' switch (type) { case 'sftp': return @@ -793,9 +793,9 @@ function ConnectionTypeIcon({ type, size = 12 }: { type?: string; size?: number } } -function EmptyAreaMenu({ x, y, onNewConnection, onClose }: { +function EmptyAreaMenu({ x, y, onNewConnection, onClose }: Readonly<{ x: number; y: number; onNewConnection: () => void; onClose: () => void -}) { +}>) { const { menuRef, pos } = useMenuBehavior(x, y, onClose) return ( diff --git a/src/renderer/src/components/StatusBar/StatusBar.tsx b/src/renderer/src/components/StatusBar/StatusBar.tsx index ab73f1a..8a4f4c5 100644 --- a/src/renderer/src/components/StatusBar/StatusBar.tsx +++ b/src/renderer/src/components/StatusBar/StatusBar.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react' import { Shield, Cable } from 'lucide-react' import { useAppStore } from '../../store' +const countActive = (list: Array<{ status: string }>) => list.filter(t => t.status === 'active').length + export default function StatusBar() { const sessions = useAppStore(s => s.sessions) const tabs = useAppStore(s => s.tabs) @@ -19,7 +21,7 @@ export default function StatusBar() { useEffect(() => { const refresh = () => { window.api.tunnels.list() - .then(list => setActiveTunnels(list.filter(t => t.status === 'active').length)) + .then(list => setActiveTunnels(countActive(list))) .catch(() => setActiveTunnels(0)) } refresh() diff --git a/src/renderer/src/components/TabBar/TabBar.tsx b/src/renderer/src/components/TabBar/TabBar.tsx index 4c345e1..d55b8be 100644 --- a/src/renderer/src/components/TabBar/TabBar.tsx +++ b/src/renderer/src/components/TabBar/TabBar.tsx @@ -99,7 +99,7 @@ export default function TabBar() { ) } -function CloseConfirmDialog({ tabLabel, unsavedEdits, onConfirm, onCancel }: { tabLabel: string; unsavedEdits?: boolean; onConfirm: () => void; onCancel: () => void }) { +function CloseConfirmDialog({ tabLabel, unsavedEdits, onConfirm, onCancel }: Readonly<{ tabLabel: string; unsavedEdits?: boolean; onConfirm: () => void; onCancel: () => void }>) { return (
{ if (e.target === e.currentTarget) onCancel() }}>
> = { + dashboard: { Icon: LayoutDashboard, activeColor: '#3B5CCC' }, + connections: { Icon: List, activeColor: '#3B5CCC' }, + settings: { Icon: Settings, activeColor: '#3B5CCC' }, + terminal: { Icon: Terminal, activeColor: '#3B5CCC' }, + k8s: { Icon: Boxes, activeColor: '#8B5CF6' }, + database: { Icon: Database, activeColor: '#10B981' }, + sftp: { Icon: FolderOpen, activeColor: '#EC4899' }, + redis: { Icon: Layers, activeColor: '#DC382D' }, + rdp: { Icon: Monitor, activeColor: '#06B6D4' }, + editor: { Icon: FileCode, activeColor: '#F59E0B' }, + docker: { Icon: Boxes, activeColor: '#2496ED' }, + 'local-term': { Icon: Terminal, activeColor: '#10B981' }, +} + function tabIcon(view: Tab['view'], active: boolean) { - const color = active ? '#3B5CCC' : 'var(--nox-text-2)' - const size = 11 - switch (view) { - case 'dashboard': return - case 'connections': return - case 'settings': return - case 'terminal': return - case 'k8s': return - case 'database': return - case 'sftp': return - case 'redis': return - case 'rdp': return - case 'editor': return - case 'docker': return - case 'local-term': return - default: return - } + const { Icon, activeColor } = VIEW_ICONS[view] ?? { Icon: Terminal, activeColor: '#3B5CCC' } + return } -function TabPill({ tab, active, draggable, isDragging, isDropTarget, onActivate, onClose, onDragStart, onDragEnd, onDragOver, onDrop }: { +function TabPill({ tab, active, draggable, isDragging, isDropTarget, onActivate, onClose, onDragStart, onDragEnd, onDragOver, onDrop }: Readonly<{ tab: Tab active: boolean draggable: boolean @@ -171,7 +171,7 @@ function TabPill({ tab, active, draggable, isDragging, isDropTarget, onActivate, onDragEnd: () => void onDragOver: (e: React.DragEvent) => void onDrop: () => void -}) { +}>) { return ( @@ -224,12 +229,12 @@ function TunnelCard({ tunnel, via, busy, onToggle, onEdit, onDelete }: { ) } -function IconButton({ title, onClick, danger, children }: { +function IconButton({ title, onClick, danger, children }: Readonly<{ title: string onClick: () => void danger?: boolean children: React.ReactNode -}) { +}>) { return (
@@ -344,35 +342,13 @@ export default function DatabaseExplorer({ tab }: Readonly<{ tab: Tab }>) { {/* Main area */}
- {/* Toolbar */} -
- - -
-
- - {watchActive && {watchCountdown}s} - {!watchActive && ( - - )} -
- {navigator.platform?.includes('Mac') ? '⌘' : 'Ctrl'}+Enter -
- -
- -
+ runQuery()} onExplain={runExplain} onStartWatch={startWatch} onStopWatch={stopWatch} + onWatchSecChange={setWatchSec} onSave={saveCurrentQuery} + onClear={() => { setSql(''); setResults(null); setQueryError(null); setBrowsingTable(null); setActiveTable(null); setExplainTree(null) }} + /> {/* SQL editor */}
@@ -380,21 +356,11 @@ export default function DatabaseExplorer({ tab }: Readonly<{ tab: Tab }>) {
- {/* Tabs */} -
- setActivePanel('results')} badge={results ? results.rowCount : undefined}>Results - setActivePanel('explain')} badge={explainTree ? 1 : undefined}>Explain - setActivePanel('history')} badge={history.length || undefined}>History - setActivePanel('saved')} badge={savedQueries.length || undefined}>Saved -
- {results && activePanel === 'results' && <> - {results.columns.length} cols · {results.duration}ms - - - setDetailOpen(d => !d)} active={detailOpen}>{detailOpen ? : } -
- } -
+ setDetailOpen(d => !d)} + /> {/* Panel content */}
@@ -411,35 +377,13 @@ export default function DatabaseExplorer({ tab }: Readonly<{ tab: Tab }>) {
)} {results && ( -
-
-
-
-
#
- {results.columns.map(col => ( - - ))} -
- {sortedRows.map((row, i) => ( -
setSelectedRow(i === selectedRow ? null : i)} className="grid cursor-default transition-colors" style={{ gridTemplateColumns: resultsGridColumns, background: selectedRow === i ? 'rgba(59,92,204,0.06)' : undefined }} onMouseEnter={e => { if (selectedRow !== i) e.currentTarget.style.background = 'var(--nox-hover)' }} onMouseLeave={e => { if (selectedRow !== i) e.currentTarget.style.background = '' }}> -
{i + 1}
- {results.columns.map(col => ( - setEditingCell(null)} - startCellEdit={startCellEdit} - expandedJson={expandedJson} onToggleJson={toggleJsonExpand} /> - ))} -
- ))} -
- {sortedRows.length === 0 &&

No rows

} -
-
+ setEditingCell(null)} startCellEdit={startCellEdit} + changedCells={changedCells} expandedJson={expandedJson} onToggleJson={toggleJsonExpand} + /> )} {!results && !queryError && !running && (

Click a table to browse, or write a query

@@ -477,6 +421,123 @@ export default function DatabaseExplorer({ tab }: Readonly<{ tab: Tab }>) { const DB_TYPE_LABELS: Record = { mysql: 'MySQL', mariadb: 'MariaDB', postgresql: 'PostgreSQL' } +function ExplorerToolbar({ running, explainRunning, hasSql, activePanel, watchActive, watchSec, watchCountdown, onRun, onExplain, onStartWatch, onStopWatch, onWatchSecChange, onSave, onClear }: Readonly<{ + running: boolean; explainRunning: boolean; hasSql: boolean; activePanel: ActivePanel + watchActive: boolean; watchSec: number; watchCountdown: number + onRun: () => void; onExplain: () => void; onStartWatch: () => void; onStopWatch: () => void + onWatchSecChange: (sec: number) => void; onSave: () => void; onClear: () => void +}>) { + return ( +
+ + +
+ + {navigator.platform?.includes('Mac') ? '⌘' : 'Ctrl'}+Enter +
+ +
+ +
+ ) +} + +function WatchControls({ watchActive, watchSec, watchCountdown, running, hasSql, onStart, onStop, onSecChange }: Readonly<{ + watchActive: boolean; watchSec: number; watchCountdown: number; running: boolean; hasSql: boolean + onStart: () => void; onStop: () => void; onSecChange: (sec: number) => void +}>) { + return ( +
+ + {watchActive && {watchCountdown}s} + {!watchActive && ( + + )} +
+ ) +} + +function ResultsTabsBar({ activePanel, onSelect, results, hasExplain, historyCount, savedCount, detailOpen, onCopy, onExport, onToggleDetail }: Readonly<{ + activePanel: ActivePanel; onSelect: (p: ActivePanel) => void; results: QueryResult | null; hasExplain: boolean + historyCount: number; savedCount: number; detailOpen: boolean + onCopy: () => void; onExport: () => void; onToggleDetail: () => void +}>) { + return ( +
+ onSelect('results')} badge={results ? results.rowCount : undefined}>Results + onSelect('explain')} badge={hasExplain ? 1 : undefined}>Explain + onSelect('history')} badge={historyCount || undefined}>History + onSelect('saved')} badge={savedCount || undefined}>Saved +
+ {results && activePanel === 'results' && <> + {results.columns.length} cols · {results.duration}ms + + + {detailOpen ? : } +
+ } +
+ ) +} + +function ResultsGrid({ results, sortedRows, resultSort, onToggleSort, selectedRow, onSelectRow, editingCell, editValue, setEditValue, editInputRef, commitEdit, cancelEdit, startCellEdit, changedCells, expandedJson, onToggleJson }: Readonly<{ + results: QueryResult; sortedRows: any[]; resultSort: ResultSort; onToggleSort: (col: string) => void + selectedRow: number | null; onSelectRow: (i: number | null) => void + editingCell: { row: number; col: string } | null; editValue: string; setEditValue: (v: string) => void + editInputRef: React.Ref; commitEdit: () => void; cancelEdit: () => void + startCellEdit: (row: number, col: string, val: unknown) => void + changedCells: Set; expandedJson: Set; onToggleJson: (k: string) => void +}>) { + const tableWidth = 48 + results.columns.length * 160 + const gridColumns = `48px repeat(${results.columns.length}, 160px)` + return ( +
+
+
+
+
#
+ {results.columns.map(col => ( + + ))} +
+ {sortedRows.map((row, i) => ( +
onSelectRow(i === selectedRow ? null : i)} + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelectRow(i === selectedRow ? null : i) } }} + className="grid cursor-default transition-colors" style={{ gridTemplateColumns: gridColumns, background: selectedRow === i ? 'rgba(59,92,204,0.06)' : undefined }} + onMouseEnter={e => { if (selectedRow !== i) e.currentTarget.style.background = 'var(--nox-hover)' }} onMouseLeave={e => { if (selectedRow !== i) e.currentTarget.style.background = '' }}> +
{i + 1}
+ {results.columns.map(col => ( + + ))} +
+ ))} +
+ {sortedRows.length === 0 &&

No rows

} +
+
+ ) +} + function SchemaSidebar({ dbLabel, footer, tables, tableFilter, setTableFilter, activeTable, tableColumns, onSelect, onRefresh }: Readonly<{ dbLabel: string; footer: string; tables: string[]; tableFilter: string; setTableFilter: (v: string) => void activeTable: string | null; tableColumns: Record; onSelect: (t: string) => void; onRefresh: () => void @@ -761,6 +822,20 @@ function ExplainTreeView({ node, maxCost, depth }: Readonly<{ node: ExplainNode; /* ── Helpers ────────────────────────────────────────────────────────────── */ +function toEditable(v: unknown): string { + if (v == null) return '' + return typeof v === 'object' ? JSON.stringify(v) : String(v) +} + +function filterTables(tables: string[], filter: string): string[] { + if (!filter) return tables + return tables.filter(t => t.toLowerCase().includes(filter.toLowerCase())) +} + +function getDetailRow(rows: any[], selectedRow: number | null): any { + return selectedRow === null ? null : rows[selectedRow] ?? null +} + function PanelTab({ active, onClick, badge, children }: Readonly<{ active: boolean; onClick: () => void; badge?: number; children: React.ReactNode }>) { return ( + +
+ ), +})) + +let api: WindowApiMock + +function localTab(overrides: Partial = {}): Tab { + return makeTab({ + id: 'ed-1', + view: 'editor', + label: 'notes.md', + editorFile: { source: 'local', path: '/home/user/notes.md' }, + ...overrides, + }) +} + +describe('EditorTab', () => { + beforeEach(() => { + api = installWindowApi({ + localfs: { readTextFile: vi.fn().mockResolvedValue('hello world') }, + }) + seedStore({ sessions: [], tabs: [], activeTabId: null, notifications: [] }) + }) + + it('shows a message when the tab has no file', () => { + render() + expect(screen.getByText('This editor tab has no file associated with it')).toBeTruthy() + }) + + it('loads a local file into the editor and shows Saved when clean', async () => { + const tab = localTab() + seedStore({ tabs: [tab], activeTabId: tab.id }) + render() + + await waitFor(() => expect(screen.getByTestId('editor-value').textContent).toBe('hello world')) + expect(screen.getByText('Saved')).toBeTruthy() + expect(screen.getByText('local')).toBeTruthy() + expect(screen.getByText('/home/user/notes.md')).toBeTruthy() + }) + + it('marks the tab dirty when the editor content changes', async () => { + const tab = localTab() + seedStore({ tabs: [tab], activeTabId: tab.id }) + render() + await waitFor(() => expect(screen.getByTestId('code-editor')).toBeTruthy()) + + fireEvent.click(screen.getByTestId('editor-change')) + expect(useAppStore.getState().tabs[0].isDirty).toBe(true) + }) + + it('saves through the header button, flipping Save → Saving → clean', async () => { + const tab = localTab({ isDirty: true }) + seedStore({ tabs: [tab], activeTabId: tab.id }) + + let resolveWrite: () => void = () => {} + api.localfs.writeTextFile.mockReturnValueOnce(new Promise(res => { resolveWrite = res })) + + render() + await waitFor(() => expect(screen.getByTestId('code-editor')).toBeTruthy()) + + fireEvent.click(screen.getByText('Save')) + await waitFor(() => expect(screen.getByText('Saving')).toBeTruthy()) + + resolveWrite() + await waitFor(() => expect(useAppStore.getState().tabs[0].isDirty).toBe(false)) + expect(api.localfs.writeTextFile).toHaveBeenCalledWith('/home/user/notes.md', 'hello world') + }) + + it('saves via Cmd+S when the tab is active', async () => { + const tab = localTab({ isDirty: true }) + seedStore({ tabs: [tab], activeTabId: tab.id }) + render() + await waitFor(() => expect(screen.getByTestId('code-editor')).toBeTruthy()) + + fireEvent.keyDown(window, { key: 's', metaKey: true }) + await waitFor(() => expect(api.localfs.writeTextFile).toHaveBeenCalled()) + }) + + it('reports save failures as notifications', async () => { + const tab = localTab({ isDirty: true }) + seedStore({ tabs: [tab], activeTabId: tab.id }) + api.localfs.writeTextFile.mockRejectedValueOnce(new Error('disk full')) + + render() + await waitFor(() => expect(screen.getByTestId('code-editor')).toBeTruthy()) + + fireEvent.click(screen.getByText('Save')) + await waitFor(() => { + const notes = useAppStore.getState().notifications + expect(notes.some(n => n.message.includes('disk full'))).toBe(true) + }) + }) + + it('shows the error state with retry when loading fails', async () => { + api.localfs.readTextFile + .mockRejectedValueOnce(new Error('no such file')) + .mockResolvedValueOnce('recovered') + const tab = localTab() + seedStore({ tabs: [tab], activeTabId: tab.id }) + render() + + await waitFor(() => expect(screen.getByText('no such file')).toBeTruthy()) + expect(useAppStore.getState().tabs[0].status).toBe('error') + + fireEvent.click(screen.getByText('Retry')) + await waitFor(() => expect(screen.getByTestId('editor-value').textContent).toBe('recovered')) + }) + + it('opens remote files over the terminal SFTP stream', async () => { + const session = makeSession({ id: 'srv-1', host: 'remote.example.com' }) + api.sftp.readFile.mockResolvedValue('remote text') + const tab = makeTab({ + id: 'ed-2', view: 'editor', label: 'app.conf', sessionId: 'srv-1', streamId: 'stream-7', + editorFile: { source: 'remote', path: '/etc/app.conf' }, + }) + seedStore({ sessions: [session], tabs: [tab], activeTabId: tab.id }) + + render() + await waitFor(() => expect(screen.getByTestId('editor-value').textContent).toBe('remote text')) + expect(screen.getByText('remote.example.com')).toBeTruthy() + expect(api.sftp.connect).toHaveBeenCalledWith(expect.objectContaining({ streamId: 'stream-7' })) + }) +}) diff --git a/src/renderer/src/components/K8s/ResourceDetailModal.tsx b/src/renderer/src/components/K8s/ResourceDetailModal.tsx index a35ffd3..3df77fc 100644 --- a/src/renderer/src/components/K8s/ResourceDetailModal.tsx +++ b/src/renderer/src/components/K8s/ResourceDetailModal.tsx @@ -10,21 +10,57 @@ interface Props { onClose: () => void } -// Matches JSON tokens: strings (optionally key-position), keywords, numbers. -const JSON_TOKEN_RE = /("(?:\\.|[^\\"])*"(?:\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g +// JSON token matchers (sticky, tried in order at each position by colorize). +const STRING_RE = /"(?:\\.|[^\\"])*"/y +const KEY_SUFFIX_RE = /\s*:/y +const KEYWORD_RE = /true|false|null/y +const NUMBER_RE = /-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?/y -// Simple JSON syntax coloring +function matchAt(re: RegExp, text: string, index: number): string | null { + re.lastIndex = index + const m = re.exec(text) + return m ? m[0] : null +} + +function isWordChar(ch: string | undefined): boolean { + return ch !== undefined && /\w/.test(ch) +} + +// Returns the JSON token starting at `index` and its color, or null. +function tokenAt(text: string, index: number): { text: string; color: string } | null { + const str = matchAt(STRING_RE, text, index) + if (str) { + const keySuffix = matchAt(KEY_SUFFIX_RE, text, index + str.length) + if (keySuffix) return { text: str + keySuffix, color: '#9d6ff8' } + return { text: str, color: '#10b981' } + } + if (!isWordChar(text[index - 1])) { + const kw = matchAt(KEYWORD_RE, text, index) + if (kw && !isWordChar(text[index + kw.length])) { + return { text: kw, color: kw === 'null' ? '#EF4444' : '#06b6d4' } + } + } + const num = matchAt(NUMBER_RE, text, index) + if (num) return { text: num, color: '#f59e0b' } + return null +} + +// Simple JSON syntax coloring: single left-to-right pass so already-emitted +// markup is never re-matched. function colorize(raw: string): string { - return raw - .replace(JSON_TOKEN_RE, (match) => { - if (match.startsWith('"')) { - if (match.endsWith(':')) return `${match}` - return `${match}` - } - if (match === 'true' || match === 'false') return `${match}` - if (match === 'null') return `${match}` - return `${match}` - }) + let out = '' + let i = 0 + while (i < raw.length) { + const token = tokenAt(raw, i) + if (token) { + out += `${token.text}` + i += token.text.length + } else { + out += raw[i] + i++ + } + } + return out } export default function ResourceDetailModal({ context, namespace, kind, name, kubeconfigPath, onClose }: Readonly) { diff --git a/src/renderer/src/components/K8s/__tests__/K8sDashboard.test.tsx b/src/renderer/src/components/K8s/__tests__/K8sDashboard.test.tsx new file mode 100644 index 0000000..c2d8b96 --- /dev/null +++ b/src/renderer/src/components/K8s/__tests__/K8sDashboard.test.tsx @@ -0,0 +1,359 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react' +import { useAppStore } from '../../../store' +import { installWindowApi, seedStore, makeTab } from '../../../__tests__/harness' + +// Keep the dashboard shallow: the pod modals stream logs / open PTYs, which is +// out of scope here. Mocking sibling modules inside this test file is allowed. +vi.mock('../PodLogsModal', () => ({ + default: ({ pod, onClose }: { pod: string; onClose: () => void }) => ( +
+ stub-logs:{pod} + +
+ ), +})) +vi.mock('../PodExecModal', () => ({ + default: ({ pod }: { pod: string }) =>
stub-exec:{pod}
, +})) +vi.mock('../ResourceDetailModal', () => ({ + default: ({ kind, name }: { kind: string; name: string }) =>
stub-detail:{kind}:{name}
, +})) + +import K8sDashboard from '../K8sDashboard' + +const now = Date.now() +const ago = (ms: number) => new Date(now - ms).toISOString() + +const PODS = [ + { name: 'web-1', ready: '1/1', status: 'Running', restarts: 0, age: ago(30_000), node: 'node-a', containers: ['app'] }, + { name: 'web-2', ready: '1/1', status: 'Running', restarts: 3, age: ago(2 * 3600_000), node: 'node-b', containers: ['app'] }, + { name: 'crash-pod', ready: '0/1', status: 'CrashLoopBackOff', restarts: 7, age: ago(3 * 86_400_000), node: '', containers: [] }, +] +const DEPLOYMENTS = [ + { name: 'api', ready: '2/2', available: 2, age: ago(600_000), replicas: 2 }, + { name: 'worker', ready: '1/2', available: 1, age: ago(600_000), replicas: 2 }, +] +const JOBS = [ + { name: 'ok-job', completions: '1/1', active: 0, failed: 0, age: ago(60_000) }, + { name: 'active-job', completions: '0/1', active: 1, failed: 0, age: ago(60_000) }, + { name: 'failed-job', completions: '0/1', active: 0, failed: 2, age: ago(60_000) }, +] + +function k8sData() { + return { + namespaces: vi.fn().mockResolvedValue(['default', 'kube-system']), + pods: vi.fn().mockResolvedValue(PODS), + deployments: vi.fn().mockResolvedValue(DEPLOYMENTS), + statefulsets: vi.fn().mockResolvedValue([{ name: 'db-ss', ready: '1/1', age: ago(60_000), replicas: 1 }]), + daemonsets: vi.fn().mockResolvedValue([{ name: 'log-ds', desired: 2, ready: 1, available: 1, age: ago(60_000) }]), + replicasets: vi.fn().mockResolvedValue([{ name: 'api-rs', desired: 1, ready: 1, age: ago(60_000) }]), + jobs: vi.fn().mockResolvedValue(JOBS), + cronjobs: vi.fn().mockResolvedValue([ + { name: 'backup-cj', schedule: '0 0 * * *', lastSchedule: null, active: 0, age: ago(60_000), suspended: true }, + ]), + services: vi.fn().mockResolvedValue([ + { name: 'svc-web', type: 'LoadBalancer', clusterIP: '10.0.0.1', externalIP: '1.2.3.4', ports: '80/TCP,443/TCP', age: ago(60_000) }, + { name: 'svc-int', type: 'ClusterIP', clusterIP: '10.0.0.2', externalIP: '', ports: '', age: ago(60_000) }, + ]), + ingresses: vi.fn().mockResolvedValue([ + { name: 'ing-a', hosts: 'app.example.com', address: '', ingressClass: 'nginx', age: ago(60_000) }, + ]), + configmaps: vi.fn().mockResolvedValue([{ name: 'cm-app', keys: 2, age: ago(60_000) }]), + secrets: vi.fn().mockResolvedValue([{ name: 'sec-tls', type: 'kubernetes.io/tls', keys: 1, age: ago(60_000) }]), + nodes: vi.fn().mockResolvedValue([ + { name: 'node-a', status: 'Ready', roles: 'control-plane', cpu: '8', memory: '16Gi', kubeletVersion: 'v1.30.0', age: ago(86_400_000) }, + { name: 'node-b', status: 'NotReady', roles: 'worker', cpu: '4', memory: '8Gi', kubeletVersion: 'v1.30.0', age: ago(86_400_000) }, + ]), + events: vi.fn().mockResolvedValue([ + { name: 'ev-1', type: 'Warning', reason: 'BackOff', object: 'pod/web-1', message: 'restarting container', count: 4, age: ago(60_000) }, + { name: 'ev-2', type: 'Normal', reason: 'Pulled', object: 'pod/web-2', message: 'image pulled', count: 1, age: ago(60_000) }, + ]), + portForwardStart: vi.fn().mockResolvedValue({ id: 'pf-1', localPort: 50123 }), + servicePortForwardStart: vi.fn().mockResolvedValue({ id: 'pf-2', localPort: 50456 }), + portForwardList: vi.fn().mockResolvedValue([]), + } +} + +function setup(k8sOverrides: Record = {}, props: Record = {}) { + const api = installWindowApi({ k8s: { ...k8sData(), ...k8sOverrides } }) + seedStore({ sessions: [], tabs: [], activeTabId: null }) + const utils = render() + return { api, ...utils } +} + +async function podsLoaded() { + await waitFor(() => expect(screen.getByText('web-1')).toBeTruthy()) +} + +beforeEach(() => { + cleanup() +}) + +describe('K8sDashboard — pods and connection state', () => { + it('loads pods for the default namespace and shows connected state', async () => { + const { api } = setup() + await podsLoaded() + expect(api.k8s.pods).toHaveBeenCalledWith('test-ctx', 'default', undefined) + expect(screen.getAllByText('Connected').length).toBeGreaterThan(0) + expect(screen.getByText('Namespace: default · 3 pods')).toBeTruthy() + // restart counts across color thresholds + expect(screen.getAllByText('0').length).toBeGreaterThan(0) + expect(screen.getAllByText('3').length).toBeGreaterThan(0) + expect(screen.getAllByText('7').length).toBeGreaterThan(0) + // age buckets: seconds / hours / days + expect(screen.getByText('30s')).toBeTruthy() + expect(screen.getByText('2h')).toBeTruthy() + expect(screen.getByText('3d')).toBeTruthy() + }) + + it('marks the owning tab connected once loaded', async () => { + const tab = makeTab({ view: 'k8s', k8sContext: 'test-ctx', status: 'connecting' }) + installWindowApi({ k8s: k8sData() }) + seedStore({ sessions: [], tabs: [tab], activeTabId: tab.id }) + render() + await podsLoaded() + await waitFor(() => expect(useAppStore.getState().tabs[0].status).toBe('connected')) + }) + + it('switches namespace and refetches', async () => { + const { api, container } = setup() + await podsLoaded() + fireEvent.change(container.querySelector('select')!, { target: { value: 'kube-system' } }) + await waitFor(() => expect(api.k8s.pods).toHaveBeenCalledWith('test-ctx', 'kube-system', undefined)) + }) + + it('filters rows by name', async () => { + setup() + await podsLoaded() + fireEvent.change(screen.getByPlaceholderText('Filter pods…'), { target: { value: 'crash' } }) + expect(screen.queryByText('web-1')).toBeNull() + expect(screen.getByText('crash-pod')).toBeTruthy() + }) + + it('shows an error banner and error tab status when the fetch fails, then retries', async () => { + const tab = makeTab({ view: 'k8s', k8sContext: 'test-ctx' }) + const pods = vi.fn().mockRejectedValueOnce(new Error('cluster unreachable')).mockResolvedValue(PODS) + installWindowApi({ k8s: { ...k8sData(), pods } }) + seedStore({ sessions: [], tabs: [tab], activeTabId: tab.id }) + render() + await waitFor(() => expect(screen.getByText('Cluster request failed')).toBeTruthy()) + expect(screen.getByText('cluster unreachable')).toBeTruthy() + expect(screen.getAllByText('Connection error').length).toBeGreaterThan(0) + expect(useAppStore.getState().tabs[0].status).toBe('error') + fireEvent.click(screen.getByText('Retry')) + await podsLoaded() + }) + + it('shows the empty state when there are no resources', async () => { + setup({ pods: vi.fn().mockResolvedValue([]) }) + await waitFor(() => expect(screen.getByText('No pods found')).toBeTruthy()) + expect(screen.getByText('in this namespace')).toBeTruthy() + }) + + it('surfaces namespace loading failures', async () => { + setup({ namespaces: vi.fn().mockRejectedValue(new Error('forbidden')) }) + await waitFor(() => expect(screen.getByText('Cluster request failed')).toBeTruthy()) + }) +}) + +describe('K8sDashboard — resource kind switching', () => { + it('renders deployments with health coloring and actions', async () => { + const { api } = setup() + await podsLoaded() + fireEvent.click(screen.getByText('Deployments')) + await waitFor(() => expect(screen.getByText('api')).toBeTruthy()) + expect(api.k8s.deployments).toHaveBeenCalled() + expect(screen.getByText('2/2')).toBeTruthy() + expect(screen.getByText('1/2')).toBeTruthy() + // restart rollout + fireEvent.click(screen.getAllByTitle('Restart rollout')[0]) + await waitFor(() => + expect(api.k8s.restartDeployment).toHaveBeenCalledWith('test-ctx', 'default', 'api', undefined), + ) + }) + + it('scales a deployment through the scale modal', async () => { + const { api } = setup() + await podsLoaded() + fireEvent.click(screen.getByText('Deployments')) + await waitFor(() => expect(screen.getByText('api')).toBeTruthy()) + fireEvent.click(screen.getAllByTitle('Scale')[0]) + expect(screen.getByText('Scale Deployment')).toBeTruthy() + // + / − and direct input + fireEvent.click(screen.getByText('Scale to 2').parentElement!.parentElement!.querySelectorAll('button')[1]) // plus? layout: minus, input, plus... + const input = screen.getByDisplayValue(/\d+/) as HTMLInputElement + fireEvent.change(input, { target: { value: '5' } }) + fireEvent.click(screen.getByText('Scale to 5')) + await waitFor(() => + expect(api.k8s.scaleDeployment).toHaveBeenCalledWith('test-ctx', 'default', 'api', 5, undefined), + ) + }) + + it('cancels the scale modal', async () => { + setup() + await podsLoaded() + fireEvent.click(screen.getByText('Deployments')) + await waitFor(() => expect(screen.getByText('api')).toBeTruthy()) + fireEvent.click(screen.getAllByTitle('Scale')[0]) + fireEvent.click(screen.getByText('Cancel')) + expect(screen.queryByText('Scale Deployment')).toBeNull() + }) + + it('renders statefulsets, daemonsets and replicasets', async () => { + setup() + await podsLoaded() + fireEvent.click(screen.getByText('StatefulSets')) + await waitFor(() => expect(screen.getByText('db-ss')).toBeTruthy()) + fireEvent.click(screen.getByText('DaemonSets')) + await waitFor(() => expect(screen.getByText('log-ds')).toBeTruthy()) + fireEvent.click(screen.getByText('ReplicaSets')) + await waitFor(() => expect(screen.getByText('api-rs')).toBeTruthy()) + }) + + it('renders jobs with dot colors for ok/active/failed and cronjobs', async () => { + setup() + await podsLoaded() + fireEvent.click(screen.getByText('Jobs')) + await waitFor(() => expect(screen.getByText('ok-job')).toBeTruthy()) + expect(screen.getByText('active-job')).toBeTruthy() + expect(screen.getByText('failed-job')).toBeTruthy() + fireEvent.click(screen.getByText('CronJobs')) + await waitFor(() => expect(screen.getByText('backup-cj')).toBeTruthy()) + expect(screen.getByText('0 0 * * *')).toBeTruthy() + expect(screen.getByText('—')).toBeTruthy() // null lastSchedule + }) + + it('renders services with type badges and starts a service port forward', async () => { + const { api } = setup() + await podsLoaded() + fireEvent.click(screen.getByText('Services')) + await waitFor(() => expect(screen.getByText('svc-web')).toBeTruthy()) + expect(screen.getByText('LoadBalancer')).toBeTruthy() + expect(screen.getByText('ClusterIP')).toBeTruthy() + fireEvent.click(screen.getByTitle('Port forward')) + await waitFor(() => + expect(api.k8s.servicePortForwardStart).toHaveBeenCalledWith('test-ctx', 'default', 'svc-web', 80, 0, undefined), + ) + await waitFor(() => expect(screen.getByText('Port Forwards')).toBeTruthy()) + expect(screen.getByText(/:50456 → svc-web:80/)).toBeTruthy() + }) + + it('renders ingresses, configmaps and secrets', async () => { + setup() + await podsLoaded() + fireEvent.click(screen.getByText('Ingresses')) + await waitFor(() => expect(screen.getByText('ing-a')).toBeTruthy()) + expect(screen.getByText('Pending')).toBeTruthy() // no address + fireEvent.click(screen.getByText('ConfigMaps')) + await waitFor(() => expect(screen.getByText('cm-app')).toBeTruthy()) + expect(screen.getByText('2 keys')).toBeTruthy() + fireEvent.click(screen.getByText('Secrets')) + await waitFor(() => expect(screen.getByText('sec-tls')).toBeTruthy()) + expect(screen.getByText('1 key')).toBeTruthy() + }) + + it('renders nodes (behind the collapsed Infrastructure section) and events', async () => { + const { api } = setup() + await podsLoaded() + fireEvent.click(screen.getByText('Infrastructure')) // collapsed by default + fireEvent.click(screen.getByText('Nodes')) + await waitFor(() => expect(screen.getByText('node-a')).toBeTruthy()) + expect(api.k8s.nodes).toHaveBeenCalledWith('test-ctx', undefined) + expect(screen.getByText('Ready')).toBeTruthy() + expect(screen.getByText('NotReady')).toBeTruthy() + expect(screen.getAllByText('2 nodes').length).toBeGreaterThan(0) // subtitle + status bar + fireEvent.click(screen.getByText('Events')) + await waitFor(() => expect(screen.getByText('BackOff')).toBeTruthy()) + expect(screen.getByText('Warning')).toBeTruthy() + expect(screen.getByText('restarting container')).toBeTruthy() + }) + + it('collapses and reopens the Workloads section', async () => { + setup() + await podsLoaded() + fireEvent.click(screen.getByText('Workloads')) + expect(screen.queryByText('Deployments')).toBeNull() + fireEvent.click(screen.getByText('Workloads')) + expect(screen.getByText('Deployments')).toBeTruthy() + }) +}) + +describe('K8sDashboard — pod actions', () => { + it('opens logs and exec modals for a pod', async () => { + setup() + await podsLoaded() + fireEvent.click(screen.getAllByTitle('View Logs')[0]) + expect(screen.getByText('stub-logs:web-1')).toBeTruthy() + fireEvent.click(screen.getByText('close-logs')) + expect(screen.queryByText('stub-logs:web-1')).toBeNull() + fireEvent.click(screen.getAllByTitle('Shell')[0]) + expect(screen.getByText('stub-exec:web-1')).toBeTruthy() + }) + + it('opens the resource detail modal when a row is clicked', async () => { + setup() + await podsLoaded() + fireEvent.click(screen.getByText('web-1')) + expect(screen.getByText('stub-detail:pod:web-1')).toBeTruthy() + }) + + it('deletes a pod after confirming the modal', async () => { + const { api } = setup() + await podsLoaded() + fireEvent.click(screen.getAllByTitle('Delete')[0]) + expect(screen.getByText('Delete Pod')).toBeTruthy() + fireEvent.click(screen.getByText('Delete')) + await waitFor(() => + expect(api.k8s.deletePod).toHaveBeenCalledWith('test-ctx', 'default', 'web-1', undefined), + ) + await waitFor(() => expect(screen.queryByText('web-1')).toBeNull()) + }) + + it('cancels pod deletion', async () => { + const { api } = setup() + await podsLoaded() + fireEvent.click(screen.getAllByTitle('Delete')[0]) + fireEvent.click(screen.getByText('Cancel')) + expect(api.k8s.deletePod).not.toHaveBeenCalled() + expect(screen.getByText('web-1')).toBeTruthy() + }) + + it('starts a pod port forward via the prompt and stops it', async () => { + vi.stubGlobal('prompt', vi.fn(() => '8080')) + const { api } = setup() + await podsLoaded() + fireEvent.click(screen.getAllByTitle('Port forward')[0]) + await waitFor(() => + expect(api.k8s.portForwardStart).toHaveBeenCalledWith('test-ctx', 'default', 'web-1', 8080, 0, undefined), + ) + await waitFor(() => expect(screen.getByText(/:50123 → web-1:8080/)).toBeTruthy()) + expect(screen.getByText('1 port forward active')).toBeTruthy() + fireEvent.click(screen.getByTitle('Stop')) + await waitFor(() => expect(api.k8s.portForwardStop).toHaveBeenCalledWith('pf-1')) + await waitFor(() => expect(screen.queryByText('Port Forwards')).toBeNull()) + vi.unstubAllGlobals() + }) + + it('ignores invalid port forward input', async () => { + vi.stubGlobal('prompt', vi.fn(() => 'not-a-port')) + const { api } = setup() + await podsLoaded() + fireEvent.click(screen.getAllByTitle('Port forward')[0]) + expect(api.k8s.portForwardStart).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) + + it('rehydrates port forwards for this context from the main process', async () => { + setup({ + portForwardList: vi.fn().mockResolvedValue([ + { id: 'pf-a', context: 'test-ctx', localPort: 40001, podName: 'web-1', targetPort: 80 }, + { id: 'pf-b', context: 'other-ctx', localPort: 40002, podName: 'zzz', targetPort: 81 }, + ]), + }) + await podsLoaded() + await waitFor(() => expect(screen.getByText(/:40001 → web-1:80/)).toBeTruthy()) + expect(screen.queryByText(/:40002/)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/K8s/__tests__/ResourceDetailModal.test.tsx b/src/renderer/src/components/K8s/__tests__/ResourceDetailModal.test.tsx new file mode 100644 index 0000000..a4eefe7 --- /dev/null +++ b/src/renderer/src/components/K8s/__tests__/ResourceDetailModal.test.tsx @@ -0,0 +1,149 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react' +import ResourceDetailModal from '../ResourceDetailModal' +import { installWindowApi } from '../../../__tests__/harness' + +/** Render the modal with the k8s IPC returning `raw`, and return the
 innerHTML. */
+async function renderColorized(raw: string) {
+  installWindowApi({ k8s: { resourceDetail: vi.fn().mockResolvedValue(raw) } })
+  const { container } = render(
+    ,
+  )
+  await waitFor(() => expect(container.querySelector('pre')).toBeTruthy())
+  return container.querySelector('pre')!.innerHTML
+}
+
+beforeEach(() => {
+  cleanup()
+})
+
+describe('ResourceDetailModal — colorize tokenizer', () => {
+  it('colors object keys (string followed by colon) purple, including the colon', async () => {
+    const html = await renderColorized('{"name": "web"}')
+    expect(html).toContain('"name":')
+  })
+
+  it('colors plain string values green', async () => {
+    const html = await renderColorized('{"kind": "Pod"}')
+    expect(html).toContain('"Pod"')
+  })
+
+  it('handles escaped quotes inside strings as a single token', async () => {
+    const html = await renderColorized('{"msg": "say \\"hi\\" now"}')
+    expect(html).toContain('"say \\"hi\\" now"')
+  })
+
+  it('treats a string with escapes followed by a colon as a key', async () => {
+    const html = await renderColorized('{"a\\"b": 1}')
+    expect(html).toContain('"a\\"b":')
+  })
+
+  it('colors integer, negative, decimal and exponent numbers amber', async () => {
+    const html = await renderColorized('[42, -3.14, 1e5, 2.5E-3, -7e+2]')
+    for (const n of ['42', '-3.14', '1e5', '2.5E-3', '-7e+2']) {
+      expect(html).toContain(`${n}`)
+    }
+  })
+
+  it('colors true/false cyan and null red', async () => {
+    const html = await renderColorized('{"a": true, "b": false, "c": null}')
+    expect(html).toContain('true')
+    expect(html).toContain('false')
+    expect(html).toContain('null')
+  })
+
+  it('does not colorize keywords glued to trailing word characters (nullable)', async () => {
+    const html = await renderColorized('nullable')
+    expect(html).not.toContain('')
+    expect(html).toContain('nullable')
+  })
+
+  it('does not colorize keywords preceded by a word character (anull, xtrue)', async () => {
+    const html = await renderColorized('anull xtrue')
+    expect(html).not.toContain('color:#EF4444')
+    expect(html).not.toContain('color:#06b6d4')
+  })
+
+  it('colorizes keywords bounded by punctuation', async () => {
+    const html = await renderColorized('[true,null]')
+    expect(html).toContain('true')
+    expect(html).toContain('null')
+  })
+
+  it('does not treat keywords inside strings as bare keywords', async () => {
+    const html = await renderColorized('{"v": "null"}')
+    // the quoted "null" is a string token, not the red null keyword
+    expect(html).toContain('"null"')
+    expect(html).not.toContain('null')
+  })
+
+  it('leaves structural characters uncolored', async () => {
+    const html = await renderColorized('{"a": [1]}')
+    // braces/brackets are emitted outside spans
+    expect(html).toMatch(/\{.*\}/s)
+    expect(html).toContain('[')
+    expect(html).toContain(']')
+  })
+})
+
+describe('ResourceDetailModal — component behavior', () => {
+  it('shows the kind label and name in the header', async () => {
+    installWindowApi({ k8s: { resourceDetail: vi.fn().mockResolvedValue('{}') } })
+    render(
+      ,
+    )
+    await waitFor(() => expect(screen.getByText('Deployment — api')).toBeTruthy())
+    expect(screen.getByText('ns1')).toBeTruthy()
+  })
+
+  it('falls back to the raw kind when unmapped', async () => {
+    installWindowApi({ k8s: { resourceDetail: vi.fn().mockResolvedValue('{}') } })
+    render(
+      ,
+    )
+    await waitFor(() => expect(screen.getByText('mystery — x')).toBeTruthy())
+  })
+
+  it('renders an error message when the IPC call fails', async () => {
+    installWindowApi({ k8s: { resourceDetail: vi.fn().mockRejectedValue(new Error('boom')) } })
+    const { container } = render(
+      ,
+    )
+    await waitFor(() => expect(container.querySelector('pre')?.textContent).toContain('Error: boom'))
+  })
+
+  it('copies the JSON to the clipboard', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
+    installWindowApi({ k8s: { resourceDetail: vi.fn().mockResolvedValue('{"a":1}') } })
+    render()
+    await waitFor(() => expect(screen.getByTitle('Copy JSON')).toBeTruthy())
+    fireEvent.click(screen.getByTitle('Copy JSON'))
+    await waitFor(() => expect(writeText).toHaveBeenCalledWith('{"a":1}'))
+  })
+
+  it('closes when clicking the backdrop but not the panel', async () => {
+    const onClose = vi.fn()
+    installWindowApi({ k8s: { resourceDetail: vi.fn().mockResolvedValue('{}') } })
+    const { container } = render(
+      ,
+    )
+    await waitFor(() => expect(container.querySelector('pre')).toBeTruthy())
+    const panel = container.querySelector('pre')!
+    fireEvent.mouseDown(panel)
+    expect(onClose).not.toHaveBeenCalled()
+    const backdrop = container.firstElementChild as HTMLElement
+    fireEvent.mouseDown(backdrop)
+    expect(onClose).toHaveBeenCalledTimes(1)
+  })
+
+  it('refreshes via the refresh button', async () => {
+    const resourceDetail = vi.fn().mockResolvedValue('{"a":1}')
+    installWindowApi({ k8s: { resourceDetail } })
+    render()
+    await waitFor(() => expect(screen.getByTitle('Refresh')).toBeTruthy())
+    fireEvent.click(screen.getByTitle('Refresh'))
+    await waitFor(() => expect(resourceDetail).toHaveBeenCalledTimes(2))
+  })
+})
diff --git a/src/renderer/src/components/RDP/RdpView.tsx b/src/renderer/src/components/RDP/RdpView.tsx
index 6315938..f41f410 100644
--- a/src/renderer/src/components/RDP/RdpView.tsx
+++ b/src/renderer/src/components/RDP/RdpView.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
 import type { Tab } from '../../store'
 import { useAppStore } from '../../store'
 
diff --git a/src/renderer/src/components/RDP/__tests__/RdpView.test.tsx b/src/renderer/src/components/RDP/__tests__/RdpView.test.tsx
new file mode 100644
index 0000000..a599f61
--- /dev/null
+++ b/src/renderer/src/components/RDP/__tests__/RdpView.test.tsx
@@ -0,0 +1,181 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, waitFor, act, cleanup } from '@testing-library/react'
+import RdpView from '../RdpView'
+import { installWindowApi, seedStore, makeSession, makeTab, WindowApiMock } from '../../../__tests__/harness'
+
+// jsdom has no canvas: return a stub 2d context so blit() can run.
+const putImageData = vi.fn()
+const ctxStub = { putImageData } as unknown as CanvasRenderingContext2D
+
+// Minimal ImageData for environments where jsdom does not provide one.
+class FakeImageData {
+  data: Uint8ClampedArray
+  width: number
+  height: number
+  constructor(data: Uint8ClampedArray, width: number, height: number) {
+    this.data = data
+    this.width = width
+    this.height = height
+  }
+}
+
+type FrameCb = (id: string, width: number, height: number, pixels: Uint8Array) => void
+type CloseCb = (id: string, error?: string) => void
+
+function setup(opts: {
+  session?: ReturnType | null
+  password?: string
+  connectImpl?: (...args: any[]) => Promise
+} = {}) {
+  const session = opts.session === undefined ? makeSession({ type: 'rdp', port: 3389 }) : opts.session
+  let frameCb: FrameCb = () => {}
+  let closeCb: CloseCb = () => {}
+  const api = installWindowApi({
+    sessions: { getCredentials: vi.fn().mockResolvedValue({ password: opts.password ?? 'secret' }) },
+    rdp: {
+      connect: vi.fn(opts.connectImpl ?? (() => Promise.resolve('rdp-1'))),
+      onFrame: vi.fn((cb: FrameCb) => { frameCb = cb; return () => {} }),
+      onClose: vi.fn((cb: CloseCb) => { closeCb = cb; return () => {} }),
+    },
+  })
+  seedStore({ sessions: session ? [session] : [] })
+  const tab = makeTab({ view: 'rdp', sessionId: session?.id ?? 'missing' })
+  const utils = render()
+  return { api, tab, session, getFrameCb: () => frameCb, getCloseCb: () => closeCb, ...utils }
+}
+
+async function flushConnect(api: WindowApiMock) {
+  await waitFor(() => expect(api.rdp.connect).toHaveBeenCalled())
+  await act(async () => {}) // let rdpId assignment settle
+}
+
+beforeEach(() => {
+  cleanup()
+  putImageData.mockClear()
+  vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { cb(0); return 1 })
+  vi.stubGlobal('cancelAnimationFrame', () => {})
+  if (!(globalThis as any).ImageData) vi.stubGlobal('ImageData', FakeImageData)
+  HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(ctxStub) as any
+})
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+})
+
+describe('RdpView', () => {
+  it('errors when the tab has no associated session', () => {
+    setup({ session: null })
+    expect(screen.getByText('RDP connection failed')).toBeTruthy()
+    expect(screen.getByText('No connection associated with this tab')).toBeTruthy()
+  })
+
+  it('errors when no password is saved (no connect attempt)', async () => {
+    const { api } = setup({ password: '' })
+    await waitFor(() =>
+      expect(screen.getByText('No password saved for this connection. Edit it and add one.')).toBeTruthy(),
+    )
+    expect(api.rdp.connect).not.toHaveBeenCalled()
+  })
+
+  it('connects with session credentials and fallback geometry', async () => {
+    const { api, session } = setup()
+    await flushConnect(api)
+    expect(api.rdp.connect).toHaveBeenCalledWith({
+      host: session!.host,
+      port: 3389,
+      username: session!.username,
+      password: 'secret',
+      width: 1280, // pane not laid out in jsdom → fallback
+      height: 800,
+    })
+    expect(screen.getByText('Connecting to RDP host…')).toBeTruthy()
+  })
+
+  it('falls back to port 3389 when the session has no port', async () => {
+    const { api } = setup({ session: makeSession({ type: 'rdp', port: 0 }) })
+    await flushConnect(api)
+    expect(api.rdp.connect.mock.calls[0][0].port).toBe(3389)
+  })
+
+  it('paints incoming frames to the canvas and flips to connected', async () => {
+    const { api, getFrameCb, container } = setup()
+    await flushConnect(api)
+    const pixels = new Uint8Array(2 * 2 * 4)
+    act(() => { getFrameCb()('rdp-1', 2, 2, pixels) })
+    expect(putImageData).toHaveBeenCalledTimes(1)
+    const canvas = container.querySelector('canvas')!
+    expect(canvas.width).toBe(2)
+    expect(canvas.height).toBe(2)
+    expect(canvas.style.display).toBe('block')
+    expect(screen.queryByText('Connecting to RDP host…')).toBeNull()
+  })
+
+  it('ignores frames for other rdp session ids', async () => {
+    const { api, getFrameCb } = setup()
+    await flushConnect(api)
+    act(() => { getFrameCb()('other-id', 2, 2, new Uint8Array(16)) })
+    expect(putImageData).not.toHaveBeenCalled()
+    expect(screen.getByText('Connecting to RDP host…')).toBeTruthy()
+  })
+
+  it('coalesces multiple frames per animation frame (latest wins)', async () => {
+    let rafCb: FrameRequestCallback | null = null
+    vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { rafCb = cb; return 1 })
+    const { api, getFrameCb, container } = setup()
+    await flushConnect(api)
+    act(() => {
+      getFrameCb()('rdp-1', 2, 2, new Uint8Array(16))
+      getFrameCb()('rdp-1', 4, 4, new Uint8Array(64))
+    })
+    act(() => { rafCb!(0) })
+    expect(putImageData).toHaveBeenCalledTimes(1)
+    expect(container.querySelector('canvas')!.width).toBe(4)
+  })
+
+  it('shows an error when the sidecar closes with an error', async () => {
+    const { api, getCloseCb } = setup()
+    await flushConnect(api)
+    act(() => { getCloseCb()('rdp-1', 'NLA sign-in failed') })
+    expect(screen.getByText('RDP connection failed')).toBeTruthy()
+    expect(screen.getByText('NLA sign-in failed')).toBeTruthy()
+  })
+
+  it('shows session ended on a clean close', async () => {
+    const { api, getCloseCb } = setup()
+    await flushConnect(api)
+    act(() => { getCloseCb()('rdp-1') })
+    expect(screen.getByText('RDP session ended')).toBeTruthy()
+  })
+
+  it('ignores close events for other ids', async () => {
+    const { api, getCloseCb } = setup()
+    await flushConnect(api)
+    act(() => { getCloseCb()('someone-else', 'boom') })
+    expect(screen.getByText('Connecting to RDP host…')).toBeTruthy()
+  })
+
+  it('surfaces connect failures', async () => {
+    setup({ connectImpl: () => Promise.reject(new Error('sidecar missing')) })
+    await waitFor(() => expect(screen.getByText('RDP connection failed')).toBeTruthy())
+    expect(screen.getByText('sidecar missing')).toBeTruthy()
+  })
+
+  it('disconnects the session on unmount', async () => {
+    const { api, unmount } = setup()
+    await flushConnect(api)
+    unmount()
+    expect(api.rdp.disconnect).toHaveBeenCalledWith('rdp-1')
+  })
+
+  it('disconnects immediately when unmounted while connect is in flight', async () => {
+    let resolveConnect: (id: string) => void = () => {}
+    const { api, unmount } = setup({
+      connectImpl: () => new Promise(res => { resolveConnect = res }),
+    })
+    await waitFor(() => expect(api.rdp.connect).toHaveBeenCalled())
+    unmount()
+    await act(async () => { resolveConnect('late-id') })
+    await waitFor(() => expect(api.rdp.disconnect).toHaveBeenCalledWith('late-id'))
+  })
+})
diff --git a/src/renderer/src/components/Redis/RedisExplorer.tsx b/src/renderer/src/components/Redis/RedisExplorer.tsx
index 3aad112..d4a85ee 100644
--- a/src/renderer/src/components/Redis/RedisExplorer.tsx
+++ b/src/renderer/src/components/Redis/RedisExplorer.tsx
@@ -274,7 +274,10 @@ function KeyListPane({ keys, pattern, setPattern, loadingKeys, selectedKey, onQu
           keys.map(({ key }) => (
             
onSelect(key)} + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(key) } }} className="group flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors" style={{ background: selectedKey === key ? '#DC382D18' : 'transparent', diff --git a/src/renderer/src/components/Redis/__tests__/RedisExplorer.test.tsx b/src/renderer/src/components/Redis/__tests__/RedisExplorer.test.tsx new file mode 100644 index 0000000..1b8fa62 --- /dev/null +++ b/src/renderer/src/components/Redis/__tests__/RedisExplorer.test.tsx @@ -0,0 +1,171 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import RedisExplorer from '../RedisExplorer' +import { installWindowApi, seedStore, makeSession, makeTab } from '../../../__tests__/harness' + +const KEY_VALUES: Record = { + 'user:1': { type: 'string', value: 'hello world', ttl: -1 }, + 'user:profile': { type: 'hash', value: { name: 'sean', role: 'admin' }, ttl: 3600 }, + 'queue:jobs': { type: 'list', value: ['job-a', 'job-b'], ttl: -1 }, + 'scores': { type: 'zset', value: [{ member: 'a', score: 1 }], ttl: -1 }, +} + +function setup(overrides: Record = {}) { + const session = makeSession({ label: 'Cache', type: 'redis', port: 6379, redisDb: 2 }) + const tab = makeTab({ view: 'redis', sessionId: session.id }) + seedStore({ sessions: [session], tabs: [tab], activeTabId: tab.id }) + const api = installWindowApi({ + redis: { + connect: vi.fn().mockResolvedValue('redis-1'), + keys: vi.fn().mockResolvedValue(Object.keys(KEY_VALUES)), + get: vi.fn().mockImplementation(async (_id: string, key: string) => KEY_VALUES[key]), + del: vi.fn().mockResolvedValue(undefined), + command: vi.fn().mockResolvedValue(42), + disconnect: vi.fn().mockResolvedValue(undefined), + }, + ...overrides, + }) + const utils = render() + return { session, tab, api, ...utils } +} + +describe('RedisExplorer', () => { + beforeEach(() => { + seedStore({ sessions: [], tabs: [], activeTabId: null }) + }) + + it('connects with stored credentials and lists sorted keys', async () => { + const { api, session, tab } = setup() + expect(await screen.findByText('4 keys')).toBeTruthy() + expect(api.sessions.getCredentials).toHaveBeenCalledWith(tab.sessionId) + expect(api.redis.connect).toHaveBeenCalledWith({ + host: session.host, + port: 6379, + password: 'pw', + db: 2, + }) + expect(screen.getByText('Redis · DB 2')).toBeTruthy() + expect(screen.getByText('Cache')).toBeTruthy() + expect(screen.getByText('user:1')).toBeTruthy() + expect(screen.getByText('Select a key to view its value')).toBeTruthy() + }) + + it('selects a string key on click and shows type, TTL and value', async () => { + const { api } = setup() + fireEvent.click(await screen.findByText('user:1')) + expect(await screen.findByText('hello world')).toBeTruthy() + expect(api.redis.get).toHaveBeenCalledWith('redis-1', 'user:1') + expect(screen.getByText('string')).toBeTruthy() + expect(screen.getByText('TTL: No expiry')).toBeTruthy() + // Close the value pane + const header = screen.getAllByText('user:1').find(el => el.classList.contains('font-medium'))! + fireEvent.click(header.closest('div')!.parentElement!.querySelector('button:last-of-type') as HTMLElement) + expect(await screen.findByText('Select a key to view its value')).toBeTruthy() + }) + + it('selects keys via Enter and Space keyboard activation', async () => { + const { api } = setup() + const row = (await screen.findByText('user:profile')).closest('[role="button"]') as HTMLElement + fireEvent.keyDown(row, { key: 'Enter' }) + expect(await screen.findByText('sean')).toBeTruthy() + expect(screen.getByText('TTL: 3600s')).toBeTruthy() + expect(screen.getByText('name')).toBeTruthy() + + const listRow = screen.getByText('queue:jobs').closest('[role="button"]') as HTMLElement + fireEvent.keyDown(listRow, { key: ' ' }) + expect(await screen.findByText('job-a')).toBeTruthy() + expect(screen.getByText('job-b')).toBeTruthy() + expect(api.redis.get).toHaveBeenCalledTimes(2) + }) + + it('renders unknown types as JSON', async () => { + setup() + fireEvent.click(await screen.findByText('scores')) + expect(await screen.findByText('zset')).toBeTruthy() + expect(screen.getByText(/"member": "a"/)).toBeTruthy() + }) + + it('deletes a key and clears the value pane if it was selected', async () => { + const { api } = setup() + fireEvent.click(await screen.findByText('user:1')) + await screen.findByText('hello world') + const row = screen.getAllByText('user:1')[0].closest('[role="button"]') as HTMLElement + fireEvent.click(row.querySelector('button') as HTMLElement) + await waitFor(() => expect(api.redis.del).toHaveBeenCalledWith('redis-1', 'user:1')) + expect(screen.queryByText('hello world')).toBeNull() + expect(await screen.findByText('3 keys')).toBeTruthy() + }) + + it('re-queries keys with the search pattern on Enter', async () => { + const { api } = setup() + const input = await screen.findByPlaceholderText('Pattern (e.g. user:*)') + api.redis.keys.mockResolvedValueOnce(['user:1', 'user:profile']) + fireEvent.change(input, { target: { value: 'user:*' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => expect(api.redis.keys).toHaveBeenLastCalledWith('redis-1', 'user:*')) + expect(await screen.findByText('2 keys')).toBeTruthy() + expect(screen.queryByText('queue:jobs')).toBeNull() + }) + + it('runs CLI commands and records results and errors', async () => { + const { api } = setup() + await screen.findByText('4 keys') + fireEvent.click(screen.getByText('CLI')) + expect(screen.getByText(/Type a Redis command below/)).toBeTruthy() + + const input = screen.getByPlaceholderText('Enter command…') + fireEvent.change(input, { target: { value: 'DBSIZE' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(await screen.findByText('DBSIZE')).toBeTruthy() + expect(screen.getByText('42')).toBeTruthy() + expect(api.redis.command).toHaveBeenCalledWith('redis-1', 'DBSIZE') + + api.redis.command.mockRejectedValueOnce(new Error('unknown command')) + fireEvent.change(input, { target: { value: 'BOGUS' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(await screen.findByText('ERR: unknown command')).toBeTruthy() + + // Back to the browser tab + fireEvent.click(screen.getByText('Key Browser')) + expect(screen.getByText('user:1')).toBeTruthy() + }) + + it('renders the retry screen when the initial connection fails, then recovers', async () => { + const session = makeSession({ label: 'Broken', type: 'redis' }) + const tab = makeTab({ view: 'redis', sessionId: session.id }) + seedStore({ sessions: [session], tabs: [tab], activeTabId: tab.id }) + const api = installWindowApi({ + redis: { + connect: vi.fn().mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue('redis-2'), + keys: vi.fn().mockResolvedValue(['a']), + get: vi.fn().mockResolvedValue({ type: 'string', value: 'x', ttl: -1 }), + }, + }) + render() + expect(await screen.findByText('ECONNREFUSED')).toBeTruthy() + fireEvent.click(screen.getByText('Retry')) + expect(await screen.findByText('1 keys')).toBeTruthy() + expect(api.redis.connect).toHaveBeenCalledTimes(2) + }) + + it('maps locked-keychain credential errors to a friendly message', async () => { + const session = makeSession({ type: 'redis' }) + const tab = makeTab({ view: 'redis', sessionId: session.id }) + seedStore({ sessions: [session], tabs: [tab], activeTabId: tab.id }) + installWindowApi({ + sessions: { + getCredentials: vi.fn().mockRejectedValue(new Error('credential store is locked')), + }, + }) + render() + expect(await screen.findByText('App is locked — unlock noxed to reconnect')).toBeTruthy() + }) + + it('disconnects the client on unmount', async () => { + const { api, unmount } = setup() + await screen.findByText('4 keys') + unmount() + await waitFor(() => expect(api.redis.disconnect).toHaveBeenCalledWith('redis-1')) + }) +}) diff --git a/src/renderer/src/components/Runner/RunnerView.tsx b/src/renderer/src/components/Runner/RunnerView.tsx index 7b0675e..1d4f33f 100644 --- a/src/renderer/src/components/Runner/RunnerView.tsx +++ b/src/renderer/src/components/Runner/RunnerView.tsx @@ -141,7 +141,10 @@ export default function RunnerView() { border: `2px solid ${checked ? '#3B5CCC' : 'var(--nox-border)'}`, background: checked ? '#3B5CCC' : 'transparent', }} + role="button" + tabIndex={0} onClick={() => toggle(s.id)} + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(s.id) } }} > {checked && }
diff --git a/src/renderer/src/components/Runner/__tests__/RunnerView.test.tsx b/src/renderer/src/components/Runner/__tests__/RunnerView.test.tsx new file mode 100644 index 0000000..762c028 --- /dev/null +++ b/src/renderer/src/components/Runner/__tests__/RunnerView.test.tsx @@ -0,0 +1,176 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import RunnerView from '../RunnerView' +import { installWindowApi, seedStore, makeSession } from '../../../__tests__/harness' +import { useAppStore } from '../../../store' + +type OutputCb = (runId: string, sessionId: string, data: string) => void +type DoneCb = (runId: string, sessionId: string, exitCode: number | null, error: string | null) => void + +function setup(runResult: 'ok' | 'fail' = 'ok') { + const host1 = makeSession({ label: 'web-1', type: 'ssh' }) + const host2 = makeSession({ label: 'web-2', type: 'ssh' }) + const redis = makeSession({ label: 'cache', type: 'redis' }) + seedStore({ sessions: [host1, host2, redis], notifications: [] }) + + let outputCb: OutputCb = () => {} + let doneCb: DoneCb = () => {} + const api = installWindowApi({ + runner: { + run: + runResult === 'ok' + ? vi.fn().mockResolvedValue('run-1') + : vi.fn().mockRejectedValue(new Error('spawn failed')), + cancel: vi.fn().mockResolvedValue(undefined), + onOutput: vi.fn().mockImplementation((cb: OutputCb) => { + outputCb = cb + return () => {} + }), + onDone: vi.fn().mockImplementation((cb: DoneCb) => { + doneCb = cb + return () => {} + }), + }, + }) + render() + return { host1, host2, redis, api, emitOutput: (...a: Parameters) => outputCb(...a), emitDone: (...a: Parameters) => doneCb(...a) } +} + +function checkboxFor(label: string): HTMLElement { + return screen.getByText(label).closest('label')!.querySelector('[role="button"]') as HTMLElement +} + +async function startRun(host1: ReturnType, api: any, command = 'uptime') { + fireEvent.click(checkboxFor(host1.label)) + fireEvent.change(screen.getByPlaceholderText(/runs on every selected host/), { target: { value: command } }) + fireEvent.click(screen.getByText('Run')) + await waitFor(() => expect(api.runner.run).toHaveBeenCalledWith([host1.id], command)) +} + +describe('RunnerView', () => { + beforeEach(() => { + seedStore({ sessions: [], notifications: [] }) + }) + + it('lists only SSH hosts and shows the empty state', () => { + setup() + expect(screen.getByText('Hosts (0/2)')).toBeTruthy() + expect(screen.getByText('web-1')).toBeTruthy() + expect(screen.getByText('web-2')).toBeTruthy() + expect(screen.queryByText('cache')).toBeNull() + expect(screen.getByText('Run a command across your fleet')).toBeTruthy() + }) + + it('toggles hosts via click and Enter/Space keyboard activation', () => { + setup() + const box = checkboxFor('web-1') + fireEvent.click(box) + expect(screen.getByText('Hosts (1/2)')).toBeTruthy() + fireEvent.keyDown(box, { key: ' ' }) + expect(screen.getByText('Hosts (0/2)')).toBeTruthy() + fireEvent.keyDown(box, { key: 'Enter' }) + expect(screen.getByText('Hosts (1/2)')).toBeTruthy() + fireEvent.keyDown(box, { key: 'x' }) + expect(screen.getByText('Hosts (1/2)')).toBeTruthy() + }) + + it('selects all / none via the header toggle', () => { + setup() + fireEvent.click(screen.getByText('All')) + expect(screen.getByText('Hosts (2/2)')).toBeTruthy() + fireEvent.click(screen.getByText('None')) + expect(screen.getByText('Hosts (0/2)')).toBeTruthy() + }) + + it('runs a command and streams output/exit codes into result cards', async () => { + const { host1, host2, api, emitOutput, emitDone } = setup() + fireEvent.click(screen.getByText('All')) + fireEvent.change(screen.getByPlaceholderText(/runs on every selected host/), { target: { value: 'uptime' } }) + fireEvent.click(screen.getByText('Run')) + + await waitFor(() => expect(api.runner.run).toHaveBeenCalledWith([host1.id, host2.id], 'uptime')) + expect(screen.getAllByText('running').length).toBe(2) + + // Output for a stale run id is ignored + act(() => emitOutput('stale-run', host1.id, 'IGNORED')) + expect(screen.queryByText(/IGNORED/)).toBeNull() + + act(() => { + emitOutput('run-1', host1.id, 'load average: 0.1') + emitOutput('run-1', host1.id, '\nmore') + }) + expect(screen.getByText(/load average: 0\.1/)).toBeTruthy() + + act(() => emitDone('run-1', host1.id, 0, null)) + expect(screen.getByText('exit 0')).toBeTruthy() + expect(screen.getByText('Cancel')).toBeTruthy() // still running host2 + + act(() => emitDone('run-1', host2.id, 3, null)) + expect(screen.getByText('exit 3')).toBeTruthy() + // Everything finished — Run button returns + expect(screen.getByText('Run')).toBeTruthy() + + // Stale done is ignored too + act(() => emitDone('other', host1.id, 1, null)) + expect(screen.getByText('exit 0')).toBeTruthy() + }) + + it('marks a host as error when done reports an error message', async () => { + const { host1, api, emitDone } = setup() + await startRun(host1, api) + act(() => emitDone('run-1', host1.id, null, 'ssh: connection refused')) + expect(screen.getByText('error')).toBeTruthy() + expect(screen.getByText('ssh: connection refused')).toBeTruthy() + }) + + it('supports Cmd+Enter to run and collapsing a result card', async () => { + const { host1, api, emitOutput } = setup() + fireEvent.click(checkboxFor('web-1')) + const input = screen.getByPlaceholderText(/runs on every selected host/) + fireEvent.change(input, { target: { value: 'whoami' } }) + fireEvent.keyDown(input, { key: 'Enter', metaKey: true }) + await waitFor(() => expect(api.runner.run).toHaveBeenCalledWith([host1.id], 'whoami')) + + act(() => emitOutput('run-1', host1.id, 'root')) + expect(screen.getByText('root')).toBeTruthy() + // Collapse the card header — the pre disappears. The sidebar label is not + // inside a