feat: release OpsXShell 2.1.0 native Tauri edition
This commit is contained in:
1 parent
e9a0d21758
commit
a3dba0c6ed
311 files changed
+31748
-14897
No files matched your search
@@ -0,0 +1,211 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertCircle, CheckCircle2, Clock3, Loader2, Play, TerminalSquare } from 'lucide-react'
|
||||
import type { Session } from '../../stores/sessionStore'
|
||||
import { getSessionConfig } from '../../stores/connection'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import Modal from '../common/Modal'
|
||||
import Button from '../common/Button'
|
||||
|
||||
type BatchCommandStatus = 'idle' | 'running' | 'success' | 'error'
|
||||
|
||||
interface BatchCommandResult {
|
||||
sessionId: string
|
||||
status: BatchCommandStatus
|
||||
output?: string
|
||||
error?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
interface BatchCommandDialogProps {
|
||||
isOpen: boolean
|
||||
sessions: Session[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const MAX_CONCURRENCY = 5
|
||||
|
||||
export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchCommandDialogProps) {
|
||||
const [command, setCommand] = useState('')
|
||||
const [running, setRunning] = useState(false)
|
||||
const [results, setResults] = useState<BatchCommandResult[]>([])
|
||||
const timeout = useSettingsStore((state) => state.settings.timeout)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setResults(sessions.map((session) => ({ sessionId: session.id, status: 'idle' })))
|
||||
}, [isOpen, sessions])
|
||||
|
||||
const updateResult = useCallback((sessionId: string, updates: Partial<BatchCommandResult>) => {
|
||||
setResults((current) => current.map((result) =>
|
||||
result.sessionId === sessionId ? { ...result, ...updates } : result
|
||||
))
|
||||
}, [])
|
||||
|
||||
const executeForSession = useCallback(async (session: Session, commandText: string) => {
|
||||
const startedAt = Date.now()
|
||||
let connectionId: string | undefined
|
||||
updateResult(session.id, { status: 'running', output: undefined, error: undefined })
|
||||
|
||||
try {
|
||||
const config = await getSessionConfig(session.id)
|
||||
if (!config) throw new Error('无法读取会话配置')
|
||||
|
||||
const connected = await window.opsxApi.sshConnect({
|
||||
...config,
|
||||
timeoutSeconds: config.timeoutSeconds ?? timeout,
|
||||
keepAlive: false,
|
||||
})
|
||||
if (!connected.success || !connected.connectionId) {
|
||||
throw new Error(connected.error || '连接失败')
|
||||
}
|
||||
connectionId = connected.connectionId
|
||||
|
||||
const executed = await window.opsxApi.sshExec(connectionId, commandText)
|
||||
if (!executed.success) throw new Error(executed.error || '命令执行失败')
|
||||
|
||||
updateResult(session.id, {
|
||||
status: 'success',
|
||||
output: executed.result?.trimEnd() || '(无输出)',
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
} catch (error) {
|
||||
updateResult(session.id, {
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
} finally {
|
||||
if (connectionId) {
|
||||
await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}, [timeout, updateResult])
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
const commandText = command.trim()
|
||||
if (!commandText || running || sessions.length === 0) return
|
||||
|
||||
setRunning(true)
|
||||
setResults(sessions.map((session) => ({ sessionId: session.id, status: 'idle' })))
|
||||
|
||||
let nextIndex = 0
|
||||
const worker = async () => {
|
||||
while (nextIndex < sessions.length) {
|
||||
const session = sessions[nextIndex]
|
||||
nextIndex += 1
|
||||
await executeForSession(session, commandText)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(MAX_CONCURRENCY, sessions.length) }, () => worker())
|
||||
)
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}, [command, executeForSession, running, sessions])
|
||||
|
||||
const summary = useMemo(() => ({
|
||||
success: results.filter((result) => result.status === 'success').length,
|
||||
error: results.filter((result) => result.status === 'error').length,
|
||||
}), [results])
|
||||
|
||||
const handleClose = () => {
|
||||
if (!running) onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
title="批量命令"
|
||||
description={`${sessions.length} 台主机 · 最大并发 ${MAX_CONCURRENCY}`}
|
||||
icon={<TerminalSquare className="h-5 w-5" />}
|
||||
onClose={handleClose}
|
||||
showFooter
|
||||
showConfirm={false}
|
||||
disableBackdropClose={running}
|
||||
size="full"
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-xs text-txt-muted">
|
||||
{summary.success > 0 && <span className="text-accent-green">成功 {summary.success}</span>}
|
||||
{summary.success > 0 && summary.error > 0 && ' · '}
|
||||
{summary.error > 0 && <span className="text-red-400">失败 {summary.error}</span>}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleClose} disabled={running}>关闭</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Play className="h-3.5 w-3.5" />}
|
||||
onClick={() => void execute()}
|
||||
disabled={!command.trim()}
|
||||
loading={running}
|
||||
>
|
||||
{results.some((result) => result.status === 'success' || result.status === 'error')
|
||||
? '再次执行'
|
||||
: '执行命令'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="batch-command" className="mb-1.5 block text-xs font-medium text-content-secondary">
|
||||
远程命令
|
||||
</label>
|
||||
<textarea
|
||||
id="batch-command"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void execute()
|
||||
}
|
||||
}}
|
||||
disabled={running}
|
||||
rows={3}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder="例如:date"
|
||||
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-sm text-content-primary outline-none transition-colors focus:border-accent-blue disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[48vh] space-y-2 overflow-y-auto pr-1">
|
||||
{sessions.map((session) => {
|
||||
const result = results.find((item) => item.sessionId === session.id)
|
||||
const status = result?.status ?? 'idle'
|
||||
return (
|
||||
<div key={session.id} className="overflow-hidden rounded-lg border border-dark-700 bg-dark-900/65">
|
||||
<div className="flex min-h-10 items-center gap-2 border-b border-dark-700/70 px-3 py-2">
|
||||
{status === 'running' && <Loader2 className="h-4 w-4 animate-spin text-accent-blue" />}
|
||||
{status === 'success' && <CheckCircle2 className="h-4 w-4 text-accent-green" />}
|
||||
{status === 'error' && <AlertCircle className="h-4 w-4 text-red-400" />}
|
||||
{status === 'idle' && <Clock3 className="h-4 w-4 text-txt-faint" />}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content-primary">{session.name}</span>
|
||||
<span className="truncate font-mono text-[11px] text-txt-muted">
|
||||
{session.username}@{session.host}:{session.port || 22}
|
||||
</span>
|
||||
{result?.durationMs !== undefined && (
|
||||
<span className="text-[10px] tabular-nums text-txt-faint">{result.durationMs} ms</span>
|
||||
)}
|
||||
</div>
|
||||
{(result?.output || result?.error) && (
|
||||
<pre className={`max-h-40 overflow-auto whitespace-pre-wrap break-words px-3 py-2 text-xs leading-relaxed ${
|
||||
result.error ? 'text-red-300' : 'text-content-secondary'
|
||||
}`}>
|
||||
{result.error || result.output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user