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.
Files changed (64) hide show
  1. package/LICENSE +73 -0
  2. package/README.md +112 -0
  3. package/README.zh.md +119 -0
  4. package/RELEASING.md +75 -0
  5. package/cordis.patch.yml +8 -0
  6. package/lib/client.js +2076 -0
  7. package/lib/index.js +2458 -0
  8. package/lib/invariant.js +6 -0
  9. package/lib/types/client/api.d.ts +59 -0
  10. package/lib/types/client/index.d.ts +38 -0
  11. package/lib/types/client/locales.d.ts +231 -0
  12. package/lib/types/client/panel/ConsoleTab.d.ts +11 -0
  13. package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
  14. package/lib/types/client/panel/HostsTab.d.ts +9 -0
  15. package/lib/types/client/panel/ProcessesTab.d.ts +7 -0
  16. package/lib/types/client/panel/ServicesTab.d.ts +7 -0
  17. package/lib/types/client/panel/TransferTab.d.ts +7 -0
  18. package/lib/types/client/panel/WinrmPanel.d.ts +12 -0
  19. package/lib/types/client/panel/WinrmPanelIcon.d.ts +8 -0
  20. package/lib/types/client/panel/helpers.d.ts +16 -0
  21. package/lib/types/engine/client.d.ts +49 -0
  22. package/lib/types/engine/console.d.ts +23 -0
  23. package/lib/types/engine.d.ts +58 -0
  24. package/lib/types/index.d.ts +44 -0
  25. package/lib/types/invariant.d.ts +3 -0
  26. package/lib/types/loopback.d.ts +24 -0
  27. package/lib/types/mount-once.d.ts +24 -0
  28. package/lib/types/powershell.d.ts +73 -0
  29. package/lib/types/protocol.d.ts +176 -0
  30. package/lib/types/routes.d.ts +26 -0
  31. package/lib/types/smb.d.ts +31 -0
  32. package/lib/types/store.d.ts +44 -0
  33. package/lib/types/tools.d.ts +20 -0
  34. package/package.json +111 -0
  35. package/scripts/enable-winrm.ps1 +0 -0
  36. package/scripts/postbuild.mjs +40 -0
  37. package/scripts/validate-manifest.mjs +18 -0
  38. package/src/client/api.ts +345 -0
  39. package/src/client/css-modules.d.ts +5 -0
  40. package/src/client/index.ts +82 -0
  41. package/src/client/locales.ts +243 -0
  42. package/src/client/panel/ConsoleTab.tsx +142 -0
  43. package/src/client/panel/HostFormDialog.tsx +142 -0
  44. package/src/client/panel/HostsTab.tsx +153 -0
  45. package/src/client/panel/ProcessesTab.tsx +119 -0
  46. package/src/client/panel/ServicesTab.tsx +123 -0
  47. package/src/client/panel/TransferTab.tsx +161 -0
  48. package/src/client/panel/WinrmPanel.tsx +85 -0
  49. package/src/client/panel/WinrmPanelIcon.tsx +27 -0
  50. package/src/client/panel/helpers.ts +33 -0
  51. package/src/client/panel/panel.module.css +1000 -0
  52. package/src/engine/client.ts +291 -0
  53. package/src/engine/console.ts +62 -0
  54. package/src/engine.ts +331 -0
  55. package/src/index.ts +142 -0
  56. package/src/invariant.ts +4 -0
  57. package/src/loopback.ts +63 -0
  58. package/src/mount-once.ts +48 -0
  59. package/src/powershell.ts +208 -0
  60. package/src/protocol.ts +178 -0
  61. package/src/routes.ts +490 -0
  62. package/src/smb.ts +129 -0
  63. package/src/store.ts +232 -0
  64. 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
+ }