feat: Docker 监控面板
后端 API (routes_docker.py): - /api/docker/containers - 容器列表(状态+资源stats: CPU/内存/网络I/O/磁盘I/O/进程数) - /api/docker/container/<id>/stats - 单容器实时资源 - /api/docker/images - 镜像列表(大小/时间/悬空标记) - /api/docker/system - 系统信息(版本/磁盘占用) - /api/docker/container/<id>/start|stop|restart - 容器控制 - /api/docker/container/<id>/logs - 容器日志 - /api/docker/prune - 资源清理 前端 Docker 监控 Tab: - 统计卡片: 总容器/运行中/已停止/镜像数/磁盘占用/可回收 - 容器列表: 状态灯+CPU/内存/NET IO/磁盘IO/端口/运行时间 - 操作按钮: 启动/停止/重启/查看日志 - 镜像列表: 有效镜像+悬空镜像分区显示 - 清理: 容器/镜像/全部一键清理
This commit is contained in:
@@ -119,6 +119,9 @@
|
||||
<button onclick="switchTab('cron')" class="tab-btn px-4 py-2 text-gray-300 hover:text-white" data-tab="cron">
|
||||
<i class="ri-timer-line"></i> Cron 管理
|
||||
</button>
|
||||
<button onclick="switchTab('docker')" class="tab-btn px-4 py-2 text-gray-300 hover:text-white" data-tab="docker">
|
||||
<i class="ri-ship-line"></i> Docker 监控
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 项目服务 Tab -->
|
||||
@@ -485,6 +488,72 @@
|
||||
<div class="text-center py-12 text-gray-400"><i class="ri-loader-4-line text-4xl animate-spin"></i><p class="mt-2">加载中...</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Docker 监控 Tab -->
|
||||
<div id="dockerTab" class="hidden">
|
||||
<!-- Docker 统计卡片 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
|
||||
<div class="card rounded-xl p-3">
|
||||
<p class="text-gray-400 text-xs">总容器数</p>
|
||||
<p id="dockerContainersTotal" class="text-xl font-bold text-blue-400">-</p>
|
||||
</div>
|
||||
<div class="card rounded-xl p-3">
|
||||
<p class="text-gray-400 text-xs">运行中</p>
|
||||
<p id="dockerContainersRunning" class="text-xl font-bold text-green-400">-</p>
|
||||
</div>
|
||||
<div class="card rounded-xl p-3">
|
||||
<p class="text-gray-400 text-xs">已停止</p>
|
||||
<p id="dockerContainersStopped" class="text-xl font-bold text-red-400">-</p>
|
||||
</div>
|
||||
<div class="card rounded-xl p-3">
|
||||
<p class="text-gray-400 text-xs">总镜像数</p>
|
||||
<p id="dockerImagesTotal" class="text-xl font-bold text-purple-400">-</p>
|
||||
</div>
|
||||
<div class="card rounded-xl p-3">
|
||||
<p class="text-gray-400 text-xs">磁盘占用</p>
|
||||
<p id="dockerDiskSize" class="text-xl font-bold text-yellow-400">-</p>
|
||||
</div>
|
||||
<div class="card rounded-xl p-3">
|
||||
<p class="text-gray-400 text-xs">可回收</p>
|
||||
<p id="dockerReclaimable" class="text-xl font-bold text-orange-400">-</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-2 mb-4 flex-wrap">
|
||||
<button onclick="loadDockerData()" class="btn bg-blue-600 hover:bg-blue-700 px-3 py-2 rounded-lg text-sm flex items-center gap-1">
|
||||
<i class="ri-refresh-line"></i> 刷新
|
||||
</button>
|
||||
<button onclick="dockerPrune('containers')" class="btn bg-yellow-600 hover:bg-yellow-700 px-3 py-2 rounded-lg text-sm flex items-center gap-1">
|
||||
<i class="ri-delete-bin-line"></i> 清理容器
|
||||
</button>
|
||||
<button onclick="dockerPrune('images')" class="btn bg-orange-600 hover:bg-orange-700 px-3 py-2 rounded-lg text-sm flex items-center gap-1">
|
||||
<i class="ri-image-line"></i> 清理镜像
|
||||
</button>
|
||||
<button onclick="dockerPrune('all')" class="btn bg-red-600 hover:bg-red-700 px-3 py-2 rounded-lg text-sm flex items-center gap-1">
|
||||
<i class="ri-broom-line"></i> 全部清理
|
||||
</button>
|
||||
<span class="text-gray-500 text-xs flex items-center ml-2">版本: <span id="dockerVersion" class="text-gray-300 ml-1">-</span></span>
|
||||
</div>
|
||||
|
||||
<!-- 容器列表 -->
|
||||
<h3 class="text-sm font-semibold text-gray-300 mb-2 flex items-center gap-2">
|
||||
<i class="ri-computer-line text-blue-400"></i> 容器列表
|
||||
<span id="dockerContainerCount" class="text-xs text-gray-500"></span>
|
||||
</h3>
|
||||
<div id="dockerContainersList" class="space-y-2 mb-6">
|
||||
<div class="text-gray-500 text-sm py-4 text-center">加载中...</div>
|
||||
</div>
|
||||
|
||||
<!-- 镜像列表 -->
|
||||
<h3 class="text-sm font-semibold text-gray-300 mb-2 flex items-center gap-2">
|
||||
<i class="ri-stack-line text-purple-400"></i> 镜像列表
|
||||
<span id="dockerImageCount" class="text-xs text-gray-500"></span>
|
||||
</h3>
|
||||
<div id="dockerImagesList" class="space-y-1">
|
||||
<div class="text-gray-500 text-sm py-4 text-center">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志模态框 -->
|
||||
@@ -855,6 +924,9 @@
|
||||
document.getElementById('projectsTab').classList.toggle('hidden', tab !== 'projects');
|
||||
document.getElementById('systemTab').classList.toggle('hidden', tab !== 'system');
|
||||
document.getElementById('cronTab').classList.toggle('hidden', tab !== 'cron');
|
||||
document.getElementById('dockerTab').classList.toggle('hidden', tab !== 'docker');
|
||||
|
||||
if (tab === 'docker') loadDockerData();
|
||||
|
||||
// 切换Tab时关闭实时监控
|
||||
if (tab !== 'system' && realtimeTimer) {
|
||||
@@ -2665,6 +2737,130 @@
|
||||
setInterval(() => {
|
||||
fetch('/api/projects').then(() => updateConnectionStatus(true)).catch(() => updateConnectionStatus(false));
|
||||
}, 10000);
|
||||
|
||||
// ==================== Docker 监控 ====================
|
||||
|
||||
async function loadDockerData() {
|
||||
try {
|
||||
const [containersRes, imagesRes, systemRes] = await Promise.all([
|
||||
fetch('/api/docker/containers'),
|
||||
fetch('/api/docker/images'),
|
||||
fetch('/api/docker/system')
|
||||
]);
|
||||
const containers = (await containersRes.json()).containers || [];
|
||||
const images = (await imagesRes.json()).images || [];
|
||||
const system = await systemRes.json();
|
||||
renderDockerStats(containers, images, system);
|
||||
renderDockerContainers(containers);
|
||||
renderDockerImages(images);
|
||||
document.getElementById('dockerVersion').textContent = system.version || '-';
|
||||
} catch (e) {
|
||||
console.error('Docker数据加载失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDockerStats(containers, images, system) {
|
||||
const total = containers.length;
|
||||
const running = containers.filter(c => c.state === 'running').length;
|
||||
const stopped = total - running;
|
||||
const imgTotal = images.length;
|
||||
document.getElementById('dockerContainersTotal').textContent = total;
|
||||
document.getElementById('dockerContainersRunning').textContent = running;
|
||||
document.getElementById('dockerContainersStopped').textContent = stopped;
|
||||
document.getElementById('dockerImagesTotal').textContent = imgTotal;
|
||||
document.getElementById('dockerContainerCount').textContent = `(${running}运行 / ${total}总计)`;
|
||||
document.getElementById('dockerImageCount').textContent = `(${imgTotal}个)`;
|
||||
const imagesDisk = system.diskUsage?.find(d => d.type === 'Images');
|
||||
if (imagesDisk) {
|
||||
document.getElementById('dockerDiskSize').textContent = imagesDisk.size;
|
||||
document.getElementById('dockerReclaimable').textContent = imagesDisk.reclaimable;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDockerContainers(containers) {
|
||||
const list = document.getElementById('dockerContainersList');
|
||||
if (containers.length === 0) {
|
||||
list.innerHTML = '<div class="text-gray-500 text-sm py-4 text-center">暂无容器</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = containers.map(c => {
|
||||
const isRunning = c.state === 'running';
|
||||
const s = c.stats || {};
|
||||
const portsDisplay = c.ports ? c.ports.split(',').slice(0, 3).join(', ') : '';
|
||||
return `<div class="card rounded-lg p-3 ${isRunning ? 'border-green-500/30' : 'border-red-500/30'}">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full ${isRunning ? 'bg-green-400' : 'bg-red-400'}" style="${isRunning ? 'box-shadow: 0 0 8px #4ade80;' : ''}"></div>
|
||||
<span class="font-semibold text-sm text-gray-200">${c.name}</span>
|
||||
<span class="text-xs ${isRunning ? 'text-green-400' : 'text-red-400'}">${isRunning ? '运行中' : '已停止'}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
${isRunning ? `<button onclick="dockerControl('${c.id}','stop')" class="btn bg-red-600/50 hover:bg-red-600 px-2 py-0.5 rounded text-xs">停止</button><button onclick="dockerControl('${c.id}','restart')" class="btn bg-yellow-600/50 hover:bg-yellow-600 px-2 py-0.5 rounded text-xs">重启</button>` : `<button onclick="dockerControl('${c.id}','start')" class="btn bg-green-600/50 hover:bg-green-600 px-2 py-0.5 rounded text-xs">启动</button>`}
|
||||
<button onclick="showDockerLogs('${c.id}','${c.name}')" class="btn bg-gray-600/50 hover:bg-gray-600 px-2 py-0.5 rounded text-xs">日志</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
<div><span class="text-gray-500">镜像:</span> <span class="text-gray-300">${c.image}</span></div>
|
||||
${isRunning ? `<div><span class="text-gray-500">CPU:</span> <span class="text-blue-400">${s.cpuPercent || '-'}</span></div><div><span class="text-gray-500">内存:</span> <span class="text-green-400">${s.memUsage || '-'} (${s.memPercent || '-'})</span></div><div><span class="text-gray-500">进程:</span> <span class="text-gray-300">${s.pids || '-'}</span></div><div><span class="text-gray-500">网络I/O:</span> <span class="text-cyan-400">${s.netIO || '-'}</span></div><div><span class="text-gray-500">磁盘I/O:</span> <span class="text-purple-400">${s.blockIO || '-'}</span></div>` : ''}
|
||||
${portsDisplay ? `<div class="col-span-2"><span class="text-gray-500">端口:</span> <span class="text-yellow-400">${portsDisplay}</span></div>` : ''}
|
||||
<div class="col-span-2"><span class="text-gray-500">运行时间:</span> <span class="text-gray-400">${c.runningFor || '-'}</span></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderDockerImages(images) {
|
||||
const list = document.getElementById('dockerImagesList');
|
||||
if (images.length === 0) {
|
||||
list.innerHTML = '<div class="text-gray-500 text-sm py-4 text-center">暂无镜像</div>';
|
||||
return;
|
||||
}
|
||||
const valid = images.filter(i => !i.dangling);
|
||||
const dangling = images.filter(i => i.dangling);
|
||||
let html = '';
|
||||
if (valid.length > 0) {
|
||||
html += valid.map(i => `<div class="flex items-center justify-between bg-gray-700/30 px-3 py-1.5 rounded text-xs"><div class="flex items-center gap-2 flex-1 min-w-0"><span class="text-gray-200 truncate">${i.repository}:${i.tag || 'latest'}</span><span class="text-gray-500">${i.size}</span></div><span class="text-gray-500 ml-2 shrink-0">${(i.created || '').substring(0, 19)}</span></div>`).join('');
|
||||
}
|
||||
if (dangling.length > 0) {
|
||||
html += `<div class="text-yellow-400/70 text-xs mt-2 mb-1"><i class="ri-error-warning-line"></i> 悬空镜像 (${dangling.length}个)</div>`;
|
||||
html += dangling.map(i => `<div class="flex items-center justify-between bg-yellow-500/10 px-3 py-1.5 rounded text-xs opacity-70"><div class="flex items-center gap-2 flex-1 min-w-0"><span class="text-gray-400 truncate"><none>:<none></span><span class="text-gray-500">${i.size}</span><span class="text-gray-500">${i.id}</span></div><span class="text-gray-500 ml-2 shrink-0">${(i.created || '').substring(0, 19)}</span></div>`).join('');
|
||||
}
|
||||
list.innerHTML = html;
|
||||
}
|
||||
|
||||
async function dockerControl(containerId, action) {
|
||||
const names = { start: '启动', stop: '停止', restart: '重启' };
|
||||
if (!confirm(`确定要${names[action]}容器吗?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/docker/container/${containerId}/${action}`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.error) { alert(`操作失败: ${data.error}`); return; }
|
||||
setTimeout(loadDockerData, 1000);
|
||||
} catch (e) { alert('操作失败: ' + e.message); }
|
||||
}
|
||||
|
||||
async function showDockerLogs(containerId, name) {
|
||||
const m = document.getElementById('logModal');
|
||||
document.getElementById('logTitle').textContent = `${name} - 日志`;
|
||||
document.getElementById('logContent').textContent = '加载中...';
|
||||
m.classList.add('flex'); m.classList.remove('hidden');
|
||||
try {
|
||||
const res = await fetch(`/api/docker/container/${containerId}/logs?lines=200`);
|
||||
const data = await res.json();
|
||||
document.getElementById('logContent').textContent = data.logs || data.error || '无日志';
|
||||
} catch (e) { document.getElementById('logContent').textContent = '加载失败: ' + e.message; }
|
||||
}
|
||||
|
||||
async function dockerPrune(target) {
|
||||
const names = { all: '所有资源(停止的容器+悬空镜像+构建缓存)', containers: '停止的容器', images: '悬空镜像', build: '构建缓存' };
|
||||
if (!confirm(`确定清理${names[target]}?不可撤销。`)) return;
|
||||
try {
|
||||
const res = await fetch('/api/docker/prune', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({target}) });
|
||||
alert((await res.json()).message || '完成');
|
||||
setTimeout(loadDockerData, 1000);
|
||||
} catch (e) { alert('失败: ' + e.message); }
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user