@electerm/electerm-react 3.2.0 → 3.5.6
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 -8
- package/client/common/fs.js +84 -0
- package/client/components/batch-op/batch-op-alert.jsx +23 -0
- package/client/components/batch-op/batch-op-editor.jsx +206 -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/fields.jsx +15 -0
- package/client/components/bookmark-form/config/rdp.js +5 -0
- 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/main/upgrade.jsx +133 -104
- package/client/components/main/upgrade.styl +2 -2
- package/client/components/rdp/file-transfer.js +375 -0
- package/client/components/rdp/rdp-session.jsx +169 -76
- package/client/components/rdp/rdp.styl +27 -0
- 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 +23 -3
- package/client/components/sidebar/bookmark-select.jsx +3 -2
- package/client/components/sidebar/history-item.jsx +3 -1
- package/client/components/sidebar/index.jsx +0 -9
- package/client/components/sidebar/info-modal.jsx +7 -2
- 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/terminal.jsx +4 -5
- 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/widgets/widget-form.jsx +6 -0
- package/client/store/app-upgrade.js +2 -2
- package/client/store/common.js +0 -28
- package/client/store/load-data.js +3 -3
- package/client/store/mcp-handler.js +2 -2
- package/client/store/sync.js +25 -1
- package/client/store/tab.js +1 -1
- package/client/store/watch.js +10 -18
- package/client/views/index.pug +1 -2
- package/package.json +1 -1
- package/client/components/batch-op/batch-op.jsx +0 -694
|
@@ -229,11 +229,6 @@ export const rendererTypes = {
|
|
|
229
229
|
canvas: 'canvas',
|
|
230
230
|
webGL: 'webGL'
|
|
231
231
|
}
|
|
232
|
-
export const mirrors = {
|
|
233
|
-
'download-electerm': 'download-electerm',
|
|
234
|
-
github: 'github',
|
|
235
|
-
sourceforge: 'sourceforge'
|
|
236
|
-
}
|
|
237
232
|
export const downloadUpgradeTimeout = 20000
|
|
238
233
|
export const expandedKeysLsKey = 'expanded-keys'
|
|
239
234
|
export const resolutionsLsKey = 'custom-resolution-key'
|
|
@@ -242,15 +237,13 @@ export const quickCommandLabelsLsKey = 'quick-command-label'
|
|
|
242
237
|
export const localAddrBookmarkLsKey = 'local-addr-bookmark-keys'
|
|
243
238
|
export const dismissDelKeyTipLsKey = 'dismiss-del-key-tip'
|
|
244
239
|
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
240
|
export const proxyHelpLink = 'https://github.com/electerm/electerm/wiki/proxy-format'
|
|
247
241
|
export const regexHelpLink = 'https://github.com/electerm/electerm/wiki/Terminal-keywords-highlight-regular-expression-exmaples'
|
|
248
242
|
export const connectionHoppingWikiLink = 'https://github.com/electerm/electerm/wiki/Connection-Hopping-Behavior-Change-in-electerm-since-v1.50.65'
|
|
249
243
|
export const aiConfigWikiLink = 'https://github.com/electerm/electerm/wiki/AI-model-config-guide'
|
|
250
244
|
export const modals = {
|
|
251
245
|
hide: 0,
|
|
252
|
-
setting: 1
|
|
253
|
-
batchOps: 2
|
|
246
|
+
setting: 1
|
|
254
247
|
}
|
|
255
248
|
export const instSftpKeys = [
|
|
256
249
|
'connect',
|
package/client/common/fs.js
CHANGED
|
@@ -16,6 +16,90 @@ const fs = fsFunctions.reduce((prev, func) => {
|
|
|
16
16
|
return prev
|
|
17
17
|
}, {})
|
|
18
18
|
|
|
19
|
+
// Encoding function
|
|
20
|
+
fs.encodeUint8Array = (uint8Array) => {
|
|
21
|
+
let str = ''
|
|
22
|
+
const len = uint8Array.byteLength
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < len; i++) {
|
|
25
|
+
str += String.fromCharCode(uint8Array[i])
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return btoa(str)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Decoding function
|
|
32
|
+
fs.decodeBase64String = (base64String) => {
|
|
33
|
+
const str = atob(base64String)
|
|
34
|
+
const len = str.length
|
|
35
|
+
|
|
36
|
+
const uint8Array = new Uint8Array(len)
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < len; i++) {
|
|
39
|
+
uint8Array[i] = str.charCodeAt(i)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return uint8Array
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
Object.assign(fs, {
|
|
46
|
+
stat: (path, cb) => {
|
|
47
|
+
window.fs.statCustom(path)
|
|
48
|
+
.catch(err => cb(err))
|
|
49
|
+
.then(obj => {
|
|
50
|
+
obj.isDirectory = () => obj.isD
|
|
51
|
+
obj.isFile = () => obj.isF
|
|
52
|
+
cb(undefined, obj)
|
|
53
|
+
})
|
|
54
|
+
},
|
|
55
|
+
access: (...args) => {
|
|
56
|
+
const cb = args.pop()
|
|
57
|
+
window.fs.access(...args)
|
|
58
|
+
.then((data) => cb(undefined, data))
|
|
59
|
+
.catch((err) => cb(err))
|
|
60
|
+
},
|
|
61
|
+
open: (...args) => {
|
|
62
|
+
const cb = args.pop()
|
|
63
|
+
window.fs.openCustom(...args)
|
|
64
|
+
.then((data) => cb(undefined, data))
|
|
65
|
+
.catch((err) => cb(err))
|
|
66
|
+
},
|
|
67
|
+
read: (p1, arr, ...args) => {
|
|
68
|
+
const cb = args.pop()
|
|
69
|
+
window.fs.readCustom(
|
|
70
|
+
p1,
|
|
71
|
+
arr.length,
|
|
72
|
+
...args
|
|
73
|
+
)
|
|
74
|
+
.then((data) => {
|
|
75
|
+
const { n, newArr } = data
|
|
76
|
+
const newArr1 = window.fs.decodeBase64String(newArr)
|
|
77
|
+
cb(undefined, n, newArr1)
|
|
78
|
+
})
|
|
79
|
+
.catch(err => cb(err))
|
|
80
|
+
},
|
|
81
|
+
close: (fd, cb) => {
|
|
82
|
+
window.fs.closeCustom(fd)
|
|
83
|
+
.then((data) => cb(undefined, data))
|
|
84
|
+
.catch((err) => cb(err))
|
|
85
|
+
},
|
|
86
|
+
readdir: (p, cb) => {
|
|
87
|
+
window.fs.readdir(p)
|
|
88
|
+
.then((data) => cb(undefined, data))
|
|
89
|
+
.catch((err) => cb(err))
|
|
90
|
+
},
|
|
91
|
+
write: (p1, buf, cb) => {
|
|
92
|
+
window.fs.writeCustom(p1, window.fs.encodeUint8Array(buf))
|
|
93
|
+
.then((data) => cb(undefined, data))
|
|
94
|
+
.catch((err) => cb(err))
|
|
95
|
+
},
|
|
96
|
+
realpath: (p, cb) => {
|
|
97
|
+
window.fs.realpath(p)
|
|
98
|
+
.then((data) => cb(undefined, data))
|
|
99
|
+
.catch((err) => cb(err))
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
19
103
|
window.fs = fs
|
|
20
104
|
|
|
21
105
|
export default fs
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Alert } 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 description = (
|
|
9
|
+
<>
|
|
10
|
+
<p>Actions: <code>connect, command, sftp_upload, sftp_download</code></p>
|
|
11
|
+
<div><ExternalLink to={batchOpWikiLink}>{batchOpWikiLink}</ExternalLink></div>
|
|
12
|
+
</>
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Alert
|
|
17
|
+
description={description}
|
|
18
|
+
type='info'
|
|
19
|
+
showIcon
|
|
20
|
+
className='mg1b'
|
|
21
|
+
/>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
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
|
+
import { safeGetItem, safeSetItem } from '../../common/safe-local-storage'
|
|
19
|
+
|
|
20
|
+
const batchOpEditorKey = 'batch-op-editor-content'
|
|
21
|
+
const workflowExample = `[
|
|
22
|
+
{
|
|
23
|
+
"name": "Connect SSH",
|
|
24
|
+
"action": "connect",
|
|
25
|
+
"params": {
|
|
26
|
+
"host": "192.168.1.100",
|
|
27
|
+
"port": 22,
|
|
28
|
+
"username": "root",
|
|
29
|
+
"authType": "password",
|
|
30
|
+
"password": "your_password"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "Create 5M Test File",
|
|
35
|
+
"action": "command",
|
|
36
|
+
"afterDelay": 500,
|
|
37
|
+
"prevDelay": 500,
|
|
38
|
+
"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"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "Log creation",
|
|
42
|
+
"action": "command",
|
|
43
|
+
"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"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "Download 5M File",
|
|
47
|
+
"action": "sftp_download",
|
|
48
|
+
"afterDelay": 200,
|
|
49
|
+
"remotePath": "/tmp/test_5m_file.bin",
|
|
50
|
+
"localPath": "/tmp/test_5m_file.bin"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "Log after download",
|
|
54
|
+
"action": "command",
|
|
55
|
+
"afterDelay": 200,
|
|
56
|
+
"command": "echo '[LOG] Download complete at $(date)' >> /tmp/test_log.log"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "Delete Remote 5M File",
|
|
60
|
+
"action": "command",
|
|
61
|
+
"afterDelay": 200,
|
|
62
|
+
"command": "rm /tmp/test_5m_file.bin && echo '[LOG] Deleted remote 5M file at $(date)' >> /tmp/test_log.log"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "Upload Downloaded File to Remote",
|
|
66
|
+
"action": "sftp_upload",
|
|
67
|
+
"afterDelay": 200,
|
|
68
|
+
"localPath": "/tmp/test_5m_file.bin",
|
|
69
|
+
"remotePath": "/tmp/test_5m_file_uploaded.bin"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"name": "Log after upload",
|
|
73
|
+
"action": "command",
|
|
74
|
+
"afterDelay": 200,
|
|
75
|
+
"command": "echo '[LOG] Upload complete at $(date)' >> /tmp/test_log.log"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"name": "Verify and clean up",
|
|
79
|
+
"action": "command",
|
|
80
|
+
"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"
|
|
81
|
+
}
|
|
82
|
+
]`
|
|
83
|
+
|
|
84
|
+
function getDefaultValue (widget) {
|
|
85
|
+
const saved = safeGetItem(batchOpEditorKey)
|
|
86
|
+
if (saved) return saved
|
|
87
|
+
return workflowExample
|
|
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
|
+
useEffect(() => {
|
|
100
|
+
safeSetItem(batchOpEditorKey, value)
|
|
101
|
+
}, [value])
|
|
102
|
+
|
|
103
|
+
const handleExecute = async () => {
|
|
104
|
+
if (!value || executing) return
|
|
105
|
+
setExecuting(true)
|
|
106
|
+
const runner = refsStatic.get('batch-op-runner')
|
|
107
|
+
runner?.reset()
|
|
108
|
+
refsStatic.get('batch-op-logs')?.setLogs({ steps: [], currentIndex: 0, status: 'running' })
|
|
109
|
+
try {
|
|
110
|
+
let workflows
|
|
111
|
+
try {
|
|
112
|
+
workflows = JSON.parse(value)
|
|
113
|
+
if (!Array.isArray(workflows)) throw new Error('Workflow must be an array')
|
|
114
|
+
} catch (e) {
|
|
115
|
+
message.error('Invalid workflow JSON: ' + e.message)
|
|
116
|
+
refsStatic.get('batch-op-logs')?.reset()
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
await runner.executeWorkflow(workflows)
|
|
120
|
+
message.success('Workflow execution completed')
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (err.message !== 'Workflow aborted') {
|
|
123
|
+
message.error('Workflow execution failed: ' + err.message)
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
setExecuting(false)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const handleTemplate = useCallback(() => {
|
|
131
|
+
setValue(workflowExample)
|
|
132
|
+
}, [])
|
|
133
|
+
|
|
134
|
+
const handleEditWithSystemEditor = useCallback(async () => {
|
|
135
|
+
const id = generate()
|
|
136
|
+
const tempPath = window.pre.resolve(window.pre.tempDir, `electerm-batch-op-${id}.json`)
|
|
137
|
+
await fs.writeFile(tempPath, value)
|
|
138
|
+
window.pre.runGlobalAsync('watchFile', tempPath)
|
|
139
|
+
fs.openFile(tempPath).catch(window.store.onError)
|
|
140
|
+
window.pre.showItemInFolder(tempPath)
|
|
141
|
+
const onFileChange = (e, text) => {
|
|
142
|
+
setValue(text)
|
|
143
|
+
window.pre.ipcOffEvent('file-change', onFileChange)
|
|
144
|
+
fs.unlink(tempPath).catch(console.log)
|
|
145
|
+
}
|
|
146
|
+
window.pre.ipcOnEvent('file-change', onFileChange)
|
|
147
|
+
}, [value])
|
|
148
|
+
|
|
149
|
+
const handleEditWithCustom = useCallback(async (editorCommand) => {
|
|
150
|
+
const id = generate()
|
|
151
|
+
const tempPath = window.pre.resolve(window.pre.tempDir, `electerm-batch-op-${id}.json`)
|
|
152
|
+
await fs.writeFile(tempPath, value)
|
|
153
|
+
window.pre.runGlobalAsync('watchFile', tempPath)
|
|
154
|
+
await window.pre.runGlobalAsync('openFileWithEditor', tempPath, editorCommand)
|
|
155
|
+
const onFileChange = (e, text) => {
|
|
156
|
+
setValue(text)
|
|
157
|
+
window.pre.ipcOffEvent('file-change', onFileChange)
|
|
158
|
+
fs.unlink(tempPath).catch(console.log)
|
|
159
|
+
}
|
|
160
|
+
window.pre.ipcOnEvent('file-change', onFileChange)
|
|
161
|
+
}, [value])
|
|
162
|
+
|
|
163
|
+
function handleChange (e) {
|
|
164
|
+
setValue(e.target.value)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div className='batch-op-editor'>
|
|
169
|
+
<BatchOpAlert />
|
|
170
|
+
<Flex className='mg2y' gap='small'>
|
|
171
|
+
<Button onClick={handleTemplate} type='dashed'>
|
|
172
|
+
Load Template
|
|
173
|
+
</Button>
|
|
174
|
+
<Button
|
|
175
|
+
onClick={handleExecute}
|
|
176
|
+
type='primary'
|
|
177
|
+
loading={executing}
|
|
178
|
+
disabled={executing}
|
|
179
|
+
icon={<PlayCircleOutlined />}
|
|
180
|
+
>
|
|
181
|
+
Execute Workflow
|
|
182
|
+
</Button>
|
|
183
|
+
</Flex>
|
|
184
|
+
<SimpleEditor
|
|
185
|
+
value={value}
|
|
186
|
+
onChange={handleChange}
|
|
187
|
+
/>
|
|
188
|
+
{!window.et.isWebApp && (
|
|
189
|
+
<div className='pd1t pd2b'>
|
|
190
|
+
<Button
|
|
191
|
+
type='primary'
|
|
192
|
+
className='mg1r mg1b'
|
|
193
|
+
onClick={handleEditWithSystemEditor}
|
|
194
|
+
>
|
|
195
|
+
{window.translate('editWithSystemEditor')}
|
|
196
|
+
</Button>
|
|
197
|
+
<EditWithCustomEditor
|
|
198
|
+
loading={executing}
|
|
199
|
+
editWithCustom={handleEditWithCustom}
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
<BatchOpLogs />
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
@@ -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
|