@linxin666/dsh-ssh 0.1.20 → 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 +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +224 -93
- package/lib/client.js.map +1 -1
- package/lib/index.js +25 -11
- package/lib/types/client/locales.d.ts +135 -127
- package/lib/types/client/panel/HostsTab.d.ts +16 -0
- package/lib/types/protocol.d.ts +1 -1
- 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/routes.ts +25 -5
|
@@ -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 {
|
package/src/routes.ts
CHANGED
|
@@ -361,9 +361,22 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
|
361
361
|
if (settled) return
|
|
362
362
|
settled = true
|
|
363
363
|
emit({ type: 'result', ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
364
|
+
// End the response only after the tmp file is gone, and unlink only
|
|
365
|
+
// after the sink is fully closed: destroying a WriteStream whose
|
|
366
|
+
// fs.open is still pending lets the open RE-CREATE the file after
|
|
367
|
+
// an early unlink, leaving staging populated (this raced the
|
|
368
|
+
// response end and made the byte-cap test flaky).
|
|
369
|
+
const cleanup = (): void => {
|
|
370
|
+
void unlink(tmp).catch(() => undefined).finally(() => {
|
|
371
|
+
try { res.end() } catch { /* closed */ }
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
if (sink.destroyed) {
|
|
375
|
+
cleanup()
|
|
376
|
+
} else {
|
|
377
|
+
sink.once('close', cleanup)
|
|
378
|
+
try { sink.destroy() } catch { cleanup() }
|
|
379
|
+
}
|
|
367
380
|
}
|
|
368
381
|
const done = (): void => {
|
|
369
382
|
if (settled) return
|
|
@@ -379,11 +392,18 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
|
|
|
379
392
|
// header-less requests: count the bytes as they actually arrive and
|
|
380
393
|
// abort the moment the cap is exceeded.
|
|
381
394
|
let received = 0
|
|
395
|
+
let capped = false
|
|
382
396
|
req.on('data', (chunk: Buffer) => {
|
|
383
397
|
received += chunk.byteLength
|
|
384
|
-
if (received > maxUploadBytes) {
|
|
398
|
+
if (received > maxUploadBytes && !capped) {
|
|
399
|
+
capped = true
|
|
385
400
|
fail('upload body too large')
|
|
386
|
-
|
|
401
|
+
// Keep the socket alive until the response flush finishes:
|
|
402
|
+
// destroying the request here races res.end() and the client
|
|
403
|
+
// sees a hang-up instead of the result frame. Drain the rest of
|
|
404
|
+
// the body so the socket can close cleanly afterwards.
|
|
405
|
+
res.on('finish', () => { try { req.destroy() } catch { /* closed */ } })
|
|
406
|
+
req.resume()
|
|
387
407
|
}
|
|
388
408
|
})
|
|
389
409
|
req.pipe(sink)
|