@linxin666/dsh-ssh 0.1.19 → 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.i18n.yaml +2 -2
- package/README.md +2 -1
- package/README.zh.md +2 -1
- package/lib/client.js +224 -93
- package/lib/client.js.map +1 -1
- package/lib/index.js +182 -91
- package/lib/types/client/locales.d.ts +8 -0
- package/lib/types/client/panel/HostsTab.d.ts +16 -0
- package/lib/types/engine/connection-pool.d.ts +2 -0
- package/lib/types/engine/tunnel.d.ts +4 -2
- package/lib/types/engine.d.ts +7 -0
- package/lib/types/index.d.ts +3 -1
- package/lib/types/mount-once.d.ts +24 -0
- package/lib/types/routes.d.ts +2 -0
- package/lib/types/store.d.ts +6 -0
- package/package.json +12 -12
- package/src/client/locales.ts +16 -0
- package/src/client/panel/HostsTab.tsx +152 -48
- package/src/client/panel/panel.module.css +75 -0
- package/src/engine/connection-pool.ts +23 -3
- package/src/engine/sftp.ts +29 -38
- package/src/engine/tunnel.ts +19 -5
- package/src/engine.ts +11 -0
- package/src/index.ts +4 -1
- package/src/mount-once.ts +48 -0
- package/src/routes.ts +46 -5
- package/src/store.ts +21 -2
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* add/edit/delete/test actions, ~/.ssh/config import, and a connect action
|
|
4
4
|
* that hands the alias to the terminal tab via onConnect.
|
|
5
5
|
*/
|
|
6
|
-
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
6
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
7
7
|
import type { SshApi } from '../api.ts'
|
|
8
8
|
import type { SshHostSummary, TestResult } from '../../protocol.ts'
|
|
9
9
|
import { errorMessage, tt } from './helpers.ts'
|
|
@@ -20,6 +20,44 @@ export interface HostsTabProps {
|
|
|
20
20
|
/** The host-form dialog invocation. */
|
|
21
21
|
type DialogState = { mode: 'create' } | { mode: 'edit'; host: SshHostSummary }
|
|
22
22
|
|
|
23
|
+
/** Host list grouping modes (#379). */
|
|
24
|
+
export type HostGroupBy = 'none' | 'environment' | 'tags'
|
|
25
|
+
|
|
26
|
+
/** One collapsible group section of the grouped host list. */
|
|
27
|
+
export interface HostGroup {
|
|
28
|
+
/** Group key: the environment name, one tag, or '' for the ungrouped bucket. */
|
|
29
|
+
key: string
|
|
30
|
+
hosts: SshHostSummary[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Bucket hosts into collapsible groups (#379). Grouping by tags places a
|
|
35
|
+
* multi-tag host in every one of its tag groups (folder view); hosts without
|
|
36
|
+
* the grouping key land in the '' bucket, which always sorts last. Groups
|
|
37
|
+
* sort alphabetically; host order inside a group follows the API listing.
|
|
38
|
+
*/
|
|
39
|
+
export function groupHosts(hosts: SshHostSummary[], groupBy: HostGroupBy): HostGroup[] {
|
|
40
|
+
if (groupBy === 'none') return [{ key: '', hosts }]
|
|
41
|
+
const buckets = new Map<string, SshHostSummary[]>()
|
|
42
|
+
const push = (key: string, host: SshHostSummary): void => {
|
|
43
|
+
const bucket = buckets.get(key)
|
|
44
|
+
if (bucket === undefined) buckets.set(key, [host])
|
|
45
|
+
else bucket.push(host)
|
|
46
|
+
}
|
|
47
|
+
for (const host of hosts) {
|
|
48
|
+
if (groupBy === 'environment') {
|
|
49
|
+
push(host.environment ?? '', host)
|
|
50
|
+
} else if (host.tags.length === 0) {
|
|
51
|
+
push('', host)
|
|
52
|
+
} else {
|
|
53
|
+
for (const tag of host.tags) push(tag, host)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...buckets.entries()]
|
|
57
|
+
.sort(([a], [b]) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b)))
|
|
58
|
+
.map(([key, group]) => ({ key, hosts: group }))
|
|
59
|
+
}
|
|
60
|
+
|
|
23
61
|
/** The hosts table plus its toolbar and dialogs. */
|
|
24
62
|
export function HostsTab({ api, onConnect }: HostsTabProps) {
|
|
25
63
|
const [hosts, setHosts] = useState<SshHostSummary[] | null>(null)
|
|
@@ -30,6 +68,9 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
|
|
|
30
68
|
const [importing, setImporting] = useState(false)
|
|
31
69
|
const [notice, setNotice] = useState<string | null>(null)
|
|
32
70
|
const [dialog, setDialog] = useState<DialogState | null>(null)
|
|
71
|
+
const [groupBy, setGroupBy] = useState<HostGroupBy>('none')
|
|
72
|
+
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
|
73
|
+
const [testingGroup, setTestingGroup] = useState<string | null>(null)
|
|
33
74
|
const seqRef = useRef(0)
|
|
34
75
|
|
|
35
76
|
const load = useCallback(async (query?: string): Promise<void> => {
|
|
@@ -78,6 +119,16 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
|
|
|
78
119
|
}
|
|
79
120
|
}
|
|
80
121
|
|
|
122
|
+
// Group-header batch action (#379): test every host in the group.
|
|
123
|
+
const testGroup = async (group: HostGroup): Promise<void> => {
|
|
124
|
+
setTestingGroup(group.key)
|
|
125
|
+
try {
|
|
126
|
+
await Promise.all(group.hosts.map(host => runTest(host.alias)))
|
|
127
|
+
} finally {
|
|
128
|
+
setTestingGroup(null)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
81
132
|
const importConfig = async (): Promise<void> => {
|
|
82
133
|
setImporting(true)
|
|
83
134
|
try {
|
|
@@ -91,10 +142,73 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
|
|
|
91
142
|
}
|
|
92
143
|
}
|
|
93
144
|
|
|
145
|
+
const renderHostRow = (host: SshHostSummary): ReactNode => {
|
|
146
|
+
const test = testResults[host.alias]
|
|
147
|
+
return (
|
|
148
|
+
<tr key={host.alias}>
|
|
149
|
+
<td className={css.mono}>{host.alias}</td>
|
|
150
|
+
<td className={css.mono}>{host.host}:{host.port}</td>
|
|
151
|
+
<td>{host.user}</td>
|
|
152
|
+
<td><span className={css.badge} data-kind={host.auth}>{host.auth === 'key' ? tt('form.auth.key') : tt('form.auth.password')}</span></td>
|
|
153
|
+
<td className={css.cellMuted}>{host.environment ?? ''}</td>
|
|
154
|
+
<td className={css.cellMuted}>{host.tags.join(', ')}</td>
|
|
155
|
+
<td className={css.cellMuted}>{host.description ?? ''}</td>
|
|
156
|
+
<td>
|
|
157
|
+
<div className={css.actions}>
|
|
158
|
+
<button type="button" className={css.linkButton} disabled={testingAlias === host.alias} onClick={() => { void runTest(host.alias) }}>
|
|
159
|
+
{testingAlias === host.alias ? tt('hosts.testing') : tt('hosts.test')}
|
|
160
|
+
</button>
|
|
161
|
+
{testingAlias === host.alias && <span className={css.spinner} aria-hidden="true" />}
|
|
162
|
+
{test !== undefined && (
|
|
163
|
+
<span className={css.inlineTest} data-status={test.ok ? 'ok' : 'fail'}>
|
|
164
|
+
{test.ok ? tt('hosts.testOk', { latency: test.latencyMs ?? 0 }) : tt('hosts.testFail', { error: test.error ?? '' })}
|
|
165
|
+
</span>
|
|
166
|
+
)}
|
|
167
|
+
<button type="button" className={css.linkButton} onClick={() => { setDialog({ mode: 'edit', host }) }}>{tt('hosts.edit')}</button>
|
|
168
|
+
<button type="button" className={css.linkButton} data-danger onClick={() => { void deleteHost(host.alias) }}>{tt('hosts.delete')}</button>
|
|
169
|
+
<button type="button" className={css.ghostButton} onClick={() => { onConnect(host.alias) }}>{tt('hosts.connected')}</button>
|
|
170
|
+
</div>
|
|
171
|
+
</td>
|
|
172
|
+
</tr>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const renderHostTable = (rows: SshHostSummary[]): ReactNode => (
|
|
177
|
+
<table className={css.table}>
|
|
178
|
+
<thead>
|
|
179
|
+
<tr>
|
|
180
|
+
<th>{tt('hosts.col.alias')}</th>
|
|
181
|
+
<th>{tt('hosts.col.host')}</th>
|
|
182
|
+
<th>{tt('hosts.col.user')}</th>
|
|
183
|
+
<th>{tt('hosts.col.auth')}</th>
|
|
184
|
+
<th>{tt('hosts.col.environment')}</th>
|
|
185
|
+
<th>{tt('hosts.col.tags')}</th>
|
|
186
|
+
<th>{tt('hosts.col.description')}</th>
|
|
187
|
+
<th>{tt('hosts.col.actions')}</th>
|
|
188
|
+
</tr>
|
|
189
|
+
</thead>
|
|
190
|
+
<tbody>
|
|
191
|
+
{rows.map(renderHostRow)}
|
|
192
|
+
</tbody>
|
|
193
|
+
</table>
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
const groups = hosts === null ? [] : groupHosts(hosts, groupBy)
|
|
197
|
+
|
|
94
198
|
return (
|
|
95
199
|
<div className={css.fillBody}>
|
|
96
200
|
<div className={css.toolbar}>
|
|
97
201
|
<input className={css.search} type="search" placeholder={tt('hosts.search')} value={search} onChange={event => { setSearch(event.target.value) }} />
|
|
202
|
+
<select
|
|
203
|
+
className={css.groupBySelect}
|
|
204
|
+
aria-label={tt('hosts.groupBy.label')}
|
|
205
|
+
value={groupBy}
|
|
206
|
+
onChange={event => { setGroupBy(event.target.value as HostGroupBy) }}
|
|
207
|
+
>
|
|
208
|
+
<option value="none">{tt('hosts.groupBy.none')}</option>
|
|
209
|
+
<option value="environment">{tt('hosts.groupBy.environment')}</option>
|
|
210
|
+
<option value="tags">{tt('hosts.groupBy.tags')}</option>
|
|
211
|
+
</select>
|
|
98
212
|
<div className={css.toolbarSpacer} />
|
|
99
213
|
<button type="button" className={css.primaryButton} onClick={() => { setDialog({ mode: 'create' }) }}>{tt('hosts.add')}</button>
|
|
100
214
|
<button type="button" className={css.ghostButton} disabled={importing} onClick={() => { void importConfig() }}>{importing ? tt('common.loading') : tt('hosts.import')}</button>
|
|
@@ -103,54 +217,44 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
|
|
|
103
217
|
{error !== null && <div className={css.banner} data-kind="error">{tt('common.error', { error })}</div>}
|
|
104
218
|
{hosts === null && error === null && <div className={css.loading}>{tt('common.loading')}</div>}
|
|
105
219
|
{hosts !== null && hosts.length === 0 && <div className={css.empty}>{tt('hosts.empty')}</div>}
|
|
106
|
-
{hosts !== null && hosts.length > 0 && (
|
|
220
|
+
{hosts !== null && hosts.length > 0 && groupBy === 'none' && (
|
|
221
|
+
<div className={css.tableWrap}>
|
|
222
|
+
{renderHostTable(hosts)}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
{hosts !== null && hosts.length > 0 && groupBy !== 'none' && (
|
|
107
226
|
<div className={css.tableWrap}>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
<
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
{test !== undefined && (
|
|
140
|
-
<span className={css.inlineTest} data-status={test.ok ? 'ok' : 'fail'}>
|
|
141
|
-
{test.ok ? tt('hosts.testOk', { latency: test.latencyMs ?? 0 }) : tt('hosts.testFail', { error: test.error ?? '' })}
|
|
142
|
-
</span>
|
|
143
|
-
)}
|
|
144
|
-
<button type="button" className={css.linkButton} onClick={() => { setDialog({ mode: 'edit', host }) }}>{tt('hosts.edit')}</button>
|
|
145
|
-
<button type="button" className={css.linkButton} data-danger onClick={() => { void deleteHost(host.alias) }}>{tt('hosts.delete')}</button>
|
|
146
|
-
<button type="button" className={css.ghostButton} onClick={() => { onConnect(host.alias) }}>{tt('hosts.connected')}</button>
|
|
147
|
-
</div>
|
|
148
|
-
</td>
|
|
149
|
-
</tr>
|
|
150
|
-
)
|
|
151
|
-
})}
|
|
152
|
-
</tbody>
|
|
153
|
-
</table>
|
|
227
|
+
{groups.map(group => {
|
|
228
|
+
const isCollapsed = collapsed[group.key] === true
|
|
229
|
+
const label = group.key === ''
|
|
230
|
+
? (groupBy === 'tags' ? tt('hosts.group.noTags') : tt('hosts.group.ungrouped'))
|
|
231
|
+
: group.key
|
|
232
|
+
return (
|
|
233
|
+
<section key={group.key} className={css.groupSection}>
|
|
234
|
+
<div className={css.groupHeader}>
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
className={css.groupToggle}
|
|
238
|
+
aria-expanded={!isCollapsed}
|
|
239
|
+
onClick={() => { setCollapsed(prev => ({ ...prev, [group.key]: !isCollapsed })) }}
|
|
240
|
+
>
|
|
241
|
+
<span className={css.groupChevron} data-collapsed={isCollapsed || undefined} aria-hidden="true" />
|
|
242
|
+
<span className={css.groupName}>{label}</span>
|
|
243
|
+
<span className={css.groupCount}>{tt('hosts.group.count', { count: group.hosts.length })}</span>
|
|
244
|
+
</button>
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
className={css.linkButton}
|
|
248
|
+
disabled={testingGroup === group.key}
|
|
249
|
+
onClick={() => { void testGroup(group) }}
|
|
250
|
+
>
|
|
251
|
+
{testingGroup === group.key ? tt('hosts.testing') : tt('hosts.group.testAll')}
|
|
252
|
+
</button>
|
|
253
|
+
</div>
|
|
254
|
+
{!isCollapsed && renderHostTable(group.hosts)}
|
|
255
|
+
</section>
|
|
256
|
+
)
|
|
257
|
+
})}
|
|
154
258
|
</div>
|
|
155
259
|
)}
|
|
156
260
|
{dialog !== null && (
|
|
@@ -242,6 +242,81 @@ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol']
|
|
|
242
242
|
color: var(--dsw-alias-label-tertiary);
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
.groupBySelect {
|
|
246
|
+
padding: 6px 8px;
|
|
247
|
+
font-size: 13px;
|
|
248
|
+
color: var(--dsw-alias-label-primary);
|
|
249
|
+
background: var(--dsw-specific-input-major);
|
|
250
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
251
|
+
border-radius: 8px;
|
|
252
|
+
outline: none;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* --- host groups (#379) ------------------------------------------------------- */
|
|
256
|
+
|
|
257
|
+
.groupSection + .groupSection {
|
|
258
|
+
border-top: 1px solid var(--dsw-alias-border-l1);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.groupHeader {
|
|
262
|
+
display: flex;
|
|
263
|
+
align-items: center;
|
|
264
|
+
gap: 8px;
|
|
265
|
+
padding: 4px 8px;
|
|
266
|
+
position: sticky;
|
|
267
|
+
top: 0;
|
|
268
|
+
background: var(--dsw-alias-bg-base, var(--dsw-specific-input-major));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.groupToggle {
|
|
272
|
+
display: flex;
|
|
273
|
+
align-items: center;
|
|
274
|
+
gap: 6px;
|
|
275
|
+
flex: 1;
|
|
276
|
+
min-width: 0;
|
|
277
|
+
padding: 4px 6px;
|
|
278
|
+
border: none;
|
|
279
|
+
background: none;
|
|
280
|
+
color: var(--dsw-alias-label-primary);
|
|
281
|
+
font-size: 12.5px;
|
|
282
|
+
font-weight: 500;
|
|
283
|
+
cursor: pointer;
|
|
284
|
+
text-align: left;
|
|
285
|
+
border-radius: 6px;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.groupToggle:hover {
|
|
289
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.groupChevron {
|
|
293
|
+
width: 7px;
|
|
294
|
+
height: 7px;
|
|
295
|
+
border-right: 1.5px solid var(--dsw-alias-label-tertiary);
|
|
296
|
+
border-bottom: 1.5px solid var(--dsw-alias-label-tertiary);
|
|
297
|
+
transform: rotate(45deg);
|
|
298
|
+
transition: transform 120ms ease;
|
|
299
|
+
flex: none;
|
|
300
|
+
margin-bottom: 2px;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.groupChevron[data-collapsed] {
|
|
304
|
+
transform: rotate(-45deg);
|
|
305
|
+
margin-bottom: 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.groupName {
|
|
309
|
+
overflow: hidden;
|
|
310
|
+
text-overflow: ellipsis;
|
|
311
|
+
white-space: nowrap;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.groupCount {
|
|
315
|
+
color: var(--dsw-alias-label-tertiary);
|
|
316
|
+
font-weight: 400;
|
|
317
|
+
flex: none;
|
|
318
|
+
}
|
|
319
|
+
|
|
245
320
|
/* --- tables ------------------------------------------------------------------- */
|
|
246
321
|
|
|
247
322
|
.tableWrap {
|
|
@@ -213,8 +213,13 @@ export function disposeRecord(engine: PoolEngine, alias: string, record?: PoolRe
|
|
|
213
213
|
if (record !== undefined && current !== record) return
|
|
214
214
|
if (current === undefined) return
|
|
215
215
|
engine.pool.delete(alias)
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
endRecordChain(current)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** End one record's client and hop chain (best-effort, safe to repeat). */
|
|
220
|
+
export function endRecordChain(record: PoolRecord): void {
|
|
221
|
+
try { record.client.end() } catch { /* already closed */ }
|
|
222
|
+
for (const hop of record.hops) {
|
|
218
223
|
try { hop.end() } catch { /* already closed */ }
|
|
219
224
|
}
|
|
220
225
|
}
|
|
@@ -247,6 +252,14 @@ export async function withClient<T>(engine: PoolEngine, alias: string, fn: (clie
|
|
|
247
252
|
const result = await fn(record.client)
|
|
248
253
|
record.idleAt = Date.now()
|
|
249
254
|
return result
|
|
255
|
+
} catch (error) {
|
|
256
|
+
lastError = error
|
|
257
|
+
// Retry only when the connection actually broke mid-flight: drop the
|
|
258
|
+
// corpse and let the next attempt reconnect (a reconnect may replay a
|
|
259
|
+
// non-idempotent command — the documented trade-off). A failure on a
|
|
260
|
+
// healthy connection is a logic error and is rethrown, not replayed.
|
|
261
|
+
if (!record.broken) throw error
|
|
262
|
+
disposeRecord(engine, alias, record)
|
|
250
263
|
} finally {
|
|
251
264
|
record.inFlight -= 1
|
|
252
265
|
}
|
|
@@ -297,8 +310,15 @@ export async function execCommand(engine: PoolEngine, alias: string, command: st
|
|
|
297
310
|
if (settled) return
|
|
298
311
|
settled = true
|
|
299
312
|
clearTimeout(timer)
|
|
313
|
+
if (typeof code !== 'number' && !timedOut) {
|
|
314
|
+
// The channel closed without an exit status: the connection
|
|
315
|
+
// dropped mid-flight. Reject so withClient can reconnect and
|
|
316
|
+
// retry within the attempt budget.
|
|
317
|
+
reject(new Error('ssh: connection lost mid-flight (channel closed without an exit status)'))
|
|
318
|
+
return
|
|
319
|
+
}
|
|
300
320
|
resolve({
|
|
301
|
-
success: code === 0
|
|
321
|
+
success: code === 0,
|
|
302
322
|
exitCode: code,
|
|
303
323
|
timedOut,
|
|
304
324
|
stdout: stdout.text,
|
package/src/engine/sftp.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* released exactly once so sshd's MaxSessions cap is never exhausted.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
|
|
7
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, statSync } from 'node:fs'
|
|
8
8
|
import { dirname, join, relative, resolve as resolvePath } from 'node:path'
|
|
9
9
|
import { Client, type SFTPWrapper } from 'ssh2'
|
|
10
10
|
import type { RemoteDirEntry, TransferProgress } from '../protocol.ts'
|
|
@@ -16,7 +16,11 @@ export function walkLocalDir(root: string): string[] {
|
|
|
16
16
|
const visit = (dir: string): void => {
|
|
17
17
|
for (const name of readdirSync(dir)) {
|
|
18
18
|
const full = join(dir, name)
|
|
19
|
-
|
|
19
|
+
// lstat, not stat: a symlink must never be followed — a link cycle
|
|
20
|
+
// (ln -s . self) recurses forever under stat, and a link to a file
|
|
21
|
+
// would silently upload the target's bytes.
|
|
22
|
+
const stat = lstatSync(full)
|
|
23
|
+
if (stat.isSymbolicLink()) continue
|
|
20
24
|
if (stat.isDirectory()) visit(full)
|
|
21
25
|
else if (stat.isFile()) files.push(relative(root, full))
|
|
22
26
|
}
|
|
@@ -169,13 +173,20 @@ function ensureRemoteDir(sftp: SFTPWrapper, remote: string): Promise<void> {
|
|
|
169
173
|
})
|
|
170
174
|
}
|
|
171
175
|
|
|
172
|
-
|
|
176
|
+
/** One fastPut/fastGet transfer with throttled progress (the two directions share everything but the verb). */
|
|
177
|
+
function fastTransfer(sftp: SFTPWrapper, kind: 'put' | 'get', src: string, dst: string, concurrency: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
173
178
|
return new Promise((resolve, reject) => {
|
|
179
|
+
// Progress frames name the destination on upload, the source on download;
|
|
180
|
+
// the final size comes from the local side of the transfer.
|
|
181
|
+
const file = kind === 'put' ? dst : src
|
|
182
|
+
const finalSize = (): number => statSync(kind === 'put' ? src : dst).size
|
|
174
183
|
let last = 0
|
|
175
184
|
let lastEmit = 0
|
|
176
185
|
const started = Date.now()
|
|
177
|
-
|
|
178
|
-
|
|
186
|
+
if (kind === 'put') {
|
|
187
|
+
onProgress?.({ phase: 'transferring', file, transferred: 0, total: statSync(src).size, percent: 0 })
|
|
188
|
+
}
|
|
189
|
+
const step = (transferred: number, _chunk: number, total: number): void => {
|
|
179
190
|
const now = Date.now()
|
|
180
191
|
// Throttle: high-speed links fire one callback per chunk; the UI only
|
|
181
192
|
// needs ~10 frames per second.
|
|
@@ -184,52 +195,32 @@ function fastPut(sftp: SFTPWrapper, src: string, dst: string, concurrency: numbe
|
|
|
184
195
|
const elapsed = (now - started) / 1000
|
|
185
196
|
onProgress?.({
|
|
186
197
|
phase: 'transferring',
|
|
187
|
-
file
|
|
198
|
+
file,
|
|
188
199
|
transferred,
|
|
189
200
|
total,
|
|
190
201
|
percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
|
|
191
202
|
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
|
|
192
203
|
})
|
|
193
204
|
last = transferred
|
|
194
|
-
}
|
|
205
|
+
}
|
|
206
|
+
const done = (error: unknown): void => {
|
|
195
207
|
if (error !== undefined) {
|
|
196
|
-
onProgress?.({ phase: 'error', file
|
|
208
|
+
onProgress?.({ phase: 'error', file, transferred: 0, total: 0, percent: 0, error: String(error) })
|
|
197
209
|
reject(error)
|
|
198
210
|
} else {
|
|
199
|
-
onProgress?.({ phase: 'done', file
|
|
211
|
+
onProgress?.({ phase: 'done', file, transferred: finalSize(), total: finalSize(), percent: 100 })
|
|
200
212
|
resolve()
|
|
201
213
|
}
|
|
202
|
-
}
|
|
214
|
+
}
|
|
215
|
+
if (kind === 'put') sftp.fastPut(src, dst, { concurrency, step }, done)
|
|
216
|
+
else sftp.fastGet(src, dst, { concurrency, step }, done)
|
|
203
217
|
})
|
|
204
218
|
}
|
|
205
219
|
|
|
220
|
+
function fastPut(sftp: SFTPWrapper, src: string, dst: string, concurrency: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
221
|
+
return fastTransfer(sftp, 'put', src, dst, concurrency, onProgress)
|
|
222
|
+
}
|
|
223
|
+
|
|
206
224
|
function fastGet(sftp: SFTPWrapper, src: string, dst: string, concurrency: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
207
|
-
return
|
|
208
|
-
let last = 0
|
|
209
|
-
let lastEmit = 0
|
|
210
|
-
const started = Date.now()
|
|
211
|
-
sftp.fastGet(src, dst, { concurrency, step: (transferred: number, _chunk: number, total: number) => {
|
|
212
|
-
const now = Date.now()
|
|
213
|
-
if (now - lastEmit < 100 && transferred < total) return
|
|
214
|
-
lastEmit = now
|
|
215
|
-
const elapsed = (now - started) / 1000
|
|
216
|
-
onProgress?.({
|
|
217
|
-
phase: 'transferring',
|
|
218
|
-
file: src,
|
|
219
|
-
transferred,
|
|
220
|
-
total,
|
|
221
|
-
percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
|
|
222
|
-
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
|
|
223
|
-
})
|
|
224
|
-
last = transferred
|
|
225
|
-
} }, (error) => {
|
|
226
|
-
if (error !== undefined) {
|
|
227
|
-
onProgress?.({ phase: 'error', file: src, transferred: 0, total: 0, percent: 0, error: String(error) })
|
|
228
|
-
reject(error)
|
|
229
|
-
} else {
|
|
230
|
-
onProgress?.({ phase: 'done', file: src, transferred: statSync(dst).size, total: statSync(dst).size, percent: 100 })
|
|
231
|
-
resolve()
|
|
232
|
-
}
|
|
233
|
-
})
|
|
234
|
-
})
|
|
225
|
+
return fastTransfer(sftp, 'get', src, dst, concurrency, onProgress)
|
|
235
226
|
}
|
package/src/engine/tunnel.ts
CHANGED
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
import { createServer, type Server as NetServer, type Socket } from 'node:net'
|
|
8
8
|
import type { TunnelInfo } from '../protocol.ts'
|
|
9
|
-
import { acquire, disposeRecord, type PoolEngine } from './connection-pool.ts'
|
|
9
|
+
import { acquire, disposeRecord, endRecordChain, type PoolEngine, type PoolRecord } from './connection-pool.ts'
|
|
10
10
|
|
|
11
|
-
/** One active tunnel record (server + pinned
|
|
11
|
+
/** One active tunnel record (server + pinned connection + live sockets). */
|
|
12
12
|
export interface TunnelRecord {
|
|
13
13
|
info: TunnelInfo
|
|
14
14
|
server: NetServer
|
|
15
15
|
alias: string
|
|
16
|
+
/** The pooled connection this tunnel pins; siblings on one alias may share it. */
|
|
17
|
+
record: PoolRecord
|
|
16
18
|
sockets: Set<Socket>
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -48,7 +50,12 @@ export async function startTunnel(
|
|
|
48
50
|
state: 'connecting',
|
|
49
51
|
startedAt: Date.now(),
|
|
50
52
|
}
|
|
51
|
-
|
|
53
|
+
// Reuse the live pooled connection when one exists so sibling tunnels on
|
|
54
|
+
// one alias multiplex over it (and no orphaned connection leaks); recycle
|
|
55
|
+
// a broken one first.
|
|
56
|
+
const existing = engine.pool.get(alias)
|
|
57
|
+
if (existing !== undefined && existing.broken) disposeRecord(engine, alias, existing)
|
|
58
|
+
const record = engine.pool.get(alias) ?? (await acquire(engine, alias))
|
|
52
59
|
const client = record.client
|
|
53
60
|
const sockets = new Set<Socket>()
|
|
54
61
|
const server = createServer((socket) => {
|
|
@@ -89,7 +96,7 @@ export async function startTunnel(
|
|
|
89
96
|
const address = server.address()
|
|
90
97
|
info.localPort = typeof address === 'object' && address !== null ? address.port : 0
|
|
91
98
|
info.state = 'forwarding'
|
|
92
|
-
engine.tunnels.set(id, { info, server, alias, sockets })
|
|
99
|
+
engine.tunnels.set(id, { info, server, alias, record, sockets })
|
|
93
100
|
return info
|
|
94
101
|
}
|
|
95
102
|
|
|
@@ -108,7 +115,14 @@ export function stopTunnel(engine: TunnelEngine, id: string): boolean {
|
|
|
108
115
|
try { socket.destroy() } catch { /* already closed */ }
|
|
109
116
|
}
|
|
110
117
|
tunnel.sockets.clear()
|
|
111
|
-
|
|
118
|
+
// Sibling tunnels on the same alias share the pinned connection: release it
|
|
119
|
+
// only once the last tunnel using it is gone. A record the pool has since
|
|
120
|
+
// replaced (e.g. after a broken-connection recycle) is ended directly.
|
|
121
|
+
const shared = [...engine.tunnels.values()].some(candidate => candidate.record === tunnel.record)
|
|
122
|
+
if (!shared) {
|
|
123
|
+
if (engine.pool.get(tunnel.alias) === tunnel.record) disposeRecord(engine, tunnel.alias, tunnel.record)
|
|
124
|
+
else endRecordChain(tunnel.record)
|
|
125
|
+
}
|
|
112
126
|
return true
|
|
113
127
|
}
|
|
114
128
|
|
package/src/engine.ts
CHANGED
|
@@ -135,6 +135,17 @@ export class SshEngine {
|
|
|
135
135
|
return stopAllTunnels(this, alias)
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Drop every live artifact bound to one alias: stop its tunnels and close
|
|
140
|
+
* the pooled connection. Host entries that are deleted or whose connection
|
|
141
|
+
* fields change must never keep serving a stale, previously authenticated
|
|
142
|
+
* connection — the next operation re-connects from the current config.
|
|
143
|
+
*/
|
|
144
|
+
dropAlias(alias: string): void {
|
|
145
|
+
stopAllTunnels(this, alias)
|
|
146
|
+
disposeRecord(this, alias)
|
|
147
|
+
}
|
|
148
|
+
|
|
138
149
|
// ------------------------------------------------------------- misc
|
|
139
150
|
|
|
140
151
|
/** Probe connectivity: connect, run `true`, close. */
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { SshEngine } from './engine.ts'
|
|
|
18
18
|
import { makeRoutes } from './routes.ts'
|
|
19
19
|
import { HostStore } from './store.ts'
|
|
20
20
|
import { sshClusterTool, sshDownloadTool, sshExecTool, sshListTool, sshTunnelTool, sshUploadTool } from './tools.ts'
|
|
21
|
+
import { mountOnce } from './mount-once.ts'
|
|
21
22
|
|
|
22
23
|
/** Stable cordis plugin name. */
|
|
23
24
|
export const name = 'ssh'
|
|
@@ -62,7 +63,9 @@ export const SSH_GUIDANCE = '本机已安装 dsh-ssh 插件(DSH 远程 SSH 运
|
|
|
62
63
|
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|
|
63
64
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
64
65
|
*/
|
|
65
|
-
export
|
|
66
|
+
export const apply = mountOnce('@linxin666/dsh-ssh', applyImpl)
|
|
67
|
+
|
|
68
|
+
function applyImpl(ctx: Context, config?: Config): void {
|
|
66
69
|
// The live source the surfaces read: the settings section once the web
|
|
67
70
|
// settings surface is served, the composition entry otherwise.
|
|
68
71
|
let current: () => Config = () => config ?? {}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Host single-instance guard shared by the plugin family. The family bundle
|
|
4
|
+
* (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
5
|
+
* the loader accepts a standalone install of the same package side by side;
|
|
6
|
+
* without this guard the second instance would still re-register the same
|
|
7
|
+
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
8
|
+
* and fail the boot. mountOnce makes the second host apply a no-op for the
|
|
9
|
+
* lifetime of the first instance (the browser half is already deduped by
|
|
10
|
+
* package name in the client module host).
|
|
11
|
+
*
|
|
12
|
+
* The registry rides a global symbol so two module instances of the same
|
|
13
|
+
* package (npm copy vs repository link) still share one verdict. cordis
|
|
14
|
+
* `ctx.effect` runs its callback immediately and treats the callback's
|
|
15
|
+
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MOUNTED = Symbol.for('dsh-web-ui.mounted-plugins')
|
|
19
|
+
|
|
20
|
+
interface MountRegistry {
|
|
21
|
+
[MOUNTED]?: Set<string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mountedSet(): Set<string> {
|
|
25
|
+
const registry = globalThis as MountRegistry
|
|
26
|
+
return (registry[MOUNTED] ??= new Set())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
31
|
+
* The first mount registers normally and unmarks when its fiber disposes;
|
|
32
|
+
* any later mount of the same package name is a no-op.
|
|
33
|
+
* @param packageName - npm package identity shared by every install source.
|
|
34
|
+
* @param fn - the original plugin apply.
|
|
35
|
+
* @returns an apply of the same shape.
|
|
36
|
+
*/
|
|
37
|
+
export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
|
|
38
|
+
return ((...args: unknown[]) => {
|
|
39
|
+
const mounted = mountedSet()
|
|
40
|
+
if (mounted.has(packageName)) return
|
|
41
|
+
mounted.add(packageName)
|
|
42
|
+
const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
|
|
43
|
+
ctx?.effect?.(() => () => {
|
|
44
|
+
mounted.delete(packageName)
|
|
45
|
+
})
|
|
46
|
+
return fn(...args)
|
|
47
|
+
}) as T
|
|
48
|
+
}
|