416 lines
13 KiB
TypeScript
416 lines
13 KiB
TypeScript
/**
|
||
* 连接管理器 — 管理本地/远程/SFTP 传输层切换
|
||
* 配置持久化:SQLite(后端),local profile 仅内存
|
||
*/
|
||
|
||
import type { FsTransport } from './transport'
|
||
import { WailsTransport } from './wails-transport'
|
||
import { HttpTransport } from './http-transport'
|
||
import { SftpTransport } from './sftp-transport'
|
||
import { OssTransport } from './oss-transport'
|
||
import { getFileServerBaseURL } from './file-server'
|
||
import {
|
||
LoadConnectionProfiles, SaveConnectionProfile, DeleteConnectionProfile,
|
||
SftpGetSystemInfo, GetLocalSystemInfo,
|
||
} from '@bindings/u-desk/app'
|
||
|
||
export type ConnectionType = 'local' | 'remote' | 'sftp' | 'qiniu' | 'aliyun'
|
||
|
||
export interface ConnectionProfile {
|
||
id: string | number
|
||
name: string
|
||
host: string
|
||
port: number
|
||
token: string
|
||
type: ConnectionType
|
||
username?: string
|
||
password?: string
|
||
keyPath?: string
|
||
accessKey?: string
|
||
secretKey?: string
|
||
bucket?: string
|
||
region?: string
|
||
endpoint?: string
|
||
lastConnected?: number
|
||
}
|
||
|
||
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||
|
||
export interface SystemInfo {
|
||
cpuCores?: number
|
||
cpuUsage?: string
|
||
memTotal?: number
|
||
memUsed?: number
|
||
memUsage?: string
|
||
diskTotal?: number
|
||
diskUsed?: number
|
||
diskUsage?: string
|
||
_error?: boolean
|
||
_errorMsg?: string
|
||
}
|
||
|
||
class ConnectionManagerImpl {
|
||
/** 连接池:profileId → transport 实例 */
|
||
private _pool = new Map<string, FsTransport>()
|
||
private _profiles: ConnectionProfile[] = []
|
||
private _activeId: string | null = null
|
||
private _state: ConnectionState = 'disconnected'
|
||
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
|
||
private _sysInfoChangeCallbacks: ((profileId: string, info: SystemInfo) => void)[] = []
|
||
private _sysInfoCache = new Map<string, SystemInfo>()
|
||
|
||
constructor() {
|
||
this.initDefaultLocal()
|
||
this.loadFromDB()
|
||
}
|
||
|
||
private initDefaultLocal() {
|
||
const localProfile: ConnectionProfile = {
|
||
id: 'local-default',
|
||
name: '本地',
|
||
host: '',
|
||
port: 0,
|
||
token: '',
|
||
type: 'local',
|
||
}
|
||
if (!this._profiles.find(p => p.id === localProfile.id)) {
|
||
this._profiles.unshift(localProfile)
|
||
}
|
||
if (!this._activeId) {
|
||
this._activeId = localProfile.id
|
||
}
|
||
// local 直接入池,无需连接
|
||
this._pool.set('local-default', new WailsTransport())
|
||
this.setState('connected')
|
||
}
|
||
|
||
/** 从 SQLite 加载配置 */
|
||
private async loadFromDB() {
|
||
try {
|
||
const list = await LoadConnectionProfiles()
|
||
if (list && list.length > 0) {
|
||
this._profiles = list.map((p: any) => ({
|
||
...p,
|
||
id: String(p.id),
|
||
lastConnected: p.lastConnected || p.last_connected ? new Date(p.lastConnected || p.last_connected).getTime() : undefined,
|
||
}))
|
||
const hasLocal = this._profiles.some(p => p.type === 'local')
|
||
if (!hasLocal) {
|
||
this._profiles.unshift({
|
||
id: 'local-default', name: '本地', host: '', port: 0, token: '', type: 'local',
|
||
})
|
||
}
|
||
}
|
||
} catch { /* 首次使用 */ }
|
||
this.notifyChange()
|
||
this._profiles.forEach(p => {
|
||
if (p.type === 'local') { this.fetchSystemInfo(p.id).catch(() => {}) }
|
||
})
|
||
const autoConnect = localStorage.getItem('desk:autoConnect')
|
||
if (autoConnect !== 'false') {
|
||
for (const p of this._profiles) {
|
||
if (p.type !== 'local') {
|
||
this.buildAndPool(String(p.id), p).catch(() => {})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 保存/更新单个 profile 到 SQLite */
|
||
private async persistProfile(profile: ConnectionProfile) {
|
||
if (profile.type === 'local') return
|
||
const id = profile.id !== 'local-default' ? Number(profile.id) : 0
|
||
await SaveConnectionProfile({
|
||
id: id > 0 ? id : undefined,
|
||
name: profile.name,
|
||
host: profile.host,
|
||
port: profile.port,
|
||
username: profile.username || 'root',
|
||
password: profile.password || '',
|
||
keyPath: profile.keyPath || '',
|
||
type: profile.type,
|
||
token: profile.token || '',
|
||
access_key: profile.accessKey || '',
|
||
secret_key: profile.secretKey || '',
|
||
bucket: profile.bucket || '',
|
||
region: profile.region || '',
|
||
endpoint: profile.endpoint || '',
|
||
lastConnected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined,
|
||
})
|
||
}
|
||
|
||
/** 从 SQLite 删除 profile */
|
||
private async removePersisted(id: string) {
|
||
if (id === 'local-default') return
|
||
await DeleteConnectionProfile(Number(id))
|
||
}
|
||
|
||
private setState(state: ConnectionState) {
|
||
this._state = state
|
||
this.notifyChange()
|
||
}
|
||
|
||
private notifyChange() {
|
||
this._stateChangeCallbacks.forEach(cb => cb(this._state))
|
||
}
|
||
|
||
onStateChange(cb: (state: ConnectionState) => void) {
|
||
this._stateChangeCallbacks.push(cb)
|
||
}
|
||
|
||
onSystemInfoChange(cb: (profileId: string, info: SystemInfo) => void) {
|
||
this._sysInfoChangeCallbacks.push(cb)
|
||
}
|
||
|
||
get state(): ConnectionState { return this._state }
|
||
|
||
get profiles(): ConnectionProfile[] { return [...this._profiles] }
|
||
|
||
get activeProfile(): ConnectionProfile | null {
|
||
return this._profiles.find(p => p.id === this._activeId) ?? null
|
||
}
|
||
|
||
/** 获取当前激活的 transport(从池中取) */
|
||
getTransport(): FsTransport {
|
||
if (this._activeId && this._pool.has(this._activeId)) {
|
||
return this._pool.get(this._activeId)!
|
||
}
|
||
return this._pool.get('local-default')!
|
||
}
|
||
|
||
/** 获取指定 profile 的 transport(用于跨 profile 操作如采集系统信息) */
|
||
getTransportFor(profileId: string): FsTransport | null {
|
||
return this._pool.get(profileId) ?? null
|
||
}
|
||
|
||
getFileServerBaseURL(): string {
|
||
const t = this.getTransport()
|
||
if (t instanceof HttpTransport) {
|
||
const profile = this.activeProfile
|
||
if (!profile) return ''
|
||
const scheme = profile.port === 443 ? 'https' : 'http'
|
||
const port = (profile.port === 80 || profile.port === 443) ? '' : `:${profile.port}`
|
||
return `${scheme}://${profile.host}${port}`
|
||
}
|
||
if (t instanceof SftpTransport) { return getFileServerBaseURL() }
|
||
return ''
|
||
}
|
||
|
||
isSftp(): boolean { return this.activeProfile?.type === 'sftp' }
|
||
|
||
isRemote(): boolean {
|
||
const t = this.activeProfile?.type
|
||
return t === 'remote' || t === 'sftp' || t === 'qiniu' || t === 'aliyun'
|
||
}
|
||
|
||
getSystemInfo(profileId: string): SystemInfo | undefined {
|
||
return this._sysInfoCache.get(profileId)
|
||
}
|
||
|
||
async fetchSystemInfo(profileId: string): Promise<SystemInfo> {
|
||
const profile = this._profiles.find(p => p.id === profileId)
|
||
if (!profile) return { _error: true }
|
||
const info: SystemInfo = {}
|
||
try {
|
||
if (profile.type === 'local') {
|
||
const data = await GetLocalSystemInfo()
|
||
if (data) Object.assign(info, data)
|
||
} else if (profile.type === 'sftp') {
|
||
const t = this.getTransportFor(profileId)
|
||
if (t instanceof SftpTransport && t.sessionId) {
|
||
const data = await SftpGetSystemInfo(t.sessionId)
|
||
if (data) Object.assign(info, snakeToCamel(data))
|
||
}
|
||
} else if (profile.type === 'qiniu' || profile.type === 'aliyun') {
|
||
// OSS 无系统信息可采集
|
||
info.diskUsage = '-'
|
||
info.cpuUsage = '-'
|
||
info.memUsage = '-'
|
||
} else if (profile.type === 'remote') {
|
||
const t = this.getTransportFor(profileId)
|
||
if (t instanceof HttpTransport) {
|
||
const baseUrl = this.getFileServerBaseURL()
|
||
if (baseUrl) {
|
||
const token = profile.token || ''
|
||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||
const res = await fetch(`${baseUrl}/api/v1/system/stats`, { headers })
|
||
if (res.ok) {
|
||
const json = await res.json()
|
||
const data = json.data ?? json
|
||
if (data) Object.assign(info, snakeToCamel(data))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { info._error = true; info._errorMsg = e instanceof Error ? e.message : String(e) }
|
||
|
||
this._sysInfoCache.set(profileId, info)
|
||
this._sysInfoChangeCallbacks.forEach(cb => cb(profileId, info))
|
||
return info
|
||
}
|
||
|
||
/**
|
||
* 切换到指定 profile(连接池模式)
|
||
* 池中有 → 直接复用,瞬间切换
|
||
* 池中无 → 新建连接并存入池
|
||
*/
|
||
async connect(profileId: string): Promise<void> {
|
||
const profile = this._profiles.find(p => p.id === profileId)
|
||
if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`))
|
||
|
||
// 池中已有,直接复用
|
||
if (this._pool.has(profileId)) {
|
||
this._activeId = profileId
|
||
this.setState('connected')
|
||
return
|
||
}
|
||
|
||
// 新建连接并入池(成功后再设 activeId)
|
||
await this.buildAndPool(profileId, profile)
|
||
this._activeId = profileId
|
||
}
|
||
|
||
/** 断开指定 profile 并从池移除 */
|
||
disconnectProfile(profileId: string): void {
|
||
if (profileId === 'local-default') return
|
||
const t = this._pool.get(profileId)
|
||
if (t instanceof SftpTransport) { t.disconnect() }
|
||
if (t instanceof OssTransport) { t.disconnect() }
|
||
this._pool.delete(profileId)
|
||
}
|
||
|
||
/** 断开所有远程连接(保留 local) */
|
||
disconnectAll(): void {
|
||
for (const [id, t] of this._pool) {
|
||
if (id !== 'local-default') {
|
||
if (t instanceof SftpTransport || t instanceof OssTransport) t.disconnect()
|
||
}
|
||
}
|
||
this._pool.clear()
|
||
this._pool.set('local-default', new WailsTransport())
|
||
this._activeId = 'local-default'
|
||
this.setState('connected')
|
||
}
|
||
|
||
disconnect(): void {
|
||
this.disconnectAll()
|
||
}
|
||
|
||
async addProfile(profile: Omit<ConnectionProfile, 'id'>): Promise<ConnectionProfile> {
|
||
const newProfile: ConnectionProfile = { ...profile, id: crypto.randomUUID() }
|
||
this._profiles.push(newProfile)
|
||
await this.persistProfile(newProfile)
|
||
this.notifyChange()
|
||
return newProfile
|
||
}
|
||
|
||
async updateProfile(id: string, updates: Partial<ConnectionProfile>): Promise<void> {
|
||
const idx = this._profiles.findIndex(p => p.id === id)
|
||
if (idx >= 0) {
|
||
this._profiles[idx] = { ...this._profiles[idx], ...updates }
|
||
await this.persistProfile(this._profiles[idx])
|
||
// 连接参数变更 → 淘汰旧连接,下次 connect 时重建
|
||
const EVICT_KEYS = ['host', 'port', 'token', 'username', 'password', 'keyPath', 'accessKey', 'secretKey', 'bucket', 'region', 'endpoint'] as const
|
||
if (EVICT_KEYS.some(k => k in updates)) {
|
||
this.disconnectProfile(id)
|
||
if (id === this._activeId) {
|
||
this.connect(id).catch(err => console.warn('[SFTP] 编辑后重连失败:', err))
|
||
}
|
||
}
|
||
this.notifyChange()
|
||
}
|
||
}
|
||
|
||
async removeProfile(id: string): Promise<void> {
|
||
if (id === 'local-default') return
|
||
this._profiles = this._profiles.filter(p => p.id !== id)
|
||
this.disconnectProfile(id)
|
||
if (this._activeId === id) {
|
||
this._activeId = 'local-default'
|
||
this.setState('connected')
|
||
}
|
||
await this.removePersisted(id)
|
||
this.notifyChange()
|
||
}
|
||
|
||
/** 新建 transport 并存入连接池 */
|
||
private async buildAndPool(profileId: string, profile: ConnectionProfile): Promise<void> {
|
||
if (profile.type === 'local') {
|
||
this._pool.set(profileId, new WailsTransport())
|
||
this.setState('connected')
|
||
return
|
||
}
|
||
|
||
if (profile.type === 'sftp') {
|
||
if (!profile.password && !profile.keyPath) {
|
||
this.setState('error')
|
||
return
|
||
}
|
||
this.setState('connecting')
|
||
try {
|
||
const t = new SftpTransport(profile)
|
||
await t.connect()
|
||
this._pool.set(profileId, t)
|
||
this.setState('connected')
|
||
this.updateProfile(profileId, { lastConnected: Date.now() })
|
||
this.fetchSystemInfo(profileId).catch(() => {})
|
||
} catch (err) {
|
||
this._pool.delete(profileId)
|
||
this.setState('error')
|
||
throw err
|
||
}
|
||
return
|
||
}
|
||
|
||
// OSS (qiniu / aliyun)
|
||
if (profile.type === 'qiniu' || profile.type === 'aliyun') {
|
||
this.setState('connecting')
|
||
try {
|
||
const t = new OssTransport(profile)
|
||
await t.connect()
|
||
this._pool.set(profileId, t)
|
||
this.setState('connected')
|
||
this.updateProfile(profileId, { lastConnected: Date.now() })
|
||
} catch (err) {
|
||
this._pool.delete(profileId)
|
||
this.setState('error')
|
||
throw err
|
||
}
|
||
return
|
||
}
|
||
|
||
// remote / http agent
|
||
this.setState('connecting')
|
||
try {
|
||
const t = new HttpTransport(profile.host, profile.port, profile.token)
|
||
this._pool.set(profileId, t)
|
||
// 验证连接可用性
|
||
t.getFileInfo('/').then(() => {
|
||
if (this._activeId !== profileId) return
|
||
this.setState('connected')
|
||
this.updateProfile(profileId, { lastConnected: Date.now() })
|
||
this.fetchSystemInfo(profileId).catch(() => {})
|
||
}).catch(() => {
|
||
if (this._activeId !== profileId) return
|
||
this.setState('error')
|
||
})
|
||
} catch (err) {
|
||
this._pool.delete(profileId)
|
||
this.setState('error')
|
||
throw err
|
||
}
|
||
}
|
||
}
|
||
|
||
export const connectionManager = new ConnectionManagerImpl()
|
||
|
||
/** snake_case → camelCase */
|
||
function snakeToCamel(obj: Record<string, any>): Record<string, any> {
|
||
const result: Record<string, any> = {}
|
||
for (const [k, v] of Object.entries(obj)) {
|
||
result[k.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = v
|
||
}
|
||
return result
|
||
}
|