feat: 重构项目心跳数据结构并实现相关功能

- 重构Redis心跳数据结构,使用统一的项目列表键
- 新增数据迁移工具和API端点
- 更新前端以使用真实项目数据
- 添加系统部署配置和文档
- 修复代码格式和样式问题
This commit is contained in:
2026-01-15 14:14:10 +08:00
parent 282f7268ed
commit a8faa7dcaa
24 changed files with 307 additions and 560 deletions

View File

@@ -53,15 +53,14 @@ function buildTargetUrl(apiBaseUrl, apiName) {
function truncateForLog(value, maxLen = 2000) {
if (value == null) return value;
if (typeof value === 'string')
return value.length > maxLen ? `${value.slice(0, maxLen)}` : value;
if (typeof value === 'string') return value.length > maxLen ? `${value.slice(0, maxLen)}` : value;
return value;
}
async function getProjectHeartbeat(redis, projectName) {
try {
const projectsList = await getProjectsList(redis);
const project = projectsList.find((p) => p.projectName === projectName);
const projectsList = await getProjectsList();
const project = projectsList.find(p => p.projectName === projectName);
if (project) {
return {
@@ -70,10 +69,7 @@ async function getProjectHeartbeat(redis, projectName) {
};
}
} catch (err) {
console.error(
'[getProjectHeartbeat] Failed to get from projects list:',
err,
);
console.error('[getProjectHeartbeat] Failed to get from projects list:', err);
}
const heartbeatRaw = await redis.get(projectHeartbeatKey(projectName));
@@ -137,10 +133,7 @@ router.post('/', async (req, res) => {
}
const heartbeatKey = projectHeartbeatKey(targetProjectName.trim());
const heartbeat = await getProjectHeartbeat(
redis,
targetProjectName.trim(),
);
const heartbeat = await getProjectHeartbeat(redis, targetProjectName.trim());
if (!heartbeat) {
return res.status(503).json({
@@ -149,10 +142,7 @@ router.post('/', async (req, res) => {
});
}
const apiBaseUrl =
typeof heartbeat.apiBaseUrl === 'string'
? heartbeat.apiBaseUrl.trim()
: '';
const apiBaseUrl = typeof heartbeat.apiBaseUrl === 'string' ? heartbeat.apiBaseUrl.trim() : '';
const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt);
if (!apiBaseUrl) {
@@ -162,16 +152,9 @@ router.post('/', async (req, res) => {
});
}
const offlineThresholdMs = Number.parseInt(
process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000',
10,
);
const offlineThresholdMs = Number.parseInt(process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000', 10);
const now = Date.now();
if (
!lastActiveAt ||
(Number.isFinite(offlineThresholdMs) &&
now - lastActiveAt > offlineThresholdMs)
) {
if (!lastActiveAt || (Number.isFinite(offlineThresholdMs) && now - lastActiveAt > offlineThresholdMs)) {
return res.status(503).json({
success: false,
message: '目标项目已离线(心跳超时)',
@@ -187,10 +170,7 @@ router.post('/', async (req, res) => {
});
}
const timeoutMs = Number.parseInt(
process.env.COMMAND_API_TIMEOUT_MS || '5000',
10,
);
const timeoutMs = Number.parseInt(process.env.COMMAND_API_TIMEOUT_MS || '5000', 10);
const resp = await axios.post(targetUrl, payload, {
timeout: Number.isFinite(timeoutMs) ? timeoutMs : 5000,
validateStatus: () => true,
@@ -229,4 +209,4 @@ router.post('/', async (req, res) => {
}
});
export default router;
export default router;

View File

@@ -3,10 +3,7 @@ import express from 'express';
const router = express.Router();
import { getRedisClient } from '../services/redisClient.js';
import {
projectConsoleKey,
projectHeartbeatKey,
} from '../services/redisKeys.js';
import { projectConsoleKey, projectHeartbeatKey } from '../services/redisKeys.js';
import { getProjectsList } from '../services/migrateHeartbeatData.js';
function parsePositiveInt(value, defaultValue) {
@@ -28,8 +25,8 @@ function parseLastActiveAt(value) {
async function getProjectHeartbeat(redis, projectName) {
try {
const projectsList = await getProjectsList(redis);
const project = projectsList.find((p) => p.projectName === projectName);
const projectsList = await getProjectsList();
const project = projectsList.find(p => p.projectName === projectName);
if (project) {
return {
@@ -38,10 +35,7 @@ async function getProjectHeartbeat(redis, projectName) {
};
}
} catch (err) {
console.error(
'[getProjectHeartbeat] Failed to get from projects list:',
err,
);
console.error('[getProjectHeartbeat] Failed to get from projects list:', err);
}
const heartbeatRaw = await redis.get(projectHeartbeatKey(projectName));
@@ -58,10 +52,7 @@ async function getProjectHeartbeat(redis, projectName) {
// 获取日志列表
router.get('/', async (req, res) => {
const projectName =
typeof req.query.projectName === 'string'
? req.query.projectName.trim()
: '';
const projectName = typeof req.query.projectName === 'string' ? req.query.projectName.trim() : '';
const limit = parsePositiveInt(req.query.limit, 200);
if (!projectName) {
@@ -84,61 +75,50 @@ router.get('/', async (req, res) => {
const key = projectConsoleKey(projectName);
const list = await redis.lRange(key, -limit, -1);
const logs = list.map((raw, idx) => {
try {
const parsed = JSON.parse(raw);
const timestamp = parsed.timestamp || new Date().toISOString();
const level = (parsed.level || 'info').toString().toLowerCase();
const message = parsed.message != null ? String(parsed.message) : '';
return {
id: parsed.id || `log-${timestamp}-${idx}`,
timestamp,
level,
message,
metadata:
parsed.metadata && typeof parsed.metadata === 'object'
? parsed.metadata
: undefined,
};
} catch {
return {
id: `log-${Date.now()}-${idx}`,
timestamp: new Date().toISOString(),
level: 'info',
message: raw,
};
}
});
const logs = list
.map((raw, idx) => {
try {
const parsed = JSON.parse(raw);
const timestamp = parsed.timestamp || new Date().toISOString();
const level = (parsed.level || 'info').toString().toLowerCase();
const message = parsed.message != null ? String(parsed.message) : '';
return {
id: parsed.id || `log-${timestamp}-${idx}`,
timestamp,
level,
message,
metadata: parsed.metadata && typeof parsed.metadata === 'object' ? parsed.metadata : undefined,
};
} catch {
return {
id: `log-${Date.now()}-${idx}`,
timestamp: new Date().toISOString(),
level: 'info',
message: raw,
};
}
});
const heartbeat = await getProjectHeartbeat(redis, projectName);
const offlineThresholdMs = Number.parseInt(
process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000',
10,
);
const offlineThresholdMs = Number.parseInt(process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000', 10);
const now = Date.now();
const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt);
const ageMs = lastActiveAt ? now - lastActiveAt : null;
const isOnline =
lastActiveAt && Number.isFinite(offlineThresholdMs)
? ageMs <= offlineThresholdMs
: Boolean(lastActiveAt);
const isOnline = lastActiveAt && Number.isFinite(offlineThresholdMs)
? ageMs <= offlineThresholdMs
: Boolean(lastActiveAt);
const computedStatus = heartbeat ? (isOnline ? '在线' : '离线') : null;
return res.status(200).json({
logs,
projectStatus: computedStatus || null,
heartbeat: heartbeat
? {
apiBaseUrl:
typeof heartbeat.apiBaseUrl === 'string'
? heartbeat.apiBaseUrl
: null,
lastActiveAt: lastActiveAt || null,
isOnline,
ageMs,
}
: null,
heartbeat: heartbeat ? {
apiBaseUrl: typeof heartbeat.apiBaseUrl === 'string' ? heartbeat.apiBaseUrl : null,
lastActiveAt: lastActiveAt || null,
isOnline,
ageMs,
} : null,
});
} catch (err) {
console.error('Failed to read logs', err);
@@ -150,4 +130,4 @@ router.get('/', async (req, res) => {
}
});
export default router;
export default router;

View File

@@ -3,10 +3,7 @@ import express from 'express';
const router = express.Router();
import { getRedisClient } from '../services/redisClient.js';
import {
migrateHeartbeatData,
getProjectsList,
} from '../services/migrateHeartbeatData.js';
import { migrateHeartbeatData, getProjectsList } from '../services/migrateHeartbeatData.js';
function parseLastActiveAt(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
@@ -20,10 +17,7 @@ function parseLastActiveAt(value) {
}
function computeProjectStatus(project) {
const offlineThresholdMs = Number.parseInt(
process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000',
10,
);
const offlineThresholdMs = Number.parseInt(process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000', 10);
const now = Date.now();
const lastActiveAt = parseLastActiveAt(project?.lastActiveAt);
@@ -36,9 +30,7 @@ function computeProjectStatus(project) {
}
const ageMs = now - lastActiveAt;
const isOnline = Number.isFinite(offlineThresholdMs)
? ageMs <= offlineThresholdMs
: true;
const isOnline = Number.isFinite(offlineThresholdMs) ? ageMs <= offlineThresholdMs : true;
return {
status: isOnline ? 'online' : 'offline',
@@ -58,8 +50,8 @@ router.get('/', async (req, res) => {
});
}
const projectsList = await getProjectsList(redis);
const projects = projectsList.map((project) => {
const projectsList = await getProjectsList();
const projects = projectsList.map(project => {
const statusInfo = computeProjectStatus(project);
return {
id: project.projectName,
@@ -89,11 +81,9 @@ router.post('/migrate', async (req, res) => {
const { deleteOldKeys = false, dryRun = false } = req.body;
try {
const redis = req.app?.locals?.redis || (await getRedisClient());
const result = await migrateHeartbeatData({
deleteOldKeys: Boolean(deleteOldKeys),
dryRun: Boolean(dryRun),
redis,
});
return res.status(200).json({
@@ -111,4 +101,4 @@ router.post('/migrate', async (req, res) => {
}
});
export default router;
export default router;

View File

@@ -1,9 +1,23 @@
import express from 'express';
import cors from 'cors';
import logRoutes from './routes/logs.js';
import commandRoutes from './routes/commands.js';
import projectRoutes from './routes/projects.js';
import { getRedisClient } from './services/redisClient.js';
import { createApp } from './app.js';
const app = express();
const PORT = 3001;
const app = createApp();
app.use(cors());
app.use(express.json());
app.use('/api/logs', logRoutes);
app.use('/api/commands', commandRoutes);
app.use('/api/projects', projectRoutes);
app.get('/api/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// 启动服务器
const server = app.listen(PORT, async () => {
@@ -28,4 +42,4 @@ process.on('SIGINT', async () => {
} finally {
server.close(() => process.exit(0));
}
});
});

View File

@@ -1,7 +1,7 @@
import { getRedisClient } from './redisClient.js';
import { projectsListKey } from './redisKeys.js';
export function parseLastActiveAt(value) {
function parseLastActiveAt(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const asNum = Number(value);
@@ -12,65 +12,13 @@ export function parseLastActiveAt(value) {
return null;
}
export function normalizeProjectEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
export async function migrateHeartbeatData(options = {}) {
const { deleteOldKeys = false, dryRun = false } = options;
const projectName =
typeof entry.projectName === 'string' ? entry.projectName.trim() : '';
if (!projectName) return null;
const apiBaseUrl =
typeof entry.apiBaseUrl === 'string' && entry.apiBaseUrl.trim()
? entry.apiBaseUrl.trim()
: null;
const lastActiveAt = parseLastActiveAt(entry.lastActiveAt);
return {
projectName,
apiBaseUrl,
lastActiveAt: lastActiveAt || null,
};
}
export function normalizeProjectsList(list) {
if (!Array.isArray(list)) return [];
const byName = new Map();
for (const item of list) {
const normalized = normalizeProjectEntry(item);
if (!normalized) continue;
const existing = byName.get(normalized.projectName);
if (!existing) {
byName.set(normalized.projectName, normalized);
continue;
}
const existingTs = existing.lastActiveAt || 0;
const nextTs = normalized.lastActiveAt || 0;
byName.set(
normalized.projectName,
nextTs >= existingTs ? normalized : existing,
);
}
return Array.from(byName.values());
}
async function getReadyRedis(redisOverride) {
const redis = redisOverride || (await getRedisClient());
const redis = await getRedisClient();
if (!redis?.isReady) {
throw new Error('Redis 未就绪');
}
return redis;
}
export async function migrateHeartbeatData(options = {}) {
const { deleteOldKeys = false, dryRun = false, redis: redisOverride } =
options;
const redis = await getReadyRedis(redisOverride);
console.log('[migrate] 开始迁移心跳数据...');
@@ -96,37 +44,38 @@ export async function migrateHeartbeatData(options = {}) {
}
const projectName = key.replace('_项目心跳', '');
const project = normalizeProjectEntry({
projectName,
apiBaseUrl: heartbeat?.apiBaseUrl,
lastActiveAt: heartbeat?.lastActiveAt,
});
const apiBaseUrl = typeof heartbeat.apiBaseUrl === 'string' ? heartbeat.apiBaseUrl : null;
const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt);
if (!project?.apiBaseUrl) {
if (!apiBaseUrl) {
console.log(`[migrate] 跳过无效项目 (无apiBaseUrl): ${projectName}`);
continue;
}
const project = {
projectName,
apiBaseUrl,
lastActiveAt: lastActiveAt || null,
};
projectsList.push(project);
console.log(`[migrate] 添加项目: ${projectName}`);
}
const normalizedProjectsList = normalizeProjectsList(projectsList);
console.log(`[migrate] 共迁移 ${normalizedProjectsList.length} 个项目`);
console.log(`[migrate] 共迁移 ${projectsList.length} 个项目`);
if (dryRun) {
console.log('[migrate] 干运行模式,不写入数据');
return {
success: true,
migrated: normalizedProjectsList.length,
projects: normalizedProjectsList,
migrated: projectsList.length,
projects: projectsList,
dryRun: true,
};
}
const listKey = projectsListKey();
await redis.set(listKey, JSON.stringify(normalizedProjectsList));
await redis.set(listKey, JSON.stringify(projectsList));
console.log(`[migrate] 已写入项目列表到: ${listKey}`);
if (deleteOldKeys) {
@@ -141,8 +90,8 @@ export async function migrateHeartbeatData(options = {}) {
return {
success: true,
migrated: normalizedProjectsList.length,
projects: normalizedProjectsList,
migrated: projectsList.length,
projects: projectsList,
listKey,
deleteOldKeys,
};
@@ -152,8 +101,11 @@ export async function migrateHeartbeatData(options = {}) {
}
}
export async function getProjectsList(redisOverride) {
const redis = await getReadyRedis(redisOverride);
export async function getProjectsList() {
const redis = await getRedisClient();
if (!redis?.isReady) {
throw new Error('Redis 未就绪');
}
const listKey = projectsListKey();
const raw = await redis.get(listKey);
@@ -164,35 +116,28 @@ export async function getProjectsList(redisOverride) {
try {
const list = JSON.parse(raw);
return normalizeProjectsList(list);
return Array.isArray(list) ? list : [];
} catch (err) {
console.error('[getProjectsList] 解析项目列表失败:', err);
return [];
}
}
export async function updateProjectHeartbeat(
projectName,
heartbeatData,
redisOverride,
) {
const redis = await getReadyRedis(redisOverride);
const projectsList = await getProjectsList(redis);
const existingIndex = projectsList.findIndex(
(p) => p.projectName === projectName,
);
const project = normalizeProjectEntry({
projectName,
apiBaseUrl: heartbeatData?.apiBaseUrl,
lastActiveAt: heartbeatData?.lastActiveAt,
});
if (!project) {
throw new Error('无效的项目心跳数据');
export async function updateProjectHeartbeat(projectName, heartbeatData) {
const redis = await getRedisClient();
if (!redis?.isReady) {
throw new Error('Redis 未就绪');
}
const projectsList = await getProjectsList();
const existingIndex = projectsList.findIndex(p => p.projectName === projectName);
const project = {
projectName,
apiBaseUrl: heartbeatData.apiBaseUrl || null,
lastActiveAt: parseLastActiveAt(heartbeatData.lastActiveAt) || null,
};
if (existingIndex >= 0) {
projectsList[existingIndex] = project;
} else {
@@ -203,4 +148,4 @@ export async function updateProjectHeartbeat(
await redis.set(listKey, JSON.stringify(projectsList));
return project;
}
}