From 18a3e32c6010e59d842bc4035f087563ff4ee380 Mon Sep 17 00:00:00 2001 From: A1ex Date: Tue, 19 May 2026 20:18:31 -0400 Subject: [PATCH] feat(clusters): add bulk start/stop/restart routes for all clusters --- .../src/clusters/clusters.controller.ts | 34 +++++++++++++++++ .../backend/src/clusters/clusters.service.ts | 37 +++++++++++++++++++ .../dto/cluster-bulk-action-result.dto.ts | 22 +++++++++++ 3 files changed, 93 insertions(+) create mode 100644 packages/backend/src/clusters/dto/cluster-bulk-action-result.dto.ts diff --git a/packages/backend/src/clusters/clusters.controller.ts b/packages/backend/src/clusters/clusters.controller.ts index a99e2e9..96e5b80 100644 --- a/packages/backend/src/clusters/clusters.controller.ts +++ b/packages/backend/src/clusters/clusters.controller.ts @@ -29,6 +29,7 @@ import { Observable, timer, exhaustMap, map, from } from 'rxjs'; import { AuthGuard } from '../auth/guards/jwt.guard.js'; import { ClustersService } from './clusters.service.js'; +import { ClusterBulkActionResultZodDto } from './dto/cluster-bulk-action-result.dto.js'; import { GetAggregateStatsZodDto } from './dto/get-aggregate-stats.dto.js'; import { GetClusterLogsQueryZodDto, GetClusterLogsZodDto } from './dto/get-cluster-logs.dto.js'; import { GetClusterStatsZodDto } from './dto/get-cluster-stats.dto.js'; @@ -69,6 +70,39 @@ export class ClustersController { ); } + @Post('start') + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ + type: ClusterBulkActionResultZodDto, + description: + 'Start every cluster in parallel. Already-running clusters are no-ops. The response lists clusters that succeeded and those that failed, with the failure reason.', + }) + async startAll() { + return await this.clustersService.startAll(); + } + + @Post('stop') + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ + type: ClusterBulkActionResultZodDto, + description: + 'Stop every cluster in parallel. Already-stopped clusters are no-ops. The response lists clusters that succeeded and those that failed, with the failure reason.', + }) + async stopAll() { + return await this.clustersService.stopAll(); + } + + @Post('restart') + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ + type: ClusterBulkActionResultZodDto, + description: + 'Restart every cluster in parallel (stop + start). Stopped clusters are simply started. The response lists clusters that succeeded and those that failed, with the failure reason.', + }) + async restartAll() { + return await this.clustersService.restartAll(); + } + @Sse('stats/stream') @ApiOkResponse({ description: diff --git a/packages/backend/src/clusters/clusters.service.ts b/packages/backend/src/clusters/clusters.service.ts index 776301c..7e8097e 100644 --- a/packages/backend/src/clusters/clusters.service.ts +++ b/packages/backend/src/clusters/clusters.service.ts @@ -6,6 +6,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { DockerService } from '../docker/docker.service.js'; import { PrismaService } from '../prisma/prisma.service.js'; +import { ClusterBulkActionResultDto } from './dto/cluster-bulk-action-result.dto.js'; import { GetAggregateStatsDto } from './dto/get-aggregate-stats.dto.js'; @Injectable() @@ -163,6 +164,42 @@ export class ClustersService { return await this.dockerService.getContainerStats(resource.containerId); } + async startAll(): Promise { + return await this.runBulkAction((id) => this.start(id)); + } + + async stopAll(): Promise { + return await this.runBulkAction((id) => this.stop(id)); + } + + async restartAll(): Promise { + return await this.runBulkAction((id) => this.restart(id)); + } + + private async runBulkAction(action: (id: number) => Promise): Promise { + const clusters = await this.prismaService.cluster.findMany({ + select: { id: true }, + orderBy: { id: 'asc' }, + }); + + const settled = await Promise.allSettled(clusters.map((c) => action(c.id))); + + const succeeded: number[] = []; + const failed: Array<{ id: number; reason: string }> = []; + + settled.forEach((result, index) => { + const id = clusters[index].id; + if (result.status === 'fulfilled') { + succeeded.push(id); + } else { + const reason = result.reason instanceof Error ? result.reason.message : String(result.reason); + failed.push({ id, reason }); + } + }); + + return { succeeded, failed }; + } + async aggregateStats(): Promise { const clusters = await this.prismaService.cluster.findMany({ where: { status: 'RUNNING', containerId: { not: null } }, diff --git a/packages/backend/src/clusters/dto/cluster-bulk-action-result.dto.ts b/packages/backend/src/clusters/dto/cluster-bulk-action-result.dto.ts new file mode 100644 index 0000000..a8a300b --- /dev/null +++ b/packages/backend/src/clusters/dto/cluster-bulk-action-result.dto.ts @@ -0,0 +1,22 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const ClusterBulkActionResultSchema = z.object({ + succeeded: z.array(z.number().int().nonnegative()).meta({ + description: 'IDs of clusters where the action completed successfully.', + }), + failed: z + .array( + z.object({ + id: z.number().int().nonnegative(), + reason: z.string(), + }), + ) + .meta({ + description: 'Clusters where the action failed, along with the failure reason.', + }), +}); + +export class ClusterBulkActionResultZodDto extends createZodDto(ClusterBulkActionResultSchema) {} + +export type ClusterBulkActionResultDto = z.infer;