paginate app operations and refine runtime settings layout
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-07 15:33:08 +09:00
parent 895e56088c
commit 5be7f0980b
8 changed files with 512 additions and 34 deletions
+2
View File
@@ -13,6 +13,7 @@ import { LauncherModule } from './launcher/launcher.module';
import { LicensesModule } from './licenses/licenses.module';
import { NoticesModule } from './notices/notices.module';
import { OrganizationsModule } from './organizations/organizations.module';
import { PageAgentModule } from './page-agent/page-agent.module';
import { PrismaModule } from './prisma/prisma.module';
import { RuntimeModule } from './runtime/runtime.module';
import { UsersModule } from './users/users.module';
@@ -39,6 +40,7 @@ import { WoodpeckerModule } from './woodpecker/woodpecker.module';
LicensesModule,
NoticesModule,
OrganizationsModule,
PageAgentModule,
PrismaModule,
RuntimeModule,
UsersModule,
+38 -8
View File
@@ -8,6 +8,7 @@ import {
CreateAppReleaseDto,
CreateBuildJobDto,
CreateDeploymentJobDto,
DecommissionAppDto,
CreateMigrationJobDto,
CreateRuntimeEnvProfileDto,
CreateRuntimeEnvVariableDto,
@@ -35,6 +36,14 @@ export class AppsController {
return this.appsService.deleteApp(appId);
}
@Post(':appId/decommission')
decommissionApp(
@Param('appId') appId: string,
@Body() body: DecommissionAppDto,
) {
return this.appsService.decommissionApp(appId, body);
}
@Post(':appId/components')
createComponent(
@Param('appId') appId: string,
@@ -62,8 +71,14 @@ export class AppsController {
}
@Get('builds')
listBuildJobs(@Query('appKey') appKey?: string) {
return this.appsService.listBuildJobs(appKey);
listBuildJobs(
@Query('appKey') appKey?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('since') since?: string,
@Query('status') status?: string,
) {
return this.appsService.listBuildJobs(appKey, page, limit, since, status);
}
@Post('builds')
@@ -103,8 +118,13 @@ export class AppsController {
}
@Get('releases')
listReleases(@Query('appKey') appKey?: string) {
return this.appsService.listReleases(appKey);
listReleases(
@Query('appKey') appKey?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('since') since?: string,
) {
return this.appsService.listReleases(appKey, page, limit, since);
}
@Post('releases')
@@ -113,8 +133,13 @@ export class AppsController {
}
@Get('deployments')
listDeployments(@Query('appKey') appKey?: string) {
return this.appsService.listDeployments(appKey);
listDeployments(
@Query('appKey') appKey?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('since') since?: string,
) {
return this.appsService.listDeployments(appKey, page, limit, since);
}
@Post('deployments')
@@ -123,8 +148,13 @@ export class AppsController {
}
@Get('migrations')
listMigrationJobs() {
return this.appsService.listMigrationJobs();
listMigrationJobs(
@Query('appId') appId?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('since') since?: string,
) {
return this.appsService.listMigrationJobs(appId, page, limit, since);
}
@Post('migrations')
+380 -26
View File
@@ -17,6 +17,7 @@ import {
CreateAppReleaseDto,
CreateBuildJobDto,
CreateDeploymentJobDto,
DecommissionAppDto,
CreateMigrationJobDto,
CreateRuntimeEnvProfileDto,
CreateRuntimeEnvVariableDto,
@@ -78,6 +79,15 @@ export class AppsService {
}
async deleteApp(appId: string) {
return this.decommissionApp(appId, {
unlinkSources: true,
scaleDownWorkloads: false,
deleteKubernetesResources: false,
deletePersistentVolumes: false,
});
}
async decommissionApp(appId: string, body: DecommissionAppDto = {}) {
const app = await this.prisma.app.findUnique({
where: { id: appId },
include: {
@@ -90,12 +100,46 @@ export class AppsService {
if (!app) throw new NotFoundException('앱을 찾을 수 없습니다.');
const logs: string[] = [`[app] decommission ${app.name}`];
const namespace = app.namespace ? normalizeNamespace(app.namespace) : '';
if (namespace && (body.scaleDownWorkloads || body.deleteKubernetesResources || body.deletePersistentVolumes)) {
const exists = await this.namespaceExists(namespace);
if (!exists) {
logs.push(`[skip] namespace ${namespace} not found`);
} else {
if (body.scaleDownWorkloads) {
await this.scaleDownAppWorkloads(namespace, app.components, logs);
}
if (body.deleteKubernetesResources) {
await this.deleteAppKubernetesResources(namespace, app.components, logs);
}
if (body.deletePersistentVolumes) {
await this.deleteAppPersistentVolumeClaims(namespace, app.components, logs);
}
}
}
if (body.unlinkSources) {
const result = await this.prisma.appComponent.updateMany({
where: { appId },
data: {
repository: null,
branch: null,
buildTarget: null,
},
});
logs.push(`[db] unlinked ${result.count} component source settings`);
}
await this.prisma.app.delete({ where: { id: appId } });
logs.push('[db] app deleted');
return {
deleted: true,
appId,
namespace: app.namespace,
logs: logs.filter(Boolean).join('\n'),
};
}
@@ -250,12 +294,50 @@ export class AppsService {
return this.listComponentBuildEnv(componentId);
}
listReleases(appKey?: string) {
return this.prisma.appRelease.findMany({
async listReleases(appKey?: string, page?: string, limit?: string, since?: string) {
const pagination = parsePagination(page, limit);
const sinceDate = parseSinceDate(since);
const where: Prisma.AppReleaseWhereInput = {
...(appKey ? { app: { key: appKey } } : {}),
...(sinceDate ? { createdAt: { gte: sinceDate } } : {}),
};
if (!pagination && !sinceDate) {
return this.prisma.appRelease.findMany({
where: appKey ? { app: { key: appKey } } : undefined,
orderBy: { createdAt: 'desc' },
include: { app: true },
});
}
const pageNumber = pagination?.page ?? 1;
const pageLimit = pagination?.limit ?? 20;
const [items, total] = await this.prisma.$transaction([
this.prisma.appRelease.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (pageNumber - 1) * pageLimit,
take: pageLimit,
include: { app: true },
}),
this.prisma.appRelease.count({ where }),
]);
const usedReleases = await this.prisma.appRelease.findMany({
where: appKey ? { app: { key: appKey } } : undefined,
orderBy: { createdAt: 'desc' },
include: { app: true },
select: {
artifact: true,
imageTags: true,
},
});
return {
releases: items,
total,
page: pageNumber,
limit: pageLimit,
usedBuildIds: getUsedReleaseBuildIds(usedReleases),
usedValues: getUsedReleaseValues(usedReleases),
};
}
async createRelease(body: CreateAppReleaseDto) {
@@ -666,15 +748,56 @@ export class AppsService {
return data.login;
}
listDeployments(appKey?: string) {
return this.prisma.deploymentJob.findMany({
where: appKey ? { app: { key: appKey } } : undefined,
orderBy: { createdAt: 'desc' },
include: {
app: true,
release: true,
async listDeployments(appKey?: string, page?: string, limit?: string, since?: string) {
const pagination = parsePagination(page, limit);
const sinceDate = parseSinceDate(since);
const where: Prisma.DeploymentJobWhereInput = {
...(appKey ? { app: { key: appKey } } : {}),
...(sinceDate ? { createdAt: { gte: sinceDate } } : {}),
};
const include = {
app: true,
release: true,
};
if (!pagination && !sinceDate) {
return this.prisma.deploymentJob.findMany({
where: appKey ? { app: { key: appKey } } : undefined,
orderBy: { createdAt: 'desc' },
include,
});
}
const pageNumber = pagination?.page ?? 1;
const pageLimit = pagination?.limit ?? 20;
const [items, total] = await this.prisma.$transaction([
this.prisma.deploymentJob.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (pageNumber - 1) * pageLimit,
take: pageLimit,
include,
}),
this.prisma.deploymentJob.count({ where }),
]);
const deployedJobs = await this.prisma.deploymentJob.findMany({
where: {
...(appKey ? { app: { key: appKey } } : {}),
status: 'SUCCEEDED',
releaseId: { not: null },
},
select: { releaseId: true },
});
return {
deployments: items,
total,
page: pageNumber,
limit: pageLimit,
deployedReleaseIds: deployedJobs
.map((job) => job.releaseId)
.filter((releaseId): releaseId is string => Boolean(releaseId)),
};
}
async createDeploymentJob(body: CreateDeploymentJobDto) {
@@ -907,11 +1030,40 @@ export class AppsService {
}
}
listMigrationJobs() {
return this.prisma.runtimeControlJob.findMany({
where: { action: 'MIGRATION' },
orderBy: { createdAt: 'desc' },
});
async listMigrationJobs(appId?: string, page?: string, limit?: string, since?: string) {
const pagination = parsePagination(page, limit);
const sinceDate = parseSinceDate(since);
const where: Prisma.RuntimeControlJobWhereInput = {
action: 'MIGRATION',
...(appId ? { request: { path: ['appId'], equals: appId } } : {}),
...(sinceDate ? { createdAt: { gte: sinceDate } } : {}),
};
if (!pagination && !sinceDate && !appId) {
return this.prisma.runtimeControlJob.findMany({
where: { action: 'MIGRATION' },
orderBy: { createdAt: 'desc' },
});
}
const pageNumber = pagination?.page ?? 1;
const pageLimit = pagination?.limit ?? 20;
const [items, total] = await this.prisma.$transaction([
this.prisma.runtimeControlJob.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (pageNumber - 1) * pageLimit,
take: pageLimit,
}),
this.prisma.runtimeControlJob.count({ where }),
]);
return {
jobs: items,
total,
page: pageNumber,
limit: pageLimit,
};
}
async createMigrationJob(body: CreateMigrationJobDto) {
@@ -1043,18 +1195,52 @@ export class AppsService {
}
}
listBuildJobs(appKey?: string) {
return this.prisma.buildJob.findMany({
where: appKey ? { component: { app: { key: appKey } } } : undefined,
orderBy: { createdAt: 'desc' },
include: {
component: {
include: {
app: true,
},
async listBuildJobs(appKey?: string, page?: string, limit?: string, since?: string, status?: string) {
const pagination = parsePagination(page, limit);
const sinceDate = parseSinceDate(since);
const where: Prisma.BuildJobWhereInput = {
...(appKey ? { component: { app: { key: appKey } } } : {}),
...(sinceDate ? { createdAt: { gte: sinceDate } } : {}),
...(status ? { status: normalizeJobStatus(status) } : {}),
};
const include = {
component: {
include: {
app: true,
},
},
});
};
if (!pagination && !sinceDate) {
return this.prisma.buildJob.findMany({
where: {
...(appKey ? { component: { app: { key: appKey } } } : {}),
...(status ? { status: normalizeJobStatus(status) } : {}),
},
orderBy: { createdAt: 'desc' },
include,
});
}
const pageNumber = pagination?.page ?? 1;
const pageLimit = pagination?.limit ?? 20;
const [items, total] = await this.prisma.$transaction([
this.prisma.buildJob.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (pageNumber - 1) * pageLimit,
take: pageLimit,
include,
}),
this.prisma.buildJob.count({ where }),
]);
return {
jobs: items,
total,
page: pageNumber,
limit: pageLimit,
};
}
listRuntimeProfiles(appId?: string) {
@@ -1716,6 +1902,121 @@ export class AppsService {
}
}
private async scaleDownAppWorkloads(
namespace: string,
components: Array<{ name: string; type: string; workloadType?: string | null }>,
logs: string[],
) {
for (const component of components) {
const name = sanitizeK8sName(component.name);
if (!name) continue;
const workloadType = normalizeWorkloadType(component.workloadType, component.type);
if (workloadType === 'Job') continue;
const resource = workloadType === 'StatefulSet' ? 'statefulset' : 'deployment';
await this.tryKubectl(logs, `[scale] ${resource}/${name} replicas=0`, [
'scale',
`${resource}/${name}`,
'--replicas=0',
'-n',
namespace,
]);
}
}
private async deleteAppKubernetesResources(
namespace: string,
components: Array<{ name: string; type: string; workloadType?: string | null }>,
logs: string[],
) {
for (const component of components) {
const name = sanitizeK8sName(component.name);
if (!name) continue;
const workloadType = normalizeWorkloadType(component.workloadType, component.type);
if (workloadType !== 'Job') {
const workload = workloadType === 'StatefulSet' ? 'statefulset' : 'deployment';
await this.tryKubectl(logs, `[delete] ${workload}/${name}`, [
'delete',
workload,
name,
'-n',
namespace,
'--ignore-not-found=true',
]);
}
for (const resource of ['service', 'ingress', 'configmap', 'secret']) {
await this.tryKubectl(logs, `[delete] ${resource}/${name}`, [
'delete',
resource,
name,
'-n',
namespace,
'--ignore-not-found=true',
]);
}
await this.tryKubectl(logs, `[delete] configmap/${name}-env`, [
'delete',
'configmap',
`${name}-env`,
'-n',
namespace,
'--ignore-not-found=true',
]);
await this.tryKubectl(logs, `[delete] secret/${name}-secret`, [
'delete',
'secret',
`${name}-secret`,
'-n',
namespace,
'--ignore-not-found=true',
]);
}
}
private async deleteAppPersistentVolumeClaims(
namespace: string,
components: Array<{ name: string }>,
logs: string[],
) {
const output = await this.kubectl(['get', 'pvc', '-n', namespace, '-o', 'json']);
const parsed = JSON.parse(output) as { items?: Array<{ metadata?: { name?: string } }> };
const names = new Set(components.map((component) => sanitizeK8sName(component.name)).filter(Boolean));
const claims = (parsed.items ?? [])
.map((item) => item.metadata?.name ?? '')
.filter((claimName) => Array.from(names).some((name) => claimName === name || claimName.startsWith(`data-${name}-`) || claimName.includes(`-${name}-`)));
if (claims.length === 0) {
logs.push('[pvc] matched persistent volume claims not found');
return;
}
for (const claim of claims) {
await this.tryKubectl(logs, `[delete] pvc/${claim}`, [
'delete',
'pvc',
claim,
'-n',
namespace,
'--ignore-not-found=true',
]);
}
}
private async tryKubectl(logs: string[], label: string, args: string[]) {
logs.push(label);
try {
const output = await this.kubectl(args);
if (output.trim()) appendOutput(logs, output);
} catch (error) {
const message = error instanceof Error ? error.message : 'kubectl failed';
logs.push(`[warn] ${label} failed: ${message}`);
}
}
private async applyDatabaseResources(input: {
namespace: string;
appKey: string;
@@ -2675,6 +2976,59 @@ function parseReleaseImageTags(value: Prisma.JsonValue | null) {
return { images };
}
function getUsedReleaseBuildIds(releases: Array<{ imageTags: Prisma.JsonValue | null }>) {
const ids = new Set<string>();
for (const release of releases) {
const value = release.imageTags;
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
const promotion = (value as Record<string, unknown>).promotion;
if (!promotion || typeof promotion !== 'object' || Array.isArray(promotion)) continue;
const sourceBuildJobIds = (promotion as Record<string, unknown>).sourceBuildJobIds;
if (!Array.isArray(sourceBuildJobIds)) continue;
for (const id of sourceBuildJobIds) {
if (typeof id === 'string') ids.add(id);
}
}
return [...ids];
}
function getUsedReleaseValues(releases: Array<{ artifact: string | null; imageTags: Prisma.JsonValue | null }>) {
const values = new Set<string>();
for (const release of releases) {
if (release.artifact) values.add(release.artifact);
const parsed = parseReleaseImageTags(release.imageTags);
for (const image of Object.values(parsed.images)) {
if (image) values.add(image);
}
if (release.imageTags && typeof release.imageTags === 'object' && !Array.isArray(release.imageTags)) {
const artifacts = (release.imageTags as Record<string, unknown>).artifacts;
if (artifacts && typeof artifacts === 'object' && !Array.isArray(artifacts)) {
for (const artifact of Object.values(artifacts)) {
if (typeof artifact === 'string' && artifact) values.add(artifact);
}
}
}
}
return [...values];
}
function normalizeJobStatus(status: string) {
return status.trim().toUpperCase() as Prisma.EnumJobStatusFilter['equals'];
}
function parsePagination(page?: string, limit?: string) {
if (!page && !limit) return null;
const pageNumber = Math.max(1, Number.parseInt(page || '1', 10) || 1);
const pageLimit = Math.min(100, Math.max(1, Number.parseInt(limit || '20', 10) || 20));
return { page: pageNumber, limit: pageLimit };
}
function parseSinceDate(value?: string) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
async function gitRefExists(workDir: string, ref: string, gitBin = 'git') {
try {
+22
View File
@@ -21,6 +21,28 @@ export class CreateAppDto {
status?: AppStatus;
}
export class DecommissionAppDto {
@IsOptional()
@IsBoolean()
unlinkSources?: boolean;
@IsOptional()
@IsBoolean()
deleteRuntimeSettings?: boolean;
@IsOptional()
@IsBoolean()
scaleDownWorkloads?: boolean;
@IsOptional()
@IsBoolean()
deleteKubernetesResources?: boolean;
@IsOptional()
@IsBoolean()
deletePersistentVolumes?: boolean;
}
export class CreateAppComponentDto {
@IsString()
name!: string;
+2
View File
@@ -1,6 +1,7 @@
import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { json } from 'express';
import { AppModule } from './app.module';
@@ -13,6 +14,7 @@ async function bootstrap() {
'http://localhost:5173,http://localhost:5174',
);
app.use(json({ limit: '2mb' }));
app.setGlobalPrefix('api', {
exclude: [
{ path: 'health', method: RequestMethod.GET },
+18
View File
@@ -0,0 +1,18 @@
import { Body, Controller, Post } from '@nestjs/common';
import { PageAgentService } from './page-agent.service';
@Controller('page-agent/llm')
export class PageAgentController {
constructor(private readonly pageAgentService: PageAgentService) {}
@Post()
createCompletion(@Body() body: Record<string, unknown>) {
return this.pageAgentService.createChatCompletion(body);
}
@Post('chat/completions')
createChatCompletion(@Body() body: Record<string, unknown>) {
return this.pageAgentService.createChatCompletion(body);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PageAgentController } from './page-agent.controller';
import { PageAgentService } from './page-agent.service';
@Module({
controllers: [PageAgentController],
providers: [PageAgentService],
})
export class PageAgentModule {}
+40
View File
@@ -0,0 +1,40 @@
import { BadGatewayException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class PageAgentService {
constructor(private readonly configService: ConfigService) {}
async createChatCompletion(body: Record<string, unknown>) {
const apiKey = this.configService.get<string>('OPENAI_API_KEY');
if (!apiKey) {
throw new InternalServerErrorException('OPENAI_API_KEY가 설정되어 있지 않습니다.');
}
const requestBody = {
...body,
model: typeof body.model === 'string' && body.model ? body.model : 'gpt-4.1-mini',
temperature: body.temperature ?? 0,
};
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
const result = await response.json().catch(() => null);
if (!response.ok) {
throw new BadGatewayException({
error: 'LLM request failed',
status: response.status,
detail: result,
});
}
return result;
}
}