@goodandready/dsh-remote-workspace 0.3.8 → 0.3.9
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 +45 -0
- package/README.ru.md +45 -0
- package/README.zh.md +45 -0
- package/lib/agent-endpoint.js +16 -0
- package/lib/client.js +796 -14
- package/lib/cluster-service.js +57 -0
- package/lib/exec-retry.js +24 -0
- package/lib/file-transfer.js +56 -0
- package/lib/host-projection.js +42 -0
- package/lib/index.js +23 -4
- package/lib/jump-chain.js +56 -0
- package/lib/poll-gate.js +5 -0
- package/lib/profile-groups.js +60 -0
- package/lib/proxy-command.js +53 -0
- package/lib/remote-fs-service.js +25 -0
- package/lib/routes/files.js +75 -0
- package/lib/routes/profiles.js +82 -8
- package/lib/routes/terminal.js +19 -0
- package/lib/ssh-config-parser.js +111 -49
- package/lib/ssh-service.js +180 -21
- package/lib/terminal-font.js +12 -0
- package/lib/tools.js +52 -1
- package/lib/tunnel-service.js +3 -0
- package/lib/types.js +7 -0
- package/lib/workspace-mount.js +5 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -77,10 +77,10 @@ window.__ModuleLoader__.load({
|
|
|
77
77
|
.drw-tab-btn.active{background:var(--dsw-alias-bg-layer-4);border-color:var(--dsw-alias-state-brand-primary);color:var(--dsw-alias-state-brand-primary)}
|
|
78
78
|
|
|
79
79
|
.drw-term-window{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;display:flex;flex-direction:column;height:340px;overflow:hidden;box-shadow:0 4px 14px var(--dsw-alias-border-l1)}
|
|
80
|
-
.drw-term-header{background:var(--dsw-alias-bg-layer-2);padding:6px 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--dsw-alias-border-l2);font-size:12px;color:var(--dsw-alias-label-secondary);font-family:monospace}
|
|
81
|
-
.drw-term-body{flex:1;padding:10px 12px;overflow-y:auto;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12.5px;line-height:1.45;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-all}
|
|
80
|
+
.drw-term-header{background:var(--dsw-alias-bg-layer-2);padding:6px 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--dsw-alias-border-l2);font-size:12px;color:var(--dsw-alias-label-secondary);font-family:var(--drw-terminal-font,monospace)}
|
|
81
|
+
.drw-term-body{flex:1;padding:10px 12px;overflow-y:auto;font-family:var(--drw-terminal-font,ui-monospace,SFMono-Regular,Consolas,monospace);font-size:12.5px;line-height:1.45;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-all}
|
|
82
82
|
.drw-term-footer{display:flex;background:var(--dsw-alias-bg-layer-2);border-top:1px solid var(--dsw-alias-border-l2);padding:6px}
|
|
83
|
-
.drw-term-input{flex:1;background:transparent;border:none;color:var(--dsw-alias-link-primary, var(--dsw-alias-label-primary));font-family:monospace;font-size:13px;padding:4px 8px;outline:none}
|
|
83
|
+
.drw-term-input{flex:1;background:transparent;border:none;color:var(--dsw-alias-link-primary, var(--dsw-alias-label-primary));font-family:var(--drw-terminal-font,monospace);font-size:13px;padding:4px 8px;outline:none}
|
|
84
84
|
|
|
85
85
|
.drw-explorer-box{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);padding:12px;display:flex;flex-direction:column;gap:10px}
|
|
86
86
|
.drw-explorer-nav{display:flex;align-items:center;gap:8px;font-family:monospace;font-size:12px;background:var(--dsw-alias-bg-layer-1);padding:6px 10px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2)}
|
|
@@ -109,12 +109,32 @@ window.__ModuleLoader__.load({
|
|
|
109
109
|
.drw-alert-warning{background:var(--dsw-alias-state-warning-bg, var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-state-warning-border, var(--dsw-alias-border-l2))}
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
.drw-ws{min-height:100%;box-sizing:border-box;color:var(--dsw-alias-label-primary)}
|
|
113
|
+
.drw-ws-bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;padding:12px 16px;position:sticky;top:0;background:var(--dsw-alias-bg-base,#171717);z-index:2}
|
|
114
|
+
.drw-ws-panel{padding:0 16px 24px}
|
|
115
|
+
.drw-ws-panel[hidden]{display:none!important}
|
|
116
|
+
[data-pane=conversation],[class*=centerCol]{position:relative!important}
|
|
117
|
+
[data-drw-view]{display:none;position:absolute;inset:0;z-index:60;overflow:auto;background:var(--dsw-alias-bg-base,#171717)}
|
|
118
|
+
html[data-drw-active] [data-drw-view]{display:block!important}
|
|
119
|
+
html[data-drw-active] [data-pane=conversation] > *:not([data-drw-view]),html[data-drw-active] [class*=centerCol] > *:not([data-drw-view]){display:none!important}
|
|
120
|
+
.drw-entry-clone{display:flex;align-items:center;gap:8px}
|
|
121
|
+
.drw-entry-icon{display:flex;align-items:center;justify-content:center;width:16px;height:16px;flex:none}
|
|
122
|
+
[data-dsh-frame][data-sidebar-collapsed] .drw-entry-label{display:none}
|
|
123
|
+
|
|
112
124
|
`
|
|
113
125
|
document.head.appendChild(style)
|
|
114
126
|
}
|
|
115
127
|
|
|
116
128
|
const en = {
|
|
117
129
|
title: 'Remote Workspace',
|
|
130
|
+
navLabel: 'Remote',
|
|
131
|
+
wsBack: 'Back',
|
|
132
|
+
wsHosts: 'Hosts',
|
|
133
|
+
wsTerminal: 'Terminal',
|
|
134
|
+
wsFiles: 'Files',
|
|
135
|
+
wsDocker: 'Containers',
|
|
136
|
+
wsTunnels: 'Tunnels',
|
|
137
|
+
wsCluster: 'Cluster',
|
|
118
138
|
subtitle: 'Native SSH connections, SFTP file access, 3-way mirror sync, and port forwarding tunnels.',
|
|
119
139
|
status: 'Ready',
|
|
120
140
|
actionFailed: 'The request failed.',
|
|
@@ -141,6 +161,11 @@ window.__ModuleLoader__.load({
|
|
|
141
161
|
|
|
142
162
|
tabEnv: '🗃️ .env Manager',
|
|
143
163
|
btnImportSsh: 'Import ~/.ssh/config',
|
|
164
|
+
importAdded: 'Imported {count} hosts.',
|
|
165
|
+
skipWildcard: 'pattern is not a single host',
|
|
166
|
+
skipMatch: 'Match blocks are not imported',
|
|
167
|
+
skipDuplicate: 'already imported',
|
|
168
|
+
skipMissing: 'included file was not found',
|
|
144
169
|
envTitle: 'Remote Environment Variables (.env)',
|
|
145
170
|
envKey: 'Key',
|
|
146
171
|
envVal: 'Value',
|
|
@@ -225,6 +250,47 @@ window.__ModuleLoader__.load({
|
|
|
225
250
|
fAuthType: 'Authentication Method',
|
|
226
251
|
fAuthKey: 'SSH Private Key',
|
|
227
252
|
fAuthPass: 'Password',
|
|
253
|
+
fEnvironment: 'Environment',
|
|
254
|
+
fLocation: 'Location',
|
|
255
|
+
fTags: 'Tags',
|
|
256
|
+
fDescription: 'Description',
|
|
257
|
+
groupFlat: 'Flat',
|
|
258
|
+
groupEnvironment: 'By environment',
|
|
259
|
+
groupTag: 'By tag',
|
|
260
|
+
groupUngrouped: 'Ungrouped',
|
|
261
|
+
testGroup: 'Test group',
|
|
262
|
+
groupOnline: 'online, {latency} ms',
|
|
263
|
+
groupOffline: 'offline: {err}',
|
|
264
|
+
tabProfiles: 'Hosts',
|
|
265
|
+
tabCluster: 'Cluster',
|
|
266
|
+
tabFiles: 'Files',
|
|
267
|
+
btnUpload: 'Upload',
|
|
268
|
+
btnDownload: 'Download',
|
|
269
|
+
transferProgress: '{name}: {percent}%',
|
|
270
|
+
transferFailed: 'Transfer failed: {name}',
|
|
271
|
+
transferCancel: 'Cancel',
|
|
272
|
+
clusterTitle: 'Run on a group',
|
|
273
|
+
clusterCommand: 'Command',
|
|
274
|
+
clusterRun: 'Run',
|
|
275
|
+
clusterRunning: 'Running...',
|
|
276
|
+
clusterCount: '{count} hosts configured',
|
|
277
|
+
clusterOk: 'ok',
|
|
278
|
+
clusterFail: 'failed',
|
|
279
|
+
fAuthAgent: 'SSH Agent',
|
|
280
|
+
authPromptTitle: 'Sign-in code',
|
|
281
|
+
authPromptCode: 'One-time code',
|
|
282
|
+
authPromptSubmit: 'Continue',
|
|
283
|
+
fAuthKeyShort: 'key',
|
|
284
|
+
fAuthPassShort: 'pwd',
|
|
285
|
+
fAuthAgentShort: 'agent',
|
|
286
|
+
fAgentPath: 'Agent socket',
|
|
287
|
+
fAgentPathHint: 'Empty uses SSH_AUTH_SOCK. Use pageant on Windows.',
|
|
288
|
+
fJumpHosts: 'Jump hosts',
|
|
289
|
+
fJumpHostsHint: 'Profile ids in order, separated by commas. The first host is the entry bastion.',
|
|
290
|
+
fProxyCommand: 'Proxy command',
|
|
291
|
+
fProxyCommandHint: 'OpenSSH ProxyCommand. Tokens: %h host, %p port, %r user, %n profile name.',
|
|
292
|
+
fTerminalFont: 'Terminal font',
|
|
293
|
+
fTerminalFontHint: 'Font family for the terminal. Leave empty to keep the default monospace stack.',
|
|
228
294
|
fKeyPath: 'Private Key Path',
|
|
229
295
|
fKeyPathHint: 'Path to ~/.ssh/id_rsa or /home/user/.ssh/id_ed25519',
|
|
230
296
|
fPassphrase: 'Key Passphrase (optional)',
|
|
@@ -271,6 +337,14 @@ window.__ModuleLoader__.load({
|
|
|
271
337
|
|
|
272
338
|
const zh = {
|
|
273
339
|
title: '远程开发工作区',
|
|
340
|
+
navLabel: '远程',
|
|
341
|
+
wsBack: '返回',
|
|
342
|
+
wsHosts: '主机',
|
|
343
|
+
wsTerminal: '终端',
|
|
344
|
+
wsFiles: '文件',
|
|
345
|
+
wsDocker: '容器',
|
|
346
|
+
wsTunnels: '隧道',
|
|
347
|
+
wsCluster: '集群',
|
|
274
348
|
subtitle: '支持原生 SSH 连接、SFTP 文件访问、3-way 镜像同步与端口转发隧道。',
|
|
275
349
|
status: '就绪',
|
|
276
350
|
actionFailed: '请求失败。',
|
|
@@ -284,6 +358,11 @@ window.__ModuleLoader__.load({
|
|
|
284
358
|
tabContainers: '🐳 容器管理',
|
|
285
359
|
tabEnv: '🗃️ 环境变量',
|
|
286
360
|
btnImportSsh: '导入 ~/.ssh/config',
|
|
361
|
+
importAdded: '已导入 {count} 台主机。',
|
|
362
|
+
skipWildcard: '通配主机不会导入',
|
|
363
|
+
skipMatch: '不导入 Match 块',
|
|
364
|
+
skipDuplicate: '已经导入',
|
|
365
|
+
skipMissing: '找不到被包含的文件',
|
|
287
366
|
envTitle: '远程环境变量配置 (.env)',
|
|
288
367
|
envKey: '变量名',
|
|
289
368
|
envVal: '变量值',
|
|
@@ -379,6 +458,47 @@ window.__ModuleLoader__.load({
|
|
|
379
458
|
fAuthType: '身份认证方式',
|
|
380
459
|
fAuthKey: 'SSH 私钥',
|
|
381
460
|
fAuthPass: '账户密码',
|
|
461
|
+
fEnvironment: '环境',
|
|
462
|
+
fLocation: '位置',
|
|
463
|
+
fTags: '标签',
|
|
464
|
+
fDescription: '说明',
|
|
465
|
+
groupFlat: '平铺',
|
|
466
|
+
groupEnvironment: '按环境',
|
|
467
|
+
groupTag: '按标签',
|
|
468
|
+
groupUngrouped: '未分组',
|
|
469
|
+
testGroup: '测试本组',
|
|
470
|
+
groupOnline: '在线,{latency} 毫秒',
|
|
471
|
+
groupOffline: '离线:{err}',
|
|
472
|
+
tabProfiles: '主机',
|
|
473
|
+
tabCluster: '集群',
|
|
474
|
+
tabFiles: '文件',
|
|
475
|
+
btnUpload: '上传',
|
|
476
|
+
btnDownload: '下载',
|
|
477
|
+
transferProgress: '{name}:{percent}%',
|
|
478
|
+
transferFailed: '传输失败:{name}',
|
|
479
|
+
transferCancel: '取消',
|
|
480
|
+
clusterTitle: '在一组主机上运行',
|
|
481
|
+
clusterCommand: '命令',
|
|
482
|
+
clusterRun: '运行',
|
|
483
|
+
clusterRunning: '正在运行...',
|
|
484
|
+
clusterCount: '已配置 {count} 台主机',
|
|
485
|
+
clusterOk: '成功',
|
|
486
|
+
clusterFail: '失败',
|
|
487
|
+
fAuthAgent: 'SSH 代理',
|
|
488
|
+
authPromptTitle: '登录验证码',
|
|
489
|
+
authPromptCode: '一次性验证码',
|
|
490
|
+
authPromptSubmit: '继续',
|
|
491
|
+
fAuthKeyShort: '密钥',
|
|
492
|
+
fAuthPassShort: '密码',
|
|
493
|
+
fAuthAgentShort: '代理',
|
|
494
|
+
fAgentPath: '代理套接字',
|
|
495
|
+
fAgentPathHint: '留空则使用 SSH_AUTH_SOCK。Windows 上填写 pageant。',
|
|
496
|
+
fJumpHosts: '跳板主机',
|
|
497
|
+
fJumpHostsHint: '按经过顺序填写配置编号,用逗号分隔。第一台是入口堡垒机。',
|
|
498
|
+
fProxyCommand: '代理命令',
|
|
499
|
+
fProxyCommandHint: 'OpenSSH ProxyCommand。令牌:%h 主机,%p 端口,%r 用户,%n 配置名称。',
|
|
500
|
+
fTerminalFont: '终端字体',
|
|
501
|
+
fTerminalFontHint: '终端使用的字体族。留空则使用默认等宽字体。',
|
|
382
502
|
fKeyPath: '私钥文件路径',
|
|
383
503
|
fKeyPathHint: '例如 ~/.ssh/id_rsa 或 /home/user/.ssh/id_ed25519',
|
|
384
504
|
fPassphrase: '私钥口令 (可选)',
|
|
@@ -717,6 +837,13 @@ window.__ModuleLoader__.load({
|
|
|
717
837
|
|
|
718
838
|
// --- Sub-component: Web Terminal Tab ---
|
|
719
839
|
|
|
840
|
+
function applyTerminalFont(font) {
|
|
841
|
+
if (typeof document === 'undefined') return
|
|
842
|
+
const value = String(font || '').trim()
|
|
843
|
+
if (value) document.documentElement.style.setProperty('--drw-terminal-font', value)
|
|
844
|
+
else document.documentElement.style.removeProperty('--drw-terminal-font')
|
|
845
|
+
}
|
|
846
|
+
|
|
720
847
|
function TerminalTab(props) {
|
|
721
848
|
const { activeProfile, t } = props
|
|
722
849
|
const [connected, setConnected] = React.useState(false)
|
|
@@ -902,7 +1029,7 @@ window.__ModuleLoader__.load({
|
|
|
902
1029
|
? React.createElement(
|
|
903
1030
|
'form',
|
|
904
1031
|
{ className: 'drw-term-footer', onSubmit: sendCommand },
|
|
905
|
-
React.createElement('span', { style: { color: 'var(--dsw-alias-link-primary)', padding: '4px 6px', fontFamily: '
|
|
1032
|
+
React.createElement('span', { style: { color: 'var(--dsw-alias-link-primary)', padding: '4px 6px', fontFamily: 'inherit' } }, '$'),
|
|
906
1033
|
React.createElement('input', {
|
|
907
1034
|
className: 'drw-term-input',
|
|
908
1035
|
type: 'text',
|
|
@@ -933,6 +1060,8 @@ window.__ModuleLoader__.load({
|
|
|
933
1060
|
const [editContent, setEditContent] = React.useState('')
|
|
934
1061
|
const [previewLoading, setPreviewLoading] = React.useState(false)
|
|
935
1062
|
const [saveStatus, setSaveStatus] = React.useState(null)
|
|
1063
|
+
const [transfer, setTransfer] = React.useState(null)
|
|
1064
|
+
const transferAbort = React.useRef(null)
|
|
936
1065
|
|
|
937
1066
|
const loadDir = async (dirPath) => {
|
|
938
1067
|
if (!activeProfile) return
|
|
@@ -990,6 +1119,75 @@ window.__ModuleLoader__.load({
|
|
|
990
1119
|
setPreviewLoading(false)
|
|
991
1120
|
}
|
|
992
1121
|
|
|
1122
|
+
|
|
1123
|
+
const filePathFor = (entry) => currentPath.endsWith('/') ? currentPath + entry.filename : currentPath + '/' + entry.filename
|
|
1124
|
+
|
|
1125
|
+
const downloadFile = async (entry, event) => {
|
|
1126
|
+
if (event) event.stopPropagation()
|
|
1127
|
+
if (!activeProfile) return
|
|
1128
|
+
const ctrl = new AbortController()
|
|
1129
|
+
transferAbort.current = () => ctrl.abort()
|
|
1130
|
+
setTransfer({ name: entry.filename, percent: 0 })
|
|
1131
|
+
try {
|
|
1132
|
+
const params = new URLSearchParams({ profileId: activeProfile.id, filePath: filePathFor(entry) })
|
|
1133
|
+
const res = await fetch('/dsh-remote-workspace/file/download?' + params.toString(), { signal: ctrl.signal })
|
|
1134
|
+
if (!res.ok || !res.body) {
|
|
1135
|
+
setTransfer({ name: entry.filename, error: true })
|
|
1136
|
+
return
|
|
1137
|
+
}
|
|
1138
|
+
const total = Number(res.headers.get('content-length')) || 0
|
|
1139
|
+
const reader = res.body.getReader()
|
|
1140
|
+
const chunks = []
|
|
1141
|
+
let loaded = 0
|
|
1142
|
+
while (true) {
|
|
1143
|
+
const step = await reader.read()
|
|
1144
|
+
if (step.done) break
|
|
1145
|
+
chunks.push(step.value)
|
|
1146
|
+
loaded += step.value.length
|
|
1147
|
+
setTransfer({ name: entry.filename, percent: total ? Math.min(100, Math.round((loaded / total) * 100)) : 0 })
|
|
1148
|
+
}
|
|
1149
|
+
const blob = new Blob(chunks)
|
|
1150
|
+
const link = document.createElement('a')
|
|
1151
|
+
link.href = URL.createObjectURL(blob)
|
|
1152
|
+
link.download = entry.filename
|
|
1153
|
+
link.click()
|
|
1154
|
+
URL.revokeObjectURL(link.href)
|
|
1155
|
+
setTransfer(null)
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
setTransfer({ name: entry.filename, error: err && err.name === 'AbortError' })
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const uploadFile = () => {
|
|
1162
|
+
if (!activeProfile) return
|
|
1163
|
+
const input = document.createElement('input')
|
|
1164
|
+
input.type = 'file'
|
|
1165
|
+
input.onchange = () => {
|
|
1166
|
+
const file = input.files && input.files[0]
|
|
1167
|
+
if (!file) return
|
|
1168
|
+
const remotePath = currentPath.endsWith('/') ? currentPath + file.name : currentPath + '/' + file.name
|
|
1169
|
+
const xhr = new XMLHttpRequest()
|
|
1170
|
+
const params = new URLSearchParams({ profileId: activeProfile.id, filePath: remotePath })
|
|
1171
|
+
xhr.open('POST', '/dsh-remote-workspace/file/upload?' + params.toString())
|
|
1172
|
+
transferAbort.current = () => xhr.abort()
|
|
1173
|
+
setTransfer({ name: file.name, percent: 0 })
|
|
1174
|
+
xhr.upload.onprogress = (event) => {
|
|
1175
|
+
if (event.lengthComputable) {
|
|
1176
|
+
setTransfer({ name: file.name, percent: Math.min(100, Math.round((event.loaded / event.total) * 100)) })
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
xhr.onload = () => {
|
|
1180
|
+
setTransfer(null)
|
|
1181
|
+
if (xhr.status >= 200 && xhr.status < 300) loadDir(currentPath)
|
|
1182
|
+
else setTransfer({ name: file.name, error: true })
|
|
1183
|
+
}
|
|
1184
|
+
xhr.onerror = () => setTransfer({ name: file.name, error: true })
|
|
1185
|
+
xhr.onabort = () => setTransfer({ name: file.name, error: true })
|
|
1186
|
+
xhr.send(file)
|
|
1187
|
+
}
|
|
1188
|
+
input.click()
|
|
1189
|
+
}
|
|
1190
|
+
|
|
993
1191
|
const handleSaveFile = async () => {
|
|
994
1192
|
if (!previewFile || !activeProfile) return
|
|
995
1193
|
setSaveStatus('saving')
|
|
@@ -1027,8 +1225,15 @@ window.__ModuleLoader__.load({
|
|
|
1027
1225
|
disabled: loading
|
|
1028
1226
|
},
|
|
1029
1227
|
loading ? 'Refreshing...' : '🔄 Refresh'
|
|
1030
|
-
)
|
|
1228
|
+
),
|
|
1229
|
+
React.createElement('button', { type: 'button', className: 'drw-btn', onClick: uploadFile }, t('btnUpload'))
|
|
1031
1230
|
),
|
|
1231
|
+
transfer
|
|
1232
|
+
? React.createElement('div', { className: 'drw-hint', role: 'status' },
|
|
1233
|
+
transfer.error ? t('transferFailed', { name: transfer.name }) : t('transferProgress', { name: transfer.name, percent: transfer.percent || 0 }),
|
|
1234
|
+
React.createElement('button', { type: 'button', className: 'drw-btn', onClick: () => transferAbort.current && transferAbort.current() }, t('transferCancel'))
|
|
1235
|
+
)
|
|
1236
|
+
: null,
|
|
1032
1237
|
React.createElement(
|
|
1033
1238
|
'div',
|
|
1034
1239
|
{ className: 'drw-explorer-nav' },
|
|
@@ -1069,7 +1274,8 @@ window.__ModuleLoader__.load({
|
|
|
1069
1274
|
'div',
|
|
1070
1275
|
{ className: 'drw-file-meta' },
|
|
1071
1276
|
e.size !== undefined ? React.createElement('span', null, `${Math.round(e.size / 1024)} KB`) : null,
|
|
1072
|
-
e.permissions ? React.createElement('span', { style: { opacity: 0.6 } }, e.permissions) : null
|
|
1277
|
+
e.permissions ? React.createElement('span', { style: { opacity: 0.6 } }, e.permissions) : null,
|
|
1278
|
+
e.isDirectory ? null : React.createElement('button', { type: 'button', className: 'drw-btn', onClick: (event) => downloadFile(e, event) }, t('btnDownload'))
|
|
1073
1279
|
)
|
|
1074
1280
|
)
|
|
1075
1281
|
)
|
|
@@ -1158,6 +1364,31 @@ window.__ModuleLoader__.load({
|
|
|
1158
1364
|
)
|
|
1159
1365
|
}
|
|
1160
1366
|
|
|
1367
|
+
|
|
1368
|
+
function groupProfiles(list, mode) {
|
|
1369
|
+
const profiles = Array.isArray(list) ? list : []
|
|
1370
|
+
const bucket = (map, key, profile) => {
|
|
1371
|
+
const name = key || 'ungrouped'
|
|
1372
|
+
if (!map.has(name)) map.set(name, [])
|
|
1373
|
+
map.get(name).push(profile)
|
|
1374
|
+
}
|
|
1375
|
+
if (mode === 'environment') {
|
|
1376
|
+
const map = new Map()
|
|
1377
|
+
profiles.forEach((profile) => bucket(map, String(profile.environment || '').trim(), profile))
|
|
1378
|
+
return Array.from(map.entries()).map(([key, items]) => ({ key, items }))
|
|
1379
|
+
}
|
|
1380
|
+
if (mode === 'tag') {
|
|
1381
|
+
const map = new Map()
|
|
1382
|
+
profiles.forEach((profile) => {
|
|
1383
|
+
const tags = Array.isArray(profile.tags) ? profile.tags.map((item) => String(item).trim()).filter(Boolean) : []
|
|
1384
|
+
if (!tags.length) bucket(map, '', profile)
|
|
1385
|
+
else tags.forEach((tag) => bucket(map, tag, profile))
|
|
1386
|
+
})
|
|
1387
|
+
return Array.from(map.entries()).map(([key, items]) => ({ key, items }))
|
|
1388
|
+
}
|
|
1389
|
+
return [{ key: 'all', items: profiles }]
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1161
1392
|
function ProfilesPanel(panel) {
|
|
1162
1393
|
const {
|
|
1163
1394
|
t,
|
|
@@ -1182,6 +1413,22 @@ window.__ModuleLoader__.load({
|
|
|
1182
1413
|
showPassword,
|
|
1183
1414
|
setShowPassword,
|
|
1184
1415
|
} = panel
|
|
1416
|
+
const [groupMode, setGroupMode] = React.useState('flat')
|
|
1417
|
+
const [groupReport, setGroupReport] = React.useState([])
|
|
1418
|
+
const sections = groupProfiles(profiles, groupMode)
|
|
1419
|
+
const testGroup = async (items) => {
|
|
1420
|
+
try {
|
|
1421
|
+
const res = await fetch('/dsh-remote-workspace/profiles/test-group', {
|
|
1422
|
+
method: 'POST',
|
|
1423
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1424
|
+
body: JSON.stringify({ ids: items.map((item) => item.id) })
|
|
1425
|
+
})
|
|
1426
|
+
const data = await res.json()
|
|
1427
|
+
if (data && data.ok) setGroupReport(data.results || [])
|
|
1428
|
+
} catch (err) {
|
|
1429
|
+
setGroupReport([])
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1185
1432
|
return (
|
|
1186
1433
|
React.createElement(
|
|
1187
1434
|
'div',
|
|
@@ -1208,7 +1455,11 @@ window.__ModuleLoader__.load({
|
|
|
1208
1455
|
passphrase: '',
|
|
1209
1456
|
password: '',
|
|
1210
1457
|
remoteWorkspace: '',
|
|
1211
|
-
localMirrorPath: ''
|
|
1458
|
+
localMirrorPath: '',
|
|
1459
|
+
environment: '',
|
|
1460
|
+
tags: [],
|
|
1461
|
+
location: '',
|
|
1462
|
+
description: ''
|
|
1212
1463
|
})
|
|
1213
1464
|
setTestResult(null)
|
|
1214
1465
|
}
|
|
@@ -1286,6 +1537,62 @@ window.__ModuleLoader__.load({
|
|
|
1286
1537
|
)
|
|
1287
1538
|
),
|
|
1288
1539
|
|
|
1540
|
+
React.createElement(
|
|
1541
|
+
'div',
|
|
1542
|
+
{ className: 'drw-field' },
|
|
1543
|
+
React.createElement('span', { className: 'drw-label' }, t('fJumpHosts')),
|
|
1544
|
+
React.createElement('input', {
|
|
1545
|
+
className: 'drw-input',
|
|
1546
|
+
placeholder: 'bastion-a, bastion-b',
|
|
1547
|
+
value: editing.jumpHostId || ((editing.jumpHosts || []).join(', ')),
|
|
1548
|
+
onChange: (e) => {
|
|
1549
|
+
const raw = e.target.value;
|
|
1550
|
+
const jumpHosts = raw.split(',').map((item) => item.trim()).filter(Boolean);
|
|
1551
|
+
setEditing({ ...editing, jumpHostId: raw, jumpHosts });
|
|
1552
|
+
}
|
|
1553
|
+
}),
|
|
1554
|
+
React.createElement('span', { className: 'drw-hint' }, t('fJumpHostsHint'))
|
|
1555
|
+
),
|
|
1556
|
+
|
|
1557
|
+
React.createElement(
|
|
1558
|
+
'div',
|
|
1559
|
+
{ className: 'drw-field' },
|
|
1560
|
+
React.createElement('span', { className: 'drw-label' }, t('fProxyCommand')),
|
|
1561
|
+
React.createElement('input', {
|
|
1562
|
+
className: 'drw-input',
|
|
1563
|
+
placeholder: 'cloudflared access ssh --hostname %h',
|
|
1564
|
+
value: editing.proxyCommand || '',
|
|
1565
|
+
onChange: (e) => setEditing({ ...editing, proxyCommand: e.target.value })
|
|
1566
|
+
}),
|
|
1567
|
+
React.createElement('span', { className: 'drw-hint' }, t('fProxyCommandHint'))
|
|
1568
|
+
),
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
React.createElement(
|
|
1572
|
+
'div',
|
|
1573
|
+
{ className: 'drw-grid-2' },
|
|
1574
|
+
React.createElement('div', { className: 'drw-field' },
|
|
1575
|
+
React.createElement('span', { className: 'drw-label' }, t('fEnvironment')),
|
|
1576
|
+
React.createElement('input', { className: 'drw-input', value: editing.environment || '', onChange: (e) => setEditing({ ...editing, environment: e.target.value }) })
|
|
1577
|
+
),
|
|
1578
|
+
React.createElement('div', { className: 'drw-field' },
|
|
1579
|
+
React.createElement('span', { className: 'drw-label' }, t('fLocation')),
|
|
1580
|
+
React.createElement('input', { className: 'drw-input', value: editing.location || '', onChange: (e) => setEditing({ ...editing, location: e.target.value }) })
|
|
1581
|
+
),
|
|
1582
|
+
React.createElement('div', { className: 'drw-field' },
|
|
1583
|
+
React.createElement('span', { className: 'drw-label' }, t('fTags')),
|
|
1584
|
+
React.createElement('input', {
|
|
1585
|
+
className: 'drw-input',
|
|
1586
|
+
value: Array.isArray(editing.tags) ? editing.tags.join(', ') : (editing.tags || ''),
|
|
1587
|
+
onChange: (e) => setEditing({ ...editing, tags: e.target.value.split(',').map((item) => item.trim()).filter(Boolean) })
|
|
1588
|
+
})
|
|
1589
|
+
),
|
|
1590
|
+
React.createElement('div', { className: 'drw-field' },
|
|
1591
|
+
React.createElement('span', { className: 'drw-label' }, t('fDescription')),
|
|
1592
|
+
React.createElement('input', { className: 'drw-input', value: editing.description || '', onChange: (e) => setEditing({ ...editing, description: e.target.value }) })
|
|
1593
|
+
)
|
|
1594
|
+
),
|
|
1595
|
+
|
|
1289
1596
|
// Auth Method Segmented Switcher (Key vs Password)
|
|
1290
1597
|
React.createElement(
|
|
1291
1598
|
'div',
|
|
@@ -1298,11 +1605,20 @@ window.__ModuleLoader__.load({
|
|
|
1298
1605
|
'button',
|
|
1299
1606
|
{
|
|
1300
1607
|
type: 'button',
|
|
1301
|
-
className: `drw-segmented-item ${editing.authType !== 'password' ? 'drw-segmented-item-active' : ''}`,
|
|
1608
|
+
className: `drw-segmented-item ${editing.authType !== 'password' && editing.authType !== 'agent' ? 'drw-segmented-item-active' : ''}`,
|
|
1302
1609
|
onClick: () => setEditing({ ...editing, authType: 'key' })
|
|
1303
1610
|
},
|
|
1304
1611
|
'🔑 ' + t('fAuthKey')
|
|
1305
1612
|
),
|
|
1613
|
+
React.createElement(
|
|
1614
|
+
'button',
|
|
1615
|
+
{
|
|
1616
|
+
type: 'button',
|
|
1617
|
+
className: `drw-segmented-item ${editing.authType === 'agent' ? 'drw-segmented-item-active' : ''}`,
|
|
1618
|
+
onClick: () => setEditing({ ...editing, authType: 'agent' })
|
|
1619
|
+
},
|
|
1620
|
+
t('fAuthAgent')
|
|
1621
|
+
),
|
|
1306
1622
|
React.createElement(
|
|
1307
1623
|
'button',
|
|
1308
1624
|
{
|
|
@@ -1316,7 +1632,19 @@ window.__ModuleLoader__.load({
|
|
|
1316
1632
|
),
|
|
1317
1633
|
|
|
1318
1634
|
// Conditional Auth Inputs
|
|
1319
|
-
editing.authType === '
|
|
1635
|
+
editing.authType === 'agent'
|
|
1636
|
+
? React.createElement(
|
|
1637
|
+
'div',
|
|
1638
|
+
{ className: 'drw-field' },
|
|
1639
|
+
React.createElement('span', { className: 'drw-label' }, t('fAgentPath')),
|
|
1640
|
+
React.createElement('input', {
|
|
1641
|
+
className: 'drw-input',
|
|
1642
|
+
placeholder: t('fAgentPathHint'),
|
|
1643
|
+
value: editing.agentPath || '',
|
|
1644
|
+
onChange: (e) => setEditing({ ...editing, agentPath: e.target.value })
|
|
1645
|
+
})
|
|
1646
|
+
)
|
|
1647
|
+
: editing.authType === 'password'
|
|
1320
1648
|
? React.createElement(
|
|
1321
1649
|
'div',
|
|
1322
1650
|
{ className: 'drw-field' },
|
|
@@ -1551,6 +1879,23 @@ window.__ModuleLoader__.load({
|
|
|
1551
1879
|
)
|
|
1552
1880
|
: null,
|
|
1553
1881
|
|
|
1882
|
+
|
|
1883
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px', marginBottom: '8px' } },
|
|
1884
|
+
['flat', 'environment', 'tag'].map((mode) => React.createElement('button', {
|
|
1885
|
+
key: mode,
|
|
1886
|
+
type: 'button',
|
|
1887
|
+
className: groupMode === mode ? 'drw-btn drw-btn-primary' : 'drw-btn',
|
|
1888
|
+
onClick: () => setGroupMode(mode)
|
|
1889
|
+
}, t(mode === 'flat' ? 'groupFlat' : mode === 'environment' ? 'groupEnvironment' : 'groupTag')))
|
|
1890
|
+
),
|
|
1891
|
+
groupReport.length
|
|
1892
|
+
? React.createElement('div', { className: 'drw-hint', role: 'status' },
|
|
1893
|
+
groupReport.map((item) => React.createElement('div', { key: item.id + (item.name || '') },
|
|
1894
|
+
(item.name || item.id) + ': ' + (item.ok ? t('groupOnline', { latency: item.latencyMs || 0 }) : t('groupOffline', { err: item.error || '' }))
|
|
1895
|
+
))
|
|
1896
|
+
)
|
|
1897
|
+
: null,
|
|
1898
|
+
|
|
1554
1899
|
// Profiles List Table
|
|
1555
1900
|
profiles.length > 0
|
|
1556
1901
|
? React.createElement(
|
|
@@ -1572,7 +1917,17 @@ window.__ModuleLoader__.load({
|
|
|
1572
1917
|
React.createElement(
|
|
1573
1918
|
'tbody',
|
|
1574
1919
|
null,
|
|
1575
|
-
|
|
1920
|
+
sections.flatMap((section) => {
|
|
1921
|
+
const header = groupMode === 'flat' ? [] : [
|
|
1922
|
+
React.createElement('tr', { key: 'group-' + section.key },
|
|
1923
|
+
React.createElement('td', { colSpan: 5 },
|
|
1924
|
+
React.createElement('strong', null, section.key === 'ungrouped' ? t('groupUngrouped') : section.key),
|
|
1925
|
+
' ',
|
|
1926
|
+
React.createElement('button', { type: 'button', className: 'drw-btn', onClick: () => testGroup(section.items) }, t('testGroup'))
|
|
1927
|
+
)
|
|
1928
|
+
)
|
|
1929
|
+
]
|
|
1930
|
+
return header.concat(section.items.map((p) => {
|
|
1576
1931
|
const isAct = p.id === activeId
|
|
1577
1932
|
return React.createElement(
|
|
1578
1933
|
'tr',
|
|
@@ -1584,7 +1939,7 @@ window.__ModuleLoader__.load({
|
|
|
1584
1939
|
React.createElement(
|
|
1585
1940
|
'span',
|
|
1586
1941
|
{ className: 'drw-badge', style: { marginLeft: '8px' } },
|
|
1587
|
-
p.authType === 'password' ? '🔒
|
|
1942
|
+
p.authType === 'password' ? '🔒 ' + t('fAuthPassShort') : p.authType === 'agent' ? t('fAuthAgentShort') : '🔑 ' + t('fAuthKeyShort')
|
|
1588
1943
|
),
|
|
1589
1944
|
isAct
|
|
1590
1945
|
? React.createElement('span', { className: 'drw-badge drw-badge-ok', style: { marginLeft: '6px' } }, t('activeBadge'))
|
|
@@ -1637,6 +1992,7 @@ window.__ModuleLoader__.load({
|
|
|
1637
1992
|
)
|
|
1638
1993
|
)
|
|
1639
1994
|
)
|
|
1995
|
+
}))
|
|
1640
1996
|
})
|
|
1641
1997
|
)
|
|
1642
1998
|
)
|
|
@@ -1873,6 +2229,57 @@ window.__ModuleLoader__.load({
|
|
|
1873
2229
|
)
|
|
1874
2230
|
}
|
|
1875
2231
|
|
|
2232
|
+
function ClusterPanel(props) {
|
|
2233
|
+
const t = props.t
|
|
2234
|
+
const profiles = props.profiles || []
|
|
2235
|
+
const [command, setCommand] = React.useState('')
|
|
2236
|
+
const [environment, setEnvironment] = React.useState('')
|
|
2237
|
+
const [tag, setTag] = React.useState('')
|
|
2238
|
+
const [rows, setRows] = React.useState([])
|
|
2239
|
+
const [open, setOpen] = React.useState('')
|
|
2240
|
+
const [running, setRunning] = React.useState(false)
|
|
2241
|
+
const run = async () => {
|
|
2242
|
+
setRunning(true)
|
|
2243
|
+
try {
|
|
2244
|
+
const res = await fetch('/dsh-remote-workspace/cluster', {
|
|
2245
|
+
method: 'POST',
|
|
2246
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2247
|
+
body: JSON.stringify({
|
|
2248
|
+
command,
|
|
2249
|
+
environment,
|
|
2250
|
+
tags: tag ? [tag] : [],
|
|
2251
|
+
maxWorkers: 8
|
|
2252
|
+
})
|
|
2253
|
+
})
|
|
2254
|
+
const data = await res.json()
|
|
2255
|
+
setRows(data && data.results ? data.results : [])
|
|
2256
|
+
} catch (err) {
|
|
2257
|
+
setRows([])
|
|
2258
|
+
}
|
|
2259
|
+
setRunning(false)
|
|
2260
|
+
}
|
|
2261
|
+
return React.createElement('div', { className: 'drw-card' },
|
|
2262
|
+
React.createElement('div', { className: 'drw-card-title' }, t('clusterTitle')),
|
|
2263
|
+
React.createElement('div', { className: 'drw-grid-2' },
|
|
2264
|
+
React.createElement('input', { className: 'drw-input', placeholder: t('fEnvironment'), value: environment, onChange: (e) => setEnvironment(e.target.value) }),
|
|
2265
|
+
React.createElement('input', { className: 'drw-input', placeholder: t('fTags'), value: tag, onChange: (e) => setTag(e.target.value) })
|
|
2266
|
+
),
|
|
2267
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px', marginTop: '8px' } },
|
|
2268
|
+
React.createElement('input', { className: 'drw-input', placeholder: t('clusterCommand'), value: command, onChange: (e) => setCommand(e.target.value) }),
|
|
2269
|
+
React.createElement('button', { type: 'button', className: 'drw-btn drw-btn-primary', disabled: running || !command.trim(), onClick: run }, running ? t('clusterRunning') : t('clusterRun'))
|
|
2270
|
+
),
|
|
2271
|
+
React.createElement('div', { className: 'drw-hint' }, t('clusterCount', { count: profiles.length })),
|
|
2272
|
+
rows.map((row, index) => React.createElement('div', { key: (row.alias || '') + index, className: 'drw-field' },
|
|
2273
|
+
React.createElement('button', { type: 'button', className: 'drw-btn', onClick: () => setOpen(open === String(index) ? '' : String(index)) },
|
|
2274
|
+
(row.alias || row.host) + ' ' + (row.success ? t('clusterOk') : t('clusterFail')) + ' ' + (row.durationMs || 0) + ' ms'
|
|
2275
|
+
),
|
|
2276
|
+
open === String(index)
|
|
2277
|
+
? React.createElement('pre', { className: 'drw-term-body' }, row.error || row.stdout || row.stderr || '')
|
|
2278
|
+
: null
|
|
2279
|
+
))
|
|
2280
|
+
)
|
|
2281
|
+
}
|
|
2282
|
+
|
|
1876
2283
|
function SettingsView(props) {
|
|
1877
2284
|
const [activeTab, setActiveTab] = React.useState('profiles')
|
|
1878
2285
|
const [healthData, setHealthData] = React.useState(null)
|
|
@@ -1886,9 +2293,13 @@ window.__ModuleLoader__.load({
|
|
|
1886
2293
|
const [busy, setBusy] = React.useState(null)
|
|
1887
2294
|
const [testResult, setTestResult] = React.useState(null)
|
|
1888
2295
|
const [actionError, setActionError] = React.useState('')
|
|
2296
|
+
const [authPrompts, setAuthPrompts] = React.useState([])
|
|
2297
|
+
const [authAnswers, setAuthAnswers] = React.useState({})
|
|
2298
|
+
const [importReport, setImportReport] = React.useState(null)
|
|
1889
2299
|
const [syncMsg, setSyncMsg] = React.useState(null)
|
|
1890
2300
|
const [tunnelMsg, setTunnelMsg] = React.useState(null)
|
|
1891
2301
|
const [showPassword, setShowPassword] = React.useState(false)
|
|
2302
|
+
const [terminalFont, setTerminalFont] = React.useState('')
|
|
1892
2303
|
|
|
1893
2304
|
// Tunnel creation inputs
|
|
1894
2305
|
const [newTunnelLocal, setNewTunnelLocal] = React.useState('3000')
|
|
@@ -1915,7 +2326,12 @@ window.__ModuleLoader__.load({
|
|
|
1915
2326
|
return text ? null : data
|
|
1916
2327
|
}
|
|
1917
2328
|
|
|
1918
|
-
const
|
|
2329
|
+
const loadInFlight = React.useRef(false)
|
|
2330
|
+
|
|
2331
|
+
const loadState = async (force) => {
|
|
2332
|
+
const hidden = typeof document !== 'undefined' && document.hidden
|
|
2333
|
+
if (!force && (hidden || loadInFlight.current)) return
|
|
2334
|
+
loadInFlight.current = true
|
|
1919
2335
|
try {
|
|
1920
2336
|
const res = await fetch('/dsh-remote-workspace/state')
|
|
1921
2337
|
const data = await reportAction(res)
|
|
@@ -1924,20 +2340,67 @@ window.__ModuleLoader__.load({
|
|
|
1924
2340
|
setActiveId(data.activeId || null)
|
|
1925
2341
|
setTunnels(data.tunnels || [])
|
|
1926
2342
|
setAutoSync(Boolean(data.autoSync))
|
|
2343
|
+
setAuthPrompts(data.authPrompts || [])
|
|
2344
|
+
setTerminalFont(data.terminalFontFamily || '')
|
|
2345
|
+
applyTerminalFont(data.terminalFontFamily || '')
|
|
1927
2346
|
if (data.activeId) {
|
|
1928
2347
|
fetch(`/dsh-remote-workspace/health?profileId=${data.activeId}`).then(r => r.json()).then(h => { if (h.ok) setHealthData(h.health) }).catch(() => {})
|
|
1929
2348
|
}
|
|
1930
2349
|
}
|
|
1931
2350
|
} catch (err) {
|
|
1932
2351
|
setActionError(err && err.message ? err.message : t('actionFailed'))
|
|
2352
|
+
} finally {
|
|
2353
|
+
loadInFlight.current = false
|
|
1933
2354
|
}
|
|
1934
2355
|
}
|
|
1935
2356
|
|
|
1936
2357
|
React.useEffect(() => {
|
|
1937
2358
|
ensureCss()
|
|
1938
|
-
loadState()
|
|
2359
|
+
loadState(true)
|
|
2360
|
+
const timer = setInterval(() => loadState(false), 2000)
|
|
2361
|
+
const onVisible = () => {
|
|
2362
|
+
if (typeof document !== 'undefined' && !document.hidden) loadState(true)
|
|
2363
|
+
}
|
|
2364
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisible)
|
|
2365
|
+
return () => {
|
|
2366
|
+
clearInterval(timer)
|
|
2367
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisible)
|
|
2368
|
+
}
|
|
1939
2369
|
}, [])
|
|
1940
2370
|
|
|
2371
|
+
|
|
2372
|
+
const saveTerminalFont = async () => {
|
|
2373
|
+
try {
|
|
2374
|
+
const res = await fetch('/dsh-remote-workspace/terminal/font', {
|
|
2375
|
+
method: 'POST',
|
|
2376
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2377
|
+
body: JSON.stringify({ fontFamily: terminalFont })
|
|
2378
|
+
})
|
|
2379
|
+
const data = await reportAction(res)
|
|
2380
|
+
if (data && data.ok) {
|
|
2381
|
+
setTerminalFont(data.terminalFontFamily || '')
|
|
2382
|
+
applyTerminalFont(data.terminalFontFamily || '')
|
|
2383
|
+
}
|
|
2384
|
+
} catch (err) {
|
|
2385
|
+
setActionError(err && err.message ? err.message : t('actionFailed'))
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
const submitAuth = async (prompt) => {
|
|
2390
|
+
const answers = (prompt.prompts || []).map((_, index) => authAnswers[prompt.profileId + ':' + index] || '')
|
|
2391
|
+
try {
|
|
2392
|
+
const res = await fetch('/dsh-remote-workspace/auth/keyboard', {
|
|
2393
|
+
method: 'POST',
|
|
2394
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2395
|
+
body: JSON.stringify({ id: prompt.profileId, answers })
|
|
2396
|
+
})
|
|
2397
|
+
const data = await reportAction(res)
|
|
2398
|
+
if (data) await loadState()
|
|
2399
|
+
} catch (err) {
|
|
2400
|
+
setActionError(err && err.message ? err.message : t('actionFailed'))
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
|
|
1941
2404
|
const handleSave = async (profile) => {
|
|
1942
2405
|
setBusy('saving')
|
|
1943
2406
|
try {
|
|
@@ -2102,6 +2565,31 @@ window.__ModuleLoader__.load({
|
|
|
2102
2565
|
|
|
2103
2566
|
const activeProfile = profiles.find((p) => p.id === activeId)
|
|
2104
2567
|
|
|
2568
|
+
const handleImport = async () => {
|
|
2569
|
+
try {
|
|
2570
|
+
const res = await fetch('/dsh-remote-workspace/profiles/import-ssh-config', {
|
|
2571
|
+
method: 'POST',
|
|
2572
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2573
|
+
body: '{}'
|
|
2574
|
+
})
|
|
2575
|
+
const data = await reportAction(res)
|
|
2576
|
+
if (data) {
|
|
2577
|
+
setImportReport(data)
|
|
2578
|
+
await loadState()
|
|
2579
|
+
}
|
|
2580
|
+
} catch (err) {
|
|
2581
|
+
setActionError(err && err.message ? err.message : t('actionFailed'))
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
const skipReason = (reason) => {
|
|
2586
|
+
if (reason === 'wildcard') return t('skipWildcard')
|
|
2587
|
+
if (reason === 'match') return t('skipMatch')
|
|
2588
|
+
if (reason === 'duplicate') return t('skipDuplicate')
|
|
2589
|
+
if (reason === 'missing-include') return t('skipMissing')
|
|
2590
|
+
return reason
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2105
2593
|
return React.createElement(
|
|
2106
2594
|
'div',
|
|
2107
2595
|
{ className: 'drw-page' },
|
|
@@ -2133,7 +2621,76 @@ window.__ModuleLoader__.load({
|
|
|
2133
2621
|
),
|
|
2134
2622
|
|
|
2135
2623
|
|
|
2136
|
-
|
|
2624
|
+
|
|
2625
|
+
authPrompts.length
|
|
2626
|
+
? React.createElement(
|
|
2627
|
+
'div',
|
|
2628
|
+
{ className: 'drw-card', role: 'dialog', 'aria-label': t('authPromptTitle') },
|
|
2629
|
+
React.createElement('div', { className: 'drw-page-title' }, t('authPromptTitle')),
|
|
2630
|
+
authPrompts.map((prompt) => React.createElement(
|
|
2631
|
+
'div',
|
|
2632
|
+
{ key: prompt.profileId, className: 'drw-field' },
|
|
2633
|
+
prompt.instructions
|
|
2634
|
+
? React.createElement('span', { className: 'drw-hint' }, prompt.instructions)
|
|
2635
|
+
: null,
|
|
2636
|
+
(prompt.prompts || []).map((field, index) => React.createElement('input', {
|
|
2637
|
+
key: index,
|
|
2638
|
+
className: 'drw-input',
|
|
2639
|
+
type: field.echo ? 'text' : 'password',
|
|
2640
|
+
placeholder: field.prompt || t('authPromptCode'),
|
|
2641
|
+
value: authAnswers[prompt.profileId + ':' + index] || '',
|
|
2642
|
+
onChange: (e) => setAuthAnswers({ ...authAnswers, [prompt.profileId + ':' + index]: e.target.value })
|
|
2643
|
+
})),
|
|
2644
|
+
React.createElement('button', {
|
|
2645
|
+
type: 'button',
|
|
2646
|
+
className: 'drw-btn drw-btn-primary',
|
|
2647
|
+
onClick: () => submitAuth(prompt)
|
|
2648
|
+
}, t('authPromptSubmit'))
|
|
2649
|
+
))
|
|
2650
|
+
)
|
|
2651
|
+
: null,
|
|
2652
|
+
|
|
2653
|
+
|
|
2654
|
+
React.createElement(
|
|
2655
|
+
'div',
|
|
2656
|
+
{ className: 'drw-card' },
|
|
2657
|
+
React.createElement('button', { type: 'button', className: 'drw-btn', onClick: handleImport }, t('btnImportSsh')),
|
|
2658
|
+
importReport
|
|
2659
|
+
? React.createElement(
|
|
2660
|
+
'div',
|
|
2661
|
+
{ className: 'drw-hint', role: 'status' },
|
|
2662
|
+
t('importAdded', { count: importReport.count || 0 }),
|
|
2663
|
+
(importReport.skipped || []).map((item, index) => React.createElement('div', { key: index }, (item.name || '') + ': ' + skipReason(item.reason)))
|
|
2664
|
+
)
|
|
2665
|
+
: null
|
|
2666
|
+
),
|
|
2667
|
+
|
|
2668
|
+
|
|
2669
|
+
React.createElement('div', { className: 'drw-card' },
|
|
2670
|
+
React.createElement('label', { className: 'drw-label' }, t('fTerminalFont')),
|
|
2671
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px' } },
|
|
2672
|
+
React.createElement('input', {
|
|
2673
|
+
className: 'drw-input',
|
|
2674
|
+
type: 'text',
|
|
2675
|
+
value: terminalFont,
|
|
2676
|
+
placeholder: 'ui-monospace, Consolas, monospace',
|
|
2677
|
+
onChange: (e) => setTerminalFont(e.target.value)
|
|
2678
|
+
}),
|
|
2679
|
+
React.createElement('button', { type: 'button', className: 'drw-btn', onClick: saveTerminalFont }, t('btnSave'))
|
|
2680
|
+
),
|
|
2681
|
+
React.createElement('div', { className: 'drw-hint' }, t('fTerminalFontHint'))
|
|
2682
|
+
),
|
|
2683
|
+
|
|
2684
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px' } },
|
|
2685
|
+
React.createElement('button', { type: 'button', className: activeTab === 'profiles' ? 'drw-btn drw-btn-primary' : 'drw-btn', onClick: () => setActiveTab('profiles') }, t('tabProfiles')),
|
|
2686
|
+
React.createElement('button', { type: 'button', className: activeTab === 'cluster' ? 'drw-btn drw-btn-primary' : 'drw-btn', onClick: () => setActiveTab('cluster') }, t('tabCluster'))
|
|
2687
|
+
, React.createElement('button', { type: 'button', className: activeTab === 'files' ? 'drw-btn drw-btn-primary' : 'drw-btn', onClick: () => setActiveTab('files') }, t('tabFiles'))
|
|
2688
|
+
),
|
|
2689
|
+
activeTab === 'cluster'
|
|
2690
|
+
? React.createElement(ClusterPanel, { t, profiles })
|
|
2691
|
+
: activeTab === 'files'
|
|
2692
|
+
? React.createElement(ExplorerTab, { t, activeProfile })
|
|
2693
|
+
: React.createElement(ProfilesPanel, { t, editing, setEditing, setTestResult, profiles, activeId, busy, handleSave, testResult, handleTest, handleSetActive, handleDelete, browserOpen, setBrowserOpen, browserPath, browserEntries, browserLoading, openDirectoryBrowser, fetchRemoteDir, showPassword, setShowPassword }),
|
|
2137
2694
|
|
|
2138
2695
|
|
|
2139
2696
|
// Section 2: Active Connection Diagnostics (ClineBot Card 4 style)
|
|
@@ -2445,7 +3002,232 @@ window.__ModuleLoader__.load({
|
|
|
2445
3002
|
)
|
|
2446
3003
|
}
|
|
2447
3004
|
|
|
3005
|
+
const DRW_ACTIVE = 'data-drw-active'
|
|
3006
|
+
const DRW_VIEW = 'data-drw-view'
|
|
3007
|
+
const DRW_ENTRY = 'data-drw-entry'
|
|
3008
|
+
const DRW_PANEL = 'remote-workspace'
|
|
3009
|
+
const DRW_ICON = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"></rect><path d="M8 20h8"></path><path d="M12 16v4"></path></svg>'
|
|
3010
|
+
|
|
3011
|
+
function workspaceTranslator(ctx) {
|
|
3012
|
+
let lang = ''
|
|
3013
|
+
try {
|
|
3014
|
+
lang = ctx && ctx.locale && typeof ctx.locale.getLocale === 'function' ? ctx.locale.getLocale() : ''
|
|
3015
|
+
} catch (err) { lang = '' }
|
|
3016
|
+
if (typeof lang === 'string' && (lang === 'zh' || lang.indexOf('zh') === 0)) return makeT(zh, en)
|
|
3017
|
+
return makeT(en, zh)
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
function WorkspaceScreen(props) {
|
|
3021
|
+
const t = workspaceTranslator(props.ctx)
|
|
3022
|
+
const [tab, setTab] = React.useState('hosts')
|
|
3023
|
+
const [profiles, setProfiles] = React.useState([])
|
|
3024
|
+
const [activeId, setActiveId] = React.useState('')
|
|
3025
|
+
const [tunnels, setTunnels] = React.useState([])
|
|
3026
|
+
const [localPort, setLocalPort] = React.useState('3000')
|
|
3027
|
+
const [remotePort, setRemotePort] = React.useState('3000')
|
|
3028
|
+
const [tunnelMsg, setTunnelMsg] = React.useState(null)
|
|
3029
|
+
const [busy, setBusy] = React.useState(null)
|
|
3030
|
+
const activeProfile = profiles.find((item) => item.id === activeId) || null
|
|
3031
|
+
|
|
3032
|
+
const loadState = async () => {
|
|
3033
|
+
try {
|
|
3034
|
+
const res = await fetch('/dsh-remote-workspace/state')
|
|
3035
|
+
const data = await res.json()
|
|
3036
|
+
if (data && data.ok) {
|
|
3037
|
+
setProfiles(data.profiles || [])
|
|
3038
|
+
setActiveId(data.activeId || '')
|
|
3039
|
+
setTunnels(data.tunnels || [])
|
|
3040
|
+
}
|
|
3041
|
+
} catch (err) { /* state refresh is best effort */ }
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
React.useEffect(() => {
|
|
3045
|
+
ensureCss()
|
|
3046
|
+
loadState()
|
|
3047
|
+
}, [])
|
|
3048
|
+
|
|
3049
|
+
const startTunnel = async () => {
|
|
3050
|
+
if (!activeId) return
|
|
3051
|
+
setBusy('tunnel_open')
|
|
3052
|
+
try {
|
|
3053
|
+
const res = await fetch('/dsh-remote-workspace/tunnels/start', {
|
|
3054
|
+
method: 'POST',
|
|
3055
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3056
|
+
body: JSON.stringify({ profileId: activeId, localPort, remotePort })
|
|
3057
|
+
})
|
|
3058
|
+
const data = await res.json()
|
|
3059
|
+
setTunnelMsg(data && data.ok
|
|
3060
|
+
? { ok: true, text: t('tOpenOk', { localPort, remotePort }) }
|
|
3061
|
+
: { ok: false, text: t('tOpenErr', { err: (data && data.error) || 'Failed' }) })
|
|
3062
|
+
if (data && data.ok) await loadState()
|
|
3063
|
+
} catch (err) {
|
|
3064
|
+
setTunnelMsg({ ok: false, text: t('tOpenErr', { err: err.message }) })
|
|
3065
|
+
}
|
|
3066
|
+
setBusy(null)
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
const stopTunnel = async (tunnelId) => {
|
|
3070
|
+
setBusy('tunnel_stop')
|
|
3071
|
+
try {
|
|
3072
|
+
await fetch('/dsh-remote-workspace/tunnels/stop', {
|
|
3073
|
+
method: 'POST',
|
|
3074
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3075
|
+
body: JSON.stringify({ tunnelId })
|
|
3076
|
+
})
|
|
3077
|
+
await loadState()
|
|
3078
|
+
} catch (err) { /* stop is reported by the next state load */ }
|
|
3079
|
+
setBusy(null)
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
const tabs = [
|
|
3083
|
+
['hosts', 'wsHosts'],
|
|
3084
|
+
['terminal', 'wsTerminal'],
|
|
3085
|
+
['files', 'wsFiles'],
|
|
3086
|
+
['docker', 'wsDocker'],
|
|
3087
|
+
['tunnels', 'wsTunnels'],
|
|
3088
|
+
['cluster', 'wsCluster']
|
|
3089
|
+
]
|
|
3090
|
+
|
|
3091
|
+
return React.createElement('div', { className: 'drw-ws' },
|
|
3092
|
+
React.createElement('div', { className: 'drw-ws-bar' },
|
|
3093
|
+
React.createElement('button', { type: 'button', className: 'drw-btn', onClick: () => props.onClose && props.onClose() }, t('wsBack')),
|
|
3094
|
+
tabs.map(([id, key]) => React.createElement('button', {
|
|
3095
|
+
key: id,
|
|
3096
|
+
type: 'button',
|
|
3097
|
+
className: tab === id ? 'drw-btn drw-btn-primary' : 'drw-btn',
|
|
3098
|
+
onClick: () => setTab(id)
|
|
3099
|
+
}, t(key)))
|
|
3100
|
+
),
|
|
3101
|
+
React.createElement('div', { className: 'drw-ws-panel', hidden: tab !== 'hosts' }, React.createElement(SettingsView, props)),
|
|
3102
|
+
React.createElement('div', { className: 'drw-ws-panel', hidden: tab !== 'terminal' }, React.createElement(TerminalTab, { t, activeProfile })),
|
|
3103
|
+
React.createElement('div', { className: 'drw-ws-panel', hidden: tab !== 'files' }, React.createElement(ExplorerTab, { t, activeProfile })),
|
|
3104
|
+
React.createElement('div', { className: 'drw-ws-panel', hidden: tab !== 'docker' }, React.createElement(ContainersTab, { t, activeProfile })),
|
|
3105
|
+
React.createElement('div', { className: 'drw-ws-panel', hidden: tab !== 'tunnels' }, React.createElement(TunnelsPanel, {
|
|
3106
|
+
t, busy, activeProfile, tunnelMsg, tunnels,
|
|
3107
|
+
newTunnelLocal: localPort, setNewTunnelLocal: setLocalPort,
|
|
3108
|
+
newTunnelRemote: remotePort, setNewTunnelRemote: setRemotePort,
|
|
3109
|
+
handleStartTunnel: startTunnel, handleStopTunnel: stopTunnel
|
|
3110
|
+
})),
|
|
3111
|
+
React.createElement('div', { className: 'drw-ws-panel', hidden: tab !== 'cluster' }, React.createElement(ClusterPanel, { t, profiles }))
|
|
3112
|
+
)
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
function createWorkspaceToggle() {
|
|
3116
|
+
let open = false
|
|
3117
|
+
const listeners = new Set()
|
|
3118
|
+
return {
|
|
3119
|
+
isOpen() { return open },
|
|
3120
|
+
set(next) {
|
|
3121
|
+
open = Boolean(next)
|
|
3122
|
+
listeners.forEach((fn) => fn(open))
|
|
3123
|
+
},
|
|
3124
|
+
toggle() { this.set(!open) },
|
|
3125
|
+
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) }
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
function nativeSidebarButton() {
|
|
3130
|
+
const nodes = document.querySelectorAll('button[class*="newSession"]')
|
|
3131
|
+
for (const node of nodes) {
|
|
3132
|
+
if (String(node.className).indexOf('newSessionLabel') === -1) return node
|
|
3133
|
+
}
|
|
3134
|
+
return null
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
function mountRemoteWorkspace(ctx) {
|
|
3138
|
+
if (typeof document === 'undefined') return function () {}
|
|
3139
|
+
ensureCss()
|
|
3140
|
+
const toggle = createWorkspaceToggle()
|
|
3141
|
+
const t = workspaceTranslator(ctx)
|
|
3142
|
+
let entry = null
|
|
3143
|
+
let container = null
|
|
3144
|
+
let root = null
|
|
3145
|
+
|
|
3146
|
+
const placeEntry = () => {
|
|
3147
|
+
if (entry && entry.isConnected) return
|
|
3148
|
+
const target = nativeSidebarButton()
|
|
3149
|
+
if (!target || !target.parentElement) return
|
|
3150
|
+
if (target.parentElement.querySelector('[' + DRW_ENTRY + ']')) return
|
|
3151
|
+
entry = target.cloneNode(true)
|
|
3152
|
+
entry.className = String(target.className) + ' drw-entry-clone'
|
|
3153
|
+
entry.setAttribute(DRW_ENTRY, '')
|
|
3154
|
+
entry.setAttribute('data-dsh-plugin', 'dsh-remote-workspace')
|
|
3155
|
+
entry.setAttribute('aria-label', t('navLabel'))
|
|
3156
|
+
entry.removeAttribute('id')
|
|
3157
|
+
entry.innerHTML = ''
|
|
3158
|
+
const icon = document.createElement('span')
|
|
3159
|
+
icon.className = 'drw-entry-icon'
|
|
3160
|
+
icon.innerHTML = DRW_ICON
|
|
3161
|
+
const label = document.createElement('span')
|
|
3162
|
+
label.className = 'drw-entry-label'
|
|
3163
|
+
label.textContent = t('navLabel')
|
|
3164
|
+
entry.appendChild(icon)
|
|
3165
|
+
entry.appendChild(label)
|
|
3166
|
+
entry.addEventListener('click', (event) => {
|
|
3167
|
+
event.preventDefault()
|
|
3168
|
+
event.stopPropagation()
|
|
3169
|
+
toggle.toggle()
|
|
3170
|
+
})
|
|
3171
|
+
target.insertAdjacentElement('afterend', entry)
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
const reactDom = () => {
|
|
3175
|
+
if (typeof window !== 'undefined' && window.ReactDOM) return window.ReactDOM
|
|
3176
|
+
try { return require('react-dom/client') } catch (err) { return null }
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
const placeView = () => {
|
|
3180
|
+
const column = document.querySelector('[data-pane=conversation], [class*=centerCol]')
|
|
3181
|
+
if (!column) return
|
|
3182
|
+
if (!container) {
|
|
3183
|
+
container = document.createElement('div')
|
|
3184
|
+
container.setAttribute(DRW_VIEW, '')
|
|
3185
|
+
container.setAttribute('data-dsh-plugin', 'dsh-remote-workspace')
|
|
3186
|
+
column.appendChild(container)
|
|
3187
|
+
const ReactDOM = reactDom()
|
|
3188
|
+
if (ReactDOM && typeof ReactDOM.createRoot === 'function') {
|
|
3189
|
+
root = ReactDOM.createRoot(container)
|
|
3190
|
+
root.render(React.createElement(WorkspaceScreen, { ctx, onClose: () => toggle.set(false) }))
|
|
3191
|
+
}
|
|
3192
|
+
return
|
|
3193
|
+
}
|
|
3194
|
+
if (container.parentElement !== column) column.appendChild(container)
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
const applyOpen = () => {
|
|
3198
|
+
if (toggle.isOpen()) {
|
|
3199
|
+
document.documentElement.setAttribute(DRW_ACTIVE, '')
|
|
3200
|
+
document.dispatchEvent(new CustomEvent('dsh-panel-activate', { detail: DRW_PANEL }))
|
|
3201
|
+
if (entry) entry.dataset.active = 'true'
|
|
3202
|
+
} else {
|
|
3203
|
+
document.documentElement.removeAttribute(DRW_ACTIVE)
|
|
3204
|
+
if (entry) delete entry.dataset.active
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
const onOther = (event) => {
|
|
3209
|
+
if (event.detail !== DRW_PANEL && toggle.isOpen()) toggle.set(false)
|
|
3210
|
+
}
|
|
3211
|
+
const observer = new MutationObserver(() => { placeEntry(); placeView() })
|
|
3212
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
3213
|
+
document.addEventListener('dsh-panel-activate', onOther)
|
|
3214
|
+
const unsubscribe = toggle.subscribe(applyOpen)
|
|
3215
|
+
placeEntry()
|
|
3216
|
+
placeView()
|
|
3217
|
+
|
|
3218
|
+
return () => {
|
|
3219
|
+
observer.disconnect()
|
|
3220
|
+
document.removeEventListener('dsh-panel-activate', onOther)
|
|
3221
|
+
unsubscribe()
|
|
3222
|
+
document.documentElement.removeAttribute(DRW_ACTIVE)
|
|
3223
|
+
if (root && root.unmount) root.unmount()
|
|
3224
|
+
if (container) container.remove()
|
|
3225
|
+
if (entry) entry.remove()
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
|
|
2448
3229
|
function apply(ctx) {
|
|
3230
|
+
try { mountRemoteWorkspace(ctx) } catch (err) { console.warn('[dsh-remote-workspace] workspace mount skipped', err && err.message) }
|
|
2449
3231
|
if (ctx.locale && typeof ctx.locale.register === 'function') {
|
|
2450
3232
|
if (typeof ctx.effect === 'function') {
|
|
2451
3233
|
ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-remote-workspace: dictionaries');
|