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:
1 parent
f97edbb016
commit
06fa4ca023
19 files changed
+1632
-30
No files matched your search
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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' }
|
||||||
})
|
})
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
Reference in new issue
Block a user