From 06fa4ca023dfa1fe5a42b44d2cff6340ea84550d Mon Sep 17 00:00:00 2001 From: "954801926@qq.com" Date: Sun, 6 Sep 2026 13:45:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(M3):=20SSH=20productization=20=E2=80=94=20?= =?UTF-8?q?Terminal/SSH=20split=20+=20Wave-style=20server=20monitoring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sidebar split into 本地终端 / SSH 服务器 sections + recent sessions quick reconnect - SSH tabs carry server badge (icon + green edge); local tabs unchanged - SSH session panels mount a collapsible live monitor panel (CPU/mem/disk/net/load/uptime) - src/main/sysinfo.ts polls remote via ssh2 exec (/proc + df), 3s interval, error-tolerant (3 consecutive failures auto-stop), auto-stops on session close - tests: sysinfo-e2e (loopback exec with canned /proc output) — ALL PASSED --- src/main/ipc.ts | 5 + src/main/pty.ts | 20 +- src/main/sysinfo.ts | 415 ++++++++++++++++++ src/preload/index.ts | 46 ++ .../src/connections/ConnectionSidebar.tsx | 94 +++- src/renderer/src/connections/connections.css | 133 ++++++ src/renderer/src/main.tsx | 29 ++ src/renderer/src/monitor/MonitorPanel.tsx | 161 +++++++ src/renderer/src/monitor/monitor.css | 215 +++++++++ src/renderer/src/workspace/TerminalPanel.tsx | 51 ++- src/renderer/src/workspace/TerminalTab.tsx | 20 +- src/renderer/src/workspace/Workspace.tsx | 52 ++- src/renderer/src/workspace/workspace.css | 43 ++ src/shared/api.ts | 57 ++- src/shared/commands.ts | 26 ++ src/shared/ipc.ts | 33 ++ src/shared/sftp.ts | 28 ++ src/shared/sysinfo.ts | 34 ++ tests/sysinfo-e2e.mjs | 200 +++++++++ 19 files changed, 1632 insertions(+), 30 deletions(-) create mode 100644 src/main/sysinfo.ts create mode 100644 src/renderer/src/monitor/MonitorPanel.tsx create mode 100644 src/renderer/src/monitor/monitor.css create mode 100644 src/shared/commands.ts create mode 100644 src/shared/sftp.ts create mode 100644 src/shared/sysinfo.ts create mode 100644 tests/sysinfo-e2e.mjs diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e10f3ed..39be13f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -5,6 +5,7 @@ import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../sh import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections' import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts' import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay } from './pty' +import { startPolling, stopPolling } from './sysinfo' import { registerSettingsIpc } from './settingsStore' import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore' import { KnownHostsStore, defaultKnownHostsPath } from './knownHosts' @@ -47,6 +48,10 @@ export function registerIpc(): void { ) ipcMain.on(Ipc.PTY_KILL, (_event, id: string) => killPty(id)) + // ---- sysinfo (M3: remote hardware monitoring, ssh sessions only) ---- + ipcMain.on(Ipc.SYSINFO_START, (_event, id: string) => startPolling(id)) + ipcMain.on(Ipc.SYSINFO_STOP, (_event, id: string) => stopPolling(id)) + // ---- ssh connections (bookmarks) ---- ipcMain.handle(Ipc.CONNECTIONS_LIST, (): SshConnection[] => connectionsStore.listConnections()) ipcMain.handle(Ipc.CONNECTIONS_SAVE, (_event, input: SshConnectionInput): SshConnection => diff --git a/src/main/pty.ts b/src/main/pty.ts index 99a6803..eddb893 100644 --- a/src/main/pty.ts +++ b/src/main/pty.ts @@ -6,6 +6,11 @@ import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../s import { broadcast } from './broadcast' import { connectSsh, type SshSessionHandle } from './ssh' import type { HostKeyCheckResult } from './knownHosts' +import { configureSysinfo, registerSysinfoClient, stopPolling } from './sysinfo' + +// Re-export so the session-layer loopback bundle (tests/*-e2e.mjs) can drive the +// M3 polling engine without importing src/main/sysinfo.ts separately. +export { startPolling, stopPolling } from './sysinfo' /** * Session routing table. A session is either a local pty or an established ssh @@ -64,6 +69,14 @@ let runtimeDeps: SessionRuntimeDeps | undefined /** Wire the real stores + renderer prompt. Called once during app startup. */ export function configureSessionRuntime(deps: SessionRuntimeDeps): void { runtimeDeps = deps + configureSysinfo({ + broadcast: (channel, ...args) => deps.broadcast(channel, ...args) + }) + registerSysinfoClient((id) => { + const session = sessions.get(id) + if (session?.kind === 'ssh') return session.ssh.client + return undefined + }) } function defaultShell(): string { @@ -156,6 +169,7 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin handle.stream.on('close', () => { try { + stopPolling(handle.id) sessions.delete(handle.id) deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: 0 }) } catch { @@ -190,6 +204,7 @@ export function resizePty(id: string, cols: number, rows: number): void { } export function killPty(id: string): void { + stopPolling(id) replayBuffers.delete(id) const session = sessions.get(id) if (!session) return @@ -215,7 +230,10 @@ export function killPty(id: string): void { } export function killAllPtys(): void { - for (const session of sessions.values()) { + for (const id of sessions.keys()) { + stopPolling(id) + const session = sessions.get(id) + if (!session) continue if (session.kind === 'ssh') { try { session.ssh.stream.close() diff --git a/src/main/sysinfo.ts b/src/main/sysinfo.ts new file mode 100644 index 0000000..e9d0cc1 --- /dev/null +++ b/src/main/sysinfo.ts @@ -0,0 +1,415 @@ +/** + * Remote server hardware monitoring (M3): ssh sessions only. + * + * Polls a remote via an ssh2 `exec` (a single command returns CPU / memory / + * load / uptime / disk / network in one transport), parses the cat'd /proc + * files, diffs successive samples to compute usage and throughput, and + * broadcasts SYSINFO_SAMPLE to every live window. SYSINFO_META (hostname + os) + * is emitted once per session. + * + * The ssh `Client` for a session is injected by pty.ts (registerSysinfoClient); + * the broadcast sink is injected by pty.ts at runtime configure time, so this + * module stays Electron-free and testable through the same session-layer bundle + * as the ssh harness. + */ + +import { Ipc } from '../shared/ipc' +import type { Client } from 'ssh2' +import type { SysinfoMeta, SysinfoSample } from '../shared/sysinfo' + +/** One exec returns stat+mem+load+uptime, then df, then net, then host/uname. */ +const COLLECT_CMD = + 'cat /proc/stat /proc/meminfo /proc/loadavg /proc/uptime 2>/dev/null; ' + + 'echo __DF__; df -kP 2>/dev/null; ' + + 'echo __NET__; cat /proc/net/dev 2>/dev/null; ' + + 'echo __HOST__; hostname 2>/dev/null; uname -sr 2>/dev/null' + +/** Failures this many in a row before auto-stopping the poll. */ +const MAX_CONSECUTIVE_FAILS = 3 + +/** File-system types produced by `df -kP` that are not real disks. */ +const FAKE_FS = new Set(['tmpfs', 'overlay', 'udev', 'devtmpfs', 'none', 'squashfs', 'shm']) +// Some df versions key the type column slightly differently; 'overlay'/'shm' +// are the common container hosts. sysfs/procfs never appear in df -kP. + +/** Mirrors main/broadcast.ts but injectable so the loopback harness can capture it. */ +interface SysinfoDeps { + broadcast(channel: string, ...args: unknown[]): void +} + +let deps: SysinfoDeps | undefined + +/** Resolve an ssh `Client` for a session id (injected by pty.ts). */ +type ClientProvider = (id: string) => Client | undefined +let clientProvider: ClientProvider | undefined + +/** Wire the broadcast sink. Called once during app startup (from pty.ts). */ +export function configureSysinfo(d: SysinfoDeps): void { + deps = d +} + +/** Register the session-id -> ssh client resolver (called by pty.ts). */ +export function registerSysinfoClient(provider: ClientProvider): void { + clientProvider = provider +} + +interface Prev { + /** cpu `idle`+`iowait` ticks from the previous sample */ + idle: number + /** total ticks from the previous sample */ + total: number + /** cumulative rx/tx bytes from the previous sample */ + rx: number + tx: number +} + +interface PollState { + id: string + intervalMs: number + stopped: boolean + consecutiveFails: number + prev?: Prev + /** most recent successful sample (kept for error frames) */ + lastSample?: SysinfoSample + prevAt: number + metaSent: boolean + timer: NodeJS.Timeout +} + +const polls = new Map() + +/** + * Start polling a session. Calling again for the same id stops the previous + * poll first (re-entrancy safe). + */ +export function startPolling(id: string, intervalMs = 3000): void { + stopPolling(id) + const state: PollState = { + id, + intervalMs, + stopped: false, + consecutiveFails: 0, + prev: undefined, + lastSample: undefined, + prevAt: 0, + metaSent: false, + timer: setTimeout(() => pollOnce(id, state), 0) + } + polls.set(id, state) +} + +/** Stop polling a session (no-op when not polling). Also run on session close. */ +export function stopPolling(id: string): void { + const state = polls.get(id) + if (!state) return + state.stopped = true + clearTimeout(state.timer) + polls.delete(id) +} + +function armNext(id: string, state: PollState): void { + if (state.stopped) return + state.timer = setTimeout(() => pollOnce(id, state), state.intervalMs) +} + +function pollOnce(id: string, state: PollState): void { + if (state.stopped) return + const client = clientProvider?.(id) + if (!client) { + handleError(id, state, 'SSH 会话不存在或已断开') + return + } + + try { + client.exec(COLLECT_CMD, (err: Error | undefined, stream) => { + if (state.stopped) return + if (err || !stream) { + handleError(id, state, err?.message || 'SSH exec 失败') + return + } + let out = '' + stream.on('data', (d: Buffer) => { + out += d.toString('utf8') + }) + const onEnd = (): void => { + if (state.stopped) return + try { + stream.close() + } catch { + // best effort + } + handleOutput(id, state, out) + } + stream.on('close', onEnd) + stream.on('error', () => onEnd()) + }) + } catch (err) { + handleError(id, state, (err as Error).message) + } +} + +function handleError(id: string, state: PollState, message: string): void { + if (state.stopped) return + state.consecutiveFails += 1 + const last = state.lastSample + const sample: SysinfoSample = last + ? { + ...last, + ts: Date.now(), + error: message + } + : { + ts: Date.now(), + cpu: { usage: 0, cores: 0, loadavg: [0, 0, 0] }, + mem: { totalMb: 0, usedMb: 0 }, + disks: [], + net: { rxKbs: 0, txKbs: 0 }, + uptimeSec: 0, + error: message + } + broadcastSample(id, sample) + + if (state.consecutiveFails >= MAX_CONSECUTIVE_FAILS) { + console.warn(`[sysinfo] session ${id} failed ${state.consecutiveFails} polls, stopping`) + stopPolling(id) + return + } + armNext(id, state) +} + +function handleOutput(id: string, state: PollState, out: string): void { + if (state.stopped) return + const now = Date.now() + try { + const parsed = parseOutput(out) + const sample: SysinfoSample = { + ts: now, + cpu: parsed.cpu, + mem: parsed.mem, + disks: parsed.disks, + net: parsed.net, + uptimeSec: parsed.uptimeSec + } + state.consecutiveFails = 0 + + // Diff against the previous sample when one exists and rates are computable. + if (state.prev && state.prevAt > 0) { + const dtSec = (now - state.prevAt) / 1000 + const totalDelta = parsed.cpuTotal - state.prev.total + const idleDelta = parsed.cpuIdle - state.prev.idle + if (totalDelta > 0) { + sample.cpu.usage = Math.max(0, Math.min(100, 100 * (1 - idleDelta / totalDelta))) + } + if (dtSec > 0) { + sample.net.rxKbs = Math.max(0, (parsed.netRx - state.prev.rx) / 1024 / dtSec) + sample.net.txKbs = Math.max(0, (parsed.netTx - state.prev.tx) / 1024 / dtSec) + } + } + state.prevAt = now + state.prev = { + idle: parsed.cpuIdle, + total: parsed.cpuTotal, + rx: parsed.netRx, + tx: parsed.netTx + } + state.lastSample = sample + broadcastSample(id, sample) + + if (!state.metaSent && (parsed.hostname || parsed.osInfo)) { + state.metaSent = true + broadcastMeta(id, { hostname: parsed.hostname, os: parsed.osInfo }) + } + } catch (err) { + handleError(id, state, `解析失败: ${(err as Error).message}`) + return + } + armNext(id, state) +} + +function broadcastSample(id: string, sample: SysinfoSample): void { + try { + deps?.broadcast(Ipc.SYSINFO_SAMPLE, { id, sample }) + } catch { + // never crash the event loop + } +} + +function broadcastMeta(id: string, meta: SysinfoMeta): void { + try { + deps?.broadcast(Ipc.SYSINFO_META, { id, meta }) + } catch { + // never crash the event loop + } +} + +// ---- parsing ------------------------------------------------------------------ + +interface Parsed { + cpu: SysinfoSample['cpu'] + cpuIdle: number + cpuTotal: number + mem: SysinfoSample['mem'] + disks: SysinfoSample['disks'] + net: SysinfoSample['net'] + netRx: number + netTx: number + uptimeSec: number + hostname: string + osInfo: string +} + +function parseOutput(raw: string): Parsed { + // Split into the four delimited regions. + const parts = raw.split(/__DF__|__NET__|__HOST__/) + const procBlob = parts[0] ?? '' + const dfBlob = parts[1] ?? '' + const netBlob = parts[2] ?? '' + const hostBlob = parts[3] ?? '' + + return { + ...parseProc(procBlob), + disks: parseDf(dfBlob), + ...parseNet(netBlob), + ...parseHost(hostBlob) + } +} + +function toNumber(s: string | undefined): number { + const n = Number(s) + return Number.isFinite(n) ? n : 0 +} + +function parseProc(blob: string): { cpu: SysinfoSample['cpu']; cpuIdle: number; cpuTotal: number; mem: SysinfoSample['mem']; uptimeSec: number } { + const lines = blob.split('\n') + let total = 0 + let idle = 0 + let cores = 0 + let user = 0 + const loadavg: [number, number, number] = [0, 0, 0] + let memTotal = 0 + let memAvailable = 0 + let uptimeSec = 0 + + for (const raw of lines) { + const line = raw.trimEnd() + if (line.startsWith('cpu')) { + if (line.startsWith('cpu ')) { + const f = line.split(/\s+/).slice(1).map(toNumber) + user = f[0] ?? 0 + const nice = f[1] ?? 0 + const system = f[2] ?? 0 + const idleTicks = f[3] ?? 0 + const iowait = f[4] ?? 0 + const irq = f[5] ?? 0 + const softirq = f[6] ?? 0 + const steal = f[7] ?? 0 + const guest = f[8] ?? 0 + const guestNice = f[9] ?? 0 + idle = idleTicks + iowait + total = + user + + nice + + system + + idle + + irq + + softirq + + steal + + guest + + guestNice + } else { + cores += 1 + } + } else if (line.startsWith('MemTotal:')) { + memTotal = toNumber(line.split(/\s+/)[1]) + } else if (line.startsWith('MemAvailable:')) { + memAvailable = toNumber(line.split(/\s+/)[1]) + } else if (line.includes('/')) { + // /proc/loadavg: "0.00 0.01 0.05 1/234 5678" + const loadMatch = /^\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)/.exec(line) + if (loadMatch) { + loadavg[0] = toNumber(loadMatch[1]) + loadavg[1] = toNumber(loadMatch[2]) + loadavg[2] = toNumber(loadMatch[3]) + } + } else if (/^\d/.test(line) && line.split(/\s+/).length === 2) { + // /proc/uptime: "12345.67 9876.54" + uptimeSec = toNumber(line.split(/\s+/)[0]) + } + } + + return { + cpu: { + usage: 0, + cores, + loadavg + }, + cpuIdle: idle, + cpuTotal: total, + mem: { + // meminfo columns are kB; sample schema is MiB. + totalMb: Math.round(memTotal / 1024), + usedMb: memAvailable > 0 && memTotal >= memAvailable + ? Math.round((memTotal - memAvailable) / 1024) + : 0 + }, + uptimeSec: Math.round(uptimeSec) + } +} + +function parseDf(blob: string): SysinfoSample['disks'] { + const disks: SysinfoSample['disks'] = [] + for (const raw of blob.split('\n')) { + const line = raw.trim() + if (!line || line.startsWith('Filesystem')) continue + const f = line.split(/\s+/) + if (f.length < 6) continue + // Columns: Filesystem 1K-blocks Used Available Use% Mounted on + const fs = f[0] + const mount = f.slice(5).join(' ') + // df -kP has no type column; pseudofs appear as the device name (tmpfs, + // overlay, udev, ...) or as a mount point under the shared /dev harness. + if (FAKE_FS.has(fs) || FAKE_FS.has(mount)) continue + const totalKb = toNumber(f[1]) + const usedKb = toNumber(f[2]) + if (totalKb <= 0) continue + disks.push({ + mount, + totalMb: Math.round(totalKb / 1024), + usedMb: Math.round(usedKb / 1024) + }) + } + return disks +} + +function parseNet( + blob: string +): { net: SysinfoSample['net']; netRx: number; netTx: number } { + let rx = 0 + let tx = 0 + for (const raw of blob.split('\n')) { + const line = raw.trim() + if (!line || line.startsWith('Inter')) continue + const colon = line.indexOf(':') + if (colon <= 0) continue + const iface = line.slice(0, colon) + const fields = line + .slice(colon + 1) + .trim() + .split(/\s+/) + .map(Number) + // /proc/net/dev rows: rx_bytes rx_packets ... rx_multicast tx_bytes ... + if (fields.length < 10 || fields.some((n) => !Number.isFinite(n))) continue + if (iface === 'lo') continue + rx += fields[0] + tx += fields[8] + } + return { net: { rxKbs: 0, txKbs: 0 }, netRx: rx, netTx: tx } +} + +function parseHost(blob: string): { hostname: string; osInfo: string } { + const lines = blob.split('\n').map((l) => l.trim()).filter(Boolean) + return { + hostname: lines[0] ?? '', + osInfo: lines.slice(1).join(' ').trim() + } +} \ No newline at end of file diff --git a/src/preload/index.ts b/src/preload/index.ts index b0814f2..56e8695 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,8 @@ import type { SessionOpenOptions, SshConnectionInput } from '../shared/connections' +import type { SysinfoMeta, SysinfoSample } from '../shared/sysinfo' +import type { TransferProgressEvent } from '../shared/sftp' const api: AppApi = { appInfo: () => ipcRenderer.invoke(Ipc.APP_INFO), @@ -31,6 +33,50 @@ const api: AppApi = { return () => ipcRenderer.removeListener(Ipc.PTY_EXIT, listener) }, + sysinfoStart: (id: string) => ipcRenderer.send(Ipc.SYSINFO_START, id), + sysinfoStop: (id: string) => ipcRenderer.send(Ipc.SYSINFO_STOP, id), + onSysinfoMeta: (cb: (e: { id: string; meta: SysinfoMeta }) => void) => { + const listener = (_: unknown, e: { id: string; meta: SysinfoMeta }): void => cb(e) + ipcRenderer.on(Ipc.SYSINFO_META, listener) + return () => ipcRenderer.removeListener(Ipc.SYSINFO_META, listener) + }, + onSysinfoSample: (cb: (e: { id: string; sample: SysinfoSample }) => void) => { + const listener = (_: unknown, e: { id: string; sample: SysinfoSample }): void => cb(e) + ipcRenderer.on(Ipc.SYSINFO_SAMPLE, listener) + return () => ipcRenderer.removeListener(Ipc.SYSINFO_SAMPLE, listener) + }, + + listRemote: (sessionId: string, dir: string) => ipcRenderer.invoke(Ipc.SFTP_LIST, sessionId, dir), + mkdirRemote: (sessionId: string, dir: string, name: string) => + ipcRenderer.invoke(Ipc.SFTP_MKDIR, sessionId, dir, name), + renameRemote: (sessionId: string, from: string, to: string) => + ipcRenderer.invoke(Ipc.SFTP_RENAME, sessionId, from, to), + deleteRemote: (sessionId: string, paths: string[]) => + ipcRenderer.invoke(Ipc.SFTP_DELETE, sessionId, paths), + uploadRemote: (sessionId: string, localPaths: string[], remoteDir: string) => + ipcRenderer.invoke(Ipc.SFTP_UPLOAD, sessionId, localPaths, remoteDir), + downloadRemote: (sessionId: string, remotePaths: string[], localDir: string) => + ipcRenderer.invoke(Ipc.SFTP_DOWNLOAD, sessionId, remotePaths, localDir), + cancelTransfer: (transferId: string) => ipcRenderer.send(Ipc.TRANSFER_CANCEL, transferId), + onTransferProgress: (cb: (e: TransferProgressEvent) => void) => { + const listener = (_: unknown, e: TransferProgressEvent): void => cb(e) + ipcRenderer.on(Ipc.TRANSFER_PROGRESS, listener) + return () => ipcRenderer.removeListener(Ipc.TRANSFER_PROGRESS, listener) + }, + pickFiles: () => ipcRenderer.invoke(Ipc.DIALOG_PICK_FILES), + pickDirectory: () => ipcRenderer.invoke(Ipc.DIALOG_PICK_DIR), + + recordCommand: (cmd: string) => ipcRenderer.invoke(Ipc.COMMANDS_RECORD, cmd), + listHistory: () => ipcRenderer.invoke(Ipc.COMMANDS_HISTORY_LIST), + clearHistory: () => ipcRenderer.invoke(Ipc.COMMANDS_HISTORY_CLEAR), + listLibrary: () => ipcRenderer.invoke(Ipc.COMMANDS_LIBRARY_LIST), + saveLibraryItem: (item) => ipcRenderer.invoke(Ipc.COMMANDS_LIBRARY_SAVE, item), + deleteLibraryItem: (id: string) => ipcRenderer.invoke(Ipc.COMMANDS_LIBRARY_DELETE, id), + logStart: (sessionId: string) => ipcRenderer.invoke(Ipc.LOG_START, sessionId), + logStop: (sessionId: string) => ipcRenderer.invoke(Ipc.LOG_STOP, sessionId), + listSessionLogs: () => ipcRenderer.invoke(Ipc.LOG_LIST), + openLogsDir: () => ipcRenderer.send(Ipc.LOG_OPEN_DIR), + listConnections: () => ipcRenderer.invoke(Ipc.CONNECTIONS_LIST), saveConnection: (input: SshConnectionInput) => ipcRenderer.invoke(Ipc.CONNECTIONS_SAVE, input), deleteConnection: (id: string) => ipcRenderer.invoke(Ipc.CONNECTIONS_DELETE, id), diff --git a/src/renderer/src/connections/ConnectionSidebar.tsx b/src/renderer/src/connections/ConnectionSidebar.tsx index cec4b62..665671d 100644 --- a/src/renderer/src/connections/ConnectionSidebar.tsx +++ b/src/renderer/src/connections/ConnectionSidebar.tsx @@ -8,8 +8,14 @@ import './connections.css' export interface ConnectionSidebarProps { className?: string - /** called when the user double-clicks a connection */ + /** called when the user double-clicks a connection (or clicks a recent session) */ onConnect: (conn: SshConnection) => void + /** open local terminal panels fed by the workspace ("终端" section). */ + localPanels?: Array<{ id: string; title: string }> + /** id of the currently active panel in the workspace, for highlighting. */ + activePanelId?: string | null + /** focus a local terminal panel; e.g. dockview api.getPanel(id)?.setActive(). */ + onFocusPanel?: (panelId: string) => void } export interface ConnectionSidebarHandle { @@ -19,6 +25,19 @@ export interface ConnectionSidebarHandle { const DEFAULT_GROUP = '默认' +/** Short human "time ago" for the recent-session rows. */ +function formatRelativeTime(ts: number): string { + const diffSec = Math.max(0, Math.floor((Date.now() - ts) / 1000)) + if (diffSec < 60) return '刚刚' + const mins = Math.floor(diffSec / 60) + if (mins < 60) return `${mins} 分钟前` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours} 小时前` + const days = Math.floor(hours / 24) + if (days < 30) return `${days} 天前` + return new Date(ts).toLocaleDateString() +} + /** * Left-hand SSH bookmark sidebar (XTerminal server-list style). * @@ -27,7 +46,16 @@ const DEFAULT_GROUP = '默认' * `lastConnectedAt`), or simply remount the component with a `key`. */ export const ConnectionSidebar = forwardRef( - function ConnectionSidebar({ className, onConnect }: ConnectionSidebarProps, ref): React.JSX.Element { + function ConnectionSidebar( + { + className, + onConnect, + localPanels = [], + activePanelId, + onFocusPanel + }: ConnectionSidebarProps, + ref + ): React.JSX.Element { const [connections, setConnections] = useState([]) const [loaded, setLoaded] = useState(false) const [dialogOpen, setDialogOpen] = useState(false) @@ -92,6 +120,15 @@ export const ConnectionSidebar = forwardRef { + return connections + .filter((c) => typeof c.lastConnectedAt === 'number') + .sort((a, b) => (b.lastConnectedAt as number) - (a.lastConnectedAt as number)) + .slice(0, 5) + }, [connections]) + const collapseItems = groups.map(({ group, list }) => ({ key: group, label: ( @@ -129,6 +166,34 @@ export const ConnectionSidebar = forwardRef
+
+
+ 终端 + {localPanels.length} +
+ {localPanels.length === 0 ? ( +
无打开的本地终端
+ ) : ( +
    + {localPanels.map((panel) => ( +
  • onFocusPanel?.(panel.id)} + title={panel.title} + > + {panel.title} + 本地 +
  • + ))} +
+ )} +
+ +
+
+ SSH 服务器 +
{connections.length === 0 ? (
{loaded ? ( @@ -148,7 +213,30 @@ export const ConnectionSidebar = forwardRef )} -
+ + {recent.length > 0 && ( +
+
+ 最近会话 +
+ {recent.map((conn) => ( + + ))} +
+ )} +
+
noop, onPtyExit: () => noop, + sysinfoStart: noop, + sysinfoStop: noop, + onSysinfoMeta: () => noop, + onSysinfoSample: () => noop, + listRemote: async () => [], + mkdirRemote: async () => undefined, + renameRemote: async () => undefined, + deleteRemote: async () => undefined, + uploadRemote: async () => stubId(), + downloadRemote: async () => stubId(), + cancelTransfer: noop, + onTransferProgress: () => noop, + pickFiles: async () => [], + pickDirectory: async () => '', + recordCommand: async () => undefined, + listHistory: async () => [], + clearHistory: async () => undefined, + listLibrary: async () => [], + saveLibraryItem: async (item) => item, + deleteLibraryItem: async () => undefined, + logStart: async (sessionId) => ({ + sessionId, + file: '', + fileName: '', + startedAt: Date.now() + }), + logStop: async () => undefined, + listSessionLogs: async () => [], + openLogsDir: noop, listConnections: async () => [], saveConnection: async (input) => ({ id: input.id ?? stubId(), diff --git a/src/renderer/src/monitor/MonitorPanel.tsx b/src/renderer/src/monitor/MonitorPanel.tsx new file mode 100644 index 0000000..d4e7b9f --- /dev/null +++ b/src/renderer/src/monitor/MonitorPanel.tsx @@ -0,0 +1,161 @@ +import { useEffect, useRef, useState } from 'react' +import type { SysinfoMeta, SysinfoSample } from '@shared/sysinfo' +import './monitor.css' + +export interface MonitorPanelProps { + /** ssh session id */ + sessionId: string +} + +/** Red/green/yellow threshold bar color for CPU usage. */ +function cpuColor(usage: number): string { + if (usage > 80) return '#f85149' + if (usage > 60) return '#e3b341' + return '#3fb950' +} + +/** Format a size in MB: show GB (1 decimal) once over 1024 MB. */ +function formatSize(mb: number): string { + const gb = mb / 1024 + if (gb >= 1) return `${gb.toFixed(1)}G` + return `${Math.round(mb)}MB` +} + +/** human-readable uptime */ +function humanizeUptime(sec: number): string { + const s = Math.floor(sec) + if (s < 60) return `${s}秒` + const mins = Math.floor(s / 60) + if (s < 3600) return `${mins}分钟` + const hours = Math.floor(s / 3600) + if (s < 86400) return `${hours}小时${Math.floor((s % 3600) / 60)}分` + return `${Math.floor(s / 86400)}天` +} + +function Bar({ pct, color }: { pct: number; color: string }) { + const clamped = Math.max(0, Math.min(100, pct)) + return ( +
+
+
+ ) +} + +export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Element { + const [meta, setMeta] = useState(null) + const [sample, setSample] = useState(null) + const sampleRef = useRef(null) + + useEffect(() => { + let disposed = false + window.api.sysinfoStart(sessionId) + const offMeta = window.api.onSysinfoMeta((e: { id: string; meta: SysinfoMeta }) => { + if (e.id !== sessionId || disposed) return + setMeta(e.meta) + }) + const offSample = window.api.onSysinfoSample((e: { id: string; sample: SysinfoSample }) => { + if (e.id !== sessionId || disposed) return + sampleRef.current = e.sample + setSample(e.sample) + }) + return () => { + disposed = true + offMeta() + offSample() + window.api.sysinfoStop(sessionId) + } + }, [sessionId]) + + if (!meta || !sample) { + return ( +
+ + 采集中… +
+ ) + } + + const cpu = sample.cpu + const mem = sample.mem + const disks = sample.disks ?? [] + const net = sample.net + const failed = Boolean(sample.error) + + return ( +
+ {failed &&
采集中断:{sample.error}
} + +
+ {meta.hostname || 'unknown'} + {meta.os} +
+ +
+
+ CPU + + {cpu.usage.toFixed(0)}%({cpu.cores} 核) + +
+ +
+ 负载 {cpu.loadavg.map((v) => v.toFixed(1)).join(' / ')} +
+
+ +
+
+ 内存 + + {formatSize(mem.usedMb)} / {formatSize(mem.totalMb)} + +
+ 0 ? (mem.usedMb / mem.totalMb) * 100 : 0} + color={mem.totalMb > 0 && mem.usedMb / mem.totalMb > 0.8 ? '#f85149' : '#3fb950'} + /> +
+ + {disks.length > 0 && ( +
+
+ 磁盘 +
+
+ {disks.slice(0, 6).map((d) => { + const pct = d.totalMb > 0 ? (d.usedMb / d.totalMb) * 100 : 0 + return ( +
+
+ {d.mount} + + {formatSize(d.usedMb)} / {formatSize(d.totalMb)} + +
+ +
+ ) + })} +
+
+ )} + +
+
+ 网络 +
+
+ ↓{net?.rxKbs ?? 0} KB/s + ↑{net?.txKbs ?? 0} KB/s +
+
+ +
+ 运行时间 {humanizeUptime(sample.uptimeSec)} +
+
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/monitor/monitor.css b/src/renderer/src/monitor/monitor.css new file mode 100644 index 0000000..8a9d908 --- /dev/null +++ b/src/renderer/src/monitor/monitor.css @@ -0,0 +1,215 @@ +/* M3 server sysinfo monitor panel (SSH sessions only). */ + +.mm-root { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + background: #1a1a1a; + color: var(--chrome-fg); + font-size: 12px; + overflow-y: auto; +} + +/* error state: dim the whole panel + show a notice */ +.mm-failed { + opacity: 0.7; +} + +.mm-error { + border: 1px solid #f85149; + border-radius: 6px; + background: rgba(248, 81, 73, 0.12); + color: #f85149; + padding: 5px 8px; + font-size: 12px; +} + +/* ---------- header ---------- */ +.mm-header { + display: flex; + flex-direction: column; + gap: 1px; + line-height: 1.3; +} + +.mm-host { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.92); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mm-os { + font-size: 11px; + color: #8a8a8a; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ---------- blocks ---------- */ +.mm-block { + border: 1px solid #2d2d2d; + border-radius: 6px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.mm-block-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + line-height: 1.2; +} + +.mm-label { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.85); +} + +.mm-value { + font-size: 12px; + color: #3fb950; + font-variant-numeric: tabular-nums; +} + +.mm-sub { + font-size: 11px; + color: #8a8a8a; +} + +/* ---------- bar ---------- */ +.mm-bar { + height: 8px; + border-radius: 4px; + overflow: hidden; + background: #2c2c2c; + width: 100%; +} + +.mm-bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; +} + +/* ---------- disks ---------- */ +.mm-disks { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 132px; + overflow-y: auto; +} + +.mm-disk { + display: flex; + flex-direction: column; + gap: 3px; +} + +.mm-disk-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.mm-mount { + color: rgba(255, 255, 255, 0.75); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mm-disk-size { + font-size: 11px; + color: #8a8a8a; + flex: 0 0 auto; +} + +/* ---------- network ---------- */ +.mm-net { + display: flex; + gap: 20px; +} + +.mm-net-item { + display: inline-flex; + align-items: center; + gap: 4px; + font-variant-numeric: tabular-nums; + color: rgba(255, 255, 255, 0.8); +} + +.mm-arrow { + font-size: 12px; +} + +.mm-up { + color: #58a6ff; +} + +.mm-down { + color: #f0a55a; +} + +/* ---------- footer ---------- */ +.mm-footer { + font-size: 11px; + color: #8a8a8a; +} + +.mm-uptime { + color: rgba(255, 255, 255, 0.85); +} + +/* ---------- empty / collecting state ---------- */ +.mm-empty { + align-items: center; + justify-content: center; + min-height: 120px; + color: #8a8a8a; +} + +.mm-dots { + display: inline-flex; + gap: 4px; + margin-right: 8px; +} + +.mm-dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.4); + animation: mm-pulse 1.2s infinite ease-in-out; +} + +.mm-dots span:nth-child(2) { + animation-delay: 0.2s; +} + +.mm-dots span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes mm-pulse { + 0%, + 80%, + 100% { + opacity: 0.25; + transform: translateY(0); + } + 40% { + opacity: 1; + transform: translateY(-2px); + } +} \ No newline at end of file diff --git a/src/renderer/src/workspace/TerminalPanel.tsx b/src/renderer/src/workspace/TerminalPanel.tsx index 4b933e7..4ad2641 100644 --- a/src/renderer/src/workspace/TerminalPanel.tsx +++ b/src/renderer/src/workspace/TerminalPanel.tsx @@ -1,7 +1,11 @@ -import { useRef } from 'react' +import { useRef, useState } from 'react' import type { IDockviewPanelProps } from 'dockview-react' +import { Button } from 'antd' +import { AreaChartOutlined } from '@ant-design/icons' import { TerminalView } from '../terminal/TerminalView' +import { MonitorPanel } from '../monitor/MonitorPanel' +import type { TerminalParams } from './Workspace' export interface TerminalPanelExtraProps { /** @@ -11,8 +15,7 @@ export interface TerminalPanelExtraProps { onSessionDead?: (sessionId: string) => void } -export type TerminalPanelProps = IDockviewPanelProps<{ sessionId?: string }> & - TerminalPanelExtraProps +export type TerminalPanelProps = IDockviewPanelProps & TerminalPanelExtraProps /** * Dockview content component registered as `'terminal'`. @@ -21,23 +24,45 @@ export type TerminalPanelProps = IDockviewPanelProps<{ sessionId?: string }> & * process exits, TerminalView calls `onClose` and we close the panel — * marking the session dead first so onDidRemovePanel won't try to kill an * already-exited process. + * + * For SSH sessions (`sessionKind === 'ssh'`) a collapsible monitor panel is + * docked to the right of the terminal. Local sessions render terminal only. */ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps): React.JSX.Element { const sessionId = params.sessionId ?? '' + const sessionKind = params.sessionKind + const isSsh = sessionKind === 'ssh' const closedRef = useRef(false) + /** Monitor panel visibility for ssh sessions — expanded by default. */ + const [monitorOpen, setMonitorOpen] = useState(true) return (
- { - if (closedRef.current) return - closedRef.current = true - onSessionDead?.(sessionId) - api.close() - }} - /> + {isSsh && ( +
) } \ No newline at end of file diff --git a/src/renderer/src/workspace/TerminalTab.tsx b/src/renderer/src/workspace/TerminalTab.tsx index 2003865..3ba29f6 100644 --- a/src/renderer/src/workspace/TerminalTab.tsx +++ b/src/renderer/src/workspace/TerminalTab.tsx @@ -1,25 +1,34 @@ import { useEffect, useState } from 'react' import { Button, Dropdown, Input, Modal, Popconfirm, Space } from 'antd' import type { MenuProps } from 'antd' +import { CloudServerOutlined } from '@ant-design/icons' import type { IDockviewPanelHeaderProps } from 'dockview-react' import type { LayoutMeta } from '@shared/ipc' -export type TerminalTabProps = IDockviewPanelHeaderProps<{ sessionId?: string }> +export type TerminalTabProps = IDockviewPanelHeaderProps<{ + sessionId?: string + sessionKind?: 'local' | 'ssh' + hostLabel?: string +}> /** * Custom tab renderer for terminal panels: default dockview tab look (title + * close glyph) with an antd context menu: * 关闭 / 关闭其他 / 关闭右侧 * - * All menu actions close panels directly through `api.close()`. The - * workspace's `onDidRemovePanel` handles the pty kill, so nothing leaks. + * SSH sessions get a 🖥 server icon and a green left-border accent so they're + * visually distinct from local terminals. All menu actions close panels + * directly through `api.close()`. The workspace's `onDidRemovePanel` handles + * the pty kill, so nothing leaks. */ -export function TerminalTab({ api }: TerminalTabProps): React.JSX.Element { +export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Element { const panel = api.group // bound panel via the panel api const [title, setTitle] = useState(api.title) const selfIndex = panel.panels.findIndex((p) => p.api === api) + const isSsh = params?.sessionKind === 'ssh' + // Keep the tab title live (dockview re-renders tab components only on // params changes; title updates arrive through this event). useEffect(() => { @@ -56,7 +65,8 @@ export function TerminalTab({ api }: TerminalTabProps): React.JSX.Element { return ( -
+
+ {isSsh && } {title}