feat: 实现Redis集成协议并重构项目控制台
refactor(backend): 重构后端服务以支持Redis协议 feat(backend): 添加Redis客户端和服务模块 feat(backend): 实现命令和日志路由处理Redis交互 refactor(frontend): 重构前端状态管理和组件结构 feat(frontend): 实现基于Redis的日志和命令功能 docs: 添加Redis集成协议文档 chore: 更新ESLint配置和依赖
This commit is contained in:
@@ -1,22 +1,64 @@
|
||||
import express from 'express'
|
||||
const router = express.Router()
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
import { getRedisClient } from '../services/redisClient.js';
|
||||
import { projectControlKey } from '../services/redisKeys.js';
|
||||
|
||||
function isNonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
// 发送指令
|
||||
router.post('/', (req, res) => {
|
||||
const { command } = req.body
|
||||
|
||||
if (!command) {
|
||||
router.post('/', async (req, res) => {
|
||||
const { targetProjectName, command, args } = req.body;
|
||||
|
||||
if (!isNonEmptyString(targetProjectName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '指令内容不能为空'
|
||||
})
|
||||
message: 'targetProjectName 不能为空',
|
||||
});
|
||||
}
|
||||
|
||||
// 这里将实现发送指令到Redis队列的逻辑
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: '指令已发送到Redis队列'
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
if (!isNonEmptyString(command)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '指令内容不能为空',
|
||||
});
|
||||
}
|
||||
|
||||
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const payload = {
|
||||
id: commandId,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'BLS Project Console',
|
||||
command: command.trim(),
|
||||
args: typeof args === 'object' && args !== null ? args : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const redis = req.app?.locals?.redis || (await getRedisClient());
|
||||
if (!redis?.isReady) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
message: 'Redis 未就绪',
|
||||
});
|
||||
}
|
||||
const key = projectControlKey(targetProjectName.trim());
|
||||
await redis.rPush(key, JSON.stringify(payload));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: '指令已写入目标项目控制队列',
|
||||
commandId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to enqueue command', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '写入Redis失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,12 +1,79 @@
|
||||
import express from 'express'
|
||||
const router = express.Router()
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
import { getRedisClient } from '../services/redisClient.js';
|
||||
import { projectConsoleKey, projectStatusKey } from '../services/redisKeys.js';
|
||||
|
||||
function parsePositiveInt(value, defaultValue) {
|
||||
const num = Number.parseInt(String(value), 10);
|
||||
if (!Number.isFinite(num) || num <= 0) return defaultValue;
|
||||
return num;
|
||||
}
|
||||
|
||||
// 获取日志列表
|
||||
router.get('/', (req, res) => {
|
||||
// 这里将实现从Redis读取日志的逻辑
|
||||
res.status(200).json({
|
||||
logs: []
|
||||
})
|
||||
})
|
||||
router.get('/', async (req, res) => {
|
||||
const projectName = typeof req.query.projectName === 'string' ? req.query.projectName.trim() : '';
|
||||
const limit = parsePositiveInt(req.query.limit, 200);
|
||||
|
||||
export default router
|
||||
if (!projectName) {
|
||||
return res.status(200).json({
|
||||
logs: [],
|
||||
projectStatus: null,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const redis = req.app?.locals?.redis || (await getRedisClient());
|
||||
if (!redis?.isReady) {
|
||||
return res.status(503).json({
|
||||
logs: [],
|
||||
projectStatus: null,
|
||||
message: 'Redis 未就绪',
|
||||
});
|
||||
}
|
||||
|
||||
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 status = await redis.get(projectStatusKey(projectName));
|
||||
|
||||
return res.status(200).json({
|
||||
logs,
|
||||
projectStatus: status || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to read logs', err);
|
||||
return res.status(500).json({
|
||||
logs: [],
|
||||
projectStatus: null,
|
||||
message: '读取Redis失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,25 +1,46 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import logRoutes from './routes/logs.js'
|
||||
import commandRoutes from './routes/commands.js'
|
||||
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';
|
||||
|
||||
const app = express()
|
||||
const PORT = 3001
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
|
||||
// 中间件
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// 路由
|
||||
app.use('/api/logs', logRoutes)
|
||||
app.use('/api/commands', commandRoutes)
|
||||
app.use('/api/logs', logRoutes);
|
||||
app.use('/api/commands', commandRoutes);
|
||||
|
||||
// 健康检查
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.status(200).json({ status: 'ok' })
|
||||
})
|
||||
res.status(200).json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`)
|
||||
})
|
||||
const server = app.listen(PORT, async () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
|
||||
try {
|
||||
const redis = await getRedisClient();
|
||||
app.locals.redis = redis;
|
||||
console.log('[redis] client attached to app.locals');
|
||||
} catch (err) {
|
||||
console.error('[redis] failed to connect on startup', err);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
try {
|
||||
if (app.locals.redis) {
|
||||
await app.locals.redis.quit();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
server.close(() => process.exit(0));
|
||||
}
|
||||
});
|
||||
88
src/backend/services/redisClient.js
Normal file
88
src/backend/services/redisClient.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { createClient } from 'redis';
|
||||
|
||||
let client = null;
|
||||
let connectPromise = null;
|
||||
|
||||
let lastErrorLogAt = 0;
|
||||
const ERROR_LOG_THROTTLE_MS = 60_000;
|
||||
|
||||
function parseIntOrDefault(value, defaultValue) {
|
||||
const num = Number.parseInt(value, 10);
|
||||
return Number.isFinite(num) ? num : defaultValue;
|
||||
}
|
||||
|
||||
export async function getRedisClient() {
|
||||
if (client) return client;
|
||||
|
||||
const host = process.env.REDIS_HOST || 'localhost';
|
||||
const port = parseIntOrDefault(process.env.REDIS_PORT, 6379);
|
||||
const password = process.env.REDIS_PASSWORD || undefined;
|
||||
const db = parseIntOrDefault(process.env.REDIS_DB, 0);
|
||||
|
||||
const url = `redis://${host}:${port}`;
|
||||
|
||||
client = createClient({
|
||||
url,
|
||||
password,
|
||||
database: db,
|
||||
socket: {
|
||||
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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
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 summary = `${code}: ${message.split('\n')[0]}`;
|
||||
console.error('[redis] error', summary);
|
||||
}
|
||||
});
|
||||
client.on('connect', () => {
|
||||
console.log('[redis] connect');
|
||||
});
|
||||
client.on('ready', () => {
|
||||
console.log('[redis] ready');
|
||||
});
|
||||
client.on('end', () => {
|
||||
console.log('[redis] end');
|
||||
});
|
||||
|
||||
connectPromise = client.connect().catch(() => {
|
||||
// allow retries via next call
|
||||
connectPromise = null;
|
||||
return null;
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function ensureRedisReady(options = {}) {
|
||||
const { timeoutMs = 2000 } = options;
|
||||
const redis = await getRedisClient();
|
||||
if (redis.isReady) return true;
|
||||
|
||||
if (!connectPromise) {
|
||||
connectPromise = redis.connect().catch(() => {
|
||||
connectPromise = null;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
connectPromise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Redis connect timeout')), timeoutMs)),
|
||||
]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return redis.isReady;
|
||||
}
|
||||
11
src/backend/services/redisKeys.js
Normal file
11
src/backend/services/redisKeys.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export function projectStatusKey(projectName) {
|
||||
return `${projectName}_项目状态`;
|
||||
}
|
||||
|
||||
export function projectConsoleKey(projectName) {
|
||||
return `${projectName}_项目控制台`;
|
||||
}
|
||||
|
||||
export function projectControlKey(projectName) {
|
||||
return `${projectName}_控制`;
|
||||
}
|
||||
Reference in New Issue
Block a user