实现 LightOps 运维面板基础功能
This commit is contained in:
559
web/src/pages/Docker.svelte
Normal file
559
web/src/pages/Docker.svelte
Normal file
@@ -0,0 +1,559 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user