From 5be7f0980b14b7c76eaf265c02c9d667b7f34862 Mon Sep 17 00:00:00 2001 From: chyoo Date: Tue, 7 Jul 2026 15:33:08 +0900 Subject: [PATCH] paginate app operations and refine runtime settings layout --- src/app.module.ts | 2 + src/apps/apps.controller.ts | 46 ++- src/apps/apps.service.ts | 406 ++++++++++++++++++++++-- src/apps/dto.ts | 22 ++ src/main.ts | 2 + src/page-agent/page-agent.controller.ts | 18 ++ src/page-agent/page-agent.module.ts | 10 + src/page-agent/page-agent.service.ts | 40 +++ 8 files changed, 512 insertions(+), 34 deletions(-) create mode 100644 src/page-agent/page-agent.controller.ts create mode 100644 src/page-agent/page-agent.module.ts create mode 100644 src/page-agent/page-agent.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 85bede1..7360cea 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/apps/apps.controller.ts b/src/apps/apps.controller.ts index c923739..d098366 100644 --- a/src/apps/apps.controller.ts +++ b/src/apps/apps.controller.ts @@ -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') diff --git a/src/apps/apps.service.ts b/src/apps/apps.service.ts index 41928e1..c3ee9a8 100644 --- a/src/apps/apps.service.ts +++ b/src/apps/apps.service.ts @@ -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(); + for (const release of releases) { + const value = release.imageTags; + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + const promotion = (value as Record).promotion; + if (!promotion || typeof promotion !== 'object' || Array.isArray(promotion)) continue; + const sourceBuildJobIds = (promotion as Record).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(); + 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).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 { diff --git a/src/apps/dto.ts b/src/apps/dto.ts index d7318dc..bcd17a1 100644 --- a/src/apps/dto.ts +++ b/src/apps/dto.ts @@ -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; diff --git a/src/main.ts b/src/main.ts index c476a6f..8248048 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 }, diff --git a/src/page-agent/page-agent.controller.ts b/src/page-agent/page-agent.controller.ts new file mode 100644 index 0000000..c7ccc83 --- /dev/null +++ b/src/page-agent/page-agent.controller.ts @@ -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) { + return this.pageAgentService.createChatCompletion(body); + } + + @Post('chat/completions') + createChatCompletion(@Body() body: Record) { + return this.pageAgentService.createChatCompletion(body); + } +} diff --git a/src/page-agent/page-agent.module.ts b/src/page-agent/page-agent.module.ts new file mode 100644 index 0000000..5a8ab4c --- /dev/null +++ b/src/page-agent/page-agent.module.ts @@ -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 {} diff --git a/src/page-agent/page-agent.service.ts b/src/page-agent/page-agent.service.ts new file mode 100644 index 0000000..0b69b1a --- /dev/null +++ b/src/page-agent/page-agent.service.ts @@ -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) { + const apiKey = this.configService.get('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; + } +}