@linxin666/dsh-ssh 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +15638 -0
- package/lib/index.js +3297 -0
- package/lib/invariant.js +6 -0
- package/lib/types/client/api.d.ts +72 -0
- package/lib/types/client/index.d.ts +37 -0
- package/lib/types/client/locales.d.ts +137 -0
- package/lib/types/client/mount.d.ts +12 -0
- package/lib/types/client/panel/ClusterTab.d.ts +7 -0
- package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
- package/lib/types/client/panel/HostsTab.d.ts +9 -0
- package/lib/types/client/panel/SshPanel.d.ts +13 -0
- package/lib/types/client/panel/TerminalTab.d.ts +11 -0
- package/lib/types/client/panel/TransferTab.d.ts +7 -0
- package/lib/types/client/panel/TunnelsTab.d.ts +7 -0
- package/lib/types/client/panel/controller.d.ts +22 -0
- package/lib/types/client/panel/helpers.d.ts +14 -0
- package/lib/types/client/panel/xterm.css.d.ts +2 -0
- package/lib/types/client/sidebar-entry.d.ts +24 -0
- package/lib/types/engine.d.ts +135 -0
- package/lib/types/index.d.ts +40 -0
- package/lib/types/invariant.d.ts +3 -0
- package/lib/types/protocol.d.ts +189 -0
- package/lib/types/routes.d.ts +29 -0
- package/lib/types/store.d.ts +54 -0
- package/lib/types/tools.d.ts +18 -0
- package/package.json +106 -0
- package/src/client/api.ts +363 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +68 -0
- package/src/client/locales.ts +284 -0
- package/src/client/mount.tsx +83 -0
- package/src/client/panel/ClusterTab.tsx +123 -0
- package/src/client/panel/HostFormDialog.tsx +221 -0
- package/src/client/panel/HostsTab.tsx +166 -0
- package/src/client/panel/SshPanel.tsx +77 -0
- package/src/client/panel/TerminalTab.tsx +176 -0
- package/src/client/panel/TransferTab.tsx +232 -0
- package/src/client/panel/TunnelsTab.tsx +177 -0
- package/src/client/panel/controller.ts +48 -0
- package/src/client/panel/helpers.ts +26 -0
- package/src/client/panel/panel.module.css +833 -0
- package/src/client/panel/xterm.css.ts +2 -0
- package/src/client/sidebar-entry.ts +123 -0
- package/src/engine.ts +780 -0
- package/src/index.ts +155 -0
- package/src/invariant.ts +4 -0
- package/src/protocol.ts +188 -0
- package/src/routes.ts +540 -0
- package/src/store.ts +344 -0
- package/src/tools.ts +371 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host form dialog: create/edit one stored host entry. A fixed backdrop with
|
|
3
|
+
* a centered card; Escape and backdrop clicks close it. Secrets (key path,
|
|
4
|
+
* passphrase, password) are never pre-filled when editing (the API only
|
|
5
|
+
* exposes the secret-free summary).
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useState } from 'react'
|
|
8
|
+
import type { SshApi } from '../api.ts'
|
|
9
|
+
import type { HostPayload, SshAuthKind, SshHostSummary } from '../../protocol.ts'
|
|
10
|
+
import { errorMessage, tt } from './helpers.ts'
|
|
11
|
+
import css from './panel.module.css'
|
|
12
|
+
|
|
13
|
+
/** Host form dialog props. */
|
|
14
|
+
export interface HostFormDialogProps {
|
|
15
|
+
api: SshApi
|
|
16
|
+
/** The host being edited; null/undefined means create. */
|
|
17
|
+
editing?: SshHostSummary | null
|
|
18
|
+
onClose: () => void
|
|
19
|
+
onSaved: (host: SshHostSummary) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The staged form values (all strings; parsed on save). */
|
|
23
|
+
interface FormState {
|
|
24
|
+
alias: string
|
|
25
|
+
host: string
|
|
26
|
+
port: string
|
|
27
|
+
user: string
|
|
28
|
+
authKind: SshAuthKind
|
|
29
|
+
keyPath: string
|
|
30
|
+
passphrase: string
|
|
31
|
+
password: string
|
|
32
|
+
proxyJump: string
|
|
33
|
+
description: string
|
|
34
|
+
environment: string
|
|
35
|
+
tags: string
|
|
36
|
+
location: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Split a comma-separated input into a trimmed, non-empty string list. */
|
|
40
|
+
function splitList(text: string): string[] {
|
|
41
|
+
return text.split(',').map(part => part.trim()).filter(part => part !== '')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Initial form state: the summary's public fields plus empty secrets. */
|
|
45
|
+
function blankOf(editing: SshHostSummary | null | undefined): FormState {
|
|
46
|
+
return {
|
|
47
|
+
alias: editing?.alias ?? '',
|
|
48
|
+
host: editing?.host ?? '',
|
|
49
|
+
port: String(editing?.port ?? 22),
|
|
50
|
+
user: editing?.user ?? '',
|
|
51
|
+
authKind: editing?.auth ?? 'key',
|
|
52
|
+
keyPath: '',
|
|
53
|
+
passphrase: '',
|
|
54
|
+
password: '',
|
|
55
|
+
proxyJump: (editing?.proxyJump ?? []).join(', '),
|
|
56
|
+
description: editing?.description ?? '',
|
|
57
|
+
environment: editing?.environment ?? '',
|
|
58
|
+
tags: (editing?.tags ?? []).join(', '),
|
|
59
|
+
location: editing?.location ?? '',
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The create/edit host modal. */
|
|
64
|
+
export function HostFormDialog({ api, editing, onClose, onSaved }: HostFormDialogProps) {
|
|
65
|
+
const [form, setForm] = useState<FormState>(() => blankOf(editing))
|
|
66
|
+
const [saving, setSaving] = useState(false)
|
|
67
|
+
const [error, setError] = useState<string | null>(null)
|
|
68
|
+
|
|
69
|
+
// Escape closes the dialog.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
72
|
+
if (event.key === 'Escape') onClose()
|
|
73
|
+
}
|
|
74
|
+
document.addEventListener('keydown', onKey)
|
|
75
|
+
return () => { document.removeEventListener('keydown', onKey) }
|
|
76
|
+
}, [onClose])
|
|
77
|
+
|
|
78
|
+
const set = <K extends keyof FormState>(key: K, value: FormState[K]): void => {
|
|
79
|
+
setForm(prev => ({ ...prev, [key]: value }))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const save = async (): Promise<void> => {
|
|
83
|
+
const alias = form.alias.trim()
|
|
84
|
+
const host = form.host.trim()
|
|
85
|
+
const user = form.user.trim()
|
|
86
|
+
if (alias === '' || host === '' || user === '') {
|
|
87
|
+
setError(tt('form.required'))
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
const port = Number(form.port)
|
|
91
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
92
|
+
setError(tt('form.portInvalid'))
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
// Secrets are never echoed back by the API: when editing and the secret
|
|
96
|
+
// fields are left empty, the auth block is omitted so the stored
|
|
97
|
+
// authentication is preserved.
|
|
98
|
+
const secretEmpty = form.authKind === 'password'
|
|
99
|
+
? form.password === ''
|
|
100
|
+
: form.keyPath.trim() === ''
|
|
101
|
+
const auth: HostPayload['auth'] = editing != null && secretEmpty
|
|
102
|
+
? undefined
|
|
103
|
+
: form.authKind === 'password'
|
|
104
|
+
? { kind: 'password', password: form.password }
|
|
105
|
+
: { kind: 'key', keyPath: form.keyPath.trim(), passphrase: form.passphrase === '' ? undefined : form.passphrase }
|
|
106
|
+
const payload: HostPayload = {
|
|
107
|
+
host,
|
|
108
|
+
port,
|
|
109
|
+
user,
|
|
110
|
+
auth,
|
|
111
|
+
proxyJump: splitList(form.proxyJump),
|
|
112
|
+
description: form.description.trim() === '' ? undefined : form.description.trim(),
|
|
113
|
+
environment: form.environment.trim() === '' ? undefined : form.environment.trim(),
|
|
114
|
+
tags: splitList(form.tags),
|
|
115
|
+
location: form.location.trim() === '' ? undefined : form.location.trim(),
|
|
116
|
+
}
|
|
117
|
+
setSaving(true)
|
|
118
|
+
setError(null)
|
|
119
|
+
try {
|
|
120
|
+
const saved = editing != null
|
|
121
|
+
? await api.updateHost(editing.alias, payload)
|
|
122
|
+
: await api.createHost({ ...payload, alias })
|
|
123
|
+
onSaved(saved)
|
|
124
|
+
} catch (cause) {
|
|
125
|
+
setError(errorMessage(cause))
|
|
126
|
+
setSaving(false)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className={css.modalBackdrop} onClick={onClose}>
|
|
132
|
+
<div className={css.modal} role="dialog" aria-modal="true" onClick={event => { event.stopPropagation() }}>
|
|
133
|
+
<h3 className={css.modalTitle}>{editing != null ? tt('form.title.edit', { alias: editing.alias }) : tt('form.title.create')}</h3>
|
|
134
|
+
<div className={css.formRow}>
|
|
135
|
+
<label className={css.field}>
|
|
136
|
+
<span className={css.fieldLabel}>{tt('form.alias')}</span>
|
|
137
|
+
<input className={css.input} value={form.alias} disabled={editing != null} onChange={event => { set('alias', event.target.value) }} />
|
|
138
|
+
<span className={css.hint}>{tt('form.aliasHint')}</span>
|
|
139
|
+
</label>
|
|
140
|
+
<label className={css.field}>
|
|
141
|
+
<span className={css.fieldLabel}>{tt('form.host')}</span>
|
|
142
|
+
<input className={css.input} value={form.host} onChange={event => { set('host', event.target.value) }} />
|
|
143
|
+
</label>
|
|
144
|
+
</div>
|
|
145
|
+
<div className={css.formRow}>
|
|
146
|
+
<label className={css.field}>
|
|
147
|
+
<span className={css.fieldLabel}>{tt('form.port')}</span>
|
|
148
|
+
<input className={css.input} type="number" min={1} max={65535} value={form.port} onChange={event => { set('port', event.target.value) }} />
|
|
149
|
+
</label>
|
|
150
|
+
<label className={css.field}>
|
|
151
|
+
<span className={css.fieldLabel}>{tt('form.user')}</span>
|
|
152
|
+
<input className={css.input} value={form.user} onChange={event => { set('user', event.target.value) }} />
|
|
153
|
+
</label>
|
|
154
|
+
</div>
|
|
155
|
+
<div className={css.field}>
|
|
156
|
+
<span className={css.fieldLabel}>{tt('form.auth')}</span>
|
|
157
|
+
<div className={css.radioRow}>
|
|
158
|
+
<label className={css.radioLabel}>
|
|
159
|
+
<input type="radio" name="dsh-ssh-auth" checked={form.authKind === 'key'} onChange={() => { set('authKind', 'key') }} />
|
|
160
|
+
{tt('form.auth.key')}
|
|
161
|
+
</label>
|
|
162
|
+
<label className={css.radioLabel}>
|
|
163
|
+
<input type="radio" name="dsh-ssh-auth" checked={form.authKind === 'password'} onChange={() => { set('authKind', 'password') }} />
|
|
164
|
+
{tt('form.auth.password')}
|
|
165
|
+
</label>
|
|
166
|
+
</div>
|
|
167
|
+
{editing != null && <span className={css.hint}>{tt('form.authKeepHint')}</span>}
|
|
168
|
+
</div>
|
|
169
|
+
{form.authKind === 'key' ? (
|
|
170
|
+
<div className={css.formRow}>
|
|
171
|
+
<label className={css.field}>
|
|
172
|
+
<span className={css.fieldLabel}>{tt('form.keyPath')}</span>
|
|
173
|
+
<input className={css.input} value={form.keyPath} onChange={event => { set('keyPath', event.target.value) }} />
|
|
174
|
+
<span className={css.hint}>{tt('form.keyPathHint')}</span>
|
|
175
|
+
</label>
|
|
176
|
+
<label className={css.field}>
|
|
177
|
+
<span className={css.fieldLabel}>{tt('form.passphrase')}</span>
|
|
178
|
+
<input className={css.input} type="password" value={form.passphrase} onChange={event => { set('passphrase', event.target.value) }} />
|
|
179
|
+
</label>
|
|
180
|
+
</div>
|
|
181
|
+
) : (
|
|
182
|
+
<label className={css.field}>
|
|
183
|
+
<span className={css.fieldLabel}>{tt('form.password')}</span>
|
|
184
|
+
<input className={css.input} type="password" value={form.password} onChange={event => { set('password', event.target.value) }} />
|
|
185
|
+
<span className={css.hint}>{tt('form.passwordHint')}</span>
|
|
186
|
+
</label>
|
|
187
|
+
)}
|
|
188
|
+
<label className={css.field}>
|
|
189
|
+
<span className={css.fieldLabel}>{tt('form.proxyJump')}</span>
|
|
190
|
+
<input className={css.input} value={form.proxyJump} onChange={event => { set('proxyJump', event.target.value) }} />
|
|
191
|
+
<span className={css.hint}>{tt('form.proxyJumpHint')}</span>
|
|
192
|
+
</label>
|
|
193
|
+
<div className={css.formRow}>
|
|
194
|
+
<label className={css.field}>
|
|
195
|
+
<span className={css.fieldLabel}>{tt('form.environment')}</span>
|
|
196
|
+
<input className={css.input} value={form.environment} onChange={event => { set('environment', event.target.value) }} />
|
|
197
|
+
<span className={css.hint}>{tt('form.environmentHint')}</span>
|
|
198
|
+
</label>
|
|
199
|
+
<label className={css.field}>
|
|
200
|
+
<span className={css.fieldLabel}>{tt('form.location')}</span>
|
|
201
|
+
<input className={css.input} value={form.location} onChange={event => { set('location', event.target.value) }} />
|
|
202
|
+
</label>
|
|
203
|
+
</div>
|
|
204
|
+
<label className={css.field}>
|
|
205
|
+
<span className={css.fieldLabel}>{tt('form.description')}</span>
|
|
206
|
+
<input className={css.input} value={form.description} onChange={event => { set('description', event.target.value) }} />
|
|
207
|
+
</label>
|
|
208
|
+
<label className={css.field}>
|
|
209
|
+
<span className={css.fieldLabel}>{tt('form.tags')}</span>
|
|
210
|
+
<input className={css.input} value={form.tags} onChange={event => { set('tags', event.target.value) }} />
|
|
211
|
+
<span className={css.hint}>{tt('form.tagsHint')}</span>
|
|
212
|
+
</label>
|
|
213
|
+
{error !== null && <p className={css.formError}>{tt('common.error', { error })}</p>}
|
|
214
|
+
<div className={css.modalFooter}>
|
|
215
|
+
<button type="button" className={css.ghostButton} disabled={saving} onClick={onClose}>{tt('form.cancel')}</button>
|
|
216
|
+
<button type="button" className={css.primaryButton} disabled={saving} onClick={() => { void save() }}>{tt('form.save')}</button>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosts tab: the host table with search (debounced through listHosts),
|
|
3
|
+
* add/edit/delete/test actions, ~/.ssh/config import, and a connect action
|
|
4
|
+
* that hands the alias to the terminal tab via onConnect.
|
|
5
|
+
*/
|
|
6
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
7
|
+
import type { SshApi } from '../api.ts'
|
|
8
|
+
import type { SshHostSummary, TestResult } from '../../protocol.ts'
|
|
9
|
+
import { errorMessage, tt } from './helpers.ts'
|
|
10
|
+
import { HostFormDialog } from './HostFormDialog.tsx'
|
|
11
|
+
import css from './panel.module.css'
|
|
12
|
+
|
|
13
|
+
/** Hosts tab props. */
|
|
14
|
+
export interface HostsTabProps {
|
|
15
|
+
api: SshApi
|
|
16
|
+
/** Connect the given alias in the terminal tab. */
|
|
17
|
+
onConnect: (alias: string) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The host-form dialog invocation. */
|
|
21
|
+
type DialogState = { mode: 'create' } | { mode: 'edit'; host: SshHostSummary }
|
|
22
|
+
|
|
23
|
+
/** The hosts table plus its toolbar and dialogs. */
|
|
24
|
+
export function HostsTab({ api, onConnect }: HostsTabProps) {
|
|
25
|
+
const [hosts, setHosts] = useState<SshHostSummary[] | null>(null)
|
|
26
|
+
const [error, setError] = useState<string | null>(null)
|
|
27
|
+
const [search, setSearch] = useState('')
|
|
28
|
+
const [testingAlias, setTestingAlias] = useState<string | null>(null)
|
|
29
|
+
const [testResults, setTestResults] = useState<Record<string, TestResult>>({})
|
|
30
|
+
const [importing, setImporting] = useState(false)
|
|
31
|
+
const [notice, setNotice] = useState<string | null>(null)
|
|
32
|
+
const [dialog, setDialog] = useState<DialogState | null>(null)
|
|
33
|
+
const seqRef = useRef(0)
|
|
34
|
+
|
|
35
|
+
const load = useCallback(async (query?: string): Promise<void> => {
|
|
36
|
+
const seq = ++seqRef.current
|
|
37
|
+
try {
|
|
38
|
+
const list = await api.listHosts(query)
|
|
39
|
+
if (seq !== seqRef.current) return
|
|
40
|
+
setHosts(list)
|
|
41
|
+
setError(null)
|
|
42
|
+
} catch (cause) {
|
|
43
|
+
if (seq !== seqRef.current) return
|
|
44
|
+
setError(errorMessage(cause))
|
|
45
|
+
}
|
|
46
|
+
}, [api])
|
|
47
|
+
|
|
48
|
+
useEffect(() => { void load() }, [load])
|
|
49
|
+
|
|
50
|
+
// Debounced search: every keystroke re-filters through the API.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
const query = search.trim()
|
|
54
|
+
void load(query === '' ? undefined : query)
|
|
55
|
+
}, 300)
|
|
56
|
+
return () => { clearTimeout(timer) }
|
|
57
|
+
}, [search, load])
|
|
58
|
+
|
|
59
|
+
const runTest = async (alias: string): Promise<void> => {
|
|
60
|
+
setTestingAlias(alias)
|
|
61
|
+
try {
|
|
62
|
+
const result = await api.testHost(alias)
|
|
63
|
+
setTestResults(prev => ({ ...prev, [alias]: result }))
|
|
64
|
+
} catch (cause) {
|
|
65
|
+
setTestResults(prev => ({ ...prev, [alias]: { ok: false, error: errorMessage(cause) } }))
|
|
66
|
+
} finally {
|
|
67
|
+
setTestingAlias(null)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const deleteHost = async (alias: string): Promise<void> => {
|
|
72
|
+
if (!window.confirm(tt('hosts.deleteConfirm', { alias }))) return
|
|
73
|
+
try {
|
|
74
|
+
await api.deleteHost(alias)
|
|
75
|
+
void load()
|
|
76
|
+
} catch (cause) {
|
|
77
|
+
setError(errorMessage(cause))
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const importConfig = async (): Promise<void> => {
|
|
82
|
+
setImporting(true)
|
|
83
|
+
try {
|
|
84
|
+
const result = await api.importSshConfig()
|
|
85
|
+
setNotice(tt('hosts.imported', { parsed: result.parsed, added: result.added, skipped: result.skipped }))
|
|
86
|
+
void load()
|
|
87
|
+
} catch (cause) {
|
|
88
|
+
setError(errorMessage(cause))
|
|
89
|
+
} finally {
|
|
90
|
+
setImporting(false)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className={css.fillBody}>
|
|
96
|
+
<div className={css.toolbar}>
|
|
97
|
+
<input className={css.search} type="search" placeholder={tt('hosts.search')} value={search} onChange={event => { setSearch(event.target.value) }} />
|
|
98
|
+
<div className={css.toolbarSpacer} />
|
|
99
|
+
<button type="button" className={css.primaryButton} onClick={() => { setDialog({ mode: 'create' }) }}>{tt('hosts.add')}</button>
|
|
100
|
+
<button type="button" className={css.ghostButton} disabled={importing} onClick={() => { void importConfig() }}>{importing ? tt('common.loading') : tt('hosts.import')}</button>
|
|
101
|
+
</div>
|
|
102
|
+
{notice !== null && <div className={css.banner} data-kind="ok">{notice}</div>}
|
|
103
|
+
{error !== null && <div className={css.banner} data-kind="error">{tt('common.error', { error })}</div>}
|
|
104
|
+
{hosts === null && error === null && <div className={css.loading}>{tt('common.loading')}</div>}
|
|
105
|
+
{hosts !== null && hosts.length === 0 && <div className={css.empty}>{tt('hosts.empty')}</div>}
|
|
106
|
+
{hosts !== null && hosts.length > 0 && (
|
|
107
|
+
<div className={css.tableWrap}>
|
|
108
|
+
<table className={css.table}>
|
|
109
|
+
<thead>
|
|
110
|
+
<tr>
|
|
111
|
+
<th>{tt('hosts.col.alias')}</th>
|
|
112
|
+
<th>{tt('hosts.col.host')}</th>
|
|
113
|
+
<th>{tt('hosts.col.user')}</th>
|
|
114
|
+
<th>{tt('hosts.col.auth')}</th>
|
|
115
|
+
<th>{tt('hosts.col.environment')}</th>
|
|
116
|
+
<th>{tt('hosts.col.tags')}</th>
|
|
117
|
+
<th>{tt('hosts.col.description')}</th>
|
|
118
|
+
<th>{tt('hosts.col.actions')}</th>
|
|
119
|
+
</tr>
|
|
120
|
+
</thead>
|
|
121
|
+
<tbody>
|
|
122
|
+
{hosts.map(host => {
|
|
123
|
+
const test = testResults[host.alias]
|
|
124
|
+
return (
|
|
125
|
+
<tr key={host.alias}>
|
|
126
|
+
<td className={css.mono}>{host.alias}</td>
|
|
127
|
+
<td className={css.mono}>{host.host}:{host.port}</td>
|
|
128
|
+
<td>{host.user}</td>
|
|
129
|
+
<td><span className={css.badge} data-kind={host.auth}>{host.auth === 'key' ? tt('form.auth.key') : tt('form.auth.password')}</span></td>
|
|
130
|
+
<td className={css.cellMuted}>{host.environment ?? ''}</td>
|
|
131
|
+
<td className={css.cellMuted}>{host.tags.join(', ')}</td>
|
|
132
|
+
<td className={css.cellMuted}>{host.description ?? ''}</td>
|
|
133
|
+
<td>
|
|
134
|
+
<div className={css.actions}>
|
|
135
|
+
<button type="button" className={css.linkButton} disabled={testingAlias === host.alias} onClick={() => { void runTest(host.alias) }}>
|
|
136
|
+
{testingAlias === host.alias ? tt('hosts.testing') : tt('hosts.test')}
|
|
137
|
+
</button>
|
|
138
|
+
{testingAlias === host.alias && <span className={css.spinner} aria-hidden="true" />}
|
|
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>
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
{dialog !== null && (
|
|
157
|
+
<HostFormDialog
|
|
158
|
+
api={api}
|
|
159
|
+
editing={dialog.mode === 'edit' ? dialog.host : null}
|
|
160
|
+
onClose={() => { setDialog(null) }}
|
|
161
|
+
onSaved={() => { setDialog(null); void load() }}
|
|
162
|
+
/>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SSH operations panel shell: a header with a close control, a five-tab
|
|
3
|
+
* bar, and the active tab's content. Tab state lives here (browser session
|
|
4
|
+
* state); inactive tabs unmount, so each tab fetches its own data on
|
|
5
|
+
* activation. The hosts tab's connect action switches here to the terminal
|
|
6
|
+
* tab with the chosen alias preselected.
|
|
7
|
+
*/
|
|
8
|
+
import { useState } from 'react'
|
|
9
|
+
import type { SshApi } from '../api.ts'
|
|
10
|
+
import type { PanelController } from './controller.ts'
|
|
11
|
+
import { tt } from './helpers.ts'
|
|
12
|
+
import { ClusterTab } from './ClusterTab.tsx'
|
|
13
|
+
import { HostsTab } from './HostsTab.tsx'
|
|
14
|
+
import { TerminalTab } from './TerminalTab.tsx'
|
|
15
|
+
import { TransferTab } from './TransferTab.tsx'
|
|
16
|
+
import { TunnelsTab } from './TunnelsTab.tsx'
|
|
17
|
+
import css from './panel.module.css'
|
|
18
|
+
|
|
19
|
+
/** The panel's tab identifiers. */
|
|
20
|
+
export type SshTab = 'hosts' | 'terminal' | 'transfer' | 'tunnels' | 'cluster'
|
|
21
|
+
|
|
22
|
+
/** Panel shell props. */
|
|
23
|
+
export interface SshPanelProps {
|
|
24
|
+
/** The panel state owner (open/close/toggle). */
|
|
25
|
+
controller: PanelController
|
|
26
|
+
/** The SSH API client every tab operates through. */
|
|
27
|
+
api: SshApi
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The tab bar definition (labels resolved at render time). */
|
|
31
|
+
const TABS: ReadonlyArray<{ id: SshTab; label: () => string }> = [
|
|
32
|
+
{ id: 'hosts', label: () => tt('tab.hosts') },
|
|
33
|
+
{ id: 'terminal', label: () => tt('tab.terminal') },
|
|
34
|
+
{ id: 'transfer', label: () => tt('tab.transfer') },
|
|
35
|
+
{ id: 'tunnels', label: () => tt('tab.tunnels') },
|
|
36
|
+
{ id: 'cluster', label: () => tt('tab.cluster') },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/** A pending "connect this host" request handed to the terminal tab. */
|
|
40
|
+
interface ConnectRequest {
|
|
41
|
+
alias: string
|
|
42
|
+
nonce: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The tabbed SSH panel. */
|
|
46
|
+
export function SshPanel({ controller, api }: SshPanelProps) {
|
|
47
|
+
const [activeTab, setActiveTab] = useState<SshTab>('hosts')
|
|
48
|
+
const [connectRequest, setConnectRequest] = useState<ConnectRequest | null>(null)
|
|
49
|
+
|
|
50
|
+
const handleConnect = (alias: string): void => {
|
|
51
|
+
setActiveTab('terminal')
|
|
52
|
+
setConnectRequest(prev => ({ alias, nonce: (prev?.nonce ?? 0) + 1 }))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className={css.panel}>
|
|
57
|
+
<div className={css.panelHeader}>
|
|
58
|
+
<h2 className={css.panelTitle}>{tt('panel.title')}</h2>
|
|
59
|
+
<button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>x</button>
|
|
60
|
+
</div>
|
|
61
|
+
<div className={css.tabBar} role="tablist">
|
|
62
|
+
{TABS.map(tab => (
|
|
63
|
+
<button key={tab.id} type="button" role="tab" aria-selected={activeTab === tab.id} data-active={activeTab === tab.id ? '' : undefined} className={css.tab} onClick={() => { setActiveTab(tab.id) }}>
|
|
64
|
+
{tab.label()}
|
|
65
|
+
</button>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
<div className={css.panelContent}>
|
|
69
|
+
{activeTab === 'hosts' && <HostsTab api={api} onConnect={handleConnect} />}
|
|
70
|
+
{activeTab === 'terminal' && <TerminalTab api={api} presetAlias={connectRequest?.alias} requestId={connectRequest?.nonce} />}
|
|
71
|
+
{activeTab === 'transfer' && <TransferTab api={api} />}
|
|
72
|
+
{activeTab === 'tunnels' && <TunnelsTab api={api} />}
|
|
73
|
+
{activeTab === 'cluster' && <ClusterTab api={api} />}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal tab: an xterm.js PTY view over the host's WebSocket terminal route.
|
|
3
|
+
* A host <select> plus connect/disconnect controls; the terminal container is
|
|
4
|
+
* sized by FitAddon (default 80x24 before first fit). On remote exit the last
|
|
5
|
+
* output stays visible and input is disabled. xterm's stylesheet is injected
|
|
6
|
+
* once per page load (module-level guard).
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useRef, useState } from 'react'
|
|
9
|
+
import { Terminal } from '@xterm/xterm'
|
|
10
|
+
import { FitAddon } from '@xterm/addon-fit'
|
|
11
|
+
import type { SshApi, TerminalConnection } from '../api.ts'
|
|
12
|
+
import type { SshHostSummary } from '../../protocol.ts'
|
|
13
|
+
import { XTERM_CSS } from './xterm.css.ts'
|
|
14
|
+
import { errorMessage, tt } from './helpers.ts'
|
|
15
|
+
import css from './panel.module.css'
|
|
16
|
+
|
|
17
|
+
/** Terminal tab props. */
|
|
18
|
+
export interface TerminalTabProps {
|
|
19
|
+
api: SshApi
|
|
20
|
+
/** Alias preselected by a "connect" action from the hosts tab. */
|
|
21
|
+
presetAlias?: string
|
|
22
|
+
/** Monotonic id of the connect request (re-applies presetAlias). */
|
|
23
|
+
requestId?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The terminal session lifecycle state shown in the status banner. */
|
|
27
|
+
type TerminalStatus =
|
|
28
|
+
| { kind: 'idle' }
|
|
29
|
+
| { kind: 'connecting' }
|
|
30
|
+
| { kind: 'connected'; alias: string }
|
|
31
|
+
| { kind: 'exited'; alias: string; detail?: string }
|
|
32
|
+
| { kind: 'error'; detail: string }
|
|
33
|
+
|
|
34
|
+
/** Injected-once guard for the xterm stylesheet (one tag per page load). */
|
|
35
|
+
let xtermCssInjected = false
|
|
36
|
+
|
|
37
|
+
function ensureXtermCss(): void {
|
|
38
|
+
if (xtermCssInjected || typeof document === 'undefined') return
|
|
39
|
+
xtermCssInjected = true
|
|
40
|
+
if (document.querySelector('style[data-dsh-ssh-xterm]') !== null) return
|
|
41
|
+
const style = document.createElement('style')
|
|
42
|
+
style.dataset.dshSshXterm = ''
|
|
43
|
+
style.textContent = XTERM_CSS
|
|
44
|
+
document.head.appendChild(style)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The xterm terminal view. */
|
|
48
|
+
export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
|
|
49
|
+
const [hosts, setHosts] = useState<SshHostSummary[]>([])
|
|
50
|
+
const [alias, setAlias] = useState(presetAlias ?? '')
|
|
51
|
+
const [status, setStatus] = useState<TerminalStatus>({ kind: 'idle' })
|
|
52
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
53
|
+
const termRef = useRef<Terminal | null>(null)
|
|
54
|
+
const fitRef = useRef<FitAddon | null>(null)
|
|
55
|
+
const connRef = useRef<TerminalConnection | null>(null)
|
|
56
|
+
|
|
57
|
+
useEffect(() => { ensureXtermCss() }, [])
|
|
58
|
+
|
|
59
|
+
// Fetch the host list on tab activation.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
let disposed = false
|
|
62
|
+
void (async () => {
|
|
63
|
+
try {
|
|
64
|
+
const list = await api.listHosts()
|
|
65
|
+
if (!disposed) setHosts(list)
|
|
66
|
+
} catch (cause) {
|
|
67
|
+
if (!disposed) setStatus({ kind: 'error', detail: errorMessage(cause) })
|
|
68
|
+
}
|
|
69
|
+
})()
|
|
70
|
+
return () => { disposed = true }
|
|
71
|
+
}, [api])
|
|
72
|
+
|
|
73
|
+
// A hosts-tab connect action preselects its alias here.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (presetAlias !== undefined) setAlias(presetAlias)
|
|
76
|
+
}, [presetAlias, requestId])
|
|
77
|
+
|
|
78
|
+
const teardown = (): void => {
|
|
79
|
+
const connection = connRef.current
|
|
80
|
+
connRef.current = null
|
|
81
|
+
if (connection !== null) {
|
|
82
|
+
connection.onReady = undefined
|
|
83
|
+
connection.onOutput = undefined
|
|
84
|
+
connection.onExit = undefined
|
|
85
|
+
connection.close()
|
|
86
|
+
}
|
|
87
|
+
termRef.current?.dispose()
|
|
88
|
+
termRef.current = null
|
|
89
|
+
fitRef.current = null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Unmount cleanup (never touches state on an unmounting component).
|
|
93
|
+
useEffect(() => () => { teardown() }, [])
|
|
94
|
+
|
|
95
|
+
// Keep the terminal fitted to its container while connected.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const onResize = (): void => {
|
|
98
|
+
const term = termRef.current
|
|
99
|
+
const fit = fitRef.current
|
|
100
|
+
if (term === null || fit === null) return
|
|
101
|
+
fit.fit()
|
|
102
|
+
connRef.current?.resize(term.cols, term.rows)
|
|
103
|
+
}
|
|
104
|
+
window.addEventListener('resize', onResize)
|
|
105
|
+
return () => { window.removeEventListener('resize', onResize) }
|
|
106
|
+
}, [])
|
|
107
|
+
|
|
108
|
+
const connect = (): void => {
|
|
109
|
+
const target = alias
|
|
110
|
+
const container = containerRef.current
|
|
111
|
+
if (target === '' || container === null) return
|
|
112
|
+
if (status.kind === 'connecting' || status.kind === 'connected') return
|
|
113
|
+
teardown()
|
|
114
|
+
setStatus({ kind: 'connecting' })
|
|
115
|
+
const term = new Terminal({
|
|
116
|
+
convertEol: false,
|
|
117
|
+
cursorBlink: true,
|
|
118
|
+
fontSize: 13,
|
|
119
|
+
fontFamily: 'Menlo, Consolas, "Liberation Mono", monospace',
|
|
120
|
+
theme: { background: '#0b0e14', foreground: '#d8dee9', cursor: '#a3b8d0' },
|
|
121
|
+
})
|
|
122
|
+
const fit = new FitAddon()
|
|
123
|
+
term.loadAddon(fit)
|
|
124
|
+
term.open(container)
|
|
125
|
+
fit.fit()
|
|
126
|
+
const connection = api.openTerminal(target, term.cols, term.rows)
|
|
127
|
+
termRef.current = term
|
|
128
|
+
fitRef.current = fit
|
|
129
|
+
connRef.current = connection
|
|
130
|
+
let settled = false
|
|
131
|
+
const dataSub = term.onData(data => { connection.send(data) })
|
|
132
|
+
connection.onReady = () => { setStatus({ kind: 'connected', alias: target }) }
|
|
133
|
+
connection.onOutput = data => { term.write(data) }
|
|
134
|
+
connection.onExit = (code, error) => {
|
|
135
|
+
if (settled) return
|
|
136
|
+
settled = true
|
|
137
|
+
dataSub.dispose()
|
|
138
|
+
term.options.disableStdin = true
|
|
139
|
+
connRef.current = null
|
|
140
|
+
// Keep the last output visible; input is now disabled.
|
|
141
|
+
setStatus({ kind: 'exited', alias: target, detail: error })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const disconnect = (): void => {
|
|
146
|
+
teardown()
|
|
147
|
+
setStatus({ kind: 'idle' })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const active = status.kind === 'connecting' || status.kind === 'connected'
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className={css.termBody}>
|
|
154
|
+
<div className={css.controls}>
|
|
155
|
+
<select className={css.input} value={alias} onChange={event => { setAlias(event.target.value) }}>
|
|
156
|
+
<option value="">{tt('terminal.selectHost')}</option>
|
|
157
|
+
{hosts.map(host => <option key={host.alias} value={host.alias}>{host.alias} ({host.host})</option>)}
|
|
158
|
+
</select>
|
|
159
|
+
<button type="button" className={css.primaryButton} disabled={alias === '' || active} onClick={connect}>{tt('terminal.connect')}</button>
|
|
160
|
+
<button type="button" className={css.ghostButton} disabled={!active} onClick={disconnect}>{tt('terminal.disconnect')}</button>
|
|
161
|
+
</div>
|
|
162
|
+
{status.kind === 'connecting' && <div className={css.banner} data-kind="info">{tt('terminal.connecting')}</div>}
|
|
163
|
+
{status.kind === 'connected' && <div className={css.banner} data-kind="ok">{tt('terminal.ready', { alias: status.alias })}</div>}
|
|
164
|
+
{status.kind === 'exited' && (
|
|
165
|
+
<div className={css.banner} data-kind="info">{tt('terminal.exited', { alias: status.alias })}{status.detail !== undefined ? ' (' + status.detail + ')' : ''}</div>
|
|
166
|
+
)}
|
|
167
|
+
{status.kind === 'error' && <div className={css.banner} data-kind="error">{tt('terminal.error', { error: status.detail })}</div>}
|
|
168
|
+
<div className={css.termWrap}>
|
|
169
|
+
<div ref={containerRef} className={css.termContainer} />
|
|
170
|
+
{status.kind === 'idle' && (
|
|
171
|
+
<div className={css.termPlaceholder}>{hosts.length === 0 ? tt('hosts.empty') : tt('terminal.placeholder')}</div>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
)
|
|
176
|
+
}
|