feat(M3): SSH productization — Terminal/SSH split + Wave-style server monitoring

- 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
This commit is contained in:
954801926@qq.com committed 2026-09-06 13:45:52 +08:00
1 parent f97edbb016
commit 06fa4ca023
19 files changed
+1632 -30

No files matched your search

+5
View File
@@ -5,6 +5,7 @@ import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../sh
import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections' import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections'
import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts' import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts'
import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay } from './pty' import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay } from './pty'
import { startPolling, stopPolling } from './sysinfo'
import { registerSettingsIpc } from './settingsStore' import { registerSettingsIpc } from './settingsStore'
import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore' import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore'
import { KnownHostsStore, defaultKnownHostsPath } from './knownHosts' import { KnownHostsStore, defaultKnownHostsPath } from './knownHosts'
@@ -47,6 +48,10 @@ export function registerIpc(): void {
) )
ipcMain.on(Ipc.PTY_KILL, (_event, id: string) => killPty(id)) 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) ---- // ---- ssh connections (bookmarks) ----
ipcMain.handle(Ipc.CONNECTIONS_LIST, (): SshConnection[] => connectionsStore.listConnections()) ipcMain.handle(Ipc.CONNECTIONS_LIST, (): SshConnection[] => connectionsStore.listConnections())
ipcMain.handle(Ipc.CONNECTIONS_SAVE, (_event, input: SshConnectionInput): SshConnection => ipcMain.handle(Ipc.CONNECTIONS_SAVE, (_event, input: SshConnectionInput): SshConnection =>
+19 -1
View File
@@ -6,6 +6,11 @@ import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../s
import { broadcast } from './broadcast' import { broadcast } from './broadcast'
import { connectSsh, type SshSessionHandle } from './ssh' import { connectSsh, type SshSessionHandle } from './ssh'
import type { HostKeyCheckResult } from './knownHosts' 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 * 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. */ /** Wire the real stores + renderer prompt. Called once during app startup. */
export function configureSessionRuntime(deps: SessionRuntimeDeps): void { export function configureSessionRuntime(deps: SessionRuntimeDeps): void {
runtimeDeps = deps 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 { function defaultShell(): string {
@@ -156,6 +169,7 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
handle.stream.on('close', () => { handle.stream.on('close', () => {
try { try {
stopPolling(handle.id)
sessions.delete(handle.id) sessions.delete(handle.id)
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: 0 }) deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: 0 })
} catch { } catch {
@@ -190,6 +204,7 @@ export function resizePty(id: string, cols: number, rows: number): void {
} }
export function killPty(id: string): void { export function killPty(id: string): void {
stopPolling(id)
replayBuffers.delete(id) replayBuffers.delete(id)
const session = sessions.get(id) const session = sessions.get(id)
if (!session) return if (!session) return
@@ -215,7 +230,10 @@ export function killPty(id: string): void {
} }
export function killAllPtys(): 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') { if (session.kind === 'ssh') {
try { try {
session.ssh.stream.close() session.ssh.stream.close()
+415
View File
@@ -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<string, PollState>()
/**
* 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()
}
}
+46
View File
@@ -9,6 +9,8 @@ import type {
SessionOpenOptions, SessionOpenOptions,
SshConnectionInput SshConnectionInput
} from '../shared/connections' } from '../shared/connections'
import type { SysinfoMeta, SysinfoSample } from '../shared/sysinfo'
import type { TransferProgressEvent } from '../shared/sftp'
const api: AppApi = { const api: AppApi = {
appInfo: () => ipcRenderer.invoke(Ipc.APP_INFO), appInfo: () => ipcRenderer.invoke(Ipc.APP_INFO),
@@ -31,6 +33,50 @@ const api: AppApi = {
return () => ipcRenderer.removeListener(Ipc.PTY_EXIT, listener) 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), listConnections: () => ipcRenderer.invoke(Ipc.CONNECTIONS_LIST),
saveConnection: (input: SshConnectionInput) => ipcRenderer.invoke(Ipc.CONNECTIONS_SAVE, input), saveConnection: (input: SshConnectionInput) => ipcRenderer.invoke(Ipc.CONNECTIONS_SAVE, input),
deleteConnection: (id: string) => ipcRenderer.invoke(Ipc.CONNECTIONS_DELETE, id), deleteConnection: (id: string) => ipcRenderer.invoke(Ipc.CONNECTIONS_DELETE, id),
@@ -8,8 +8,14 @@ import './connections.css'
export interface ConnectionSidebarProps { export interface ConnectionSidebarProps {
className?: string 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 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 { export interface ConnectionSidebarHandle {
@@ -19,6 +25,19 @@ export interface ConnectionSidebarHandle {
const DEFAULT_GROUP = '默认' 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). * 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`. * `lastConnectedAt`), or simply remount the component with a `key`.
*/ */
export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionSidebarProps>( export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionSidebarProps>(
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<SshConnection[]>([]) const [connections, setConnections] = useState<SshConnection[]>([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
@@ -92,6 +120,15 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
void refresh() void refresh()
} }
// Most-recently-connected servers (top 5, most recent first). The active
// connection list must be consulted so the row renders its auth badge etc.
const recent = useMemo(() => {
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 }) => ({ const collapseItems = groups.map(({ group, list }) => ({
key: group, key: group,
label: ( label: (
@@ -129,6 +166,34 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
</div> </div>
<div className="connections-list"> <div className="connections-list">
<section className="connections-section">
<div className="connections-section-head">
<span className="connections-section-title">终端</span>
<span className="connections-section-count">{localPanels.length}</span>
</div>
{localPanels.length === 0 ? (
<div className="connections-section-empty">无打开的本地终端</div>
) : (
<ul className="connections-local-list">
{localPanels.map((panel) => (
<li
key={panel.id}
className={`connections-local-item${panel.id === activePanelId ? ' is-active' : ''}`}
onClick={() => onFocusPanel?.(panel.id)}
title={panel.title}
>
<span className="connections-local-name">{panel.title}</span>
<span className="connections-local-tag">本地</span>
</li>
))}
</ul>
)}
</section>
<section className="connections-section">
<div className="connections-section-head">
<span className="connections-section-title">SSH 服务器</span>
</div>
{connections.length === 0 ? ( {connections.length === 0 ? (
<div className="connections-empty"> <div className="connections-empty">
{loaded ? ( {loaded ? (
@@ -148,7 +213,30 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
activeKey={activeGroups} activeKey={activeGroups}
/> />
)} )}
</div>
{recent.length > 0 && (
<div className="connections-recent">
<div className="connections-recent-head">
<span className="connections-recent-title">最近会话</span>
</div>
{recent.map((conn) => (
<button
key={conn.id}
type="button"
className="connections-recent-item"
onClick={() => onConnect(conn)}
title={`${conn.name} · ${new Date(conn.lastConnectedAt as number).toLocaleString()}`}
>
<span className="connections-recent-name">{conn.name}</span>
<span className="connections-recent-time">
{formatRelativeTime(conn.lastConnectedAt as number)}
</span>
</button>
))}
</div>
)}
</section>
</div>
<ConnectionEditDialog <ConnectionEditDialog
open={dialogOpen} open={dialogOpen}
@@ -268,4 +268,137 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
}
/* ---------- dual sections: 终端 / SSH 服务器 ---------- */
.connections-section {
margin-bottom: 12px;
}
.connections-section-head {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 4px 6px;
}
.connections-section-title {
flex: 1 1 auto;
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
letter-spacing: 0.6px;
}
.connections-section-count {
flex: 0 0 auto;
font-size: 11px;
color: #8a8a8a;
background: rgba(255, 255, 255, 0.08);
border-radius: 9px;
padding: 0 7px;
line-height: 18px;
}
.connections-section-empty {
padding: 10px 8px;
color: #6a6a6a;
font-size: 12px;
}
/* local terminal list */
.connections-local-list {
list-style: none;
margin: 0;
padding: 0;
}
.connections-local-item {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 6px 5px 8px;
border-radius: 4px;
cursor: pointer;
user-select: none;
}
.connections-local-item:hover {
background: var(--chrome-hover);
}
.connections-local-item.is-active {
background: rgba(63, 185, 80, 0.12);
}
.connections-local-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: var(--chrome-fg);
}
.connections-local-tag {
flex: 0 0 auto;
font-size: 10px;
line-height: 1;
padding: 2px 5px;
border-radius: 4px;
color: #9a9a9a;
background: rgba(255, 255, 255, 0.08);
}
/* recent sessions */
.connections-recent {
margin-top: 8px;
}
.connections-recent-head {
padding: 2px 4px 4px;
}
.connections-recent-title {
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
letter-spacing: 0.6px;
}
.connections-recent-item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
border: none;
background: none;
font: inherit;
text-align: left;
padding: 5px 6px 5px 8px;
border-radius: 4px;
cursor: pointer;
user-select: none;
color: var(--chrome-fg);
}
.connections-recent-item:hover {
background: var(--chrome-hover);
}
.connections-recent-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.connections-recent-time {
flex: 0 0 auto;
font-size: 11px;
color: #6a6a6a;
} }
+29
View File
@@ -21,6 +21,35 @@ if (typeof window !== 'undefined' && !window.api) {
killPty: noop, killPty: noop,
onPtyData: () => noop, onPtyData: () => noop,
onPtyExit: () => 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 () => [], listConnections: async () => [],
saveConnection: async (input) => ({ saveConnection: async (input) => ({
id: input.id ?? stubId(), id: input.id ?? stubId(),
+161
View File
@@ -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 (
<div className="mm-bar">
<div
className="mm-bar-fill"
style={{ width: `${clamped}%`, background: color }}
/>
</div>
)
}
export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Element {
const [meta, setMeta] = useState<SysinfoMeta | null>(null)
const [sample, setSample] = useState<SysinfoSample | null>(null)
const sampleRef = useRef<SysinfoSample | null>(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 (
<div className="mm-root mm-empty">
<span className="mm-dots"><span /> <span /> <span /></span>
采集中…
</div>
)
}
const cpu = sample.cpu
const mem = sample.mem
const disks = sample.disks ?? []
const net = sample.net
const failed = Boolean(sample.error)
return (
<div className={`mm-root${failed ? ' mm-failed' : ''}`}>
{failed && <div className="mm-error">采集中断:{sample.error}</div>}
<header className="mm-header">
<span className="mm-host">{meta.hostname || 'unknown'}</span>
<span className="mm-os">{meta.os}</span>
</header>
<section className="mm-block" aria-label="CPU">
<div className="mm-block-row">
<span className="mm-label">CPU</span>
<span className="mm-value" style={{ color: cpuColor(cpu.usage) }}>
{cpu.usage.toFixed(0)}%({cpu.cores} 核)
</span>
</div>
<Bar pct={cpu.usage} color={cpuColor(cpu.usage)} />
<div className="mm-sub">
负载 {cpu.loadavg.map((v) => v.toFixed(1)).join(' / ')}
</div>
</section>
<section className="mm-block" aria-label="内存">
<div className="mm-block-row">
<span className="mm-label">内存</span>
<span className="mm-value">
{formatSize(mem.usedMb)} / {formatSize(mem.totalMb)}
</span>
</div>
<Bar
pct={mem.totalMb > 0 ? (mem.usedMb / mem.totalMb) * 100 : 0}
color={mem.totalMb > 0 && mem.usedMb / mem.totalMb > 0.8 ? '#f85149' : '#3fb950'}
/>
</section>
{disks.length > 0 && (
<section className="mm-block" aria-label="磁盘">
<div className="mm-block-row">
<span className="mm-label">磁盘</span>
</div>
<div className="mm-disks">
{disks.slice(0, 6).map((d) => {
const pct = d.totalMb > 0 ? (d.usedMb / d.totalMb) * 100 : 0
return (
<div className="mm-disk" key={d.mount}>
<div className="mm-disk-top">
<span className="mm-mount">{d.mount}</span>
<span className="mm-disk-size">
{formatSize(d.usedMb)} / {formatSize(d.totalMb)}
</span>
</div>
<Bar pct={pct} color={cpuColor(pct)} />
</div>
)
})}
</div>
</section>
)}
<section className="mm-block" aria-label="网络">
<div className="mm-block-row">
<span className="mm-label">网络</span>
</div>
<div className="mm-net">
<span className="mm-net-item"><span className="mm-arrow mm-up">↓</span>{net?.rxKbs ?? 0} KB/s</span>
<span className="mm-net-item"><span className="mm-arrow mm-down">↑</span>{net?.txKbs ?? 0} KB/s</span>
</div>
</section>
<footer className="mm-footer">
运行时间 <span className="mm-uptime">{humanizeUptime(sample.uptimeSec)}</span>
</footer>
</div>
)
}
+215
View File
@@ -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);
}
}
+38 -13
View File
@@ -1,7 +1,11 @@
import { useRef } from 'react' import { useRef, useState } from 'react'
import type { IDockviewPanelProps } from 'dockview-react' import type { IDockviewPanelProps } from 'dockview-react'
import { Button } from 'antd'
import { AreaChartOutlined } from '@ant-design/icons'
import { TerminalView } from '../terminal/TerminalView' import { TerminalView } from '../terminal/TerminalView'
import { MonitorPanel } from '../monitor/MonitorPanel'
import type { TerminalParams } from './Workspace'
export interface TerminalPanelExtraProps { export interface TerminalPanelExtraProps {
/** /**
@@ -11,8 +15,7 @@ export interface TerminalPanelExtraProps {
onSessionDead?: (sessionId: string) => void onSessionDead?: (sessionId: string) => void
} }
export type TerminalPanelProps = IDockviewPanelProps<{ sessionId?: string }> & export type TerminalPanelProps = IDockviewPanelProps<TerminalParams> & TerminalPanelExtraProps
TerminalPanelExtraProps
/** /**
* Dockview content component registered as `'terminal'`. * 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 — * process exits, TerminalView calls `onClose` and we close the panel —
* marking the session dead first so onDidRemovePanel won't try to kill an * marking the session dead first so onDidRemovePanel won't try to kill an
* already-exited process. * 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 { export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps): React.JSX.Element {
const sessionId = params.sessionId ?? '' const sessionId = params.sessionId ?? ''
const sessionKind = params.sessionKind
const isSsh = sessionKind === 'ssh'
const closedRef = useRef(false) const closedRef = useRef(false)
/** Monitor panel visibility for ssh sessions — expanded by default. */
const [monitorOpen, setMonitorOpen] = useState(true)
return ( return (
<div className="workspace-terminal-panel"> <div className="workspace-terminal-panel">
<TerminalView {isSsh && (
sessionId={sessionId} <Button
className="workspace-terminal-view" type="text"
onClose={() => { size="small"
if (closedRef.current) return className="workspace-terminal-monitor-toggle"
closedRef.current = true icon={<AreaChartOutlined />}
onSessionDead?.(sessionId) aria-label={monitorOpen ? '收起系统监控' : '展开系统监控'}
api.close() aria-expanded={monitorOpen}
}} title={monitorOpen ? '收起系统监控' : '展开系统监控'}
/> onClick={() => setMonitorOpen((prev) => !prev)}
/>
)}
<div className="workspace-terminal-split">
<TerminalView
sessionId={sessionId}
className="workspace-terminal-view"
onClose={() => {
if (closedRef.current) return
closedRef.current = true
onSessionDead?.(sessionId)
api.close()
}}
/>
{isSsh && monitorOpen && <MonitorPanel sessionId={sessionId} />}
</div>
</div> </div>
) )
} }
+15 -5
View File
@@ -1,25 +1,34 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Button, Dropdown, Input, Modal, Popconfirm, Space } from 'antd' import { Button, Dropdown, Input, Modal, Popconfirm, Space } from 'antd'
import type { MenuProps } from 'antd' import type { MenuProps } from 'antd'
import { CloudServerOutlined } from '@ant-design/icons'
import type { IDockviewPanelHeaderProps } from 'dockview-react' import type { IDockviewPanelHeaderProps } from 'dockview-react'
import type { LayoutMeta } from '@shared/ipc' 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 + * Custom tab renderer for terminal panels: default dockview tab look (title +
* close glyph) with an antd context menu: * close glyph) with an antd context menu:
* 关闭 / 关闭其他 / 关闭右侧 * 关闭 / 关闭其他 / 关闭右侧
* *
* All menu actions close panels directly through `api.close()`. The * SSH sessions get a 🖥 server icon and a green left-border accent so they're
* workspace's `onDidRemovePanel` handles the pty kill, so nothing leaks. * 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 panel = api.group // bound panel via the panel api
const [title, setTitle] = useState<string | undefined>(api.title) const [title, setTitle] = useState<string | undefined>(api.title)
const selfIndex = panel.panels.findIndex((p) => p.api === api) 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 // Keep the tab title live (dockview re-renders tab components only on
// params changes; title updates arrive through this event). // params changes; title updates arrive through this event).
useEffect(() => { useEffect(() => {
@@ -56,7 +65,8 @@ export function TerminalTab({ api }: TerminalTabProps): React.JSX.Element {
return ( return (
<Dropdown menu={menu} trigger={['contextMenu']}> <Dropdown menu={menu} trigger={['contextMenu']}>
<div className="workspace-terminal-tab"> <div className={`workspace-terminal-tab${isSsh ? ' workspace-terminal-tab-ssh' : ''}`}>
{isSsh && <CloudServerOutlined className="workspace-terminal-tab-icon" />}
<span className="workspace-terminal-tab-title">{title}</span> <span className="workspace-terminal-tab-title">{title}</span>
<button <button
type="button" type="button"
+46 -6
View File
@@ -42,7 +42,13 @@ interface LayoutMetaLike {
createdAt: number createdAt: number
} }
type TerminalParams = { sessionId?: string } interface TerminalParams {
sessionId?: string
sessionKind?: 'local' | 'ssh'
hostLabel?: string
}
export type { TerminalParams }
const TERMINAL_COMPONENT = 'terminal' const TERMINAL_COMPONENT = 'terminal'
const TERMINAL_TAB_COMPONENT = 'terminal-tab' const TERMINAL_TAB_COMPONENT = 'terminal-tab'
@@ -83,6 +89,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const [saveOpen, setSaveOpen] = useState(false) const [saveOpen, setSaveOpen] = useState(false)
const [applyOpen, setApplyOpen] = useState(false) const [applyOpen, setApplyOpen] = useState(false)
/** Local terminal panels surfaced to the sidebar's "终端" section. */
const [localPanels, setLocalPanels] = useState<Array<{ id: string; title: string }>>([])
/** id of the currently active panel (for sidebar highlight). */
const [activePanelId, setActivePanelId] = useState<string | null>(null)
/** [侧栏] toggle — hidden by default; the choice persists per device. */ /** [侧栏] toggle — hidden by default; the choice persists per device. */
const [sidebarOpen, setSidebarOpen] = useState<boolean>(() => const [sidebarOpen, setSidebarOpen] = useState<boolean>(() =>
window.localStorage.getItem('ot.sidebarOpen') === '1' window.localStorage.getItem('ot.sidebarOpen') === '1'
@@ -122,6 +133,29 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
return result.id return result.id
}, []) }, [])
/**
* Rebuild the local-terminal list for the sidebar's "终端" section. Called on
* layout changes (add/remove/renumber) so both the panel set and any title
* updates are reflected.
*/
const recomputeLocalPanels = useCallback((): void => {
const current = apiRef.current
if (!current) {
setLocalPanels([])
return
}
const panels = current.panels
.filter((p) => (p.params as TerminalParams | undefined)?.sessionKind === 'local')
.map((p) => ({ id: p.id, title: p.title ?? '' }))
setLocalPanels(panels)
}, [])
/** Focus a local terminal panel from the sidebar. */
const onFocusPanel = useCallback((panelId: string): void => {
const current = apiRef.current
current?.getPanel(panelId)?.api.setActive()
}, [])
const handleReady = useCallback((event: DockviewReadyEvent): void => { const handleReady = useCallback((event: DockviewReadyEvent): void => {
apiRef.current = event.api apiRef.current = event.api
setPanelCount(event.api.panels.length) setPanelCount(event.api.panels.length)
@@ -140,18 +174,21 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
// Panel vanished (tab close / context menu / layout clear): kill its // Panel vanished (tab close / context menu / layout clear): kill its
// session unless it was already marked dead (process already exited). // session unless it was already marked dead (process already exited).
killSession(sessionIdOf(panel)) killSession(sessionIdOf(panel))
recomputeLocalPanels()
}), }),
current.onDidActivePanelChange((event) => { current.onDidActivePanelChange((event) => {
activePanelRef.current = event.panel ?? undefined activePanelRef.current = event.panel ?? undefined
setActivePanelId(event.panel?.id ?? null)
}), }),
current.onDidLayoutChange(() => { current.onDidLayoutChange(() => {
setPanelCount(current.panels.length) setPanelCount(current.panels.length)
recomputeLocalPanels()
}) })
] ]
return () => { return () => {
for (const disposable of disposables) disposable.dispose() for (const disposable of disposables) disposable.dispose()
} }
}, [killSession]) }, [killSession, recomputeLocalPanels])
/** Kill remaining sessions on window close. */ /** Kill remaining sessions on window close. */
useEffect(() => { useEffect(() => {
@@ -201,7 +238,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
component: TERMINAL_COMPONENT, component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT, tabComponent: TERMINAL_TAB_COMPONENT,
title: nextTerminalTitle(), title: nextTerminalTitle(),
params: { sessionId }, params: { sessionId, sessionKind: 'local' },
position: position:
reference && direction reference && direction
? { referencePanel: reference.id, direction } ? { referencePanel: reference.id, direction }
@@ -233,7 +270,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
component: TERMINAL_COMPONENT, component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT, tabComponent: TERMINAL_TAB_COMPONENT,
title: conn.name, title: conn.name,
params: { sessionId: result.id } params: { sessionId: result.id, sessionKind: 'ssh', hostLabel: conn.host }
}) })
setPanelCount(current.panels.length) setPanelCount(current.panels.length)
// B's sidebar refreshes lastConnectedAt on connect. // B's sidebar refreshes lastConnectedAt on connect.
@@ -340,7 +377,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const id = sessionIdOf(panel) const id = sessionIdOf(panel)
if (id && panel.view.contentComponent === TERMINAL_COMPONENT) { if (id && panel.view.contentComponent === TERMINAL_COMPONENT) {
const newSessionId = (await window.api.createPty()).id const newSessionId = (await window.api.createPty()).id
panel.api.updateParameters({ sessionId: newSessionId }) panel.api.updateParameters({ sessionId: newSessionId, sessionKind: 'local' })
deadSessionsRef.current.delete(id) deadSessionsRef.current.delete(id)
} }
} }
@@ -385,6 +422,9 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
ref={sidebarRef} ref={sidebarRef}
className="connections-sidebar" className="connections-sidebar"
onConnect={handleConnectRequest} onConnect={handleConnectRequest}
localPanels={localPanels}
activePanelId={activePanelId}
onFocusPanel={onFocusPanel}
/> />
)} )}
<div className="workspace-dockview"> <div className="workspace-dockview">
@@ -464,7 +504,7 @@ function GroupAddButton({
component: TERMINAL_COMPONENT, component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT, tabComponent: TERMINAL_TAB_COMPONENT,
title: nextTerminalTitle(), title: nextTerminalTitle(),
params: { sessionId: result.id }, params: { sessionId: result.id, sessionKind: 'local' },
position: { referenceGroup: group.id, direction: 'within' } position: { referenceGroup: group.id, direction: 'within' }
}) })
})() })()
+43
View File
@@ -184,13 +184,56 @@
/* ---- terminal panel ---- */ /* ---- terminal panel ---- */
.workspace-terminal-panel { .workspace-terminal-panel {
position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.workspace-terminal-split {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
}
.workspace-terminal-view { .workspace-terminal-view {
width: 100%; width: 100%;
height: 100%; height: 100%;
flex: 1 1 auto;
min-width: 0;
}
/* ---- SSH session monitor (right dock, collapsible) ---- */
.workspace-terminal-panel > .workspace-terminal-split > .mm-root,
.workspace-terminal-panel > .workspace-terminal-split > .mm-empty {
flex: 0 0 260px;
width: 260px;
}
.workspace-terminal-monitor-toggle {
position: absolute;
top: 4px;
right: 4px;
z-index: 4;
color: #8a8a8a;
font-size: 13px;
}
.workspace-terminal-monitor-toggle:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.1);
}
/* ---- term tab: SSH accent ---- */
.workspace-terminal-tab-icon {
flex: 0 0 auto;
font-size: 12px;
color: #3fb950;
}
.workspace-dockview .dv-tab .workspace-terminal-tab-ssh {
border-left: 2px solid #3fb950;
padding-left: 5px !important;
} }
/* ---- per-group "+" right after the last tab ---- /* ---- per-group "+" right after the last tab ----
+55 -2
View File
@@ -1,6 +1,23 @@
import type { AppInfo, LayoutMeta, PtyCreateOptions, PtyCreateResult, PtyDataEvent, PtyExitEvent, SessionOpenResult } from './ipc' import type {
AppInfo,
LayoutMeta,
PtyCreateOptions,
PtyCreateResult,
PtyDataEvent,
PtyExitEvent,
SessionOpenResult
} from './ipc'
import type { AppSettings } from './settings' import type { AppSettings } from './settings'
import type { HostKeyAction, HostKeyPromptEvent, SessionOpenOptions, SshConnection, SshConnectionInput } from './connections' import type {
HostKeyAction,
HostKeyPromptEvent,
SessionOpenOptions,
SshConnection,
SshConnectionInput
} from './connections'
import type { SysinfoMeta, SysinfoSample } from './sysinfo'
import type { SftpEntry, TransferProgressEvent } from './sftp'
import type { CommandItem, SessionLogMeta } from './commands'
/** /**
* The single API surface exposed on `window.api` by the preload script. * The single API surface exposed on `window.api` by the preload script.
@@ -22,6 +39,42 @@ export interface AppApi {
onPtyData(cb: (e: PtyDataEvent) => void): () => void onPtyData(cb: (e: PtyDataEvent) => void): () => void
onPtyExit(cb: (e: PtyExitEvent) => void): () => void onPtyExit(cb: (e: PtyExitEvent) => void): () => void
// ---- sysinfo (M3: ssh sessions only; main polls the remote and broadcasts) ----
/** start polling for an ssh session; stop happens automatically on session close */
sysinfoStart(id: string): void
sysinfoStop(id: string): void
onSysinfoMeta(cb: (e: { id: string; meta: SysinfoMeta }) => void): () => void
onSysinfoSample(cb: (e: { id: string; sample: SysinfoSample }) => void): () => void
// ---- sftp (M4: bound to an established ssh session) ----
listRemote(sessionId: string, dir: string): Promise<SftpEntry[]>
mkdirRemote(sessionId: string, dir: string, name: string): Promise<void>
renameRemote(sessionId: string, from: string, to: string): Promise<void>
deleteRemote(sessionId: string, paths: string[]): Promise<void>
/** upload local files into remote dir; returns transferId */
uploadRemote(sessionId: string, localPaths: string[], remoteDir: string): Promise<string>
/** download remote paths into local dir; returns transferId */
downloadRemote(sessionId: string, remotePaths: string[], localDir: string): Promise<string>
cancelTransfer(transferId: string): void
onTransferProgress(cb: (e: TransferProgressEvent) => void): () => void
/** native open dialog; returns picked file paths (empty when cancelled) */
pickFiles(): Promise<string[]>
pickDirectory(): Promise<string>
// ---- command history / library + session logs (M5) ----
/** record one executed command (deduped, newest first, capped) */
recordCommand(cmd: string): Promise<void>
listHistory(): Promise<CommandItem[]>
clearHistory(): Promise<void>
listLibrary(): Promise<CommandItem[]>
saveLibraryItem(item: CommandItem): Promise<CommandItem>
deleteLibraryItem(id: string): Promise<void>
/** session output logging */
logStart(sessionId: string): Promise<SessionLogMeta>
logStop(sessionId: string): Promise<void>
listSessionLogs(): Promise<SessionLogMeta[]>
openLogsDir(): void
// ---- ssh connections ---- // ---- ssh connections ----
listConnections(): Promise<SshConnection[]> listConnections(): Promise<SshConnection[]>
saveConnection(input: SshConnectionInput): Promise<SshConnection> saveConnection(input: SshConnectionInput): Promise<SshConnection>
+26
View File
@@ -0,0 +1,26 @@
/** Command history / command library + session output logs (M5). */
export interface CommandItem {
id: string
/** library items have a name; history items don't */
name?: string
command: string
note?: string
group?: string
/** library items support {{var}} placeholders; recorded purely as metadata */
params?: string[]
createdAt: number
/** epoch ms of last execution (history ordering) */
lastUsedAt?: number
}
export interface SessionLogMeta {
sessionId: string
/** absolute file path of the plain-text log */
file: string
fileName: string
/** epoch ms when logging started */
startedAt: number
/** undefined while still logging */
endedAt?: number
}
+33
View File
@@ -46,6 +46,39 @@ export const Ipc = {
HOSTKEY_PROMPT: 'hostkey:prompt', HOSTKEY_PROMPT: 'hostkey:prompt',
HOSTKEY_RESPOND: 'hostkey:respond', HOSTKEY_RESPOND: 'hostkey:respond',
// ---- sysinfo (M3) ----
SYSINFO_START: 'sysinfo:start',
SYSINFO_STOP: 'sysinfo:stop',
/** main -> renderer broadcast: { id, meta } (once per session) */
SYSINFO_META: 'sysinfo:meta',
/** main -> renderer broadcast: { id, sample } (poll interval) */
SYSINFO_SAMPLE: 'sysinfo:sample',
// ---- sftp (M4) ----
SFTP_LIST: 'sftp:list',
SFTP_MKDIR: 'sftp:mkdir',
SFTP_RENAME: 'sftp:rename',
SFTP_DELETE: 'sftp:delete',
SFTP_UPLOAD: 'sftp:upload',
SFTP_DOWNLOAD: 'sftp:download',
TRANSFER_CANCEL: 'transfer:cancel',
/** main -> renderer broadcast: TransferProgressEvent */
TRANSFER_PROGRESS: 'transfer:progress',
DIALOG_PICK_FILES: 'dialog:pickFiles',
DIALOG_PICK_DIR: 'dialog:pickDirectory',
// ---- commands + logs (M5) ----
COMMANDS_RECORD: 'commands:record',
COMMANDS_HISTORY_LIST: 'commands:historyList',
COMMANDS_HISTORY_CLEAR: 'commands:historyClear',
COMMANDS_LIBRARY_LIST: 'commands:libraryList',
COMMANDS_LIBRARY_SAVE: 'commands:librarySave',
COMMANDS_LIBRARY_DELETE: 'commands:libraryDelete',
LOG_START: 'log:start',
LOG_STOP: 'log:stop',
LOG_LIST: 'log:list',
LOG_OPEN_DIR: 'log:openDir',
// ---- misc ---- // ---- misc ----
APP_INFO: 'app:info' APP_INFO: 'app:info'
} as const } as const
+28
View File
@@ -0,0 +1,28 @@
/** SFTP file model + transfer progress (M4). */
export interface SftpEntry {
name: string
/** absolute remote path */
path: string
isDir: boolean
/** bytes (0 for dirs) */
size: number
/** epoch ms */
mtime: number
/** octal permission string, e.g. "0644" (best effort) */
mode?: string
}
export type TransferKind = 'upload' | 'download'
export type TransferState = 'running' | 'done' | 'error' | 'cancelled'
export interface TransferProgressEvent {
transferId: string
kind: TransferKind
state: TransferState
/** file currently being transferred */
file: string
bytes: number
totalBytes: number
error?: string
}
+34
View File
@@ -0,0 +1,34 @@
/** Remote server hardware monitoring sample (M3, ssh sessions only). */
export interface SysinfoMeta {
/** e.g. "Linux 5.15.0 x86_64" */
os: string
hostname: string
}
export interface SysinfoSample {
ts: number
cpu: {
/** 0..100 overall */
usage: number
cores: number
loadavg: [number, number, number]
}
mem: {
totalMb: number
usedMb: number
}
disks: Array<{
mount: string
totalMb: number
usedMb: number
}>
/** kilobytes per second since previous sample */
net: {
rxKbs: number
txKbs: number
}
uptimeSec: number
/** when set, the last collection failed (render an error state, keep old data) */
error?: string
}
+200
View File
@@ -0,0 +1,200 @@
/**
* Sysinfo polling e2e (M3). Drives the REAL M3 engine (src/main/sysinfo.ts,
* reached through pty.ts's re-export) against an in-process ssh2.Server whose
* `exec(accept)` responds with canned /proc-style text. Verifies the META
* broadcast, growing samples (usage + net rates), and that stopPolling halts
* the stream.
*
* Pre-req: npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs
* --outfile=tests/.session-e2e.cjs --external:@lydell/node-pty
* --external:ssh2 --alias:electron=./tests/electron-stub.cjs
* Run: node tests/sysinfo-e2e.mjs (must exit 0)
*/
import pkg from 'ssh2'
import { createHash } from 'crypto'
import { createRequire } from 'module'
const require_ = createRequire(import.meta.url)
const { Server, utils } = pkg
const fail = (msg) => {
console.error(`FAIL: ${msg}`)
process.exit(1)
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
// ---- 1. Loopback ssh server with canned /proc exec -----------------------------
const serverKey = utils.generateKeyPairSync('ed25519')
let serverPort = 0
// Two successive outputs with rising cpu ticks and rx/tx bytes so rates compute.
let execCount = 0
const srv = new Server({ hostKeys: [serverKey.private] }, (client) => {
client.on('authentication', (ctx) => {
if (ctx.method === 'password' && ctx.username === 'test' && ctx.password === 'test') {
ctx.accept()
} else {
ctx.reject(['password'])
}
})
client.on('ready', () => {
client.on('session', (accept) => {
const session = accept()
session.on('pty', (accept) => accept())
session.on('shell', (accept) => {
const stream = accept()
stream.write('SYSINFO-E2E-BANNER\n')
stream.on('data', () => {})
})
session.on('exec', (accept, reject, info) => {
if (!info || typeof info.command !== 'string') {
reject && reject()
return
}
const stream = accept()
execCount += 1
const rising = execCount > 1
const rxBytes = (rising ? 80 * 1024 * 1024 : 40 * 1024 * 1024) // 64KB/s basis
const txBytes = (rising ? 32 * 1024 * 1024 : 16 * 1024 * 1024)
const user = rising ? 4000 : 2000
const idle = rising ? 8000 : 6000
const idle2 = rising ? 8950 : 6000
const idle3 = rising ? 9600 : 7500
const out =
`cpu ${user} 100 500 ${idle} 120 30 20 0 0 0\n` +
`cpu0 ${user} 100 500 ${idle} 120 30 20 0 0 0\n` +
`cpu1 ${user} 100 500 ${idle2} 120 30 20 0 0 0\n` +
`cpu2 ${user} 100 500 ${idle3} 120 30 20 0 0 0\n` +
`irq 0 0 0 0 0 0 0 0 0 0\n` +
`MemTotal: ${4000000} kB\n` +
`MemFree: 1000000 kB\n` +
`MemAvailable: ${3100000} kB\n` +
`Buffers: 100000 kB\n` +
'0.15 0.12 0.10 1/234 5678\n' +
'12345.67 9876.54\n' +
`__DF__\n` +
'Filesystem 1K-blocks Used Available Use% Mounted on\n' +
'/dev/sda1 10240000 2048000 8192000 20% /\n' +
'tmpfs 2048000 128 2047872 1% /dev/shm\n' +
'overlay 10240000 2048000 8192000 20% /overlay\n' +
'/dev/nvme0n1p3 51200000 12800000 38400000 25% /home\n' +
`__NET__\n` +
`Inter-| Receive | Transmit\n` +
` face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed\n` +
` lo: ${rxBytes} 1000 0 0 0 0 0 0 ${txBytes} 1000 0 0 0 0 0 0\n` +
` eth0: ${rxBytes} 5000 0 0 0 0 0 0 ${txBytes} 2000 0 0 0 0 0 0\n` +
`__HOST__\n` +
'loopback-host\n' +
'Linux 6.8.0-45-generic x86_64\n'
stream.write(out)
stream.exit(0)
stream.end()
})
})
})
client.on('error', () => {})
})
await new Promise((resolve, reject) => {
srv.on('error', reject)
srv.listen(0, '127.0.0.1', () => {
serverPort = srv.address().port
resolve()
})
})
console.log(`[sysinfo] ssh server listening on 127.0.0.1:${serverPort}`)
// ---- 2. Load the real session layer + sysinfo engine ---------------------------
const sessionLayer = require_('./.session-e2e.cjs')
const events = []
sessionLayer.configureSessionRuntime({
broadcast: (channel, payload) => events.push({ channel, payload }),
getConnection: (id) => {
if (id !== 'conn-1') fail(`unexpected connectionId ${id}`)
return {
id: 'conn-1',
name: 'loopback',
host: '127.0.0.1',
port: serverPort,
username: 'test',
auth: 'password',
askPasswordAtConnect: false,
askPassphraseAtConnect: false,
keepaliveIntervalSec: 0,
createdAt: Date.now(),
savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false }
}
},
getSecret: (conn, field) => (field === 'password' ? 'test' : undefined),
touch: () => {},
knownHosts: {
check: (host, port, key) => {
const fp = 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '')
return { status: 'match', fingerprint: fp }
},
accept: () => {}
},
promptHostKey: () => fail('promptHostKey must not fire for matching keys')
})
const timer = setTimeout(() => fail('sysinfo e2e timed out'), 20000)
const openResult = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'conn-1' })
if (!openResult?.id) fail('openSession did not resolve with an id')
console.log(`[sysinfo] session open: ${openResult.id}`)
const seenMeta = (id) =>
events.some((e) => e.channel === 'sysinfo:meta' && e.payload?.id === id)
const samples = (id) =>
events.filter((e) => e.channel === 'sysinfo:sample' && e.payload?.id === id).map((e) => e.payload.sample)
// ---- 3. Start polling -----------------------------------------------------------
sessionLayer.startPolling(openResult.id, 200)
// META once the first sample has fields to build it from.
for (let i = 0; i < 100 && !seenMeta(openResult.id); i++) await wait(50)
if (!seenMeta(openResult.id)) fail('SYSINFO_META never broadcast')
const meta = events.find((e) => e.channel === 'sysinfo:meta' && e.payload?.id === openResult.id).payload.meta
if (meta.hostname !== 'loopback-host') fail(`meta.hostname wrong: ${meta.hostname}`)
if (!meta.os.includes('Linux') || !meta.os.includes('6.8.0')) fail(`meta.os wrong: ${meta.os}`)
console.log(`[sysinfo] SYSINFO_META ok: hostname=${meta.hostname} os="${meta.os}"`)
// At least two SYSINFO_SAMPLE.
for (let i = 0; i < 100 && samples(openResult.id).length < 2; i++) await wait(50)
const got = samples(openResult.id)
if (got.length < 2) fail(`expected >=2 samples, got ${got.length}`)
const first = got[0]
const second = got[1]
if (first.cpu.usage !== 0) fail(`first sample cpu.usage must be 0, got ${first.cpu.usage}`)
if (first.net.rxKbs !== 0 || first.net.txKbs !== 0) fail('first sample net rates must be 0')
if (!(second.cpu.usage > 0 && second.cpu.usage <= 100)) fail(`second cpu.usage out of range: ${second.cpu.usage}`)
if (!(second.net.rxKbs > 0 && second.net.rxKbs < 1_000_000)) fail(`second net.rxKbs unreasonable: ${second.net.rxKbs}`)
if (!(second.net.txKbs > 0 && second.net.txKbs < 1_000_000)) fail(`second net.txKbs unreasonable: ${second.net.txKbs}`)
if (second.cpu.cores !== 3) fail(`cpu.cores should be 3, got ${second.cpu.cores}`)
if (second.mem.totalMb !== Math.round(4000000 / 1024)) fail(`mem.totalMb wrong: ${second.mem.totalMb}`)
if (second.uptimeSec !== 12346 && second.uptimeSec !== 12345) fail(`uptimeSec wrong: ${second.uptimeSec}`)
// df must skip tmpfs + overlay pseudofs; only the two real mounts remain.
const mounts = second.disks.map((d) => d.mount).sort()
if (mounts.length !== 2 || mounts[0] !== '/' || mounts[1] !== '/home') {
fail(`df mounts wrong (pseudofs not filtered?): ${JSON.stringify(mounts)}`)
}
console.log(
`[sysinfo] samples ok: usage=${second.cpu.usage.toFixed(1)} cores=${second.cpu.cores} ` +
`rx=${second.net.rxKbs.toFixed(1)}kB/s tx=${second.net.txKbs.toFixed(1)}kB/s disks=${mounts.join(',')}`
)
// ---- 4. Stop polling: no further samples -----------------------------------------
const before = samples(openResult.id).length
sessionLayer.stopPolling(openResult.id)
await wait(400)
const after = samples(openResult.id).length
if (after !== before) fail(`stopPolling did not halt: got ${after - before} new samples`)
console.log('[sysinfo] stopPolling halts the sample stream')
clearTimeout(timer)
srv.close()
console.log('[sysinfo] ALL CHECKS PASSED')
process.exit(0)