feat(scenarios): overhaul export/import with multi-entity YAML format
- export now accepts includeEnvironment and credentialIds query params - output is a YAML stream with comment-delimited environment, credential, and scenario sections - scenario entity includes credentials array with alias mappings - import accepts both old single-object and new array format, upserts envs/creds before linking - new ExportScenarioModal with env and per-credential checkboxes replaces direct download
This commit is contained in:
@@ -965,7 +965,10 @@ export class McpService {
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
const exported = await this.scenarioService.exportScenario(id);
|
||||
const exported = await this.scenarioService.exportScenario(id, {
|
||||
includeEnvironment: false,
|
||||
credentialIds: [],
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: JSON.stringify(exported) },
|
||||
@@ -1010,11 +1013,10 @@ export class McpService {
|
||||
async ({ name, description, steps }) => {
|
||||
try {
|
||||
const scenario = await this.scenarioService.importScenario({
|
||||
kind: "scenario",
|
||||
name,
|
||||
description,
|
||||
steps: steps as Parameters<
|
||||
typeof this.scenarioService.importScenario
|
||||
>[0]["steps"],
|
||||
steps: steps as { title: string | null; execCode: string | null }[],
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { EnvironmentData } from "../../environment/environment.entity";
|
||||
|
||||
export class ScenarioStepExportDto {
|
||||
@ApiPropertyOptional()
|
||||
@@ -24,6 +26,65 @@ export class ScenarioStepExportDto {
|
||||
execCode: string | null;
|
||||
}
|
||||
|
||||
export class CredentialExportDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsIn(["credential"])
|
||||
kind?: "credential";
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
id?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
data?: string | null;
|
||||
}
|
||||
|
||||
export class EnvironmentExportInlineDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsIn(["environment"])
|
||||
kind?: "environment";
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
id?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsObject()
|
||||
data: EnvironmentData;
|
||||
}
|
||||
|
||||
export class ScenarioCredentialMappingDto {
|
||||
@ApiProperty()
|
||||
@IsUUID()
|
||||
credentialId: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export class ScenarioExportDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@@ -51,9 +112,27 @@ export class ScenarioExportDto {
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
environmentId?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [ScenarioCredentialMappingDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ScenarioCredentialMappingDto)
|
||||
credentials?: ScenarioCredentialMappingDto[];
|
||||
|
||||
@ApiProperty({ type: [ScenarioStepExportDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ScenarioStepExportDto)
|
||||
steps: ScenarioStepExportDto[];
|
||||
}
|
||||
|
||||
/** Union type for multi-entity export array items */
|
||||
export type ExportEntity =
|
||||
| EnvironmentExportInlineDto
|
||||
| CredentialExportDto
|
||||
| ScenarioExportDto;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
|
||||
import { CreateScenarioDto } from "./dto/create-scenario.dto";
|
||||
import { PaginationQueryDto } from "./dto/pagination-query.dto";
|
||||
import { RunsQueryDto } from "./dto/runs-query.dto";
|
||||
import { ScenarioExportDto } from "./dto/scenario-export.dto";
|
||||
import { ExportEntity } from "./dto/scenario-export.dto";
|
||||
import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
|
||||
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
|
||||
import { ScenarioOrderBy, ScenarioService } from "./scenario.service";
|
||||
@@ -40,10 +40,10 @@ export class ScenarioController {
|
||||
}
|
||||
|
||||
@Post("import")
|
||||
@ApiOperation({ summary: "Import a scenario from an export payload" })
|
||||
@ApiOperation({ summary: "Import a scenario from an export payload (single object or array)" })
|
||||
@ApiResponse({ status: 201, description: "Scenario imported" })
|
||||
importScenario(@Body() dto: ScenarioExportDto) {
|
||||
return this.scenarioService.importScenario(dto);
|
||||
importScenario(@Body() payload: unknown) {
|
||||
return this.scenarioService.importScenario(payload as any);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@@ -96,11 +96,34 @@ export class ScenarioController {
|
||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||
async exportScenario(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Query("includeEnvironment") includeEnvironment: string,
|
||||
@Query("credentialIds") credentialIds: string | string[] | undefined,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const payload = await this.scenarioService.exportScenario(id);
|
||||
const credIds: string[] = credentialIds
|
||||
? Array.isArray(credentialIds)
|
||||
? credentialIds
|
||||
: credentialIds.split(",").filter(Boolean)
|
||||
: [];
|
||||
const entities = await this.scenarioService.exportScenario(id, {
|
||||
includeEnvironment: includeEnvironment === "true",
|
||||
credentialIds: credIds,
|
||||
});
|
||||
res.setHeader("Content-Type", "application/yaml; charset=utf-8");
|
||||
return yamlStringify(payload);
|
||||
// Serialize as YAML stream: each entity separated by a comment header
|
||||
const kindLabels: Record<string, string> = {
|
||||
environment: "Environment",
|
||||
credential: "Credential",
|
||||
scenario: "Scenario",
|
||||
};
|
||||
const parts = (entities as ExportEntity[]).map((entity) => {
|
||||
const label = kindLabels[entity.kind ?? ""] ?? entity.kind ?? "Entity";
|
||||
const comment = `# --- ${label}: ${
|
||||
(entity as any).name ?? (entity as any).id ?? ""
|
||||
} ---`;
|
||||
return `${comment}\n${yamlStringify(entity)}`;
|
||||
});
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
// ── Steps ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -15,7 +15,12 @@ import { AddScenarioCredentialDto } from "./dto/add-scenario-credential.dto";
|
||||
import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
|
||||
import { CreateScenarioDto } from "./dto/create-scenario.dto";
|
||||
import { RunsQueryDto } from "./dto/runs-query.dto";
|
||||
import { ScenarioExportDto } from "./dto/scenario-export.dto";
|
||||
import {
|
||||
CredentialExportDto,
|
||||
EnvironmentExportInlineDto,
|
||||
ExportEntity,
|
||||
ScenarioExportDto,
|
||||
} from "./dto/scenario-export.dto";
|
||||
import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
|
||||
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
|
||||
import { ScenarioCredentialEntity } from "./scenario-credential.entity";
|
||||
@@ -405,30 +410,135 @@ export class ScenarioService {
|
||||
|
||||
// ── Export / Import ───────────────────────────────────────────────────────
|
||||
|
||||
async exportScenario(id: string): Promise<ScenarioExportDto> {
|
||||
async exportScenario(
|
||||
id: string,
|
||||
options: { includeEnvironment: boolean; credentialIds: string[] },
|
||||
): Promise<ExportEntity[]> {
|
||||
const scenario = await this.findOne(id);
|
||||
return {
|
||||
const entities: ExportEntity[] = [];
|
||||
|
||||
// Include environment entity if requested and linked
|
||||
if (options.includeEnvironment && scenario.environment) {
|
||||
const envDto: EnvironmentExportInlineDto = {
|
||||
kind: "environment",
|
||||
id: scenario.environment.id,
|
||||
name: scenario.environment.name,
|
||||
description: scenario.environment.description ?? undefined,
|
||||
data: scenario.environment.data,
|
||||
};
|
||||
entities.push(envDto);
|
||||
}
|
||||
|
||||
// Include selected credentials
|
||||
if (options.credentialIds.length > 0) {
|
||||
const creds = scenario.scenarioCredentials ?? [];
|
||||
for (const sc of creds) {
|
||||
if (
|
||||
sc.credential &&
|
||||
options.credentialIds.includes(sc.credential.id)
|
||||
) {
|
||||
const credDto: CredentialExportDto = {
|
||||
kind: "credential",
|
||||
id: sc.credential.id,
|
||||
name: sc.credential.name,
|
||||
data: sc.credential.data,
|
||||
};
|
||||
entities.push(credDto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario is always last
|
||||
const allCreds = scenario.scenarioCredentials ?? [];
|
||||
const credentialMappings = allCreds
|
||||
.filter(
|
||||
(sc) =>
|
||||
options.credentialIds.length === 0 ||
|
||||
options.credentialIds.includes(sc.credentialId),
|
||||
)
|
||||
.map((sc) => ({ credentialId: sc.credentialId, alias: sc.alias }));
|
||||
|
||||
const scenarioDto: ScenarioExportDto = {
|
||||
kind: "scenario",
|
||||
id: scenario.id,
|
||||
name: scenario.name,
|
||||
description: scenario.description ?? undefined,
|
||||
environmentId: scenario.environmentId ?? undefined,
|
||||
credentials: credentialMappings.length > 0 ? credentialMappings : undefined,
|
||||
steps: scenario.steps.map((s) => ({
|
||||
title: s.title,
|
||||
execCode: s.execCode,
|
||||
})),
|
||||
};
|
||||
entities.push(scenarioDto);
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
async importScenario(dto: ScenarioExportDto): Promise<ScenarioEntity> {
|
||||
// Upsert: if id provided and entity exists, replace steps; else create new
|
||||
async importScenario(
|
||||
payload: ScenarioExportDto | ExportEntity[],
|
||||
): Promise<ScenarioEntity> {
|
||||
const items: ExportEntity[] = Array.isArray(payload) ? payload : [payload];
|
||||
|
||||
let scenarioDto: ScenarioExportDto | undefined;
|
||||
for (const item of items) {
|
||||
if (item.kind === "environment") {
|
||||
await this.environmentRepo
|
||||
.findOneBy({ id: item.id })
|
||||
.then(async (existing) => {
|
||||
if (existing) {
|
||||
Object.assign(existing, {
|
||||
name: item.name,
|
||||
description: (item as EnvironmentExportInlineDto).description ?? null,
|
||||
data: (item as EnvironmentExportInlineDto).data,
|
||||
});
|
||||
return this.environmentRepo.save(existing);
|
||||
}
|
||||
return this.environmentRepo.save(
|
||||
this.environmentRepo.create({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: (item as EnvironmentExportInlineDto).description ?? null,
|
||||
data: (item as EnvironmentExportInlineDto).data,
|
||||
}),
|
||||
);
|
||||
});
|
||||
} else if (item.kind === "credential") {
|
||||
const credItem = item as CredentialExportDto;
|
||||
const existingCred = credItem.id
|
||||
? await this.credentialRepo.findOneBy({ id: credItem.id })
|
||||
: null;
|
||||
if (existingCred) {
|
||||
existingCred.name = credItem.name;
|
||||
existingCred.data = credItem.data ?? null;
|
||||
await this.credentialRepo.save(existingCred);
|
||||
} else {
|
||||
await this.credentialRepo.save(
|
||||
this.credentialRepo.create({
|
||||
...(credItem.id ? { id: credItem.id } : {}),
|
||||
name: credItem.name,
|
||||
data: credItem.data ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (item.kind === "scenario") {
|
||||
scenarioDto = item as ScenarioExportDto;
|
||||
}
|
||||
}
|
||||
|
||||
if (!scenarioDto) {
|
||||
throw new Error("No scenario entity found in import payload");
|
||||
}
|
||||
|
||||
const dto = scenarioDto;
|
||||
let scenario: ScenarioEntity;
|
||||
if (dto.id) {
|
||||
const existing = await this.scenarioRepo.findOneBy({ id: dto.id });
|
||||
if (existing) {
|
||||
existing.name = dto.name;
|
||||
existing.description = dto.description ?? null;
|
||||
existing.environmentId = dto.environmentId ?? existing.environmentId;
|
||||
scenario = await this.scenarioRepo.save(existing);
|
||||
// Delete old steps and recreate
|
||||
await this.stepRepo.delete({ scenarioId: scenario.id });
|
||||
} else {
|
||||
scenario = await this.scenarioRepo.save(
|
||||
@@ -436,6 +546,7 @@ export class ScenarioService {
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
description: dto.description ?? null,
|
||||
environmentId: dto.environmentId ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -444,6 +555,7 @@ export class ScenarioService {
|
||||
this.scenarioRepo.create({
|
||||
name: dto.name,
|
||||
description: dto.description ?? null,
|
||||
environmentId: dto.environmentId ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -459,6 +571,18 @@ export class ScenarioService {
|
||||
await this.stepRepo.save(steps);
|
||||
await this.normalizeStepOrder(scenario.id);
|
||||
}
|
||||
// Restore credential alias mappings
|
||||
if (dto.credentials && dto.credentials.length > 0) {
|
||||
await this.scenarioCredRepo.delete({ scenarioId: scenario.id });
|
||||
const mappings = dto.credentials.map((c) =>
|
||||
this.scenarioCredRepo.create({
|
||||
scenarioId: scenario.id,
|
||||
credentialId: c.credentialId,
|
||||
alias: c.alias,
|
||||
}),
|
||||
);
|
||||
await this.scenarioCredRepo.save(mappings);
|
||||
}
|
||||
return this.findOne(scenario.id);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user