Files
lightOps/web/src/pages/Docker.svelte

560 lines
21 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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&#10;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:latestredis: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>