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:
2026-05-27 18:37:48 +08:00
parent ee3c851d17
commit aac64ed8b7
3 changed files with 176 additions and 25 deletions
+22 -6
View File
@@ -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 });
}
+119 -5
View File
@@ -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) {