dsh-plugin-container 0.0.0-stage → 0.3.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 +80 -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 +3560 -0
- package/package.json +62 -4
- package/src/client/api.js +98 -0
- package/src/client/attach.jsx +134 -0
- package/src/client/components.jsx +320 -0
- package/src/client/hooks.js +161 -0
- package/src/client/i18n.js +502 -0
- package/src/client/icons.jsx +227 -0
- package/src/client/index.jsx +76 -0
- package/src/client/page.jsx +782 -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 +371 -0
- package/src/client/tabs/compose.jsx +94 -0
- package/src/client/tabs/console.jsx +211 -0
- package/src/client/tabs/containers.jsx +148 -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 +246 -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 +836 -0
- package/src/host/tools.js +198 -0
- package/src/host/util.js +97 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/** Docker overview: runtime, counts, recent containers, disk, quick commands. */
|
|
2
|
+
import { useMemo } from 'react'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Button,
|
|
6
|
+
Chips,
|
|
7
|
+
EmptyState,
|
|
8
|
+
ErrorState,
|
|
9
|
+
LoadingSkeleton,
|
|
10
|
+
Spinner,
|
|
11
|
+
StateLabel,
|
|
12
|
+
displayValue,
|
|
13
|
+
formatBytes,
|
|
14
|
+
formatTime,
|
|
15
|
+
} from '../components.jsx'
|
|
16
|
+
import { useDockerList } from '../hooks.js'
|
|
17
|
+
import { IconBox, IconImage, IconPlay, IconRestart, IconStop, IconTerminal } from '../icons.jsx'
|
|
18
|
+
|
|
19
|
+
function Stat({ label, value, detail }) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="dsh-docker-stat">
|
|
22
|
+
<div className="dsh-docker-stat-label">{label}</div>
|
|
23
|
+
<div className="dsh-docker-stat-value">{value}</div>
|
|
24
|
+
{detail !== undefined && <div className="dsh-docker-stat-detail">{detail}</div>}
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function RuntimeCard({ t, provider, status, imageCount, volumeCount, networkCount, onRuntime, busy }) {
|
|
30
|
+
const runtime = status.runtime
|
|
31
|
+
const details = runtime?.details ?? {}
|
|
32
|
+
const isApple = provider === 'apple-container'
|
|
33
|
+
const rows = (isApple ? [
|
|
34
|
+
[t('providerLabel'), t('providerApple')],
|
|
35
|
+
[t('systemService'), details.status],
|
|
36
|
+
[t('client'), status.client?.version],
|
|
37
|
+
[t('server'), status.server?.version],
|
|
38
|
+
[t('containers'), status.server?.containers],
|
|
39
|
+
[t('images'), imageCount],
|
|
40
|
+
[t('volumes'), volumeCount],
|
|
41
|
+
[t('networks'), networkCount],
|
|
42
|
+
[t('cpus'), status.server?.ncpu !== undefined ? String(status.server.ncpu) : undefined],
|
|
43
|
+
[t('memory'), status.server?.memTotal !== undefined ? formatBytes(status.server.memTotal) : undefined],
|
|
44
|
+
] : [
|
|
45
|
+
[t('endpoint'), status.context?.endpoint],
|
|
46
|
+
[t('profile'), runtime?.profile],
|
|
47
|
+
[t('driver'), runtime?.driver ?? details.driver],
|
|
48
|
+
[t('arch'), runtime?.arch ?? details.arch],
|
|
49
|
+
[t('mountType'), runtime?.mountType ?? details.mountType],
|
|
50
|
+
[t('kubernetes'), runtime?.kubernetes === undefined ? undefined : runtime.kubernetes ? t('yes') : t('no')],
|
|
51
|
+
[t('cpus'), runtime?.cpus !== undefined ? String(runtime.cpus) : undefined],
|
|
52
|
+
[t('memory'), runtime?.memory !== undefined ? formatBytes(runtime.memory) : undefined],
|
|
53
|
+
[t('disk'), runtime?.disk !== undefined ? formatBytes(runtime.disk) : undefined],
|
|
54
|
+
]).filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|
55
|
+
return (
|
|
56
|
+
<section className="dsh-docker-card">
|
|
57
|
+
<div className="dsh-docker-card-head">
|
|
58
|
+
<div>
|
|
59
|
+
<h2 className="dsh-docker-card-title">{t('runtime')}</h2>
|
|
60
|
+
<p className="dsh-docker-card-sub">{runtime?.kind ?? ''}</p>
|
|
61
|
+
</div>
|
|
62
|
+
<span className="dsh-docker-status" data-state={runtime?.running ? 'running' : 'stopped'}>
|
|
63
|
+
<span className="dsh-docker-dot" />
|
|
64
|
+
{runtime?.running ? t('statusRunning') : t('statusStopped')}
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div className="dsh-docker-card-body">
|
|
68
|
+
<div className="dsh-docker-runtime-head">
|
|
69
|
+
<span className="dsh-docker-mark" style={{ width: 34, height: 34 }}><IconBox size={19} /></span>
|
|
70
|
+
<div>
|
|
71
|
+
<div className="dsh-docker-runtime-name">{runtime?.label ?? t('unknownRuntime')}</div>
|
|
72
|
+
<div className="dsh-docker-muted">{isApple ? 'container · per-container VM' : runtime?.kind === 'colima' ? `colima · profile ${runtime.profile ?? 'default'}` : (runtime?.kind ?? '')}</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
<div className="dsh-docker-kv">
|
|
76
|
+
{rows.map(([key, value]) => (
|
|
77
|
+
<div key={key} className="dsh-docker-kv-row" style={{ display: 'contents' }}>
|
|
78
|
+
<div className="dsh-docker-kv-key">{key}</div>
|
|
79
|
+
<div className="dsh-docker-kv-value">{displayValue(value)}</div>
|
|
80
|
+
</div>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
<div className="dsh-docker-middle" style={{ marginTop: 14 }}>
|
|
84
|
+
{runtime?.canStart === true && (
|
|
85
|
+
<Button size="sm" variant="primary" onClick={() => onRuntime('start')} disabled={busy}>
|
|
86
|
+
{busy ? <Spinner size={13} /> : <IconPlay size={13} />} {t('start')}
|
|
87
|
+
</Button>
|
|
88
|
+
)}
|
|
89
|
+
{runtime?.canStop === true && (
|
|
90
|
+
<Button size="sm" onClick={() => onRuntime('stop')} disabled={busy}>
|
|
91
|
+
<IconStop size={13} /> {t('stop')}
|
|
92
|
+
</Button>
|
|
93
|
+
)}
|
|
94
|
+
{runtime?.canRestart === true && (
|
|
95
|
+
<Button size="sm" onClick={() => onRuntime('restart')} disabled={busy}>
|
|
96
|
+
<IconRestart size={13} /> {t('restart')}
|
|
97
|
+
</Button>
|
|
98
|
+
)}
|
|
99
|
+
{runtime?.canStart !== true && runtime?.canStop !== true && runtime?.canRestart !== true && (
|
|
100
|
+
<span className="dsh-docker-muted">{status.capabilities?.enableRuntimeControl === false ? t('runtimeControlDisabled') : runtime?.error ?? t('remoteHint')}</span>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</section>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function RecentContainers({ t, list, onOpenTab, onInspect }) {
|
|
109
|
+
if (list.loading && list.data === null) return <LoadingSkeleton rows={4} />
|
|
110
|
+
if (list.error !== null && list.data === null) {
|
|
111
|
+
return <ErrorState title={t('listFailed')} text={list.error.message} retryLabel={t('retry')} onRetry={list.reload} />
|
|
112
|
+
}
|
|
113
|
+
const items = (list.data?.items ?? []).slice(0, 5)
|
|
114
|
+
if (items.length === 0) {
|
|
115
|
+
return <EmptyState icon={<IconBox size={19} />} title={t('emptyContainers')} />
|
|
116
|
+
}
|
|
117
|
+
return (
|
|
118
|
+
<div className="dsh-docker-table-wrap">
|
|
119
|
+
<table className="dsh-docker-table">
|
|
120
|
+
<thead>
|
|
121
|
+
<tr>
|
|
122
|
+
<th>{t('name')}</th>
|
|
123
|
+
<th>{t('image')}</th>
|
|
124
|
+
<th>{t('status')}</th>
|
|
125
|
+
<th>{t('ports')}</th>
|
|
126
|
+
</tr>
|
|
127
|
+
</thead>
|
|
128
|
+
<tbody>
|
|
129
|
+
{items.map((item) => (
|
|
130
|
+
<tr key={item.id} onClick={() => onInspect('container', item.id)} style={{ cursor: 'pointer' }}>
|
|
131
|
+
<td><span className="dsh-docker-row-title">{item.name || item.id}</span></td>
|
|
132
|
+
<td className="dsh-docker-mono dsh-docker-truncate">{item.image}</td>
|
|
133
|
+
<td><StateLabel state={item.state} t={t} /></td>
|
|
134
|
+
<td><Chips values={item.ports.slice(0, 2)} mono /></td>
|
|
135
|
+
</tr>
|
|
136
|
+
))}
|
|
137
|
+
</tbody>
|
|
138
|
+
</table>
|
|
139
|
+
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '8px 12px' }}>
|
|
140
|
+
<Button size="sm" variant="ghost" onClick={() => onOpenTab('containers')}>{t('viewAll')}</Button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function DiskCard({ t, list }) {
|
|
147
|
+
if (list.loading && list.data === null) return <LoadingSkeleton rows={3} />
|
|
148
|
+
if (list.error !== null && list.data === null) return <div className="dsh-docker-muted" style={{ padding: 16 }}>{list.error.message}</div>
|
|
149
|
+
const items = list.data?.items ?? []
|
|
150
|
+
if (items.length === 0) return <div className="dsh-docker-muted" style={{ padding: 16 }}>{t('noDiskData')}</div>
|
|
151
|
+
return (
|
|
152
|
+
<div className="dsh-docker-table-wrap">
|
|
153
|
+
<table className="dsh-docker-table">
|
|
154
|
+
<thead>
|
|
155
|
+
<tr>
|
|
156
|
+
<th>{t('status')}</th>
|
|
157
|
+
<th className="dsh-docker-col-num">{t('size')}</th>
|
|
158
|
+
<th className="dsh-docker-col-num">Reclaimable</th>
|
|
159
|
+
</tr>
|
|
160
|
+
</thead>
|
|
161
|
+
<tbody>
|
|
162
|
+
{items.map((item) => (
|
|
163
|
+
<tr key={item.type}>
|
|
164
|
+
<td>{item.type}</td>
|
|
165
|
+
<td className="dsh-docker-col-num">{displayValue(item.size)}</td>
|
|
166
|
+
<td className="dsh-docker-col-num">{displayValue(item.reclaimable)}</td>
|
|
167
|
+
</tr>
|
|
168
|
+
))}
|
|
169
|
+
</tbody>
|
|
170
|
+
</table>
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export default function OverviewTab({ t, api, provider, context, status, refreshToken, onRuntime, onOpenTab, onInspect, onQuickCommand, runState }) {
|
|
176
|
+
const enabled = status?.daemon?.state === 'running'
|
|
177
|
+
const containers = useDockerList(api, provider, 'containers', context, { all: true, refreshToken, enabled })
|
|
178
|
+
const images = useDockerList(api, provider, 'images', context, { refreshToken, enabled })
|
|
179
|
+
const volumes = useDockerList(api, provider, 'volumes', context, { refreshToken, enabled })
|
|
180
|
+
const networks = useDockerList(api, provider, 'networks', context, { refreshToken, enabled })
|
|
181
|
+
const disk = useDockerList(api, provider, 'disk', context, { refreshToken, enabled })
|
|
182
|
+
const busy = runState?.run !== null && runState?.run !== undefined && (runState.run.status === 'running' || runState.run.status === 'cancelling')
|
|
183
|
+
const server = status?.server
|
|
184
|
+
const isApple = provider === 'apple-container'
|
|
185
|
+
const quick = isApple ? ['ls -a', 'image ls', 'volume ls', 'network ls', 'system df', 'system version'] : ['ps -a', 'images', 'volume ls', 'network ls', 'compose ls', 'system df', 'info', 'version']
|
|
186
|
+
const commandPrefix = isApple ? 'container' : 'docker'
|
|
187
|
+
const recent = useMemo(() => (containers.data?.items ?? []).slice(0, 5), [containers.data])
|
|
188
|
+
|
|
189
|
+
const volumeCount = volumes.data?.items?.length
|
|
190
|
+
const networkCount = networks.data?.items?.length
|
|
191
|
+
const imageCount = images.data?.items?.length ?? server?.images
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className="dsh-docker-stack">
|
|
195
|
+
<div className="dsh-docker-stats">
|
|
196
|
+
<Stat label={t('runningContainers')} value={server?.containersRunning ?? '—'} detail={isApple ? t('providerApple') : context ?? t('noContext')} />
|
|
197
|
+
<Stat label={t('stoppedContainers')} value={server?.containersStopped ?? '—'} />
|
|
198
|
+
<Stat label={t('imageCount')} value={imageCount ?? '—'} />
|
|
199
|
+
<Stat label={t('volumeCount')} value={volumeCount ?? '—'} />
|
|
200
|
+
<Stat label={t('networkCount')} value={networkCount ?? '—'} />
|
|
201
|
+
<Stat
|
|
202
|
+
label={t('server')}
|
|
203
|
+
value={provider === 'apple-container' ? status?.client?.version ?? server?.version ?? '—' : server?.version ?? status?.client?.version ?? '—'}
|
|
204
|
+
detail={server?.ncpu !== undefined ? `${server.ncpu} CPU · ${formatBytes(server.memTotal)}` : undefined}
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<div className="dsh-docker-grid-2">
|
|
209
|
+
<RuntimeCard t={t} provider={provider} status={status} imageCount={imageCount} volumeCount={volumeCount} networkCount={networkCount} onRuntime={onRuntime} busy={busy} />
|
|
210
|
+
<section className="dsh-docker-card">
|
|
211
|
+
<div className="dsh-docker-card-head">
|
|
212
|
+
<div>
|
|
213
|
+
<h2 className="dsh-docker-card-title">{t('quickCommands')}</h2>
|
|
214
|
+
<p className="dsh-docker-card-sub">{t('terminal')}</p>
|
|
215
|
+
</div>
|
|
216
|
+
<IconTerminal size={16} className="dsh-docker-muted" />
|
|
217
|
+
</div>
|
|
218
|
+
<div className="dsh-docker-card-body">
|
|
219
|
+
<div className="dsh-docker-chip-row">
|
|
220
|
+
{quick.map((command) => (
|
|
221
|
+
<button key={command} type="button" className="dsh-docker-quick" onClick={() => onQuickCommand(`${commandPrefix} ${command}`)}>
|
|
222
|
+
{command}
|
|
223
|
+
</button>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
<div className="dsh-docker-divider" />
|
|
227
|
+
<div className="dsh-docker-kv">
|
|
228
|
+
<div className="dsh-docker-kv-key">{t('client')}</div>
|
|
229
|
+
<div className="dsh-docker-kv-value">{status?.client?.version ?? '—'} {status?.cli?.path ? <span className="dsh-docker-mono dsh-docker-muted"> · {status.cli.path}</span> : null}</div>
|
|
230
|
+
<div className="dsh-docker-kv-key">{isApple ? t('providerLabel') : t('context')}</div>
|
|
231
|
+
<div className="dsh-docker-kv-value">{isApple ? t('providerApple') : context ?? status?.context?.selected ?? status?.context?.current ?? t('noContext')}</div>
|
|
232
|
+
<div className="dsh-docker-kv-key">{t('lastUpdated')}</div>
|
|
233
|
+
<div className="dsh-docker-kv-value">{formatTime(status?.fetchedAt)}</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</section>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<div className="dsh-docker-grid-2">
|
|
240
|
+
<section className="dsh-docker-card">
|
|
241
|
+
<div className="dsh-docker-card-head">
|
|
242
|
+
<div>
|
|
243
|
+
<h2 className="dsh-docker-card-title">{t('recentContainers')}</h2>
|
|
244
|
+
</div>
|
|
245
|
+
<Button size="sm" variant="ghost" onClick={() => onOpenTab('containers')}>{t('viewAll')}</Button>
|
|
246
|
+
</div>
|
|
247
|
+
<div className="dsh-docker-card-body dsh-docker-flush" style={{ paddingTop: 10 }}>
|
|
248
|
+
<RecentContainers t={t} list={{ ...containers, data: containers.data ?? { items: recent } }} onOpenTab={onOpenTab} onInspect={onInspect} />
|
|
249
|
+
</div>
|
|
250
|
+
</section>
|
|
251
|
+
<section className="dsh-docker-card">
|
|
252
|
+
<div className="dsh-docker-card-head">
|
|
253
|
+
<div>
|
|
254
|
+
<h2 className="dsh-docker-card-title">{t('disk')}</h2>
|
|
255
|
+
<p className="dsh-docker-card-sub">{commandPrefix} system df</p>
|
|
256
|
+
</div>
|
|
257
|
+
<IconImage size={16} className="dsh-docker-muted" />
|
|
258
|
+
</div>
|
|
259
|
+
<div className="dsh-docker-card-body dsh-docker-flush" style={{ paddingTop: 10 }}>
|
|
260
|
+
<DiskCard t={t} list={disk} />
|
|
261
|
+
</div>
|
|
262
|
+
</section>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
)
|
|
266
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Volumes tab. */
|
|
2
|
+
import { useMemo, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { Button, EmptyState, ErrorState, IconButton, LoadingSkeleton, SearchInput, displayValue } from '../components.jsx'
|
|
5
|
+
import { useDockerList } from '../hooks.js'
|
|
6
|
+
import { IconAlert, IconInfo, IconTrash, IconVolume } from '../icons.jsx'
|
|
7
|
+
|
|
8
|
+
export default function VolumesTab({ t, api, provider, context, status, refreshToken, onAction, onInspect }) {
|
|
9
|
+
const [search, setSearch] = useState('')
|
|
10
|
+
const enabled = status?.daemon?.state === 'running'
|
|
11
|
+
const list = useDockerList(api, provider, 'volumes', context, { refreshToken, enabled })
|
|
12
|
+
const items = list.data?.items ?? []
|
|
13
|
+
const filtered = useMemo(() => {
|
|
14
|
+
const needle = search.trim().toLowerCase()
|
|
15
|
+
return needle === '' ? items : items.filter((item) => `${item.name} ${item.driver} ${item.mountpoint}`.toLowerCase().includes(needle))
|
|
16
|
+
}, [items, search])
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="dsh-docker-stack">
|
|
20
|
+
<div className="dsh-docker-toolbar">
|
|
21
|
+
<div className="dsh-docker-toolbar-left">
|
|
22
|
+
<SearchInput value={search} onChange={setSearch} placeholder={t('search')} label={t('search')} />
|
|
23
|
+
</div>
|
|
24
|
+
<div className="dsh-docker-toolbar-right">
|
|
25
|
+
<span className="dsh-docker-muted">{filtered.length} / {items.length}</span>
|
|
26
|
+
<Button size="sm" onClick={list.reload} disabled={list.loading}>{t('refresh')}</Button>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
{!enabled && items.length === 0 ? (
|
|
30
|
+
<EmptyState icon={<IconAlert size={20} />} title={provider === 'apple-container' ? t('appleSystemStopped') : t('daemonStopped')} text={provider === 'apple-container' ? t('appleSystemStoppedBody') : t('daemonStoppedBody')} />
|
|
31
|
+
) : list.loading && list.data === null ? (
|
|
32
|
+
<LoadingSkeleton rows={5} />
|
|
33
|
+
) : list.error !== null && list.data === null ? (
|
|
34
|
+
<ErrorState title={t('listFailed')} text={list.error.message} retryLabel={t('retry')} onRetry={list.reload} />
|
|
35
|
+
) : filtered.length === 0 ? (
|
|
36
|
+
<EmptyState icon={<IconVolume size={20} />} title={search === '' ? t('emptyVolumes') : t('emptySearch')} text={search === '' ? undefined : search} />
|
|
37
|
+
) : (
|
|
38
|
+
<section className="dsh-docker-card">
|
|
39
|
+
<div className="dsh-docker-table-wrap">
|
|
40
|
+
<table className="dsh-docker-table">
|
|
41
|
+
<thead>
|
|
42
|
+
<tr>
|
|
43
|
+
<th>{t('name')}</th>
|
|
44
|
+
<th>{t('driver')}</th>
|
|
45
|
+
<th>{t('scope')}</th>
|
|
46
|
+
<th>{t('mountpoint')}</th>
|
|
47
|
+
<th className="dsh-docker-col-actions">{t('actions')}</th>
|
|
48
|
+
</tr>
|
|
49
|
+
</thead>
|
|
50
|
+
<tbody>
|
|
51
|
+
{filtered.map((item) => (
|
|
52
|
+
<tr key={item.name}>
|
|
53
|
+
<td className="dsh-docker-mono dsh-docker-row-title">{item.name}</td>
|
|
54
|
+
<td>{displayValue(item.driver)}</td>
|
|
55
|
+
<td>{displayValue(item.scope)}</td>
|
|
56
|
+
<td className="dsh-docker-mono dsh-docker-muted dsh-docker-truncate" title={item.mountpoint}>{displayValue(item.mountpoint)}</td>
|
|
57
|
+
<td className="dsh-docker-col-actions">
|
|
58
|
+
<span className="dsh-docker-row-actions">
|
|
59
|
+
<IconButton size="sm" label={t('inspect')} onClick={() => onInspect('volume', item.name)}>
|
|
60
|
+
<IconInfo size={14} />
|
|
61
|
+
</IconButton>
|
|
62
|
+
<IconButton size="sm" label={t('removeVolume')} onClick={() => onAction({ kind: 'volume', action: 'delete', id: item.name })}>
|
|
63
|
+
<IconTrash size={14} />
|
|
64
|
+
</IconButton>
|
|
65
|
+
</span>
|
|
66
|
+
</td>
|
|
67
|
+
</tr>
|
|
68
|
+
))}
|
|
69
|
+
</tbody>
|
|
70
|
+
</table>
|
|
71
|
+
</div>
|
|
72
|
+
</section>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apple `container` CLI discovery and process execution.
|
|
3
|
+
*
|
|
4
|
+
* Apple container is not Docker: there is no daemon socket, no Docker context,
|
|
5
|
+
* and every container runs in its own lightweight VM. The CLI is invoked
|
|
6
|
+
* directly and its `--format json` output is parsed by the provider.
|
|
7
|
+
*/
|
|
8
|
+
import { accessSync, constants, statSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { summarizeStderr } from './parsers.js'
|
|
13
|
+
import { codeOf, messageOf } from './util.js'
|
|
14
|
+
|
|
15
|
+
const DEFAULT_MAX_OUTPUT = 1024 * 1024
|
|
16
|
+
const DEFAULT_TIMEOUT = 20_000
|
|
17
|
+
const RESOLVE_RETRY_MS = 10_000
|
|
18
|
+
|
|
19
|
+
export class AppleCliError extends Error {
|
|
20
|
+
/** @param {string} message @param {string} code @param {Record<string, unknown>} details */
|
|
21
|
+
constructor(message, code = 'container/cli-missing', details = {}) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = 'AppleCliError'
|
|
24
|
+
this.code = code
|
|
25
|
+
this.details = details
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class AppleCommandError extends Error {
|
|
30
|
+
/** @param {string} message @param {{code?: string, stderr?: string, details?: Record<string, unknown>}} [options] */
|
|
31
|
+
constructor(message, options = {}) {
|
|
32
|
+
super(message)
|
|
33
|
+
this.name = 'AppleCommandError'
|
|
34
|
+
this.code = options.code ?? 'container/exec-failed'
|
|
35
|
+
this.stderr = options.stderr ?? ''
|
|
36
|
+
this.details = options.details ?? {}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {string} path */
|
|
41
|
+
function executable(path) {
|
|
42
|
+
try {
|
|
43
|
+
accessSync(path, constants.X_OK)
|
|
44
|
+
return statSync(path).isFile()
|
|
45
|
+
} catch {
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {number} value @param {number} fallback */
|
|
51
|
+
function clampBytes(value, fallback) {
|
|
52
|
+
return Number.isFinite(value) ? Math.min(Math.max(Math.trunc(value), 64 * 1024), 8 * 1024 * 1024) : fallback
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class AppleCli {
|
|
56
|
+
/**
|
|
57
|
+
* @param {{
|
|
58
|
+
* subprocess: any,
|
|
59
|
+
* config?: Record<string, any>,
|
|
60
|
+
* env?: NodeJS.ProcessEnv,
|
|
61
|
+
* home?: string,
|
|
62
|
+
* platform?: NodeJS.Platform,
|
|
63
|
+
* logger?: any,
|
|
64
|
+
* }} options
|
|
65
|
+
*/
|
|
66
|
+
constructor(options) {
|
|
67
|
+
this.subprocess = options.subprocess
|
|
68
|
+
this.config = options.config ?? {}
|
|
69
|
+
this.env = options.env ?? process.env
|
|
70
|
+
this.home = options.home ?? homedir()
|
|
71
|
+
this.platform = options.platform ?? process.platform
|
|
72
|
+
this.logger = options.logger
|
|
73
|
+
this.maxOutput = clampBytes(Number(this.config.maxOutputBytes), DEFAULT_MAX_OUTPUT)
|
|
74
|
+
this.containerPath = undefined
|
|
75
|
+
this.lastAttempt = 0
|
|
76
|
+
this.error = undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @returns {string[]} */
|
|
80
|
+
candidates() {
|
|
81
|
+
const configured = typeof this.config.containerPath === 'string' && this.config.containerPath.trim() !== '' ? this.config.containerPath.trim() : null
|
|
82
|
+
const envPath = typeof this.env.CONTAINER_PATH === 'string' && this.env.CONTAINER_PATH.trim() !== '' ? this.env.CONTAINER_PATH.trim() : null
|
|
83
|
+
return [
|
|
84
|
+
configured,
|
|
85
|
+
envPath,
|
|
86
|
+
'/usr/local/bin/container',
|
|
87
|
+
'/opt/homebrew/bin/container',
|
|
88
|
+
join(this.home, '.local/bin/container'),
|
|
89
|
+
].filter((value, index, list) => typeof value === 'string' && value !== '' && list.indexOf(value) === index)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {AbortSignal | undefined} signal */
|
|
93
|
+
async resolveOnPath(signal) {
|
|
94
|
+
if (typeof this.subprocess.resolveExecutable !== 'function') return undefined
|
|
95
|
+
try {
|
|
96
|
+
return await this.subprocess.resolveExecutable('container', undefined, signal)
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error?.name === 'SubprocessExecutableNotFoundError' || codeOf(error) === 'ENOENT') return undefined
|
|
99
|
+
throw error
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {{signal?: AbortSignal, force?: boolean}} [options]
|
|
105
|
+
* @returns {Promise<string>}
|
|
106
|
+
*/
|
|
107
|
+
async ensureContainer(options = {}) {
|
|
108
|
+
if (this.containerPath !== undefined) return this.containerPath
|
|
109
|
+
const now = Date.now()
|
|
110
|
+
if (!options.force && now - this.lastAttempt < RESOLVE_RETRY_MS && this.error !== undefined) throw this.error
|
|
111
|
+
this.lastAttempt = now
|
|
112
|
+
const configured = typeof this.config.containerPath === 'string' && this.config.containerPath.trim() !== '' ? this.config.containerPath.trim() : null
|
|
113
|
+
if (configured !== null) {
|
|
114
|
+
const path = isAbsolute(configured) ? configured : resolve(this.home, configured)
|
|
115
|
+
if (executable(path)) {
|
|
116
|
+
this.containerPath = path
|
|
117
|
+
this.error = undefined
|
|
118
|
+
return path
|
|
119
|
+
}
|
|
120
|
+
const error = new AppleCliError(`配置的 containerPath 不可执行: ${configured}`, 'container/cli-missing', { candidates: [configured] })
|
|
121
|
+
this.error = error
|
|
122
|
+
throw error
|
|
123
|
+
}
|
|
124
|
+
for (const candidate of this.candidates()) {
|
|
125
|
+
const path = isAbsolute(candidate) ? candidate : resolve(this.home, candidate)
|
|
126
|
+
if (executable(path)) {
|
|
127
|
+
this.containerPath = path
|
|
128
|
+
this.error = undefined
|
|
129
|
+
return path
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const onPath = await this.resolveOnPath(options.signal)
|
|
133
|
+
if (onPath !== undefined && executable(onPath)) {
|
|
134
|
+
this.containerPath = onPath
|
|
135
|
+
this.error = undefined
|
|
136
|
+
return onPath
|
|
137
|
+
}
|
|
138
|
+
const error = new AppleCliError(
|
|
139
|
+
'未找到 Apple container 命令。请安装 container(macOS 26 / Apple silicon)并执行 container system start。',
|
|
140
|
+
'container/cli-missing',
|
|
141
|
+
{ candidates: this.candidates() },
|
|
142
|
+
)
|
|
143
|
+
this.error = error
|
|
144
|
+
throw error
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @returns {NodeJS.ProcessEnv} */
|
|
148
|
+
childEnv() {
|
|
149
|
+
const pathParts = []
|
|
150
|
+
if (typeof this.containerPath === 'string') pathParts.push(dirname(this.containerPath))
|
|
151
|
+
pathParts.push('/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin')
|
|
152
|
+
if (typeof this.env.PATH === 'string') pathParts.push(...this.env.PATH.split(':'))
|
|
153
|
+
return {
|
|
154
|
+
PATH: [...new Set(pathParts.filter(Boolean))].join(':'),
|
|
155
|
+
NO_COLOR: '1',
|
|
156
|
+
CLICOLOR: '0',
|
|
157
|
+
TERM: 'dumb',
|
|
158
|
+
CONTAINER_DEBUG: '0',
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @param {string} path */
|
|
163
|
+
markMissing(path) {
|
|
164
|
+
if (this.containerPath === path) {
|
|
165
|
+
this.containerPath = undefined
|
|
166
|
+
this.error = undefined
|
|
167
|
+
this.lastAttempt = 0
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {string[]} subargv
|
|
173
|
+
* @param {{cwd?: string, timeoutMs?: number, signal?: AbortSignal, maxBytes?: number}} [options]
|
|
174
|
+
*/
|
|
175
|
+
async exec(subargv, options = {}) {
|
|
176
|
+
const path = await this.ensureContainer({ signal: options.signal })
|
|
177
|
+
const maxBytes = clampBytes(Number(options.maxBytes ?? this.maxOutput), this.maxOutput)
|
|
178
|
+
const timeoutMs = Math.min(Math.max(Number(options.timeoutMs ?? DEFAULT_TIMEOUT), 1000), 10 * 60_000)
|
|
179
|
+
const abort = new AbortController()
|
|
180
|
+
let timedOut = false
|
|
181
|
+
const onAbort = () => abort.abort(options.signal?.reason)
|
|
182
|
+
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
183
|
+
if (options.signal?.aborted === true) abort.abort(options.signal.reason)
|
|
184
|
+
const timeout = setTimeout(() => {
|
|
185
|
+
timedOut = true
|
|
186
|
+
abort.abort(new Error('container command timed out'))
|
|
187
|
+
}, timeoutMs)
|
|
188
|
+
timeout.unref?.()
|
|
189
|
+
|
|
190
|
+
let handle
|
|
191
|
+
try {
|
|
192
|
+
handle = this.subprocess.spawn({
|
|
193
|
+
argv: [path, ...subargv],
|
|
194
|
+
cwd: options.cwd ?? this.home,
|
|
195
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes }, stderr: { maxBytes } },
|
|
196
|
+
graceMs: 2000,
|
|
197
|
+
env: this.childEnv(),
|
|
198
|
+
signal: abort.signal,
|
|
199
|
+
})
|
|
200
|
+
} catch (error) {
|
|
201
|
+
clearTimeout(timeout)
|
|
202
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
203
|
+
throw new AppleCommandError(`无法启动 container: ${messageOf(error)}`, { code: 'container/exec-failed' })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let outcome
|
|
207
|
+
try {
|
|
208
|
+
outcome = await handle.done
|
|
209
|
+
} catch (error) {
|
|
210
|
+
clearTimeout(timeout)
|
|
211
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
212
|
+
this.markMissing(path)
|
|
213
|
+
throw new AppleCommandError(`container 进程失败: ${messageOf(error)}`, { code: 'container/exec-failed' })
|
|
214
|
+
}
|
|
215
|
+
clearTimeout(timeout)
|
|
216
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
217
|
+
const stdout = handle.collected?.stdout?.readFrom(0)?.text ?? ''
|
|
218
|
+
const stderr = handle.collected?.stderr?.readFrom(0)?.text ?? ''
|
|
219
|
+
return { exitCode: outcome.exitCode, signal: outcome.signal, stdout, stderr, timedOut, argv: [path, ...subargv] }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Execute and require a zero exit code.
|
|
224
|
+
* @param {string[]} subargv
|
|
225
|
+
* @param {{cwd?: string, timeoutMs?: number, signal?: AbortSignal, maxBytes?: number}} [options]
|
|
226
|
+
*/
|
|
227
|
+
async ok(subargv, options = {}) {
|
|
228
|
+
const result = await this.exec(subargv, options)
|
|
229
|
+
if (result.timedOut || result.exitCode !== 0) {
|
|
230
|
+
const detail = summarizeStderr(result.stderr)
|
|
231
|
+
throw new AppleCommandError(
|
|
232
|
+
result.timedOut ? `container ${subargv[0] ?? ''} 超时` : detail.trim() === '' ? `container ${subargv[0] ?? ''} 退出码 ${String(result.exitCode)}` : detail,
|
|
233
|
+
{
|
|
234
|
+
code: result.timedOut ? 'container/timeout' : 'container/exec-failed',
|
|
235
|
+
stderr: result.stderr,
|
|
236
|
+
details: { argv: result.argv, exitCode: result.exitCode, timedOut: result.timedOut },
|
|
237
|
+
},
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
return result
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @param {string[]} subargv
|
|
245
|
+
* @param {{cwd?: string, timeoutMs?: number, signal?: AbortSignal, maxBytes?: number}} [options]
|
|
246
|
+
*/
|
|
247
|
+
async json(subargv, options = {}) {
|
|
248
|
+
const result = await this.ok(subargv, options)
|
|
249
|
+
try {
|
|
250
|
+
return JSON.parse(result.stdout)
|
|
251
|
+
} catch (error) {
|
|
252
|
+
throw new AppleCommandError(`无法解析 container JSON: ${messageOf(error)}`, {
|
|
253
|
+
code: 'container/protocol',
|
|
254
|
+
stderr: result.stderr,
|
|
255
|
+
details: { stdout: result.stdout.slice(0, 2000) },
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** @param {AbortSignal | undefined} signal */
|
|
261
|
+
async systemStatus(signal) {
|
|
262
|
+
try {
|
|
263
|
+
const result = await this.exec(['system', 'status', '--format', 'json'], { signal, timeoutMs: 10_000 })
|
|
264
|
+
if (result.exitCode !== 0) {
|
|
265
|
+
return { status: 'unavailable', error: summarizeStderr(result.stderr) || `exit ${String(result.exitCode)}`, raw: null }
|
|
266
|
+
}
|
|
267
|
+
return JSON.parse(result.stdout)
|
|
268
|
+
} catch (error) {
|
|
269
|
+
return { status: 'unavailable', error: messageOf(error), raw: null }
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** @param {AbortSignal | undefined} signal */
|
|
274
|
+
async systemVersion(signal) {
|
|
275
|
+
try {
|
|
276
|
+
const raw = await this.json(['system', 'version', '--format', 'json'], { signal, timeoutMs: 10_000 })
|
|
277
|
+
return Array.isArray(raw) ? raw : [raw]
|
|
278
|
+
} catch {
|
|
279
|
+
return []
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** @param {AbortSignal | undefined} signal */
|
|
284
|
+
async df(signal) {
|
|
285
|
+
return this.json(['system', 'df', '--format', 'json'], { signal, timeoutMs: 30_000 })
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* `container system status` has reported `unregistered` on installs where the
|
|
290
|
+
* apiserver is still reachable; a lightweight list is the authoritative
|
|
291
|
+
* health probe. Returns undefined when the service is not reachable.
|
|
292
|
+
* @param {AbortSignal | undefined} signal
|
|
293
|
+
*/
|
|
294
|
+
async probe(signal) {
|
|
295
|
+
try {
|
|
296
|
+
const raw = await this.json(['list', '-a', '--format', 'json'], { signal, timeoutMs: 10_000 })
|
|
297
|
+
return Array.isArray(raw) ? raw : []
|
|
298
|
+
} catch {
|
|
299
|
+
return undefined
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** @param {string} path */
|
|
304
|
+
static commandName(path) {
|
|
305
|
+
return basename(path)
|
|
306
|
+
}
|
|
307
|
+
}
|