Private
Public Access
1
0
Files
u-desk/frontend/src/api/connection-manager.ts

416 lines
13 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.
/**
* 连接管理器 — 管理本地/远程/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
}