Skip to content

Commit d3fe284

Browse files
feat: v0.12.0 — UI polish, sidebar sorting, and connection health
- Sidebar: connections with open sessions always float to top; default sort changed to Last Connected; divider between active and recent - Sidebar empty state: added Import SSH Config button alongside Import Connections - Connection Health Monitor: ping dashboard on HomeScreen with live latency badges - Terminal search: result counter (n/total), focus-visible ring on input, close button title/aria-label - ThemePanel: font name truncation, +/- buttons now have title and aria-label, close button aria-label - TabBar: close tab button gets title/aria-label/cursor-pointer; context menu items get transition-colors/cursor-pointer - TerminalTab toolbar: hostname truncates instead of overflowing; Log/Search buttons use cursor-pointer - globals.css: Firefox scrollbar support via scrollbar-width/scrollbar-color - IPC: single-listener fanout pattern eliminates MaxListenersExceededWarning - ThemePanel right sidebar is now resizable by dragging its left edge
1 parent 6544825 commit d3fe284

9 files changed

Lines changed: 339 additions & 152 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "netcopilot",
3-
"version": "0.11.11",
3+
"version": "0.12.0",
44
"description": "NetCopilot – AI-powered SSH/Telnet terminal for network engineers",
55
"main": "./out/main/index.js",
66
"author": {

src/preload/index.ts

Lines changed: 50 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,38 @@
11
import { contextBridge, ipcRenderer } from 'electron'
22

3+
// ── Single-listener fanout ────────────────────────────────────────────────────
4+
// Instead of registering one ipcRenderer listener per session (causing
5+
// MaxListenersExceededWarning when >10 sessions are open), we keep exactly ONE
6+
// ipcRenderer listener per channel and fan out to all registered callbacks.
7+
function makeFanout<T extends unknown[]>(channel: string): (cb: (...args: T) => void) => () => void {
8+
const handlers = new Set<(...args: T) => void>()
9+
ipcRenderer.on(channel, (_: unknown, ...args: unknown[]) => {
10+
handlers.forEach(cb => cb(...(args as T)))
11+
})
12+
return (cb) => {
13+
handlers.add(cb)
14+
return () => handlers.delete(cb)
15+
}
16+
}
17+
18+
const onSshData = makeFanout<[string, string]>('ssh:data')
19+
const onSshClosed = makeFanout<[string]>('ssh:closed')
20+
const onTelnetData = makeFanout<[string, string]>('telnet:data')
21+
const onTelnetClosed = makeFanout<[string]>('telnet:closed')
22+
const onSerialData = makeFanout<[string, string]>('serial:data')
23+
const onSerialClosed = makeFanout<[string]>('serial:closed')
24+
const onSerialError = makeFanout<[string, string]>('serial:error')
25+
const onSftpProgress = makeFanout<[string, string, number, number]>('sftp:progress')
26+
const onSftpClosed = makeFanout<[string]>('sftp:closed')
27+
const onAiChunk = makeFanout<[string]>('ai:chunk')
28+
const onAiDone = makeFanout<[{ inputTokens: number; outputTokens: number } | undefined]>('ai:done')
29+
const onAiToolCall = makeFanout<[{ id: string; command: string; reason: string; targetSession?: string }]>('ai:tool-call')
30+
const onAiError = makeFanout<[string]>('ai:error')
31+
const onAiPlan = makeFanout<[{ objective: string; steps: string[] }]>('ai:plan')
32+
const onWindowMaximized = makeFanout<[boolean]>('window:maximized-change')
33+
const onUpdaterAvailable = makeFanout<[{ version: string; releaseDate: string; releaseNotes: string | null }]>('updater:update-available')
34+
const onUpdaterError = makeFanout<[string]>('updater:error')
35+
336
const api = {
437
// Store
538
store: {
@@ -39,16 +72,8 @@ const api = {
3972
forwardStart: (payload: unknown) => ipcRenderer.invoke('ssh:forward-start', payload),
4073
forwardStop: (forwardId: string) => ipcRenderer.invoke('ssh:forward-stop', forwardId),
4174
forwardStopSession: (sessionId: string) => ipcRenderer.invoke('ssh:forward-stop-session', sessionId),
42-
onData: (cb: (sessionId: string, data: string) => void) => {
43-
const handler = (_: unknown, sessionId: string, data: string) => cb(sessionId, data)
44-
ipcRenderer.on('ssh:data', handler)
45-
return () => ipcRenderer.removeListener('ssh:data', handler)
46-
},
47-
onClosed: (cb: (sessionId: string) => void) => {
48-
const handler = (_: unknown, sessionId: string) => cb(sessionId)
49-
ipcRenderer.on('ssh:closed', handler)
50-
return () => ipcRenderer.removeListener('ssh:closed', handler)
51-
}
75+
onData: onSshData,
76+
onClosed: onSshClosed,
5277
},
5378

5479
// Telnet
@@ -58,16 +83,8 @@ const api = {
5883
resize: (sessionId: string, cols: number, rows: number) =>
5984
ipcRenderer.invoke('telnet:resize', sessionId, cols, rows),
6085
disconnect: (sessionId: string) => ipcRenderer.invoke('telnet:disconnect', sessionId),
61-
onData: (cb: (sessionId: string, data: string) => void) => {
62-
const handler = (_: unknown, sessionId: string, data: string) => cb(sessionId, data)
63-
ipcRenderer.on('telnet:data', handler)
64-
return () => ipcRenderer.removeListener('telnet:data', handler)
65-
},
66-
onClosed: (cb: (sessionId: string) => void) => {
67-
const handler = (_: unknown, sessionId: string) => cb(sessionId)
68-
ipcRenderer.on('telnet:closed', handler)
69-
return () => ipcRenderer.removeListener('telnet:closed', handler)
70-
}
86+
onData: onTelnetData,
87+
onClosed: onTelnetClosed,
7188
},
7289

7390
// Session Logging
@@ -112,27 +129,15 @@ const api = {
112129
maximize: () => ipcRenderer.invoke('window:maximize'),
113130
close: () => ipcRenderer.invoke('window:close'),
114131
isMaximized: () => ipcRenderer.invoke('window:is-maximized'),
115-
onMaximizedChange: (cb: (maximized: boolean) => void) => {
116-
const handler = (_: unknown, maximized: boolean) => cb(maximized)
117-
ipcRenderer.on('window:maximized-change', handler)
118-
return () => ipcRenderer.removeListener('window:maximized-change', handler)
119-
},
132+
onMaximizedChange: onWindowMaximized,
120133
},
121134

122135
// Auto-updater (check only — downloads open in browser)
123136
updater: {
124137
check: () => ipcRenderer.invoke('updater:check'),
125138
openRelease: (url: string) => ipcRenderer.invoke('updater:open-release', url),
126-
onUpdateAvailable: (cb: (info: { version: string; releaseDate: string; releaseNotes: string | null }) => void) => {
127-
const handler = (_: unknown, info: { version: string; releaseDate: string; releaseNotes: string | null }) => cb(info)
128-
ipcRenderer.on('updater:update-available', handler)
129-
return () => ipcRenderer.removeListener('updater:update-available', handler)
130-
},
131-
onError: (cb: (message: string) => void) => {
132-
const handler = (_: unknown, message: string) => cb(message)
133-
ipcRenderer.on('updater:error', handler)
134-
return () => ipcRenderer.removeListener('updater:error', handler)
135-
},
139+
onUpdateAvailable: onUpdaterAvailable,
140+
onError: onUpdaterError,
136141
},
137142

138143
// Serial
@@ -141,21 +146,9 @@ const api = {
141146
connect: (payload: unknown) => ipcRenderer.invoke('serial:connect', payload),
142147
send: (sessionId: string, data: string) => ipcRenderer.send('serial:send', sessionId, data),
143148
disconnect: (sessionId: string) => ipcRenderer.invoke('serial:disconnect', sessionId),
144-
onData: (cb: (sessionId: string, data: string) => void) => {
145-
const handler = (_: unknown, sessionId: string, data: string) => cb(sessionId, data)
146-
ipcRenderer.on('serial:data', handler)
147-
return () => ipcRenderer.removeListener('serial:data', handler)
148-
},
149-
onClosed: (cb: (sessionId: string) => void) => {
150-
const handler = (_: unknown, sessionId: string) => cb(sessionId)
151-
ipcRenderer.on('serial:closed', handler)
152-
return () => ipcRenderer.removeListener('serial:closed', handler)
153-
},
154-
onError: (cb: (sessionId: string, error: string) => void) => {
155-
const handler = (_: unknown, sessionId: string, error: string) => cb(sessionId, error)
156-
ipcRenderer.on('serial:error', handler)
157-
return () => ipcRenderer.removeListener('serial:error', handler)
158-
}
149+
onData: onSerialData,
150+
onClosed: onSerialClosed,
151+
onError: onSerialError,
159152
},
160153

161154
// Auth
@@ -196,17 +189,8 @@ const api = {
196189
rename: (sessionId: string, oldPath: string, newPath: string) => ipcRenderer.invoke('sftp:rename', sessionId, oldPath, newPath),
197190
mkdir: (sessionId: string, remotePath: string) => ipcRenderer.invoke('sftp:mkdir', sessionId, remotePath),
198191
disconnect: (sessionId: string) => ipcRenderer.invoke('sftp:disconnect', sessionId),
199-
onProgress: (cb: (sessionId: string, filePath: string, transferred: number, total: number) => void) => {
200-
const handler = (_: unknown, sessionId: string, filePath: string, transferred: number, total: number) =>
201-
cb(sessionId, filePath, transferred, total)
202-
ipcRenderer.on('sftp:progress', handler)
203-
return () => ipcRenderer.removeListener('sftp:progress', handler)
204-
},
205-
onClosed: (cb: (sessionId: string) => void) => {
206-
const handler = (_: unknown, sessionId: string) => cb(sessionId)
207-
ipcRenderer.on('sftp:closed', handler)
208-
return () => ipcRenderer.removeListener('sftp:closed', handler)
209-
},
192+
onProgress: onSftpProgress,
193+
onClosed: onSftpClosed,
210194
},
211195

212196
// AI Copilot
@@ -216,31 +200,11 @@ const api = {
216200
toolResult: (callId: string, output: string) => ipcRenderer.invoke('ai:tool-result', callId, output),
217201
resetBlacklist: () => ipcRenderer.invoke('ai:reset-blacklist'),
218202
exportMarkdown: (payload: unknown) => ipcRenderer.invoke('ai:export-markdown', payload),
219-
onChunk: (cb: (chunk: string) => void) => {
220-
const handler = (_: unknown, chunk: string) => cb(chunk)
221-
ipcRenderer.on('ai:chunk', handler)
222-
return () => ipcRenderer.removeListener('ai:chunk', handler)
223-
},
224-
onDone: (cb: (usage?: { inputTokens: number; outputTokens: number }) => void) => {
225-
const handler = (_: unknown, usage?: { inputTokens: number; outputTokens: number }) => cb(usage)
226-
ipcRenderer.on('ai:done', handler)
227-
return () => ipcRenderer.removeListener('ai:done', handler)
228-
},
229-
onToolCall: (cb: (call: { id: string; command: string; reason: string; targetSession?: string }) => void) => {
230-
const handler = (_: unknown, call: { id: string; command: string; reason: string; targetSession?: string }) => cb(call)
231-
ipcRenderer.on('ai:tool-call', handler)
232-
return () => ipcRenderer.removeListener('ai:tool-call', handler)
233-
},
234-
onError: (cb: (error: string) => void) => {
235-
const handler = (_: unknown, error: string) => cb(error)
236-
ipcRenderer.on('ai:error', handler)
237-
return () => ipcRenderer.removeListener('ai:error', handler)
238-
},
239-
onPlan: (cb: (plan: { objective: string; steps: string[] }) => void) => {
240-
const handler = (_: unknown, plan: { objective: string; steps: string[] }) => cb(plan)
241-
ipcRenderer.on('ai:plan', handler)
242-
return () => ipcRenderer.removeListener('ai:plan', handler)
243-
},
203+
onChunk: onAiChunk,
204+
onDone: onAiDone,
205+
onToolCall: onAiToolCall,
206+
onError: onAiError,
207+
onPlan: onAiPlan,
244208
},
245209

246210
connection: {

src/renderer/src/assets/globals.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@
5858
body {
5959
@apply bg-background text-foreground;
6060
}
61+
/* Firefox */
62+
* {
63+
scrollbar-width: thin;
64+
scrollbar-color: hsl(var(--border)) transparent;
65+
}
66+
/* WebKit (Chrome, Safari, Electron) */
6167
::-webkit-scrollbar {
6268
width: 4px;
6369
height: 4px;

src/renderer/src/components/home/HomeScreen.tsx

Lines changed: 117 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { useState, useMemo } from 'react'
1+
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
22
import {
33
Search, Plus, FolderPlus, Router, Server, Monitor, Usb,
44
ChevronRight, Zap, Terminal, ArrowRight,
5-
Layers, X, FolderInput, Trash2, ArrowUpDown, Filter
5+
Layers, X, FolderInput, Trash2, ArrowUpDown, Filter, Activity, RefreshCw
66
} from 'lucide-react'
77
import { useAppStore } from '../../store'
88
import { Connection, ConnectionGroup, DeviceType } from '../../types'
@@ -44,6 +44,13 @@ function getDeviceAccent(deviceType: DeviceType, protocol?: string): string {
4444
}
4545
}
4646

47+
// ── Types ─────────────────────────────────────────────────────────────────────
48+
interface PingState {
49+
alive: boolean
50+
latency?: number
51+
checking: boolean
52+
}
53+
4754
// ── Group Card ────────────────────────────────────────────────────────────────
4855
function GroupCard({ group, hostCount, connectedCount, onClick }: {
4956
group: ConnectionGroup
@@ -83,13 +90,42 @@ function GroupCard({ group, hostCount, connectedCount, onClick }: {
8390
)
8491
}
8592

93+
// ── Latency badge ─────────────────────────────────────────────────────────────
94+
function LatencyBadge({ ping }: { ping: PingState }) {
95+
if (ping.checking) {
96+
return (
97+
<span className="flex items-center gap-1 text-[10px] text-muted-foreground/50">
98+
<RefreshCw className="w-3 h-3 animate-spin" />
99+
</span>
100+
)
101+
}
102+
if (!ping.alive) {
103+
return (
104+
<span className="flex items-center gap-1 text-[10px] font-medium text-red-400/80">
105+
<span className="w-1.5 h-1.5 rounded-full bg-red-400/80" />
106+
offline
107+
</span>
108+
)
109+
}
110+
const ms = ping.latency ?? 0
111+
const color = ms < 50 ? 'text-emerald-500' : ms < 150 ? 'text-yellow-500' : 'text-orange-500'
112+
const dot = ms < 50 ? 'bg-emerald-500' : ms < 150 ? 'bg-yellow-500' : 'bg-orange-500'
113+
return (
114+
<span className={cn('flex items-center gap-1 text-[10px] font-medium tabular-nums', color)}>
115+
<span className={cn('w-1.5 h-1.5 rounded-full', dot)} />
116+
{ms}ms
117+
</span>
118+
)
119+
}
120+
86121
// ── Host Card ─────────────────────────────────────────────────────────────────
87-
function HostCard({ connection, isConnected, onConnect, isSelected, onSelect }: {
122+
function HostCard({ connection, isConnected, onConnect, isSelected, onSelect, ping }: {
88123
connection: Connection
89124
isConnected: boolean
90125
onConnect: () => void
91126
isSelected?: boolean
92127
onSelect?: (e: React.MouseEvent) => void
128+
ping?: PingState
93129
}) {
94130
const Icon = getDeviceIcon(connection.deviceType, connection.protocol)
95131
const accent = getDeviceAccent(connection.deviceType, connection.protocol)
@@ -142,13 +178,15 @@ function HostCard({ connection, isConnected, onConnect, isSelected, onSelect }:
142178
</p>
143179
</div>
144180

145-
{/* Right side — status */}
181+
{/* Right side — status / latency */}
146182
<div className="shrink-0 self-center">
147183
{isConnected ? (
148184
<span className="flex items-center gap-1.5 text-xs text-emerald-600 font-medium">
149185
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
150186
live
151187
</span>
188+
) : ping ? (
189+
<LatencyBadge ping={ping} />
152190
) : (
153191
<ArrowRight className="w-4 h-4 text-muted-foreground/20 group-hover:text-primary group-hover:translate-x-0.5 transition-all" />
154192
)}
@@ -178,9 +216,58 @@ export function HomeScreen(): JSX.Element {
178216
const [filterMenuOpen, setFilterMenuOpen] = useState(false)
179217
const [filterProtocol, setFilterProtocol] = useState<string | null>(null)
180218
const [filterStatus, setFilterStatus] = useState<'all' | 'connected' | 'disconnected'>('all')
219+
const [healthMode, setHealthMode] = useState(false)
220+
const [pingMap, setPingMap] = useState<Map<string, PingState>>(new Map())
221+
const pingAbortRef = useRef(false)
181222

182223
const selCount = selectedConnectionIds.size
183224

225+
const runHealthScan = useCallback(async (conns: Connection[]) => {
226+
const pingable = conns.filter(c => c.protocol !== 'serial')
227+
if (pingable.length === 0) return
228+
pingAbortRef.current = false
229+
230+
// mark all as checking
231+
setPingMap(prev => {
232+
const next = new Map(prev)
233+
pingable.forEach(c => next.set(c.id, { alive: false, checking: true }))
234+
return next
235+
})
236+
237+
// ping in batches of 5 to avoid flooding
238+
const BATCH = 5
239+
for (let i = 0; i < pingable.length; i += BATCH) {
240+
if (pingAbortRef.current) break
241+
const batch = pingable.slice(i, i + BATCH)
242+
await Promise.all(batch.map(async (c) => {
243+
try {
244+
const result = await window.api.connection.ping(c.host, c.port)
245+
if (!pingAbortRef.current) {
246+
setPingMap(prev => new Map(prev).set(c.id, { ...result, checking: false }))
247+
}
248+
} catch {
249+
if (!pingAbortRef.current) {
250+
setPingMap(prev => new Map(prev).set(c.id, { alive: false, checking: false }))
251+
}
252+
}
253+
}))
254+
}
255+
}, [])
256+
257+
// auto-scan when health mode is enabled, repeat every 60s
258+
useEffect(() => {
259+
if (!healthMode) {
260+
pingAbortRef.current = true
261+
return
262+
}
263+
runHealthScan(connections)
264+
const id = setInterval(() => runHealthScan(connections), 60_000)
265+
return () => {
266+
clearInterval(id)
267+
pingAbortRef.current = true
268+
}
269+
}, [healthMode, connections, runHealthScan])
270+
184271
const handleBulkDelete = async () => {
185272
if (!confirm(`Delete ${selCount} connection${selCount !== 1 ? 's' : ''}?`)) return
186273
for (const id of selectedConnectionIds) {
@@ -382,6 +469,31 @@ export function HomeScreen(): JSX.Element {
382469
</div>
383470

384471
{/* Actions */}
472+
{/* Health scan toggle */}
473+
<button
474+
onClick={() => setHealthMode(v => !v)}
475+
title={healthMode ? 'Stop health scan' : 'Scan host reachability'}
476+
className={cn(
477+
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-[13px] font-medium transition-all shrink-0 cursor-pointer',
478+
healthMode
479+
? 'bg-primary/10 border-primary/40 text-primary'
480+
: 'bg-card border-border text-muted-foreground hover:text-foreground hover:bg-accent'
481+
)}
482+
>
483+
<Activity className={cn('w-3.5 h-3.5', healthMode && 'animate-pulse')} />
484+
{healthMode ? 'Scanning' : 'Health'}
485+
</button>
486+
487+
{healthMode && (
488+
<button
489+
onClick={() => runHealthScan(connections)}
490+
title="Re-scan now"
491+
className="p-1.5 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-accent transition-colors cursor-pointer shrink-0"
492+
>
493+
<RefreshCw className="w-3.5 h-3.5" />
494+
</button>
495+
)}
496+
385497
<button
386498
onClick={() => setConnectionDialogOpen(true)}
387499
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-[13px] font-semibold hover:bg-primary/90 transition-colors shrink-0 cursor-pointer shadow-sm"
@@ -586,6 +698,7 @@ export function HomeScreen(): JSX.Element {
586698
onConnect={() => openSession(conn)}
587699
isSelected={selectedConnectionIds.has(conn.id)}
588700
onSelect={(e) => toggleSelectConnection(conn.id, e.ctrlKey || e.metaKey)}
701+
ping={healthMode ? pingMap.get(conn.id) : undefined}
589702
/>
590703
))}
591704
</div>

0 commit comments

Comments
 (0)