@linxin666/dsh-ssh 0.1.1
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/README.md +75 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +15638 -0
- package/lib/index.js +3297 -0
- package/lib/invariant.js +6 -0
- package/lib/types/client/api.d.ts +72 -0
- package/lib/types/client/index.d.ts +37 -0
- package/lib/types/client/locales.d.ts +137 -0
- package/lib/types/client/mount.d.ts +12 -0
- package/lib/types/client/panel/ClusterTab.d.ts +7 -0
- package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
- package/lib/types/client/panel/HostsTab.d.ts +9 -0
- package/lib/types/client/panel/SshPanel.d.ts +13 -0
- package/lib/types/client/panel/TerminalTab.d.ts +11 -0
- package/lib/types/client/panel/TransferTab.d.ts +7 -0
- package/lib/types/client/panel/TunnelsTab.d.ts +7 -0
- package/lib/types/client/panel/controller.d.ts +22 -0
- package/lib/types/client/panel/helpers.d.ts +14 -0
- package/lib/types/client/panel/xterm.css.d.ts +2 -0
- package/lib/types/client/sidebar-entry.d.ts +24 -0
- package/lib/types/engine.d.ts +135 -0
- package/lib/types/index.d.ts +40 -0
- package/lib/types/invariant.d.ts +3 -0
- package/lib/types/protocol.d.ts +189 -0
- package/lib/types/routes.d.ts +29 -0
- package/lib/types/store.d.ts +54 -0
- package/lib/types/tools.d.ts +18 -0
- package/package.json +106 -0
- package/src/client/api.ts +363 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +68 -0
- package/src/client/locales.ts +284 -0
- package/src/client/mount.tsx +83 -0
- package/src/client/panel/ClusterTab.tsx +123 -0
- package/src/client/panel/HostFormDialog.tsx +221 -0
- package/src/client/panel/HostsTab.tsx +166 -0
- package/src/client/panel/SshPanel.tsx +77 -0
- package/src/client/panel/TerminalTab.tsx +176 -0
- package/src/client/panel/TransferTab.tsx +232 -0
- package/src/client/panel/TunnelsTab.tsx +177 -0
- package/src/client/panel/controller.ts +48 -0
- package/src/client/panel/helpers.ts +26 -0
- package/src/client/panel/panel.module.css +833 -0
- package/src/client/panel/xterm.css.ts +2 -0
- package/src/client/sidebar-entry.ts +123 -0
- package/src/engine.ts +780 -0
- package/src/index.ts +155 -0
- package/src/invariant.ts +4 -0
- package/src/protocol.ts +188 -0
- package/src/routes.ts +540 -0
- package/src/store.ts +344 -0
- package/src/tools.ts +371 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-ssh surface copy: zh is the key source, en mirrors every key.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const zh = {
|
|
6
|
+
'entry.label': 'SSH',
|
|
7
|
+
'entry.tooltip': '远程 SSH 运维面板',
|
|
8
|
+
'panel.title': '远程 SSH',
|
|
9
|
+
'tab.hosts': '主机',
|
|
10
|
+
'tab.terminal': '终端',
|
|
11
|
+
'tab.transfer': '传输',
|
|
12
|
+
'tab.tunnels': '隧道',
|
|
13
|
+
'tab.cluster': '集群',
|
|
14
|
+
// hosts
|
|
15
|
+
'hosts.empty': '尚未配置主机。点击「新增主机」添加,或从 ~/.ssh/config 导入。',
|
|
16
|
+
'hosts.add': '新增主机',
|
|
17
|
+
'hosts.import': '导入 ~/.ssh/config',
|
|
18
|
+
'hosts.search': '搜索别名 / 描述 / 标签…',
|
|
19
|
+
'hosts.col.alias': '别名',
|
|
20
|
+
'hosts.col.host': '主机',
|
|
21
|
+
'hosts.col.user': '用户',
|
|
22
|
+
'hosts.col.auth': '认证',
|
|
23
|
+
'hosts.col.environment': '环境',
|
|
24
|
+
'hosts.col.tags': '标签',
|
|
25
|
+
'hosts.col.description': '备注',
|
|
26
|
+
'hosts.col.actions': '操作',
|
|
27
|
+
'hosts.test': '测试',
|
|
28
|
+
'hosts.edit': '编辑',
|
|
29
|
+
'hosts.delete': '删除',
|
|
30
|
+
'hosts.deleteConfirm': '确定删除主机「{alias}」?将同时停止其隧道。',
|
|
31
|
+
'hosts.imported': '解析 {parsed} 个配置块,新增 {added} 台主机,跳过 {skipped} 个。',
|
|
32
|
+
'hosts.testing': '连接测试中…',
|
|
33
|
+
'hosts.testOk': '连接成功({latency} ms)',
|
|
34
|
+
'hosts.testFail': '连接失败:{error}',
|
|
35
|
+
'hosts.connected': '连接',
|
|
36
|
+
'hosts.notConnected': '未连接',
|
|
37
|
+
// host form
|
|
38
|
+
'form.title.create': '新增主机',
|
|
39
|
+
'form.title.edit': '编辑主机 {alias}',
|
|
40
|
+
'form.alias': '别名',
|
|
41
|
+
'form.aliasHint': '小写字母、数字与单连字符,操作时使用',
|
|
42
|
+
'form.host': '主机地址',
|
|
43
|
+
'form.port': '端口',
|
|
44
|
+
'form.user': '用户名',
|
|
45
|
+
'form.auth': '认证方式',
|
|
46
|
+
'form.auth.key': '密钥',
|
|
47
|
+
'form.auth.password': '密码',
|
|
48
|
+
'form.authKeepHint': '编辑时密钥/密码留空将保留原认证方式与已存凭据;如需更换认证方式请填写新凭据。',
|
|
49
|
+
'form.keyPath': '私钥路径',
|
|
50
|
+
'form.keyPathHint': '如 ~/.ssh/id_ed25519',
|
|
51
|
+
'form.passphrase': '密钥口令',
|
|
52
|
+
'form.password': '密码',
|
|
53
|
+
'form.passwordHint': '明文保存在 ~/.dsh/dsh-ssh.json(本机私有文件)',
|
|
54
|
+
'form.proxyJump': '跳板机别名',
|
|
55
|
+
'form.proxyJumpHint': '逗号分隔的本地主机别名,按顺序连接',
|
|
56
|
+
'form.description': '备注',
|
|
57
|
+
'form.environment': '环境',
|
|
58
|
+
'form.environmentHint': '如 development / production',
|
|
59
|
+
'form.tags': '标签',
|
|
60
|
+
'form.tagsHint': '逗号分隔',
|
|
61
|
+
'form.location': '位置',
|
|
62
|
+
'form.save': '保存',
|
|
63
|
+
'form.cancel': '取消',
|
|
64
|
+
'form.required': '请填写必填字段:别名、主机地址、用户名。',
|
|
65
|
+
'form.portInvalid': '端口必须是 1-65535 之间的整数。',
|
|
66
|
+
// terminal
|
|
67
|
+
'terminal.selectHost': '选择主机',
|
|
68
|
+
'terminal.connect': '连接',
|
|
69
|
+
'terminal.disconnect': '断开',
|
|
70
|
+
'terminal.placeholder': '选择主机后点击「连接」打开远程终端。',
|
|
71
|
+
'terminal.connecting': '正在连接…',
|
|
72
|
+
'terminal.ready': '终端已连接({alias})',
|
|
73
|
+
'terminal.exited': '终端已退出({alias})',
|
|
74
|
+
'terminal.error': '终端错误:{error}',
|
|
75
|
+
// transfer
|
|
76
|
+
'transfer.selectHost': '选择主机',
|
|
77
|
+
'transfer.remotePath': '远程路径',
|
|
78
|
+
'transfer.remotePathHint': '如 /tmp/ 或 /home/user/app.tar.gz',
|
|
79
|
+
'transfer.upload': '上传',
|
|
80
|
+
'transfer.uploading': '正在上传 {file}…',
|
|
81
|
+
'transfer.download': '下载',
|
|
82
|
+
'transfer.downloading': '正在下载…',
|
|
83
|
+
'transfer.done': '完成:{bytes} 字节',
|
|
84
|
+
'transfer.failed': '传输失败:{error}',
|
|
85
|
+
'transfer.browseRemote': '浏览远程目录',
|
|
86
|
+
'transfer.refresh': '刷新',
|
|
87
|
+
'transfer.name': '名称',
|
|
88
|
+
'transfer.upLevel': '上级目录',
|
|
89
|
+
'transfer.size': '大小',
|
|
90
|
+
'transfer.modified': '修改时间',
|
|
91
|
+
'transfer.bytes': '{value} B',
|
|
92
|
+
'transfer.kib': '{value} KB',
|
|
93
|
+
'transfer.mib': '{value} MB',
|
|
94
|
+
'transfer.gib': '{value} GB',
|
|
95
|
+
'transfer.percent': '{value}%',
|
|
96
|
+
'transfer.speed': '{value}/s',
|
|
97
|
+
'transfer.dir': '目录',
|
|
98
|
+
'transfer.file': '文件',
|
|
99
|
+
// tunnels
|
|
100
|
+
'tunnel.empty': '没有活跃的隧道。',
|
|
101
|
+
'tunnel.start': '新建隧道',
|
|
102
|
+
'tunnel.alias': '主机别名',
|
|
103
|
+
'tunnel.remotePort': '远程端口',
|
|
104
|
+
'tunnel.remoteHost': '远程主机',
|
|
105
|
+
'tunnel.remoteHostHint': '默认 127.0.0.1(服务器自身)',
|
|
106
|
+
'tunnel.localPort': '本地端口',
|
|
107
|
+
'tunnel.localPortHint': '留空自动分配',
|
|
108
|
+
'tunnel.stop': '停止',
|
|
109
|
+
'tunnel.stopAll': '全部停止',
|
|
110
|
+
'tunnel.stopAllConfirm': '确定停止全部隧道吗?',
|
|
111
|
+
'tunnel.row': '{alias}: 127.0.0.1:{localPort} -> {remoteHost}:{remotePort}',
|
|
112
|
+
'tunnel.started': '隧道已建立:127.0.0.1:{localPort}',
|
|
113
|
+
'tunnel.failed': '隧道启动失败:{error}',
|
|
114
|
+
// cluster
|
|
115
|
+
'cluster.command': '命令',
|
|
116
|
+
'cluster.run': '执行',
|
|
117
|
+
'cluster.confirm': '确定在匹配的主机上执行该命令吗?该操作会真实运行在远程服务器上。',
|
|
118
|
+
'cluster.aliases': '主机别名(逗号分隔,留空为全部)',
|
|
119
|
+
'cluster.environment': '环境过滤',
|
|
120
|
+
'cluster.tags': '标签过滤(逗号分隔)',
|
|
121
|
+
'cluster.results': '执行结果',
|
|
122
|
+
'cluster.empty': '输入命令后点击「执行」。',
|
|
123
|
+
'cluster.noMatch': '没有匹配的主机。',
|
|
124
|
+
'cluster.ok': '成功',
|
|
125
|
+
'cluster.fail': '失败',
|
|
126
|
+
'cluster.timeout': '超时',
|
|
127
|
+
'cluster.col.alias': '主机',
|
|
128
|
+
'cluster.col.status': '状态',
|
|
129
|
+
'cluster.col.exit': '退出码',
|
|
130
|
+
'cluster.col.stdout': '标准输出',
|
|
131
|
+
'cluster.col.stderr': '标准错误',
|
|
132
|
+
'cluster.col.error': '错误',
|
|
133
|
+
'cluster.col.duration': '耗时',
|
|
134
|
+
// common
|
|
135
|
+
'common.loading': '加载中…',
|
|
136
|
+
'common.error': '出错:{error}',
|
|
137
|
+
'common.refresh': '刷新',
|
|
138
|
+
'common.close': '关闭',
|
|
139
|
+
'common.confirm': '确定',
|
|
140
|
+
} as const
|
|
141
|
+
|
|
142
|
+
export const en: Record<keyof typeof zh, string> = {
|
|
143
|
+
'entry.label': 'SSH',
|
|
144
|
+
'entry.tooltip': 'Remote SSH operations panel',
|
|
145
|
+
'panel.title': 'Remote SSH',
|
|
146
|
+
'tab.hosts': 'Hosts',
|
|
147
|
+
'tab.terminal': 'Terminal',
|
|
148
|
+
'tab.transfer': 'Transfer',
|
|
149
|
+
'tab.tunnels': 'Tunnels',
|
|
150
|
+
'tab.cluster': 'Cluster',
|
|
151
|
+
'hosts.empty': 'No hosts configured. Click "Add host" or import from ~/.ssh/config.',
|
|
152
|
+
'hosts.add': 'Add host',
|
|
153
|
+
'hosts.import': 'Import ~/.ssh/config',
|
|
154
|
+
'hosts.search': 'Search alias / description / tags…',
|
|
155
|
+
'hosts.col.alias': 'Alias',
|
|
156
|
+
'hosts.col.host': 'Host',
|
|
157
|
+
'hosts.col.user': 'User',
|
|
158
|
+
'hosts.col.auth': 'Auth',
|
|
159
|
+
'hosts.col.environment': 'Environment',
|
|
160
|
+
'hosts.col.tags': 'Tags',
|
|
161
|
+
'hosts.col.description': 'Description',
|
|
162
|
+
'hosts.col.actions': 'Actions',
|
|
163
|
+
'hosts.test': 'Test',
|
|
164
|
+
'hosts.edit': 'Edit',
|
|
165
|
+
'hosts.delete': 'Delete',
|
|
166
|
+
'hosts.deleteConfirm': 'Delete host "{alias}"? Its tunnels will be stopped.',
|
|
167
|
+
'hosts.imported': 'Parsed {parsed} blocks, added {added} hosts, skipped {skipped}.',
|
|
168
|
+
'hosts.testing': 'Testing connection…',
|
|
169
|
+
'hosts.testOk': 'Connected ({latency} ms)',
|
|
170
|
+
'hosts.testFail': 'Connection failed: {error}',
|
|
171
|
+
'hosts.connected': 'Connect',
|
|
172
|
+
'hosts.notConnected': 'Not connected',
|
|
173
|
+
'form.title.create': 'Add host',
|
|
174
|
+
'form.title.edit': 'Edit host {alias}',
|
|
175
|
+
'form.alias': 'Alias',
|
|
176
|
+
'form.aliasHint': 'Lowercase letters, digits and single hyphens; used in every operation',
|
|
177
|
+
'form.host': 'Host',
|
|
178
|
+
'form.port': 'Port',
|
|
179
|
+
'form.user': 'User',
|
|
180
|
+
'form.auth': 'Authentication',
|
|
181
|
+
'form.auth.key': 'Key',
|
|
182
|
+
'form.auth.password': 'Password',
|
|
183
|
+
'form.authKeepHint': 'While editing, empty secret fields keep the stored authentication and credentials; enter new secrets to switch methods.',
|
|
184
|
+
'form.keyPath': 'Private key path',
|
|
185
|
+
'form.keyPathHint': 'e.g. ~/.ssh/id_ed25519',
|
|
186
|
+
'form.passphrase': 'Key passphrase',
|
|
187
|
+
'form.password': 'Password',
|
|
188
|
+
'form.passwordHint': 'Stored in plaintext in ~/.dsh/dsh-ssh.json (private local file)',
|
|
189
|
+
'form.proxyJump': 'Jump host aliases',
|
|
190
|
+
'form.proxyJumpHint': 'Comma-separated local aliases, chained in order',
|
|
191
|
+
'form.description': 'Description',
|
|
192
|
+
'form.environment': 'Environment',
|
|
193
|
+
'form.environmentHint': 'e.g. development / production',
|
|
194
|
+
'form.tags': 'Tags',
|
|
195
|
+
'form.tagsHint': 'Comma-separated',
|
|
196
|
+
'form.location': 'Location',
|
|
197
|
+
'form.save': 'Save',
|
|
198
|
+
'form.cancel': 'Cancel',
|
|
199
|
+
'form.required': 'Required fields missing: alias, host, user.',
|
|
200
|
+
'form.portInvalid': 'Port must be an integer between 1 and 65535.',
|
|
201
|
+
'terminal.selectHost': 'Select host',
|
|
202
|
+
'terminal.connect': 'Connect',
|
|
203
|
+
'terminal.disconnect': 'Disconnect',
|
|
204
|
+
'terminal.placeholder': 'Select a host and click "Connect" to open the remote terminal.',
|
|
205
|
+
'terminal.connecting': 'Connecting…',
|
|
206
|
+
'terminal.ready': 'Terminal connected ({alias})',
|
|
207
|
+
'terminal.exited': 'Terminal exited ({alias})',
|
|
208
|
+
'terminal.error': 'Terminal error: {error}',
|
|
209
|
+
'transfer.selectHost': 'Select host',
|
|
210
|
+
'transfer.remotePath': 'Remote path',
|
|
211
|
+
'transfer.remotePathHint': 'e.g. /tmp/ or /home/user/app.tar.gz',
|
|
212
|
+
'transfer.upload': 'Upload',
|
|
213
|
+
'transfer.uploading': 'Uploading {file}…',
|
|
214
|
+
'transfer.download': 'Download',
|
|
215
|
+
'transfer.downloading': 'Downloading…',
|
|
216
|
+
'transfer.done': 'Done: {bytes} bytes',
|
|
217
|
+
'transfer.failed': 'Transfer failed: {error}',
|
|
218
|
+
'transfer.browseRemote': 'Browse remote',
|
|
219
|
+
'transfer.refresh': 'Refresh',
|
|
220
|
+
'transfer.name': 'Name',
|
|
221
|
+
'transfer.upLevel': 'Parent directory',
|
|
222
|
+
'transfer.size': 'Size',
|
|
223
|
+
'transfer.modified': 'Modified',
|
|
224
|
+
'transfer.bytes': '{value} B',
|
|
225
|
+
'transfer.kib': '{value} KB',
|
|
226
|
+
'transfer.mib': '{value} MB',
|
|
227
|
+
'transfer.gib': '{value} GB',
|
|
228
|
+
'transfer.percent': '{value}%',
|
|
229
|
+
'transfer.speed': '{value}/s',
|
|
230
|
+
'transfer.dir': 'Directory',
|
|
231
|
+
'transfer.file': 'File',
|
|
232
|
+
'tunnel.empty': 'No active tunnels.',
|
|
233
|
+
'tunnel.start': 'New tunnel',
|
|
234
|
+
'tunnel.alias': 'Host alias',
|
|
235
|
+
'tunnel.remotePort': 'Remote port',
|
|
236
|
+
'tunnel.remoteHost': 'Remote host',
|
|
237
|
+
'tunnel.remoteHostHint': 'Default 127.0.0.1 (the server itself)',
|
|
238
|
+
'tunnel.localPort': 'Local port',
|
|
239
|
+
'tunnel.localPortHint': 'Leave empty for auto-assign',
|
|
240
|
+
'tunnel.stop': 'Stop',
|
|
241
|
+
'tunnel.stopAll': 'Stop all',
|
|
242
|
+
'tunnel.stopAllConfirm': 'Stop all tunnels?',
|
|
243
|
+
'tunnel.row': '{alias}: 127.0.0.1:{localPort} -> {remoteHost}:{remotePort}',
|
|
244
|
+
'tunnel.started': 'Tunnel up: 127.0.0.1:{localPort}',
|
|
245
|
+
'tunnel.failed': 'Tunnel failed: {error}',
|
|
246
|
+
'cluster.command': 'Command',
|
|
247
|
+
'cluster.run': 'Run',
|
|
248
|
+
'cluster.confirm': 'Run this command on the matching hosts? It executes for real on the remote servers.',
|
|
249
|
+
'cluster.aliases': 'Host aliases (comma-separated; empty = all)',
|
|
250
|
+
'cluster.environment': 'Environment filter',
|
|
251
|
+
'cluster.tags': 'Tag filter (comma-separated)',
|
|
252
|
+
'cluster.results': 'Results',
|
|
253
|
+
'cluster.empty': 'Type a command and click "Run".',
|
|
254
|
+
'cluster.noMatch': 'No hosts matched.',
|
|
255
|
+
'cluster.ok': 'OK',
|
|
256
|
+
'cluster.fail': 'Failed',
|
|
257
|
+
'cluster.timeout': 'Timed out',
|
|
258
|
+
'cluster.col.alias': 'Host',
|
|
259
|
+
'cluster.col.status': 'Status',
|
|
260
|
+
'cluster.col.exit': 'Exit',
|
|
261
|
+
'cluster.col.stdout': 'Stdout',
|
|
262
|
+
'cluster.col.stderr': 'Stderr',
|
|
263
|
+
'cluster.col.error': 'Error',
|
|
264
|
+
'cluster.col.duration': 'Duration',
|
|
265
|
+
'common.loading': 'Loading…',
|
|
266
|
+
'common.error': 'Error: {error}',
|
|
267
|
+
'common.refresh': 'Refresh',
|
|
268
|
+
'common.close': 'Close',
|
|
269
|
+
'common.confirm': 'Confirm',
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Locale key union. */
|
|
273
|
+
export type SshKey = keyof typeof zh
|
|
274
|
+
|
|
275
|
+
/** Tiny interpolation: {name} -> value. */
|
|
276
|
+
export function t(dictionary: Record<string, string>, key: string, values?: Record<string, string | number>): string {
|
|
277
|
+
let text = dictionary[key] ?? key
|
|
278
|
+
if (values !== undefined) {
|
|
279
|
+
for (const [name, value] of Object.entries(values)) {
|
|
280
|
+
text = text.replaceAll(`{${name}}`, String(value))
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return text
|
|
284
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panel view mounting.
|
|
3
|
+
*
|
|
4
|
+
* The `conversation` slot is single-occupant (ui-conversation) and external
|
|
5
|
+
* plugins cannot declare slots, so the panel takes over the center column at
|
|
6
|
+
* the DOM level: a container is appended inside the `[data-pane="conversation"]`
|
|
7
|
+
* grid item (an extra trailing child React never manages), and a stylesheet
|
|
8
|
+
* rule hides the conversation content while the panel is active. Toggling is
|
|
9
|
+
* a data attribute on <html> — no React involvement, so the conversation
|
|
10
|
+
* subtree underneath stays mounted and stateful.
|
|
11
|
+
*/
|
|
12
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
13
|
+
import type { SshApi } from './api.ts'
|
|
14
|
+
import type { PanelController } from './panel/controller.ts'
|
|
15
|
+
import { SshPanel } from './panel/SshPanel.tsx'
|
|
16
|
+
import css from './panel/panel.module.css'
|
|
17
|
+
|
|
18
|
+
/** The injected panel container (kept in the DOM, hidden when inactive). */
|
|
19
|
+
export const PANEL_VIEW_SELECTOR = '[data-dsh-ssh-view]'
|
|
20
|
+
|
|
21
|
+
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"]'
|
|
22
|
+
const ACTIVE_ATTR = 'data-dsh-ssh-active'
|
|
23
|
+
|
|
24
|
+
/** Find the center column, or undefined while the frame is not mounted. */
|
|
25
|
+
function conversationColumn(): HTMLElement | undefined {
|
|
26
|
+
return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Mount the panel React tree into the center column and bind its visibility
|
|
31
|
+
* to the controller's panelOpen state.
|
|
32
|
+
* @param controller - the panel controller driving the view.
|
|
33
|
+
* @param api - the SSH API client the tabs operate through.
|
|
34
|
+
* @returns disposer unmounting the tree and restoring the column.
|
|
35
|
+
*/
|
|
36
|
+
export function mountPanel(controller: PanelController, api: SshApi): () => void {
|
|
37
|
+
let root: Root | undefined
|
|
38
|
+
let container: HTMLDivElement | undefined
|
|
39
|
+
|
|
40
|
+
const ensure = (): void => {
|
|
41
|
+
if (container !== undefined) {
|
|
42
|
+
if (container.isConnected) return
|
|
43
|
+
// The conversation pane was replaced; drop the stale tree and remount.
|
|
44
|
+
root?.unmount()
|
|
45
|
+
root = undefined
|
|
46
|
+
container.remove()
|
|
47
|
+
container = undefined
|
|
48
|
+
}
|
|
49
|
+
const column = conversationColumn()
|
|
50
|
+
if (column === undefined) return
|
|
51
|
+
container = document.createElement('div')
|
|
52
|
+
container.dataset.dshSshView = ''
|
|
53
|
+
container.className = css.view
|
|
54
|
+
column.appendChild(container)
|
|
55
|
+
root = createRoot(container)
|
|
56
|
+
root.render(<SshPanel controller={controller} api={api} />)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The frame mounts after boot settlement; watch for the column's arrival.
|
|
60
|
+
const waitObserver = new MutationObserver(() => { ensure() })
|
|
61
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
62
|
+
|
|
63
|
+
const applyActive = (): void => {
|
|
64
|
+
if (controller.getSnapshot().panelOpen) {
|
|
65
|
+
document.documentElement.setAttribute(ACTIVE_ATTR, '')
|
|
66
|
+
} else {
|
|
67
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const unsubscribe = controller.subscribe(applyActive)
|
|
71
|
+
applyActive()
|
|
72
|
+
ensure()
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
waitObserver.disconnect()
|
|
76
|
+
unsubscribe()
|
|
77
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
78
|
+
root?.unmount()
|
|
79
|
+
root = undefined
|
|
80
|
+
container?.remove()
|
|
81
|
+
container = undefined
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cluster tab: run one command across many hosts (aliases / environment /
|
|
3
|
+
* tags filters) and inspect per-host results. Stdout/stderr render in
|
|
4
|
+
* collapsed <details> blocks; status renders as a colored badge.
|
|
5
|
+
*/
|
|
6
|
+
import { useState } from 'react'
|
|
7
|
+
import type { SshApi } from '../api.ts'
|
|
8
|
+
import type { ClusterResult } from '../../protocol.ts'
|
|
9
|
+
import { errorMessage, tt } from './helpers.ts'
|
|
10
|
+
import css from './panel.module.css'
|
|
11
|
+
|
|
12
|
+
/** Cluster tab props. */
|
|
13
|
+
export interface ClusterTabProps {
|
|
14
|
+
api: SshApi
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Split a comma-separated input into a trimmed, non-empty string list. */
|
|
18
|
+
function splitList(text: string): string[] {
|
|
19
|
+
return text.split(',').map(part => part.trim()).filter(part => part !== '')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The cluster execution tab. */
|
|
23
|
+
export function ClusterTab({ api }: ClusterTabProps) {
|
|
24
|
+
const [command, setCommand] = useState('')
|
|
25
|
+
const [aliases, setAliases] = useState('')
|
|
26
|
+
const [environment, setEnvironment] = useState('')
|
|
27
|
+
const [tags, setTags] = useState('')
|
|
28
|
+
const [running, setRunning] = useState(false)
|
|
29
|
+
const [results, setResults] = useState<ClusterResult[] | null>(null)
|
|
30
|
+
const [error, setError] = useState<string | null>(null)
|
|
31
|
+
|
|
32
|
+
const run = async (): Promise<void> => {
|
|
33
|
+
if (command.trim() === '' || running) return
|
|
34
|
+
if (!window.confirm(tt('cluster.confirm'))) return
|
|
35
|
+
setRunning(true)
|
|
36
|
+
setError(null)
|
|
37
|
+
try {
|
|
38
|
+
const aliasList = splitList(aliases)
|
|
39
|
+
const tagList = splitList(tags)
|
|
40
|
+
const outcome = await api.cluster({
|
|
41
|
+
command: command.trim(),
|
|
42
|
+
aliases: aliasList.length > 0 ? aliasList : undefined,
|
|
43
|
+
environment: environment.trim() === '' ? undefined : environment.trim(),
|
|
44
|
+
tags: tagList.length > 0 ? tagList : undefined,
|
|
45
|
+
})
|
|
46
|
+
setResults(outcome)
|
|
47
|
+
} catch (cause) {
|
|
48
|
+
setError(errorMessage(cause))
|
|
49
|
+
} finally {
|
|
50
|
+
setRunning(false)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className={css.fillBody}>
|
|
56
|
+
<div className={css.clusterForm}>
|
|
57
|
+
<label className={css.field}>
|
|
58
|
+
<span className={css.fieldLabel}>{tt('cluster.command')}</span>
|
|
59
|
+
<textarea className={css.input + ' ' + css.commandInput} value={command} onChange={event => { setCommand(event.target.value) }} />
|
|
60
|
+
</label>
|
|
61
|
+
<div className={css.clusterFilters}>
|
|
62
|
+
<input className={css.input} placeholder={tt('cluster.aliases')} value={aliases} onChange={event => { setAliases(event.target.value) }} />
|
|
63
|
+
<input className={css.input} placeholder={tt('cluster.environment')} value={environment} onChange={event => { setEnvironment(event.target.value) }} />
|
|
64
|
+
<input className={css.input} placeholder={tt('cluster.tags')} value={tags} onChange={event => { setTags(event.target.value) }} />
|
|
65
|
+
</div>
|
|
66
|
+
<div>
|
|
67
|
+
<button type="button" className={css.primaryButton} disabled={running || command.trim() === ''} onClick={() => { void run() }}>{tt('cluster.run')}</button>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
{error !== null && <div className={css.banner} data-kind="error">{tt('common.error', { error })}</div>}
|
|
71
|
+
{results === null && error === null && <div className={css.empty}>{tt('cluster.empty')}</div>}
|
|
72
|
+
{results !== null && results.length === 0 && <div className={css.empty}>{tt('cluster.noMatch')}</div>}
|
|
73
|
+
{results !== null && results.length > 0 && (
|
|
74
|
+
<div className={css.tableWrap}>
|
|
75
|
+
<table className={css.table}>
|
|
76
|
+
<thead>
|
|
77
|
+
<tr>
|
|
78
|
+
<th>{tt('cluster.col.alias')}</th>
|
|
79
|
+
<th>{tt('cluster.col.status')}</th>
|
|
80
|
+
<th>{tt('cluster.col.exit')}</th>
|
|
81
|
+
<th>{tt('cluster.col.stdout')}</th>
|
|
82
|
+
<th>{tt('cluster.col.stderr')}</th>
|
|
83
|
+
<th>{tt('cluster.col.error')}</th>
|
|
84
|
+
<th>{tt('cluster.col.duration')}</th>
|
|
85
|
+
</tr>
|
|
86
|
+
</thead>
|
|
87
|
+
<tbody>
|
|
88
|
+
{results.map(result => {
|
|
89
|
+
const status = result.ok ? 'ok' : result.timedOut === true ? 'timeout' : 'fail'
|
|
90
|
+
const label = result.ok ? tt('cluster.ok') : result.timedOut === true ? tt('cluster.timeout') : tt('cluster.fail')
|
|
91
|
+
return (
|
|
92
|
+
<tr key={result.alias}>
|
|
93
|
+
<td className={css.mono}>{result.alias}</td>
|
|
94
|
+
<td><span className={css.badge} data-status={status}>{label}</span></td>
|
|
95
|
+
<td className={css.mono}>{result.exitCode ?? '-'}</td>
|
|
96
|
+
<td>
|
|
97
|
+
{result.stdout !== undefined && result.stdout !== '' && (
|
|
98
|
+
<details className={css.cellDetails}>
|
|
99
|
+
<summary>{tt('cluster.col.stdout')}</summary>
|
|
100
|
+
<pre className={css.cellPre}>{result.stdout}</pre>
|
|
101
|
+
</details>
|
|
102
|
+
)}
|
|
103
|
+
</td>
|
|
104
|
+
<td>
|
|
105
|
+
{result.stderr !== undefined && result.stderr !== '' && (
|
|
106
|
+
<details className={css.cellDetails}>
|
|
107
|
+
<summary>{tt('cluster.col.stderr')}</summary>
|
|
108
|
+
<pre className={css.cellPre}>{result.stderr}</pre>
|
|
109
|
+
</details>
|
|
110
|
+
)}
|
|
111
|
+
</td>
|
|
112
|
+
<td className={css.cellMuted}>{result.error ?? ''}</td>
|
|
113
|
+
<td className={css.mono}>{result.durationMs !== undefined ? result.durationMs + ' ms' : '-'}</td>
|
|
114
|
+
</tr>
|
|
115
|
+
)
|
|
116
|
+
})}
|
|
117
|
+
</tbody>
|
|
118
|
+
</table>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|