dsh-winrm 0.1.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/LICENSE +73 -0
- package/README.md +112 -0
- package/README.zh.md +119 -0
- package/RELEASING.md +75 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +2076 -0
- package/lib/index.js +2458 -0
- package/lib/invariant.js +6 -0
- package/lib/types/client/api.d.ts +59 -0
- package/lib/types/client/index.d.ts +38 -0
- package/lib/types/client/locales.d.ts +231 -0
- package/lib/types/client/panel/ConsoleTab.d.ts +11 -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/ProcessesTab.d.ts +7 -0
- package/lib/types/client/panel/ServicesTab.d.ts +7 -0
- package/lib/types/client/panel/TransferTab.d.ts +7 -0
- package/lib/types/client/panel/WinrmPanel.d.ts +12 -0
- package/lib/types/client/panel/WinrmPanelIcon.d.ts +8 -0
- package/lib/types/client/panel/helpers.d.ts +16 -0
- package/lib/types/engine/client.d.ts +49 -0
- package/lib/types/engine/console.d.ts +23 -0
- package/lib/types/engine.d.ts +58 -0
- package/lib/types/index.d.ts +44 -0
- package/lib/types/invariant.d.ts +3 -0
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/lib/types/powershell.d.ts +73 -0
- package/lib/types/protocol.d.ts +176 -0
- package/lib/types/routes.d.ts +26 -0
- package/lib/types/smb.d.ts +31 -0
- package/lib/types/store.d.ts +44 -0
- package/lib/types/tools.d.ts +20 -0
- package/package.json +111 -0
- package/scripts/enable-winrm.ps1 +0 -0
- package/scripts/postbuild.mjs +40 -0
- package/scripts/validate-manifest.mjs +18 -0
- package/src/client/api.ts +345 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +82 -0
- package/src/client/locales.ts +243 -0
- package/src/client/panel/ConsoleTab.tsx +142 -0
- package/src/client/panel/HostFormDialog.tsx +142 -0
- package/src/client/panel/HostsTab.tsx +153 -0
- package/src/client/panel/ProcessesTab.tsx +119 -0
- package/src/client/panel/ServicesTab.tsx +123 -0
- package/src/client/panel/TransferTab.tsx +161 -0
- package/src/client/panel/WinrmPanel.tsx +85 -0
- package/src/client/panel/WinrmPanelIcon.tsx +27 -0
- package/src/client/panel/helpers.ts +33 -0
- package/src/client/panel/panel.module.css +1000 -0
- package/src/engine/client.ts +291 -0
- package/src/engine/console.ts +62 -0
- package/src/engine.ts +331 -0
- package/src/index.ts +142 -0
- package/src/invariant.ts +4 -0
- package/src/loopback.ts +63 -0
- package/src/mount-once.ts +48 -0
- package/src/powershell.ts +208 -0
- package/src/protocol.ts +178 -0
- package/src/routes.ts +490 -0
- package/src/smb.ts +129 -0
- package/src/store.ts +232 -0
- package/src/tools.ts +387 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console tab: a streaming PowerShell console over the /api/dsh-winrm
|
|
3
|
+
* WebSocket. Output renders into a monospace pre; commands are sent as stdin
|
|
4
|
+
* lines. Non-ASCII output may garble (transport decodes as ASCII) — the
|
|
5
|
+
* exec route/tool carries the UTF-8 envelope instead.
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useRef, useState } from 'react'
|
|
8
|
+
import type { ConsoleConnection, WinrmApi } from '../api.ts'
|
|
9
|
+
import { errorMessage, tt } from './helpers.ts'
|
|
10
|
+
import css from './panel.module.css'
|
|
11
|
+
|
|
12
|
+
/** Console tab props. */
|
|
13
|
+
export interface ConsoleTabProps {
|
|
14
|
+
api: WinrmApi
|
|
15
|
+
/** Alias to auto-connect (from the hosts tab). */
|
|
16
|
+
presetAlias?: string
|
|
17
|
+
/** Increments when a new connect request arrives. */
|
|
18
|
+
requestId?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface SessionState {
|
|
22
|
+
alias: string
|
|
23
|
+
lines: string[]
|
|
24
|
+
connected: boolean
|
|
25
|
+
error?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The streaming PowerShell console. */
|
|
29
|
+
export function ConsoleTab({ api, presetAlias, requestId }: ConsoleTabProps) {
|
|
30
|
+
const [aliases, setAliases] = useState<string[]>([])
|
|
31
|
+
const [session, setSession] = useState<SessionState | null>(null)
|
|
32
|
+
const [input, setInput] = useState('')
|
|
33
|
+
const [busy, setBusy] = useState(false)
|
|
34
|
+
const connRef = useRef<ConsoleConnection | null>(null)
|
|
35
|
+
const linesRef = useRef<string[]>([])
|
|
36
|
+
const bottomRef = useRef<HTMLDivElement | null>(null)
|
|
37
|
+
|
|
38
|
+
// Load host aliases once.
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
void api.listHosts().then(list => setAliases(list.map(h => h.alias))).catch(() => { /* ignore */ })
|
|
41
|
+
}, [api])
|
|
42
|
+
|
|
43
|
+
const pushLines = (text: string): void => {
|
|
44
|
+
linesRef.current = [...linesRef.current, ...text.split('\n')].slice(-5000)
|
|
45
|
+
setSession(prev => prev === null ? prev : { ...prev, lines: [...linesRef.current] })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Auto-connect when the hosts tab requests it.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (presetAlias !== undefined && presetAlias !== '' && requestId !== undefined) {
|
|
51
|
+
connect(presetAlias)
|
|
52
|
+
}
|
|
53
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
54
|
+
}, [presetAlias, requestId])
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
bottomRef.current?.scrollIntoView({ block: 'end' })
|
|
58
|
+
}, [session?.lines])
|
|
59
|
+
|
|
60
|
+
useEffect(() => () => {
|
|
61
|
+
connRef.current?.close()
|
|
62
|
+
connRef.current = null
|
|
63
|
+
}, [])
|
|
64
|
+
|
|
65
|
+
const connect = (alias: string): void => {
|
|
66
|
+
connRef.current?.close()
|
|
67
|
+
linesRef.current = []
|
|
68
|
+
setSession({ alias, lines: [], connected: false })
|
|
69
|
+
setBusy(true)
|
|
70
|
+
const connection = api.openConsole(alias)
|
|
71
|
+
connRef.current = connection
|
|
72
|
+
connection.onReady = () => {
|
|
73
|
+
setBusy(false)
|
|
74
|
+
setSession(prev => prev === null ? prev : { ...prev, connected: true })
|
|
75
|
+
pushLines(tt('console.connected', { alias }))
|
|
76
|
+
}
|
|
77
|
+
connection.onOutput = (data) => { pushLines(data) }
|
|
78
|
+
connection.onExit = (code, error) => {
|
|
79
|
+
connRef.current = null
|
|
80
|
+
setBusy(false)
|
|
81
|
+
setSession(prev => prev === null ? prev : {
|
|
82
|
+
...prev,
|
|
83
|
+
connected: false,
|
|
84
|
+
error: error !== undefined ? errorMessage(error) : tt('console.disconnected'),
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const disconnect = (): void => {
|
|
90
|
+
connRef.current?.close()
|
|
91
|
+
connRef.current = null
|
|
92
|
+
setSession(prev => prev === null ? prev : { ...prev, connected: false, error: tt('console.disconnected') })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const send = (): void => {
|
|
96
|
+
const command = input
|
|
97
|
+
if (command.trim() === '' || connRef.current === null) return
|
|
98
|
+
connRef.current?.send(command + '\r\n')
|
|
99
|
+
setInput('')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div className={css.fillBody}>
|
|
104
|
+
{session === null && (
|
|
105
|
+
<div className={css.controls}>
|
|
106
|
+
<select className={css.input} value="" onChange={event => { if (event.target.value !== '') connect(event.target.value) }}>
|
|
107
|
+
<option value="">{tt('console.select')}</option>
|
|
108
|
+
{aliases.map(alias => <option key={alias} value={alias}>{alias}</option>)}
|
|
109
|
+
</select>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
{session !== null && (
|
|
113
|
+
<div className={css.consoleWrap}>
|
|
114
|
+
<div className={css.consoleHeader}>
|
|
115
|
+
<span className={css.mono}>{session.alias}</span>
|
|
116
|
+
{session.connected && <span className={css.badge} data-kind="ok">{tt('console.connected', { alias: session.alias }).split('—')[0]}</span>}
|
|
117
|
+
<div className={css.toolbarSpacer} />
|
|
118
|
+
{busy && <span className={css.loading}>{tt('console.connecting', { alias: session.alias })}</span>}
|
|
119
|
+
<button type="button" className={css.ghostButton} onClick={disconnect}>{tt('console.disconnect')}</button>
|
|
120
|
+
</div>
|
|
121
|
+
<pre className={css.consoleOutput}>
|
|
122
|
+
{session.lines.join('\n')}
|
|
123
|
+
<div ref={bottomRef} />
|
|
124
|
+
</pre>
|
|
125
|
+
{session.error !== undefined && <div className={css.banner} data-kind="error">{session.error}</div>}
|
|
126
|
+
<div className={css.consoleInputRow}>
|
|
127
|
+
<input
|
|
128
|
+
className={css.input}
|
|
129
|
+
value={input}
|
|
130
|
+
placeholder={tt('console.inputPlaceholder')}
|
|
131
|
+
disabled={!session.connected}
|
|
132
|
+
onChange={event => { setInput(event.target.value) }}
|
|
133
|
+
onKeyDown={event => { if (event.key === 'Enter') send() }}
|
|
134
|
+
/>
|
|
135
|
+
<button type="button" className={css.primaryButton} disabled={!session.connected} onClick={send}>{tt('console.send')}</button>
|
|
136
|
+
</div>
|
|
137
|
+
<div className={css.utf8Note}>{tt('console.utf8Note')}</div>
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host form dialog: create/edit one Windows host entry. Passwords are never
|
|
3
|
+
* returned by the API — the edit form shows a placeholder and keeps the
|
|
4
|
+
* stored secret unless a new one is typed.
|
|
5
|
+
*/
|
|
6
|
+
import { useEffect, useState } from 'react'
|
|
7
|
+
import type { WinrmApi } from '../api.ts'
|
|
8
|
+
import type { WinHostPayload, WinHostSummary } from '../../protocol.ts'
|
|
9
|
+
import { errorMessage, tt } from './helpers.ts'
|
|
10
|
+
import css from './panel.module.css'
|
|
11
|
+
|
|
12
|
+
/** Host form dialog props. */
|
|
13
|
+
export interface HostFormDialogProps {
|
|
14
|
+
api: WinrmApi
|
|
15
|
+
/** The host being edited, or null for create. */
|
|
16
|
+
editing: WinHostSummary | null
|
|
17
|
+
onClose: () => void
|
|
18
|
+
onSaved: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The create/edit host dialog. */
|
|
22
|
+
export function HostFormDialog({ api, editing, onClose, onSaved }: HostFormDialogProps) {
|
|
23
|
+
const [alias, setAlias] = useState(editing?.alias ?? '')
|
|
24
|
+
const [host, setHost] = useState(editing?.host ?? '')
|
|
25
|
+
const [port, setPort] = useState(String(editing?.port ?? 5985))
|
|
26
|
+
const [user, setUser] = useState(editing?.user ?? '')
|
|
27
|
+
const [password, setPassword] = useState('')
|
|
28
|
+
const [transport, setTransport] = useState<'http' | 'https'>(editing?.transport ?? 'http')
|
|
29
|
+
const [rejectUnauthorized, setRejectUnauthorized] = useState(editing?.rejectUnauthorized ?? false)
|
|
30
|
+
const [environment, setEnvironment] = useState(editing?.environment ?? '')
|
|
31
|
+
const [tags, setTags] = useState(editing?.tags.join(', ') ?? '')
|
|
32
|
+
const [description, setDescription] = useState(editing?.description ?? '')
|
|
33
|
+
const [error, setError] = useState<string | null>(null)
|
|
34
|
+
const [saving, setSaving] = useState(false)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
38
|
+
if (event.key === 'Escape') onClose()
|
|
39
|
+
}
|
|
40
|
+
window.addEventListener('keydown', onKey)
|
|
41
|
+
return () => { window.removeEventListener('keydown', onKey) }
|
|
42
|
+
}, [onClose])
|
|
43
|
+
|
|
44
|
+
const submit = async (): Promise<void> => {
|
|
45
|
+
setSaving(true)
|
|
46
|
+
setError(null)
|
|
47
|
+
const payload: WinHostPayload = {
|
|
48
|
+
host: host.trim(),
|
|
49
|
+
port: Number.parseInt(port, 10) || 5985,
|
|
50
|
+
user: user.trim(),
|
|
51
|
+
transport,
|
|
52
|
+
rejectUnauthorized,
|
|
53
|
+
environment: environment.trim() || undefined,
|
|
54
|
+
tags: tags.split(',').map(tag => tag.trim()).filter(tag => tag !== ''),
|
|
55
|
+
description: description.trim() || undefined,
|
|
56
|
+
}
|
|
57
|
+
if (password !== '') payload.auth = { kind: 'password', password }
|
|
58
|
+
try {
|
|
59
|
+
if (editing === null) {
|
|
60
|
+
payload.alias = alias.trim()
|
|
61
|
+
await api.createHost(payload)
|
|
62
|
+
} else {
|
|
63
|
+
await api.updateHost(editing.alias, payload)
|
|
64
|
+
}
|
|
65
|
+
onSaved()
|
|
66
|
+
} catch (cause) {
|
|
67
|
+
setError(errorMessage(cause))
|
|
68
|
+
setSaving(false)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className={css.dialogOverlay}>
|
|
74
|
+
<div className={css.dialog}>
|
|
75
|
+
<h3 className={css.dialogTitle}>{editing === null ? tt('form.title.create') : tt('form.title.edit', { alias: editing.alias })}</h3>
|
|
76
|
+
<div className={css.formCard}>
|
|
77
|
+
{editing === null && (
|
|
78
|
+
<label className={css.field}>
|
|
79
|
+
<span className={css.fieldLabel}>{tt('form.alias')}</span>
|
|
80
|
+
<input className={css.input} value={alias} onChange={event => { setAlias(event.target.value) }} placeholder={tt('form.aliasHint')} />
|
|
81
|
+
</label>
|
|
82
|
+
)}
|
|
83
|
+
<label className={css.field}>
|
|
84
|
+
<span className={css.fieldLabel}>{tt('form.host')}</span>
|
|
85
|
+
<input className={css.input} value={host} onChange={event => { setHost(event.target.value) }} />
|
|
86
|
+
</label>
|
|
87
|
+
<div className={css.formRow}>
|
|
88
|
+
<label className={css.field}>
|
|
89
|
+
<span className={css.fieldLabel}>{tt('form.port')}</span>
|
|
90
|
+
<input className={css.input} type="number" value={port} onChange={event => { setPort(event.target.value) }} />
|
|
91
|
+
</label>
|
|
92
|
+
<label className={css.field}>
|
|
93
|
+
<span className={css.fieldLabel}>{tt('form.transport')}</span>
|
|
94
|
+
<select className={css.input} value={transport} onChange={event => {
|
|
95
|
+
const next = event.target.value as 'http' | 'https'
|
|
96
|
+
setTransport(next)
|
|
97
|
+
if (port === '5985' || port === '5986') setPort(next === 'https' ? '5986' : '5985')
|
|
98
|
+
}}>
|
|
99
|
+
<option value="http">{tt('form.transport.http')}</option>
|
|
100
|
+
<option value="https">{tt('form.transport.https')}</option>
|
|
101
|
+
</select>
|
|
102
|
+
</label>
|
|
103
|
+
</div>
|
|
104
|
+
<label className={css.field}>
|
|
105
|
+
<span className={css.fieldLabel}>{tt('form.user')}</span>
|
|
106
|
+
<input className={css.input} value={user} onChange={event => { setUser(event.target.value) }} placeholder={tt('form.userHint')} />
|
|
107
|
+
</label>
|
|
108
|
+
<label className={css.field}>
|
|
109
|
+
<span className={css.fieldLabel}>{tt('form.password')}</span>
|
|
110
|
+
<input className={css.input} type="password" value={password} onChange={event => { setPassword(event.target.value) }} placeholder={editing !== null ? tt('form.passwordPlaceholder') : undefined} />
|
|
111
|
+
<span className={css.fieldHint}>{tt('form.passwordHint')}</span>
|
|
112
|
+
</label>
|
|
113
|
+
{transport === 'https' && (
|
|
114
|
+
<label className={css.checkRow}>
|
|
115
|
+
<input type="checkbox" checked={!rejectUnauthorized} onChange={event => { setRejectUnauthorized(!event.target.checked) }} />
|
|
116
|
+
<span>{tt('form.rejectUnauthorized')}</span>
|
|
117
|
+
</label>
|
|
118
|
+
)}
|
|
119
|
+
<label className={css.field}>
|
|
120
|
+
<span className={css.fieldLabel}>{tt('form.environment')}</span>
|
|
121
|
+
<input className={css.input} value={environment} onChange={event => { setEnvironment(event.target.value) }} />
|
|
122
|
+
</label>
|
|
123
|
+
<label className={css.field}>
|
|
124
|
+
<span className={css.fieldLabel}>{tt('form.tags')}</span>
|
|
125
|
+
<input className={css.input} value={tags} onChange={event => { setTags(event.target.value) }} />
|
|
126
|
+
</label>
|
|
127
|
+
<label className={css.field}>
|
|
128
|
+
<span className={css.fieldLabel}>{tt('form.description')}</span>
|
|
129
|
+
<input className={css.input} value={description} onChange={event => { setDescription(event.target.value) }} />
|
|
130
|
+
</label>
|
|
131
|
+
{error !== null && <div className={css.formError}>{tt('form.error', { error })}</div>}
|
|
132
|
+
<div className={css.actions}>
|
|
133
|
+
<button type="button" className={css.ghostButton} onClick={onClose}>{tt('form.cancel')}</button>
|
|
134
|
+
<button type="button" className={css.primaryButton} disabled={saving} onClick={() => { void submit() }}>
|
|
135
|
+
{saving ? tt('common.loading') : tt('form.save')}
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosts tab: the host table with search (debounced through listHosts),
|
|
3
|
+
* add/edit/delete/test actions, and a connect action that hands the alias to
|
|
4
|
+
* the console tab via onConnect.
|
|
5
|
+
*/
|
|
6
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
7
|
+
import type { WinrmApi } from '../api.ts'
|
|
8
|
+
import type { TestResult, WinHostSummary } 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: WinrmApi
|
|
16
|
+
/** Connect the given alias in the console tab. */
|
|
17
|
+
onConnect: (alias: string) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The host-form dialog invocation. */
|
|
21
|
+
type DialogState = { mode: 'create' } | { mode: 'edit'; host: WinHostSummary }
|
|
22
|
+
|
|
23
|
+
/** The hosts table plus its toolbar and dialogs. */
|
|
24
|
+
export function HostsTab({ api, onConnect }: HostsTabProps) {
|
|
25
|
+
const [hosts, setHosts] = useState<WinHostSummary[] | 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 [dialog, setDialog] = useState<DialogState | null>(null)
|
|
31
|
+
const seqRef = useRef(0)
|
|
32
|
+
const mountedRef = useRef(true)
|
|
33
|
+
useEffect(() => () => { mountedRef.current = false }, [])
|
|
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 (!mountedRef.current || seq !== seqRef.current) return
|
|
40
|
+
setHosts(list)
|
|
41
|
+
setError(null)
|
|
42
|
+
} catch (cause) {
|
|
43
|
+
if (!mountedRef.current || seq !== seqRef.current) return
|
|
44
|
+
setError(errorMessage(cause))
|
|
45
|
+
}
|
|
46
|
+
}, [api])
|
|
47
|
+
|
|
48
|
+
useEffect(() => { void load() }, [load])
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const timer = setTimeout(() => {
|
|
52
|
+
const query = search.trim()
|
|
53
|
+
void load(query === '' ? undefined : query)
|
|
54
|
+
}, 300)
|
|
55
|
+
return () => { clearTimeout(timer) }
|
|
56
|
+
}, [search, load])
|
|
57
|
+
|
|
58
|
+
const runTest = async (alias: string): Promise<void> => {
|
|
59
|
+
setTestingAlias(alias)
|
|
60
|
+
try {
|
|
61
|
+
const result = await api.testHost(alias)
|
|
62
|
+
setTestResults(prev => ({ ...prev, [alias]: result }))
|
|
63
|
+
} catch (cause) {
|
|
64
|
+
setTestResults(prev => ({ ...prev, [alias]: { ok: false, error: errorMessage(cause) } }))
|
|
65
|
+
} finally {
|
|
66
|
+
setTestingAlias(null)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const deleteHost = async (alias: string): Promise<void> => {
|
|
71
|
+
if (!window.confirm(tt('hosts.deleteConfirm', { alias }))) return
|
|
72
|
+
try {
|
|
73
|
+
await api.deleteHost(alias)
|
|
74
|
+
void load()
|
|
75
|
+
} catch (cause) {
|
|
76
|
+
setError(errorMessage(cause))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const renderHostRow = (host: WinHostSummary): ReactNode => {
|
|
81
|
+
const test = testResults[host.alias]
|
|
82
|
+
return (
|
|
83
|
+
<tr key={host.alias}>
|
|
84
|
+
<td className={css.mono}>{host.alias}</td>
|
|
85
|
+
<td className={css.mono}>{host.host}:{host.port}</td>
|
|
86
|
+
<td>{host.user}</td>
|
|
87
|
+
<td><span className={css.badge} data-kind="password">{host.transport === 'https' ? 'HTTPS' : 'HTTP'}</span></td>
|
|
88
|
+
<td className={css.cellMuted}>{host.environment ?? ''}</td>
|
|
89
|
+
<td className={css.cellMuted}>{host.tags.join(', ')}</td>
|
|
90
|
+
<td className={css.cellMuted}>{host.description ?? ''}</td>
|
|
91
|
+
<td>
|
|
92
|
+
<div className={css.actions}>
|
|
93
|
+
<button type="button" className={css.linkButton} disabled={testingAlias === host.alias} onClick={() => { void runTest(host.alias) }}>
|
|
94
|
+
{testingAlias === host.alias ? tt('hosts.testing') : tt('hosts.test')}
|
|
95
|
+
</button>
|
|
96
|
+
{testingAlias === host.alias && <span className={css.spinner} aria-hidden="true" />}
|
|
97
|
+
{test !== undefined && (
|
|
98
|
+
<span className={css.inlineTest} data-status={test.ok ? 'ok' : 'fail'}>
|
|
99
|
+
{test.ok ? tt('hosts.testOk', { latency: test.latencyMs ?? 0 }) : tt('hosts.testFail', { error: test.error ?? '' })}
|
|
100
|
+
</span>
|
|
101
|
+
)}
|
|
102
|
+
<button type="button" className={css.linkButton} onClick={() => { setDialog({ mode: 'edit', host }) }}>{tt('hosts.edit')}</button>
|
|
103
|
+
<button type="button" className={css.linkButton} data-danger onClick={() => { void deleteHost(host.alias) }}>{tt('hosts.delete')}</button>
|
|
104
|
+
<button type="button" className={css.ghostButton} onClick={() => { onConnect(host.alias) }}>{tt('hosts.connect')}</button>
|
|
105
|
+
</div>
|
|
106
|
+
</td>
|
|
107
|
+
</tr>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className={css.fillBody}>
|
|
113
|
+
<div className={css.toolbar}>
|
|
114
|
+
<input className={css.search} type="search" placeholder={tt('hosts.search')} value={search} onChange={event => { setSearch(event.target.value) }} />
|
|
115
|
+
<div className={css.toolbarSpacer} />
|
|
116
|
+
<button type="button" className={css.ghostButton} onClick={() => { void load(search.trim() || undefined) }}>{tt('common.refresh')}</button>
|
|
117
|
+
<button type="button" className={css.primaryButton} onClick={() => { setDialog({ mode: 'create' }) }}>{tt('hosts.add')}</button>
|
|
118
|
+
</div>
|
|
119
|
+
{error !== null && <div className={css.banner} data-kind="error">{tt('common.error', { error })}</div>}
|
|
120
|
+
{hosts === null && error === null && <div className={css.loading}>{tt('common.loading')}</div>}
|
|
121
|
+
{hosts !== null && hosts.length === 0 && <div className={css.empty}>{tt('hosts.empty')}</div>}
|
|
122
|
+
{hosts !== null && hosts.length > 0 && (
|
|
123
|
+
<div className={css.tableWrap}>
|
|
124
|
+
<table className={css.table}>
|
|
125
|
+
<thead>
|
|
126
|
+
<tr>
|
|
127
|
+
<th>{tt('hosts.col.alias')}</th>
|
|
128
|
+
<th>{tt('hosts.col.host')}</th>
|
|
129
|
+
<th>{tt('hosts.col.user')}</th>
|
|
130
|
+
<th>{tt('hosts.col.transport')}</th>
|
|
131
|
+
<th>{tt('hosts.col.environment')}</th>
|
|
132
|
+
<th>{tt('hosts.col.tags')}</th>
|
|
133
|
+
<th>{tt('hosts.col.description')}</th>
|
|
134
|
+
<th>{tt('hosts.col.actions')}</th>
|
|
135
|
+
</tr>
|
|
136
|
+
</thead>
|
|
137
|
+
<tbody>
|
|
138
|
+
{hosts.map(renderHostRow)}
|
|
139
|
+
</tbody>
|
|
140
|
+
</table>
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
{dialog !== null && (
|
|
144
|
+
<HostFormDialog
|
|
145
|
+
api={api}
|
|
146
|
+
editing={dialog.mode === 'edit' ? dialog.host : null}
|
|
147
|
+
onClose={() => { setDialog(null) }}
|
|
148
|
+
onSaved={() => { setDialog(null); void load() }}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Processes tab: list processes for a host, filter, and kill by id.
|
|
3
|
+
*/
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
5
|
+
import type { WinrmApi } from '../api.ts'
|
|
6
|
+
import type { ProcessInfo } from '../../protocol.ts'
|
|
7
|
+
import { errorMessage, tt } from './helpers.ts'
|
|
8
|
+
import css from './panel.module.css'
|
|
9
|
+
|
|
10
|
+
/** Processes tab props. */
|
|
11
|
+
export interface ProcessesTabProps {
|
|
12
|
+
api: WinrmApi
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The processes table with host picker and kill actions. */
|
|
16
|
+
export function ProcessesTab({ api }: ProcessesTabProps) {
|
|
17
|
+
const [aliases, setAliases] = useState<string[]>([])
|
|
18
|
+
const [alias, setAlias] = useState('')
|
|
19
|
+
const [processes, setProcesses] = useState<ProcessInfo[] | null>(null)
|
|
20
|
+
const [filter, setFilter] = useState('')
|
|
21
|
+
const [error, setError] = useState<string | null>(null)
|
|
22
|
+
const [notice, setNotice] = useState<string | null>(null)
|
|
23
|
+
const [killing, setKilling] = useState<number | null>(null)
|
|
24
|
+
const mountedRef = useRef(true)
|
|
25
|
+
useEffect(() => () => { mountedRef.current = false }, [])
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
void api.listHosts().then(list => setAliases(list.map(h => h.alias))).catch(() => { /* ignore */ })
|
|
29
|
+
}, [api])
|
|
30
|
+
|
|
31
|
+
const load = useCallback(async (target: string): Promise<void> => {
|
|
32
|
+
try {
|
|
33
|
+
const list = await api.listProcesses(target)
|
|
34
|
+
if (!mountedRef.current) return
|
|
35
|
+
setProcesses(list)
|
|
36
|
+
setError(null)
|
|
37
|
+
} catch (cause) {
|
|
38
|
+
if (!mountedRef.current) return
|
|
39
|
+
setError(errorMessage(cause))
|
|
40
|
+
setProcesses(null)
|
|
41
|
+
}
|
|
42
|
+
}, [api])
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (alias === '') return
|
|
46
|
+
setProcesses(null)
|
|
47
|
+
void load(alias)
|
|
48
|
+
}, [alias, load])
|
|
49
|
+
|
|
50
|
+
const kill = async (process: ProcessInfo): Promise<void> => {
|
|
51
|
+
if (!window.confirm(tt('processes.killConfirm', { name: process.name, id: process.id }))) return
|
|
52
|
+
setKilling(process.id)
|
|
53
|
+
setNotice(null)
|
|
54
|
+
setError(null)
|
|
55
|
+
try {
|
|
56
|
+
await api.killProcess(alias, process.id)
|
|
57
|
+
setNotice(tt('processes.killed', { id: process.id }))
|
|
58
|
+
void load(alias)
|
|
59
|
+
} catch (cause) {
|
|
60
|
+
setError(tt('processes.killFail', { error: errorMessage(cause) }))
|
|
61
|
+
} finally {
|
|
62
|
+
setKilling(null)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const needle = filter.trim().toLowerCase()
|
|
67
|
+
const visible = processes === null ? [] : processes.filter(p => p.name.toLowerCase().includes(needle))
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className={css.fillBody}>
|
|
71
|
+
<div className={css.toolbar}>
|
|
72
|
+
<select className={css.input} value={alias} onChange={event => { setAlias(event.target.value) }}>
|
|
73
|
+
<option value="">{tt('processes.select')}</option>
|
|
74
|
+
{aliases.map(a => <option key={a} value={a}>{a}</option>)}
|
|
75
|
+
</select>
|
|
76
|
+
<input className={css.search} type="search" placeholder={tt('processes.filter')} value={filter} onChange={event => { setFilter(event.target.value) }} />
|
|
77
|
+
<div className={css.toolbarSpacer} />
|
|
78
|
+
<button type="button" className={css.ghostButton} disabled={alias === ''} onClick={() => { void load(alias) }}>{tt('common.refresh')}</button>
|
|
79
|
+
</div>
|
|
80
|
+
{notice !== null && <div className={css.banner} data-kind="ok">{notice}</div>}
|
|
81
|
+
{error !== null && <div className={css.banner} data-kind="error">{error}</div>}
|
|
82
|
+
{processes === null && alias !== '' && error === null && <div className={css.loading}>{tt('common.loading')}</div>}
|
|
83
|
+
{processes !== null && (
|
|
84
|
+
<div className={css.tableWrap}>
|
|
85
|
+
<table className={css.table}>
|
|
86
|
+
<thead>
|
|
87
|
+
<tr>
|
|
88
|
+
<th>{tt('processes.id')}</th>
|
|
89
|
+
<th>{tt('processes.name')}</th>
|
|
90
|
+
<th>{tt('processes.cpu')}</th>
|
|
91
|
+
<th>{tt('processes.memMB')}</th>
|
|
92
|
+
<th>{tt('processes.startTime')}</th>
|
|
93
|
+
<th>{tt('processes.path')}</th>
|
|
94
|
+
<th>{tt('hosts.col.actions')}</th>
|
|
95
|
+
</tr>
|
|
96
|
+
</thead>
|
|
97
|
+
<tbody>
|
|
98
|
+
{visible.map(process => (
|
|
99
|
+
<tr key={process.id}>
|
|
100
|
+
<td className={css.mono}>{process.id}</td>
|
|
101
|
+
<td>{process.name}</td>
|
|
102
|
+
<td>{process.cpu ?? ''}</td>
|
|
103
|
+
<td>{process.memMB ?? ''}</td>
|
|
104
|
+
<td className={css.cellMuted}>{process.startTime ?? ''}</td>
|
|
105
|
+
<td className={css.cellMuted} title={process.path ?? ''}>{process.path ?? ''}</td>
|
|
106
|
+
<td>
|
|
107
|
+
<button type="button" className={css.linkButton} data-danger disabled={killing === process.id} onClick={() => { void kill(process) }}>
|
|
108
|
+
{killing === process.id ? tt('common.loading') : tt('processes.kill')}
|
|
109
|
+
</button>
|
|
110
|
+
</td>
|
|
111
|
+
</tr>
|
|
112
|
+
))}
|
|
113
|
+
</tbody>
|
|
114
|
+
</table>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|