560 lines
21 KiB
Svelte
560 lines
21 KiB
Svelte
<script lang="ts">
|
||
import { onDestroy, onMount, tick } from 'svelte';
|
||
import { Terminal } from '@xterm/xterm';
|
||
import '@xterm/xterm/css/xterm.css';
|
||
import { api, post, del, wsUrl } from '../lib/api';
|
||
import { requireConfirm, requireDangerConfirm, withConfirm } from '../lib/confirm';
|
||
export let id: string;
|
||
let status: any = {};
|
||
let containers: any[] = [];
|
||
let images: any[] = [];
|
||
let volumes: any[] = [];
|
||
let composeProjects: any[] = [];
|
||
let logs = '';
|
||
let selectedContainer = '';
|
||
let containerDetail: any = null;
|
||
let containerStats: any = null;
|
||
let execContainer: any = null;
|
||
let execEl: HTMLDivElement;
|
||
let execTerm: Terminal | null = null;
|
||
let execWs: WebSocket | null = null;
|
||
let imageName = '';
|
||
let pulling = false;
|
||
let runImage = 'nginx:latest';
|
||
let runName = '';
|
||
let runPorts = '8080:80';
|
||
let runVolumes = '';
|
||
let runEnv = '';
|
||
let runRestart = 'unless-stopped';
|
||
let runCommand = '';
|
||
let running = false;
|
||
let composeProject = 'lightops-app';
|
||
let composeWorkDir = '/opt/lightops-compose/lightops-app';
|
||
let composeContent = `services:
|
||
app:
|
||
image: nginx:latest
|
||
ports:
|
||
- "8080:80"
|
||
restart: unless-stopped
|
||
`;
|
||
let deployingCompose = false;
|
||
let proxyVisible = false;
|
||
let proxyTarget = '';
|
||
let proxySiteName = '';
|
||
let proxyServerName = '';
|
||
let proxyUpstream = 'http://127.0.0.1:3000';
|
||
let proxyIssueSsl = false;
|
||
let proxySslEmail = '';
|
||
let creatingProxy = false;
|
||
let error = '';
|
||
onMount(load);
|
||
onDestroy(closeExec);
|
||
async function load() {
|
||
error = '';
|
||
try {
|
||
status = await api(`/api/agents/${id}/docker/status`);
|
||
containers = ((await api(`/api/agents/${id}/docker/containers`)) as any).containers || [];
|
||
images = ((await api(`/api/agents/${id}/docker/images`)) as any).images || [];
|
||
volumes = ((await api(`/api/agents/${id}/docker/volumes`)) as any).volumes || [];
|
||
composeProjects = ((await api(`/api/agents/${id}/docker/compose/projects`)) as any).projects || [];
|
||
} catch (err: any) {
|
||
error = err.message;
|
||
}
|
||
}
|
||
async function act(cid: string, action: string) {
|
||
const label = ({ start: '启动', stop: '停止', restart: '重启' } as Record<string, string>)[action] || action;
|
||
if (action === 'start') {
|
||
if (!requireConfirm(`确认${label}容器 ${cid}?`)) return;
|
||
await post(`/api/agents/${id}/docker/containers/${cid}/${action}`, {});
|
||
} else {
|
||
const confirmInfo = requireDangerConfirm(`${label}容器`, cid);
|
||
if (!confirmInfo) return;
|
||
await post(`/api/agents/${id}/docker/containers/${cid}/${action}`, withConfirm({}, confirmInfo));
|
||
}
|
||
await load();
|
||
}
|
||
async function rm(cid: string) {
|
||
if (!requireDangerConfirm('删除容器', cid)) return;
|
||
await del(`/api/agents/${id}/docker/containers/${cid}`);
|
||
await load();
|
||
}
|
||
async function showLogs(cid: string) {
|
||
error = '';
|
||
try {
|
||
const data: any = await api(`/api/agents/${id}/docker/containers/${cid}/logs`);
|
||
logs = data.stdout || data.stderr || '';
|
||
} catch (err: any) {
|
||
error = err.message;
|
||
}
|
||
}
|
||
|
||
async function showContainerDetail(cid: string) {
|
||
error = '';
|
||
selectedContainer = cid;
|
||
containerDetail = null;
|
||
containerStats = null;
|
||
try {
|
||
const [detail, stats]: any[] = await Promise.all([
|
||
api(`/api/agents/${id}/docker/containers/${cid}/inspect`),
|
||
api(`/api/agents/${id}/docker/containers/${cid}/stats`)
|
||
]);
|
||
containerDetail = detail.detail || detail;
|
||
containerStats = stats.stats || stats;
|
||
} catch (err: any) {
|
||
error = err.message;
|
||
}
|
||
}
|
||
|
||
async function openExec(container: any) {
|
||
closeExec();
|
||
execContainer = container;
|
||
await tick();
|
||
execTerm = new Terminal({
|
||
cursorBlink: true,
|
||
fontFamily: 'JetBrains Mono, monospace',
|
||
theme: { background: '#08110f' }
|
||
});
|
||
execTerm.open(execEl);
|
||
execTerm.writeln(`正在进入容器 ${container.names || container.id} ...\r\n`);
|
||
execWs = new WebSocket(wsUrl(`/api/agents/${id}/docker/containers/${encodeURIComponent(container.id)}/exec`));
|
||
execWs.onopen = () => execTerm?.writeln('Docker exec 终端已连接,默认 shell: sh\r\n');
|
||
execWs.onmessage = (ev) => execTerm?.write(ev.data);
|
||
execWs.onclose = () => execTerm?.writeln('\r\n连接已关闭');
|
||
execWs.onerror = () => execTerm?.writeln('\r\n连接失败或无权限');
|
||
execTerm.onData((data) => execWs?.readyState === WebSocket.OPEN && execWs.send(data));
|
||
}
|
||
|
||
function closeExec() {
|
||
execWs?.close();
|
||
execWs = null;
|
||
execTerm?.dispose();
|
||
execTerm = null;
|
||
execContainer = null;
|
||
}
|
||
async function rmi(imageId: string) {
|
||
if (!requireDangerConfirm('删除镜像', imageId)) return;
|
||
await del(`/api/agents/${id}/docker/images/${encodeURIComponent(imageId)}`);
|
||
await load();
|
||
}
|
||
|
||
async function removeVolume(name: string) {
|
||
if (!requireDangerConfirm('删除 Docker 数据卷', name)) return;
|
||
await del(`/api/agents/${id}/docker/volumes/${encodeURIComponent(name)}`);
|
||
await load();
|
||
}
|
||
|
||
async function pullImage() {
|
||
const image = imageName.trim();
|
||
if (!image) {
|
||
error = '请输入镜像名称,例如 nginx:latest';
|
||
return;
|
||
}
|
||
if (!requireConfirm(`确认拉取镜像 ${image}?大镜像可能需要较长时间。`)) return;
|
||
error = '';
|
||
logs = '';
|
||
pulling = true;
|
||
try {
|
||
const data: any = await post(`/api/agents/${id}/docker/images/pull`, { image });
|
||
logs = data.stdout || data.stderr || '镜像拉取完成';
|
||
imageName = '';
|
||
await load();
|
||
} catch (err: any) {
|
||
error = err.message;
|
||
} finally {
|
||
pulling = false;
|
||
}
|
||
}
|
||
|
||
async function runContainer() {
|
||
const image = runImage.trim();
|
||
if (!image) {
|
||
error = '请输入镜像名称';
|
||
return;
|
||
}
|
||
const confirmInfo = requireDangerConfirm('创建并启动容器', runName.trim() || image);
|
||
if (!confirmInfo) return;
|
||
error = '';
|
||
logs = '';
|
||
running = true;
|
||
try {
|
||
const data: any = await post(`/api/agents/${id}/docker/containers/run`, withConfirm({
|
||
image,
|
||
name: runName.trim() || null,
|
||
ports: parsePorts(runPorts),
|
||
volumes: parseVolumes(runVolumes),
|
||
env: parseEnv(runEnv),
|
||
restart: runRestart,
|
||
detach: true,
|
||
command: runCommand.trim() || null
|
||
}, confirmInfo));
|
||
logs = data.stdout || data.stderr || '容器已创建';
|
||
await load();
|
||
} catch (err: any) {
|
||
error = err.message;
|
||
} finally {
|
||
running = false;
|
||
}
|
||
}
|
||
|
||
async function deployCompose() {
|
||
const project = composeProject.trim();
|
||
const workDir = composeWorkDir.trim();
|
||
if (!project || !workDir || !composeContent.trim()) {
|
||
error = '请填写项目名、工作目录和 Compose 内容';
|
||
return;
|
||
}
|
||
const confirmInfo = requireDangerConfirm('部署 Compose 项目', project);
|
||
if (!confirmInfo) return;
|
||
error = '';
|
||
logs = '';
|
||
deployingCompose = true;
|
||
try {
|
||
const data: any = await post(`/api/agents/${id}/docker/compose/deploy`, withConfirm({
|
||
project,
|
||
work_dir: workDir,
|
||
content: composeContent
|
||
}, confirmInfo));
|
||
logs = data.stdout || data.stderr || 'Compose 项目已部署';
|
||
await load();
|
||
} catch (err: any) {
|
||
error = err.message;
|
||
} finally {
|
||
deployingCompose = false;
|
||
}
|
||
}
|
||
|
||
async function composeAct(project: string, action: string) {
|
||
const label = ({ start: '启动', stop: '停止', restart: '重启' } as Record<string, string>)[action] || action;
|
||
const confirmInfo = action === 'start' ? null : requireDangerConfirm(`${label} Compose 项目`, project);
|
||
if (action !== 'start' && !confirmInfo) return;
|
||
if (action === 'start' && !requireConfirm(`确认${label} Compose 项目 ${project}?`)) return;
|
||
await post(`/api/agents/${id}/docker/compose/projects/${encodeURIComponent(project)}/${action}`, withConfirm({}, confirmInfo));
|
||
await load();
|
||
}
|
||
|
||
async function showComposeLogs(project: string) {
|
||
error = '';
|
||
try {
|
||
const data: any = await api(`/api/agents/${id}/docker/compose/projects/${encodeURIComponent(project)}/logs`);
|
||
logs = data.stdout || data.stderr || '';
|
||
} catch (err: any) {
|
||
error = err.message;
|
||
}
|
||
}
|
||
|
||
function containerStatus(value: string) {
|
||
if (!value) return '-';
|
||
if (value.startsWith('Up')) return value.replace('Up', '运行中');
|
||
if (value.startsWith('Exited')) return value.replace('Exited', '已退出');
|
||
if (value.startsWith('Created')) return value.replace('Created', '已创建');
|
||
if (value.startsWith('Restarting')) return value.replace('Restarting', '重启中');
|
||
return value;
|
||
}
|
||
|
||
function parsePorts(value: string) {
|
||
return value
|
||
.split(/[,\n]+/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
.map((item) => {
|
||
const [host, containerProtocol] = item.split(':');
|
||
const [container, protocol = 'tcp'] = (containerProtocol || '').split('/');
|
||
return { host: Number(host), container: Number(container), protocol };
|
||
})
|
||
.filter((item) => item.host && item.container);
|
||
}
|
||
|
||
function parseVolumes(value: string) {
|
||
return value
|
||
.split(/[,\n]+/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
.map((item) => {
|
||
const parts = item.split(':');
|
||
return { host: parts[0], container: parts[1], readonly: parts[2] === 'ro' };
|
||
})
|
||
.filter((item) => item.host && item.container);
|
||
}
|
||
|
||
function parseEnv(value: string) {
|
||
const env: Record<string, string> = {};
|
||
for (const line of value.split('\n')) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed || !trimmed.includes('=')) continue;
|
||
const index = trimmed.indexOf('=');
|
||
env[trimmed.slice(0, index).trim()] = trimmed.slice(index + 1);
|
||
}
|
||
return env;
|
||
}
|
||
|
||
function openProxyForContainer(container: any) {
|
||
proxyVisible = true;
|
||
proxyTarget = container.names || container.id;
|
||
proxySiteName = normalizeSiteName(container.names || container.id);
|
||
proxyServerName = `${proxySiteName}.example.com`;
|
||
proxyUpstream = `http://127.0.0.1:${extractHostPort(container.ports) || '3000'}`;
|
||
}
|
||
|
||
function openProxyForCompose(project: any) {
|
||
proxyVisible = true;
|
||
proxyTarget = project.name;
|
||
proxySiteName = normalizeSiteName(project.name);
|
||
proxyServerName = `${proxySiteName}.example.com`;
|
||
proxyUpstream = 'http://127.0.0.1:3000';
|
||
}
|
||
|
||
async function createProxySite() {
|
||
const site = proxySiteName.trim();
|
||
const serverNameValue = proxyServerName.trim();
|
||
const upstreamValue = proxyUpstream.trim();
|
||
if (!site || !serverNameValue || !upstreamValue) {
|
||
error = '请填写站点名、域名和上游地址';
|
||
return;
|
||
}
|
||
const confirmInfo = requireDangerConfirm('创建并启用 Nginx 反代', site);
|
||
if (!confirmInfo) return;
|
||
error = '';
|
||
logs = '';
|
||
creatingProxy = true;
|
||
try {
|
||
const body = withConfirm(
|
||
{
|
||
name: site,
|
||
server_name: serverNameValue,
|
||
mode: 'proxy',
|
||
upstream: upstreamValue,
|
||
websocket: true,
|
||
gzip: true,
|
||
cache_static: true,
|
||
client_max_body_size: '64m',
|
||
source: 'docker',
|
||
target: proxyTarget
|
||
},
|
||
confirmInfo
|
||
);
|
||
await post(`/api/agents/${id}/nginx/sites`, body);
|
||
await post(`/api/agents/${id}/nginx/sites/${encodeURIComponent(site)}/enable`, withConfirm({}, confirmInfo));
|
||
await post(`/api/agents/${id}/nginx/reload`, withConfirm({}, confirmInfo));
|
||
if (proxyIssueSsl) {
|
||
await post(`/api/agents/${id}/nginx/ssl/issue`, withConfirm({
|
||
domains: serverNameValue.split(/[\s,]+/).filter(Boolean),
|
||
email: proxySslEmail || null
|
||
}, confirmInfo));
|
||
}
|
||
logs = `反代站点 ${site} 已创建,上游 ${upstreamValue}`;
|
||
proxyVisible = false;
|
||
} catch (err: any) {
|
||
error = err.message;
|
||
} finally {
|
||
creatingProxy = false;
|
||
}
|
||
}
|
||
|
||
function extractHostPort(ports: string) {
|
||
const match = ports?.match(/(?:0\.0\.0\.0|127\.0\.0\.1|\[?::\]?|\*)?:(\d+)->/);
|
||
return match?.[1] || '';
|
||
}
|
||
|
||
function normalizeSiteName(value: string) {
|
||
return value
|
||
.toLowerCase()
|
||
.replace(/^[\/]+/, '')
|
||
.replace(/[^a-z0-9._-]+/g, '-')
|
||
.replace(/^-+|-+$/g, '')
|
||
.slice(0, 64) || 'lightops-app';
|
||
}
|
||
|
||
function detailPairs(detail: any) {
|
||
if (!detail) return [];
|
||
const config = detail.Config || {};
|
||
const host = detail.HostConfig || {};
|
||
const state = detail.State || {};
|
||
return [
|
||
['容器 ID', detail.Id || '-'],
|
||
['镜像', config.Image || detail.Image || '-'],
|
||
['状态', state.Status || '-'],
|
||
['启动时间', state.StartedAt || '-'],
|
||
['重启策略', host.RestartPolicy?.Name || '-'],
|
||
['网络模式', host.NetworkMode || '-'],
|
||
['工作目录', config.WorkingDir || '-'],
|
||
['入口命令', [config.Entrypoint, config.Cmd].flat().filter(Boolean).join(' ') || '-']
|
||
];
|
||
}
|
||
|
||
function mountList(detail: any) {
|
||
return (detail?.Mounts || []).map((item: any) => `${item.Source || '-'} -> ${item.Destination || '-'}${item.RW === false ? ' (只读)' : ''}`);
|
||
}
|
||
|
||
function statValue(key: string) {
|
||
return containerStats?.[key] || '-';
|
||
}
|
||
</script>
|
||
|
||
<section class="panel">
|
||
<h1>Docker</h1>
|
||
<p class="muted">安装状态:{status.installed ? '已安装' : '未安装'} {status.version || ''}</p>
|
||
{#if error}<p class="error">{error}</p>{/if}
|
||
<h2>运行容器</h2>
|
||
<div class="nginx-create">
|
||
<div class="form-grid">
|
||
<label>镜像<input bind:value={runImage} placeholder="nginx:latest" /></label>
|
||
<label>容器名<input bind:value={runName} placeholder="可选,例如 my-nginx" /></label>
|
||
<label>端口映射<input bind:value={runPorts} placeholder="8080:80,8443:443/tcp" /></label>
|
||
<label>数据卷<input bind:value={runVolumes} placeholder="/host/data:/data,/host/conf:/conf:ro" /></label>
|
||
<label>重启策略
|
||
<select bind:value={runRestart}>
|
||
<option value="no">不自动重启</option>
|
||
<option value="always">always</option>
|
||
<option value="unless-stopped">unless-stopped</option>
|
||
<option value="on-failure">on-failure</option>
|
||
</select>
|
||
</label>
|
||
<label>启动命令<input bind:value={runCommand} placeholder="可选,例如 server --port 3000" /></label>
|
||
</div>
|
||
<label>环境变量<textarea bind:value={runEnv} rows="4" placeholder="TZ=Asia/Shanghai PUID=1000"></textarea></label>
|
||
<button on:click={runContainer} disabled={running}>{running ? '创建中...' : '创建并启动容器'}</button>
|
||
</div>
|
||
|
||
<h2>部署 Compose</h2>
|
||
<div class="nginx-create">
|
||
<div class="form-grid">
|
||
<label>项目名<input bind:value={composeProject} placeholder="my-app" /></label>
|
||
<label>工作目录<input bind:value={composeWorkDir} placeholder="/opt/lightops-compose/my-app" /></label>
|
||
</div>
|
||
<textarea bind:value={composeContent} rows="10" placeholder="docker compose yaml"></textarea>
|
||
<button on:click={deployCompose} disabled={deployingCompose}>{deployingCompose ? '部署中...' : '保存并 up -d'}</button>
|
||
</div>
|
||
|
||
<h2>Docker Compose 项目</h2>
|
||
<div class="table">
|
||
{#each composeProjects as project}
|
||
<div class="row">
|
||
<strong>{project.name}</strong>
|
||
<span>{project.status === 'Running' ? '运行中' : '已停止'}</span>
|
||
<span>服务:{(project.services || []).join(', ') || '-'}</span>
|
||
<span>容器:{(project.containers || []).length}</span>
|
||
<button on:click={() => composeAct(project.name, 'start')}>启动</button>
|
||
<button on:click={() => composeAct(project.name, 'restart')}>重启</button>
|
||
<button on:click={() => composeAct(project.name, 'stop')}>停止</button>
|
||
<button class="ghost" on:click={() => showComposeLogs(project.name)}>日志</button>
|
||
<button class="ghost" on:click={() => openProxyForCompose(project)}>创建反代</button>
|
||
</div>
|
||
{/each}
|
||
{#if composeProjects.length === 0}
|
||
<div class="empty-state"><p class="muted">未发现 Docker Compose 项目。</p></div>
|
||
{/if}
|
||
</div>
|
||
<h2>容器</h2>
|
||
<div class="table">
|
||
{#each containers as c}
|
||
<div class="row docker-row">
|
||
<strong>{c.names}</strong><span>{c.image}</span><span>{containerStatus(c.status)}</span><span>{c.ports || '-'}</span>
|
||
<button on:click={() => act(c.id, 'start')}>启动</button>
|
||
<button on:click={() => act(c.id, 'restart')}>重启</button>
|
||
<button on:click={() => act(c.id, 'stop')}>停止</button>
|
||
<button class="ghost" on:click={() => openExec(c)}>终端</button>
|
||
<button class="ghost" on:click={() => showContainerDetail(c.id)}>详情/资源</button>
|
||
<button class="ghost" on:click={() => showLogs(c.id)}>日志</button>
|
||
<button class="ghost" on:click={() => openProxyForContainer(c)}>创建反代</button>
|
||
<button class="danger" on:click={() => rm(c.id)}>删除</button>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{#if execContainer}
|
||
<h2>容器终端</h2>
|
||
<div class="panel inner terminal-panel">
|
||
<div class="title-row">
|
||
<div>
|
||
<strong>{execContainer.names || execContainer.id}</strong>
|
||
<p class="muted">通过 docker exec -it 进入容器,只连接当前容器,不开放任意宿主机命令。</p>
|
||
</div>
|
||
<button class="ghost" on:click={closeExec}>关闭终端</button>
|
||
</div>
|
||
<div bind:this={execEl} class="terminal"></div>
|
||
</div>
|
||
{/if}
|
||
{#if selectedContainer}
|
||
<h2>容器详情</h2>
|
||
<div class="panel inner">
|
||
<div class="title-row">
|
||
<div>
|
||
<strong>{selectedContainer}</strong>
|
||
<p class="muted">资源占用来自 docker stats --no-stream,配置来自 docker inspect。</p>
|
||
</div>
|
||
<button class="ghost" on:click={() => showContainerDetail(selectedContainer)}>刷新详情</button>
|
||
</div>
|
||
<div class="metric-grid">
|
||
<div><b>{statValue('CPUPerc')}</b><span>CPU</span></div>
|
||
<div><b>{statValue('MemUsage')}</b><span>内存</span></div>
|
||
<div><b>{statValue('NetIO')}</b><span>网络</span></div>
|
||
<div><b>{statValue('BlockIO')}</b><span>磁盘 IO</span></div>
|
||
</div>
|
||
<div class="detail-grid">
|
||
{#each detailPairs(containerDetail) as pair}
|
||
<div><span>{pair[0]}</span><strong>{pair[1]}</strong></div>
|
||
{/each}
|
||
</div>
|
||
<h3>挂载目录</h3>
|
||
{#if mountList(containerDetail).length === 0}
|
||
<p class="muted">无挂载目录。</p>
|
||
{:else}
|
||
<div class="permission-list">
|
||
{#each mountList(containerDetail) as mount}
|
||
<span>{mount}</span>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{#if proxyVisible}
|
||
<h2>一键创建 Nginx 反代</h2>
|
||
<div class="nginx-create">
|
||
<p class="muted">目标:{proxyTarget}。会创建站点、启用站点,并执行 nginx -t 后重载。</p>
|
||
<div class="form-grid">
|
||
<label>站点名<input bind:value={proxySiteName} placeholder="my-app" /></label>
|
||
<label>域名<input bind:value={proxyServerName} placeholder="app.example.com" /></label>
|
||
<label>上游地址<input bind:value={proxyUpstream} placeholder="http://127.0.0.1:3000" /></label>
|
||
<label>证书邮箱<input bind:value={proxySslEmail} placeholder="可选,用于 certbot" /></label>
|
||
<label class="check"><input type="checkbox" bind:checked={proxyIssueSsl} /> 同时申请免费 SSL 证书</label>
|
||
</div>
|
||
<div class="actions">
|
||
<button on:click={createProxySite} disabled={creatingProxy}>{creatingProxy ? '创建中...' : '创建并启用反代'}</button>
|
||
<button class="ghost" on:click={() => (proxyVisible = false)}>取消</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{#if logs}
|
||
<h2>容器日志</h2>
|
||
<pre class="log-output">{logs}</pre>
|
||
{/if}
|
||
<h2>镜像</h2>
|
||
<div class="inline-form">
|
||
<input bind:value={imageName} placeholder="镜像名称,例如 nginx:latest、redis:7" />
|
||
<button on:click={pullImage} disabled={pulling}>{pulling ? '拉取中...' : '拉取镜像'}</button>
|
||
</div>
|
||
<div class="table">
|
||
{#each images as img}
|
||
<div class="row">
|
||
<strong>{img.repository}:{img.tag}</strong><span>{img.id}</span><span>{img.size}</span>
|
||
<button class="danger" on:click={() => rmi(img.id)}>删除镜像</button>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
<h2>数据卷</h2>
|
||
<p class="muted">删除数据卷会删除持久化数据。只建议删除确认不再被容器使用的卷。</p>
|
||
<div class="table">
|
||
{#each volumes as volume}
|
||
<div class="row volume-row">
|
||
<strong>{volume.Name || volume.name}</strong>
|
||
<span>驱动:{volume.Driver || '-'}</span>
|
||
<span>挂载点:{volume.Mountpoint || '-'}</span>
|
||
<button class="danger" on:click={() => removeVolume(volume.Name || volume.name)}>删除数据卷</button>
|
||
</div>
|
||
{/each}
|
||
{#if volumes.length === 0}
|
||
<div class="empty-state"><p class="muted">未发现 Docker 数据卷。</p></div>
|
||
{/if}
|
||
</div>
|
||
</section>
|