refactor: DevTools → ethend 重命名 + 加入生产环境
- 目录 devtools/ → ethend/ - CLI 脚本 devtools.sh/.bat → ethend.sh/.bat - 环境变量 DEVTOOLS_PORT → ETHEND_PORT - docker-compose.yml 新增 ethend 服务(生产部署) - 同步更新全部文档、注释和配置文件 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY src/ ./src/
|
||||
COPY public/ ./public/
|
||||
|
||||
ENV ETHEND_PORT=9090
|
||||
ENV NODE_ENV=production
|
||||
|
||||
EXPOSE 9090
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "cyrene-ethend",
|
||||
"version": "1.0.0",
|
||||
"description": "Cyrene AI 高级管理控制台 - 服务管理、日志监控、性能分析",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"ws": "^8.18.1",
|
||||
"pidusage": "^4.0.0",
|
||||
"chokidar": "^4.0.3"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,375 @@
|
||||
// ========== IoT 设备控制面板 ==========
|
||||
function startIoTRefresh() {
|
||||
stopIoTRefresh();
|
||||
STATE.iotInterval = setInterval(function() {
|
||||
if (STATE.activePanel === 'iot') renderIoTPanel();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function stopIoTRefresh() {
|
||||
if (STATE.iotInterval) { clearInterval(STATE.iotInterval); STATE.iotInterval = null; }
|
||||
}
|
||||
|
||||
async function fetchIoTDevices() {
|
||||
var data = await api('/api/iot/devices');
|
||||
if (data.error) return { error: data };
|
||||
var devices = [];
|
||||
if (Array.isArray(data)) devices = data;
|
||||
else if (data.devices) devices = data.devices;
|
||||
return { devices: devices };
|
||||
}
|
||||
|
||||
var IOT_DEVICE_TYPES = {
|
||||
'ac': '❄️', 'light': '💡', 'curtain': '🪟', 'door_lock': '🔒',
|
||||
'camera': '📷', 'sensor': '📡', 'speaker': '🔊', 'thermostat': '🌡️',
|
||||
};
|
||||
|
||||
var IOT_MODE_OPTIONS = ['cool', 'heat', 'auto', 'fan', 'dry'];
|
||||
var IOT_COLOR_OPTIONS = [
|
||||
{ name: '暖白', value: 'warm_white', bg: '#f5d0a9' },
|
||||
{ name: '冷白', value: 'cool_white', bg: '#d4e6fc' },
|
||||
{ name: '暖黄', value: 'warm_yellow', bg: '#fce4a6' },
|
||||
{ name: '蓝色', value: 'blue', bg: '#3b82f6' },
|
||||
{ name: '紫色', value: 'purple', bg: '#a855f7' },
|
||||
{ name: '绿色', value: 'green', bg: '#22c55e' },
|
||||
];
|
||||
|
||||
async function renderIoTPanel() {
|
||||
var result = await fetchIoTDevices();
|
||||
var panel = document.getElementById('panel-iot');
|
||||
|
||||
// 更新操作栏 (只更新时间戳文本)
|
||||
document.getElementById('panel-actions').innerHTML =
|
||||
'<button class="btn btn-sm" onclick="renderIoTPanel()" id="iot-refresh-btn">🔄 刷新</button>' +
|
||||
'<span class="iot-last-update">⏱ 每3秒自动刷新 · 最后更新: ' + new Date().toLocaleTimeString('zh-CN', {hour12: false}) + '</span>';
|
||||
|
||||
if (result.error) {
|
||||
var hint = '';
|
||||
if (result.error.errorType === 'iot_not_running') {
|
||||
hint = '<br><span style="font-size:11px">💡 提示: 请先在「服务管理」面板中启动 IoT Debug 服务</span>';
|
||||
}
|
||||
panel.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(result.error.error) + hint + '</div>';
|
||||
STATE.iotInitialized = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 数据未变则跳过 (设备列表不变)
|
||||
var devices = result.devices;
|
||||
var iotHash = simpleHash(JSON.stringify(devices));
|
||||
if (STATE.renderHashes && STATE.renderHashes['iot'] === iotHash) return;
|
||||
if (STATE.renderHashes) STATE.renderHashes['iot'] = iotHash;
|
||||
|
||||
// 固定设备排列顺序: 先按类型,同类型再按 device_id
|
||||
var typeOrder = { 'sensor': 1, 'ac': 2, 'light': 3, 'curtain': 4, 'lock': 5, 'camera': 6, 'speaker': 7, 'thermostat': 8 };
|
||||
devices.sort(function(a, b) {
|
||||
var oa = typeOrder[a.type] || 99;
|
||||
var ob = typeOrder[b.type] || 99;
|
||||
if (oa !== ob) return oa - ob;
|
||||
return (a.id || a.entity_id || '').localeCompare(b.id || b.entity_id || '');
|
||||
});
|
||||
|
||||
var badge = document.getElementById('iot-badge');
|
||||
if (badge) {
|
||||
badge.textContent = devices.length;
|
||||
badge.style.display = devices.length > 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
// Bug 7: 增量更新 — 首次渲染完整 DOM,后续只更新设备属性值
|
||||
var grid = document.getElementById('iot-device-grid');
|
||||
var firstRender = !STATE.iotInitialized;
|
||||
|
||||
if (firstRender || !grid) {
|
||||
// 首次渲染: 创建完整结构
|
||||
var html = '<div class="iot-refresh-bar">' +
|
||||
'<span style="font-weight:600;font-size:14px" id="iot-device-count">📡 模拟 IoT 设备 (' + devices.length + ')</span>' +
|
||||
'<span style="font-size:11px;color:var(--text2)">通过 IoT 调试服务 (端口 8083) 管理</span>' +
|
||||
'</div><div class="iot-device-grid" id="iot-device-grid">' +
|
||||
devices.map(function(d) { return renderIoTDeviceCard(d); }).join('') +
|
||||
'</div>';
|
||||
panel.innerHTML = html;
|
||||
STATE.iotInitialized = true;
|
||||
} else {
|
||||
// 增量更新: 只更新设备数量、最后更新时间
|
||||
var countEl = document.getElementById('iot-device-count');
|
||||
if (countEl) countEl.textContent = '📡 模拟 IoT 设备 (' + devices.length + ')';
|
||||
|
||||
// 对每个已有设备卡片做增量更新
|
||||
devices.forEach(function(device) {
|
||||
updateIoTDeviceCardInPlace(device);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Bug 7 helper: 增量更新单个设备卡片的属性值,不重建 DOM
|
||||
function updateIoTDeviceCardInPlace(device) {
|
||||
var card = document.getElementById('iot-card-' + device.id);
|
||||
if (!card) return;
|
||||
|
||||
var isOn = device.status === 'on';
|
||||
|
||||
// 更新卡片 class (on/off 边框)
|
||||
card.className = 'iot-device-card ' + (isOn ? 'on' : 'off');
|
||||
|
||||
// 更新状态圆点和文字
|
||||
var statusDot = card.querySelector('.iot-status-dot');
|
||||
if (statusDot) statusDot.className = 'iot-status-dot ' + (isOn ? 'on' : 'off');
|
||||
var statusText = card.querySelector('.iot-device-status span:last-child');
|
||||
if (statusText) {
|
||||
statusText.textContent = isOn ? '开启' : '关闭';
|
||||
statusText.style.color = isOn ? 'var(--green)' : 'var(--text3)';
|
||||
}
|
||||
|
||||
// 更新开关按钮
|
||||
var toggleBtn = card.querySelector('.iot-toggle-btn');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.className = 'iot-toggle-btn ' + (isOn ? 'on' : 'off');
|
||||
toggleBtn.textContent = isOn ? '⏻ 关闭' : '⏻ 开启';
|
||||
}
|
||||
|
||||
// 更新设备属性值 (温度、亮度、位置等)
|
||||
var propValues = card.querySelectorAll('.iot-prop-value');
|
||||
var type = device.type;
|
||||
|
||||
if (type === 'ac') {
|
||||
// AC: 温度 slider + 模式按钮
|
||||
var tempSlider = card.querySelector('.iot-prop-control input[type="range"]');
|
||||
if (tempSlider) {
|
||||
tempSlider.value = device.temperature || 26;
|
||||
var tempVal = tempSlider.nextElementSibling;
|
||||
if (tempVal) tempVal.textContent = (device.temperature || 26) + '°C';
|
||||
}
|
||||
// 更新模式按钮
|
||||
var modeBtns = card.querySelectorAll('.iot-mode-btn');
|
||||
modeBtns.forEach(function(btn) {
|
||||
var btnMode = btn.textContent.trim();
|
||||
if (btnMode === (device.mode || 'cool')) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
} else if (type === 'light') {
|
||||
var brightSlider = card.querySelector('.iot-prop-control input[type="range"]');
|
||||
if (brightSlider) {
|
||||
brightSlider.value = device.brightness || 80;
|
||||
var brightVal = brightSlider.nextElementSibling;
|
||||
if (brightVal) brightVal.textContent = (device.brightness || 80) + '%';
|
||||
}
|
||||
// 更新颜色按钮
|
||||
var colorBtns = card.querySelectorAll('.iot-color-btn');
|
||||
colorBtns.forEach(function(btn) {
|
||||
// 颜色值从 onclick 属性解析
|
||||
var onclick = btn.getAttribute('onclick') || '';
|
||||
var match = onclick.match(/iotSetProperty\('[^']+',\s*'color',\s*'([^']+)'\)/);
|
||||
if (match && match[1] === (device.color || 'warm_white')) {
|
||||
btn.classList.add('active');
|
||||
} else if (!match || match[1] !== (device.color || 'warm_white')) {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
} else if (type === 'curtain') {
|
||||
var posSlider = card.querySelector('.iot-prop-control input[type="range"]');
|
||||
if (posSlider) {
|
||||
posSlider.value = device.position != null ? device.position : 100;
|
||||
var posVal = posSlider.nextElementSibling;
|
||||
if (posVal) posVal.textContent = (device.position != null ? device.position : 100) + '%';
|
||||
}
|
||||
} else if (device.temperature != null) {
|
||||
// sensor/thermostat: 只读温度
|
||||
if (propValues.length > 0) propValues[0].textContent = device.temperature + (device.unit || '°C');
|
||||
}
|
||||
|
||||
// 更新电量
|
||||
var batteryEls = card.querySelectorAll('.iot-prop-value');
|
||||
batteryEls.forEach(function(el) {
|
||||
if (el.parentElement && el.parentElement.querySelector('.iot-prop-label') &&
|
||||
el.parentElement.querySelector('.iot-prop-label').textContent.indexOf('🔋') !== -1) {
|
||||
if (device.battery != null) el.textContent = device.battery + '%';
|
||||
}
|
||||
});
|
||||
|
||||
// 更新 AC 温度按钮的 onclick 引用
|
||||
if (type === 'ac') {
|
||||
var acBtns = card.querySelectorAll('.iot-device-actions .btn-xs');
|
||||
acBtns.forEach(function(btn) {
|
||||
var text = btn.textContent.trim();
|
||||
var currentTemp = device.temperature || 26;
|
||||
if (text === '⬇ -2°C') {
|
||||
btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp - 2) + ");refreshIoTDeviceCard('" + device.id + "')");
|
||||
} else if (text === '⬆ +2°C') {
|
||||
btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp + 2) + ");refreshIoTDeviceCard('" + device.id + "')");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderIoTDeviceCard(device) {
|
||||
var isOn = device.status === 'on';
|
||||
var icon = IOT_DEVICE_TYPES[device.type] || '📦';
|
||||
var propsHtml = '';
|
||||
|
||||
if (device.type === 'ac') {
|
||||
propsHtml =
|
||||
'<div class="iot-prop-row">' +
|
||||
'<span class="iot-prop-label">🌡️ 温度</span>' +
|
||||
'<div class="iot-prop-control">' +
|
||||
'<input type="range" min="16" max="30" value="' + (device.temperature || 26) + '"' +
|
||||
' onchange="iotSetProperty(\'' + device.id + '\', \'temperature\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'°C\'">' +
|
||||
'<span class="iot-prop-value">' + (device.temperature || 26) + '°C</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="iot-prop-row">' +
|
||||
'<span class="iot-prop-label">🔄 模式</span>' +
|
||||
'<div class="iot-prop-control" style="gap:4px">' +
|
||||
IOT_MODE_OPTIONS.map(function(m) {
|
||||
var active = (device.mode || 'cool') === m ? ' active' : '';
|
||||
return '<button class="iot-mode-btn' + active + '" onclick="iotSetProperty(\'' + device.id + '\', \'mode\', \'' + m + '\');refreshIoTDeviceCard(\'' + device.id + '\')">' + m + '</button>';
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
} else if (device.type === 'light') {
|
||||
propsHtml =
|
||||
'<div class="iot-prop-row">' +
|
||||
'<span class="iot-prop-label">💡 亮度</span>' +
|
||||
'<div class="iot-prop-control">' +
|
||||
'<input type="range" min="1" max="100" value="' + (device.brightness || 80) + '"' +
|
||||
' onchange="iotSetProperty(\'' + device.id + '\', \'brightness\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
|
||||
'<span class="iot-prop-value">' + (device.brightness || 80) + '%</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="iot-prop-row">' +
|
||||
'<span class="iot-prop-label">🎨 颜色</span>' +
|
||||
'<div class="iot-prop-control" style="gap:4px">' +
|
||||
IOT_COLOR_OPTIONS.map(function(c) {
|
||||
var active = (device.color || 'warm_white') === c.value ? ' active' : '';
|
||||
return '<button class="iot-color-btn' + active + '" style="background:' + c.bg + '" title="' + c.name + '"' +
|
||||
' onclick="iotSetProperty(\'' + device.id + '\', \'color\', \'' + c.value + '\');refreshIoTDeviceCard(\'' + device.id + '\')"></button>';
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
} else if (device.type === 'curtain') {
|
||||
propsHtml =
|
||||
'<div class="iot-prop-row">' +
|
||||
'<span class="iot-prop-label">🪟 位置</span>' +
|
||||
'<div class="iot-prop-control">' +
|
||||
'<input type="range" min="0" max="100" value="' + (device.position != null ? device.position : 100) + '"' +
|
||||
' onchange="iotSetProperty(\'' + device.id + '\', \'position\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
|
||||
'<span class="iot-prop-value">' + (device.position != null ? device.position : 100) + '%</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
} else if (device.temperature != null) {
|
||||
propsHtml =
|
||||
'<div class="iot-prop-row">' +
|
||||
'<span class="iot-prop-label">🌡️ 温度</span>' +
|
||||
'<span class="iot-prop-value">' + device.temperature + (device.unit || '°C') + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
if (device.battery != null) {
|
||||
propsHtml +=
|
||||
'<div class="iot-prop-row">' +
|
||||
'<span class="iot-prop-label">🔋 电量</span>' +
|
||||
'<span class="iot-prop-value">' + device.battery + '%</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
var actionsHtml = '<div class="iot-device-actions">' +
|
||||
'<button class="iot-toggle-btn ' + (isOn ? 'on' : 'off') + '" onclick="iotToggle(\'' + device.id + '\')">' +
|
||||
(isOn ? '⏻ 关闭' : '⏻ 开启') +
|
||||
'</button>';
|
||||
|
||||
if (device.type === 'ac') {
|
||||
var currentTemp = device.temperature || 26;
|
||||
actionsHtml +=
|
||||
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp - 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')">⬇ -2°C</button>' +
|
||||
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp + 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')">⬆ +2°C</button>';
|
||||
}
|
||||
|
||||
actionsHtml +=
|
||||
'<button class="btn btn-xs" onclick="iotShowHistory(\'' + device.id + '\')" style="margin-left:auto">📋 历史</button>' +
|
||||
'</div>' +
|
||||
'<div id="iot-history-' + device.id + '" class="iot-history-panel" style="display:none"></div>';
|
||||
|
||||
return '<div class="iot-device-card ' + (isOn ? 'on' : 'off') + '" id="iot-card-' + device.id + '">' +
|
||||
'<div class="iot-device-header">' +
|
||||
'<div class="iot-device-name">' +
|
||||
'<span style="font-size:24px">' + icon + '</span>' +
|
||||
'<div>' +
|
||||
'<div>' + escHtml(device.name) + '</div>' +
|
||||
'<div class="iot-device-type">' + escHtml(device.type) + ' · ' + escHtml(device.id) + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="iot-device-status">' +
|
||||
'<span class="iot-status-dot ' + (isOn ? 'on' : 'off') + '"></span>' +
|
||||
'<span style="font-size:12px;font-weight:600;color:' + (isOn ? 'var(--green)' : 'var(--text3)') + '">' + (isOn ? '开启' : '关闭') + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
(propsHtml ? '<div class="iot-device-props">' + propsHtml + '</div>' : '') +
|
||||
actionsHtml +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
async function iotToggle(deviceId) {
|
||||
console.log('[IoT] 切换设备开关: ' + deviceId);
|
||||
var data = await api('/api/iot/devices/' + deviceId + '/toggle', { method: 'POST' });
|
||||
if (data.error) {
|
||||
showToast('切换失败: ' + data.error, 'error');
|
||||
} else {
|
||||
var device = data.device || {};
|
||||
showToast((device.name || deviceId) + ': ' + (device.status === 'on' ? '已开启' : '已关闭'), 'success');
|
||||
renderIoTPanel();
|
||||
}
|
||||
}
|
||||
|
||||
async function iotSetProperty(deviceId, field, value) {
|
||||
console.log('[IoT] 设置设备属性: ' + deviceId + ' -> ' + field + ' = ' + value);
|
||||
var data = await api('/api/iot/devices/' + deviceId + '/set', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ field: field, value: value }),
|
||||
});
|
||||
if (data.error) {
|
||||
showToast('设置失败: ' + data.error, 'error');
|
||||
} else {
|
||||
var device = data.device || {};
|
||||
showToast((device.name || deviceId) + ': ' + field + ' = ' + value, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshIoTDeviceCard(deviceId) {
|
||||
var data = await api('/api/iot/devices/' + deviceId);
|
||||
if (data.error) return;
|
||||
var device = data.device;
|
||||
if (!device) return;
|
||||
var card = document.getElementById('iot-card-' + deviceId);
|
||||
if (!card) return;
|
||||
card.outerHTML = renderIoTDeviceCard(device);
|
||||
}
|
||||
|
||||
async function iotShowHistory(deviceId) {
|
||||
var panel = document.getElementById('iot-history-' + deviceId);
|
||||
if (!panel) return;
|
||||
if (panel.style.display !== 'none') {
|
||||
panel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
console.log('[IoT] 获取设备历史: ' + deviceId);
|
||||
var data = await api('/api/iot/devices/' + deviceId + '/history');
|
||||
panel.style.display = '';
|
||||
if (data.error) {
|
||||
panel.innerHTML = '<div style="color:var(--red);font-size:11px;padding:4px">' + escHtml(data.error) + '</div>';
|
||||
return;
|
||||
}
|
||||
var history = data.history || [];
|
||||
if (history.length === 0) {
|
||||
panel.innerHTML = '<div style="color:var(--text3);font-size:11px;padding:4px">暂无操作历史</div>';
|
||||
return;
|
||||
}
|
||||
panel.innerHTML = history.slice(-20).reverse().map(function(h) {
|
||||
var timeStr = h.timestamp ? new Date(h.timestamp).toLocaleTimeString('zh-CN', {hour12: false}) : '—';
|
||||
return '<div class="iot-history-item">' +
|
||||
'<span class="iot-hist-time">' + timeStr + '</span>' +
|
||||
'<span class="iot-hist-action">' + escHtml(h.action || '操作') + '</span>' +
|
||||
'<span class="iot-hist-detail">' + escHtml(h.detail || h.value || '') + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 管理控制台配置
|
||||
* 定义各服务的启动参数、端口、健康检查等
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
const isWin = os.platform() === 'win32';
|
||||
|
||||
// 读取 backend/.env 文件,将值合并到 process.env(不覆盖已有的环境变量)
|
||||
// 这样 ethend 启动各服务时能传递用户配置的凭据
|
||||
function loadEnvFile() {
|
||||
const envPath = path.join(ROOT, 'backend', '.env');
|
||||
try {
|
||||
const content = fs.readFileSync(envPath, 'utf-8');
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
const key = trimmed.substring(0, eqIdx).trim();
|
||||
const val = trimmed.substring(eqIdx + 1).trim();
|
||||
if (key && val !== undefined) {
|
||||
process.env[key] = process.env[key] || val;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// .env 文件不存在,使用默认值
|
||||
}
|
||||
}
|
||||
loadEnvFile();
|
||||
|
||||
/** 跨平台 Go 二进制路径 */
|
||||
function findGoBin() {
|
||||
// 优先使用环境变量
|
||||
if (process.env.GOROOT) return path.join(process.env.GOROOT, 'bin', 'go');
|
||||
// Windows 常见路径
|
||||
const candidates = isWin
|
||||
? ['C:\\Program Files\\Go\\bin\\go.exe', 'C:\\Go\\bin\\go.exe', 'go']
|
||||
: ['/usr/local/go/bin/go', '/usr/bin/go', 'go'];
|
||||
for (const p of candidates) {
|
||||
if (p === 'go' || fs.existsSync(p)) return p;
|
||||
}
|
||||
return 'go';
|
||||
}
|
||||
|
||||
const GO_BIN = findGoBin();
|
||||
|
||||
export const ETHEND_PORT = process.env.ETHEND_PORT || 9090;
|
||||
export const LOGS_DIR = path.resolve(__dirname, '../logs');
|
||||
export const GATEWAY_URL = process.env.GATEWAY_URL || 'http://localhost:8080';
|
||||
export const PLUGIN_MANAGER_URL = process.env.PLUGIN_MANAGER_URL || 'http://localhost:8094';
|
||||
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
|
||||
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'cyrene-dev-admin';
|
||||
|
||||
export const SERVICES = {
|
||||
'ai-core': {
|
||||
name: 'AI-Core',
|
||||
cwd: path.join(ROOT, 'backend/ai-core'),
|
||||
command: './main',
|
||||
env: {
|
||||
AI_CORE_PORT: '8081',
|
||||
PERSONA_DIR: './internal/persona',
|
||||
SEARXNG_URL: process.env.SEARXNG_URL || 'http://localhost:8088',
|
||||
IOT_SERVICE_URL: process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
|
||||
ENABLE_BACKGROUND_THINKING: process.env.ENABLE_BACKGROUND_THINKING || 'true',
|
||||
},
|
||||
healthUrl: 'http://localhost:8081/api/v1/health',
|
||||
port: 8081,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
'iot-debug-service': {
|
||||
name: 'IoT Debug',
|
||||
cwd: path.join(ROOT, 'backend/iot-debug-service'),
|
||||
command: './main',
|
||||
env: {
|
||||
IOT_DEBUG_PORT: '8083',
|
||||
},
|
||||
healthUrl: 'http://localhost:8083/api/v1/health',
|
||||
port: 8083,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
gateway: {
|
||||
name: 'Gateway',
|
||||
cwd: path.join(ROOT, 'backend/gateway'),
|
||||
command: './main',
|
||||
env: {
|
||||
GATEWAY_PORT: '8080',
|
||||
JWT_SECRET: process.env.JWT_SECRET || 'dev-secret-key-change-me',
|
||||
AI_CORE_URL: 'http://localhost:8081',
|
||||
MEMORY_SERVICE_URL: process.env.MEMORY_SERVICE_URL || 'http://localhost:8091',
|
||||
ADMIN_USERNAME: process.env.ADMIN_USERNAME || 'admin',
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'cyrene-dev-admin',
|
||||
REGISTRATION_ENABLED: process.env.REGISTRATION_ENABLED || 'true',
|
||||
},
|
||||
healthUrl: 'http://localhost:8080/api/v1/health',
|
||||
port: 8080,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
'memory-service': {
|
||||
name: '记忆服务',
|
||||
cwd: path.join(ROOT, 'backend/memory-service'),
|
||||
command: './main',
|
||||
env: {
|
||||
PORT: '8091',
|
||||
DB_URL: process.env.DB_URL || 'postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable',
|
||||
},
|
||||
healthUrl: 'http://localhost:8091/api/v1/health',
|
||||
port: 8091,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
'voice-service': {
|
||||
name: '语音识别服务',
|
||||
cwd: path.join(ROOT, 'backend/voice-service'),
|
||||
command: './main',
|
||||
env: {
|
||||
PORT: '8093',
|
||||
WHISPER_BINARY: './whisper.cpp/main',
|
||||
WHISPER_MODEL: './whisper.cpp/models/ggml-small.bin',
|
||||
WHISPER_LANGUAGE: 'zh',
|
||||
DASHSCOPE_API_KEY: process.env.DASHSCOPE_API_KEY || '',
|
||||
DASHSCOPE_STT_MODEL: 'gummy-chat-v1',
|
||||
},
|
||||
healthUrl: 'http://localhost:8093/api/v1/health',
|
||||
port: 8093,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
'plugin-manager': {
|
||||
name: '插件管理器',
|
||||
cwd: path.join(ROOT, 'backend/plugin-manager'),
|
||||
command: './main',
|
||||
env: {
|
||||
PORT: '8094',
|
||||
IOT_SERVICE_URL: process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
|
||||
},
|
||||
healthUrl: 'http://localhost:8094/api/v1/health',
|
||||
port: 8094,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
'platform-bridge': {
|
||||
name: '多平台桥接',
|
||||
cwd: path.join(ROOT, 'backend/platform-bridge'),
|
||||
command: './main',
|
||||
env: {
|
||||
PORT: '8095',
|
||||
AI_CORE_URL: 'http://localhost:8081',
|
||||
QQ_BOT_PORT: process.env.QQ_BOT_PORT || '8096',
|
||||
TELEGRAM_BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN || '',
|
||||
TELEGRAM_WEBHOOK_URL: process.env.TELEGRAM_WEBHOOK_URL || '',
|
||||
QQ_ADMIN_UID: process.env.QQ_ADMIN_UID || '',
|
||||
TELEGRAM_ADMIN_UID: process.env.TELEGRAM_ADMIN_UID || '',
|
||||
},
|
||||
healthUrl: 'http://localhost:8095/health',
|
||||
port: 8095,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
|
||||
goBin: GO_BIN,
|
||||
},
|
||||
frontend: {
|
||||
name: 'Frontend',
|
||||
cwd: path.join(ROOT, 'frontend/web'),
|
||||
command: 'npx',
|
||||
args: ['vite', '--host', '0.0.0.0'],
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
},
|
||||
healthUrl: 'http://localhost:5173',
|
||||
port: 5173,
|
||||
nodeBin: 'node',
|
||||
npmBin: 'npx',
|
||||
// frontend不需要预编译,dev server即可
|
||||
buildCommand: null,
|
||||
},
|
||||
};
|
||||
|
||||
/** 各服务默认的日志文件路径 */
|
||||
export function logFile(serviceId) {
|
||||
return path.join(LOGS_DIR, `${serviceId}.log`);
|
||||
}
|
||||
+1752
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 性能监控模块
|
||||
* 监控各服务进程的 CPU、内存使用情况
|
||||
*/
|
||||
|
||||
import pidusage from 'pidusage';
|
||||
import { processManager } from './process-manager.js';
|
||||
import { SERVICES } from './config.js';
|
||||
|
||||
class PerformanceMonitor {
|
||||
constructor() {
|
||||
/** @type {Map<string, Array<{ts: number, cpu: number, mem: number}>>} */
|
||||
this.history = new Map();
|
||||
this.interval = null;
|
||||
|
||||
// Ring buffer for actual HTTP request latencies (ms)
|
||||
this.latencyBuffer = [];
|
||||
this.maxLatencySamples = 500;
|
||||
|
||||
for (const id of Object.keys(SERVICES)) {
|
||||
this.history.set(id, []);
|
||||
}
|
||||
}
|
||||
|
||||
/** Record an HTTP request duration (ms). Called by middleware. */
|
||||
recordLatency(durationMs) {
|
||||
this.latencyBuffer.push(durationMs);
|
||||
if (this.latencyBuffer.length > this.maxLatencySamples) {
|
||||
this.latencyBuffer.splice(0, this.latencyBuffer.length - this.maxLatencySamples);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get average request latency from recent samples. Returns null if no data. */
|
||||
getAverageLatency() {
|
||||
if (this.latencyBuffer.length === 0) return null;
|
||||
const sum = this.latencyBuffer.reduce((a, b) => a + b, 0);
|
||||
return Math.round(sum / this.latencyBuffer.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始定期采样 (每3秒)
|
||||
*/
|
||||
start() {
|
||||
if (this.interval) return;
|
||||
this.interval = setInterval(() => this.sample(), 3000);
|
||||
this.interval.unref(); // 不阻止进程退出
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止采样
|
||||
*/
|
||||
stop() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 采样一次
|
||||
*/
|
||||
async sample() {
|
||||
for (const [id, info] of processManager.processes) {
|
||||
if (!info.pid) continue;
|
||||
try {
|
||||
const stats = await pidusage(info.pid);
|
||||
const history = this.history.get(id);
|
||||
history.push({
|
||||
ts: Date.now(),
|
||||
cpu: Math.round(stats.cpu * 100) / 100,
|
||||
mem: Math.round(stats.memory / 1024 / 1024 * 100) / 100, // MB
|
||||
});
|
||||
// 保留最近300条 (约15分钟)
|
||||
if (history.length > 300) {
|
||||
history.splice(0, history.length - 300);
|
||||
}
|
||||
} catch {
|
||||
// 进程可能已退出
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前性能快照
|
||||
*/
|
||||
async getSnapshot() {
|
||||
const result = {};
|
||||
for (const [id, info] of processManager.processes) {
|
||||
if (!info.pid) {
|
||||
result[id] = { pid: null, cpu: 0, mem: 0 };
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stats = await pidusage(info.pid);
|
||||
result[id] = {
|
||||
pid: info.pid,
|
||||
cpu: Math.round(stats.cpu * 100) / 100,
|
||||
mem: Math.round(stats.memory / 1024 / 1024 * 100) / 100,
|
||||
elapsed: stats.elapsed,
|
||||
};
|
||||
} catch {
|
||||
result[id] = { pid: info.pid, cpu: 0, mem: 0 };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史数据
|
||||
*/
|
||||
getHistory(serviceId) {
|
||||
return this.history.get(serviceId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有服务的历史数据
|
||||
*/
|
||||
getAllHistory() {
|
||||
const result = {};
|
||||
for (const id of this.history.keys()) {
|
||||
result[id] = this.history.get(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新仪表盘数据 — 返回聚合的性能摘要供首页仪表盘使用
|
||||
* 调用方负责将数据渲染到 #performance-dashboard 元素
|
||||
* @returns {object} 仪表盘性能摘要
|
||||
*/
|
||||
async updateDashboard() {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const entries = Object.entries(snapshot);
|
||||
|
||||
let totalCpu = 0, totalMem = 0, activeCount = 0;
|
||||
for (const [, p] of entries) {
|
||||
totalCpu += p.cpu || 0;
|
||||
totalMem += p.mem || 0;
|
||||
if (p.pid) activeCount++;
|
||||
}
|
||||
|
||||
const avgCpu = entries.length > 0 ? Math.round(totalCpu / entries.length * 10) / 10 : 0;
|
||||
const totalMemRounded = Math.round(totalMem * 100) / 100;
|
||||
|
||||
// 计算平均请求延迟 (基于实际 HTTP 请求耗时,非进程 uptime)
|
||||
const avgLatencyMs = this.getAverageLatency();
|
||||
|
||||
// 获取最近历史用于趋势判断
|
||||
const recentHistory = this.getAllHistory();
|
||||
let trendCpu = 'stable', trendMem = 'stable';
|
||||
for (const [, hist] of Object.entries(recentHistory)) {
|
||||
if (hist.length < 5) continue;
|
||||
const recent = hist.slice(-5);
|
||||
const firstCpu = recent[0].cpu, lastCpu = recent[recent.length - 1].cpu;
|
||||
const firstMem = recent[0].mem, lastMem = recent[recent.length - 1].mem;
|
||||
if (lastCpu > firstCpu * 1.15) trendCpu = 'up';
|
||||
else if (lastCpu < firstCpu * 0.85) trendCpu = 'down';
|
||||
if (lastMem > firstMem * 1.15) trendMem = 'up';
|
||||
else if (lastMem < firstMem * 0.85) trendMem = 'down';
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
summary: {
|
||||
avgCpu,
|
||||
totalMemMB: totalMemRounded,
|
||||
activeProcesses: activeCount,
|
||||
monitoredServices: entries.length,
|
||||
avgLatencyMs,
|
||||
trend: { cpu: trendCpu, mem: trendMem },
|
||||
},
|
||||
perService: snapshot,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const performanceMonitor = new PerformanceMonitor();
|
||||
@@ -0,0 +1,631 @@
|
||||
/**
|
||||
* 进程管理器
|
||||
* 负责启动/停止/重启各服务,捕获stdout/stderr并推送到日志系统
|
||||
*/
|
||||
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import fs from 'fs';
|
||||
import net from 'net';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { SERVICES, logFile } from './config.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
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 释放
|
||||
*/
|
||||
function releasePort(port) {
|
||||
return new Promise((resolve) => {
|
||||
const sock = new net.Socket();
|
||||
sock.setTimeout(1000);
|
||||
sock.on('connect', () => {
|
||||
sock.destroy();
|
||||
// 端口被占用,尝试释放
|
||||
try {
|
||||
execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { timeout: 3000 });
|
||||
} catch { /* ignore */ }
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
sock.on('error', () => {
|
||||
sock.destroy();
|
||||
resolve(); // 端口空闲
|
||||
});
|
||||
sock.on('timeout', () => {
|
||||
sock.destroy();
|
||||
resolve();
|
||||
});
|
||||
sock.connect(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查端口是否可连接 (TCP connect, 超时2秒)
|
||||
*/
|
||||
function isPortOpen(port) {
|
||||
return new Promise((resolve) => {
|
||||
const sock = new net.Socket();
|
||||
sock.setTimeout(2000);
|
||||
sock.on('connect', () => { sock.destroy(); resolve(true); });
|
||||
sock.on('error', () => { sock.destroy(); resolve(false); });
|
||||
sock.on('timeout', () => { sock.destroy(); resolve(false); });
|
||||
sock.connect(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保数据库在线
|
||||
* 检查 DB_PORTS 中至少有一个端口可用,若不可用则尝试 docker compose up
|
||||
* 等待最多 30 秒检查数据库就绪
|
||||
* @param {string} serviceId - 正在启动的服务 ID
|
||||
* @param {EventEmitter} emitter - 用于发送日志事件
|
||||
*/
|
||||
async function ensureDBOnline(serviceId, emitter) {
|
||||
// 1. 快速检查:任意数据库端口是否已在线
|
||||
for (const port of DB_PORTS) {
|
||||
if (await isPortOpen(port)) {
|
||||
emitter.emit('log', serviceId, 'system', `数据库端口 ${port} 已在线`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 数据库不在线,尝试 docker compose up
|
||||
emitter.emit('log', serviceId, 'system', '数据库未启动,正在通过 Docker Compose 启动...');
|
||||
try {
|
||||
execSync(`docker compose -f "${DB_COMPOSE_FILE}" up -d`, {
|
||||
timeout: 60000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
emitter.emit('log', serviceId, 'system', 'Docker Compose 启动命令已执行,等待数据库就绪...');
|
||||
} catch (err) {
|
||||
const stderr = err.stderr?.toString() || err.message;
|
||||
emitter.emit('log', serviceId, 'error', `Docker Compose 启动失败: ${stderr}`);
|
||||
}
|
||||
|
||||
// 3. 等待最多 30 秒检查数据库就绪
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
for (const port of DB_PORTS) {
|
||||
if (await isPortOpen(port)) {
|
||||
emitter.emit('log', serviceId, 'system', `数据库端口 ${port} 已就绪 (等待 ${i + 1}s)`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 30 秒后仍不可用
|
||||
emitter.emit('log', serviceId, 'error', '⚠️ 数据库无法启动,请手动检查 Docker。将继续启动后端服务...');
|
||||
}
|
||||
|
||||
class ProcessManager extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
/** @type {Map<string, {process: ChildProcess|null, status: string, startTime: number|null, pid: number|null, buildLog: string[]}>} */
|
||||
this.processes = new Map();
|
||||
|
||||
for (const id of Object.keys(SERVICES)) {
|
||||
this.processes.set(id, {
|
||||
process: null,
|
||||
status: 'stopped',
|
||||
startTime: null,
|
||||
pid: null,
|
||||
buildLog: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动服务
|
||||
*/
|
||||
async start(serviceId) {
|
||||
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} 已在运行中`);
|
||||
}
|
||||
|
||||
// 对需要数据库的服务做前置检查
|
||||
if (['gateway', 'ai-core', 'memory-service', 'plugin-manager', 'platform-bridge'].includes(serviceId)) {
|
||||
this.emit('log', serviceId, 'system', '检查数据库连接状态...');
|
||||
await ensureDBOnline(serviceId, this);
|
||||
}
|
||||
|
||||
// 启动前释放端口,避免 "address already in use"
|
||||
if (svc.port) {
|
||||
this.emit('log', serviceId, 'system', `检查端口 ${svc.port}...`);
|
||||
await releasePort(svc.port);
|
||||
}
|
||||
|
||||
this.emit('log', serviceId, 'system', `正在启动 ${svc.name}...`);
|
||||
procInfo.status = 'starting';
|
||||
procInfo.buildLog = [];
|
||||
|
||||
// 确保日志目录存在
|
||||
const logPath = logFile(serviceId);
|
||||
const logDir = path.dirname(logPath);
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
|
||||
const logStream = fs.createWriteStream(logPath, { flags: 'a' });
|
||||
|
||||
// 确定二进制路径或命令
|
||||
let command, args;
|
||||
if (svc.command === './main') {
|
||||
command = isWin ? './main.exe' : './main';
|
||||
args = svc.args || [];
|
||||
} else if (svc.command === 'npx') {
|
||||
command = isWin ? 'npx.cmd' : (svc.npmBin || 'npx');
|
||||
args = svc.args || [];
|
||||
} else {
|
||||
command = svc.command;
|
||||
args = svc.args || [];
|
||||
}
|
||||
|
||||
// 对使用 npm 的服务,检查 node_modules 是否完整
|
||||
if (svc.command === 'npx' || svc.command === 'node') {
|
||||
const modulesDir = path.join(svc.cwd, 'node_modules');
|
||||
const installMarker = path.join(modulesDir, '.package-lock.json');
|
||||
if (!fs.existsSync(installMarker)) {
|
||||
// 如果 node_modules 目录存在但不完整,先删除
|
||||
if (fs.existsSync(modulesDir)) {
|
||||
this.emit('log', serviceId, 'system', 'node_modules 不完整,正在清理...');
|
||||
fs.rmSync(modulesDir, { recursive: true, force: true });
|
||||
}
|
||||
this.emit('log', serviceId, 'system', '正在运行 npm install...');
|
||||
// Windows: npm.cmd batch file has module resolution issues when
|
||||
// cwd has a node_modules directory, even if empty. Use node+npm-cli.js directly.
|
||||
const installCmd = isWin
|
||||
? `"${process.execPath}" "${path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js')}" install`
|
||||
: 'npm install';
|
||||
try {
|
||||
execSync(installCmd, { cwd: svc.cwd, timeout: 120000, stdio: 'pipe' });
|
||||
this.emit('log', serviceId, 'system', 'npm install 完成');
|
||||
} catch (err) {
|
||||
const stderr = err.stderr?.toString() || err.message;
|
||||
this.emit('log', serviceId, 'error', `npm install 失败: ${stderr}`);
|
||||
throw new Error(`npm install 失败: ${stderr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const env = { ...process.env, ...svc.env };
|
||||
// .cmd/.bat on Windows needs shell:true
|
||||
const needsShell = isWin && (command.endsWith('.cmd') || command.endsWith('.bat'));
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: svc.cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: needsShell,
|
||||
});
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
logStream.write(text);
|
||||
this.emit('log', serviceId, 'stdout', text);
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
logStream.write(text);
|
||||
this.emit('log', serviceId, 'stderr', text);
|
||||
});
|
||||
|
||||
// spawn() 返回后进程已启动,立即记录 PID 和状态
|
||||
// 注意: Node.js 没有 'spawn' 事件,spawn() 调用本身是同步的
|
||||
procInfo.pid = child.pid;
|
||||
procInfo.startTime = Date.now();
|
||||
procInfo.status = 'running';
|
||||
procInfo.process = child;
|
||||
this.emit('log', serviceId, 'system', `${svc.name} 已启动 (PID: ${child.pid})`);
|
||||
|
||||
child.on('error', (err) => {
|
||||
const msg = `进程错误: ${err.message}`;
|
||||
logStream.write(msg + '\n');
|
||||
this.emit('log', serviceId, 'error', msg);
|
||||
procInfo.status = 'error';
|
||||
procInfo.process = null;
|
||||
procInfo.pid = null;
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const msg = `进程退出,退出码: ${code}`;
|
||||
logStream.write(msg + '\n');
|
||||
this.emit('log', serviceId, 'system', msg);
|
||||
procInfo.status = 'stopped';
|
||||
procInfo.process = null;
|
||||
procInfo.pid = null;
|
||||
logStream.end();
|
||||
});
|
||||
|
||||
return { success: true, message: `${svc.name} 启动中...` };
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止服务
|
||||
*/
|
||||
async stop(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 管理,请使用 docker compose 控制` };
|
||||
}
|
||||
|
||||
const procInfo = this.processes.get(serviceId);
|
||||
if (!procInfo.process) {
|
||||
// 可能已经崩溃了,重置状态
|
||||
procInfo.status = 'stopped';
|
||||
return { success: true, message: `${svc.name} 未在运行` };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
// 强制杀死
|
||||
if (procInfo.process) {
|
||||
procInfo.process.kill('SIGKILL');
|
||||
}
|
||||
procInfo.status = 'stopped';
|
||||
procInfo.process = null;
|
||||
procInfo.pid = null;
|
||||
resolve({ success: true, message: `${svc.name} 已强制停止` });
|
||||
}, 5000);
|
||||
|
||||
procInfo.process.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
procInfo.status = 'stopped';
|
||||
procInfo.process = null;
|
||||
procInfo.pid = null;
|
||||
resolve({ success: true, message: `${svc.name} 已停止` });
|
||||
});
|
||||
|
||||
procInfo.process.kill('SIGTERM');
|
||||
this.emit('log', serviceId, 'system', `正在停止 ${svc.name}...`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重启服务
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建服务 (Go服务需要预编译)
|
||||
*/
|
||||
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} 不需要预编译` };
|
||||
}
|
||||
|
||||
const procInfo = this.processes.get(serviceId);
|
||||
procInfo.status = 'building';
|
||||
procInfo.buildLog = [];
|
||||
this.emit('log', serviceId, 'system', `正在编译 ${svc.name}...`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const buildCmd = svc.goBin || svc.buildCommand;
|
||||
const buildArgs = svc.buildArgs || [];
|
||||
|
||||
const child = spawn(buildCmd, buildArgs, {
|
||||
cwd: svc.cwd,
|
||||
env: { ...process.env, GOPROXY: 'https://goproxy.cn,direct', GOWORK: 'off' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
||||
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
||||
|
||||
child.on('close', (code) => {
|
||||
procInfo.status = 'stopped';
|
||||
procInfo.buildLog = [
|
||||
...stdout.split('\n').filter(Boolean),
|
||||
...stderr.split('\n').filter(Boolean),
|
||||
];
|
||||
|
||||
if (code === 0) {
|
||||
this.emit('log', serviceId, 'system', `${svc.name} 编译成功`);
|
||||
resolve({ success: true, message: `${svc.name} 编译成功` });
|
||||
} else {
|
||||
this.emit('log', serviceId, 'error', `${svc.name} 编译失败:\n${stderr || stdout}`);
|
||||
resolve({ success: false, message: '编译失败', buildLog: procInfo.buildLog });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
procInfo.status = 'stopped';
|
||||
resolve({ success: false, message: `编译错误: ${err.message}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有服务状态
|
||||
*/
|
||||
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: 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个服务状态
|
||||
*/
|
||||
getServiceStatus(serviceId) {
|
||||
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: 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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有服务
|
||||
*/
|
||||
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 });
|
||||
} catch (err) {
|
||||
results.push({ id, success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试接管已运行的服务 (通过健康检查端点)
|
||||
* 如果服务已在运行,直接标记为 running 而不是杀死重启
|
||||
*/
|
||||
async tryAdopt(serviceId) {
|
||||
const svc = SERVICES[serviceId];
|
||||
if (!svc || !svc.healthUrl) return false;
|
||||
|
||||
try {
|
||||
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(3000) });
|
||||
if (resp.ok) {
|
||||
const procInfo = this.processes.get(serviceId);
|
||||
// 尝试通过 fuser 获取 PID
|
||||
let pid = null;
|
||||
try {
|
||||
const out = execSync(`fuser ${svc.port}/tcp 2>/dev/null || true`, { timeout: 2000 }).toString().trim();
|
||||
const match = out.match(/(\d+)/);
|
||||
if (match) pid = parseInt(match[1]);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
procInfo.pid = pid;
|
||||
procInfo.startTime = Date.now();
|
||||
procInfo.status = 'running';
|
||||
procInfo.process = null; // 不是我们的子进程,但标记为已接管
|
||||
this.emit('log', serviceId, 'system', `${svc.name} 已在运行 (PID: ${pid || '未知'}),已接管`);
|
||||
return true;
|
||||
}
|
||||
} catch { /* 未运行或不可达 */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查服务是否需要编译 (Go 服务且二进制不存在)
|
||||
*/
|
||||
needsBuild(serviceId) {
|
||||
const svc = SERVICES[serviceId];
|
||||
if (!svc || !svc.buildCommand) return false;
|
||||
|
||||
const binaryPath = path.join(svc.cwd, 'main');
|
||||
const exePath = binaryPath + '.exe';
|
||||
return !fs.existsSync(binaryPath) && !fs.existsSync(exePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按顺序启动所有服务 (memory → iot → voice → ai-core → gateway → frontend)
|
||||
* 每步等待健康检查通过后再启动下一个
|
||||
*/
|
||||
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) {
|
||||
results.push({ id, success: true, message: `${svc.name} 已接管 (无需重启)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 编译检查: 如果 Go 服务二进制不存在,先编译
|
||||
if (this.needsBuild(id)) {
|
||||
this.emit('log', id, 'system', `未找到编译产物,正在编译 ${svc.name}...`);
|
||||
const buildResult = await this.build(id);
|
||||
if (!buildResult.success) {
|
||||
const errMsg = `编译失败: ${buildResult.message}`;
|
||||
this.emit('log', id, 'error', errMsg);
|
||||
results.push({ id, success: false, message: errMsg });
|
||||
continue;
|
||||
}
|
||||
this.emit('log', id, 'system', `${svc.name} 编译完成`);
|
||||
}
|
||||
|
||||
// 启动服务
|
||||
try {
|
||||
const r = await this.start(id);
|
||||
results.push({ id, ...r });
|
||||
|
||||
// 等待健康检查通过
|
||||
if (svc.healthUrl) {
|
||||
let healthy = false;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
try {
|
||||
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(2000) });
|
||||
if (resp.ok) { healthy = true; break; }
|
||||
} catch { /* continue waiting */ }
|
||||
}
|
||||
if (!healthy) {
|
||||
this.emit('log', id, 'error', `${svc.name} 健康检查超时`);
|
||||
} else {
|
||||
this.emit('log', id, 'system', `${svc.name} 健康检查通过 ✓`);
|
||||
// Gateway 和 AI-Core 启动后额外等待 2 秒,确保内部路由和 Handler 完全初始化
|
||||
if (id === 'gateway' || id === 'ai-core') {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
this.emit('log', id, 'system', `${svc.name} 已就绪 (额外等待 2s 确保服务稳定)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
results.push({ id, success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export const processManager = new ProcessManager();
|
||||
Reference in New Issue
Block a user