feat: 重构项目心跳数据结构并实现相关功能
- 重构Redis心跳数据结构,使用统一的项目列表键 - 新增数据迁移工具和API端点 - 更新前端以使用真实项目数据 - 添加系统部署配置和文档 - 修复代码格式和样式问题
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user