dsh-ssh-manage 0.1.0 → 0.2.0
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.en.md +8 -7
- package/README.md +8 -7
- package/client.js +209 -56
- package/index.js +50 -5
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
[](package.json)
|
|
11
|
+
[](https://www.npmjs.com/package/dsh-ssh-manage)
|
|
11
12
|
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
12
13
|
[](https://github.com/sunChargedd/dsh-plugins)
|
|
13
14
|
|
|
@@ -33,7 +34,7 @@ No more jumping between a terminal and DSH, and no need to remember where each h
|
|
|
33
34
|
|
|
34
35
|
### Host management
|
|
35
36
|
|
|
36
|
-
- Group tree on the left, host table on the right: search, filter by group, favorite, tags, notes, connection state and latency.
|
|
37
|
+
- Group tree on the left, host table on the right: search, filter by group, favorite, tags, notes, connection state and latency. Drag the divider to resize the group sidebar (double-click resets; the width is remembered).
|
|
37
38
|
- Create, edit, rename and delete hosts; renaming migrates group, tags, favorites, notes, inspection data and the saved password.
|
|
38
39
|
- Configurable table columns (info / notes / compact rows), responsive on narrow screens.
|
|
39
40
|
- Host details drawer: address, user, port, group, tags, notes, distro, CPU, memory, disk, latency, plus "Test connection" and "Inspect".
|
|
@@ -43,7 +44,7 @@ No more jumping between a terminal and DSH, and no need to remember where each h
|
|
|
43
44
|
### Interactive terminal
|
|
44
45
|
|
|
45
46
|
- Click "Connect" to open a real PTY shell, not a one-command-at-a-time console.
|
|
46
|
-
- Multiple hosts stay as tabs
|
|
47
|
+
- Multiple hosts stay as tabs: switching to another DSH session and coming back re-attaches to the existing shell and replays roughly the last 256 KB of output; only closing the tab ends the session.
|
|
47
48
|
- xterm.js 6.0.0 rendering with 256 colors, cursor, scrollback, selection copy, paste and resize.
|
|
48
49
|
- Terminal theme follows the DSH light/dark theme; the session bar has quick actions for address, password status, test, files, details, clear and close.
|
|
49
50
|
- Output is streamed over a same-origin HTTP/SSE connection; input, resize and close use same-origin HTTP POST. A tested WebSocket channel is kept as a fallback.
|
|
@@ -93,18 +94,18 @@ Creating a host requires alias, hostname and password. The password is not writt
|
|
|
93
94
|
|
|
94
95
|
## Installation
|
|
95
96
|
|
|
96
|
-
### From
|
|
97
|
+
### From npm (recommended)
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.2.0`).
|
|
99
100
|
|
|
100
101
|
```text
|
|
101
|
-
plugin_manager action=install_bundle target
|
|
102
|
+
plugin_manager action=install_bundle target=dsh-ssh-manage@0.2.0
|
|
102
103
|
```
|
|
103
104
|
|
|
104
105
|
Or with the DSH CLI:
|
|
105
106
|
|
|
106
107
|
```bash
|
|
107
|
-
dsh plugin add
|
|
108
|
+
dsh plugin add dsh-ssh-manage@0.2.0
|
|
108
109
|
```
|
|
109
110
|
|
|
110
111
|
After installation, refresh the Web page; the sidebar shows an "SSH Hosts" entry with id `dsh-ssh-manage`.
|
|
@@ -215,7 +216,7 @@ The terminal uses vendored xterm.js 6.0.0 (MIT, see `assets/LICENSE.xterm`) and
|
|
|
215
216
|
## Known limitations
|
|
216
217
|
|
|
217
218
|
- **Host key verification** is not implemented; ssh2 accepts the server-provided key. Use trusted networks only.
|
|
218
|
-
- **Session lifetime**:
|
|
219
|
+
- **Session lifetime**: sessions are kept on the Host; switching to another DSH session, temporarily leaving the panel, or refreshing the page re-attaches to the existing shell and replays roughly the last 256 KB of output. Only closing the tab or exiting the Host process ends them; orphaned sessions are reaped after 6 hours of inactivity (max 16 sessions).
|
|
219
220
|
- **Password auth only**: IdentityFile / SSH agent / ProxyJump are not used for transport; those config fields are preserved but ignored. ProxyJump hosts fail with a clear error.
|
|
220
221
|
- **Config editing scope**: the editor only changes `Host`, `HostName`, `User`, `Port` and plugin metadata; maintain other SSH directives in `~/.ssh/config`.
|
|
221
222
|
- **Batch commands** run one command; they are not an interactive terminal. Per-host output is capped at 1 MB and marked `truncated` when exceeded.
|
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
[](package.json)
|
|
11
|
+
[](https://www.npmjs.com/package/dsh-ssh-manage)
|
|
11
12
|
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
12
13
|
[](https://github.com/sunChargedd/dsh-plugins)
|
|
13
14
|
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
|
|
34
35
|
### 主机管理
|
|
35
36
|
|
|
36
|
-
- 左侧分组树 +
|
|
37
|
+
- 左侧分组树 + 右侧主机表格:搜索、按分组筛选、收藏、标签、备注、连接状态与延迟一目了然;分组栏可拖拽调整宽度(双击恢复默认),宽度会记住。
|
|
37
38
|
- 新建、编辑、重命名、删除主机;重命名自动迁移分组、标签、收藏、备注、巡检信息和已保存密码。
|
|
38
39
|
- 表格列可配置(信息列 / 备注列 / 紧凑行高),窄屏自适应。
|
|
39
40
|
- 主机详情抽屉:地址、用户、端口、分组、标签、备注、发行版、CPU、内存、磁盘、延迟,以及「测试连接」「巡检」。
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
### 交互式终端
|
|
44
45
|
|
|
45
46
|
- 点「连接」打开真正的 PTY shell,不是一条命令一条命令地执行。
|
|
46
|
-
-
|
|
47
|
+
- 多台主机以标签页常驻:切换到其它 DSH 会话再回来,终端会重新附着到原来的 shell,并回放最近约 256 KB 输出;关闭标签才结束会话。
|
|
47
48
|
- xterm.js 6.0.0 渲染,支持 256 色、光标、滚动回看、选中复制、粘贴、窗口 resize。
|
|
48
49
|
- 终端主题跟随 DSH 明暗主题;会话顶部有地址、密码状态、测试连接、文件、详情、清屏、关闭等快捷操作。
|
|
49
50
|
- 输出走同源 HTTP/SSE 长连接,输入 / 尺寸 / 关闭走同源 HTTP POST;插件同时保留经过测试的 WebSocket 备用通道。
|
|
@@ -95,18 +96,18 @@
|
|
|
95
96
|
|
|
96
97
|
## 安装
|
|
97
98
|
|
|
98
|
-
###
|
|
99
|
+
### 从 npm 安装(推荐)
|
|
99
100
|
|
|
100
|
-
|
|
101
|
+
npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.2.0`)。
|
|
101
102
|
|
|
102
103
|
```text
|
|
103
|
-
plugin_manager action=install_bundle target
|
|
104
|
+
plugin_manager action=install_bundle target=dsh-ssh-manage@0.2.0
|
|
104
105
|
```
|
|
105
106
|
|
|
106
107
|
或使用 DSH CLI:
|
|
107
108
|
|
|
108
109
|
```bash
|
|
109
|
-
dsh plugin add
|
|
110
|
+
dsh plugin add dsh-ssh-manage@0.2.0
|
|
110
111
|
```
|
|
111
112
|
|
|
112
113
|
安装后刷新 Web 页面,左侧栏会出现「SSH 主机」入口,入口 id 为 `dsh-ssh-manage`。
|
|
@@ -217,7 +218,7 @@ npm test
|
|
|
217
218
|
## 已知限制
|
|
218
219
|
|
|
219
220
|
- **主机密钥校验**:未接入 `known_hosts`,ssh2 默认接受服务器主机密钥;仅在可信网络使用。
|
|
220
|
-
-
|
|
221
|
+
- **会话生命周期**:终端会话由 Host 保留,切换 DSH 会话/暂时离开插件面板/刷新页面后会重新附着并回放最近约 256 KB 输出;只有关闭标签或 Host 进程退出才真正结束。Host 侧空闲超过 6 小时会清理孤儿会话(最大 16 个)。
|
|
221
222
|
- **仅密码认证**:不支持 IdentityFile / SSH agent / ProxyJump 传输;配置中的这些字段会原样保留,但不会用于连接。ProxyJump 主机会明确报错。
|
|
222
223
|
- **配置编辑范围**:编辑器只修改 `Host`、`HostName`、`User`、`Port` 与插件元数据;其它 SSH 指令请在 `~/.ssh/config` 中维护。
|
|
223
224
|
- **批量命令**:一次执行一条命令,不等同于交互式终端;单台输出上限 1 MB,超出会标记 `truncated`。
|
package/client.js
CHANGED
|
@@ -26,6 +26,7 @@ window.__ModuleLoader__.load({
|
|
|
26
26
|
'app.configFile': ['配置文件', 'Config file'],
|
|
27
27
|
'app.copyConfig': ['复制配置路径', 'Copy config path'],
|
|
28
28
|
'toolbar.collapse': ['收起', 'Collapse'],
|
|
29
|
+
'toolbar.resize': ['拖动调整分组栏宽度,双击恢复默认', 'Drag to resize the group sidebar; double-click to reset'],
|
|
29
30
|
'toolbar.expand': ['展开', 'Expand'],
|
|
30
31
|
'toolbar.refresh': ['刷新', 'Refresh'],
|
|
31
32
|
'toolbar.groups': ['分组', 'Groups'],
|
|
@@ -1068,7 +1069,7 @@ window.__ModuleLoader__.load({
|
|
|
1068
1069
|
|
|
1069
1070
|
function tableTracks(prefs) {
|
|
1070
1071
|
const tracks = ['32px', '112px', '92px', 'minmax(0,1.1fr)', 'minmax(0,1.2fr)']
|
|
1071
|
-
if (prefs.info) tracks.push('minmax(
|
|
1072
|
+
if (prefs.info) tracks.push('minmax(250px,1.5fr)')
|
|
1072
1073
|
if (prefs.notes) tracks.push('minmax(0,0.9fr)')
|
|
1073
1074
|
tracks.push('176px', '32px')
|
|
1074
1075
|
return tracks.join(' ')
|
|
@@ -1093,6 +1094,48 @@ window.__ModuleLoader__.load({
|
|
|
1093
1094
|
// App
|
|
1094
1095
|
// ---------------------------------------------------------------------
|
|
1095
1096
|
|
|
1097
|
+
// Survives the plugin panel unmounting when the user switches to another
|
|
1098
|
+
// DSH session (and a client-module reload). Host-side PTYs stay alive;
|
|
1099
|
+
// mounting again attaches to them and replays recent output.
|
|
1100
|
+
const PANEL_STATE_KEY = 'dsh-ssh-manage:panel-state'
|
|
1101
|
+
const TREE_WIDTH_KEY = 'dsh-ssh-manage:tree-width'
|
|
1102
|
+
|
|
1103
|
+
function readPanelState() {
|
|
1104
|
+
try {
|
|
1105
|
+
const parsed = JSON.parse(String(window.sessionStorage.getItem(PANEL_STATE_KEY) ?? 'null'))
|
|
1106
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.sessions)) {
|
|
1107
|
+
return { sessions: [], activeSessionId: null, tab: 'hosts' }
|
|
1108
|
+
}
|
|
1109
|
+
const sessions = parsed.sessions
|
|
1110
|
+
.filter((session) => session && typeof session.id === 'string' && typeof session.hostAlias === 'string')
|
|
1111
|
+
.map((session) => ({ id: session.id, hostAlias: session.hostAlias }))
|
|
1112
|
+
const tab = ['hosts', 'sessions', 'batch', 'settings'].includes(parsed.tab) ? parsed.tab : 'hosts'
|
|
1113
|
+
const activeSessionId = typeof parsed.activeSessionId === 'string' ? parsed.activeSessionId : null
|
|
1114
|
+
return { sessions, activeSessionId, tab }
|
|
1115
|
+
} catch {
|
|
1116
|
+
return { sessions: [], activeSessionId: null, tab: 'hosts' }
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const persistentPanelState = readPanelState()
|
|
1121
|
+
const TREE_MIN_WIDTH = 220
|
|
1122
|
+
const TREE_MAX_WIDTH = 560
|
|
1123
|
+
|
|
1124
|
+
function clampTreeWidth(value) {
|
|
1125
|
+
const numeric = Number(value)
|
|
1126
|
+
if (!Number.isFinite(numeric)) return 300
|
|
1127
|
+
return Math.min(TREE_MAX_WIDTH, Math.max(TREE_MIN_WIDTH, Math.round(numeric)))
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function readTreeWidth() {
|
|
1131
|
+
try {
|
|
1132
|
+
const stored = window.localStorage.getItem(TREE_WIDTH_KEY)
|
|
1133
|
+
return stored === null ? 300 : clampTreeWidth(stored)
|
|
1134
|
+
} catch {
|
|
1135
|
+
return 300
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1096
1139
|
function App({ ctx }) {
|
|
1097
1140
|
const t = useT(ctx)
|
|
1098
1141
|
const localeId = useLocaleId(ctx)
|
|
@@ -1100,7 +1143,11 @@ window.__ModuleLoader__.load({
|
|
|
1100
1143
|
const [loading, setLoading] = React.useState(true)
|
|
1101
1144
|
const [refreshing, setRefreshing] = React.useState(false)
|
|
1102
1145
|
const [error, setError] = React.useState('')
|
|
1103
|
-
const [tab, setTab] = React.useState(
|
|
1146
|
+
const [tab, setTab] = React.useState(() => {
|
|
1147
|
+
const saved = persistentPanelState.tab
|
|
1148
|
+
if (saved === 'sessions' && persistentPanelState.sessions.length === 0) return 'hosts'
|
|
1149
|
+
return ['hosts', 'sessions', 'batch', 'settings'].includes(saved) ? saved : 'hosts'
|
|
1150
|
+
})
|
|
1104
1151
|
const [filter, setFilter] = React.useState('')
|
|
1105
1152
|
const [favoriteOnly, setFavoriteOnly] = React.useState(false)
|
|
1106
1153
|
const [groupFilter, setGroupFilter] = React.useState(null)
|
|
@@ -1119,12 +1166,14 @@ window.__ModuleLoader__.load({
|
|
|
1119
1166
|
const [pending, setPending] = React.useState({})
|
|
1120
1167
|
const [sort, setSort] = React.useState({ key: 'name', dir: 'asc' })
|
|
1121
1168
|
const [columns, setColumns] = React.useState({ info: true, notes: true, compact: false })
|
|
1122
|
-
const [sessions, setSessions] = React.useState(
|
|
1123
|
-
|
|
1169
|
+
const [sessions, setSessions] = React.useState(() =>
|
|
1170
|
+
persistentPanelState.sessions.map((session) => ({ id: session.id, hostAlias: session.hostAlias })),
|
|
1171
|
+
)
|
|
1172
|
+
const [activeSessionId, setActiveSessionId] = React.useState(() => persistentPanelState.activeSessionId)
|
|
1173
|
+
const [treeWidth, setTreeWidth] = React.useState(readTreeWidth)
|
|
1124
1174
|
const [clearSignals, setClearSignals] = React.useState({})
|
|
1125
1175
|
const toastTimer = React.useRef(null)
|
|
1126
1176
|
const floatingRef = React.useRef(null)
|
|
1127
|
-
const sessionCounter = React.useRef(0)
|
|
1128
1177
|
|
|
1129
1178
|
const notify = React.useCallback((message, tone = 'info') => {
|
|
1130
1179
|
if (toastTimer.current) clearTimeout(toastTimer.current)
|
|
@@ -1139,6 +1188,25 @@ window.__ModuleLoader__.load({
|
|
|
1139
1188
|
[],
|
|
1140
1189
|
)
|
|
1141
1190
|
|
|
1191
|
+
React.useEffect(() => {
|
|
1192
|
+
persistentPanelState.sessions = sessions.map((session) => ({ id: session.id, hostAlias: session.hostAlias }))
|
|
1193
|
+
persistentPanelState.activeSessionId = activeSessionId
|
|
1194
|
+
persistentPanelState.tab = tab
|
|
1195
|
+
try {
|
|
1196
|
+
window.sessionStorage.setItem(PANEL_STATE_KEY, JSON.stringify(persistentPanelState))
|
|
1197
|
+
} catch {}
|
|
1198
|
+
}, [sessions, activeSessionId, tab])
|
|
1199
|
+
|
|
1200
|
+
React.useEffect(() => {
|
|
1201
|
+
try {
|
|
1202
|
+
window.localStorage.setItem(TREE_WIDTH_KEY, String(treeWidth))
|
|
1203
|
+
} catch {}
|
|
1204
|
+
}, [treeWidth])
|
|
1205
|
+
|
|
1206
|
+
React.useEffect(() => {
|
|
1207
|
+
for (const session of sessions) ptyActiveIds.add(session.id)
|
|
1208
|
+
}, [sessions])
|
|
1209
|
+
|
|
1142
1210
|
const applySnapshot = React.useCallback((next) => {
|
|
1143
1211
|
if (!next) return
|
|
1144
1212
|
const aliases = new Set((next.hosts ?? []).map((host) => host.host))
|
|
@@ -1217,8 +1285,11 @@ window.__ModuleLoader__.load({
|
|
|
1217
1285
|
if (!snapshot) return
|
|
1218
1286
|
const aliases = new Set(hosts.map((host) => host.host))
|
|
1219
1287
|
setSessions((prev) => {
|
|
1288
|
+
const removed = prev.filter((session) => !aliases.has(session.hostAlias))
|
|
1220
1289
|
const next = prev.filter((session) => aliases.has(session.hostAlias))
|
|
1221
|
-
|
|
1290
|
+
if (removed.length === 0) return prev
|
|
1291
|
+
for (const session of removed) closePtySession(session.id)
|
|
1292
|
+
return next
|
|
1222
1293
|
})
|
|
1223
1294
|
}, [hosts, snapshot])
|
|
1224
1295
|
|
|
@@ -1298,8 +1369,8 @@ window.__ModuleLoader__.load({
|
|
|
1298
1369
|
if (existing) {
|
|
1299
1370
|
setActiveSessionId(existing.id)
|
|
1300
1371
|
} else {
|
|
1301
|
-
|
|
1302
|
-
|
|
1372
|
+
const id = newPtySessionId()
|
|
1373
|
+
ptyActiveIds.add(id)
|
|
1303
1374
|
setSessions((prev) => [...prev, { id, hostAlias: host.host }])
|
|
1304
1375
|
setActiveSessionId(id)
|
|
1305
1376
|
}
|
|
@@ -1309,6 +1380,7 @@ window.__ModuleLoader__.load({
|
|
|
1309
1380
|
function closeSession(id) {
|
|
1310
1381
|
const index = sessions.findIndex((session) => session.id === id)
|
|
1311
1382
|
if (index < 0) return
|
|
1383
|
+
closePtySession(id)
|
|
1312
1384
|
const next = sessions.filter((session) => session.id !== id)
|
|
1313
1385
|
setSessions(next)
|
|
1314
1386
|
setClearSignals((prev) => {
|
|
@@ -1602,6 +1674,8 @@ window.__ModuleLoader__.load({
|
|
|
1602
1674
|
setFloating,
|
|
1603
1675
|
setEditor,
|
|
1604
1676
|
setDrawer,
|
|
1677
|
+
treeWidth,
|
|
1678
|
+
setTreeWidth,
|
|
1605
1679
|
sessions,
|
|
1606
1680
|
activeSessionId,
|
|
1607
1681
|
setActiveSessionId,
|
|
@@ -2498,10 +2572,55 @@ window.__ModuleLoader__.load({
|
|
|
2498
2572
|
}
|
|
2499
2573
|
|
|
2500
2574
|
function HostsTab({ model }) {
|
|
2575
|
+
const { t } = model
|
|
2576
|
+
const beginResize = (event) => {
|
|
2577
|
+
if (!model.sidebarOpen || event.button !== 0) return
|
|
2578
|
+
event.preventDefault()
|
|
2579
|
+
event.stopPropagation()
|
|
2580
|
+
const startX = event.clientX
|
|
2581
|
+
const startWidth = model.treeWidth
|
|
2582
|
+
document.body.classList.add('ssh-col-resizing')
|
|
2583
|
+
const onMove = (moveEvent) => {
|
|
2584
|
+
model.setTreeWidth(clampTreeWidth(startWidth + moveEvent.clientX - startX))
|
|
2585
|
+
}
|
|
2586
|
+
const onUp = () => {
|
|
2587
|
+
document.body.classList.remove('ssh-col-resizing')
|
|
2588
|
+
window.removeEventListener('pointermove', onMove)
|
|
2589
|
+
window.removeEventListener('pointerup', onUp)
|
|
2590
|
+
window.removeEventListener('pointercancel', onUp)
|
|
2591
|
+
}
|
|
2592
|
+
window.addEventListener('pointermove', onMove)
|
|
2593
|
+
window.addEventListener('pointerup', onUp)
|
|
2594
|
+
window.addEventListener('pointercancel', onUp)
|
|
2595
|
+
}
|
|
2596
|
+
const nudgeWidth = (event) => {
|
|
2597
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
|
2598
|
+
event.preventDefault()
|
|
2599
|
+
model.setTreeWidth(clampTreeWidth(model.treeWidth + (event.key === 'ArrowLeft' ? -20 : 20)))
|
|
2600
|
+
}
|
|
2501
2601
|
return h(
|
|
2502
2602
|
'div',
|
|
2503
|
-
{
|
|
2603
|
+
{
|
|
2604
|
+
className: cx('ssh-body', !model.sidebarOpen && 'collapsed'),
|
|
2605
|
+
style: { '--ssh-tree-width': `${model.treeWidth}px` },
|
|
2606
|
+
},
|
|
2504
2607
|
h(Sidebar, { model }),
|
|
2608
|
+
model.sidebarOpen
|
|
2609
|
+
? h('div', {
|
|
2610
|
+
className: 'ssh-treeResizer',
|
|
2611
|
+
role: 'separator',
|
|
2612
|
+
tabIndex: 0,
|
|
2613
|
+
'aria-orientation': 'vertical',
|
|
2614
|
+
'aria-label': t('toolbar.resize'),
|
|
2615
|
+
'aria-valuemin': TREE_MIN_WIDTH,
|
|
2616
|
+
'aria-valuemax': TREE_MAX_WIDTH,
|
|
2617
|
+
'aria-valuenow': model.treeWidth,
|
|
2618
|
+
title: t('toolbar.resize'),
|
|
2619
|
+
onPointerDown: beginResize,
|
|
2620
|
+
onKeyDown: nudgeWidth,
|
|
2621
|
+
onDoubleClick: () => model.setTreeWidth(300),
|
|
2622
|
+
})
|
|
2623
|
+
: null,
|
|
2505
2624
|
model.sidebarOpen ? h('div', { className: 'ssh-sideScrim', onClick: () => model.setSidebarOpen(false) }) : null,
|
|
2506
2625
|
h(
|
|
2507
2626
|
'main',
|
|
@@ -2913,10 +3032,26 @@ window.__ModuleLoader__.load({
|
|
|
2913
3032
|
}
|
|
2914
3033
|
|
|
2915
3034
|
const PTY_EVENTS_ROUTE = '/dsh-ssh-manage/pty/events'
|
|
3035
|
+
const PTY_ATTACH_ROUTE = '/dsh-ssh-manage/pty/attach'
|
|
2916
3036
|
const ptyStream = { source: null, ready: false, promise: null, listeners: new Map() }
|
|
2917
3037
|
const ptyActiveIds = new Set()
|
|
3038
|
+
const ptyClosedIds = new Set()
|
|
2918
3039
|
const ptyStreamLostHandlers = new Set()
|
|
2919
3040
|
|
|
3041
|
+
function closePtySession(id) {
|
|
3042
|
+
if (!id) return
|
|
3043
|
+
ptyClosedIds.add(id)
|
|
3044
|
+
ptyActiveIds.delete(id)
|
|
3045
|
+
try {
|
|
3046
|
+
void fetch('/dsh-ssh-manage/pty/close', {
|
|
3047
|
+
method: 'POST',
|
|
3048
|
+
headers: { 'content-type': 'application/json' },
|
|
3049
|
+
body: JSON.stringify({ id }),
|
|
3050
|
+
keepalive: true,
|
|
3051
|
+
})
|
|
3052
|
+
} catch {}
|
|
3053
|
+
}
|
|
3054
|
+
|
|
2920
3055
|
function onPtyStreamLost(handler) {
|
|
2921
3056
|
ptyStreamLostHandlers.add(handler)
|
|
2922
3057
|
return () => ptyStreamLostHandlers.delete(handler)
|
|
@@ -2934,17 +3069,6 @@ window.__ModuleLoader__.load({
|
|
|
2934
3069
|
}
|
|
2935
3070
|
}
|
|
2936
3071
|
|
|
2937
|
-
if (typeof window !== 'undefined') {
|
|
2938
|
-
window.addEventListener('pagehide', () => {
|
|
2939
|
-
for (const id of ptyActiveIds) {
|
|
2940
|
-
try {
|
|
2941
|
-
navigator.sendBeacon('/dsh-ssh-manage/pty/close', new Blob([JSON.stringify({ id })], { type: 'application/json' }))
|
|
2942
|
-
} catch {}
|
|
2943
|
-
}
|
|
2944
|
-
ptyActiveIds.clear()
|
|
2945
|
-
})
|
|
2946
|
-
}
|
|
2947
|
-
|
|
2948
3072
|
function subscribePty(id, listener) {
|
|
2949
3073
|
ptyStream.listeners.set(id, listener)
|
|
2950
3074
|
return () => {
|
|
@@ -3043,7 +3167,6 @@ window.__ModuleLoader__.load({
|
|
|
3043
3167
|
const containerRef = React.useRef(null)
|
|
3044
3168
|
const termRef = React.useRef(null)
|
|
3045
3169
|
const fitRef = React.useRef(null)
|
|
3046
|
-
const sessionIdRef = React.useRef(null)
|
|
3047
3170
|
const activeRef = React.useRef(active === true)
|
|
3048
3171
|
const [status, setStatus] = React.useState('loading')
|
|
3049
3172
|
const [message, setMessage] = React.useState('')
|
|
@@ -3099,24 +3222,7 @@ window.__ModuleLoader__.load({
|
|
|
3099
3222
|
unsubscribe()
|
|
3100
3223
|
unsubscribe = null
|
|
3101
3224
|
}
|
|
3102
|
-
const id = sessionKey
|
|
3103
3225
|
sessionKey = null
|
|
3104
|
-
sessionIdRef.current = null
|
|
3105
|
-
if (id) {
|
|
3106
|
-
ptyActiveIds.delete(id)
|
|
3107
|
-
postClose(id)
|
|
3108
|
-
}
|
|
3109
|
-
}
|
|
3110
|
-
|
|
3111
|
-
const postClose = (id) => {
|
|
3112
|
-
try {
|
|
3113
|
-
void fetch('/dsh-ssh-manage/pty/close', {
|
|
3114
|
-
method: 'POST',
|
|
3115
|
-
headers: { 'content-type': 'application/json' },
|
|
3116
|
-
body: JSON.stringify({ id }),
|
|
3117
|
-
keepalive: true,
|
|
3118
|
-
})
|
|
3119
|
-
} catch {}
|
|
3120
3226
|
}
|
|
3121
3227
|
|
|
3122
3228
|
const failSession = (error) => {
|
|
@@ -3207,42 +3313,83 @@ window.__ModuleLoader__.load({
|
|
|
3207
3313
|
return
|
|
3208
3314
|
}
|
|
3209
3315
|
if (removed) return
|
|
3210
|
-
sessionKey =
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3316
|
+
sessionKey = sessionId
|
|
3317
|
+
const id = sessionKey
|
|
3318
|
+
let synced = false
|
|
3319
|
+
let lastSeq = 0
|
|
3320
|
+
const pendingEvents = []
|
|
3321
|
+
|
|
3322
|
+
const applyOutput = (event) => {
|
|
3323
|
+
const seq = Number(event.seq)
|
|
3324
|
+
if (Number.isFinite(seq)) {
|
|
3325
|
+
if (seq <= lastSeq) return
|
|
3326
|
+
lastSeq = seq
|
|
3327
|
+
}
|
|
3328
|
+
try { term.write(decodeBase64(event.data)) } catch {}
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
const handleEvent = (event) => {
|
|
3215
3332
|
if (event.kind === 'output') {
|
|
3216
|
-
|
|
3333
|
+
if (!synced) pendingEvents.push(event)
|
|
3334
|
+
else applyOutput(event)
|
|
3217
3335
|
} else if (event.kind === 'exit') {
|
|
3336
|
+
if (!synced) {
|
|
3337
|
+
pendingEvents.push(event)
|
|
3338
|
+
return
|
|
3339
|
+
}
|
|
3218
3340
|
finished = true
|
|
3219
3341
|
setStatus('closed')
|
|
3220
3342
|
setMessage(t('terminal.exited'))
|
|
3221
3343
|
teardown()
|
|
3222
3344
|
} else if (event.kind === 'error') {
|
|
3345
|
+
if (!synced) {
|
|
3346
|
+
pendingEvents.push(event)
|
|
3347
|
+
return
|
|
3348
|
+
}
|
|
3223
3349
|
finished = true
|
|
3224
3350
|
setStatus('error')
|
|
3225
3351
|
setMessage(String(event.message ?? t('terminal.failed')))
|
|
3226
3352
|
teardown()
|
|
3227
3353
|
}
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
unsubscribe = subscribePty(id, (event) => {
|
|
3357
|
+
if (removed) return
|
|
3358
|
+
handleEvent(event)
|
|
3228
3359
|
})
|
|
3229
3360
|
let dims = { cols: 80, rows: 24 }
|
|
3230
3361
|
try {
|
|
3231
3362
|
const proposed = fit?.proposeDimensions?.()
|
|
3232
3363
|
if (proposed && proposed.cols > 0 && proposed.rows > 0) dims = proposed
|
|
3233
3364
|
} catch {}
|
|
3234
|
-
const openedId = sessionKey
|
|
3235
3365
|
try {
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3366
|
+
let value
|
|
3367
|
+
try {
|
|
3368
|
+
value = await ptyPost(PTY_ATTACH_ROUTE, { id, cols: dims.cols, rows: dims.rows }, 15_000)
|
|
3369
|
+
} catch {
|
|
3370
|
+
value = await ptyPost('/dsh-ssh-manage/pty/open', {
|
|
3371
|
+
id,
|
|
3372
|
+
host: hostRef.current.host,
|
|
3373
|
+
cols: dims.cols,
|
|
3374
|
+
rows: dims.rows,
|
|
3375
|
+
}, 40_000)
|
|
3376
|
+
}
|
|
3377
|
+
if (ptyClosedIds.has(id)) {
|
|
3378
|
+
closePtySession(id)
|
|
3244
3379
|
return
|
|
3245
3380
|
}
|
|
3381
|
+
if (removed) return
|
|
3382
|
+
if (value && value.output) {
|
|
3383
|
+
try { term.write(decodeBase64(value.output)) } catch {}
|
|
3384
|
+
}
|
|
3385
|
+
const seq = Number(value?.seq)
|
|
3386
|
+
if (Number.isFinite(seq)) lastSeq = seq
|
|
3387
|
+
synced = true
|
|
3388
|
+
for (const event of pendingEvents.splice(0)) {
|
|
3389
|
+
if (finished) break
|
|
3390
|
+
handleEvent(event)
|
|
3391
|
+
}
|
|
3392
|
+
if (finished) return
|
|
3246
3393
|
opened = true
|
|
3247
3394
|
setStatus('ready')
|
|
3248
3395
|
setMessage('')
|
|
@@ -4283,8 +4430,13 @@ window.__ModuleLoader__.load({
|
|
|
4283
4430
|
.ssh-errorBar{flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:8px 16px;border-bottom:.5px solid var(--ssh-border);color:var(--ssh-danger);font-size:11.5px;background:color-mix(in srgb,var(--ssh-danger) 7%,var(--ssh-bg))}
|
|
4284
4431
|
.ssh-errorText{flex:1;min-width:0}
|
|
4285
4432
|
.ssh-loadingPage{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--ssh-text-3);font-size:12.5px;padding:40px}
|
|
4286
|
-
.ssh-body{flex:1;min-height:0;display:grid;grid-template-columns:300px minmax(0,1fr);position:relative;overflow:hidden}
|
|
4433
|
+
.ssh-body{flex:1;min-height:0;display:grid;grid-template-columns:var(--ssh-tree-width,300px) minmax(0,1fr);position:relative;overflow:hidden}
|
|
4287
4434
|
.ssh-body.collapsed{grid-template-columns:0 minmax(0,1fr)}
|
|
4435
|
+
.ssh-treeResizer{position:absolute;top:0;bottom:0;left:var(--ssh-tree-width,300px);width:10px;transform:translateX(-5px);z-index:26;cursor:col-resize;touch-action:none;outline:none}
|
|
4436
|
+
.ssh-treeResizer::after{content:"";position:absolute;top:0;bottom:0;left:4px;width:1px;background:transparent;transition:background .12s ease}
|
|
4437
|
+
.ssh-treeResizer:hover::after,.ssh-treeResizer:focus-visible::after{background:var(--ssh-brand);box-shadow:0 0 0 1px color-mix(in srgb,var(--ssh-brand) 35%,transparent)}
|
|
4438
|
+
.ssh-body.collapsed .ssh-treeResizer{display:none}
|
|
4439
|
+
body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-select:none!important}
|
|
4288
4440
|
.ssh-sidebar{display:flex;flex-direction:column;min-height:0;border-right:.5px solid var(--ssh-border);background:var(--ssh-bg)}
|
|
4289
4441
|
.ssh-body.collapsed .ssh-sidebar{display:none}
|
|
4290
4442
|
.ssh-sideScrim{display:none}
|
|
@@ -4369,8 +4521,8 @@ window.__ModuleLoader__.load({
|
|
|
4369
4521
|
.ssh-row:hover .ssh-inlineActions{opacity:1}
|
|
4370
4522
|
.ssh-miniBtn{appearance:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:transparent;color:var(--ssh-text-3);cursor:pointer}
|
|
4371
4523
|
.ssh-miniBtn:hover{background:var(--ssh-hover);color:var(--ssh-text)}
|
|
4372
|
-
.ssh-colInfo{gap:4px;flex-wrap:
|
|
4373
|
-
.ssh-chip{display:inline-flex;align-items:center;height:21px;max-width:100%;padding:0 7px;border-radius:6px;border:.5px solid var(--ssh-border);background:var(--ssh-layer-3);font-size:10.5px;color:var(--ssh-text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
4524
|
+
.ssh-colInfo{gap:4px;row-gap:3px;flex-wrap:wrap;align-content:center;overflow:visible}
|
|
4525
|
+
.ssh-chip{display:inline-flex;flex:0 0 auto;align-items:center;height:21px;max-width:100%;padding:0 7px;border-radius:6px;border:.5px solid var(--ssh-border);background:var(--ssh-layer-3);font-size:10.5px;color:var(--ssh-text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
4374
4526
|
.ssh-mutedText{font-size:11.5px;color:var(--ssh-dim)}
|
|
4375
4527
|
.ssh-colNotes{min-width:0}
|
|
4376
4528
|
.ssh-noteText{font-size:11.5px;color:var(--ssh-text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
@@ -4620,6 +4772,7 @@ window.__ModuleLoader__.load({
|
|
|
4620
4772
|
.ssh-tree::-webkit-scrollbar-thumb:hover,.ssh-tableScroll::-webkit-scrollbar-thumb:hover,.ssh-details::-webkit-scrollbar-thumb:hover,.ssh-terminalLog::-webkit-scrollbar-thumb:hover,.ssh-fileList::-webkit-scrollbar-thumb:hover,.ssh-page::-webkit-scrollbar-thumb:hover,.ssh-modalBody::-webkit-scrollbar-thumb:hover,.ssh-floating::-webkit-scrollbar-thumb:hover,.ssh-targetList::-webkit-scrollbar-thumb:hover{background:var(--dsw-alias-scrollbar-hover-l1,var(--ssh-border-strong));border:2px solid transparent;background-clip:padding-box}
|
|
4621
4773
|
@media (max-width: 900px){
|
|
4622
4774
|
.ssh-body{grid-template-columns:minmax(0,1fr)}
|
|
4775
|
+
.ssh-treeResizer{display:none}
|
|
4623
4776
|
.ssh-sidebar{position:absolute;top:0;bottom:0;left:0;width:290px;z-index:25;display:none;box-shadow:var(--ssh-shadow)}
|
|
4624
4777
|
.ssh-sidebar.open{display:flex}
|
|
4625
4778
|
.ssh-body:not(.collapsed) .ssh-sideScrim{display:block;position:absolute;inset:0;z-index:20;background:color-mix(in srgb,var(--ssh-bg) 45%,transparent)}
|
package/index.js
CHANGED
|
@@ -15,9 +15,11 @@ const PTY_EVENTS_ROUTE = '/dsh-ssh-manage/pty/events'
|
|
|
15
15
|
const PTY_OPEN_ROUTE = '/dsh-ssh-manage/pty/open'
|
|
16
16
|
const PTY_INPUT_ROUTE = '/dsh-ssh-manage/pty/input'
|
|
17
17
|
const PTY_RESIZE_ROUTE = '/dsh-ssh-manage/pty/resize'
|
|
18
|
+
const PTY_ATTACH_ROUTE = '/dsh-ssh-manage/pty/attach'
|
|
18
19
|
const PTY_CLOSE_ROUTE = '/dsh-ssh-manage/pty/close'
|
|
19
20
|
const MAX_PTY_SESSIONS = 16
|
|
20
|
-
const
|
|
21
|
+
const PTY_BUFFER_BYTES = 256 * 1024
|
|
22
|
+
const PTY_IDLE_MS = 6 * 60 * 60 * 1000
|
|
21
23
|
const MAX_PTY_BUFFER_BYTES = 4 * 1024 * 1024
|
|
22
24
|
const PACKAGE_DIRECTORY = path.dirname(fileURLToPath(import.meta.url))
|
|
23
25
|
const ASSETS = [
|
|
@@ -330,11 +332,18 @@ async function openTextSession(paths, payload) {
|
|
|
330
332
|
try { session.close() } catch {}
|
|
331
333
|
throw new Error('Terminal session already exists')
|
|
332
334
|
}
|
|
333
|
-
const entry = { id, session, cols, rows, lastActivity: Date.now() }
|
|
335
|
+
const entry = { id, session, cols, rows, lastActivity: Date.now(), seq: 0, buffer: [], bufferBytes: 0 }
|
|
334
336
|
ptyTextSessions.set(id, entry)
|
|
335
337
|
session.stream.on('data', (chunk) => {
|
|
336
338
|
entry.lastActivity = Date.now()
|
|
337
|
-
|
|
339
|
+
const copy = Buffer.from(chunk)
|
|
340
|
+
entry.seq += 1
|
|
341
|
+
entry.buffer.push(copy)
|
|
342
|
+
entry.bufferBytes += copy.length
|
|
343
|
+
while (entry.bufferBytes > PTY_BUFFER_BYTES && entry.buffer.length > 0) {
|
|
344
|
+
entry.bufferBytes -= entry.buffer.shift().length
|
|
345
|
+
}
|
|
346
|
+
broadcastPty({ kind: 'output', id, seq: entry.seq, data: copy.toString('base64') })
|
|
338
347
|
})
|
|
339
348
|
session.stream.on('close', () => {
|
|
340
349
|
closeTextSession(id)
|
|
@@ -347,7 +356,7 @@ async function openTextSession(paths, payload) {
|
|
|
347
356
|
closeTextSession(id, false)
|
|
348
357
|
broadcastPty({ kind: 'error', id, message: errorMessage(error) })
|
|
349
358
|
})
|
|
350
|
-
return { id, cols, rows }
|
|
359
|
+
return { id, seq: 0, cols, rows, output: '' }
|
|
351
360
|
} finally {
|
|
352
361
|
ptyPendingIds.delete(id)
|
|
353
362
|
ptyOpening -= 1
|
|
@@ -424,6 +433,42 @@ function registerPtyHttpRoutes(ctx, paths) {
|
|
|
424
433
|
'pty open route',
|
|
425
434
|
)
|
|
426
435
|
|
|
436
|
+
register(
|
|
437
|
+
{
|
|
438
|
+
kind: 'exact',
|
|
439
|
+
path: PTY_ATTACH_ROUTE,
|
|
440
|
+
handler: async (req, res) => {
|
|
441
|
+
if (rejectUnauthenticated(ctx, req, res)) return
|
|
442
|
+
if (req.method !== 'POST') {
|
|
443
|
+
res.statusCode = 405
|
|
444
|
+
res.end()
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
const body = (await readJsonBody(req)) ?? {}
|
|
448
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
449
|
+
const entry = ptyTextSessions.get(id)
|
|
450
|
+
if (!entry) {
|
|
451
|
+
sendJson(res, 200, { ok: false, error: { code: 'pty-session-missing', message: 'Terminal session was not found' } })
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
entry.lastActivity = Date.now()
|
|
456
|
+
entry.cols = boundedInteger(body.cols, 20, 500, entry.cols)
|
|
457
|
+
entry.rows = boundedInteger(body.rows, 5, 300, entry.rows)
|
|
458
|
+
entry.session.stream.setWindow(entry.rows, entry.cols, 0, 0)
|
|
459
|
+
const output = Buffer.concat(entry.buffer, entry.bufferBytes).toString('base64')
|
|
460
|
+
sendJson(res, 200, {
|
|
461
|
+
ok: true,
|
|
462
|
+
value: { id, seq: entry.seq, cols: entry.cols, rows: entry.rows, output },
|
|
463
|
+
})
|
|
464
|
+
} catch (error) {
|
|
465
|
+
sendJson(res, 200, { ok: false, error: { code: 'pty-attach-failed', message: errorMessage(error) } })
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
'pty attach route',
|
|
470
|
+
)
|
|
471
|
+
|
|
427
472
|
for (const [path, action, label] of [
|
|
428
473
|
[PTY_INPUT_ROUTE, 'input', 'pty input route'],
|
|
429
474
|
[PTY_RESIZE_ROUTE, 'resize', 'pty resize route'],
|
|
@@ -669,7 +714,7 @@ function registerAssetRoutes(ctx) {
|
|
|
669
714
|
}
|
|
670
715
|
}
|
|
671
716
|
|
|
672
|
-
export const __test = { resolveSshPaths, listSshHosts, saveSshHost, deleteSshHost, runAction, managerTool, MAX_BODY_BYTES, PTY_ROUTE, PTY_EVENTS_ROUTE, PTY_OPEN_ROUTE, PTY_INPUT_ROUTE, PTY_RESIZE_ROUTE, PTY_CLOSE_ROUTE, MAX_PTY_SESSIONS, ASSETS, handlePtyUpgrade, ptyTextSessions }
|
|
717
|
+
export const __test = { resolveSshPaths, listSshHosts, saveSshHost, deleteSshHost, runAction, managerTool, MAX_BODY_BYTES, PTY_ROUTE, PTY_EVENTS_ROUTE, PTY_OPEN_ROUTE, PTY_ATTACH_ROUTE, PTY_INPUT_ROUTE, PTY_RESIZE_ROUTE, PTY_CLOSE_ROUTE, MAX_PTY_SESSIONS, PTY_BUFFER_BYTES, ASSETS, handlePtyUpgrade, ptyTextSessions }
|
|
673
718
|
|
|
674
719
|
export const inject = ['webServer']
|
|
675
720
|
|