Files
opsxshell/src/components/ConnectionPanel.tsx
T
Logan.Li 514d62644e v2.0.0: 主题系统+SSH私钥解析修复+会话持久化+连接池优化+UI打磨
核心修复:
- ssh-service.ts: 新增 resolvePrivateKey(),将私钥文件路径(~/.ssh/id_rsa)解析为内容后传给 ssh2,
  解决 'Cannot parse privateKey: Unsupported key format' 错误
- useTerminalConnection.ts + sessionHelper.ts: 根据 authType 过滤字段,password/agent 认证不再传 privateKey
- sessionStore.ts: addSession() 现在先调用 sessionCreate() 持久化到 SQLite,解决恢复标签'无可用连接信息'问题
- database-service.ts: updateSession() 补充 private_key_path 字段持久化
- SessionDialog.tsx: 默认 username='root'、privateKey='~/.ssh/id_rsa'

UI/UX:
- 语义化主题系统(app-theme.ts),强制深色模式,全局无边框设计
- macOS 标题栏支持双击最大化(window:toggleMaximize IPC)
- macOS 菜单栏('OpsXShell' 应用菜单 + Settings Cmd+,)
- WelcomeView 4卡片布局,移除 Settings 按钮
- TabBar/TabItem 背景色统一(bg-dark-800/900),消除白色边框线

架构优化:
- SFTP 生命周期修复(useSFTP.ts useRef + autoConnectAttempted guard)
- 连接池竞态修复,支持快速多标签连接
- IPC 接口规范化,安全错误处理
- Vite 代码分割优化

质量: lint零错误 | tsc零错误 | build成功 | 49 tests pass
2026-04-22 11:15:41 +08:00

336 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ConnectionPanel - 连接状态管理面板
*
* 右侧滑出面板,实时监控所有连接状态、流量统计、支持重连/断开操作。
*/
import { useState, useCallback, useMemo } from 'react'
import {
Wifi,
WifiOff,
RefreshCw,
X,
Clock,
HardDrive,
Activity,
} from 'lucide-react'
import { useConnectionStore } from '../stores/connection'
import { usePoolStore } from '../stores/connection/poolStore'
import { useConnectionPanelStore } from '../stores/connectionPanelStore'
import type { PooledConnection, ConnectionState } from '../stores/connection/types'
import EmptyState from './common/EmptyState'
// ============== Helpers ==============
/** 格式化字节 */
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const k = 1024
const i = Math.floor(Math.log(bytes) / Math.log(k))
const value = bytes / Math.pow(k, i)
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
/** 格式化连接时长 (ms → readable) */
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000)
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ${seconds % 60}s`
const hours = Math.floor(minutes / 60)
return `${hours}h ${minutes % 60}m`
}
// ============== Status Dot ==============
interface StatusDotProps {
state: ConnectionState
}
const STATUS_DOT_COLORS: Record<ConnectionState, string> = {
connected: 'bg-green-400',
connecting: 'bg-yellow-400 animate-pulse',
background: 'bg-green-400 opacity-60',
suspended: 'bg-gray-400',
error: 'bg-red-400',
disconnected: 'bg-gray-500',
reconnecting: 'bg-yellow-400 animate-pulse',
}
function StatusDot({ state }: StatusDotProps) {
return (
<span
className={`inline-block w-2 h-2 rounded-full ${STATUS_DOT_COLORS[state] ?? 'bg-gray-500'}`}
/>
)
}
// ============== Status Label ==============
const STATUS_LABELS: Record<ConnectionState, string> = {
connected: '已连接',
connecting: '连接中',
background: '后台',
suspended: '已挂起',
error: '错误',
disconnected: '已断开',
reconnecting: '重连中',
}
// ============== Connection Card ==============
interface ConnectionCardProps {
conn: PooledConnection
onReconnect: (id: string) => void
onDisconnect: (id: string) => void
reconnectingId: string | null
}
function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: ConnectionCardProps) {
const isReconnecting = reconnectingId === conn.connectionId
const canReconnect =
conn.state === 'suspended' || conn.state === 'error' || conn.state === 'disconnected'
const canDisconnect =
conn.state !== 'disconnected' && conn.state !== 'error'
const durationMs = Date.now() - conn.stats.connectedAt
const sessionCount = conn.shellIds.size + conn.sftpIds.size
return (
<div className="bg-dark-900 rounded-lg p-3 space-y-2.5">
{/* Header: host + status */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<StatusDot state={conn.state} />
<span className="text-sm text-gray-200 truncate font-medium">
{conn.config.username}@{conn.config.host}
</span>
</div>
<span
className={`text-xs px-1.5 py-0.5 rounded ${
conn.state === 'connected'
? 'bg-green-400/10 text-green-400'
: conn.state === 'connecting' || conn.state === 'reconnecting'
? 'bg-yellow-400/10 text-yellow-400'
: conn.state === 'background'
? 'bg-green-400/10 text-green-400 opacity-70'
: conn.state === 'suspended'
? 'bg-gray-400/10 text-gray-400'
: conn.state === 'error'
? 'bg-red-400/10 text-red-400'
: 'bg-gray-500/10 text-gray-500'
}`}
>
{STATUS_LABELS[conn.state]}
</span>
</div>
{/* Details */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-400">
{/* Port */}
<span className="flex items-center gap-1">
<Activity className="w-3 h-3" />
:{conn.config.port ?? 22}
</span>
{/* Duration */}
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDuration(durationMs)}
</span>
{/* Transfer */}
{conn.stats.bytesTransferred > 0 && (
<span className="flex items-center gap-1">
<HardDrive className="w-3 h-3" />
{formatBytes(conn.stats.bytesTransferred)}
</span>
)}
{/* Sessions */}
{sessionCount > 0 && (
<span>
{conn.shellIds.size} shell{conn.sftpIds.size > 0 ? ` / ${conn.sftpIds.size} sftp` : ''}
</span>
)}
{/* Ref count */}
{conn.refCount > 1 && <span>×{conn.refCount} 引用</span>}
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
{canReconnect && (
<button
type="button"
onClick={() => onReconnect(conn.connectionId)}
disabled={isReconnecting}
className="flex items-center gap-1 px-2 py-1 rounded text-xs
bg-accent-blue/10 text-accent-blue hover:bg-accent-blue/20
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
{isReconnecting ? '重连中...' : '重连'}
</button>
)}
{canDisconnect && (
<button
type="button"
onClick={() => onDisconnect(conn.connectionId)}
className="flex items-center gap-1 px-2 py-1 rounded text-xs
bg-red-400/10 text-red-400 hover:bg-red-400/20 transition-colors"
>
<WifiOff className="w-3 h-3" />
断开
</button>
)}
</div>
{/* Error message */}
{conn.error && (
<p className="text-xs text-red-400 truncate" title={conn.error}>
{conn.error}
</p>
)}
</div>
)
}
// ============== Stats Overview ==============
interface StatsBarProps {
total: number
connected: number
background: number
error: number
}
function StatsOverview({ total, connected, background, error }: StatsBarProps) {
const items = [
{ label: '总计', value: total, color: 'text-gray-300' },
{ label: '活跃', value: connected, color: 'text-green-400' },
{ label: '后台', value: background, color: 'text-yellow-400' },
{ label: '断开/错误', value: error, color: 'text-red-400' },
]
return (
<div className="grid grid-cols-4 gap-2">
{items.map((item) => (
<div key={item.label} className="bg-dark-900 rounded-lg p-2 text-center">
<div className={`text-lg font-semibold ${item.color}`}>{item.value}</div>
<div className="text-[10px] text-gray-500">{item.label}</div>
</div>
))}
</div>
)
}
// ============== Main Panel ==============
export default function ConnectionPanel() {
const isOpen = useConnectionPanelStore((s) => s.isOpen)
const close = useConnectionPanelStore((s) => s.close)
const connections = usePoolStore((s) => s.connections)
const {
resumeConnection,
disconnectConnection,
getStats,
} = useConnectionStore()
const [reconnectingId, setReconnectingId] = useState<string | null>(null)
// Derive connections list and stats
const { connList, stats } = useMemo(() => {
const list = Array.from(connections.values())
const s = getStats()
return { connList: list, stats: s }
}, [connections, getStats])
// Handlers
const handleReconnect = useCallback(
async (connectionId: string) => {
setReconnectingId(connectionId)
try {
await resumeConnection(connectionId)
} finally {
setReconnectingId(null)
}
},
[resumeConnection],
)
const handleDisconnect = useCallback(
async (connectionId: string) => {
await disconnectConnection(connectionId)
},
[disconnectConnection],
)
if (!isOpen) return null
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/30 z-40 animate-fade-in"
onClick={close}
/>
{/* Panel */}
<div
className="fixed top-0 right-0 bottom-0 w-[360px] z-50
bg-dark-800/95 backdrop-blur border-l border-dark-700/30
flex flex-col shadow-xl animate-slide-in-right"
>
{/* Header */}
<div className="flex items-center justify-between px-4 h-12 border-b border-dark-700/30 shrink-0">
<h2 className="text-sm font-semibold text-gray-200 flex items-center gap-2">
<Wifi className="w-4 h-4 text-accent-blue" />
连接管理
</h2>
<button
type="button"
onClick={close}
className="p-1 rounded hover:bg-dark-700 text-gray-400 hover:text-gray-200 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Stats Overview */}
<div className="px-4 py-3 border-b border-dark-700/30 shrink-0">
<StatsOverview
total={stats.total}
connected={stats.connected}
background={stats.background}
error={stats.error + stats.suspended}
/>
</div>
{/* Connection List */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
{connList.length === 0 ? (
<EmptyState
icon={WifiOff}
title="暂无连接"
description="当前没有任何活跃连接,连接到服务器后将在此显示。"
/>
) : (
connList.map((conn) => (
<ConnectionCard
key={conn.connectionId}
conn={conn}
onReconnect={handleReconnect}
onDisconnect={handleDisconnect}
reconnectingId={reconnectingId}
/>
))
)}
</div>
</div>
</>
)
}