feat: DevTools 检测 Docker 运行的服务并禁用本地操作
- process-manager: 新增 detectDockerServices() 通过 docker ps 匹配端口, getStatus() 返回 source 字段 (docker/local/none) 和容器名 - process-manager: Docker 服务拒绝 start/stop/restart/build, 批量操作自动跳过 Docker 服务 - index.js: Docker 管理服务返回 409 Conflict - UI: Docker 服务显示蓝色 "🐳 Docker" badge + 容器名, 隐藏操作按钮并提示 "请使用 docker compose" Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+35
-14
@@ -184,6 +184,8 @@ tr.expanded td { background: var(--bg3); }
|
||||
.badge-stopped, .badge-error { background: var(--red-bg); color: var(--red); }
|
||||
.badge-starting, .badge-building, .badge-thinking { background: var(--blue-bg); color: var(--blue); }
|
||||
.badge-streaming { background: var(--yellow-bg); color: var(--yellow); }
|
||||
.badge-docker { background: rgba(56,139,253,.12); color: #388bfd; border: 1px solid rgba(56,139,253,.3); }
|
||||
.docker-hint { font-size: 11px; color: var(--text3); font-style: italic; }
|
||||
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||||
@keyframes bluePulse { 0%,100%{box-shadow:0 0 4px var(--blue)} 50%{box-shadow:0 0 12px var(--blue)} }
|
||||
@@ -1457,25 +1459,30 @@ async function updatePerformanceDashboard(perfData) {
|
||||
function renderDashboardSvcCards(svcs) {
|
||||
const container = document.getElementById('dashboard-svc-cards');
|
||||
if (!container) return;
|
||||
container.innerHTML = Object.entries(svcs).map(([id, svc]) => `
|
||||
container.innerHTML = Object.entries(svcs).map(([id, svc]) => {
|
||||
const isDocker = svc.source === 'docker';
|
||||
return `
|
||||
<div class="card" style="margin:0">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
|
||||
<span style="font-weight:600">${svc.name}</span>
|
||||
<span class="badge ${statusBadge(svc.status)}">${svc.status}</span>
|
||||
${isDocker ? '<span class="badge badge-docker">🐳 Docker</span>' : `<span class="badge ${statusBadge(svc.status)}">${svc.status}</span>`}
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="value">${svc.pid || '—'}</div><div class="label">PID</div></div>
|
||||
<div class="metric"><div class="value">${isDocker ? (svc.containerName || '—') : (svc.pid || '—')}</div><div class="label">${isDocker ? '容器' : 'PID'}</div></div>
|
||||
<div class="metric"><div class="value">${svc.port}</div><div class="label">端口</div></div>
|
||||
<div class="metric"><div class="value">${formatUptime(svc.uptime)}</div><div class="label">运行时间</div></div>
|
||||
</div>
|
||||
<div class="btn-group" style="margin-top:10px">
|
||||
${svc.status === 'stopped' || svc.status === 'error' ? `<button class="btn btn-xs btn-green" onclick="svcAction('start','${id}')">▶</button>` : ''}
|
||||
${isDocker
|
||||
? '<span class="docker-hint">🐳 Docker 管理</span>'
|
||||
: `${svc.status === 'stopped' || svc.status === 'error' ? `<button class="btn btn-xs btn-green" onclick="svcAction('start','${id}')">▶</button>` : ''}
|
||||
${svc.status === 'running' ? `<button class="btn btn-xs" onclick="svcAction('restart','${id}')">🔄</button>` : ''}
|
||||
${svc.status === 'running' || svc.status === 'starting' ? `<button class="btn btn-xs btn-red" onclick="svcAction('stop','${id}')">⏹</button>` : ''}
|
||||
${svc.status === 'stopped' || svc.status === 'error' ? `<button class="btn btn-xs btn-accent" onclick="svcAction('build','${id}')">🔨</button>` : ''}
|
||||
${svc.status === 'stopped' || svc.status === 'error' ? `<button class="btn btn-xs btn-accent" onclick="svcAction('build','${id}')">🔨</button>` : ''}`}
|
||||
${svc.healthUrl ? `<button class="btn btn-xs" onclick="checkHealth('${id}')">❤️</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
`}).join('');
|
||||
}
|
||||
|
||||
// ========== 记忆分类颜色映射 ==========
|
||||
@@ -2242,7 +2249,8 @@ function renderServiceCards() {
|
||||
const ids = Object.keys(status).length > 0 ? Object.keys(status) : ALL_SVC_IDS;
|
||||
|
||||
container.innerHTML = ids.map(id => {
|
||||
const svc = status[id] || { name: escapeId(id), status: 'unknown', pid: null, port: '—', uptime: 0, healthUrl: null };
|
||||
const svc = status[id] || { name: escapeId(id), status: 'unknown', pid: null, port: '—', uptime: 0, healthUrl: null, source: 'none' };
|
||||
const isDocker = svc.source === 'docker';
|
||||
const isRunning = svc.status === 'running';
|
||||
const isStarting = svc.status === 'starting' || svc.status === 'building';
|
||||
const isStopped = svc.status === 'stopped' || svc.status === 'error' || svc.status === 'unknown';
|
||||
@@ -2251,18 +2259,20 @@ function renderServiceCards() {
|
||||
<div class="card" style="margin:0">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
|
||||
<span style="font-weight:600">${svc.name}</span>
|
||||
<span class="badge ${statusBadge(svc.status)}">${svc.status}</span>
|
||||
${isDocker ? '<span class="badge badge-docker">🐳 Docker</span>' : `<span class="badge ${statusBadge(svc.status)}">${svc.status}</span>`}
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="value">${svc.pid || '—'}</div><div class="label">PID</div></div>
|
||||
<div class="metric"><div class="value">${isDocker ? (svc.containerName || '—') : (svc.pid || '—')}</div><div class="label">${isDocker ? '容器' : 'PID'}</div></div>
|
||||
<div class="metric"><div class="value">${svc.port}</div><div class="label">端口</div></div>
|
||||
<div class="metric"><div class="value">${formatUptime(svc.uptime)}</div><div class="label">运行时间</div></div>
|
||||
</div>
|
||||
<div class="btn-group" style="margin-top:10px">
|
||||
${isStopped ? `<button class="btn btn-xs btn-green" onclick="svcAction('start','${id}')">▶ 启动</button>` : ''}
|
||||
${isDocker
|
||||
? '<span class="docker-hint">🐳 Docker 管理 — 请使用 docker compose</span>'
|
||||
: `${isStopped ? `<button class="btn btn-xs btn-green" onclick="svcAction('start','${id}')">▶ 启动</button>` : ''}
|
||||
${isStopped || isStarting ? `<button class="btn btn-xs btn-accent" onclick="svcAction('build','${id}')">🔨 编译</button>` : ''}
|
||||
${isRunning ? `<button class="btn btn-xs" onclick="svcAction('restart','${id}')">🔄 重启</button>` : ''}
|
||||
${isRunning || isStarting ? `<button class="btn btn-xs btn-red" onclick="svcAction('stop','${id}')">⏹ 停止</button>` : ''}
|
||||
${isRunning || isStarting ? `<button class="btn btn-xs btn-red" onclick="svcAction('stop','${id}')">⏹ 停止</button>` : ''}`}
|
||||
${svc.healthUrl ? `<button class="btn btn-xs" onclick="checkHealth('${id}')">❤️</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -2366,6 +2376,11 @@ async function svcAction(cmd, serviceId) {
|
||||
|
||||
// 立即更新本地状态和 UI(乐观更新)
|
||||
if (serviceId) {
|
||||
var svc = STATE.serviceStatus[serviceId];
|
||||
if (svc && svc.source === "docker") {
|
||||
showToast(svc.name + " 由 Docker 管理,不支持此操作", "error");
|
||||
return;
|
||||
}
|
||||
var newStatus;
|
||||
if (cmd === "start") newStatus = "starting";
|
||||
else if (cmd === "stop") newStatus = "stopped";
|
||||
@@ -2373,7 +2388,7 @@ async function svcAction(cmd, serviceId) {
|
||||
else if (cmd === "build") newStatus = "building";
|
||||
if (newStatus) {
|
||||
if (!STATE.serviceStatus[serviceId]) {
|
||||
STATE.serviceStatus[serviceId] = { name: escapeId(serviceId), status: newStatus, pid: null, port: "-", uptime: 0 };
|
||||
STATE.serviceStatus[serviceId] = { name: escapeId(serviceId), status: newStatus, pid: null, port: "-", uptime: 0, source: "none" };
|
||||
} else {
|
||||
STATE.serviceStatus[serviceId].status = newStatus;
|
||||
}
|
||||
@@ -2381,15 +2396,21 @@ async function svcAction(cmd, serviceId) {
|
||||
if (STATE.activePanel === "dashboard") renderDashboardSvcCards(STATE.serviceStatus);
|
||||
}
|
||||
} else {
|
||||
// 批量操作:给所有服务设置过渡状态
|
||||
// 批量操作:跳过 Docker 服务,只更新本地服务状态
|
||||
var dockerCount = 0;
|
||||
var newStatus = (cmd === "start-all" || cmd === "start-all-fresh") ? "starting" : "stopped";
|
||||
ALL_SVC_IDS.forEach(function(id) {
|
||||
if (!STATE.serviceStatus[id]) {
|
||||
STATE.serviceStatus[id] = { name: escapeId(id), status: newStatus, pid: null, port: "-", uptime: 0 };
|
||||
STATE.serviceStatus[id] = { name: escapeId(id), status: newStatus, pid: null, port: "-", uptime: 0, source: "none" };
|
||||
} else if (STATE.serviceStatus[id].source === "docker") {
|
||||
dockerCount++;
|
||||
} else {
|
||||
STATE.serviceStatus[id].status = newStatus;
|
||||
}
|
||||
});
|
||||
if (dockerCount > 0) {
|
||||
showToast("跳过 " + dockerCount + " 个 Docker 管理服务的操作", "info");
|
||||
}
|
||||
if (STATE.activePanel === "services") renderServiceCards();
|
||||
if (STATE.activePanel === "dashboard") renderDashboardSvcCards(STATE.serviceStatus);
|
||||
}
|
||||
|
||||
+22
-6
@@ -364,8 +364,12 @@ app.get('/api/services/:id', (req, res) => {
|
||||
app.post('/api/services/:id/start', async (req, res) => {
|
||||
try {
|
||||
const result = await processManager.start(req.params.id);
|
||||
broadcast('status', processManager.getStatus());
|
||||
res.json(result);
|
||||
if (result.error === 'docker_managed') {
|
||||
res.status(409).json(result);
|
||||
} else {
|
||||
broadcast('status', processManager.getStatus());
|
||||
res.json(result);
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
@@ -374,8 +378,12 @@ app.post('/api/services/:id/start', async (req, res) => {
|
||||
app.post('/api/services/:id/stop', async (req, res) => {
|
||||
try {
|
||||
const result = await processManager.stop(req.params.id);
|
||||
broadcast('status', processManager.getStatus());
|
||||
res.json(result);
|
||||
if (result.error === 'docker_managed') {
|
||||
res.status(409).json(result);
|
||||
} else {
|
||||
broadcast('status', processManager.getStatus());
|
||||
res.json(result);
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
@@ -383,9 +391,13 @@ app.post('/api/services/:id/stop', async (req, res) => {
|
||||
|
||||
app.post('/api/services/:id/restart', async (req, res) => {
|
||||
try {
|
||||
const result = await processManager.restart(req.params.id);
|
||||
if (result.error === 'docker_managed') {
|
||||
res.status(409).json(result);
|
||||
return;
|
||||
}
|
||||
// 异步重启,因为可能耗时较长
|
||||
res.json({ success: true, message: '重启中...' });
|
||||
const result = await processManager.restart(req.params.id);
|
||||
broadcast('status', processManager.getStatus());
|
||||
} catch (err) {
|
||||
// 已经在上面res了
|
||||
@@ -395,7 +407,11 @@ app.post('/api/services/:id/restart', async (req, res) => {
|
||||
app.post('/api/services/:id/build', async (req, res) => {
|
||||
try {
|
||||
const result = await processManager.build(req.params.id);
|
||||
res.json(result);
|
||||
if (result.error === 'docker_managed') {
|
||||
res.status(409).json(result);
|
||||
} else {
|
||||
res.json(result);
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
|
||||
@@ -19,6 +19,67 @@ const DB_PORTS = [5432, 5433, 5434];
|
||||
const DB_COMPOSE_FILE = path.join(ROOT, 'docker-compose.dev.db.yml');
|
||||
const isWin = os.platform() === 'win32';
|
||||
|
||||
// ---- Docker 检测缓存 ----
|
||||
let _dockerCache = null;
|
||||
let _dockerCacheTime = 0;
|
||||
const DOCKER_CACHE_TTL = 5000; // 5秒缓存,避免每次 status 轮询都执行 docker ps
|
||||
|
||||
/**
|
||||
* 检测哪些服务运行在 Docker 容器中
|
||||
* 通过 docker ps 获取所有容器端口映射,与 SERVICES 端口匹配
|
||||
* @returns {Map<string, {containerName: string, containerId: string, status: string}>}
|
||||
*/
|
||||
function detectDockerServices() {
|
||||
const now = Date.now();
|
||||
if (_dockerCache && (now - _dockerCacheTime) < DOCKER_CACHE_TTL) {
|
||||
return _dockerCache;
|
||||
}
|
||||
|
||||
const result = new Map();
|
||||
try {
|
||||
const out = execSync('docker ps --format "{{.ID}}\\t{{.Names}}\\t{{.Ports}}\\t{{.Status}}"', {
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
}).toString().trim();
|
||||
|
||||
if (!out) return result;
|
||||
|
||||
for (const line of out.split('\n')) {
|
||||
const [containerId, containerName, ports, status] = line.split('\t');
|
||||
if (!ports) continue;
|
||||
|
||||
// 解析端口映射: "0.0.0.0:8080->8080/tcp, :::8080->8080/tcp"
|
||||
const hostPorts = new Set();
|
||||
for (const m of ports.matchAll(/:(\d+)->/g)) {
|
||||
hostPorts.add(parseInt(m[1]));
|
||||
}
|
||||
|
||||
// 匹配 SERVICES 中定义的端口
|
||||
for (const [svcId, svc] of Object.entries(SERVICES)) {
|
||||
if (svc.port && hostPorts.has(svc.port)) {
|
||||
result.set(svcId, {
|
||||
containerName,
|
||||
containerId: containerId.substring(0, 12),
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Docker 不可用,返回空 Map
|
||||
}
|
||||
|
||||
_dockerCache = result;
|
||||
_dockerCacheTime = now;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 清除 Docker 缓存(用于手动刷新状态) */
|
||||
export function clearDockerCache() {
|
||||
_dockerCache = null;
|
||||
_dockerCacheTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 TCP 连接尝试判断端口是否被占用,若被占用则尝试用 fuser 释放
|
||||
*/
|
||||
@@ -128,6 +189,11 @@ class ProcessManager extends EventEmitter {
|
||||
const svc = SERVICES[serviceId];
|
||||
if (!svc) throw new Error(`未知服务: ${serviceId}`);
|
||||
|
||||
// Docker 管理的服务拒绝本地操作
|
||||
if (detectDockerServices().has(serviceId)) {
|
||||
return { success: false, error: 'docker_managed', message: `${svc.name} 由 Docker 管理,请使用 docker compose 控制` };
|
||||
}
|
||||
|
||||
const procInfo = this.processes.get(serviceId);
|
||||
if (procInfo.process) {
|
||||
throw new Error(`${svc.name} 已在运行中`);
|
||||
@@ -256,6 +322,10 @@ class ProcessManager extends EventEmitter {
|
||||
const svc = SERVICES[serviceId];
|
||||
if (!svc) throw new Error(`未知服务: ${serviceId}`);
|
||||
|
||||
if (detectDockerServices().has(serviceId)) {
|
||||
return { success: false, error: 'docker_managed', message: `${svc.name} 由 Docker 管理,请使用 docker compose 控制` };
|
||||
}
|
||||
|
||||
const procInfo = this.processes.get(serviceId);
|
||||
if (!procInfo.process) {
|
||||
// 可能已经崩溃了,重置状态
|
||||
@@ -292,8 +362,11 @@ class ProcessManager extends EventEmitter {
|
||||
* 重启服务
|
||||
*/
|
||||
async restart(serviceId) {
|
||||
if (detectDockerServices().has(serviceId)) {
|
||||
const svc = SERVICES[serviceId];
|
||||
return { success: false, error: 'docker_managed', message: `${svc?.name || serviceId} 由 Docker 管理` };
|
||||
}
|
||||
await this.stop(serviceId);
|
||||
// 等待一小段时间确保端口释放
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
return this.start(serviceId);
|
||||
}
|
||||
@@ -304,6 +377,11 @@ class ProcessManager extends EventEmitter {
|
||||
async build(serviceId) {
|
||||
const svc = SERVICES[serviceId];
|
||||
if (!svc) throw new Error(`未知服务: ${serviceId}`);
|
||||
|
||||
if (detectDockerServices().has(serviceId)) {
|
||||
return { success: false, error: 'docker_managed', message: `${svc.name} 由 Docker 管理,请在容器内构建或重建镜像` };
|
||||
}
|
||||
|
||||
if (!svc.buildCommand) {
|
||||
return { success: false, message: `${svc.name} 不需要预编译` };
|
||||
}
|
||||
@@ -356,17 +434,27 @@ class ProcessManager extends EventEmitter {
|
||||
* 获取所有服务状态
|
||||
*/
|
||||
getStatus() {
|
||||
const dockerSvcs = detectDockerServices();
|
||||
const result = {};
|
||||
for (const [id, info] of this.processes) {
|
||||
const svc = SERVICES[id];
|
||||
const docker = dockerSvcs.get(id);
|
||||
let source = 'none';
|
||||
if (docker) {
|
||||
source = 'docker';
|
||||
} else if (info.status === 'running' || info.status === 'starting') {
|
||||
source = 'local';
|
||||
}
|
||||
result[id] = {
|
||||
name: svc.name,
|
||||
status: info.status,
|
||||
pid: info.pid,
|
||||
status: docker ? 'running' : info.status, // Docker 容器总是 running
|
||||
pid: docker ? null : info.pid,
|
||||
startTime: info.startTime,
|
||||
uptime: info.startTime ? Date.now() - info.startTime : 0,
|
||||
port: svc.port,
|
||||
healthUrl: svc.healthUrl,
|
||||
source,
|
||||
...(docker ? { containerName: docker.containerName, containerId: docker.containerId } : {}),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
@@ -379,14 +467,24 @@ class ProcessManager extends EventEmitter {
|
||||
const info = this.processes.get(serviceId);
|
||||
if (!info) return null;
|
||||
const svc = SERVICES[serviceId];
|
||||
const dockerSvcs = detectDockerServices();
|
||||
const docker = dockerSvcs.get(serviceId);
|
||||
let source = 'none';
|
||||
if (docker) {
|
||||
source = 'docker';
|
||||
} else if (info.status === 'running' || info.status === 'starting') {
|
||||
source = 'local';
|
||||
}
|
||||
return {
|
||||
name: svc.name,
|
||||
status: info.status,
|
||||
pid: info.pid,
|
||||
status: docker ? 'running' : info.status,
|
||||
pid: docker ? null : info.pid,
|
||||
startTime: info.startTime,
|
||||
uptime: info.startTime ? Date.now() - info.startTime : 0,
|
||||
port: svc.port,
|
||||
healthUrl: svc.healthUrl,
|
||||
source,
|
||||
...(docker ? { containerName: docker.containerName, containerId: docker.containerId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -395,7 +493,13 @@ class ProcessManager extends EventEmitter {
|
||||
*/
|
||||
async stopAll() {
|
||||
const results = [];
|
||||
const dockerSvcs = detectDockerServices();
|
||||
for (const id of Object.keys(SERVICES)) {
|
||||
if (dockerSvcs.has(id)) {
|
||||
const svc = SERVICES[id];
|
||||
results.push({ id, success: true, message: `${svc.name} 由 Docker 管理,跳过停止`, docker: true });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const r = await this.stop(id);
|
||||
results.push({ id, ...r });
|
||||
@@ -456,9 +560,19 @@ class ProcessManager extends EventEmitter {
|
||||
async startAllSequential() {
|
||||
const order = ['memory-service', 'plugin-manager', 'iot-debug-service', 'voice-service', 'ai-core', 'platform-bridge', 'gateway', 'frontend'];
|
||||
const results = [];
|
||||
const dockerSvcs = detectDockerServices();
|
||||
|
||||
for (const id of order) {
|
||||
const svc = SERVICES[id];
|
||||
|
||||
// Docker 管理的服务:跳过
|
||||
if (dockerSvcs.has(id)) {
|
||||
const d = dockerSvcs.get(id);
|
||||
this.emit('log', id, 'system', `${svc.name} 由 Docker 容器 ${d.containerName} 管理,跳过本地启动`);
|
||||
results.push({ id, success: true, message: `${svc.name} 由 Docker 管理 (${d.containerName}),已跳过`, docker: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 先尝试接管已运行的服务
|
||||
const adopted = await this.tryAdopt(id);
|
||||
if (adopted) {
|
||||
|
||||
Reference in New Issue
Block a user