feat: 重构项目心跳数据结构并实现项目列表API

- 新增统一项目列表Redis键和迁移工具
- 实现GET /api/projects端点获取项目列表
- 实现POST /api/projects/migrate端点支持数据迁移
- 更新前端ProjectSelector组件使用真实项目数据
- 扩展projectStore状态管理
- 更新相关文档和OpenSpec规范
- 添加测试用例验证新功能
This commit is contained in:
2026-01-13 19:45:05 +08:00
parent 19e65d78dc
commit 282f7268ed
66 changed files with 4378 additions and 456 deletions

29
src/backend/app.js Normal file
View File

@@ -0,0 +1,29 @@
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';
export function createApp(options = {}) {
const { redis } = options;
const app = express();
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' });
});
if (redis) {
app.locals.redis = redis;
}
return app;
}

View File

@@ -1,14 +1,93 @@
import express from 'express';
import axios from 'axios';
const router = express.Router();
import { getRedisClient } from '../services/redisClient.js';
import { projectControlKey } from '../services/redisKeys.js';
import { projectHeartbeatKey } from '../services/redisKeys.js';
import { getProjectsList } from '../services/migrateHeartbeatData.js';
function isNonEmptyString(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function splitCommandLine(commandLine) {
const tokens = String(commandLine || '')
.trim()
.split(/\s+/)
.filter(Boolean);
if (tokens.length === 0) return { apiName: '', args: [], argsText: '' };
const [ apiName, ...args ] = tokens;
return {
apiName: apiName.trim(),
args,
argsText: args.join(' '),
};
}
function parseLastActiveAt(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const asNum = Number(value);
if (Number.isFinite(asNum)) return asNum;
const asDate = Date.parse(value);
if (Number.isFinite(asDate)) return asDate;
}
return null;
}
function buildTargetUrl(apiBaseUrl, apiName) {
const base = String(apiBaseUrl || '').trim();
const name = String(apiName || '').trim();
if (!base) return null;
if (!name) return null;
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
const normalizedName = name.startsWith('/') ? name.slice(1) : name;
try {
return new URL(normalizedName, normalizedBase).toString();
} catch {
return null;
}
}
function truncateForLog(value, maxLen = 2000) {
if (value == null) return 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);
if (project) {
return {
apiBaseUrl: project.apiBaseUrl,
lastActiveAt: project.lastActiveAt,
};
}
} catch (err) {
console.error(
'[getProjectHeartbeat] Failed to get from projects list:',
err,
);
}
const heartbeatRaw = await redis.get(projectHeartbeatKey(projectName));
if (heartbeatRaw) {
try {
return JSON.parse(heartbeatRaw);
} catch {
return null;
}
}
return null;
}
// 发送指令
router.post('/', async (req, res) => {
const { targetProjectName, command, args } = req.body;
@@ -28,12 +107,24 @@ router.post('/', async (req, res) => {
}
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const { apiName, args: parsedArgs, argsText } = splitCommandLine(command);
if (!isNonEmptyString(apiName)) {
return res.status(400).json({
success: false,
message: '指令格式错误:第一个 token 必须为 API 接口名',
});
}
const payload = {
id: commandId,
commandId,
timestamp: new Date().toISOString(),
source: 'BLS Project Console',
command: command.trim(),
args: typeof args === 'object' && args !== null ? args : undefined,
apiName,
args: parsedArgs,
argsText,
// backward compatible, if caller still sends structured args
extraArgs: typeof args === 'object' && args !== null ? args : undefined,
};
try {
@@ -44,21 +135,98 @@ router.post('/', async (req, res) => {
message: 'Redis 未就绪',
});
}
const key = projectControlKey(targetProjectName.trim());
await redis.rPush(key, JSON.stringify(payload));
return res.status(200).json({
success: true,
message: '指令已写入目标项目控制队列',
const heartbeatKey = projectHeartbeatKey(targetProjectName.trim());
const heartbeat = await getProjectHeartbeat(
redis,
targetProjectName.trim(),
);
if (!heartbeat) {
return res.status(503).json({
success: false,
message: `目标项目未上报心跳:${heartbeatKey}`,
});
}
const apiBaseUrl =
typeof heartbeat.apiBaseUrl === 'string'
? heartbeat.apiBaseUrl.trim()
: '';
const lastActiveAt = parseLastActiveAt(heartbeat?.lastActiveAt);
if (!apiBaseUrl) {
return res.status(503).json({
success: false,
message: '目标项目心跳缺少 apiBaseUrl',
});
}
const offlineThresholdMs = Number.parseInt(
process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000',
10,
);
const now = Date.now();
if (
!lastActiveAt ||
(Number.isFinite(offlineThresholdMs) &&
now - lastActiveAt > offlineThresholdMs)
) {
return res.status(503).json({
success: false,
message: '目标项目已离线(心跳超时)',
lastActiveAt: lastActiveAt || null,
});
}
const targetUrl = buildTargetUrl(apiBaseUrl, apiName);
if (!targetUrl) {
return res.status(400).json({
success: false,
message: '无法构造目标 API 地址(请检查 apiBaseUrl/apiName',
});
}
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,
});
if (resp.status >= 200 && resp.status < 300) {
return res.status(200).json({
success: true,
message: '已调用目标项目 API',
commandId,
targetUrl,
upstreamStatus: resp.status,
upstreamData: truncateForLog(resp.data),
});
}
return res.status(502).json({
success: false,
message: '目标项目 API 返回非成功状态',
commandId,
targetUrl,
upstreamStatus: resp.status,
upstreamData: truncateForLog(resp.data),
});
} catch (err) {
console.error('Failed to enqueue command', err);
return res.status(500).json({
const status = err?.response?.status;
const data = err?.response?.data;
const message = err?.message || '调用失败';
console.error('Failed to call target API', message);
return res.status(502).json({
success: false,
message: '写入Redis失败',
message,
upstreamStatus: typeof status === 'number' ? status : undefined,
upstreamData: truncateForLog(data),
});
}
});
export default router;
export default router;

View File

@@ -3,7 +3,11 @@ import express from 'express';
const router = express.Router();
import { getRedisClient } from '../services/redisClient.js';
import { projectConsoleKey, projectStatusKey } from '../services/redisKeys.js';
import {
projectConsoleKey,
projectHeartbeatKey,
} from '../services/redisKeys.js';
import { getProjectsList } from '../services/migrateHeartbeatData.js';
function parsePositiveInt(value, defaultValue) {
const num = Number.parseInt(String(value), 10);
@@ -11,9 +15,53 @@ function parsePositiveInt(value, defaultValue) {
return num;
}
function parseLastActiveAt(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const asNum = Number(value);
if (Number.isFinite(asNum)) return asNum;
const asDate = Date.parse(value);
if (Number.isFinite(asDate)) return asDate;
}
return null;
}
async function getProjectHeartbeat(redis, projectName) {
try {
const projectsList = await getProjectsList(redis);
const project = projectsList.find((p) => p.projectName === projectName);
if (project) {
return {
apiBaseUrl: project.apiBaseUrl,
lastActiveAt: project.lastActiveAt,
};
}
} catch (err) {
console.error(
'[getProjectHeartbeat] Failed to get from projects list:',
err,
);
}
const heartbeatRaw = await redis.get(projectHeartbeatKey(projectName));
if (heartbeatRaw) {
try {
return JSON.parse(heartbeatRaw);
} catch {
return null;
}
}
return null;
}
// 获取日志列表
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) {
@@ -36,35 +84,61 @@ 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 status = await redis.get(projectStatusKey(projectName));
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 computedStatus = heartbeat ? (isOnline ? '在线' : '离线') : null;
return res.status(200).json({
logs,
projectStatus: status || null,
projectStatus: computedStatus || 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);
@@ -76,4 +150,4 @@ router.get('/', async (req, res) => {
}
});
export default router;
export default router;

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import request from 'supertest';
import { createApp } from '../app.js';
import { createFakeRedis } from '../test/fakeRedis.js';
describe('projects API', () => {
it('GET /api/projects returns projects from unified list', async () => {
const now = Date.now();
const redis = createFakeRedis({
项目心跳: JSON.stringify([
{ projectName: 'Demo', apiBaseUrl: 'http://localhost:8080', lastActiveAt: now },
]),
});
const app = createApp({ redis });
const resp = await request(app).get('/api/projects');
expect(resp.status).toBe(200);
expect(resp.body.success).toBe(true);
expect(resp.body.count).toBe(1);
expect(resp.body.projects[0]).toMatchObject({
id: 'Demo',
name: 'Demo',
apiBaseUrl: 'http://localhost:8080',
});
expect([ 'online', 'offline', 'unknown' ]).toContain(
resp.body.projects[0].status,
);
});
it('POST /api/projects/migrate migrates old *_项目心跳 keys into 项目心跳 list', async () => {
const now = Date.now();
const redis = createFakeRedis({
'A_项目心跳': JSON.stringify({
apiBaseUrl: 'http://a',
lastActiveAt: now,
}),
});
const app = createApp({ redis });
const resp = await request(app)
.post('/api/projects/migrate')
.send({ dryRun: false, deleteOldKeys: true });
expect(resp.status).toBe(200);
expect(resp.body.success).toBe(true);
expect(resp.body.migrated).toBe(1);
const listRaw = await redis.get('项目心跳');
expect(typeof listRaw).toBe('string');
const list = JSON.parse(listRaw);
expect(Array.isArray(list)).toBe(true);
expect(list[0]).toMatchObject({
projectName: 'A',
apiBaseUrl: 'http://a',
});
const old = await redis.get('A_项目心跳');
expect(old).toBeNull();
});
});

View File

@@ -0,0 +1,114 @@
import express from 'express';
const router = express.Router();
import { getRedisClient } from '../services/redisClient.js';
import {
migrateHeartbeatData,
getProjectsList,
} from '../services/migrateHeartbeatData.js';
function parseLastActiveAt(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const asNum = Number(value);
if (Number.isFinite(asNum)) return asNum;
const asDate = Date.parse(value);
if (Number.isFinite(asDate)) return asDate;
}
return null;
}
function computeProjectStatus(project) {
const offlineThresholdMs = Number.parseInt(
process.env.HEARTBEAT_OFFLINE_THRESHOLD_MS || '10000',
10,
);
const now = Date.now();
const lastActiveAt = parseLastActiveAt(project?.lastActiveAt);
if (!lastActiveAt) {
return {
status: 'unknown',
isOnline: false,
ageMs: null,
};
}
const ageMs = now - lastActiveAt;
const isOnline = Number.isFinite(offlineThresholdMs)
? ageMs <= offlineThresholdMs
: true;
return {
status: isOnline ? 'online' : 'offline',
isOnline,
ageMs,
};
}
router.get('/', async (req, res) => {
try {
const redis = req.app?.locals?.redis || (await getRedisClient());
if (!redis?.isReady) {
return res.status(503).json({
success: false,
message: 'Redis 未就绪',
projects: [],
});
}
const projectsList = await getProjectsList(redis);
const projects = projectsList.map((project) => {
const statusInfo = computeProjectStatus(project);
return {
id: project.projectName,
name: project.projectName,
apiBaseUrl: project.apiBaseUrl,
lastActiveAt: project.lastActiveAt,
...statusInfo,
};
});
return res.status(200).json({
success: true,
projects,
count: projects.length,
});
} catch (err) {
console.error('Failed to get projects list', err);
return res.status(500).json({
success: false,
message: '获取项目列表失败',
projects: [],
});
}
});
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({
success: true,
message: '数据迁移完成',
...result,
});
} catch (err) {
console.error('Failed to migrate heartbeat data', err);
return res.status(500).json({
success: false,
message: '数据迁移失败',
error: err.message,
});
}
});
export default router;

View File

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

View File

@@ -0,0 +1,206 @@
import { getRedisClient } from './redisClient.js';
import { projectsListKey } from './redisKeys.js';
export function parseLastActiveAt(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const asNum = Number(value);
if (Number.isFinite(asNum)) return asNum;
const asDate = Date.parse(value);
if (Number.isFinite(asDate)) return asDate;
}
return null;
}
export function normalizeProjectEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
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());
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] 开始迁移心跳数据...');
try {
const keys = await redis.keys('*_项目心跳');
console.log(`[migrate] 找到 ${keys.length} 个心跳键`);
const projectsList = [];
for (const key of keys) {
const heartbeatRaw = await redis.get(key);
if (!heartbeatRaw) {
console.log(`[migrate] 跳过空键: ${key}`);
continue;
}
let heartbeat;
try {
heartbeat = JSON.parse(heartbeatRaw);
} catch (err) {
console.error(`[migrate] 解析失败: ${key}`, err.message);
continue;
}
const projectName = key.replace('_项目心跳', '');
const project = normalizeProjectEntry({
projectName,
apiBaseUrl: heartbeat?.apiBaseUrl,
lastActiveAt: heartbeat?.lastActiveAt,
});
if (!project?.apiBaseUrl) {
console.log(`[migrate] 跳过无效项目 (无apiBaseUrl): ${projectName}`);
continue;
}
projectsList.push(project);
console.log(`[migrate] 添加项目: ${projectName}`);
}
const normalizedProjectsList = normalizeProjectsList(projectsList);
console.log(`[migrate] 共迁移 ${normalizedProjectsList.length} 个项目`);
if (dryRun) {
console.log('[migrate] 干运行模式,不写入数据');
return {
success: true,
migrated: normalizedProjectsList.length,
projects: normalizedProjectsList,
dryRun: true,
};
}
const listKey = projectsListKey();
await redis.set(listKey, JSON.stringify(normalizedProjectsList));
console.log(`[migrate] 已写入项目列表到: ${listKey}`);
if (deleteOldKeys) {
console.log('[migrate] 删除旧键...');
for (const key of keys) {
await redis.del(key);
console.log(`[migrate] 已删除: ${key}`);
}
} else {
console.log('[migrate] 保留旧键 (deleteOldKeys=false)');
}
return {
success: true,
migrated: normalizedProjectsList.length,
projects: normalizedProjectsList,
listKey,
deleteOldKeys,
};
} catch (err) {
console.error('[migrate] 迁移失败:', err);
throw err;
}
}
export async function getProjectsList(redisOverride) {
const redis = await getReadyRedis(redisOverride);
const listKey = projectsListKey();
const raw = await redis.get(listKey);
if (!raw) {
return [];
}
try {
const list = JSON.parse(raw);
return normalizeProjectsList(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('无效的项目心跳数据');
}
if (existingIndex >= 0) {
projectsList[existingIndex] = project;
} else {
projectsList.push(project);
}
const listKey = projectsListKey();
await redis.set(listKey, JSON.stringify(projectsList));
return project;
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import {
parseLastActiveAt,
normalizeProjectEntry,
normalizeProjectsList,
} from './migrateHeartbeatData.js';
describe('migrateHeartbeatData helpers', () => {
it('parseLastActiveAt handles number and numeric string', () => {
expect(parseLastActiveAt(123)).toBe(123);
expect(parseLastActiveAt('456')).toBe(456);
});
it('parseLastActiveAt handles ISO date string', () => {
const ts = parseLastActiveAt('2026-01-13T00:00:00.000Z');
expect(typeof ts).toBe('number');
expect(Number.isFinite(ts)).toBe(true);
});
it('normalizeProjectEntry enforces required fields and trims', () => {
expect(
normalizeProjectEntry({
projectName: ' A ',
apiBaseUrl: ' http://localhost:8080 ',
lastActiveAt: '1000',
}),
).toEqual({
projectName: 'A',
apiBaseUrl: 'http://localhost:8080',
lastActiveAt: 1000,
});
expect(normalizeProjectEntry({ projectName: ' ' })).toBeNull();
});
it('normalizeProjectsList de-duplicates by projectName and keeps latest heartbeat', () => {
const list = normalizeProjectsList([
{ projectName: 'A', apiBaseUrl: 'http://a', lastActiveAt: 1000 },
{ projectName: 'A', apiBaseUrl: 'http://a2', lastActiveAt: 2000 },
{ projectName: 'B', apiBaseUrl: 'http://b', lastActiveAt: null },
]);
expect(list.length).toBe(2);
const a = list.find((p) => p.projectName === 'A');
expect(a).toEqual({
projectName: 'A',
apiBaseUrl: 'http://a2',
lastActiveAt: 2000,
});
});
});

View File

@@ -14,7 +14,7 @@ function parseIntOrDefault(value, defaultValue) {
export async function getRedisClient() {
if (client) return client;
const host = process.env.REDIS_HOST || 'localhost';
const host = process.env.REDIS_HOST || '10.8.8.109';
const port = parseIntOrDefault(process.env.REDIS_PORT, 6379);
const password = process.env.REDIS_PASSWORD || undefined;
const db = parseIntOrDefault(process.env.REDIS_DB, 0);
@@ -26,7 +26,10 @@ export async function getRedisClient() {
password,
database: db,
socket: {
connectTimeout: parseIntOrDefault(process.env.REDIS_CONNECT_TIMEOUT_MS, 2000),
connectTimeout: parseIntOrDefault(
process.env.REDIS_CONNECT_TIMEOUT_MS,
2000,
),
reconnectStrategy: (retries) => {
// exponential-ish backoff with cap
return Math.min(1000 * Math.max(1, retries), 30_000);
@@ -38,8 +41,14 @@ export async function getRedisClient() {
const now = Date.now();
if (now - lastErrorLogAt >= ERROR_LOG_THROTTLE_MS) {
lastErrorLogAt = now;
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : 'UNKNOWN';
const message = err && typeof err === 'object' && 'message' in err ? String(err.message) : String(err);
const code =
err && typeof err === 'object' && 'code' in err
? String(err.code)
: 'UNKNOWN';
const message =
err && typeof err === 'object' && 'message' in err
? String(err.message)
: String(err);
const summary = `${code}: ${message.split('\n')[0]}`;
console.error('[redis] error', summary);
}
@@ -78,7 +87,9 @@ export async function ensureRedisReady(options = {}) {
try {
await Promise.race([
connectPromise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Redis connect timeout')), timeoutMs)),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Redis connect timeout')), timeoutMs),
),
]);
} catch {
// ignore

View File

@@ -1,7 +1,3 @@
export function projectStatusKey(projectName) {
return `${projectName}_项目状态`;
}
export function projectConsoleKey(projectName) {
return `${projectName}_项目控制台`;
}
@@ -9,3 +5,11 @@ export function projectConsoleKey(projectName) {
export function projectControlKey(projectName) {
return `${projectName}_控制`;
}
export function projectHeartbeatKey(projectName) {
return `${projectName}_项目心跳`;
}
export function projectsListKey() {
return '项目心跳';
}

View File

@@ -0,0 +1,53 @@
function escapeRegex(input) {
return String(input).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function globToRegex(glob) {
const parts = String(glob).split('*').map(escapeRegex);
const regex = `^${parts.join('.*')}$`;
return new RegExp(regex);
}
export function createFakeRedis(initial = {}) {
const kv = new Map(Object.entries(initial));
return {
isReady: true,
async get(key) {
return kv.has(key) ? kv.get(key) : null;
},
async set(key, value) {
kv.set(key, String(value));
return 'OK';
},
async del(key) {
const existed = kv.delete(key);
return existed ? 1 : 0;
},
async keys(pattern) {
const re = globToRegex(pattern);
return Array.from(kv.keys()).filter((k) => re.test(k));
},
// optional: used by logs route
async lRange(key, start, stop) {
const raw = kv.get(key);
const list = Array.isArray(raw) ? raw : [];
const normalizeIndex = (idx) => (idx < 0 ? list.length + idx : idx);
const s = normalizeIndex(start);
const e = normalizeIndex(stop);
return list.slice(Math.max(0, s), Math.min(list.length, e + 1));
},
// helper for tests
_dump() {
return Object.fromEntries(kv.entries());
},
};
}