fix: 第二轮修复 — 数据库启动检查、会话持久化、URL路由、设备排序等
1. DevTools 启动前检查数据库状态,失败时自动尝试启动 2. ai-core 添加数据库断线重连机制 (30秒间隔) 3. Dashboard 添加数据库状态卡片 (启动/停止/重启) 4. Gateway 会话空闲超时管理 (30分钟标记空闲) 5. 会话/消息 PostgreSQL 持久化 (SessionStore + REST API) 6. 前端服务端会话持久化 + URL hash 路由 + 侧边栏管理 7. 管理员回到主对话按钮 8. IoT 设备卡片固定排序 9. 更新相关文档
This commit is contained in:
+54
-12
@@ -744,19 +744,22 @@ async function renderDashboard() {
|
||||
<div class="cards-grid cards-4" id="dashboard-svc-cards"></div>
|
||||
</div>
|
||||
|
||||
<!-- 数据库连接状态 -->
|
||||
<div class="card">
|
||||
<!-- 数据库状态卡片 -->
|
||||
<div class="card" id="db-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">🗄️ 数据库连接</span>
|
||||
${data.database?.checked ? `
|
||||
<span class="badge ${data.database.postgresAlive ? 'badge-running' : 'badge-error'}">
|
||||
PostgreSQL ${data.database.postgresAlive ? '通联' : '断开'}
|
||||
</span>
|
||||
<span class="badge ${data.database.tunnelRunning ? 'badge-running' : 'badge-stopped'}" style="margin-left:6px">
|
||||
隧道 ${data.database.tunnelRunning ? '运行中' : '未运行'}
|
||||
</span>
|
||||
` : '<span class="badge badge-stopped">待检查</span>'}
|
||||
<a href="#" onclick="switchPanel('database');return false" style="font-size:11px;color:var(--accent);text-decoration:none">🔍 详情 →</a>
|
||||
<span class="card-title">🗄️ 数据库</span>
|
||||
<span class="badge badge-stopped" id="db-status-badge">检查中...</span>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="value" id="db-type-display">PostgreSQL</div><div class="label">类型</div></div>
|
||||
<div class="metric"><div class="value" id="db-port-display">5432</div><div class="label">端口</div></div>
|
||||
<div class="metric"><div class="value" id="db-uptime-display">—</div><div class="label">状态</div></div>
|
||||
</div>
|
||||
<div class="btn-group" style="margin-top:10px">
|
||||
<button class="btn btn-xs btn-green" onclick="controlDB('start')">▶ 启动</button>
|
||||
<button class="btn btn-xs btn-red" onclick="controlDB('stop')">⏹ 停止</button>
|
||||
<button class="btn btn-xs" onclick="controlDB('restart')">🔄 重启</button>
|
||||
<a href="#" onclick="switchPanel('database');return false" style="font-size:10px;color:var(--accent);text-decoration:none;margin-left:auto;align-self:center">🔍 详情 →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -789,6 +792,9 @@ async function renderDashboard() {
|
||||
// 渲染服务卡片
|
||||
renderDashboardSvcCards(svcs);
|
||||
|
||||
// 渲染数据库卡片
|
||||
renderDBCard();
|
||||
|
||||
// 渲染性能快照
|
||||
const perfContainer = document.getElementById('dashboard-perf');
|
||||
const perf = data.performance?.perService || {};
|
||||
@@ -1701,6 +1707,42 @@ async function tunnelAction(action) {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 数据库卡片控制 ==========
|
||||
async function renderDBCard() {
|
||||
const data = await api('/api/db/status');
|
||||
const badge = document.getElementById('db-status-badge');
|
||||
const typeDisplay = document.getElementById('db-type-display');
|
||||
const portDisplay = document.getElementById('db-port-display');
|
||||
const uptimeDisplay = document.getElementById('db-uptime-display');
|
||||
|
||||
if (data.error) {
|
||||
if (badge) { badge.textContent = '错误'; badge.className = 'badge badge-error'; }
|
||||
if (uptimeDisplay) uptimeDisplay.textContent = '错误';
|
||||
return;
|
||||
}
|
||||
|
||||
const online = data.online;
|
||||
if (badge) {
|
||||
badge.textContent = online ? '🟢 在线' : '🔴 离线';
|
||||
badge.className = 'badge ' + (online ? 'badge-running' : 'badge-error');
|
||||
}
|
||||
if (typeDisplay) typeDisplay.textContent = 'PostgreSQL';
|
||||
if (portDisplay) portDisplay.textContent = data.port || 5432;
|
||||
if (uptimeDisplay) uptimeDisplay.textContent = online ? '已连接' : '未连接';
|
||||
}
|
||||
|
||||
async function controlDB(action) {
|
||||
showToast('正在' + action + '数据库...', 'info');
|
||||
const data = await api('/api/db/' + action, { method: 'POST' });
|
||||
if (data.error) {
|
||||
showToast('操作失败: ' + data.error, 'error');
|
||||
} else {
|
||||
showToast('数据库 ' + action + ' 完成', 'success');
|
||||
// 等待2秒后刷新状态
|
||||
setTimeout(renderDBCard, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<script src="iot-panel.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -52,6 +52,16 @@ async function renderIoTPanel() {
|
||||
}
|
||||
|
||||
var devices = result.devices;
|
||||
|
||||
// 固定设备排列顺序: 先按类型,同类型再按 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;
|
||||
|
||||
@@ -696,6 +696,82 @@ app.post('/api/tunnel/:action', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 数据库控制 (Docker Compose) ----
|
||||
const DB_COMPOSE_FILE = path.join(ROOT, 'docker-compose.dev.db.yml');
|
||||
const DB_PORT = 5432;
|
||||
|
||||
// GET /api/db/status
|
||||
app.get('/api/db/status', (_req, res) => {
|
||||
try {
|
||||
const online = checkPort(DB_PORT);
|
||||
res.json({
|
||||
online,
|
||||
port: DB_PORT,
|
||||
checked_at: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
res.json({
|
||||
online: false,
|
||||
port: DB_PORT,
|
||||
checked_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/db/start
|
||||
app.post('/api/db/start', (_req, res) => {
|
||||
try {
|
||||
const out = execSync(`docker compose -f "${DB_COMPOSE_FILE}" up -d`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
res.json({ success: true, action: 'start', output: out.trim() });
|
||||
} catch (err) {
|
||||
const stderr = err.stderr?.toString() || err.message;
|
||||
res.status(500).json({ success: false, action: 'start', error: stderr });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/db/stop
|
||||
app.post('/api/db/stop', (_req, res) => {
|
||||
try {
|
||||
const out = execSync(`docker compose -f "${DB_COMPOSE_FILE}" down`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
res.json({ success: true, action: 'stop', output: out.trim() });
|
||||
} catch (err) {
|
||||
const stderr = err.stderr?.toString() || err.message;
|
||||
res.status(500).json({ success: false, action: 'stop', error: stderr });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/db/restart
|
||||
app.post('/api/db/restart', (_req, res) => {
|
||||
try {
|
||||
const downOut = execSync(`docker compose -f "${DB_COMPOSE_FILE}" down`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const upOut = execSync(`docker compose -f "${DB_COMPOSE_FILE}" up -d`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
action: 'restart',
|
||||
output: `down: ${downOut.trim()}\nup: ${upOut.trim()}`,
|
||||
});
|
||||
} catch (err) {
|
||||
const stderr = err.stderr?.toString() || err.message;
|
||||
res.status(500).json({ success: false, action: 'restart', error: stderr });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 启动 ==========
|
||||
// 启动性能监控
|
||||
performanceMonitor.start();
|
||||
|
||||
@@ -7,8 +7,16 @@ import { spawn, execSync } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import fs from 'fs';
|
||||
import net from 'net';
|
||||
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');
|
||||
|
||||
/**
|
||||
* 通过 TCP 连接尝试判断端口是否被占用,若被占用则尝试用 fuser 释放
|
||||
*/
|
||||
@@ -36,6 +44,64 @@ function releasePort(port) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查端口是否可连接 (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();
|
||||
@@ -65,6 +131,12 @@ class ProcessManager extends EventEmitter {
|
||||
throw new Error(`${svc.name} 已在运行中`);
|
||||
}
|
||||
|
||||
// 对 gateway 和 ai-core 做数据库前置检查
|
||||
if (serviceId === 'gateway' || serviceId === 'ai-core') {
|
||||
this.emit('log', serviceId, 'system', '检查数据库连接状态...');
|
||||
await ensureDBOnline(serviceId, this);
|
||||
}
|
||||
|
||||
// 启动前释放端口,避免 "address already in use"
|
||||
if (svc.port) {
|
||||
this.emit('log', serviceId, 'system', `检查端口 ${svc.port}...`);
|
||||
|
||||
Reference in New Issue
Block a user