@electerm/electerm-react 3.1.26 → 3.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/common/constants.js +1 -3
- package/client/common/db.js +4 -2
- package/client/components/ai/ai-history.jsx +4 -4
- package/client/components/batch-op/batch-op-alert.jsx +42 -0
- package/client/components/batch-op/batch-op-editor.jsx +202 -0
- package/client/components/batch-op/batch-op-logs.jsx +53 -0
- package/client/components/batch-op/batch-op-runner.jsx +315 -0
- package/client/components/bookmark-form/ai-bookmark-form.jsx +2 -1
- package/client/components/bookmark-form/bookmark-from-history-modal.jsx +2 -1
- package/client/components/bookmark-form/common/bookmark-select.jsx +18 -2
- package/client/components/bookmark-form/common/connection-hopping-form.jsx +153 -0
- package/client/components/bookmark-form/common/connection-hopping.jsx +136 -129
- package/client/components/common/auto-check-update.jsx +31 -0
- package/client/components/common/notification.styl +1 -1
- package/client/components/file-transfer/conflict-resolve.jsx +3 -0
- package/client/components/footer/batch-input.jsx +10 -7
- package/client/components/main/error-wrapper.jsx +18 -7
- package/client/components/main/main.jsx +6 -7
- package/client/components/quick-commands/qm.styl +0 -2
- package/client/components/quick-commands/quick-commands-list-form.jsx +1 -1
- package/client/components/setting-panel/hotkey.jsx +9 -1
- package/client/components/setting-panel/list.jsx +0 -1
- package/client/components/setting-panel/list.styl +4 -0
- package/client/components/setting-panel/setting-modal.jsx +53 -47
- package/client/components/setting-sync/auto-sync.jsx +53 -0
- package/client/components/setting-sync/data-import.jsx +69 -8
- package/client/components/sftp/address-bar.jsx +7 -1
- package/client/components/shortcuts/shortcut-editor.jsx +4 -2
- package/client/components/sidebar/bookmark-select.jsx +3 -2
- package/client/components/sidebar/history-item.jsx +3 -1
- package/client/components/sidebar/history.jsx +1 -0
- package/client/components/sidebar/index.jsx +0 -9
- package/client/components/tabs/add-btn-menu.jsx +1 -1
- package/client/components/tabs/add-btn.jsx +9 -15
- package/client/components/tabs/quick-connect.jsx +6 -10
- package/client/components/terminal/attach-addon-custom.js +86 -0
- package/client/components/terminal/cmd-item.jsx +13 -3
- package/client/components/terminal/drop-file-modal.jsx +57 -0
- package/client/components/terminal/terminal-command-dropdown.jsx +91 -13
- package/client/components/terminal/terminal.jsx +107 -10
- package/client/components/terminal/terminal.styl +9 -0
- package/client/components/tree-list/tree-list-item.jsx +0 -1
- package/client/components/tree-list/tree-list.jsx +115 -10
- package/client/components/tree-list/tree-list.styl +3 -0
- package/client/components/tree-list/tree-search.jsx +9 -1
- package/client/components/vnc/vnc-session.jsx +2 -0
- package/client/components/widgets/widget-control.jsx +3 -0
- package/client/components/widgets/widget-form.jsx +6 -0
- package/client/components/widgets/widget-instance.jsx +26 -7
- package/client/css/includes/box.styl +3 -0
- package/client/store/common.js +0 -28
- package/client/store/init-state.js +2 -1
- package/client/store/load-data.js +6 -4
- package/client/store/mcp-handler.js +20 -2
- package/client/store/sync.js +25 -1
- package/client/store/tab.js +1 -1
- package/client/store/watch.js +10 -18
- package/client/store/widgets.js +54 -0
- package/client/views/index.pug +1 -2
- package/package.json +1 -1
- package/client/components/batch-op/batch-op.jsx +0 -694
|
@@ -242,15 +242,13 @@ export const quickCommandLabelsLsKey = 'quick-command-label'
|
|
|
242
242
|
export const localAddrBookmarkLsKey = 'local-addr-bookmark-keys'
|
|
243
243
|
export const dismissDelKeyTipLsKey = 'dismiss-del-key-tip'
|
|
244
244
|
export const sshTunnelHelpLink = 'https://github.com/electerm/electerm/wiki/How-to-use-ssh-tunnel'
|
|
245
|
-
export const batchOpHelpLink = 'https://github.com/electerm/electerm/wiki/batch-operation'
|
|
246
245
|
export const proxyHelpLink = 'https://github.com/electerm/electerm/wiki/proxy-format'
|
|
247
246
|
export const regexHelpLink = 'https://github.com/electerm/electerm/wiki/Terminal-keywords-highlight-regular-expression-exmaples'
|
|
248
247
|
export const connectionHoppingWikiLink = 'https://github.com/electerm/electerm/wiki/Connection-Hopping-Behavior-Change-in-electerm-since-v1.50.65'
|
|
249
248
|
export const aiConfigWikiLink = 'https://github.com/electerm/electerm/wiki/AI-model-config-guide'
|
|
250
249
|
export const modals = {
|
|
251
250
|
hide: 0,
|
|
252
|
-
setting: 1
|
|
253
|
-
batchOps: 2
|
|
251
|
+
setting: 1
|
|
254
252
|
}
|
|
255
253
|
export const instSftpKeys = [
|
|
256
254
|
'connect',
|
package/client/common/db.js
CHANGED
|
@@ -31,7 +31,8 @@ export const dbNames = [
|
|
|
31
31
|
),
|
|
32
32
|
'history',
|
|
33
33
|
'terminalCommandHistory',
|
|
34
|
-
'aiChatHistory'
|
|
34
|
+
'aiChatHistory',
|
|
35
|
+
'autoRunWidgets'
|
|
35
36
|
]
|
|
36
37
|
export const dbNamesForSync = [
|
|
37
38
|
...without(
|
|
@@ -45,7 +46,8 @@ export const dbNamesForWatch = [
|
|
|
45
46
|
...dbNamesForSync,
|
|
46
47
|
'history',
|
|
47
48
|
'terminalCommandHistory',
|
|
48
|
-
'aiChatHistory'
|
|
49
|
+
'aiChatHistory',
|
|
50
|
+
'autoRunWidgets'
|
|
49
51
|
]
|
|
50
52
|
|
|
51
53
|
/**
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
import { useState, useEffect } from 'react'
|
|
5
5
|
import { Space } from 'antd'
|
|
6
6
|
import { HistoryOutlined } from '@ant-design/icons'
|
|
7
|
-
import {
|
|
7
|
+
import { safeGetItemJSON, safeSetItemJSON } from '../../common/safe-local-storage'
|
|
8
8
|
import AiHistoryItem from './ai-history-item'
|
|
9
9
|
|
|
10
10
|
const MAX_HISTORY = 20
|
|
11
11
|
const e = window.translate
|
|
12
12
|
|
|
13
13
|
export function getHistory (storageKey) {
|
|
14
|
-
return
|
|
14
|
+
return safeGetItemJSON(storageKey, [])
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function addHistoryItem (storageKey, itemData, eventName) {
|
|
@@ -37,7 +37,7 @@ export function addHistoryItem (storageKey, itemData, eventName) {
|
|
|
37
37
|
if (history.length > MAX_HISTORY) {
|
|
38
38
|
history = history.slice(0, MAX_HISTORY)
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
safeSetItemJSON(storageKey, history)
|
|
41
41
|
|
|
42
42
|
// Custom event to trigger update
|
|
43
43
|
if (eventName) {
|
|
@@ -72,7 +72,7 @@ export default function AiHistory (props) {
|
|
|
72
72
|
return hStr !== itemStr
|
|
73
73
|
})
|
|
74
74
|
setHistory(newHistory)
|
|
75
|
-
|
|
75
|
+
safeSetItemJSON(storageKey, newHistory)
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
if (!history.length) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { Alert, Button } from 'antd'
|
|
3
|
+
import ExternalLink from '../common/external-link'
|
|
4
|
+
|
|
5
|
+
const batchOpWikiLink = 'https://github.com/electerm/electerm/wiki/batch-operation'
|
|
6
|
+
|
|
7
|
+
export default function BatchOpAlert () {
|
|
8
|
+
const [expanded, setExpanded] = useState(false)
|
|
9
|
+
|
|
10
|
+
const description = (
|
|
11
|
+
<div>
|
|
12
|
+
<div>
|
|
13
|
+
<p>Actions: <code>connect, command, sftp_upload, sftp_download</code></p>
|
|
14
|
+
<p><ExternalLink to={batchOpWikiLink}>{batchOpWikiLink}</ExternalLink></p>
|
|
15
|
+
</div>
|
|
16
|
+
{expanded && (
|
|
17
|
+
<div className='mg1t'>
|
|
18
|
+
<p><strong>connect</strong> params: <code>host, port, username, authType, password, privateKey, passphrase, certificate, profile, enableSftp, enableSsh, useSshAgent, sshAgent, term, encode, envLang, setEnv, startDirectoryRemote, startDirectoryLocal, proxy, x11, displayRaw, sshTunnels, connectionHoppings</code></p>
|
|
19
|
+
<p><strong>command</strong> params: <code>command</code></p>
|
|
20
|
+
<p><strong>sftp_upload</strong> params: <code>localPath, remotePath</code></p>
|
|
21
|
+
<p><strong>sftp_download</strong> params: <code>remotePath, localPath</code></p>
|
|
22
|
+
</div>
|
|
23
|
+
)}
|
|
24
|
+
<Button
|
|
25
|
+
size='small'
|
|
26
|
+
className='mg1y'
|
|
27
|
+
onClick={() => setExpanded(v => !v)}
|
|
28
|
+
>
|
|
29
|
+
{expanded ? 'Show less' : 'Show more'}
|
|
30
|
+
</Button>
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Alert
|
|
36
|
+
description={description}
|
|
37
|
+
type='info'
|
|
38
|
+
showIcon
|
|
39
|
+
className='mg1b'
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch Operation Editor Component
|
|
3
|
+
* Self-contained workflow editor: handles execute, external editors, and progress logs
|
|
4
|
+
*/
|
|
5
|
+
import React, { useCallback, useState, useEffect } from 'react'
|
|
6
|
+
import { Button, Flex } from 'antd'
|
|
7
|
+
import {
|
|
8
|
+
PlayCircleOutlined
|
|
9
|
+
} from '@ant-design/icons'
|
|
10
|
+
import SimpleEditor from '../text-editor/simple-editor'
|
|
11
|
+
import EditWithCustomEditor from '../text-editor/edit-with-custom-editor'
|
|
12
|
+
import BatchOpAlert from './batch-op-alert'
|
|
13
|
+
import BatchOpLogs from './batch-op-logs'
|
|
14
|
+
import message from '../common/message'
|
|
15
|
+
import { refsStatic } from '../common/ref'
|
|
16
|
+
import generate from '../../common/uid'
|
|
17
|
+
import fs from '../../common/fs'
|
|
18
|
+
|
|
19
|
+
const workflowExample = `[
|
|
20
|
+
{
|
|
21
|
+
"name": "Connect SSH",
|
|
22
|
+
"action": "connect",
|
|
23
|
+
"params": {
|
|
24
|
+
"host": "192.168.1.100",
|
|
25
|
+
"port": 22,
|
|
26
|
+
"username": "root",
|
|
27
|
+
"authType": "password",
|
|
28
|
+
"password": "your_password"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "Create 5M Test File",
|
|
33
|
+
"action": "command",
|
|
34
|
+
"afterDelay": 500,
|
|
35
|
+
"prevDelay": 500,
|
|
36
|
+
"command": "fallocate -l 5M /tmp/test_5m_file.bin && rm -f /tmp/test_log.log && echo '[LOG] Created 5M test file at $(date)' >> /tmp/test_log.log"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "Log creation",
|
|
40
|
+
"action": "command",
|
|
41
|
+
"command": "ls -la /tmp/test_5m_file.bin >> /tmp/test_log.log 2>&1 && echo '[LOG] File size logged at $(date)' >> /tmp/test_log.log"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "Download 5M File",
|
|
45
|
+
"action": "sftp_download",
|
|
46
|
+
"afterDelay": 200,
|
|
47
|
+
"remotePath": "/tmp/test_5m_file.bin",
|
|
48
|
+
"localPath": "/tmp/test_5m_file.bin"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "Log after download",
|
|
52
|
+
"action": "command",
|
|
53
|
+
"afterDelay": 200,
|
|
54
|
+
"command": "echo '[LOG] Download complete at $(date)' >> /tmp/test_log.log"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"name": "Delete Remote 5M File",
|
|
58
|
+
"action": "command",
|
|
59
|
+
"afterDelay": 200,
|
|
60
|
+
"command": "rm /tmp/test_5m_file.bin && echo '[LOG] Deleted remote 5M file at $(date)' >> /tmp/test_log.log"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "Upload Downloaded File to Remote",
|
|
64
|
+
"action": "sftp_upload",
|
|
65
|
+
"afterDelay": 200,
|
|
66
|
+
"localPath": "/tmp/test_5m_file.bin",
|
|
67
|
+
"remotePath": "/tmp/test_5m_file_uploaded.bin"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"name": "Log after upload",
|
|
71
|
+
"action": "command",
|
|
72
|
+
"afterDelay": 200,
|
|
73
|
+
"command": "echo '[LOG] Upload complete at $(date)' >> /tmp/test_log.log"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"name": "Verify and clean up",
|
|
77
|
+
"action": "command",
|
|
78
|
+
"command": "ls -la /tmp/test_5m_file_uploaded.bin >> /tmp/test_log.log 2>&1 && rm -f /tmp/test_5m_file*.bin && echo '[LOG] Cleaned up at $(date)' >> /tmp/test_log.log"
|
|
79
|
+
}
|
|
80
|
+
]`
|
|
81
|
+
|
|
82
|
+
function getDefaultValue (widget) {
|
|
83
|
+
if (widget?.info?.configs) {
|
|
84
|
+
const config = widget.info.configs.find(c => c.name === 'workflowJson')
|
|
85
|
+
if (config?.default) return config.default
|
|
86
|
+
}
|
|
87
|
+
return ''
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default function BatchOpEditor ({ widget }) {
|
|
91
|
+
const [value, setValue] = useState(() => getDefaultValue(widget))
|
|
92
|
+
const [executing, setExecuting] = useState(false)
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const v = getDefaultValue(widget)
|
|
96
|
+
if (v) setValue(v)
|
|
97
|
+
}, [widget?.id])
|
|
98
|
+
|
|
99
|
+
const handleExecute = async () => {
|
|
100
|
+
if (!value || executing) return
|
|
101
|
+
setExecuting(true)
|
|
102
|
+
const runner = refsStatic.get('batch-op-runner')
|
|
103
|
+
runner?.reset()
|
|
104
|
+
refsStatic.get('batch-op-logs')?.setLogs({ steps: [], currentIndex: 0, status: 'running' })
|
|
105
|
+
try {
|
|
106
|
+
let workflows
|
|
107
|
+
try {
|
|
108
|
+
workflows = JSON.parse(value)
|
|
109
|
+
if (!Array.isArray(workflows)) throw new Error('Workflow must be an array')
|
|
110
|
+
} catch (e) {
|
|
111
|
+
message.error('Invalid workflow JSON: ' + e.message)
|
|
112
|
+
refsStatic.get('batch-op-logs')?.reset()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
await runner.executeWorkflow(workflows)
|
|
116
|
+
message.success('Workflow execution completed')
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err.message !== 'Workflow aborted') {
|
|
119
|
+
message.error('Workflow execution failed: ' + err.message)
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
setExecuting(false)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const handleTemplate = useCallback(() => {
|
|
127
|
+
setValue(workflowExample)
|
|
128
|
+
}, [])
|
|
129
|
+
|
|
130
|
+
const handleEditWithSystemEditor = useCallback(async () => {
|
|
131
|
+
const id = generate()
|
|
132
|
+
const tempPath = window.pre.resolve(window.pre.tempDir, `electerm-batch-op-${id}.json`)
|
|
133
|
+
await fs.writeFile(tempPath, value)
|
|
134
|
+
window.pre.runGlobalAsync('watchFile', tempPath)
|
|
135
|
+
fs.openFile(tempPath).catch(window.store.onError)
|
|
136
|
+
window.pre.showItemInFolder(tempPath)
|
|
137
|
+
const onFileChange = (e, text) => {
|
|
138
|
+
setValue(text)
|
|
139
|
+
window.pre.ipcOffEvent('file-change', onFileChange)
|
|
140
|
+
fs.unlink(tempPath).catch(console.log)
|
|
141
|
+
}
|
|
142
|
+
window.pre.ipcOnEvent('file-change', onFileChange)
|
|
143
|
+
}, [value])
|
|
144
|
+
|
|
145
|
+
const handleEditWithCustom = useCallback(async (editorCommand) => {
|
|
146
|
+
const id = generate()
|
|
147
|
+
const tempPath = window.pre.resolve(window.pre.tempDir, `electerm-batch-op-${id}.json`)
|
|
148
|
+
await fs.writeFile(tempPath, value)
|
|
149
|
+
window.pre.runGlobalAsync('watchFile', tempPath)
|
|
150
|
+
await window.pre.runGlobalAsync('openFileWithEditor', tempPath, editorCommand)
|
|
151
|
+
const onFileChange = (e, text) => {
|
|
152
|
+
setValue(text)
|
|
153
|
+
window.pre.ipcOffEvent('file-change', onFileChange)
|
|
154
|
+
fs.unlink(tempPath).catch(console.log)
|
|
155
|
+
}
|
|
156
|
+
window.pre.ipcOnEvent('file-change', onFileChange)
|
|
157
|
+
}, [value])
|
|
158
|
+
|
|
159
|
+
function handleChange (e) {
|
|
160
|
+
setValue(e.target.value)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div className='batch-op-editor'>
|
|
165
|
+
<BatchOpAlert />
|
|
166
|
+
<Flex className='mg2y' gap='small'>
|
|
167
|
+
<Button onClick={handleTemplate} type='dashed'>
|
|
168
|
+
Load Template
|
|
169
|
+
</Button>
|
|
170
|
+
<Button
|
|
171
|
+
onClick={handleExecute}
|
|
172
|
+
type='primary'
|
|
173
|
+
loading={executing}
|
|
174
|
+
disabled={executing}
|
|
175
|
+
icon={<PlayCircleOutlined />}
|
|
176
|
+
>
|
|
177
|
+
Execute Workflow
|
|
178
|
+
</Button>
|
|
179
|
+
</Flex>
|
|
180
|
+
<SimpleEditor
|
|
181
|
+
value={value}
|
|
182
|
+
onChange={handleChange}
|
|
183
|
+
/>
|
|
184
|
+
{!window.et.isWebApp && (
|
|
185
|
+
<div className='pd1t pd2b'>
|
|
186
|
+
<Button
|
|
187
|
+
type='primary'
|
|
188
|
+
className='mg1r mg1b'
|
|
189
|
+
onClick={handleEditWithSystemEditor}
|
|
190
|
+
>
|
|
191
|
+
{window.translate('editWithSystemEditor')}
|
|
192
|
+
</Button>
|
|
193
|
+
<EditWithCustomEditor
|
|
194
|
+
loading={executing}
|
|
195
|
+
editWithCustom={handleEditWithCustom}
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
<BatchOpLogs />
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import React, { useState, useImperativeHandle, forwardRef, useEffect } from 'react'
|
|
2
|
+
import { refsStatic } from '../common/ref'
|
|
3
|
+
|
|
4
|
+
const STATIC_KEY = 'batch-op-logs'
|
|
5
|
+
|
|
6
|
+
const BatchOpLogs = forwardRef(function BatchOpLogs (_, ref) {
|
|
7
|
+
const [logs, setLogsState] = useState(null)
|
|
8
|
+
|
|
9
|
+
useImperativeHandle(ref, () => ({
|
|
10
|
+
setLogs: (progress) => setLogsState(progress),
|
|
11
|
+
reset: () => setLogsState(null)
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
refsStatic.add(STATIC_KEY, {
|
|
16
|
+
setLogs: (progress) => setLogsState(progress),
|
|
17
|
+
reset: () => setLogsState(null)
|
|
18
|
+
})
|
|
19
|
+
return () => refsStatic.remove(STATIC_KEY)
|
|
20
|
+
}, [])
|
|
21
|
+
|
|
22
|
+
if (!logs || (!logs.steps.length && !logs.currentStep)) {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const statusIcon = { success: '✓', error: '✗' }
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className='batch-op-logs mg1t pd1 font13'>
|
|
30
|
+
<div className='bold mg1b'>Execution Log</div>
|
|
31
|
+
{logs.steps.map((step, i) => (
|
|
32
|
+
<div key={i} className={`batch-op-log-entry ${step.status}`}>
|
|
33
|
+
<span className='log-icon mg1r'>{statusIcon[step.status] || '○'}</span>
|
|
34
|
+
<span className='log-name'>{step.name}</span>
|
|
35
|
+
{step.error && <span className='log-error mg1l color-red'>{step.error}</span>}
|
|
36
|
+
</div>
|
|
37
|
+
))}
|
|
38
|
+
{logs.status === 'running' && logs.currentStep && (
|
|
39
|
+
<div className='batch-op-log-entry running'>
|
|
40
|
+
<span className='log-icon mg1r'>→</span>
|
|
41
|
+
<span className='log-name'>{logs.currentStep}</span>
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
{logs.status === 'completed' && (
|
|
45
|
+
<div className='batch-op-log-entry completed color-green mg1t'>
|
|
46
|
+
✓ Workflow completed
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
export default BatchOpLogs
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { Component } from 'react'
|
|
2
|
+
import { refsStatic } from '../common/ref'
|
|
3
|
+
import { statusMap } from '../../common/constants'
|
|
4
|
+
import { autoRun } from 'manate'
|
|
5
|
+
import uid from '../../common/uid'
|
|
6
|
+
|
|
7
|
+
const STATIC_KEY = 'batch-op-runner'
|
|
8
|
+
|
|
9
|
+
export default class BatchOpRunner extends Component {
|
|
10
|
+
constructor () {
|
|
11
|
+
super()
|
|
12
|
+
this.steps = []
|
|
13
|
+
this.currentIndex = 0
|
|
14
|
+
this.status = 'idle'
|
|
15
|
+
this.currentTabId = null
|
|
16
|
+
this.currentStep = null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
componentDidMount () {
|
|
20
|
+
refsStatic.add(STATIC_KEY, this)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
getActiveTabId () {
|
|
24
|
+
return window.store?.activeTabId
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getState () {
|
|
28
|
+
return {
|
|
29
|
+
steps: this.steps,
|
|
30
|
+
currentIndex: this.currentIndex,
|
|
31
|
+
status: this.status,
|
|
32
|
+
currentStep: this.currentStep
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
reset () {
|
|
37
|
+
this.steps = []
|
|
38
|
+
this.currentIndex = 0
|
|
39
|
+
this.status = 'idle'
|
|
40
|
+
this.currentTabId = null
|
|
41
|
+
this.currentStep = null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
runBatchOpFromFile (filePath) {
|
|
45
|
+
return this._runBatchOpFromFile(filePath)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
executeWorkflow (workflows) {
|
|
49
|
+
return this._executeWorkflow(workflows)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async _runBatchOpFromFile (filePath) {
|
|
53
|
+
try {
|
|
54
|
+
const content = await window.fs.readFile(filePath)
|
|
55
|
+
let workflows
|
|
56
|
+
try {
|
|
57
|
+
workflows = JSON.parse(content)
|
|
58
|
+
if (!Array.isArray(workflows)) {
|
|
59
|
+
throw new Error('Workflow must be an array')
|
|
60
|
+
}
|
|
61
|
+
} catch (e) {
|
|
62
|
+
console.error('Invalid batch operation JSON:', e.message)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.reset()
|
|
67
|
+
await this._executeWorkflow(workflows)
|
|
68
|
+
console.log('Batch operation completed from file')
|
|
69
|
+
} catch (e) {
|
|
70
|
+
console.error('Failed to run batch operation from file:', e.message)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async _executeWorkflow (workflows) {
|
|
75
|
+
if (!Array.isArray(workflows)) {
|
|
76
|
+
throw new Error('Workflow must be an array')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.steps = []
|
|
80
|
+
this.currentIndex = 0
|
|
81
|
+
this.status = 'running'
|
|
82
|
+
|
|
83
|
+
const results = []
|
|
84
|
+
const logsRef = refsStatic.get('batch-op-logs')
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < workflows.length; i++) {
|
|
87
|
+
const step = workflows[i]
|
|
88
|
+
this.currentIndex = i
|
|
89
|
+
this.currentStep = step.name
|
|
90
|
+
|
|
91
|
+
logsRef?.setLogs(this.getState())
|
|
92
|
+
|
|
93
|
+
let result
|
|
94
|
+
try {
|
|
95
|
+
result = await this._executeStep(step, results)
|
|
96
|
+
this.steps.push({ name: step.name, status: 'success', result })
|
|
97
|
+
results.push(result)
|
|
98
|
+
console.log(`Batch op step ${i + 1} completed:`, step.name || 'unnamed')
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.log(e)
|
|
101
|
+
this.steps.push({ name: step.name, status: 'error', error: e.message })
|
|
102
|
+
logsRef?.setLogs(this.getState())
|
|
103
|
+
console.error(`Batch op step ${i + 1} failed:`, step.name || 'unnamed', e.message)
|
|
104
|
+
this.status = 'error'
|
|
105
|
+
logsRef?.setLogs(this.getState())
|
|
106
|
+
throw e
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.status = 'completed'
|
|
111
|
+
this.currentStep = null
|
|
112
|
+
logsRef?.setLogs(this.getState())
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async _executeStep (step, previousResults) {
|
|
116
|
+
const { action, prevDelay, afterDelay } = step
|
|
117
|
+
|
|
118
|
+
if (!action) {
|
|
119
|
+
throw new Error('Step must have an "action" field')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (prevDelay > 0) {
|
|
123
|
+
await new Promise(resolve => setTimeout(resolve, prevDelay))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const s = step.params ? { ...step, ...step.params } : step
|
|
127
|
+
|
|
128
|
+
let result
|
|
129
|
+
switch (action) {
|
|
130
|
+
case 'connect':
|
|
131
|
+
result = await this._batchStepConnect(s)
|
|
132
|
+
this.currentTabId = result.tabId
|
|
133
|
+
break
|
|
134
|
+
case 'command':
|
|
135
|
+
result = await this._batchStepCommand(s)
|
|
136
|
+
break
|
|
137
|
+
case 'sftp_upload':
|
|
138
|
+
result = await this._batchStepSftpUpload(s)
|
|
139
|
+
break
|
|
140
|
+
case 'sftp_download':
|
|
141
|
+
result = await this._batchStepSftpDownload(s)
|
|
142
|
+
break
|
|
143
|
+
default:
|
|
144
|
+
throw new Error(`Unknown action: ${action}`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (afterDelay > 0) {
|
|
148
|
+
await new Promise(resolve => setTimeout(resolve, afterDelay))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return result
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async _batchStepConnect (step) {
|
|
155
|
+
const { store } = window
|
|
156
|
+
const p = step.params || step
|
|
157
|
+
|
|
158
|
+
const tabId = uid()
|
|
159
|
+
const tab = {
|
|
160
|
+
id: tabId,
|
|
161
|
+
type: 'ssh',
|
|
162
|
+
host: p.host || '',
|
|
163
|
+
port: p.port || 22,
|
|
164
|
+
username: p.username || '',
|
|
165
|
+
password: p.password || '',
|
|
166
|
+
privateKey: p.privateKey || '',
|
|
167
|
+
passphrase: p.passphrase || '',
|
|
168
|
+
certificate: p.certificate || '',
|
|
169
|
+
authType: p.authType || 'password',
|
|
170
|
+
profile: p.profile || '',
|
|
171
|
+
enableSftp: p.enableSftp !== false,
|
|
172
|
+
enableSsh: p.enableSsh !== false,
|
|
173
|
+
useSshAgent: p.useSshAgent !== false,
|
|
174
|
+
sshAgent: p.sshAgent || '',
|
|
175
|
+
term: p.term || 'xterm-256color',
|
|
176
|
+
encode: p.encode || 'utf8',
|
|
177
|
+
envLang: p.envLang || 'en_US.UTF-8',
|
|
178
|
+
setEnv: p.setEnv || '',
|
|
179
|
+
startDirectoryRemote: p.startDirectoryRemote || '',
|
|
180
|
+
startDirectoryLocal: p.startDirectoryLocal || '',
|
|
181
|
+
proxy: p.proxy || '',
|
|
182
|
+
x11: p.x11 || false,
|
|
183
|
+
displayRaw: p.displayRaw || false,
|
|
184
|
+
sshTunnels: p.sshTunnels || [],
|
|
185
|
+
connectionHoppings: p.connectionHoppings || [],
|
|
186
|
+
title: step.name || `SSH: ${p.host}`,
|
|
187
|
+
status: 'processing',
|
|
188
|
+
pane: 'terminal'
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
store.addTab(tab)
|
|
192
|
+
await this._waitForConnection(tabId)
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
success: true,
|
|
196
|
+
action: 'connect',
|
|
197
|
+
host: p.host,
|
|
198
|
+
port: p.port,
|
|
199
|
+
tabId
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_waitForConnection = async (tabId) => {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const timeout = setTimeout(() => {
|
|
206
|
+
if (this._refWait) {
|
|
207
|
+
this._refWait.stop()
|
|
208
|
+
delete this._refWait
|
|
209
|
+
}
|
|
210
|
+
reject(new Error('Connection timeout'))
|
|
211
|
+
}, 30000)
|
|
212
|
+
|
|
213
|
+
this._refWait = autoRun(() => {
|
|
214
|
+
const { tabs } = window.store
|
|
215
|
+
const tab = tabs.find(t => t.id === tabId)
|
|
216
|
+
if (tab && tab.status === statusMap.success) {
|
|
217
|
+
clearTimeout(timeout)
|
|
218
|
+
this._refWait && this._refWait.stop()
|
|
219
|
+
delete this._refWait
|
|
220
|
+
resolve(tab)
|
|
221
|
+
} else if (tab && tab.status === statusMap.error) {
|
|
222
|
+
clearTimeout(timeout)
|
|
223
|
+
this._refWait && this._refWait.stop()
|
|
224
|
+
delete this._refWait
|
|
225
|
+
reject(new Error('Connection failed: ' + (tab.errorMsg || 'unknown error')))
|
|
226
|
+
}
|
|
227
|
+
return window.store.tabs
|
|
228
|
+
})
|
|
229
|
+
this._refWait.start()
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async _batchStepCommand (step) {
|
|
234
|
+
const tabId = this.currentTabId
|
|
235
|
+
|
|
236
|
+
if (!tabId) {
|
|
237
|
+
throw new Error('No active tab. Please connect first.')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const { refs } = await import('../common/ref')
|
|
241
|
+
const term = refs.get('term-' + tabId)
|
|
242
|
+
if (!term || !term.term) {
|
|
243
|
+
throw new Error('Terminal not found')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let waited = 0
|
|
247
|
+
while (!term.attachAddon && waited < 10000) {
|
|
248
|
+
await new Promise(resolve => setTimeout(resolve, 200))
|
|
249
|
+
waited += 200
|
|
250
|
+
}
|
|
251
|
+
if (!term.attachAddon) {
|
|
252
|
+
throw new Error('Terminal not ready: attach addon not initialized')
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
term.runQuickCommand(step.command)
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
success: true,
|
|
259
|
+
action: 'command',
|
|
260
|
+
command: step.command,
|
|
261
|
+
tabId
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async _batchStepSftpUpload (step) {
|
|
266
|
+
const tabId = this.currentTabId
|
|
267
|
+
const { store } = window
|
|
268
|
+
const stepWithTabId = { ...step, tabId, conflictPolicy: 'mergeOrOverwriteAll' }
|
|
269
|
+
const { transferId } = await store.mcpSftpUpload(stepWithTabId)
|
|
270
|
+
await this._batchWaitForTransfer(transferId)
|
|
271
|
+
return { success: true, action: 'sftp_upload', localPath: step.localPath, remotePath: step.remotePath, transferId, tabId }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async _batchStepSftpDownload (step) {
|
|
275
|
+
const tabId = this.currentTabId
|
|
276
|
+
const { store } = window
|
|
277
|
+
const stepWithTabId = { ...step, tabId, conflictPolicy: 'mergeOrOverwriteAll' }
|
|
278
|
+
const { transferId } = await store.mcpSftpDownload(stepWithTabId)
|
|
279
|
+
await this._batchWaitForTransfer(transferId)
|
|
280
|
+
return { success: true, action: 'sftp_download', remotePath: step.remotePath, localPath: step.localPath, transferId, tabId }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async _batchWaitForTransfer (transferId) {
|
|
284
|
+
return new Promise((resolve, reject) => {
|
|
285
|
+
const timeout = setTimeout(() => {
|
|
286
|
+
if (this._refTransferWait) {
|
|
287
|
+
this._refTransferWait.stop()
|
|
288
|
+
delete this._refTransferWait
|
|
289
|
+
}
|
|
290
|
+
reject(new Error('Transfer timeout (1 hour)'))
|
|
291
|
+
}, 60 * 60 * 1000)
|
|
292
|
+
|
|
293
|
+
this._refTransferWait = autoRun(() => {
|
|
294
|
+
const { transferHistory } = window.store
|
|
295
|
+
const item = transferHistory.find(t => t.id === transferId || t.originalId === transferId)
|
|
296
|
+
if (item) {
|
|
297
|
+
clearTimeout(timeout)
|
|
298
|
+
this._refTransferWait && this._refTransferWait.stop()
|
|
299
|
+
delete this._refTransferWait
|
|
300
|
+
if (item.error) {
|
|
301
|
+
reject(new Error('Transfer failed: ' + item.error))
|
|
302
|
+
} else {
|
|
303
|
+
resolve(item)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return window.store.transferHistory
|
|
307
|
+
})
|
|
308
|
+
this._refTransferWait.start()
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
render () {
|
|
313
|
+
return null
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* AI-powered bookmark generation form
|
|
3
3
|
*/
|
|
4
4
|
import { useState, useEffect } from 'react'
|
|
5
|
-
import { Button, Input,
|
|
5
|
+
import { Button, Input, Space, Alert } from 'antd'
|
|
6
|
+
import message from '../common/message'
|
|
6
7
|
import {
|
|
7
8
|
RobotOutlined,
|
|
8
9
|
LoadingOutlined,
|