Compare commits
6 Commits
a067d167e9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 45047f40aa | |||
| cd50716dc6 | |||
| 14aa3436cf | |||
| a2f31ec5a0 | |||
| 82da2a1a04 | |||
| db2b2af0d3 |
@@ -80,6 +80,7 @@ this.routeScanner.registerControllers([
|
||||
## TC39 装饰器特性
|
||||
|
||||
### 1. 标准语法
|
||||
|
||||
```typescript
|
||||
// TC39 标准装饰器使用 addInitializer
|
||||
@Get('/users')
|
||||
@@ -89,6 +90,7 @@ async getUsers(ctx: Context) {
|
||||
```
|
||||
|
||||
### 2. 类型安全
|
||||
|
||||
```typescript
|
||||
// 完整的 TypeScript 类型检查
|
||||
@Controller('/api')
|
||||
@@ -101,6 +103,7 @@ export class ApiController {
|
||||
```
|
||||
|
||||
### 3. 无外部依赖
|
||||
|
||||
```typescript
|
||||
// 不再需要 reflect-metadata
|
||||
// 使用内置的 WeakMap 存储元数据
|
||||
@@ -136,6 +139,7 @@ export class ApiController {
|
||||
最终的API路径 = 全局前缀 + 控制器前缀 + 方法路径
|
||||
|
||||
例如:
|
||||
|
||||
- 全局前缀:`/api`
|
||||
- 控制器前缀:`/user`
|
||||
- 方法路径:`/list`
|
||||
@@ -176,56 +180,11 @@ async getUser(ctx: Context) {
|
||||
|
||||
## 现有路由
|
||||
|
||||
项目中已注册的路由:
|
||||
|
||||
### ApplicationController
|
||||
- `GET /api/application/list` - 获取应用列表
|
||||
- `GET /api/application/detail/:id` - 获取应用详情
|
||||
|
||||
### UserController
|
||||
|
||||
- `GET /api/user/list` - 获取用户列表
|
||||
- `GET /api/user/detail/:id` - 获取用户详情
|
||||
- `POST /api/user` - 创建用户
|
||||
- `PUT /api/user/:id` - 更新用户
|
||||
- `DELETE /api/user/:id` - 删除用户
|
||||
- `GET /api/user/search` - 搜索用户
|
||||
|
||||
## 与旧版本装饰器的区别
|
||||
|
||||
| 特性 | 实验性装饰器 | TC39 标准装饰器 |
|
||||
|------|-------------|----------------|
|
||||
| 标准化 | ❌ TypeScript 特有 | ✅ ECMAScript 标准 |
|
||||
| 依赖 | ❌ 需要 reflect-metadata | ✅ 零依赖 |
|
||||
| 性能 | ❌ 运行时反射 | ✅ 编译时优化 |
|
||||
| 类型安全 | ⚠️ 部分支持 | ✅ 完整支持 |
|
||||
| 未来兼容 | ❌ 可能被废弃 | ✅ 持续演进 |
|
||||
|
||||
## 迁移指南
|
||||
|
||||
从实验性装饰器迁移到 TC39 标准装饰器:
|
||||
|
||||
1. **更新 tsconfig.json**
|
||||
```json
|
||||
{
|
||||
"experimentalDecorators": false,
|
||||
"emitDecoratorMetadata": false
|
||||
}
|
||||
```
|
||||
|
||||
2. **移除依赖**
|
||||
```bash
|
||||
pnpm remove reflect-metadata
|
||||
```
|
||||
|
||||
3. **代码无需修改**
|
||||
- 装饰器语法保持不变
|
||||
- 控制器代码无需修改
|
||||
- 自动兼容新标准
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 需要 TypeScript 5.0+ 支持
|
||||
2. 需要 Node.js 16+ 运行环境
|
||||
3. 控制器类需要导出并在路由中间件中注册
|
||||
4. 控制器方法应该返回数据而不是直接操作 `ctx.body`
|
||||
5. TC39 装饰器使用 `addInitializer` 进行初始化,性能更优
|
||||
|
||||
@@ -27,6 +27,6 @@ async function initializeApp() {
|
||||
|
||||
// 启动应用
|
||||
initializeApp().catch((error) => {
|
||||
console.error('Failed to start application:', error);
|
||||
log.error('APP', 'Failed to start application:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const listDeploymentsQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional().default(10),
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
projectId: z.coerce.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -20,22 +20,28 @@ export class DeploymentController {
|
||||
where.projectId = projectId;
|
||||
}
|
||||
|
||||
const isPagination = page !== undefined && pageSize !== undefined;
|
||||
|
||||
const result = await prisma.deployment.findMany({
|
||||
where,
|
||||
take: pageSize,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: isPagination ? pageSize : undefined,
|
||||
skip: isPagination ? (page! - 1) * pageSize! : 0,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
const total = await prisma.deployment.count({ where });
|
||||
|
||||
return {
|
||||
data: result,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
};
|
||||
if (isPagination) {
|
||||
return {
|
||||
list: result,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('')
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Context } from 'koa';
|
||||
import { Controller, Get } from '../../decorators/route.ts';
|
||||
import { gitea } from '../../libs/gitea.ts';
|
||||
import { log } from '../../libs/logger.ts';
|
||||
import { prisma } from '../../libs/prisma.ts';
|
||||
import { BusinessError } from '../../middlewares/exception.ts';
|
||||
import { getBranchesQuerySchema, getCommitsQuerySchema } from './dto.ts';
|
||||
|
||||
const TAG = 'Git';
|
||||
|
||||
@Controller('/git')
|
||||
export class GitController {
|
||||
@Get('/commits')
|
||||
@@ -30,7 +33,7 @@ export class GitController {
|
||||
|
||||
// Get access token from session
|
||||
const accessToken = ctx.session?.gitea?.access_token;
|
||||
console.log('Access token present:', !!accessToken);
|
||||
log.debug(TAG, 'Access token present: %s', !!accessToken);
|
||||
|
||||
if (!accessToken) {
|
||||
throw new BusinessError(
|
||||
@@ -44,7 +47,7 @@ export class GitController {
|
||||
const commits = await gitea.getCommits(owner, repo, accessToken, branch);
|
||||
return commits;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch commits:', error);
|
||||
log.error(TAG, 'Failed to fetch commits:', error);
|
||||
throw new BusinessError('Failed to fetch commits from Gitea', 1005, 500);
|
||||
}
|
||||
}
|
||||
@@ -80,7 +83,7 @@ export class GitController {
|
||||
const branches = await gitea.getBranches(owner, repo, accessToken);
|
||||
return branches;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch branches:', error);
|
||||
log.error(TAG, 'Failed to fetch branches:', error);
|
||||
throw new BusinessError('Failed to fetch branches from Gitea', 1006, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export class PipelineController {
|
||||
const templates = await getAvailableTemplates();
|
||||
return templates;
|
||||
} catch (error) {
|
||||
console.error('Failed to get templates:', error);
|
||||
log.error('pipeline', 'Failed to get templates:', error);
|
||||
throw new BusinessError('获取模板失败', 3002, 500);
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,7 @@ export class PipelineController {
|
||||
log.info('pipeline', 'Created pipeline from template: %s', pipeline.name);
|
||||
return pipeline;
|
||||
} catch (error) {
|
||||
console.error('Failed to create pipeline from template:', error);
|
||||
log.error('pipeline', 'Failed to create pipeline from template:', error);
|
||||
if (error instanceof BusinessError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -66,19 +66,8 @@ export const updateProjectSchema = z.object({
|
||||
*/
|
||||
export const listProjectQuerySchema = z
|
||||
.object({
|
||||
page: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1, { message: '页码必须大于0' })
|
||||
.optional()
|
||||
.default(1),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1, { message: '每页数量必须大于0' })
|
||||
.max(100, { message: '每页数量不能超过100' })
|
||||
.optional()
|
||||
.default(10),
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
@@ -29,27 +29,30 @@ export class ProjectController {
|
||||
};
|
||||
}
|
||||
|
||||
const isPagination = query?.page !== undefined && query?.pageSize !== undefined;
|
||||
|
||||
const [total, projects] = await Promise.all([
|
||||
prisma.project.count({ where: whereCondition }),
|
||||
prisma.project.findMany({
|
||||
where: whereCondition,
|
||||
skip: query ? (query.page - 1) * query.limit : 0,
|
||||
take: query?.limit,
|
||||
skip: isPagination ? (query.page! - 1) * query.pageSize! : 0,
|
||||
take: isPagination ? query.pageSize : undefined,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: projects,
|
||||
pagination: {
|
||||
page: query?.page || 1,
|
||||
limit: query?.limit || 10,
|
||||
if (isPagination) {
|
||||
return {
|
||||
list: projects,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / (query?.limit || 10)),
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
return projects;
|
||||
}
|
||||
|
||||
// GET /api/projects/:id - 获取单个项目
|
||||
@@ -68,27 +71,21 @@ export class ProjectController {
|
||||
throw new BusinessError('项目不存在', 1002, 404);
|
||||
}
|
||||
|
||||
// 获取工作目录状态信息
|
||||
// 获取工作目录状态信息(不包含目录大小)
|
||||
let workspaceStatus = null;
|
||||
if (project.projectDir) {
|
||||
try {
|
||||
const status = await GitManager.checkWorkspaceStatus(
|
||||
project.projectDir,
|
||||
);
|
||||
let size = 0;
|
||||
let gitInfo = null;
|
||||
|
||||
if (status.exists && !status.isEmpty) {
|
||||
size = await GitManager.getDirectorySize(project.projectDir);
|
||||
}
|
||||
|
||||
if (status.hasGit) {
|
||||
gitInfo = await GitManager.getGitInfo(project.projectDir);
|
||||
}
|
||||
|
||||
workspaceStatus = {
|
||||
...status,
|
||||
size,
|
||||
gitInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -84,15 +84,13 @@ export const listStepsQuerySchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, { message: '页码必须大于0' })
|
||||
.optional()
|
||||
.default(1),
|
||||
limit: z.coerce
|
||||
.optional(),
|
||||
pageSize: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1, { message: '每页数量必须大于0' })
|
||||
.max(100, { message: '每页数量不能超过100' })
|
||||
.optional()
|
||||
.default(10),
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
|
||||
@@ -26,27 +26,30 @@ export class StepController {
|
||||
whereCondition.pipelineId = query.pipelineId;
|
||||
}
|
||||
|
||||
const isPagination = query?.page !== undefined && query?.pageSize !== undefined;
|
||||
|
||||
const [total, steps] = await Promise.all([
|
||||
prisma.step.count({ where: whereCondition }),
|
||||
prisma.step.findMany({
|
||||
where: whereCondition,
|
||||
skip: query ? (query.page - 1) * query.limit : 0,
|
||||
take: query?.limit,
|
||||
skip: isPagination ? (query.page! - 1) * query.pageSize! : 0,
|
||||
take: isPagination ? query.pageSize : undefined,
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: steps,
|
||||
pagination: {
|
||||
page: query?.page || 1,
|
||||
limit: query?.limit || 10,
|
||||
if (isPagination) {
|
||||
return {
|
||||
list: steps,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / (query?.limit || 10)),
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
// GET /api/steps/:id - 获取单个步骤
|
||||
|
||||
@@ -118,11 +118,6 @@ export class UserController {
|
||||
results = results.filter((user) => user.status === status);
|
||||
}
|
||||
|
||||
return {
|
||||
keyword,
|
||||
status,
|
||||
total: results.length,
|
||||
results,
|
||||
};
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { PipelineRunner } from '../runners/index.ts';
|
||||
import { prisma } from './prisma.ts';
|
||||
import { log } from '../libs/logger.ts';
|
||||
|
||||
const TAG = 'Queue';
|
||||
// 存储正在运行的部署任务
|
||||
const runningDeployments = new Set<number>();
|
||||
|
||||
@@ -40,14 +42,14 @@ export class ExecutionQueue {
|
||||
* 初始化执行队列,包括恢复未完成的任务
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
console.log('Initializing execution queue...');
|
||||
log.info(TAG, 'Initializing execution queue...');
|
||||
// 恢复未完成的任务
|
||||
await this.recoverPendingDeployments();
|
||||
|
||||
// 启动定时轮询
|
||||
this.startPolling();
|
||||
|
||||
console.log('Execution queue initialized');
|
||||
log.info(TAG, 'Execution queue initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,7 +57,7 @@ export class ExecutionQueue {
|
||||
*/
|
||||
private async recoverPendingDeployments(): Promise<void> {
|
||||
try {
|
||||
console.log('Recovering pending deployments from database...');
|
||||
log.info(TAG, 'Recovering pending deployments from database...');
|
||||
|
||||
// 查询数据库中状态为pending的部署任务
|
||||
const pendingDeployments = await prisma.deployment.findMany({
|
||||
@@ -69,16 +71,16 @@ export class ExecutionQueue {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Found ${pendingDeployments.length} pending deployments`);
|
||||
log.info(TAG, `Found ${pendingDeployments.length} pending deployments`);
|
||||
|
||||
// 将这些任务添加到执行队列中
|
||||
for (const deployment of pendingDeployments) {
|
||||
await this.addTask(deployment.id, deployment.pipelineId);
|
||||
}
|
||||
|
||||
console.log('Pending deployments recovery completed');
|
||||
log.info(TAG, 'Pending deployments recovery completed');
|
||||
} catch (error) {
|
||||
console.error('Failed to recover pending deployments:', error);
|
||||
log.error(TAG, 'Failed to recover pending deployments:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,12 +89,12 @@ export class ExecutionQueue {
|
||||
*/
|
||||
private startPolling(): void {
|
||||
if (this.isPolling) {
|
||||
console.log('Polling is already running');
|
||||
log.info(TAG, 'Polling is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isPolling = true;
|
||||
console.log(`Starting polling with interval ${POLLING_INTERVAL}ms`);
|
||||
log.info(TAG, `Starting polling with interval ${POLLING_INTERVAL}ms`);
|
||||
|
||||
// 立即执行一次检查
|
||||
this.checkPendingDeployments();
|
||||
@@ -111,7 +113,7 @@ export class ExecutionQueue {
|
||||
clearInterval(pollingTimer);
|
||||
pollingTimer = null;
|
||||
this.isPolling = false;
|
||||
console.log('Polling stopped');
|
||||
log.info(TAG, 'Polling stopped');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +122,7 @@ export class ExecutionQueue {
|
||||
*/
|
||||
private async checkPendingDeployments(): Promise<void> {
|
||||
try {
|
||||
console.log('Checking for pending deployments in database...');
|
||||
log.info(TAG, 'Checking for pending deployments in database...');
|
||||
|
||||
// 查询数据库中状态为pending的部署任务
|
||||
const pendingDeployments = await prisma.deployment.findMany({
|
||||
@@ -134,7 +136,8 @@ export class ExecutionQueue {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
log.info(
|
||||
TAG,
|
||||
`Found ${pendingDeployments.length} pending deployments in polling`,
|
||||
);
|
||||
|
||||
@@ -142,14 +145,15 @@ export class ExecutionQueue {
|
||||
for (const deployment of pendingDeployments) {
|
||||
// 检查是否已经在运行队列中
|
||||
if (!runningDeployments.has(deployment.id)) {
|
||||
console.log(
|
||||
log.info(
|
||||
TAG,
|
||||
`Adding deployment ${deployment.id} to queue from polling`,
|
||||
);
|
||||
await this.addTask(deployment.id, deployment.pipelineId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check pending deployments:', error);
|
||||
log.error(TAG, 'Failed to check pending deployments:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +168,7 @@ export class ExecutionQueue {
|
||||
): Promise<void> {
|
||||
// 检查是否已经在运行队列中
|
||||
if (runningDeployments.has(deploymentId)) {
|
||||
console.log(`Deployment ${deploymentId} is already queued or running`);
|
||||
log.info(TAG, `Deployment ${deploymentId} is already queued or running`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -194,7 +198,7 @@ export class ExecutionQueue {
|
||||
// 执行流水线
|
||||
await this.executePipeline(task.deploymentId, task.pipelineId);
|
||||
} catch (error) {
|
||||
console.error('执行流水线失败:', error);
|
||||
log.error(TAG, '执行流水线失败:', error);
|
||||
// 这里可以添加更多的错误处理逻辑
|
||||
} finally {
|
||||
// 从运行队列中移除
|
||||
@@ -245,7 +249,7 @@ export class ExecutionQueue {
|
||||
);
|
||||
await runner.run(pipelineId);
|
||||
} catch (error) {
|
||||
console.error('执行流水线失败:', error);
|
||||
log.error(TAG, '执行流水线失败:', error);
|
||||
// 错误处理可以在这里添加,比如更新部署状态为失败
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { log } from './logger.ts';
|
||||
|
||||
const TAG = 'Gitea';
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
@@ -43,7 +47,7 @@ class Gitea {
|
||||
|
||||
async getToken(code: string) {
|
||||
const { giteaUrl, clientId, clientSecret, redirectUri } = this.config;
|
||||
console.log('this.config', this.config);
|
||||
log.debug(TAG, 'Gitea token request started');
|
||||
const response = await fetch(`${giteaUrl}/login/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
@@ -56,7 +60,13 @@ class Gitea {
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.log(await response.json());
|
||||
const payload = await response.json().catch(() => null as unknown);
|
||||
log.error(
|
||||
TAG,
|
||||
'Gitea token request failed: status=%d payload=%o',
|
||||
response.status,
|
||||
payload,
|
||||
);
|
||||
throw new Error(`Fetch failed: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as TokenResponse;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { prisma } from './prisma.ts';
|
||||
import { log } from './logger.ts';
|
||||
|
||||
const TAG = 'PipelineTemplate';
|
||||
|
||||
// 默认流水线模板
|
||||
export interface PipelineTemplate {
|
||||
@@ -52,7 +55,7 @@ export const DEFAULT_PIPELINE_TEMPLATES: PipelineTemplate[] = [
|
||||
* 初始化系统默认流水线模板
|
||||
*/
|
||||
export async function initializePipelineTemplates(): Promise<void> {
|
||||
console.log('Initializing pipeline templates...');
|
||||
log.info(TAG, 'Initializing pipeline templates...');
|
||||
|
||||
try {
|
||||
// 检查是否已经存在模板流水线
|
||||
@@ -67,7 +70,7 @@ export async function initializePipelineTemplates(): Promise<void> {
|
||||
|
||||
// 如果没有现有的模板,则创建默认模板
|
||||
if (existingTemplates.length === 0) {
|
||||
console.log('Creating default pipeline templates...');
|
||||
log.info(TAG, 'Creating default pipeline templates...');
|
||||
|
||||
for (const template of DEFAULT_PIPELINE_TEMPLATES) {
|
||||
// 创建模板流水线(使用负数ID表示模板)
|
||||
@@ -97,15 +100,18 @@ export async function initializePipelineTemplates(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Created template: ${template.name}`);
|
||||
log.info(TAG, `Created template: ${template.name}`);
|
||||
}
|
||||
} else {
|
||||
console.log('Pipeline templates already exist, skipping initialization');
|
||||
log.info(
|
||||
TAG,
|
||||
'Pipeline templates already exist, skipping initialization',
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Pipeline templates initialization completed');
|
||||
log.info(TAG, 'Pipeline templates initialization completed');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize pipeline templates:', error);
|
||||
log.error(TAG, 'Failed to initialize pipeline templates:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -136,7 +142,7 @@ export async function getAvailableTemplates(): Promise<
|
||||
description: template.description || '',
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to get pipeline templates:', error);
|
||||
log.error(TAG, 'Failed to get pipeline templates:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -205,12 +211,13 @@ export async function createPipelineFromTemplate(
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
log.info(
|
||||
TAG,
|
||||
`Created pipeline from template ${templateId}: ${newPipeline.name}`,
|
||||
);
|
||||
return newPipeline.id;
|
||||
} catch (error) {
|
||||
console.error('Failed to create pipeline from template:', error);
|
||||
log.error(TAG, 'Failed to create pipeline from template:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
type RouteMetadata,
|
||||
} from '../decorators/route.ts';
|
||||
import { createSuccessResponse } from '../middlewares/exception.ts';
|
||||
import { log } from './logger.ts';
|
||||
|
||||
const TAG = 'RouteScanner';
|
||||
|
||||
/**
|
||||
* 控制器类型
|
||||
@@ -79,7 +82,7 @@ export class RouteScanner {
|
||||
this.router.patch(fullPath, handler);
|
||||
break;
|
||||
default:
|
||||
console.warn(`未支持的HTTP方法: ${route.method}`);
|
||||
log.info(TAG, `未支持的HTTP方法: ${route.method}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import type { Middleware } from './types.ts';
|
||||
|
||||
export class Session implements Middleware {
|
||||
apply(app: Koa): void {
|
||||
app.keys = ['foka-ci'];
|
||||
app.keys = ['mini-ci'];
|
||||
app.use(
|
||||
session(
|
||||
{
|
||||
key: 'foka.sid',
|
||||
key: 'mini-ci.sid',
|
||||
maxAge: 86400000,
|
||||
autoCommit: true /** (boolean) automatically commit headers (default true) */,
|
||||
overwrite: true /** (boolean) can overwrite or not (default true) */,
|
||||
|
||||
Binary file not shown.
@@ -66,11 +66,14 @@ export class PipelineRunner {
|
||||
|
||||
// 依次执行每个步骤
|
||||
for (const [index, step] of pipeline.steps.entries()) {
|
||||
const progress = `[${index + 1}/${pipeline.steps.length}]`;
|
||||
// 准备环境变量
|
||||
const envVars = this.prepareEnvironmentVariables(pipeline, deployment);
|
||||
|
||||
// 记录开始执行步骤的日志
|
||||
const startLog = `[${new Date().toISOString()}] 开始执行步骤 ${index + 1}/${pipeline.steps.length}: ${step.name}\n`;
|
||||
const startLog = this.addTimestamp(
|
||||
`${progress} 开始执行: ${step.name}`,
|
||||
);
|
||||
logs += startLog;
|
||||
|
||||
// 实时更新日志
|
||||
@@ -81,10 +84,10 @@ export class PipelineRunner {
|
||||
|
||||
// 执行步骤
|
||||
const stepLog = await this.executeStep(step, envVars);
|
||||
logs += `${stepLog}\n`;
|
||||
logs += stepLog;
|
||||
|
||||
// 记录步骤执行完成的日志
|
||||
const endLog = `[${new Date().toISOString()}] 步骤 "${step.name}" 执行完成\n`;
|
||||
const endLog = this.addTimestamp(`${progress} 执行完成: ${step.name}`);
|
||||
logs += endLog;
|
||||
|
||||
// 实时更新日志
|
||||
@@ -93,9 +96,16 @@ export class PipelineRunner {
|
||||
data: { buildLog: logs },
|
||||
});
|
||||
}
|
||||
await prisma.deployment.update({
|
||||
where: { id: this.deploymentId },
|
||||
data: {
|
||||
buildLog: logs,
|
||||
status: 'success',
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
hasError = true;
|
||||
const errorMsg = `[${new Date().toISOString()}] Error: ${(error as Error).message}\n`;
|
||||
const errorMsg = this.addTimestamp(`${(error as Error).message}`);
|
||||
logs += errorMsg;
|
||||
|
||||
log.error(
|
||||
@@ -116,18 +126,6 @@ export class PipelineRunner {
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 更新最终状态
|
||||
if (!hasError) {
|
||||
await prisma.deployment.update({
|
||||
where: { id: this.deploymentId },
|
||||
data: {
|
||||
buildLog: logs,
|
||||
status: 'success',
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,21 +139,20 @@ export class PipelineRunner {
|
||||
branch: string,
|
||||
): Promise<string> {
|
||||
let logs = '';
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
try {
|
||||
logs += `[${timestamp}] 检查工作目录状态: ${this.projectDir}\n`;
|
||||
logs += this.addTimestamp(`检查工作目录状态: ${this.projectDir}`);
|
||||
|
||||
// 检查工作目录状态
|
||||
const status = await GitManager.checkWorkspaceStatus(this.projectDir);
|
||||
logs += `[${new Date().toISOString()}] 工作目录状态: ${status.status}\n`;
|
||||
logs += this.addTimestamp(`工作目录状态: ${status.status}`);
|
||||
|
||||
if (
|
||||
status.status === WorkspaceDirStatus.NOT_CREATED ||
|
||||
status.status === WorkspaceDirStatus.EMPTY
|
||||
) {
|
||||
// 目录不存在或为空,需要克隆
|
||||
logs += `[${new Date().toISOString()}] 工作目录不存在或为空,开始克隆仓库\n`;
|
||||
logs += this.addTimestamp('工作目录不存在或为空,开始克隆仓库');
|
||||
|
||||
// 确保父目录存在
|
||||
await GitManager.ensureDirectory(this.projectDir);
|
||||
@@ -168,7 +165,7 @@ export class PipelineRunner {
|
||||
// TODO: 添加 token 支持
|
||||
);
|
||||
|
||||
logs += `[${new Date().toISOString()}] 仓库克隆成功\n`;
|
||||
logs += this.addTimestamp('仓库克隆成功');
|
||||
} else if (status.status === WorkspaceDirStatus.NO_GIT) {
|
||||
// 目录存在但不是 Git 仓库
|
||||
throw new Error(
|
||||
@@ -176,14 +173,16 @@ export class PipelineRunner {
|
||||
);
|
||||
} else if (status.status === WorkspaceDirStatus.READY) {
|
||||
// 已存在 Git 仓库,更新代码
|
||||
logs += `[${new Date().toISOString()}] 工作目录已存在 Git 仓库,开始更新代码\n`;
|
||||
logs += this.addTimestamp('工作目录已存在 Git 仓库,开始更新代码');
|
||||
await GitManager.updateRepository(this.projectDir, branch);
|
||||
logs += `[${new Date().toISOString()}] 代码更新成功\n`;
|
||||
logs += this.addTimestamp('代码更新成功');
|
||||
}
|
||||
|
||||
return logs;
|
||||
} catch (error) {
|
||||
const errorLog = `[${new Date().toISOString()}] 准备工作目录失败: ${(error as Error).message}\n`;
|
||||
const errorLog = this.addTimestamp(
|
||||
`准备工作目录失败: ${(error as Error).message}`,
|
||||
);
|
||||
logs += errorLog;
|
||||
log.error(
|
||||
this.TAG,
|
||||
@@ -221,7 +220,7 @@ export class PipelineRunner {
|
||||
const userEnvVars = JSON.parse(deployment.envVars);
|
||||
Object.assign(envVars, userEnvVars);
|
||||
} catch (error) {
|
||||
console.error('解析环境变量失败:', error);
|
||||
log.error(this.TAG, '解析环境变量失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,28 +236,9 @@ export class PipelineRunner {
|
||||
* @param isError 是否为错误日志
|
||||
* @returns 带时间戳的日志消息
|
||||
*/
|
||||
private addTimestamp(message: string, isError = false): string {
|
||||
private addTimestamp(message: string): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
if (isError) {
|
||||
return `[${timestamp}] [ERROR] ${message}`;
|
||||
}
|
||||
return `[${timestamp}] ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为多行日志添加时间戳前缀
|
||||
* @param content 多行日志内容
|
||||
* @param isError 是否为错误日志
|
||||
* @returns 带时间戳的多行日志消息
|
||||
*/
|
||||
private addTimestampToLines(content: string, isError = false): string {
|
||||
if (!content) return '';
|
||||
|
||||
return `${content
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() !== '')
|
||||
.map((line) => this.addTimestamp(line, isError))
|
||||
.join('\n')}\n`;
|
||||
return `[${timestamp}] [ERROR] ${message}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,35 +252,21 @@ export class PipelineRunner {
|
||||
): Promise<string> {
|
||||
let logs = '';
|
||||
|
||||
try {
|
||||
// 添加步骤开始执行的时间戳
|
||||
logs += `${this.addTimestamp(`执行脚本: ${step.script}`)}\n`;
|
||||
// 使用zx执行脚本,设置项目目录为工作目录和环境变量
|
||||
const script = step.script;
|
||||
|
||||
// 使用zx执行脚本,设置项目目录为工作目录和环境变量
|
||||
const script = step.script;
|
||||
// bash -c 执行脚本,确保环境变量能被正确解析
|
||||
const result = await $({
|
||||
cwd: this.projectDir,
|
||||
env: { ...process.env, ...envVars },
|
||||
})`bash -c ${script}`;
|
||||
|
||||
// 通过bash -c执行脚本,确保环境变量能被正确解析
|
||||
const result = await $({
|
||||
cwd: this.projectDir,
|
||||
env: { ...process.env, ...envVars },
|
||||
})`bash -c ${script}`;
|
||||
if (result.stdout) {
|
||||
logs += this.addTimestamp(`\n${result.stdout}`);
|
||||
}
|
||||
|
||||
if (result.stdout) {
|
||||
// 为stdout中的每一行添加时间戳
|
||||
logs += this.addTimestampToLines(result.stdout);
|
||||
}
|
||||
|
||||
if (result.stderr) {
|
||||
// 为stderr中的每一行添加时间戳和错误标记
|
||||
logs += this.addTimestampToLines(result.stderr, true);
|
||||
}
|
||||
|
||||
logs += `${this.addTimestamp(`步骤执行完成`)}\n`;
|
||||
} catch (error) {
|
||||
const errorMsg = `Error executing step "${step.name}": ${(error as Error).message}`;
|
||||
logs += `${this.addTimestamp(errorMsg, true)}\n`;
|
||||
log.error(this.TAG, errorMsg);
|
||||
throw error;
|
||||
if (result.stderr) {
|
||||
logs += this.addTimestamp(`\n${result.stderr}`);
|
||||
}
|
||||
|
||||
return logs;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { pluginSvgr } from '@rsbuild/plugin-svgr';
|
||||
export default defineConfig({
|
||||
plugins: [pluginReact(), pluginLess(), pluginSvgr()],
|
||||
html: {
|
||||
title: 'Foka CI',
|
||||
title: 'Mini CI',
|
||||
},
|
||||
source: {
|
||||
define: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type React from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
export function useAsyncEffect(
|
||||
effect: () => Promise<undefined | (() => void)>,
|
||||
effect: () => Promise<any | (() => void)>,
|
||||
deps: React.DependencyList,
|
||||
) {
|
||||
const callback = useCallback(effect, [...deps]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Env from '@pages/env';
|
||||
|
||||
import Home from '@pages/home';
|
||||
import Login from '@pages/login';
|
||||
import ProjectDetail from '@pages/project/detail';
|
||||
@@ -13,7 +13,7 @@ const App = () => {
|
||||
<Route index element={<Navigate to="project" replace />} />
|
||||
<Route path="project" element={<ProjectList />} />
|
||||
<Route path="project/:id" element={<ProjectDetail />} />
|
||||
<Route path="env" element={<Env />} />
|
||||
|
||||
</Route>
|
||||
<Route path="/login" element={<Login />} />
|
||||
</Routes>
|
||||
|
||||
5
apps/web/src/pages/env/index.tsx
vendored
5
apps/web/src/pages/env/index.tsx
vendored
@@ -1,5 +0,0 @@
|
||||
function Env() {
|
||||
return <div>env page</div>;
|
||||
}
|
||||
|
||||
export default Env;
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
IconExport,
|
||||
IconMenuFold,
|
||||
IconMenuUnfold,
|
||||
IconRobot,
|
||||
|
||||
} from '@arco-design/web-react/icon';
|
||||
import Logo from '@assets/images/logo.svg?react';
|
||||
import { loginService } from '@pages/login/service';
|
||||
@@ -31,7 +31,7 @@ function Home() {
|
||||
>
|
||||
<div className="flex flex-row items-center justify-center h-[56px]">
|
||||
<Logo />
|
||||
{!collapsed && <h2 className="ml-4 text-xl font-medium">Foka CI</h2>}
|
||||
{!collapsed && <h2 className="ml-4 text-xl font-medium">Mini CI</h2>}
|
||||
</div>
|
||||
<Menu
|
||||
className="flex-1"
|
||||
@@ -45,12 +45,7 @@ function Home() {
|
||||
<span>项目管理</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="1">
|
||||
<Link to="/env">
|
||||
<IconRobot fontSize={16} />
|
||||
环境管理
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
|
||||
</Menu>
|
||||
</Layout.Sider>
|
||||
<Layout>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Message, Notification } from '@arco-design/web-react';
|
||||
import { net } from '@shared';
|
||||
import { net } from '../../utils';
|
||||
import type { NavigateFunction } from 'react-router';
|
||||
import { useGlobalStore } from '../../stores/global';
|
||||
import type { AuthLoginResponse, AuthURLResponse } from './types';
|
||||
import type { AuthURL, User } from './types';
|
||||
|
||||
class LoginService {
|
||||
async getAuthUrl() {
|
||||
const { code, data } = await net.request<AuthURLResponse>({
|
||||
const { code, data } = await net.request<AuthURL>({
|
||||
method: 'GET',
|
||||
url: '/api/auth/url',
|
||||
params: {
|
||||
@@ -19,7 +19,7 @@ class LoginService {
|
||||
}
|
||||
|
||||
async login(authCode: string, navigate: NavigateFunction) {
|
||||
const { data, code } = await net.request<AuthLoginResponse>({
|
||||
const { data, code } = await net.request<User>({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
data: {
|
||||
@@ -37,7 +37,7 @@ class LoginService {
|
||||
}
|
||||
|
||||
async logout() {
|
||||
const { code } = await net.request<AuthURLResponse>({
|
||||
const { code } = await net.request<null>({
|
||||
method: 'GET',
|
||||
url: '/api/auth/logout',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { APIResponse } from '@shared';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -8,8 +6,6 @@ export interface User {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export type AuthURLResponse = APIResponse<{
|
||||
export interface AuthURL {
|
||||
url: string;
|
||||
}>;
|
||||
|
||||
export type AuthLoginResponse = APIResponse<User>;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Menu,
|
||||
Message,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
@@ -94,6 +95,11 @@ function ProjectDetailPage() {
|
||||
const [pipelineForm] = Form.useForm();
|
||||
const [deployRecords, setDeployRecords] = useState<Deployment[]>([]);
|
||||
const [deployModalVisible, setDeployModalVisible] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
// 流水线模板相关状态
|
||||
const [isCreatingFromTemplate, setIsCreatingFromTemplate] = useState(false);
|
||||
@@ -153,10 +159,15 @@ function ProjectDetailPage() {
|
||||
|
||||
// 获取部署记录
|
||||
try {
|
||||
const records = await detailService.getDeployments(Number(id));
|
||||
setDeployRecords(records);
|
||||
if (records.length > 0) {
|
||||
setSelectedRecordId(records[0].id);
|
||||
const res = await detailService.getDeployments(
|
||||
Number(id),
|
||||
1,
|
||||
pagination.pageSize,
|
||||
);
|
||||
setDeployRecords(res.list);
|
||||
setPagination((prev) => ({ ...prev, total: res.total, current: 1 }));
|
||||
if (res.list.length > 0) {
|
||||
setSelectedRecordId(res.list[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取部署记录失败:', error);
|
||||
@@ -175,32 +186,40 @@ function ProjectDetailPage() {
|
||||
};
|
||||
|
||||
// 定期轮询部署记录以更新状态和日志
|
||||
useAsyncEffect(async () => {
|
||||
const interval = setInterval(async () => {
|
||||
if (id) {
|
||||
try {
|
||||
const records = await detailService.getDeployments(Number(id));
|
||||
setDeployRecords(records);
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
// 如果当前选中的记录正在运行,则更新选中记录
|
||||
const selectedRecord = records.find(
|
||||
(r: Deployment) => r.id === selectedRecordId,
|
||||
);
|
||||
if (
|
||||
selectedRecord &&
|
||||
(selectedRecord.status === 'running' ||
|
||||
selectedRecord.status === 'pending')
|
||||
) {
|
||||
// 保持当前选中状态,但更新数据
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('轮询部署记录失败:', error);
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await detailService.getDeployments(
|
||||
Number(id),
|
||||
pagination.current,
|
||||
pagination.pageSize,
|
||||
);
|
||||
setDeployRecords(res.list);
|
||||
setPagination((prev) => ({ ...prev, total: res.total }));
|
||||
|
||||
// 如果当前选中的记录正在运行,则更新选中记录
|
||||
const selectedRecord = res.list.find(
|
||||
(r: Deployment) => r.id === selectedRecordId,
|
||||
);
|
||||
if (
|
||||
selectedRecord &&
|
||||
(selectedRecord.status === 'running' ||
|
||||
selectedRecord.status === 'pending')
|
||||
) {
|
||||
// 保持当前选中状态,但更新数据
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('轮询部署记录失败:', error);
|
||||
}
|
||||
}, 3000); // 每3秒轮询一次
|
||||
};
|
||||
|
||||
poll(); // 立即执行一次
|
||||
const interval = setInterval(poll, 3000); // 每3秒轮询一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [id, selectedRecordId]);
|
||||
}, [id, selectedRecordId, pagination.current, pagination.pageSize]);
|
||||
|
||||
// 触发部署
|
||||
const handleDeploy = () => {
|
||||
@@ -601,8 +620,13 @@ function ProjectDetailPage() {
|
||||
|
||||
// 刷新部署记录
|
||||
if (id) {
|
||||
const records = await detailService.getDeployments(Number(id));
|
||||
setDeployRecords(records);
|
||||
const res = await detailService.getDeployments(
|
||||
Number(id),
|
||||
pagination.current,
|
||||
pagination.pageSize,
|
||||
);
|
||||
setDeployRecords(res.list);
|
||||
setPagination((prev) => ({ ...prev, total: res.total }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重新执行部署失败:', error);
|
||||
@@ -725,24 +749,9 @@ function ProjectDetailPage() {
|
||||
item={item}
|
||||
isSelected={selectedRecordId === item.id}
|
||||
onSelect={setSelectedRecordId}
|
||||
onRetry={handleRetryDeployment} // 传递重新执行函数
|
||||
/>
|
||||
);
|
||||
|
||||
// 获取选中的流水线
|
||||
const _selectedPipeline = pipelines.find(
|
||||
(pipeline) => pipeline.id === selectedPipelineId,
|
||||
);
|
||||
|
||||
// 格式化文件大小
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
// 获取工作目录状态标签
|
||||
const getWorkspaceStatusTag = (
|
||||
status: string,
|
||||
@@ -784,12 +793,6 @@ function ProjectDetailPage() {
|
||||
label: '状态',
|
||||
value: <Tag color={statusInfo.color}>{statusInfo.text}</Tag>,
|
||||
},
|
||||
{
|
||||
label: '目录大小',
|
||||
value: workspaceStatus.size
|
||||
? formatSize(workspaceStatus.size)
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
label: '当前分支',
|
||||
value: workspaceStatus.gitInfo?.branch || '-',
|
||||
@@ -797,7 +800,7 @@ function ProjectDetailPage() {
|
||||
{
|
||||
label: '最后提交',
|
||||
value: workspaceStatus.gitInfo?.lastCommit ? (
|
||||
<Space direction="vertical" size="mini">
|
||||
<Space size="small">
|
||||
<Typography.Text code>
|
||||
{workspaceStatus.gitInfo.lastCommit}
|
||||
</Typography.Text>
|
||||
@@ -862,19 +865,33 @@ function ProjectDetailPage() {
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{deployRecords.length > 0 ? (
|
||||
<List
|
||||
className="bg-white rounded-lg border"
|
||||
dataSource={deployRecords}
|
||||
render={renderDeployRecordItem}
|
||||
split={true}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{deployRecords.length > 0 ? (
|
||||
<List
|
||||
className="bg-white rounded-lg border"
|
||||
dataSource={deployRecords}
|
||||
render={renderDeployRecordItem}
|
||||
split={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Empty description="暂无部署记录" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-right">
|
||||
<Pagination
|
||||
total={pagination.total}
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
size="small"
|
||||
simple
|
||||
onChange={(page) =>
|
||||
setPagination((prev) => ({ ...prev, current: page }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Empty description="暂无部署记录" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1404,14 +1421,7 @@ function ProjectDetailPage() {
|
||||
style={{ fontFamily: 'Monaco, Consolas, monospace' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="bg-blue-50 p-3 rounded text-sm">
|
||||
<Typography.Text type="secondary">
|
||||
<strong>可用环境变量:</strong>
|
||||
<br />• $PROJECT_NAME - 项目名称
|
||||
<br />• $BUILD_NUMBER - 构建编号
|
||||
<br />• $REGISTRY - 镜像仓库地址
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -1422,12 +1432,19 @@ function ProjectDetailPage() {
|
||||
setDeployModalVisible(false);
|
||||
// 刷新部署记录
|
||||
if (id) {
|
||||
detailService.getDeployments(Number(id)).then((records) => {
|
||||
setDeployRecords(records);
|
||||
if (records.length > 0) {
|
||||
setSelectedRecordId(records[0].id);
|
||||
}
|
||||
});
|
||||
detailService
|
||||
.getDeployments(Number(id), 1, pagination.pageSize)
|
||||
.then((res) => {
|
||||
setDeployRecords(res.list);
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
total: res.total,
|
||||
current: 1,
|
||||
}));
|
||||
if (res.list.length > 0) {
|
||||
setSelectedRecordId(res.list[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
pipelines={pipelines}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type APIResponse, net } from '@shared';
|
||||
import { net } from '../../../utils';
|
||||
import type {
|
||||
Branch,
|
||||
Commit,
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
|
||||
class DetailService {
|
||||
async getProject(id: string) {
|
||||
const { data } = await net.request<APIResponse<Project>>({
|
||||
const { data } = await net.request<Project>({
|
||||
url: `/api/projects/${id}`,
|
||||
});
|
||||
return data;
|
||||
@@ -19,28 +19,32 @@ class DetailService {
|
||||
|
||||
// 获取项目的所有流水线
|
||||
async getPipelines(projectId: number) {
|
||||
const { data } = await net.request<APIResponse<Pipeline[]>>({
|
||||
const { data } = await net.request<Pipeline[] | { list: Pipeline[] }>({
|
||||
url: `/api/pipelines?projectId=${projectId}`,
|
||||
});
|
||||
return data;
|
||||
return Array.isArray(data) ? data : data.list;
|
||||
}
|
||||
|
||||
// 获取可用的流水线模板
|
||||
async getPipelineTemplates() {
|
||||
const { data } = await net.request<
|
||||
APIResponse<{ id: number; name: string; description: string }[]>
|
||||
| { id: number; name: string; description: string }[]
|
||||
| { list: { id: number; name: string; description: string }[] }
|
||||
>({
|
||||
url: '/api/pipelines/templates',
|
||||
});
|
||||
return data;
|
||||
return Array.isArray(data) ? data : data.list;
|
||||
}
|
||||
|
||||
// 获取项目的部署记录
|
||||
async getDeployments(projectId: number) {
|
||||
const { data } = await net.request<any>({
|
||||
url: `/api/deployments?projectId=${projectId}`,
|
||||
async getDeployments(
|
||||
projectId: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 10,
|
||||
) {
|
||||
const { data } = await net.request<DeploymentListResponse>({
|
||||
url: `/api/deployments?projectId=${projectId}&page=${page}&pageSize=${pageSize}`,
|
||||
});
|
||||
return data.data;
|
||||
return data;
|
||||
}
|
||||
|
||||
// 创建流水线
|
||||
@@ -56,7 +60,7 @@ class DetailService {
|
||||
| 'steps'
|
||||
>,
|
||||
) {
|
||||
const { data } = await net.request<APIResponse<Pipeline>>({
|
||||
const { data } = await net.request<Pipeline>({
|
||||
url: '/api/pipelines',
|
||||
method: 'POST',
|
||||
data: pipeline,
|
||||
@@ -71,7 +75,7 @@ class DetailService {
|
||||
name: string,
|
||||
description?: string,
|
||||
) {
|
||||
const { data } = await net.request<APIResponse<Pipeline>>({
|
||||
const { data } = await net.request<Pipeline>({
|
||||
url: '/api/pipelines/from-template',
|
||||
method: 'POST',
|
||||
data: {
|
||||
@@ -100,7 +104,7 @@ class DetailService {
|
||||
>
|
||||
>,
|
||||
) {
|
||||
const { data } = await net.request<APIResponse<Pipeline>>({
|
||||
const { data } = await net.request<Pipeline>({
|
||||
url: `/api/pipelines/${id}`,
|
||||
method: 'PUT',
|
||||
data: pipeline,
|
||||
@@ -110,7 +114,7 @@ class DetailService {
|
||||
|
||||
// 删除流水线
|
||||
async deletePipeline(id: number) {
|
||||
const { data } = await net.request<APIResponse<null>>({
|
||||
const { data } = await net.request<null>({
|
||||
url: `/api/pipelines/${id}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
@@ -119,10 +123,10 @@ class DetailService {
|
||||
|
||||
// 获取流水线的所有步骤
|
||||
async getSteps(pipelineId: number) {
|
||||
const { data } = await net.request<APIResponse<Step[]>>({
|
||||
const { data } = await net.request<Step[] | { list: Step[] }>({
|
||||
url: `/api/steps?pipelineId=${pipelineId}`,
|
||||
});
|
||||
return data;
|
||||
return Array.isArray(data) ? data : data.list;
|
||||
}
|
||||
|
||||
// 创建步骤
|
||||
@@ -132,7 +136,7 @@ class DetailService {
|
||||
'id' | 'createdAt' | 'updatedAt' | 'createdBy' | 'updatedBy' | 'valid'
|
||||
>,
|
||||
) {
|
||||
const { data } = await net.request<APIResponse<Step>>({
|
||||
const { data } = await net.request<Step>({
|
||||
url: '/api/steps',
|
||||
method: 'POST',
|
||||
data: step,
|
||||
@@ -150,7 +154,7 @@ class DetailService {
|
||||
>
|
||||
>,
|
||||
) {
|
||||
const { data } = await net.request<APIResponse<Step>>({
|
||||
const { data } = await net.request<Step>({
|
||||
url: `/api/steps/${id}`,
|
||||
method: 'PUT',
|
||||
data: step,
|
||||
@@ -161,7 +165,7 @@ class DetailService {
|
||||
// 删除步骤
|
||||
async deleteStep(id: number) {
|
||||
// DELETE请求返回204状态码,通过拦截器处理为成功响应
|
||||
const { data } = await net.request<APIResponse<null>>({
|
||||
const { data } = await net.request<null>({
|
||||
url: `/api/steps/${id}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
@@ -170,23 +174,23 @@ class DetailService {
|
||||
|
||||
// 获取项目的提交记录
|
||||
async getCommits(projectId: number, branch?: string) {
|
||||
const { data } = await net.request<APIResponse<Commit[]>>({
|
||||
const { data } = await net.request<Commit[] | { list: Commit[] }>({
|
||||
url: `/api/git/commits?projectId=${projectId}${branch ? `&branch=${branch}` : ''}`,
|
||||
});
|
||||
return data;
|
||||
return Array.isArray(data) ? data : data.list;
|
||||
}
|
||||
|
||||
// 获取项目的分支列表
|
||||
async getBranches(projectId: number) {
|
||||
const { data } = await net.request<APIResponse<Branch[]>>({
|
||||
const { data } = await net.request<Branch[] | { list: Branch[] }>({
|
||||
url: `/api/git/branches?projectId=${projectId}`,
|
||||
});
|
||||
return data;
|
||||
return Array.isArray(data) ? data : data.list;
|
||||
}
|
||||
|
||||
// 创建部署
|
||||
async createDeployment(deployment: CreateDeploymentRequest) {
|
||||
const { data } = await net.request<APIResponse<Deployment>>({
|
||||
const { data } = await net.request<Deployment>({
|
||||
url: '/api/deployments',
|
||||
method: 'POST',
|
||||
data: deployment,
|
||||
@@ -196,7 +200,7 @@ class DetailService {
|
||||
|
||||
// 重新执行部署
|
||||
async retryDeployment(deploymentId: number) {
|
||||
const { data } = await net.request<APIResponse<Deployment>>({
|
||||
const { data } = await net.request<Deployment>({
|
||||
url: `/api/deployments/${deploymentId}/retry`,
|
||||
method: 'POST',
|
||||
});
|
||||
@@ -205,18 +209,15 @@ class DetailService {
|
||||
|
||||
// 获取项目详情(包含工作目录状态)
|
||||
async getProjectDetail(id: number) {
|
||||
const { data } = await net.request<APIResponse<Project>>({
|
||||
const { data } = await net.request<Project>({
|
||||
url: `/api/projects/${id}`,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
// 更新项目
|
||||
async updateProject(
|
||||
id: number,
|
||||
project: Partial<Project>,
|
||||
) {
|
||||
const { data } = await net.request<APIResponse<Project>>({
|
||||
async updateProject(id: number, project: Partial<Project>) {
|
||||
const { data } = await net.request<Project>({
|
||||
url: `/api/projects/${id}`,
|
||||
method: 'PUT',
|
||||
data: project,
|
||||
@@ -234,3 +235,10 @@ class DetailService {
|
||||
}
|
||||
|
||||
export const detailService = new DetailService();
|
||||
|
||||
export interface DeploymentListResponse {
|
||||
list: Deployment[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
Form,
|
||||
Input,
|
||||
Message,
|
||||
Modal,
|
||||
} from '@arco-design/web-react';
|
||||
import { Button, Form, Input, Message, Modal } from '@arco-design/web-react';
|
||||
import { useState } from 'react';
|
||||
import EnvPresetsEditor from '../../detail/components/EnvPresetsEditor';
|
||||
import type { Project } from '../../types';
|
||||
import { projectService } from '../service';
|
||||
|
||||
@@ -30,15 +22,7 @@ function CreateProjectModal({
|
||||
const values = await form.validate();
|
||||
setLoading(true);
|
||||
|
||||
// 序列化环境预设
|
||||
const submitData = {
|
||||
...values,
|
||||
envPresets: values.envPresets
|
||||
? JSON.stringify(values.envPresets)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const newProject = await projectService.create(submitData);
|
||||
const newProject = await projectService.create(values);
|
||||
|
||||
Message.success('项目创建成功');
|
||||
onSuccess(newProject);
|
||||
@@ -142,14 +126,6 @@ function CreateProjectModal({
|
||||
>
|
||||
<Input placeholder="请输入绝对路径,如: /data/projects/my-app" />
|
||||
</Form.Item>
|
||||
|
||||
<Collapse defaultActiveKey={[]} style={{ marginTop: 16 }}>
|
||||
<Collapse.Item header="环境变量预设配置(可选)" name="envPresets">
|
||||
<Form.Item field="envPresets" noStyle>
|
||||
<EnvPresetsEditor />
|
||||
</Form.Item>
|
||||
</Collapse.Item>
|
||||
</Collapse>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -59,7 +59,7 @@ function ProjectCard({ project }: ProjectCardProps) {
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="foka-card !rounded-xl border border-gray-200 h-[280px] cursor-pointer"
|
||||
className="!rounded-xl border border-gray-200 h-[280px] cursor-pointer"
|
||||
hoverable
|
||||
bodyStyle={{ padding: '20px' }}
|
||||
onClick={onProjectClick}
|
||||
|
||||
@@ -15,7 +15,7 @@ function ProjectPage() {
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
const response = await projectService.list();
|
||||
setProjects(response.data);
|
||||
setProjects(response.list);
|
||||
}, []);
|
||||
|
||||
const handleCreateProject = () => {
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { type APIResponse, net } from '@shared';
|
||||
import { net } from '../../../utils';
|
||||
import type { Project } from '../types';
|
||||
|
||||
class ProjectService {
|
||||
async list(params?: ProjectQueryParams) {
|
||||
const { data } = await net.request<APIResponse<ProjectListResponse>>({
|
||||
const { data } = await net.request<Project[] | ProjectListResponse>({
|
||||
method: 'GET',
|
||||
url: '/api/projects',
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
return Array.isArray(data)
|
||||
? { list: data, page: 1, pageSize: data.length, total: data.length }
|
||||
: data;
|
||||
}
|
||||
|
||||
async show(id: string) {
|
||||
const { data } = await net.request<APIResponse<Project>>({
|
||||
const { data } = await net.request<Project>({
|
||||
method: 'GET',
|
||||
url: `/api/projects/${id}`,
|
||||
});
|
||||
@@ -24,7 +26,7 @@ class ProjectService {
|
||||
description?: string;
|
||||
repository: string;
|
||||
}) {
|
||||
const { data } = await net.request<APIResponse<Project>>({
|
||||
const { data } = await net.request<Project>({
|
||||
method: 'POST',
|
||||
url: '/api/projects',
|
||||
data: project,
|
||||
@@ -36,7 +38,7 @@ class ProjectService {
|
||||
id: string,
|
||||
project: Partial<{ name: string; description: string; repository: string }>,
|
||||
) {
|
||||
const { data } = await net.request<APIResponse<Project>>({
|
||||
const { data } = await net.request<Project>({
|
||||
method: 'PUT',
|
||||
url: `/api/projects/${id}`,
|
||||
data: project,
|
||||
@@ -56,17 +58,14 @@ class ProjectService {
|
||||
export const projectService = new ProjectService();
|
||||
|
||||
interface ProjectListResponse {
|
||||
data: Project[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
list: Project[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ProjectQueryParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
pageSize?: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
import { type APIResponse, net } from '@shared';
|
||||
import { net } from '@utils';
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar_url: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface GlobalStore {
|
||||
user: User | null;
|
||||
setUser: (user: User) => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
import type { GlobalStore } from './types';
|
||||
import type { User } from '@pages/login/types';
|
||||
|
||||
export const useGlobalStore = create<GlobalStore>((set) => ({
|
||||
user: null,
|
||||
setUser: (user: User) => set({ user }),
|
||||
async refreshUser() {
|
||||
const { data } = await net.request<APIResponse<User>>({
|
||||
const { data } = await net.request<User>({
|
||||
method: 'GET',
|
||||
url: '/api/auth/info',
|
||||
});
|
||||
|
||||
13
apps/web/src/stores/types.ts
Normal file
13
apps/web/src/stores/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar_url: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalStore {
|
||||
user: User | null;
|
||||
setUser: (user: User) => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
@@ -42,9 +42,9 @@ class Net {
|
||||
);
|
||||
}
|
||||
|
||||
async request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||
async request<T>(config: AxiosRequestConfig): Promise<APIResponse<T>> {
|
||||
try {
|
||||
const response = await this.instance.request<T>(config);
|
||||
const response = await this.instance.request<APIResponse<T>>(config);
|
||||
if (!response || !response.data) {
|
||||
throw new Error('Invalid response');
|
||||
}
|
||||
@@ -56,6 +56,13 @@ class Net {
|
||||
}
|
||||
}
|
||||
|
||||
export interface APIPagination<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface APIResponse<T> {
|
||||
code: number;
|
||||
data: T;
|
||||
@@ -24,7 +24,8 @@
|
||||
"@pages/*": ["./src/pages/*"],
|
||||
"@styles/*": ["./src/styles/*"],
|
||||
"@assets/*": ["./src/assets/*"],
|
||||
"@shared": ["./src/shared"]
|
||||
"@utils/*": ["./src/utils/*"],
|
||||
"@utils": ["./src/utils"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# 文档拥有者
|
||||
|
||||
- backend: backend-team@example.com
|
||||
- ops: ops-team@example.com
|
||||
- product: product-team@example.com
|
||||
|
||||
每个文档请在 front-matter 中声明 `owners` 字段。
|
||||
@@ -1,116 +0,0 @@
|
||||
---
|
||||
title: 设计文档模板
|
||||
summary: 记录一个功能/模块的设计方案、权衡与落地计划(建议配套 ADR)。
|
||||
owners:
|
||||
- team: <team>
|
||||
reviewers:
|
||||
- <name-or-team>
|
||||
status: draft
|
||||
date: 2026-01-03
|
||||
version: 0.1.0
|
||||
related:
|
||||
- adr: docs/architecture/adr-xxxx-<slug>.md
|
||||
- pr: <link>
|
||||
- issue: <link>
|
||||
---
|
||||
|
||||
# 设计文档:<标题>
|
||||
|
||||
## 1. 背景(Context)
|
||||
|
||||
- 当前问题是什么?为什么现在要做?
|
||||
- 相关现状:已有模块/接口/数据模型(附链接)
|
||||
- 约束:技术栈、部署方式、团队边界、时间/人力
|
||||
|
||||
## 2. 目标(Goals)
|
||||
|
||||
- [ ] 目标 1(可验证)
|
||||
- [ ] 目标 2(可验证)
|
||||
|
||||
## 3. 非目标(Non-goals)
|
||||
|
||||
- 不做什么(防止范围膨胀)
|
||||
|
||||
## 4. 需求与范围(Requirements & Scope)
|
||||
|
||||
- 用户/角色:谁会用?(例如:管理员、开发者、CI runner)
|
||||
- 功能需求:
|
||||
- R1:
|
||||
- R2:
|
||||
- 非功能需求:性能、可用性、可维护性、可观测性
|
||||
|
||||
## 5. 方案概览(High-level Design)
|
||||
|
||||
- 用 5-10 行描述整体方案(模块、数据流、调用链)
|
||||
- 关键选择:为什么选这个方案?
|
||||
|
||||
## 6. 详细设计(Detailed Design)
|
||||
|
||||
### 6.1 接口/API 设计
|
||||
|
||||
- 新增/变更端点(路径、方法、权限、请求/响应示例)
|
||||
- 错误码与错误语义(与 `BusinessError` 对齐)
|
||||
|
||||
### 6.2 数据模型/数据库
|
||||
|
||||
- Prisma model 变更(字段、索引、迁移策略)
|
||||
- 数据一致性与幂等策略(例如:重试/重复提交)
|
||||
|
||||
### 6.3 任务/队列/异步处理(如有)
|
||||
|
||||
- 队列模型:入队、出队、并发、重试、死信/失败处理
|
||||
- 状态机:状态枚举与迁移
|
||||
|
||||
### 6.4 配置与环境变量
|
||||
|
||||
- 新增 env(默认值、是否敏感、是否需要重启)
|
||||
|
||||
### 6.5 可观测性
|
||||
|
||||
- 日志:关键日志点、traceId/requestId(如有)
|
||||
- 指标:成功率、延迟、队列长度、失败原因分布
|
||||
- 告警:P0/P1 触发条件
|
||||
|
||||
### 6.6 安全与权限
|
||||
|
||||
- 认证:是否要求登录(session)
|
||||
- 授权:角色/资源权限(项目级、流水线级)
|
||||
- 数据安全:敏感信息、token、日志脱敏
|
||||
|
||||
## 7. 影响与权衡(Trade-offs)
|
||||
|
||||
- 性能影响
|
||||
- 运维影响
|
||||
- 对现有接口/调用方的影响
|
||||
- 技术债与后续演进
|
||||
|
||||
## 8. 兼容性与迁移(Compatibility & Migration)
|
||||
|
||||
- 是否 breaking change?
|
||||
- 迁移步骤(DB、配置、数据回填)
|
||||
- 回滚策略
|
||||
|
||||
## 9. 测试计划(Test Plan)
|
||||
|
||||
- 单测:覆盖哪些模块
|
||||
- 集成测试:关键链路(如:创建部署 -> 入队 -> 执行 -> 状态更新)
|
||||
- 手工验证:步骤清单
|
||||
|
||||
## 10. 发布计划(Rollout Plan)
|
||||
|
||||
- 分阶段:灰度/开关/逐步放量(如有)
|
||||
- 监控指标与验收标准
|
||||
|
||||
## 11. 备选方案(Alternatives Considered)
|
||||
|
||||
- 方案 A:为什么不用
|
||||
- 方案 B:为什么不用
|
||||
|
||||
## 12. 风险与开放问题(Risks & Open Questions)
|
||||
|
||||
- 风险 1
|
||||
- 问题 1(需要谁来决定/何时决定)
|
||||
|
||||
## 13. 附录(Appendix)
|
||||
|
||||
- 相关链接:控制器、DTO、Prisma schema、PR 等
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
title: Runbook 模板
|
||||
owners:
|
||||
- ops: ops-team
|
||||
status: draft
|
||||
---
|
||||
|
||||
# Runbook 标题
|
||||
|
||||
## 触发条件
|
||||
|
||||
## 负责人
|
||||
|
||||
## 联系方式
|
||||
|
||||
## 暂时性缓解
|
||||
|
||||
## 恢复步骤
|
||||
|
||||
## 验证
|
||||
|
||||
## 回滚(如果适用)
|
||||
22
docs/README.md
Normal file
22
docs/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# MiniCI 文档中心
|
||||
|
||||
欢迎查阅 MiniCI 开发者文档。本项目是一个轻量级的自研 CI 系统。
|
||||
|
||||
## 目录索引
|
||||
|
||||
- [系统架构](./architecture.md) - 核心设计与模块划分
|
||||
- [决策记录 (ADR)](./decisions/0001-tech-stack.md) - 技术选型背后的逻辑
|
||||
- [约束与禁区](./constraints.md) - 开发中不可触碰的红线
|
||||
- [编码规范](./conventions.md) - 风格与最佳实践
|
||||
- [踩坑指南](./pitfalls.md) - 已解决的问题与易错点
|
||||
- [当前进度](./status.md) - 项目现状与待办
|
||||
- [AI 助手说明](./ai.md) - 专门为 coding agent 准备的作业指南
|
||||
|
||||
## 快速上手
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
项目访问:前端 `localhost:3000` (Rsbuild),后端 `localhost:3001` (Koa)。
|
||||
21
docs/ai.md
Normal file
21
docs/ai.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# AI 助手作业指南 (ai.md)
|
||||
|
||||
你好,Agent!在处理 MiniCI 项目时,请遵循以下原则:
|
||||
|
||||
## 1. 增加新 API 的步骤
|
||||
|
||||
1. 在 `controllers/` 对应模块下创建/修改 `dto.ts` 定义输入。
|
||||
2. 在 `index.ts` 中编写类,使用 `@Controller` 和 `@Post/Get` 等装饰器。
|
||||
3. 如果涉及数据库,修改 `schema.prisma` 并运行 `npx prisma db push`。
|
||||
|
||||
## 2. 核心逻辑位置
|
||||
|
||||
- 如果要修改 **流水线如何运行**,请看 `apps/server/runners/pipeline-runner.ts`。
|
||||
- 如果要修改 **任务调度**,请看 `apps/server/libs/execution-queue.ts`。
|
||||
- 如果要修改 **路由扫描**,请看 `apps/server/libs/route-scanner.ts`。
|
||||
|
||||
## 3. 交互规范
|
||||
|
||||
- 前端 `import` 代码优先使用路径别名,例如:`import {net} from '@utils'`。
|
||||
- 始终保持代码简洁,优先使用现有的 `libs` 工具类。
|
||||
- 修改代码后,务必确认 `pnpm dev` 是否能正常编译通过。
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
title: API 文档
|
||||
summary: 本目录存放 OpenAPI 定义与 API 使用说明。
|
||||
tags: [api]
|
||||
owners:
|
||||
- team: backend
|
||||
status: stable
|
||||
---
|
||||
|
||||
# API 文档
|
||||
|
||||
本目录包含 OpenAPI 规范与示例。可使用 Swagger UI 或 Redoc 渲染 `openapi.yaml`。
|
||||
|
||||
- OpenAPI: `openapi.yaml`
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
title: API 端点总览
|
||||
summary: 基于 `apps/server` 控制器实现的主要 REST API 端点汇总。
|
||||
owners:
|
||||
- team: backend
|
||||
status: stable
|
||||
---
|
||||
|
||||
# API 端点总览
|
||||
|
||||
基础前缀:`/api`
|
||||
|
||||
下面列出当前实现的主要控制器与常用端点。
|
||||
|
||||
## Projects (`/api/projects`)
|
||||
|
||||
- GET `/api/projects` : 列表(支持分页与按 name 搜索)
|
||||
- GET `/api/projects/:id` : 获取单个项目(包含 workspace 状态)
|
||||
- POST `/api/projects` : 创建项目(body: `name`, `repository`, `projectDir` 等)
|
||||
- PUT `/api/projects/:id` : 更新项目
|
||||
- DELETE `/api/projects/:id` : 软删除(将 `valid` 置为 0)
|
||||
|
||||
示例:
|
||||
|
||||
```http
|
||||
GET /api/projects?page=1&limit=10
|
||||
```
|
||||
|
||||
## User (`/api/user`)
|
||||
|
||||
- GET `/api/user/list` : 模拟用户列表
|
||||
- GET `/api/user/detail/:id` : 用户详情
|
||||
- POST `/api/user` : 创建用户
|
||||
- PUT `/api/user/:id` : 更新用户
|
||||
- DELETE `/api/user/:id` : 删除用户
|
||||
- GET `/api/user/search` : 搜索用户
|
||||
|
||||
## Auth (`/api/auth`)
|
||||
|
||||
- GET `/api/auth/url` : 获取 Gitea OAuth 授权 URL
|
||||
- POST `/api/auth/login` : 使用 OAuth code 登录(返回 session)
|
||||
- GET `/api/auth/logout` : 登出
|
||||
- GET `/api/auth/info` : 当前会话用户信息
|
||||
|
||||
注意:需要配置 `GITEA_URL`、`GITEA_CLIENT_ID`、`GITEA_REDIRECT_URI`。
|
||||
|
||||
## Deployments (`/api/deployments`)
|
||||
|
||||
- GET `/api/deployments` : 列表(支持 projectId 过滤)
|
||||
- POST `/api/deployments` : 创建部署(会将任务加入执行队列)
|
||||
- POST `/api/deployments/:id/retry` : 重新执行某次部署(复制记录并 requeue)
|
||||
|
||||
## Pipelines (`/api/pipelines`)
|
||||
|
||||
- GET `/api/pipelines` : 列表(含 steps)
|
||||
- GET `/api/pipelines/templates` : 获取可用流水线模板
|
||||
- GET `/api/pipelines/:id` : 单个流水线(含步骤)
|
||||
- POST `/api/pipelines` : 创建流水线
|
||||
- POST `/api/pipelines/from-template` : 基于模板创建流水线
|
||||
- PUT `/api/pipelines/:id` : 更新流水线
|
||||
- DELETE `/api/pipelines/:id` : 软删除
|
||||
|
||||
## Steps (`/api/steps`)
|
||||
|
||||
- GET `/api/steps` : 列表(支持 pipelineId 过滤)
|
||||
- GET `/api/steps/:id` : 单个步骤
|
||||
- POST `/api/steps` : 创建步骤(包含 `script` 字段)
|
||||
- PUT `/api/steps/:id` : 更新步骤
|
||||
- DELETE `/api/steps/:id` : 软删除
|
||||
|
||||
## Git (`/api/git`)
|
||||
|
||||
- GET `/api/git/commits?projectId=&branch=` : 获取指定项目的提交列表(调用 Gitea)
|
||||
- GET `/api/git/branches?projectId=` : 获取分支列表
|
||||
|
||||
---
|
||||
|
||||
想要更详细的示例(请求 body、响应 schema),我可以为每个端点基于 `dto.ts` 自动生成示例请求/响应片段。是否需要我继续生成?
|
||||
@@ -1,28 +0,0 @@
|
||||
openapi: 3.0.1
|
||||
info:
|
||||
title: Foka-CI 示例 API
|
||||
version: '1.0.0'
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
summary: 健康检查
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
/projects:
|
||||
get:
|
||||
summary: 列出项目
|
||||
responses:
|
||||
'200':
|
||||
description: 项目列表
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
29
docs/architecture.md
Normal file
29
docs/architecture.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 系统架构
|
||||
|
||||
## 1. 概览
|
||||
|
||||
MiniCI 采用典型的 **Monorepo** 架构,前端 React 配合后端 Koa,数据持久化使用 SQLite。
|
||||
|
||||
## 2. 核心模块
|
||||
|
||||
### 2.1 后端 (apps/server)
|
||||
|
||||
- **Route System**: 基于 TC39 装饰器的自动扫描路由。
|
||||
- **Execution Queue**: 单例模式的执行队列,控制并发并支持任务持久化恢复。
|
||||
- **Pipeline Runner**: 核心执行逻辑,利用 `zx` 在独立的工作目录下运行 Shell 脚本。
|
||||
- **Data Access**: Prisma ORM 提供类型安全的数据访问。
|
||||
|
||||
### 2.2 前端 (apps/web)
|
||||
|
||||
- **Build Tool**: Rsbuild (基于 Rspack),提供极速的开发体验。
|
||||
- **UI Framework**: React 19 + Arco Design。
|
||||
- **State Management**: Zustand 实现轻量级全局状态。
|
||||
|
||||
## 3. 部署流 (Pipeline Flow)
|
||||
|
||||
1. 用户触发部署 -> 创建 `Deployment` 记录 (Status: pending)。
|
||||
2. `ExecutionQueue` 捕获新任务 -> 实例化 `PipelineRunner`。
|
||||
3. `GitManager` 准备工作目录 (`git clone` 或 `git pull`)。
|
||||
4. `PipelineRunner` 逐个执行 `Step` 脚本。
|
||||
5. 执行过程中实时更新 `Deployment.buildLog`。
|
||||
6. 完成后更新状态为 `success` 或 `failed`。
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
title: ADR 0001 - 服务设计决策
|
||||
date: 2026-01-03
|
||||
authors:
|
||||
- backend-team
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# ADR 0001: 服务设计与部署模型
|
||||
|
||||
## 背景
|
||||
|
||||
需要选择微服务还是单体部署以便平衡开发速度与运维复杂度。
|
||||
|
||||
## 决策
|
||||
|
||||
采用模块化单体(modular monolith)作为初始阶段部署方式,关键模块解耦、接口明确,后续按需拆分服务。
|
||||
|
||||
## 影响
|
||||
|
||||
- 优点:降低初期运维成本,便于本地调试与 CI 集成。
|
||||
- 缺点:需要在代码边界设计中预留拆分点。
|
||||
|
||||
## 备注
|
||||
|
||||
在拆分时优先考虑数据库边界和独立部署能力。
|
||||
@@ -1,175 +0,0 @@
|
||||
---
|
||||
title: 设计文档 0001 - 产品原型
|
||||
summary: 产品的设计原型
|
||||
owners:
|
||||
- team: backend
|
||||
reviewers:
|
||||
- ops-team
|
||||
status: draft
|
||||
date: 2026-01-03
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# 设计文档 0001:产品整体原型
|
||||
|
||||
## 1. 背景(Context)
|
||||
|
||||
企业内部代码发布是非常频繁的事情,无论是测试环境还是生产环境,发布代码常常面临着一些痛点:频繁发布浪费开发时;手动发布代码可能漏掉某些步骤、造成环境报错。所以需要引入 CI/CD 工具让发布流程自动化,市场上已经存在很多解决类似问题的产品,例如 Drone CI、jenkins 等,但是这些工具不够灵活、不能满足全部场景,所以便有了 Foka-CI 这款产品。
|
||||
|
||||
## 2. 拆解需求
|
||||
|
||||
### 登录认证页面
|
||||
|
||||
仅提供 Gitea 的 OAuth 认证登录,方便获取仓库信息,简化密码登录过程
|
||||
|
||||
### 项目列表页面
|
||||
|
||||
查看所有项目,项目的基本信息有:
|
||||
|
||||
- 项目名
|
||||
- 项目描述(可选)
|
||||
- 代码仓库地址
|
||||
- 项目的工作目录,用于将代码克隆到该目录下,发布流水线脚本在该目录下执行
|
||||
|
||||
创建项目,需要填写基本信息
|
||||
|
||||
### 项目详情页
|
||||
|
||||
|
||||
## 2. 目标(Goals)
|
||||
|
||||
- [ ] 明确“部署任务”从创建到执行的状态流转与约束
|
||||
- [ ] 明确重试语义(retry 会创建新 deployment,而不是复用原记录)
|
||||
- [ ] 降低重复入队/重复执行的风险(幂等与并发边界清晰)
|
||||
- [ ] 让运维/排障更容易:关键日志点与可观测性清单
|
||||
|
||||
## 3. 非目标(Non-goals)
|
||||
|
||||
- 不引入外部消息队列(如 Redis/Kafka),仍以当前内存队列 + DB 恢复为基础
|
||||
- 不在本文直接实现大规模调度/分布式 runner(后续 ADR/设计再做)
|
||||
|
||||
## 4. 需求与范围(Requirements & Scope)
|
||||
|
||||
### 功能需求
|
||||
|
||||
- 创建部署:写入一条 Deployment 记录,初始状态为 `pending`,并加入执行队列
|
||||
- 重试部署:通过 `/deployments/:id/retry` 创建一条新的 Deployment 记录(复制必要字段),并加入执行队列
|
||||
- 服务重启恢复:服务启动后能从 DB 找回仍为 `pending` 的任务并继续执行
|
||||
|
||||
### 非功能需求
|
||||
|
||||
- 可靠性:服务重启不会“丢任务”(pending 的任务能恢复)
|
||||
- 幂等性:避免同一个 deployment 被重复执行
|
||||
- 可观测性:能定位某次部署为何失败、何时开始/结束、队列长度
|
||||
|
||||
## 5. 方案概览(High-level Design)
|
||||
|
||||
当前方案核心链路如下:
|
||||
|
||||
1. API 创建 Deployment(status=pending)
|
||||
2. `ExecutionQueue.addTask(deploymentId, pipelineId)` 入队
|
||||
3. `ExecutionQueue.processQueue()` 串行消费 `pendingQueue`
|
||||
4. `executePipeline()` 会读取 Deployment 与关联 Project,获取 `projectDir`,然后创建 `PipelineRunner` 执行
|
||||
5. 定时轮询:每 30 秒扫描 DB 中的 pending 任务,若不在 `runningDeployments` 集合则补入队列
|
||||
|
||||
## 6. 详细设计(Detailed Design)
|
||||
|
||||
### 6.1 接口/API 设计
|
||||
|
||||
#### 创建部署
|
||||
|
||||
- POST `/api/deployments`
|
||||
- 行为:创建 Deployment 后立即入队
|
||||
|
||||
#### 重试部署
|
||||
|
||||
- POST `/api/deployments/:id/retry`
|
||||
- 行为:读取原 deployment -> 创建新 deployment(status=pending)-> 入队
|
||||
- 语义:重试是“创建一个新的任务实例”,便于保留历史执行记录
|
||||
|
||||
### 6.2 数据模型/数据库
|
||||
|
||||
Deployment 当前关键字段(见 schema 注释):
|
||||
|
||||
- `status`: pending/running/success/failed/cancelled(目前入队依赖 pending)
|
||||
- `buildLog`: 执行日志(当前创建时写空字符串)
|
||||
- `startedAt`/`finishedAt`: 时间标记(目前 created 时 startedAt 默认 now)
|
||||
|
||||
建议补齐/明确(文档层面约束,代码后续落地):
|
||||
|
||||
- 状态迁移:
|
||||
- `pending` -> `running`(开始执行前)
|
||||
- `running` -> `success|failed|cancelled`(结束后)
|
||||
- 幂等控制:
|
||||
- 以 deploymentId 为“单次执行唯一标识”,同一个 deploymentId 不允许重复开始执行
|
||||
|
||||
### 6.3 队列/轮询/并发
|
||||
|
||||
现状:
|
||||
|
||||
- `runningDeployments` 同时承担“已入队/执行中”的去重集合
|
||||
- `pendingQueue` 为内存 FIFO
|
||||
- 单实例串行消费(`processQueue` while 循环)
|
||||
- 轮询间隔常量 30 秒
|
||||
|
||||
风险点(需要在文档中明确约束/后续逐步修正):
|
||||
|
||||
- 多实例部署:如果将来启动多个 server 实例,每个实例都可能轮询到同一条 pending 记录并执行(需要 DB 锁/租约/状态原子更新)
|
||||
- 状态更新缺口:当前 `ExecutionQueue` 代码中没有看到明确把 status 从 pending 改成 running/failed/success 的逻辑(可能在 `PipelineRunner` 内处理;若没有,需要补齐)
|
||||
|
||||
建议(不改变整体架构前提):
|
||||
|
||||
- 将轮询间隔改为可配置 env:`EXECUTION_POLL_INTERVAL_MS`(默认 30000)
|
||||
- 在真正执行前做一次 DB 原子“抢占”:仅当 status=pending 时更新为 running(并记录开始时间);更新失败则放弃执行
|
||||
|
||||
### 6.4 可观测性
|
||||
|
||||
最低要求(建议后续落地到代码/日志规范):
|
||||
|
||||
- 日志字段:deploymentId、pipelineId、projectId、projectDir、status
|
||||
- 队列指标:pendingQueue length、runningDeployments size
|
||||
- 失败记录:捕获异常 message/stack(避免泄露敏感信息)
|
||||
|
||||
### 6.5 安全与权限
|
||||
|
||||
当前接口层面需要确认:
|
||||
|
||||
- `/api/deployments` 与 `/api/deployments/:id/retry` 是否需要登录/鉴权(取决于 middleware 配置)
|
||||
- 若需要鉴权:建议限制为有项目权限的用户才能创建/重试部署
|
||||
|
||||
## 7. 影响与权衡(Trade-offs)
|
||||
|
||||
- 继续采用内存队列:实现简单,但天然不支持多实例并发安全
|
||||
- DB 轮询恢复:可靠性提升,但会带来额外 DB 查询压力
|
||||
|
||||
## 8. 兼容性与迁移(Compatibility & Migration)
|
||||
|
||||
- 文档层面不破坏现有 API
|
||||
- 若引入 status 原子抢占,需要确保旧数据/旧状态兼容(例如对历史 pending 记录仍可恢复)
|
||||
|
||||
## 9. 测试计划(Test Plan)
|
||||
|
||||
- 集成链路:创建 deployment -> 入队 -> 触发执行(可用假 runner)
|
||||
- 重启恢复:插入 pending 记录 -> initialize() -> 任务被 addTask
|
||||
- 重试接口:原记录存在/不存在的分支
|
||||
|
||||
## 10. 发布计划(Rollout Plan)
|
||||
|
||||
- 先补齐文档 + 最小日志规范
|
||||
- 再逐步落地:status 原子抢占 + 轮询间隔 env
|
||||
|
||||
## 11. 备选方案(Alternatives Considered)
|
||||
|
||||
- 引入 Redis 队列(BullMQ 等):更可靠、支持多实例,但复杂度上升
|
||||
- 使用 DB 作为队列(表 + 锁/租约):更可靠,但需要严格的并发控制
|
||||
|
||||
## 12. 风险与开放问题(Risks & Open Questions)
|
||||
|
||||
- Q1:`PipelineRunner` 是否负责更新 Deployment.status?如果没有,状态机应由谁维护?
|
||||
- Q2:服务是否计划多实例部署?如果是,必须补齐“抢占执行”机制
|
||||
|
||||
## 13. 附录(Appendix)
|
||||
|
||||
- 代码:`apps/server/libs/execution-queue.ts`
|
||||
- 控制器:`apps/server/controllers/deployment/index.ts`
|
||||
- Schema:`apps/server/prisma/schema.prisma`
|
||||
@@ -1,166 +0,0 @@
|
||||
---
|
||||
title: 设计文档 0001 - 部署执行队列与重试(基于当前实现)
|
||||
summary: 记录当前 ExecutionQueue 的行为与下一步改进方向,便于团队对齐。
|
||||
owners:
|
||||
- team: backend
|
||||
reviewers:
|
||||
- ops-team
|
||||
status: draft
|
||||
date: 2026-01-03
|
||||
version: 0.1.0
|
||||
related:
|
||||
- code: apps/server/libs/execution-queue.ts
|
||||
- code: apps/server/controllers/deployment/index.ts
|
||||
- schema: apps/server/prisma/schema.prisma
|
||||
---
|
||||
|
||||
# 设计文档 0001:部署执行队列与重试
|
||||
|
||||
## 1. 背景(Context)
|
||||
|
||||
当前服务端在启动时会初始化执行队列(`ExecutionQueue.initialize()`),用于从数据库恢复 `status=pending` 的部署任务并按顺序执行流水线。
|
||||
|
||||
现有相关事实(来自当前代码):
|
||||
|
||||
- 服务启动入口:`apps/server/app.ts`
|
||||
- 路由:`/api/deployments`(创建部署后入队)与 `/api/deployments/:id/retry`(复制记录后入队)
|
||||
- 数据库:SQLite + Prisma,Deployment 存在 `status` 字段(注释标明:pending/running/success/failed/cancelled)
|
||||
- 队列实现:内存队列 `pendingQueue` + `runningDeployments` Set,并有轮询机制(默认 30 秒)把 DB 中的 pending 任务补进队列
|
||||
|
||||
## 2. 目标(Goals)
|
||||
|
||||
- [ ] 明确“部署任务”从创建到执行的状态流转与约束
|
||||
- [ ] 明确重试语义(retry 会创建新 deployment,而不是复用原记录)
|
||||
- [ ] 降低重复入队/重复执行的风险(幂等与并发边界清晰)
|
||||
- [ ] 让运维/排障更容易:关键日志点与可观测性清单
|
||||
|
||||
## 3. 非目标(Non-goals)
|
||||
|
||||
- 不引入外部消息队列(如 Redis/Kafka),仍以当前内存队列 + DB 恢复为基础
|
||||
- 不在本文直接实现大规模调度/分布式 runner(后续 ADR/设计再做)
|
||||
|
||||
## 4. 需求与范围(Requirements & Scope)
|
||||
|
||||
### 功能需求
|
||||
|
||||
- 创建部署:写入一条 Deployment 记录,初始状态为 `pending`,并加入执行队列
|
||||
- 重试部署:通过 `/deployments/:id/retry` 创建一条新的 Deployment 记录(复制必要字段),并加入执行队列
|
||||
- 服务重启恢复:服务启动后能从 DB 找回仍为 `pending` 的任务并继续执行
|
||||
|
||||
### 非功能需求
|
||||
|
||||
- 可靠性:服务重启不会“丢任务”(pending 的任务能恢复)
|
||||
- 幂等性:避免同一个 deployment 被重复执行
|
||||
- 可观测性:能定位某次部署为何失败、何时开始/结束、队列长度
|
||||
|
||||
## 5. 方案概览(High-level Design)
|
||||
|
||||
当前方案核心链路如下:
|
||||
|
||||
1) API 创建 Deployment(status=pending)
|
||||
2) `ExecutionQueue.addTask(deploymentId, pipelineId)` 入队
|
||||
3) `ExecutionQueue.processQueue()` 串行消费 `pendingQueue`
|
||||
4) `executePipeline()` 会读取 Deployment 与关联 Project,获取 `projectDir`,然后创建 `PipelineRunner` 执行
|
||||
5) 定时轮询:每 30 秒扫描 DB 中的 pending 任务,若不在 `runningDeployments` 集合则补入队列
|
||||
|
||||
## 6. 详细设计(Detailed Design)
|
||||
|
||||
### 6.1 接口/API 设计
|
||||
|
||||
#### 创建部署
|
||||
|
||||
- POST `/api/deployments`
|
||||
- 行为:创建 Deployment 后立即入队
|
||||
|
||||
#### 重试部署
|
||||
|
||||
- POST `/api/deployments/:id/retry`
|
||||
- 行为:读取原 deployment -> 创建新 deployment(status=pending)-> 入队
|
||||
- 语义:重试是“创建一个新的任务实例”,便于保留历史执行记录
|
||||
|
||||
### 6.2 数据模型/数据库
|
||||
|
||||
Deployment 当前关键字段(见 schema 注释):
|
||||
|
||||
- `status`: pending/running/success/failed/cancelled(目前入队依赖 pending)
|
||||
- `buildLog`: 执行日志(当前创建时写空字符串)
|
||||
- `startedAt`/`finishedAt`: 时间标记(目前 created 时 startedAt 默认 now)
|
||||
|
||||
建议补齐/明确(文档层面约束,代码后续落地):
|
||||
|
||||
- 状态迁移:
|
||||
- `pending` -> `running`(开始执行前)
|
||||
- `running` -> `success|failed|cancelled`(结束后)
|
||||
- 幂等控制:
|
||||
- 以 deploymentId 为“单次执行唯一标识”,同一个 deploymentId 不允许重复开始执行
|
||||
|
||||
### 6.3 队列/轮询/并发
|
||||
|
||||
现状:
|
||||
|
||||
- `runningDeployments` 同时承担“已入队/执行中”的去重集合
|
||||
- `pendingQueue` 为内存 FIFO
|
||||
- 单实例串行消费(`processQueue` while 循环)
|
||||
- 轮询间隔常量 30 秒
|
||||
|
||||
风险点(需要在文档中明确约束/后续逐步修正):
|
||||
|
||||
- 多实例部署:如果将来启动多个 server 实例,每个实例都可能轮询到同一条 pending 记录并执行(需要 DB 锁/租约/状态原子更新)
|
||||
- 状态更新缺口:当前 `ExecutionQueue` 代码中没有看到明确把 status 从 pending 改成 running/failed/success 的逻辑(可能在 `PipelineRunner` 内处理;若没有,需要补齐)
|
||||
|
||||
建议(不改变整体架构前提):
|
||||
|
||||
- 将轮询间隔改为可配置 env:`EXECUTION_POLL_INTERVAL_MS`(默认 30000)
|
||||
- 在真正执行前做一次 DB 原子“抢占”:仅当 status=pending 时更新为 running(并记录开始时间);更新失败则放弃执行
|
||||
|
||||
### 6.4 可观测性
|
||||
|
||||
最低要求(建议后续落地到代码/日志规范):
|
||||
|
||||
- 日志字段:deploymentId、pipelineId、projectId、projectDir、status
|
||||
- 队列指标:pendingQueue length、runningDeployments size
|
||||
- 失败记录:捕获异常 message/stack(避免泄露敏感信息)
|
||||
|
||||
### 6.5 安全与权限
|
||||
|
||||
当前接口层面需要确认:
|
||||
|
||||
- `/api/deployments` 与 `/api/deployments/:id/retry` 是否需要登录/鉴权(取决于 middleware 配置)
|
||||
- 若需要鉴权:建议限制为有项目权限的用户才能创建/重试部署
|
||||
|
||||
## 7. 影响与权衡(Trade-offs)
|
||||
|
||||
- 继续采用内存队列:实现简单,但天然不支持多实例并发安全
|
||||
- DB 轮询恢复:可靠性提升,但会带来额外 DB 查询压力
|
||||
|
||||
## 8. 兼容性与迁移(Compatibility & Migration)
|
||||
|
||||
- 文档层面不破坏现有 API
|
||||
- 若引入 status 原子抢占,需要确保旧数据/旧状态兼容(例如对历史 pending 记录仍可恢复)
|
||||
|
||||
## 9. 测试计划(Test Plan)
|
||||
|
||||
- 集成链路:创建 deployment -> 入队 -> 触发执行(可用假 runner)
|
||||
- 重启恢复:插入 pending 记录 -> initialize() -> 任务被 addTask
|
||||
- 重试接口:原记录存在/不存在的分支
|
||||
|
||||
## 10. 发布计划(Rollout Plan)
|
||||
|
||||
- 先补齐文档 + 最小日志规范
|
||||
- 再逐步落地:status 原子抢占 + 轮询间隔 env
|
||||
|
||||
## 11. 备选方案(Alternatives Considered)
|
||||
|
||||
- 引入 Redis 队列(BullMQ 等):更可靠、支持多实例,但复杂度上升
|
||||
- 使用 DB 作为队列(表 + 锁/租约):更可靠,但需要严格的并发控制
|
||||
|
||||
## 12. 风险与开放问题(Risks & Open Questions)
|
||||
|
||||
- Q1:`PipelineRunner` 是否负责更新 Deployment.status?如果没有,状态机应由谁维护?
|
||||
- Q2:服务是否计划多实例部署?如果是,必须补齐“抢占执行”机制
|
||||
|
||||
## 13. 附录(Appendix)
|
||||
|
||||
- 代码:`apps/server/libs/execution-queue.ts`
|
||||
- 控制器:`apps/server/controllers/deployment/index.ts`
|
||||
- Schema:`apps/server/prisma/schema.prisma`
|
||||
@@ -1,127 +0,0 @@
|
||||
---
|
||||
title: 设计文档 0005 - 部署流程重构(移除稀疏检出 & 环境预设)
|
||||
summary: 调整部署相关能力:移除稀疏检出;将部署环境从创建时输入改为在项目设置中预设。
|
||||
owners:
|
||||
- team: backend
|
||||
reviewers:
|
||||
- team: frontend
|
||||
status: draft
|
||||
date: 2026-01-03
|
||||
version: 0.1.0
|
||||
related:
|
||||
- docs: docs/api/endpoints.md
|
||||
- schema: apps/server/prisma/schema.prisma
|
||||
---
|
||||
|
||||
# 设计文档 0005:部署流程重构(移除稀疏检出 & 环境预设)
|
||||
|
||||
## 1. 背景(Context)
|
||||
|
||||
当前部署流程在“项目详情页发起部署”时包含“稀疏检出(sparse checkout)”表单项,并且流水线模板中也包含与稀疏检出相关的逻辑。
|
||||
|
||||
另外,部署时需要指定环境变量(例如 env),但目前是在“创建部署”时临时输入/选择。随着项目数量增加,这种方式容易造成不一致与误操作。
|
||||
|
||||
## 2. 目标(Goals)
|
||||
|
||||
- [ ] 移除项目详情页部署表单中的“稀疏检出”相关输入项
|
||||
- [ ] 移除流水线模板中与稀疏检出相关的代码逻辑(后端模板/生成逻辑)
|
||||
- [ ] 将“部署环境(env)”从创建部署时指定,调整为在“项目设置”中提前预设
|
||||
- [ ] 创建部署时仍需要选择/指定环境,但选项来源于项目设置中的预设项
|
||||
|
||||
## 3. 非目标(Non-goals)
|
||||
|
||||
- 不新增多维度环境变量管理(仅覆盖本次提到的 env 单项预设)
|
||||
- 不在本次引入复杂的环境权限、审批流
|
||||
|
||||
## 4. 需求与范围(Requirements & Scope)
|
||||
|
||||
### 4.1 移除稀疏检出
|
||||
|
||||
#### 用户侧
|
||||
|
||||
- 项目详情页发起部署时:不再展示/提交稀疏检出字段
|
||||
|
||||
#### 系统侧
|
||||
|
||||
- 流水线模板:移除任何基于稀疏检出路径的生成/执行逻辑
|
||||
|
||||
> 说明:当前 DB 中 Deployment 仍存在 `sparseCheckoutPaths` 字段(见 `schema.prisma`),本次需求仅明确“功能不再需要”。字段是否删除/迁移由本设计后续章节确定。
|
||||
|
||||
### 4.2 部署环境 env 改为项目设置预设
|
||||
|
||||
#### 核心约束
|
||||
|
||||
- 环境变量预设需要支持多选、单选、输入框这几种类型
|
||||
- 在项目设置中新增可配置项(预设项):
|
||||
例如:指定env 环境变量
|
||||
- 类型:单选(single select)
|
||||
- key:`env`,value 及时部署是选中的候选项的值
|
||||
- options:`staging`(测试环境)、`production`(生产环境)
|
||||
|
||||
#### 行为
|
||||
|
||||
- 创建部署时仍需指定环境(env),但:
|
||||
- 不再由用户自由输入
|
||||
- 只允许从该项目预设的 options 中选择
|
||||
|
||||
## 5. 影响面(Impact)
|
||||
|
||||
### 5.1 前端
|
||||
|
||||
- 项目详情页部署表单:移除“稀疏检出”相关 UI 与字段提交
|
||||
- 项目设置页:新增“环境预设(env)”配置入口(单选 + 选项 staging/production)
|
||||
- 创建部署交互:环境选项从项目设置读取(不再硬编码/临时输入)
|
||||
|
||||
### 5.2 后端
|
||||
|
||||
- 部署创建接口:校验 env 必须来自项目预设(避免非法 env)
|
||||
- 流水线模板:移除稀疏检出相关的模板字段/生成逻辑
|
||||
|
||||
### 5.3 数据库
|
||||
|
||||
- 需要新增“项目设置/项目配置”承载 env 预设(落库方案待定)
|
||||
- 既有 Deployment 的 `sparseCheckoutPaths` 字段:后续决定是否保留(兼容历史)或迁移删除
|
||||
|
||||
## 6. 兼容性与迁移(Compatibility & Migration)
|
||||
|
||||
- 对历史部署记录:
|
||||
- 若存在 `sparseCheckoutPaths`,不影响查询展示,但新建部署不再写入该字段
|
||||
- 对创建部署:
|
||||
- 若项目未配置 env 预设:创建部署应失败并提示先到项目设置配置(或提供默认值策略,待确认)
|
||||
|
||||
## 7. 测试要点(Test Plan)
|
||||
|
||||
- 前端:
|
||||
- 项目详情页部署表单不再出现稀疏检出项
|
||||
- 项目设置可保存 env 预设(单选)并在创建部署时正确展示
|
||||
- 后端:
|
||||
- 创建部署:env 不在项目预设 options 内时应拒绝
|
||||
- 流水线模板:移除稀疏检出后仍能正常创建并执行
|
||||
|
||||
## 8. 实施状态(Implementation Status)
|
||||
|
||||
### 已完成(后端)
|
||||
|
||||
- ✅ Prisma Schema:在 Project 表添加 `envPresets` 字段(String? 类型,存储 JSON)
|
||||
- ✅ 移除部署创建/重试接口中的 `sparseCheckoutPaths` 写入
|
||||
- ✅ 在部署创建接口添加环境校验:验证 env 是否在项目 envPresets 的 options 中
|
||||
- ✅ 更新 project DTO 和 controller 支持 envPresets 读写
|
||||
- ✅ 移除 pipeline-runner 中的 `SPARSE_CHECKOUT_PATHS` 环境变量
|
||||
- ✅ 生成 Prisma Client
|
||||
|
||||
### 已完成(前端)
|
||||
|
||||
- ✅ 创建 EnvPresetsEditor 组件(支持单选、多选、输入框类型)
|
||||
- ✅ 在 CreateProjectModal 和 EditProjectModal 中集成环境预设编辑器
|
||||
- ✅ 从 DeployModal 移除稀疏检出表单项
|
||||
- ✅ 在 DeployModal 中从项目 envPresets 读取环境选项并展示
|
||||
- ✅ 移除 DeployModal 中的动态环境变量列表(envVars Form.List)
|
||||
- ✅ 从类型定义中移除 sparseCheckoutPaths 字段
|
||||
- ✅ 在项目详情页项目设置 tab 中添加环境变量预设的查看和编辑功能
|
||||
|
||||
### 待定问题
|
||||
|
||||
- Q1:项目设置存储方式 → **已决定**:使用 Project.envPresets JSON 字段
|
||||
- Q2:未配置 env 预设的默认行为 → **已实现**:若配置了预设则校验,否则允许任意值(向后兼容)
|
||||
- Q3:Deployment.sparseCheckoutPaths 字段 → **已决定**:保留字段(兼容历史),但新建部署不再写入
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
所有 notable 更改应在此记录。遵循 Keep a Changelog 格式。
|
||||
|
||||
## [Unreleased]
|
||||
- 初始文档目录建立。
|
||||
|
||||
## [1.0.0] - 2026-01-03
|
||||
- 初始发布
|
||||
20
docs/constraints.md
Normal file
20
docs/constraints.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 约束与禁区
|
||||
|
||||
## 1. 路由规范
|
||||
|
||||
- **禁止** 在 `app.ts` 中手动编写 `router.get/post`。
|
||||
- **必须** 使用 `@Controller` 和 `@Get/Post` 装饰器,并放在 `controllers/` 目录下。
|
||||
|
||||
## 2. 数据库安全
|
||||
|
||||
- **禁止** 绕过 Prisma 直接操作数据库文件。
|
||||
- **禁止** 在生产环境中手动修改 `dev.db`。
|
||||
|
||||
## 3. 环境变量
|
||||
|
||||
- **禁区**: 严禁将敏感 Token 或密钥直接硬编码在代码或 `schema.prisma` 中。
|
||||
- 请使用 `.env` 文件配合 `dotenv` 加载。
|
||||
|
||||
## 4. 依赖管理
|
||||
|
||||
- **禁止** 混合使用 npm/yarn,必须统一使用 `pnpm`。
|
||||
86
docs/conventions.md
Normal file
86
docs/conventions.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 编码规范
|
||||
|
||||
## 1. 命名习惯
|
||||
|
||||
- **前端组件**: PascalCase (如 `ProjectCard.tsx`)。
|
||||
- **后端文件**: kebab-case (如 `route-scanner.ts`)。
|
||||
- **DTO**: 文件名为 `dto.ts`,类名为 `XxxDTO`。
|
||||
|
||||
## 2. 代码组织
|
||||
|
||||
web 项目代码组织如下:
|
||||
|
||||
```yaml
|
||||
├── web/
|
||||
│ ├── pages/
|
||||
│ │ ├── components/ # 页面组件
|
||||
│ │ ├── index.tsx # 页面入口组件
|
||||
│ │ ├── service.ts # 当前页面使用到的 api 请求方法和纯函数
|
||||
│ │ ├── types.ts # 当前页面使用到的类型定义
|
||||
│ │ └── ...
|
||||
│ └── hooks/ # 通用 hooks (不止一个组件引用)
|
||||
│ └── stores/ # 全局状态
|
||||
│ └── utils/ # 通用工具类
|
||||
│ └── styles/ # 全局样式
|
||||
│ └── assets/ # 静态资源
|
||||
│ └── components/ # 通用组件
|
||||
│ └── types/ # 全局类型定义
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
## 3. 代码规范
|
||||
|
||||
- 注释符合 jsdoc 规范
|
||||
- 代码简洁,避免冗余,移除无用的代码引用、变量、函数和css样式
|
||||
- 禁止使用 any 类型
|
||||
|
||||
## 4. 前端发送net请求示例
|
||||
|
||||
- 分页
|
||||
|
||||
```typescript
|
||||
import {net} from '@utils';
|
||||
import type {APIPagination} from '@utils/net';
|
||||
|
||||
const data = await net.request<APIPagination<Deployment>>({
|
||||
method: 'GET',
|
||||
url: '/api/deployments',
|
||||
// 注意:查询参数使用 params 传递,不要手动拼接到 url 上
|
||||
params: {
|
||||
projectId: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- 其他
|
||||
|
||||
```typescript
|
||||
import {net} from '@utils';
|
||||
|
||||
const data = await net.request<void>({
|
||||
method: 'POST',
|
||||
url: '/api/deployment',
|
||||
data: {
|
||||
name: 'xxx',
|
||||
description: 'xxx',
|
||||
repository: 'https://a.com',
|
||||
}
|
||||
})
|
||||
if (data.code === 0) {
|
||||
console.log("创建成功")
|
||||
} else {
|
||||
console.log("创建失败")
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 异步处理
|
||||
|
||||
- 统一使用 `async/await`。
|
||||
- 后端错误通过抛出异常由 `exception.ts` 中间件统一捕获。
|
||||
|
||||
## 6. 格式化
|
||||
|
||||
- 使用 Biome 进行 Lint 和 Format。
|
||||
- 提交代码前建议运行 `pnpm --filter web format`。
|
||||
17
docs/decisions/0001-tech-stack.md
Normal file
17
docs/decisions/0001-tech-stack.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# ADR 0001: 技术选型
|
||||
|
||||
## 背景
|
||||
|
||||
需要构建一个轻量级、易扩展且易于本地部署的 CI 系统。
|
||||
|
||||
## 决策
|
||||
|
||||
- **语言**: 全栈 TypeScript,确保模型定义在前后端的一致性。
|
||||
- **后端框架**: Koa。相比 Express 更加轻量,利用 async/await 处理异步中间件更优雅。
|
||||
- **数据库**: SQLite。CI 系统通常是单机或小规模使用,SQLite 无需独立服务,运维成本极低。
|
||||
- **执行工具**: `zx`。相比原生的 `child_process`,`zx` 处理 Shell 交互更加直观和安全。
|
||||
|
||||
## 后果
|
||||
|
||||
- 优势:开发效率极高,部署简单。
|
||||
- 挑战:SQLite 在极高并发写入(如数百个任务同时输出日志)时可能存在性能瓶颈。
|
||||
18
docs/decisions/0002-state.md
Normal file
18
docs/decisions/0002-state.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# ADR 0002: 状态管理
|
||||
|
||||
## 背景
|
||||
|
||||
需要在前端管理用户信息、全局配置以及各页面的复杂 UI 状态。
|
||||
|
||||
## 决策
|
||||
|
||||
- **全局状态**: 使用 Zustand。
|
||||
- **理由**:
|
||||
- 相比 Redux 模板代码极少。
|
||||
- 相比 Context API 性能更好且不引起全量重绘。
|
||||
- 符合 React 19 的 Concurrent 模式。
|
||||
- **持久化**: 对关键状态(如 Token)使用 Zustand 的 persist 中间件。
|
||||
|
||||
## 后果
|
||||
|
||||
状态管理逻辑高度内聚在 `apps/web/src/stores` 中。
|
||||
15
docs/decisions/0003-pipeline-execution.md
Normal file
15
docs/decisions/0003-pipeline-execution.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# ADR 0003: 流水线执行策略
|
||||
|
||||
## 背景
|
||||
|
||||
如何确保流水线执行的隔离性与可靠性。
|
||||
|
||||
## 决策
|
||||
|
||||
- **工作目录**: 每个项目在服务器上拥有独立的 `projectDir`。
|
||||
- **执行器**: 采用线性执行。目前不支持多步骤并行,以确保日志顺序的确定性。
|
||||
- **队列**: 使用内存队列 + 数据库扫描实现。系统重启后能通过数据库中的 `pending` 状态恢复任务。
|
||||
|
||||
## 后果
|
||||
|
||||
目前的隔离级别为目录级。未来可能需要引入 Docker 容器化执行以增强安全性。
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
title: 开发与本地环境搭建
|
||||
summary: 针对本项目的本地开发、数据库与调试指南。
|
||||
owners:
|
||||
- team: backend
|
||||
status: stable
|
||||
---
|
||||
|
||||
# 开发与本地环境搭建
|
||||
|
||||
## 1. 安装依赖
|
||||
|
||||
建议使用 `pnpm` 管理工作区依赖:
|
||||
|
||||
```bash
|
||||
# 在仓库根
|
||||
pnpm install
|
||||
```
|
||||
|
||||
或者只在 server 子包安装:
|
||||
|
||||
```bash
|
||||
cd apps/server
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## 2. 生成 Prisma Client
|
||||
|
||||
```bash
|
||||
cd apps/server
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
如果需要执行迁移(开发场景):
|
||||
|
||||
```bash
|
||||
npx prisma migrate dev --name init
|
||||
```
|
||||
|
||||
数据库:项目使用 SQLite(见 `apps/server/prisma/schema.prisma`),迁移会在本地创建 `.db` 文件。
|
||||
|
||||
## 3. 启动服务
|
||||
|
||||
并行启动 workspace 中所有 dev 脚本(推荐):
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
或单独启动 server:
|
||||
|
||||
```bash
|
||||
cd apps/server
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
服务默认端口:`3001`。如需修改:
|
||||
|
||||
```bash
|
||||
PORT=4000 pnpm dev
|
||||
```
|
||||
|
||||
## 4. 常见开发命令
|
||||
|
||||
- 运行测试脚本(仓库自带):
|
||||
|
||||
```bash
|
||||
cd apps/server
|
||||
node test.js
|
||||
```
|
||||
|
||||
- TypeScript 类型检查(本地可使用 `tsc`):
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
## 5. 环境变量与第三方集成
|
||||
|
||||
常见 env:`GITEA_URL`, `GITEA_CLIENT_ID`, `GITEA_REDIRECT_URI`, `PORT`, `NODE_ENV`。
|
||||
|
||||
登录采用 Gitea OAuth,未配置时某些 auth 接口会返回 401,需要先登录获取 session。
|
||||
|
||||
## 6. 运行与调试要点
|
||||
|
||||
- 代码通过装饰器注册路由(见 `apps/server/decorators/route.ts` 与 `apps/server/libs/route-scanner.ts`)。
|
||||
- Prisma client 生成路径:`apps/server/generated`。
|
||||
- 若变更 Prisma schema,请执行 `prisma generate` 并更新迁移。
|
||||
@@ -1,79 +0,0 @@
|
||||
---
|
||||
title: 快速开始
|
||||
summary: 本文档介绍如何在本地启动与运行项目的基础步骤。
|
||||
tags: [getting-started]
|
||||
owners:
|
||||
- team: backend
|
||||
status: stable
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# 快速开始
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Node.js >= 18
|
||||
- pnpm
|
||||
- 克隆权限(或访问仓库)
|
||||
|
||||
## 克隆与依赖安装
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd foka-ci
|
||||
pnpm install
|
||||
```
|
||||
|
||||
说明:仓库使用 pnpm workspace,根目录脚本 `pnpm dev` 会并行启动工作区内的 `dev` 脚本。
|
||||
|
||||
## 运行服务(开发)
|
||||
|
||||
- 从仓库根(并行运行所有 dev 脚本):
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- 单独运行 server:
|
||||
|
||||
```bash
|
||||
cd apps/server
|
||||
pnpm install
|
||||
pnpm dev # 等同于: tsx watch ./app.ts
|
||||
```
|
||||
|
||||
服务器默认监听端口 `3001`(可通过 `PORT` 环境变量覆盖)。API 前缀为 `/api`。
|
||||
|
||||
## Prisma 与数据库
|
||||
|
||||
项目使用 SQLite(见 `apps/server/prisma/schema.prisma`)。如果需要生成 Prisma Client 或运行迁移:
|
||||
|
||||
```bash
|
||||
cd apps/server
|
||||
npx prisma generate
|
||||
npx prisma migrate dev --name init # 本地开发使用
|
||||
```
|
||||
|
||||
生成的 Prisma Client 位于 `apps/server/generated`(由 schema 中的 generator 指定)。
|
||||
|
||||
## 环境变量(常用)
|
||||
|
||||
- `GITEA_URL`、`GITEA_CLIENT_ID`、`GITEA_REDIRECT_URI`:用于 OAuth 登录(Gitea)。
|
||||
- `PORT`:服务监听端口,默认 `3001`。
|
||||
- `NODE_ENV`:环境(development/production)。
|
||||
|
||||
将敏感值放入 `.env`(不要将 `.env` 提交到仓库)。
|
||||
|
||||
## 运行脚本与测试
|
||||
|
||||
```bash
|
||||
cd apps/server
|
||||
node test.js # 运行仓库自带的简单测试脚本
|
||||
```
|
||||
|
||||
## 其他说明
|
||||
|
||||
- 文档目录位于 `docs/`,设计模板在 `docs/.meta/templates/`。
|
||||
- API 路由由装饰器注册,路由前缀为 `/api`,在 `apps/server/middlewares/router.ts` 中可查看。
|
||||
|
||||
更多开发细节请参见 `docs/development/setup.md` 与 `docs/api/endpoints.md`。
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
title: 项目文档总览
|
||||
summary: 项目文档的导航页,指向快速开始、架构、API、运维等部分。
|
||||
tags: [overview]
|
||||
owners:
|
||||
- team: backend
|
||||
status: stable
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# 文档总览
|
||||
|
||||
欢迎来到项目文档。下列是常用文档的入口:
|
||||
|
||||
- Getting Started: ./getting-started.md
|
||||
- Architecture: ./architecture/adr-0001-service-design.md
|
||||
- API: ./api/README.md
|
||||
- Runbooks: ./runbooks/incident-response.md
|
||||
- Onboarding: ./onboarding/new-hire.md
|
||||
- Changelog: ./changelogs/CHANGELOG.md
|
||||
|
||||
维护说明:请在变更同时更新 `owners` 与 `version`,并通过 PR 提交。
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
title: 新成员入职指南
|
||||
owners:
|
||||
- people-team
|
||||
status: draft
|
||||
---
|
||||
|
||||
# 新成员入职快速清单
|
||||
|
||||
1. 获取公司邮箱与账号
|
||||
2. 克隆仓库并运行 `pnpm install`
|
||||
3. 阅读 `docs/getting-started.md` 与 `docs/architecture` 中关键 ADR
|
||||
4. 约见导师并完成第一次 code walkthrough
|
||||
21
docs/pitfalls.md
Normal file
21
docs/pitfalls.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 踩坑指南 (Pitfalls)
|
||||
|
||||
## 1. Prisma 客户端生成
|
||||
|
||||
- **现象**: 修改 `schema.prisma` 后代码报错找不到类型。
|
||||
- **解决**: 需要在 `apps/server` 下运行 `pnpm prisma generate`。本项目将生成的代码放在了 `generated/` 目录而非 node_modules,请注意引用路径。
|
||||
|
||||
## 2. zx 环境变量继承
|
||||
|
||||
- **现象**: 在流水线脚本中找不到 `node` 或 `git` 命令。
|
||||
- **解决**: `PipelineRunner` 在调用 `zx` 时必须手动扩展 `env: { ...process.env, ...userEnv }`,否则会丢失系统 PATH。
|
||||
|
||||
## 3. Koa BodyParser 顺序
|
||||
|
||||
- **现象**: 获取不到 `ctx.request.body`。
|
||||
- **解决**: `koa-bodyparser` 中间件必须在 `router` 中间件之前注册。
|
||||
|
||||
## 4. SQLite 并发写入
|
||||
|
||||
- **现象**: 部署日志极快输出时偶发 `SQLITE_BUSY`。
|
||||
- **解决**: 适当增加 `better-sqlite3` 的 busy timeout。
|
||||
33
docs/requirements/0001-Fix-Response-structure.md
Normal file
33
docs/requirements/0001-Fix-Response-structure.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 标准化响应结构
|
||||
|
||||
1. 需要标准化接口响应的结构,修改后端接口中不符合规范的代码。
|
||||
2. 前端接口类型定义需要与后端接口响应结构保持一致。
|
||||
|
||||
## 响应示例
|
||||
|
||||
- 列表分页响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0, // 响应状态码,0 表示成功,其他值表示失败
|
||||
"message": "success", // 响应消息
|
||||
"data": { // 响应数据
|
||||
"list": [], // 列表数据
|
||||
"page": 1, // 当前页码
|
||||
"pageSize": 10, // 每页显示数量
|
||||
"total": 10 // 总数量
|
||||
},
|
||||
"timestamp": "12346579" // 响应时间戳
|
||||
}
|
||||
```
|
||||
|
||||
- 其他响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {},
|
||||
"timestamp": "12346579"
|
||||
}
|
||||
```
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
title: 事故响应 Runbook
|
||||
summary: 处理生产事故的步骤和联系方式。
|
||||
owners:
|
||||
- ops-team
|
||||
status: stable
|
||||
---
|
||||
|
||||
# 事故响应(Incident Response)
|
||||
|
||||
## 1. 识别与分级
|
||||
|
||||
- P0: 系统不可用或数据安全泄露
|
||||
- P1: 主要功能严重受损
|
||||
|
||||
## 2. 通知
|
||||
|
||||
- 联系人:`on-call@company.example`,电话:+86-10-12345678
|
||||
|
||||
## 3. 暂时性缓解
|
||||
|
||||
- 回滚最近的部署
|
||||
- 启用备用服务
|
||||
|
||||
## 4. 根因分析与恢复
|
||||
|
||||
- 记录时间线
|
||||
- 生成 RCA 并在 72 小时内发布
|
||||
23
docs/status.md
Normal file
23
docs/status.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 当前进度 (Status)
|
||||
|
||||
## 已完成 ✅
|
||||
|
||||
- 基础 Monorepo 框架搭设
|
||||
- TC39 装饰器路由系统
|
||||
- 项目管理 (CRUD)
|
||||
- 基础流水线执行流程 (Git Clone -> zx Run -> Log Update)
|
||||
- 前端项目列表与详情页预览
|
||||
- 优化: 移除菜单环境管理及页面(目前无用)
|
||||
- 优化:移除创建 Step 弹窗可用环境变量区域
|
||||
- 优化: 部署记录的分页查询每页 10 条
|
||||
|
||||
## 进行中 🚧
|
||||
|
||||
- [标准化接口响应结构](./requirements/0001-Fix-Response-structure.md)
|
||||
|
||||
## 待办 📅
|
||||
|
||||
- [ ] Gitea Webhook 自动触发
|
||||
- [ ] 用户权限管理 (RBAC)
|
||||
- [ ] 修复: 表单必填项,*号和 label 不在一行
|
||||
- [ ] 修复:项目详情页,未选中 tab【部署记录】还会拉取日志信息
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "ark-ci",
|
||||
"name": "MiniCI",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user