核心修复:
- 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
336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
/**
|
||
* 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>
|
||
</>
|
||
)
|
||
}
|