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,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Services tab: list Windows services for a host, filter, and run
|
|
3
|
+
* start/stop/restart/startup-type actions.
|
|
4
|
+
*/
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
6
|
+
import type { WinrmApi } from '../api.ts'
|
|
7
|
+
import type { ServiceInfo } from '../../protocol.ts'
|
|
8
|
+
import { errorMessage, tt } from './helpers.ts'
|
|
9
|
+
import css from './panel.module.css'
|
|
10
|
+
|
|
11
|
+
/** Services tab props. */
|
|
12
|
+
export interface ServicesTabProps {
|
|
13
|
+
api: WinrmApi
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The services table with host picker and actions. */
|
|
17
|
+
export function ServicesTab({ api }: ServicesTabProps) {
|
|
18
|
+
const [aliases, setAliases] = useState<string[]>([])
|
|
19
|
+
const [alias, setAlias] = useState('')
|
|
20
|
+
const [services, setServices] = useState<ServiceInfo[] | null>(null)
|
|
21
|
+
const [filter, setFilter] = useState('')
|
|
22
|
+
const [error, setError] = useState<string | null>(null)
|
|
23
|
+
const [notice, setNotice] = useState<string | null>(null)
|
|
24
|
+
const [acting, setActing] = useState<string | null>(null)
|
|
25
|
+
const mountedRef = useRef(true)
|
|
26
|
+
useEffect(() => () => { mountedRef.current = false }, [])
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
void api.listHosts().then(list => setAliases(list.map(h => h.alias))).catch(() => { /* ignore */ })
|
|
30
|
+
}, [api])
|
|
31
|
+
|
|
32
|
+
const load = useCallback(async (target: string): Promise<void> => {
|
|
33
|
+
try {
|
|
34
|
+
const list = await api.listServices(target)
|
|
35
|
+
if (!mountedRef.current) return
|
|
36
|
+
setServices(list)
|
|
37
|
+
setError(null)
|
|
38
|
+
} catch (cause) {
|
|
39
|
+
if (!mountedRef.current) return
|
|
40
|
+
setError(errorMessage(cause))
|
|
41
|
+
setServices(null)
|
|
42
|
+
}
|
|
43
|
+
}, [api])
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (alias === '') return
|
|
47
|
+
setServices(null)
|
|
48
|
+
void load(alias)
|
|
49
|
+
}, [alias, load])
|
|
50
|
+
|
|
51
|
+
const act = async (name: string, action: string): Promise<void> => {
|
|
52
|
+
setActing(name)
|
|
53
|
+
setNotice(null)
|
|
54
|
+
setError(null)
|
|
55
|
+
try {
|
|
56
|
+
const updated = await api.serviceAction(alias, name, action)
|
|
57
|
+
setNotice(tt('services.actionOk', { name: updated.name, status: updated.status }))
|
|
58
|
+
void load(alias)
|
|
59
|
+
} catch (cause) {
|
|
60
|
+
setError(tt('services.actionFail', { error: errorMessage(cause) }))
|
|
61
|
+
} finally {
|
|
62
|
+
setActing(null)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const needle = filter.trim().toLowerCase()
|
|
67
|
+
const visible = services === null ? [] : services.filter(s =>
|
|
68
|
+
s.name.toLowerCase().includes(needle) || s.displayName.toLowerCase().includes(needle))
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className={css.fillBody}>
|
|
72
|
+
<div className={css.toolbar}>
|
|
73
|
+
<select className={css.input} value={alias} onChange={event => { setAlias(event.target.value) }}>
|
|
74
|
+
<option value="">{tt('services.select')}</option>
|
|
75
|
+
{aliases.map(a => <option key={a} value={a}>{a}</option>)}
|
|
76
|
+
</select>
|
|
77
|
+
<input className={css.search} type="search" placeholder={tt('services.filter')} value={filter} onChange={event => { setFilter(event.target.value) }} />
|
|
78
|
+
<div className={css.toolbarSpacer} />
|
|
79
|
+
<button type="button" className={css.ghostButton} disabled={alias === ''} onClick={() => { void load(alias) }}>{tt('common.refresh')}</button>
|
|
80
|
+
</div>
|
|
81
|
+
{notice !== null && <div className={css.banner} data-kind="ok">{notice}</div>}
|
|
82
|
+
{error !== null && <div className={css.banner} data-kind="error">{error}</div>}
|
|
83
|
+
{services === null && alias !== '' && error === null && <div className={css.loading}>{tt('common.loading')}</div>}
|
|
84
|
+
{services !== null && (
|
|
85
|
+
<div className={css.tableWrap}>
|
|
86
|
+
<table className={css.table}>
|
|
87
|
+
<thead>
|
|
88
|
+
<tr>
|
|
89
|
+
<th>{tt('services.name')}</th>
|
|
90
|
+
<th>{tt('services.displayName')}</th>
|
|
91
|
+
<th>{tt('services.status')}</th>
|
|
92
|
+
<th>{tt('services.startMode')}</th>
|
|
93
|
+
<th>{tt('services.startName')}</th>
|
|
94
|
+
<th>{tt('hosts.col.actions')}</th>
|
|
95
|
+
</tr>
|
|
96
|
+
</thead>
|
|
97
|
+
<tbody>
|
|
98
|
+
{visible.map(service => (
|
|
99
|
+
<tr key={service.name}>
|
|
100
|
+
<td className={css.mono}>{service.name}</td>
|
|
101
|
+
<td>{service.displayName}</td>
|
|
102
|
+
<td><span className={css.badge} data-kind={service.status === 'Running' ? 'ok' : 'muted'}>{service.status}</span></td>
|
|
103
|
+
<td>{service.startMode}</td>
|
|
104
|
+
<td className={css.cellMuted}>{service.startName}</td>
|
|
105
|
+
<td>
|
|
106
|
+
<div className={css.actions}>
|
|
107
|
+
<button type="button" className={css.linkButton} disabled={acting === service.name} onClick={() => { void act(service.name, 'start') }}>{tt('services.start')}</button>
|
|
108
|
+
<button type="button" className={css.linkButton} disabled={acting === service.name} onClick={() => { void act(service.name, 'stop') }}>{tt('services.stop')}</button>
|
|
109
|
+
<button type="button" className={css.linkButton} disabled={acting === service.name} onClick={() => { void act(service.name, 'restart') }}>{tt('services.restart')}</button>
|
|
110
|
+
<button type="button" className={css.linkButton} disabled={acting === service.name} onClick={() => { void act(service.name, 'set-auto') }}>{tt('services.auto')}</button>
|
|
111
|
+
<button type="button" className={css.linkButton} disabled={acting === service.name} onClick={() => { void act(service.name, 'set-manual') }}>{tt('services.manual')}</button>
|
|
112
|
+
<button type="button" className={css.linkButton} disabled={acting === service.name} onClick={() => { void act(service.name, 'set-disabled') }}>{tt('services.disabled')}</button>
|
|
113
|
+
</div>
|
|
114
|
+
</td>
|
|
115
|
+
</tr>
|
|
116
|
+
))}
|
|
117
|
+
</tbody>
|
|
118
|
+
</table>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transfer tab: browse remote directories (ls), download files to the local
|
|
3
|
+
* machine, and upload local files to the remote host. Transfers ride the
|
|
4
|
+
* NDJSON progress stream (upload) / content-length progress (download).
|
|
5
|
+
*/
|
|
6
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
7
|
+
import type { WinrmApi } from '../api.ts'
|
|
8
|
+
import type { RemoteDirEntry, TransferProgress } from '../../protocol.ts'
|
|
9
|
+
import { errorMessage, formatBytes, tt } from './helpers.ts'
|
|
10
|
+
import css from './panel.module.css'
|
|
11
|
+
|
|
12
|
+
/** Transfer tab props. */
|
|
13
|
+
export interface TransferTabProps {
|
|
14
|
+
api: WinrmApi
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One remote directory entry with a clickable path. */
|
|
18
|
+
function joinPath(dir: string, name: string): string {
|
|
19
|
+
return dir.replace(/[\\/]+$/, '') + '\\' + name
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The file browser with upload/download. */
|
|
23
|
+
export function TransferTab({ api }: TransferTabProps) {
|
|
24
|
+
const [aliases, setAliases] = useState<string[]>([])
|
|
25
|
+
const [alias, setAlias] = useState('')
|
|
26
|
+
const [dir, setDir] = useState('C:\\')
|
|
27
|
+
const [entries, setEntries] = useState<RemoteDirEntry[] | null>(null)
|
|
28
|
+
const [error, setError] = useState<string | null>(null)
|
|
29
|
+
const [notice, setNotice] = useState<string | null>(null)
|
|
30
|
+
const [progress, setProgress] = useState<TransferProgress | null>(null)
|
|
31
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
|
32
|
+
const mountedRef = useRef(true)
|
|
33
|
+
useEffect(() => () => { mountedRef.current = false }, [])
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
void api.listHosts().then(list => setAliases(list.map(h => h.alias))).catch(() => { /* ignore */ })
|
|
37
|
+
}, [api])
|
|
38
|
+
|
|
39
|
+
const load = useCallback(async (target: string, path: string): Promise<void> => {
|
|
40
|
+
try {
|
|
41
|
+
const list = await api.ls(target, path)
|
|
42
|
+
if (!mountedRef.current) return
|
|
43
|
+
setEntries(list)
|
|
44
|
+
setDir(path)
|
|
45
|
+
setError(null)
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
if (!mountedRef.current) return
|
|
48
|
+
setError(errorMessage(cause))
|
|
49
|
+
setEntries(null)
|
|
50
|
+
}
|
|
51
|
+
}, [api])
|
|
52
|
+
|
|
53
|
+
const open = (): void => {
|
|
54
|
+
if (alias === '' || dir === '') return
|
|
55
|
+
setEntries(null)
|
|
56
|
+
void load(alias, dir)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const pickAlias = (value: string): void => {
|
|
60
|
+
setAlias(value)
|
|
61
|
+
setEntries(null)
|
|
62
|
+
if (value !== '') void load(value, 'C:\\')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const download = async (entry: RemoteDirEntry): Promise<void> => {
|
|
66
|
+
if (entry.type !== 'file') return
|
|
67
|
+
setProgress({ phase: 'transferring', file: entry.name, transferred: 0, total: 0, percent: 0 })
|
|
68
|
+
setError(null)
|
|
69
|
+
try {
|
|
70
|
+
const outcome = await api.downloadFile(alias, joinPath(dir, entry.name), p => setProgress(p))
|
|
71
|
+
setNotice(tt('transfer.downloaded', { bytes: outcome.bytes }))
|
|
72
|
+
} catch (cause) {
|
|
73
|
+
setError(tt('transfer.fail', { error: errorMessage(cause) }))
|
|
74
|
+
} finally {
|
|
75
|
+
setProgress(null)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const upload = async (file: File): Promise<void> => {
|
|
80
|
+
if (file === undefined) return
|
|
81
|
+
const remotePath = joinPath(dir, file.name)
|
|
82
|
+
setProgress({ phase: 'transferring', file: file.name, transferred: 0, total: file.size, percent: 0 })
|
|
83
|
+
setError(null)
|
|
84
|
+
try {
|
|
85
|
+
const outcome = await api.uploadFile(file, alias, remotePath, p => setProgress(p))
|
|
86
|
+
setNotice(tt('transfer.uploaded', { bytes: outcome.transferredBytes }))
|
|
87
|
+
void load(alias, dir)
|
|
88
|
+
} catch (cause) {
|
|
89
|
+
setError(tt('transfer.fail', { error: errorMessage(cause) }))
|
|
90
|
+
} finally {
|
|
91
|
+
setProgress(null)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className={css.fillBody}>
|
|
97
|
+
<div className={css.toolbar}>
|
|
98
|
+
<select className={css.input} value={alias} onChange={event => { pickAlias(event.target.value) }}>
|
|
99
|
+
<option value="">{tt('transfer.select')}</option>
|
|
100
|
+
{aliases.map(a => <option key={a} value={a}>{a}</option>)}
|
|
101
|
+
</select>
|
|
102
|
+
<input className={css.search} value={dir} onChange={event => { setDir(event.target.value) }} onKeyDown={event => { if (event.key === 'Enter') open() }} />
|
|
103
|
+
<button type="button" className={css.primaryButton} disabled={alias === '' || dir === ''} onClick={open}>{tt('transfer.go')}</button>
|
|
104
|
+
<button type="button" className={css.ghostButton} disabled={alias === ''} onClick={() => { void load(alias, dir.replace(/[\\/][^\\/]*$/, '') || 'C:\\') }}>{tt('transfer.up')}</button>
|
|
105
|
+
<button type="button" className={css.ghostButton} disabled={alias === ''} onClick={() => { void load(alias, 'C:\\') }}>{tt('transfer.root')}</button>
|
|
106
|
+
<div className={css.toolbarSpacer} />
|
|
107
|
+
<button type="button" className={css.ghostButton} disabled={alias === ''} onClick={() => { fileInputRef.current?.click() }}>{tt('transfer.upload')}</button>
|
|
108
|
+
<input ref={fileInputRef} type="file" style={{ display: 'none' }} onChange={event => {
|
|
109
|
+
const file = event.target.files?.[0]
|
|
110
|
+
if (file !== undefined) void upload(file)
|
|
111
|
+
event.target.value = ''
|
|
112
|
+
}} />
|
|
113
|
+
</div>
|
|
114
|
+
{notice !== null && <div className={css.banner} data-kind="ok">{notice}</div>}
|
|
115
|
+
{error !== null && <div className={css.banner} data-kind="error">{error}</div>}
|
|
116
|
+
{progress !== null && (
|
|
117
|
+
<div className={css.banner} data-kind="ok">
|
|
118
|
+
{progress.phase === 'transferring' ? (progress.total > 0 ? tt('transfer.downloading') : tt('transfer.uploading')) : tt('transfer.uploading')}
|
|
119
|
+
{' '}{formatBytes(progress.transferred)}{progress.total > 0 ? ' / ' + formatBytes(progress.total) + ' (' + progress.percent + '%)' : ''}
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
{entries === null && alias !== '' && error === null && <div className={css.loading}>{tt('common.loading')}</div>}
|
|
123
|
+
{entries !== null && (
|
|
124
|
+
<div className={css.tableWrap}>
|
|
125
|
+
<table className={css.table}>
|
|
126
|
+
<thead>
|
|
127
|
+
<tr>
|
|
128
|
+
<th>{tt('transfer.name')}</th>
|
|
129
|
+
<th>{tt('transfer.type')}</th>
|
|
130
|
+
<th>{tt('transfer.size')}</th>
|
|
131
|
+
<th>{tt('transfer.modified')}</th>
|
|
132
|
+
<th>{tt('hosts.col.actions')}</th>
|
|
133
|
+
</tr>
|
|
134
|
+
</thead>
|
|
135
|
+
<tbody>
|
|
136
|
+
{entries.map(entry => (
|
|
137
|
+
<tr key={entry.name}>
|
|
138
|
+
<td>
|
|
139
|
+
{entry.type === 'dir' ? (
|
|
140
|
+
<button type="button" className={css.linkButton} onClick={() => { void load(alias, joinPath(dir, entry.name)) }}>📁 {entry.name}</button>
|
|
141
|
+
) : (
|
|
142
|
+
<span className={css.mono}>{entry.name}</span>
|
|
143
|
+
)}
|
|
144
|
+
</td>
|
|
145
|
+
<td>{entry.type === 'dir' ? tt('transfer.dir') : tt('transfer.file')}</td>
|
|
146
|
+
<td>{entry.type === 'file' ? formatBytes(entry.size) : ''}</td>
|
|
147
|
+
<td className={css.cellMuted}>{entry.mtimeMs > 0 ? new Date(entry.mtimeMs).toLocaleString() : ''}</td>
|
|
148
|
+
<td>
|
|
149
|
+
{entry.type === 'file' && (
|
|
150
|
+
<button type="button" className={css.linkButton} onClick={() => { void download(entry) }}>{tt('transfer.download')}</button>
|
|
151
|
+
)}
|
|
152
|
+
</td>
|
|
153
|
+
</tr>
|
|
154
|
+
))}
|
|
155
|
+
</tbody>
|
|
156
|
+
</table>
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WinRM operations panel shell: a header with a back control, a
|
|
3
|
+
* five-tab bar, and the active tab's content. Tab state lives here (reset to
|
|
4
|
+
* Hosts when the panel is reopened, since the keyed main slot unmounts the
|
|
5
|
+
* occupant on close); inactive tabs unmount, so each tab fetches its own data
|
|
6
|
+
* on activation. The hosts tab's connect action switches here to the console
|
|
7
|
+
* tab with the chosen alias preselected.
|
|
8
|
+
*/
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
import type { WinrmApi } from '../api.ts'
|
|
11
|
+
import { tt } from './helpers.ts'
|
|
12
|
+
import { ConsoleTab } from './ConsoleTab.tsx'
|
|
13
|
+
import { HostsTab } from './HostsTab.tsx'
|
|
14
|
+
import { ProcessesTab } from './ProcessesTab.tsx'
|
|
15
|
+
import { ServicesTab } from './ServicesTab.tsx'
|
|
16
|
+
import { TransferTab } from './TransferTab.tsx'
|
|
17
|
+
import css from './panel.module.css'
|
|
18
|
+
|
|
19
|
+
/** The panel's tab identifiers. */
|
|
20
|
+
export type WinrmTab = 'hosts' | 'console' | 'services' | 'processes' | 'transfer'
|
|
21
|
+
|
|
22
|
+
/** Panel shell props (the inject face supplied by the main-slot registration). */
|
|
23
|
+
export interface WinrmPanelProps {
|
|
24
|
+
/** The WinRM API client every tab operates through. */
|
|
25
|
+
api: WinrmApi
|
|
26
|
+
/** Return to the Conversation (wired to ctx.layout.selectPanel(null)). */
|
|
27
|
+
onBack: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The tab bar definition (labels resolved at render time). */
|
|
31
|
+
const TABS: ReadonlyArray<{ id: WinrmTab; label: () => string }> = [
|
|
32
|
+
{ id: 'hosts', label: () => tt('tab.hosts') },
|
|
33
|
+
{ id: 'console', label: () => tt('tab.console') },
|
|
34
|
+
{ id: 'services', label: () => tt('tab.services') },
|
|
35
|
+
{ id: 'processes', label: () => tt('tab.processes') },
|
|
36
|
+
{ id: 'transfer', label: () => tt('tab.transfer') },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/** A pending "connect this host" request handed to the console tab. */
|
|
40
|
+
interface ConnectRequest {
|
|
41
|
+
alias: string
|
|
42
|
+
nonce: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The tabbed WinRM panel. */
|
|
46
|
+
export function WinrmPanel({ api, onBack }: WinrmPanelProps) {
|
|
47
|
+
const [activeTab, setActiveTab] = useState<WinrmTab>('hosts')
|
|
48
|
+
const [connectRequest, setConnectRequest] = useState<ConnectRequest | null>(null)
|
|
49
|
+
|
|
50
|
+
const handleConnect = (alias: string): void => {
|
|
51
|
+
setActiveTab('console')
|
|
52
|
+
setConnectRequest(prev => ({ alias, nonce: (prev?.nonce ?? 0) + 1 }))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className={css.panel}>
|
|
57
|
+
<div className={css.panelHeader}>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className={`${css.ghostButton} ${css.backButton}`}
|
|
61
|
+
aria-label={tt('panel.backToConversation')}
|
|
62
|
+
onClick={onBack}
|
|
63
|
+
>
|
|
64
|
+
<span aria-hidden="true">‹</span>
|
|
65
|
+
<span>{tt('panel.backToConversation')}</span>
|
|
66
|
+
</button>
|
|
67
|
+
<h2 className={css.panelTitle}>{tt('panel.title')}</h2>
|
|
68
|
+
</div>
|
|
69
|
+
<div className={css.tabBar} role="tablist" data-dsh-part="tab-bar">
|
|
70
|
+
{TABS.map(tab => (
|
|
71
|
+
<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) }}>
|
|
72
|
+
{tab.label()}
|
|
73
|
+
</button>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
<div className={css.panelContent}>
|
|
77
|
+
{activeTab === 'hosts' && <HostsTab api={api} onConnect={handleConnect} />}
|
|
78
|
+
{activeTab === 'console' && <ConsoleTab api={api} presetAlias={connectRequest?.alias} requestId={connectRequest?.nonce} />}
|
|
79
|
+
{activeTab === 'services' && <ServicesTab api={api} />}
|
|
80
|
+
{activeTab === 'processes' && <ProcessesTab api={api} />}
|
|
81
|
+
{activeTab === 'transfer' && <TransferTab api={api} />}
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar `panellist` icon for the WinRM panel. The shell owns the row
|
|
3
|
+
* button and its label; this component only renders the glyph at the size
|
|
4
|
+
* and active state the row requests.
|
|
5
|
+
*/
|
|
6
|
+
import type { SidebarPanelIconOwnerProps } from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
|
7
|
+
|
|
8
|
+
/** One glyph contribution for the `sidebar.panellist` list slot. */
|
|
9
|
+
export function WinrmPanelIcon({ size }: SidebarPanelIconOwnerProps) {
|
|
10
|
+
return (
|
|
11
|
+
<svg
|
|
12
|
+
viewBox="0 0 16 16"
|
|
13
|
+
width={size}
|
|
14
|
+
height={size}
|
|
15
|
+
fill="none"
|
|
16
|
+
stroke="currentColor"
|
|
17
|
+
strokeWidth={1.3}
|
|
18
|
+
strokeLinecap="round"
|
|
19
|
+
strokeLinejoin="round"
|
|
20
|
+
aria-hidden="true"
|
|
21
|
+
>
|
|
22
|
+
<rect x="2" y="2.5" width="12" height="11" rx="1.5" />
|
|
23
|
+
<path d="M2.5 6.5h11" />
|
|
24
|
+
<path d="M6 6.5v7" />
|
|
25
|
+
</svg>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared panel helpers: the active-dictionary pick (document-language based)
|
|
3
|
+
* bound to the dsh-winrm interpolator in locales.ts, plus a small
|
|
4
|
+
* error-message extractor.
|
|
5
|
+
*/
|
|
6
|
+
import { en, t, zh, type WinKey } from '../locales.ts'
|
|
7
|
+
|
|
8
|
+
/** Template values accepted by the interpolator. */
|
|
9
|
+
export type TranslateValues = Record<string, string | number>
|
|
10
|
+
|
|
11
|
+
/** Active dictionary, picked by the document language at call time. */
|
|
12
|
+
export function dictionary(): Record<string, string> {
|
|
13
|
+
const lang = typeof document !== 'undefined' ? document.documentElement.lang : 'zh'
|
|
14
|
+
return lang.toLowerCase().startsWith('en') ? { ...en } : { ...zh }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Translate a key with optional {name} template params (current language). */
|
|
18
|
+
export function tt(key: WinKey, values?: TranslateValues): string {
|
|
19
|
+
return t(dictionary(), key, values)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Human-readable error text from an unknown thrown value. */
|
|
23
|
+
export function errorMessage(error: unknown): string {
|
|
24
|
+
if (error instanceof Error) return error.message
|
|
25
|
+
return String(error)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Format a byte count into B/KB/MB. */
|
|
29
|
+
export function formatBytes(bytes: number): string {
|
|
30
|
+
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
|
31
|
+
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
|
32
|
+
return bytes + ' B'
|
|
33
|
+
}
|