dsh-plugin-container 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +341 -2
- package/cordis.patch.yml +3 -0
- package/docs/ai-chat.png +0 -0
- package/docs/ai-new-session.png +0 -0
- package/docs/apple-container.png +0 -0
- package/docs/attach.png +0 -0
- package/docs/docker-console.png +0 -0
- package/docs/docker-overview.png +0 -0
- package/docs/docker-volumes.png +0 -0
- package/docs/image-architecture.png +0 -0
- package/docs/package-install.png +0 -0
- package/docs/provider-switch.png +0 -0
- package/docs/run-container.png +0 -0
- package/index.js +138 -0
- package/lib/client.js +3499 -0
- package/package.json +62 -4
- package/src/client/api.js +94 -0
- package/src/client/attach.jsx +133 -0
- package/src/client/components.jsx +301 -0
- package/src/client/hooks.js +161 -0
- package/src/client/i18n.js +496 -0
- package/src/client/icons.jsx +219 -0
- package/src/client/index.jsx +76 -0
- package/src/client/page.jsx +755 -0
- package/src/client/run-container.jsx +187 -0
- package/src/client/runs.js +182 -0
- package/src/client/store.js +91 -0
- package/src/client/styles.js +363 -0
- package/src/client/tabs/compose.jsx +94 -0
- package/src/client/tabs/console.jsx +211 -0
- package/src/client/tabs/containers.jsx +143 -0
- package/src/client/tabs/images.jsx +127 -0
- package/src/client/tabs/networks.jsx +91 -0
- package/src/client/tabs/overview.jsx +266 -0
- package/src/client/tabs/volumes.jsx +76 -0
- package/src/host/apple-cli.js +307 -0
- package/src/host/apple.js +471 -0
- package/src/host/attach.js +243 -0
- package/src/host/commands.js +235 -0
- package/src/host/docker-cli.js +405 -0
- package/src/host/jobs.js +245 -0
- package/src/host/parsers.js +484 -0
- package/src/host/runtime.js +285 -0
- package/src/host/service.js +832 -0
- package/src/host/tools.js +198 -0
- package/src/host/util.js +97 -0
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-container",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "DSH sidebar container panel for macOS Docker runtimes and Apple container",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js",
|
|
10
|
+
"./client": "./lib/client.js",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"lib/client.js",
|
|
16
|
+
"src/**/*.js",
|
|
17
|
+
"src/**/*.jsx",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"README.md",
|
|
20
|
+
"docs/*.png"
|
|
21
|
+
],
|
|
22
|
+
"dsh": {
|
|
23
|
+
"client": {
|
|
24
|
+
"platform": "web",
|
|
25
|
+
"inject": [
|
|
26
|
+
"@deepseek-ai/dsh-client-connection",
|
|
27
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
28
|
+
"@deepseek-ai/dsh-client-ui-sidebar",
|
|
29
|
+
"@deepseek-ai/dsh-client-locale"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"bundle": {
|
|
33
|
+
"patch": "./cordis.patch.yml"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "node scripts/build-client.mjs",
|
|
38
|
+
"install-profile": "node scripts/install-profile.mjs",
|
|
39
|
+
"test": "node --test test/*.test.mjs",
|
|
40
|
+
"prepack": "npm run build && npm test"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"esbuild": "0.25.0"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@deepseek-ai/dsh-tools": "^0.2.1-rc.5"
|
|
47
|
+
},
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"keywords": [
|
|
50
|
+
"dsh",
|
|
51
|
+
"dsh-plugin",
|
|
52
|
+
"deepseek-harness",
|
|
53
|
+
"docker",
|
|
54
|
+
"apple-container",
|
|
55
|
+
"macos",
|
|
56
|
+
"containers"
|
|
57
|
+
],
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=22"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Typed wrapper around the host `/docker` Connection RPC channel. */
|
|
2
|
+
|
|
3
|
+
export const DOCKER_CHANNEL = '/docker'
|
|
4
|
+
|
|
5
|
+
export class DockerApiError extends Error {
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} code
|
|
8
|
+
* @param {string} message
|
|
9
|
+
* @param {Record<string, any>} [details]
|
|
10
|
+
* @param {unknown} [cause]
|
|
11
|
+
*/
|
|
12
|
+
constructor(code, message, details = {}, cause = undefined) {
|
|
13
|
+
super(message)
|
|
14
|
+
this.name = 'DockerApiError'
|
|
15
|
+
this.code = code
|
|
16
|
+
this.details = details
|
|
17
|
+
this.cause = cause
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {unknown} error */
|
|
22
|
+
export function isAbortError(error) {
|
|
23
|
+
return error instanceof DOMException && error.name === 'AbortError' || error?.name === 'AbortError'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {unknown} value */
|
|
27
|
+
function isResult(value) {
|
|
28
|
+
return typeof value === 'object' && value !== null && typeof value.ok === 'boolean'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @param {any} connection */
|
|
32
|
+
export function createApi(connection) {
|
|
33
|
+
return {
|
|
34
|
+
connection,
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} method
|
|
37
|
+
* @param {Record<string, any>} payload
|
|
38
|
+
* @param {AbortSignal} [signal]
|
|
39
|
+
*/
|
|
40
|
+
async call(method, payload = {}, signal = undefined) {
|
|
41
|
+
let result
|
|
42
|
+
try {
|
|
43
|
+
result = await connection.rpc.call(DOCKER_CHANNEL, method, payload, signal)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (isAbortError(error)) throw new DockerApiError('docker/cancelled', '请求已取消', {}, error)
|
|
46
|
+
throw new DockerApiError('docker/transport', transportMessage(error), {}, error)
|
|
47
|
+
}
|
|
48
|
+
if (!isResult(result)) throw new DockerApiError('docker/protocol', 'Docker 服务返回了无效响应')
|
|
49
|
+
if (result.ok !== true) {
|
|
50
|
+
const error = result.error ?? {}
|
|
51
|
+
throw new DockerApiError(
|
|
52
|
+
typeof error.code === 'string' ? error.code : 'docker/internal',
|
|
53
|
+
typeof error.message === 'string' ? error.message : 'Docker 服务调用失败',
|
|
54
|
+
error.details ?? {},
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
return result.value
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {unknown} error */
|
|
63
|
+
function transportMessage(error) {
|
|
64
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
65
|
+
if (/HTTP 404|not found/iu.test(message)) return 'Docker 服务未加载(/docker 通道不可用),请刷新页面或重启 DSH'
|
|
66
|
+
if (/HTTP 500/iu.test(message)) return 'Docker 服务内部错误,请查看 DSH 日志'
|
|
67
|
+
if (/HTTP 401|HTTP 403/iu.test(message)) return '没有权限访问本机 Docker 服务'
|
|
68
|
+
return `Docker 服务连接失败:${message}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Module-level query cache used by the tabs. Values only, never promises. */
|
|
72
|
+
const queryCache = new Map()
|
|
73
|
+
|
|
74
|
+
/** @param {string} key */
|
|
75
|
+
export function cacheGet(key) {
|
|
76
|
+
const hit = queryCache.get(key)
|
|
77
|
+
if (hit === undefined) return undefined
|
|
78
|
+
if (Date.now() - hit.at > hit.ttl) {
|
|
79
|
+
queryCache.delete(key)
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
return hit.value
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @param {string} key @param {unknown} value @param {number} [ttl] */
|
|
86
|
+
export function cacheSet(key, value, ttl = 2000) {
|
|
87
|
+
queryCache.set(key, { at: Date.now(), value, ttl })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @param {string} [prefix] */
|
|
91
|
+
export function cacheClear(prefix = '') {
|
|
92
|
+
if (prefix === '') queryCache.clear()
|
|
93
|
+
else for (const key of [...queryCache.keys()]) if (key.startsWith(prefix)) queryCache.delete(key)
|
|
94
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/** Interactive PTY attach viewer: raw output plus keyboard/paste input. */
|
|
2
|
+
import { useEffect, useRef, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { Button, CopyButton, Modal, Spinner } from './components.jsx'
|
|
5
|
+
|
|
6
|
+
/** Keep common terminal output readable without pulling xterm into this modal. */
|
|
7
|
+
function cleanTerminal(text) {
|
|
8
|
+
return text
|
|
9
|
+
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/gu, '')
|
|
10
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/gu, '')
|
|
11
|
+
.replace(/\x1b[@-_]/gu, '')
|
|
12
|
+
.replace(/\r/gu, '')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function statusLabel(t, status) {
|
|
16
|
+
if (status === 'running') return t('attachRunning')
|
|
17
|
+
if (status === 'exited') return t('attachExited')
|
|
18
|
+
if (status === 'failed') return t('attachFailed')
|
|
19
|
+
if (status === 'closed') return t('attachClosed')
|
|
20
|
+
return t('attachStarting')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function keyBytes(event) {
|
|
24
|
+
if (event.metaKey) return null
|
|
25
|
+
if (event.ctrlKey && event.key.length === 1) {
|
|
26
|
+
const code = event.key.toUpperCase().charCodeAt(0)
|
|
27
|
+
if (code >= 64 && code <= 95) return String.fromCharCode(code - 64)
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
if (event.altKey) return null
|
|
31
|
+
if (event.key === 'Enter') return '\r'
|
|
32
|
+
if (event.key === 'Backspace') return '\x7f'
|
|
33
|
+
if (event.key === 'Tab') return '\t'
|
|
34
|
+
if (event.key === 'Escape') return '\x1b'
|
|
35
|
+
if (event.key === 'ArrowUp') return '\x1b[A'
|
|
36
|
+
if (event.key === 'ArrowDown') return '\x1b[B'
|
|
37
|
+
if (event.key === 'ArrowRight') return '\x1b[C'
|
|
38
|
+
if (event.key === 'ArrowLeft') return '\x1b[D'
|
|
39
|
+
if (event.key === 'Home') return '\x1b[H'
|
|
40
|
+
if (event.key === 'End') return '\x1b[F'
|
|
41
|
+
if (event.key === 'Delete') return '\x1b[3~'
|
|
42
|
+
if (event.key === 'PageUp') return '\x1b[5~'
|
|
43
|
+
if (event.key === 'PageDown') return '\x1b[6~'
|
|
44
|
+
if (event.key.length === 1) return event.key
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function AttachDialog({ open, t, session, output, loading, error, onInput, onClose }) {
|
|
49
|
+
const screenRef = useRef(null)
|
|
50
|
+
const preRef = useRef(null)
|
|
51
|
+
const [stick, setStick] = useState(true)
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!open) return undefined
|
|
55
|
+
setStick(true)
|
|
56
|
+
const timer = setTimeout(() => screenRef.current?.focus(), 60)
|
|
57
|
+
return () => clearTimeout(timer)
|
|
58
|
+
}, [open])
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!stick) return
|
|
62
|
+
const element = preRef.current
|
|
63
|
+
if (element !== null) element.scrollTop = element.scrollHeight
|
|
64
|
+
}, [output, stick])
|
|
65
|
+
|
|
66
|
+
const running = session?.status === 'running'
|
|
67
|
+
const shown = cleanTerminal(output)
|
|
68
|
+
return (
|
|
69
|
+
<Modal
|
|
70
|
+
open={open}
|
|
71
|
+
title={`${t('attachTitle')} · ${session?.containerName ?? ''}`}
|
|
72
|
+
subtitle={session?.command ?? undefined}
|
|
73
|
+
wide
|
|
74
|
+
onClose={onClose}
|
|
75
|
+
closeLabel={t('close')}
|
|
76
|
+
footer={
|
|
77
|
+
<>
|
|
78
|
+
<span className="dsh-docker-muted">{t('attachInputHint')}</span>
|
|
79
|
+
<span style={{ flex: 1 }} />
|
|
80
|
+
{session?.exitCode !== null && session?.exitCode !== undefined && <span className="dsh-docker-muted">{t('exitCode')}: {session.exitCode}</span>}
|
|
81
|
+
<CopyButton text={shown} label={t('copy')} copiedLabel={t('copied')} />
|
|
82
|
+
<Button size="sm" onClick={onClose}>{t('attachClose')}</Button>
|
|
83
|
+
</>
|
|
84
|
+
}
|
|
85
|
+
>
|
|
86
|
+
<div className="dsh-docker-attach-status">
|
|
87
|
+
<span className="dsh-docker-status" data-state={running ? 'running' : session?.status === 'failed' ? 'error' : 'stopped'}>
|
|
88
|
+
<span className="dsh-docker-dot" />
|
|
89
|
+
{statusLabel(t, session?.status)}
|
|
90
|
+
</span>
|
|
91
|
+
<span className="dsh-docker-muted">{session?.cols ?? 120} × {session?.rows ?? 30}</span>
|
|
92
|
+
</div>
|
|
93
|
+
<div
|
|
94
|
+
ref={screenRef}
|
|
95
|
+
className="dsh-docker-attach-screen"
|
|
96
|
+
tabIndex={0}
|
|
97
|
+
role="textbox"
|
|
98
|
+
aria-label={t('attachTitle')}
|
|
99
|
+
onKeyDown={(event) => {
|
|
100
|
+
if (!running || loading) return
|
|
101
|
+
const data = keyBytes(event)
|
|
102
|
+
if (data === null) return
|
|
103
|
+
event.preventDefault()
|
|
104
|
+
onInput(data)
|
|
105
|
+
}}
|
|
106
|
+
onPaste={(event) => {
|
|
107
|
+
if (!running || loading) return
|
|
108
|
+
event.preventDefault()
|
|
109
|
+
const text = event.clipboardData.getData('text')
|
|
110
|
+
if (text !== '') onInput(text)
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{loading ? (
|
|
114
|
+
<div className="dsh-docker-attach-loading"><Spinner size={16} /> {t('attachStarting')}</div>
|
|
115
|
+
) : error !== null ? (
|
|
116
|
+
<pre className="dsh-docker-attach-pre dsh-docker-attach-error">{error.message ?? String(error)}</pre>
|
|
117
|
+
) : shown === '' ? (
|
|
118
|
+
<pre className="dsh-docker-attach-pre dsh-docker-no-output">{t('attachHint')}</pre>
|
|
119
|
+
) : (
|
|
120
|
+
<pre
|
|
121
|
+
ref={preRef}
|
|
122
|
+
className="dsh-docker-attach-pre"
|
|
123
|
+
onScroll={() => {
|
|
124
|
+
const element = preRef.current
|
|
125
|
+
if (element === null) return
|
|
126
|
+
setStick(element.scrollHeight - element.scrollTop - element.clientHeight < 28)
|
|
127
|
+
}}
|
|
128
|
+
>{shown}</pre>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
</Modal>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/** Small presentational building blocks shared by the Docker tabs. */
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
import { createPortal } from 'react-dom'
|
|
4
|
+
|
|
5
|
+
import { IconAlert, IconCheck, IconCopy, IconSearch, IconSpinner, IconX } from './icons.jsx'
|
|
6
|
+
|
|
7
|
+
export function cx(...values) {
|
|
8
|
+
return values.filter(Boolean).join(' ')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Button({ variant = 'ghost', size = 'md', className, type = 'button', children, ...rest }) {
|
|
12
|
+
return (
|
|
13
|
+
<button
|
|
14
|
+
type={type}
|
|
15
|
+
className={cx(
|
|
16
|
+
'dsh-docker-btn',
|
|
17
|
+
variant === 'primary' && 'dsh-docker-primary',
|
|
18
|
+
variant === 'danger' && 'dsh-docker-danger',
|
|
19
|
+
variant === 'danger-solid' && 'dsh-docker-danger-solid',
|
|
20
|
+
variant === 'ghost' && 'dsh-docker-ghost',
|
|
21
|
+
size === 'sm' && 'dsh-docker-sm',
|
|
22
|
+
size === 'lg' && 'dsh-docker-lg',
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
{...rest}
|
|
26
|
+
>
|
|
27
|
+
{children}
|
|
28
|
+
</button>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function IconButton({ label, size = 'md', className, children, ...rest }) {
|
|
33
|
+
return (
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
aria-label={label}
|
|
37
|
+
title={label}
|
|
38
|
+
className={cx('dsh-docker-icon-btn', size === 'sm' && 'dsh-docker-sm', className)}
|
|
39
|
+
{...rest}
|
|
40
|
+
>
|
|
41
|
+
{children}
|
|
42
|
+
</button>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function Spinner({ size = 16, className }) {
|
|
47
|
+
return <IconSpinner size={size} className={className} />
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function StatusDot({ state = 'unknown' }) {
|
|
51
|
+
return <span className="dsh-docker-dot" data-state={state} aria-hidden="true" />
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function StateLabel({ state, t }) {
|
|
55
|
+
const normalized = state === 'running' ? 'running'
|
|
56
|
+
: state === 'restarting' ? 'restarting'
|
|
57
|
+
: state === 'paused' ? 'paused'
|
|
58
|
+
: state === 'dead' ? 'dead'
|
|
59
|
+
: 'exited'
|
|
60
|
+
const label = normalized === 'running' ? t('running')
|
|
61
|
+
: normalized === 'restarting' ? t('statusStarting')
|
|
62
|
+
: normalized === 'paused' ? t('statusStarting')
|
|
63
|
+
: normalized === 'dead' ? t('failed')
|
|
64
|
+
: t('stopped')
|
|
65
|
+
return (
|
|
66
|
+
<span className="dsh-docker-state" data-state={normalized}>
|
|
67
|
+
<StatusDot state={normalized} />
|
|
68
|
+
{label}
|
|
69
|
+
</span>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function Modal({ open, title, subtitle, wide, onClose, children, footer, closeLabel = 'Close' }) {
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!open) return undefined
|
|
76
|
+
const onKey = (event) => {
|
|
77
|
+
if (event.key === 'Escape') {
|
|
78
|
+
event.stopPropagation()
|
|
79
|
+
onClose?.()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
document.addEventListener('keydown', onKey)
|
|
83
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
84
|
+
}, [open, onClose])
|
|
85
|
+
|
|
86
|
+
if (!open) return null
|
|
87
|
+
return createPortal(
|
|
88
|
+
<div
|
|
89
|
+
className="dsh-docker-modal-backdrop"
|
|
90
|
+
onMouseDown={(event) => {
|
|
91
|
+
if (event.target === event.currentTarget) onClose?.()
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<div
|
|
95
|
+
className={cx('dsh-docker-modal', wide && 'dsh-docker-modal-wide')}
|
|
96
|
+
role="dialog"
|
|
97
|
+
aria-modal="true"
|
|
98
|
+
aria-label={title}
|
|
99
|
+
>
|
|
100
|
+
<div className="dsh-docker-modal-head">
|
|
101
|
+
<div>
|
|
102
|
+
<h2 className="dsh-docker-modal-title">{title}</h2>
|
|
103
|
+
{subtitle !== undefined && <p className="dsh-docker-modal-sub">{subtitle}</p>}
|
|
104
|
+
</div>
|
|
105
|
+
<IconButton label={closeLabel} autoFocus onClick={onClose}>
|
|
106
|
+
<IconX size={16} />
|
|
107
|
+
</IconButton>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="dsh-docker-modal-body">{children}</div>
|
|
110
|
+
{footer !== undefined && <div className="dsh-docker-modal-foot">{footer}</div>}
|
|
111
|
+
</div>
|
|
112
|
+
</div>,
|
|
113
|
+
document.body,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function ConfirmDialog({ open, title, body, command, confirmLabel, cancelLabel, closeLabel = 'Close', danger = false, busy = false, onConfirm, onCancel }) {
|
|
118
|
+
return (
|
|
119
|
+
<Modal
|
|
120
|
+
open={open}
|
|
121
|
+
title={title}
|
|
122
|
+
closeLabel={closeLabel}
|
|
123
|
+
onClose={onCancel}
|
|
124
|
+
footer={
|
|
125
|
+
<>
|
|
126
|
+
<Button onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
|
|
127
|
+
<Button variant={danger ? 'danger-solid' : 'primary'} onClick={onConfirm} disabled={busy}>
|
|
128
|
+
{busy ? <Spinner size={14} /> : null}
|
|
129
|
+
{confirmLabel}
|
|
130
|
+
</Button>
|
|
131
|
+
</>
|
|
132
|
+
}
|
|
133
|
+
>
|
|
134
|
+
<div className={cx(danger && 'dsh-docker-modal-danger')} style={{ whiteSpace: 'pre-wrap' }}>
|
|
135
|
+
<div>{body}</div>
|
|
136
|
+
{command !== undefined && (
|
|
137
|
+
<pre className="dsh-docker-json" style={{ marginTop: 10, whiteSpace: 'pre-wrap' }}>{command}</pre>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
</Modal>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function ToastStack({ toasts, onDismiss }) {
|
|
145
|
+
if (toasts.length === 0) return null
|
|
146
|
+
return createPortal(
|
|
147
|
+
<div className="dsh-docker-toast-wrap" aria-live="polite">
|
|
148
|
+
{toasts.map((toast) => (
|
|
149
|
+
<div key={toast.id} className="dsh-docker-toast" data-tone={toast.tone}>
|
|
150
|
+
<span style={{ marginTop: 1 }}>
|
|
151
|
+
{toast.tone === 'success' ? <IconCheck size={14} /> : toast.tone === 'error' ? <IconAlert size={14} /> : null}
|
|
152
|
+
</span>
|
|
153
|
+
<span className="dsh-docker-toast-main">{toast.message}</span>
|
|
154
|
+
<IconButton label="Close" size="sm" onClick={() => onDismiss(toast.id)}>
|
|
155
|
+
<IconX size={13} />
|
|
156
|
+
</IconButton>
|
|
157
|
+
</div>
|
|
158
|
+
))}
|
|
159
|
+
</div>,
|
|
160
|
+
document.body,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function EmptyState({ icon, title, text, action }) {
|
|
165
|
+
return (
|
|
166
|
+
<div className="dsh-docker-empty">
|
|
167
|
+
{icon !== undefined && <span className="dsh-docker-empty-icon">{icon}</span>}
|
|
168
|
+
<div className="dsh-docker-empty-title">{title}</div>
|
|
169
|
+
{text !== undefined && <div className="dsh-docker-empty-text">{text}</div>}
|
|
170
|
+
{action}
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function ErrorState({ title, text, retryLabel, onRetry }) {
|
|
176
|
+
return (
|
|
177
|
+
<div className="dsh-docker-error">
|
|
178
|
+
<span className="dsh-docker-empty-icon"><IconAlert size={21} /></span>
|
|
179
|
+
<div className="dsh-docker-error-title">{title}</div>
|
|
180
|
+
{text !== undefined && <div className="dsh-docker-error-text">{text}</div>}
|
|
181
|
+
{onRetry !== undefined && <Button onClick={onRetry}>{retryLabel}</Button>}
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function LoadingSkeleton({ rows = 3 }) {
|
|
187
|
+
return (
|
|
188
|
+
<div className="dsh-docker-skeleton" aria-label="Loading">
|
|
189
|
+
{Array.from({ length: rows }, (_, index) => <div key={index} className="dsh-docker-skel-line" />)}
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function SearchInput({ value, onChange, placeholder, label }) {
|
|
195
|
+
return (
|
|
196
|
+
<label className="dsh-docker-search">
|
|
197
|
+
<span className="dsh-docker-search-icon"><IconSearch size={14} /></span>
|
|
198
|
+
<input
|
|
199
|
+
className="dsh-docker-input"
|
|
200
|
+
value={value}
|
|
201
|
+
onChange={(event) => onChange(event.target.value)}
|
|
202
|
+
placeholder={placeholder}
|
|
203
|
+
aria-label={label ?? placeholder}
|
|
204
|
+
spellCheck="false"
|
|
205
|
+
/>
|
|
206
|
+
</label>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function CopyButton({ text, label, copiedLabel, className, children }) {
|
|
211
|
+
const [copied, setCopied] = useState(false)
|
|
212
|
+
const write = async () => {
|
|
213
|
+
try {
|
|
214
|
+
if (navigator.clipboard?.writeText !== undefined) await navigator.clipboard.writeText(text)
|
|
215
|
+
else {
|
|
216
|
+
const area = document.createElement('textarea')
|
|
217
|
+
area.value = text
|
|
218
|
+
area.style.position = 'fixed'
|
|
219
|
+
area.style.opacity = '0'
|
|
220
|
+
document.body.appendChild(area)
|
|
221
|
+
area.select()
|
|
222
|
+
document.execCommand('copy')
|
|
223
|
+
area.remove()
|
|
224
|
+
}
|
|
225
|
+
setCopied(true)
|
|
226
|
+
setTimeout(() => setCopied(false), 1500)
|
|
227
|
+
} catch {
|
|
228
|
+
setCopied(false)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return (
|
|
232
|
+
<Button onClick={write} className={className} title={label}>
|
|
233
|
+
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
|
234
|
+
{children ?? (copied ? copiedLabel : label)}
|
|
235
|
+
</Button>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function Chips({ values, className, mono = false }) {
|
|
240
|
+
if (values.length === 0) return <span className="dsh-docker-muted">—</span>
|
|
241
|
+
return (
|
|
242
|
+
<span className={cx('dsh-docker-chips', className)}>
|
|
243
|
+
{values.map((value, index) => (
|
|
244
|
+
<span key={`${value}-${index}`} className={cx('dsh-docker-chip', mono && 'dsh-docker-chip-code')} title={value}>
|
|
245
|
+
{value}
|
|
246
|
+
</span>
|
|
247
|
+
))}
|
|
248
|
+
</span>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** @param {number} bytes */
|
|
253
|
+
export function formatBytes(bytes) {
|
|
254
|
+
if (!Number.isFinite(bytes) || bytes < 0) return '—'
|
|
255
|
+
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
|
256
|
+
let value = bytes
|
|
257
|
+
let unit = 0
|
|
258
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
259
|
+
value /= 1024
|
|
260
|
+
unit += 1
|
|
261
|
+
}
|
|
262
|
+
return `${unit === 0 ? Math.round(value) : value.toFixed(value >= 10 ? 1 : 2)} ${units[unit]}`
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** @param {number} ms */
|
|
266
|
+
export function formatDuration(ms) {
|
|
267
|
+
if (!Number.isFinite(ms) || ms < 0) return '—'
|
|
268
|
+
const seconds = Math.floor(ms / 1000)
|
|
269
|
+
if (seconds < 60) return `${seconds}s`
|
|
270
|
+
const minutes = Math.floor(seconds / 60)
|
|
271
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`
|
|
272
|
+
const hours = Math.floor(minutes / 60)
|
|
273
|
+
return `${hours}h ${minutes % 60}m`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @param {number | undefined} timestamp */
|
|
277
|
+
export function formatTime(timestamp) {
|
|
278
|
+
if (typeof timestamp !== 'number') return '—'
|
|
279
|
+
const date = new Date(timestamp)
|
|
280
|
+
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** @param {string} value */
|
|
284
|
+
export function shortId(value) {
|
|
285
|
+
if (typeof value !== 'string') return ''
|
|
286
|
+
return value.replace(/^sha256:/u, '').slice(0, 12)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** @param {unknown} value */
|
|
290
|
+
export function displayValue(value) {
|
|
291
|
+
if (value === undefined || value === null || value === '') return '—'
|
|
292
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
293
|
+
return String(value)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** @param {unknown} error */
|
|
297
|
+
export function errorMessage(error) {
|
|
298
|
+
if (error === undefined || error === null) return ''
|
|
299
|
+
if (error instanceof Error) return error.message
|
|
300
|
+
return String(error)
|
|
301
|
+
}
|