实现 LightOps 运维面板基础功能

This commit is contained in:
2026-05-25 01:13:03 +08:00
commit d3bb9f45a6
84 changed files with 23505 additions and 0 deletions

559
web/src/pages/Docker.svelte Normal file
View 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&#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>