diff --git a/client/src/api/client.ts b/client/src/api/client.ts index f3eae3f..c9f5697 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -216,8 +216,19 @@ export const scenarios = { ): Promise> { return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`); }, - exportScenario(id: string): Promise { - return request(`/scenarios/${id}/export`); + exportScenario( + id: string, + options: { includeEnvironment: boolean; credentialIds: string[] } = { + includeEnvironment: false, + credentialIds: [], + }, + ): Promise { + const params = new URLSearchParams(); + params.set('includeEnvironment', String(options.includeEnvironment)); + for (const cid of options.credentialIds) { + params.append('credentialIds', cid); + } + return request(`/scenarios/${id}/export?${params.toString()}`); }, importScenario(payload: unknown): Promise { return request('/scenarios/import', { diff --git a/client/src/components/modals/ExportScenarioModal.module.css b/client/src/components/modals/ExportScenarioModal.module.css new file mode 100644 index 0000000..f36f586 --- /dev/null +++ b/client/src/components/modals/ExportScenarioModal.module.css @@ -0,0 +1,63 @@ +.exportModal { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.scenarioMeta { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: var(--color-primary); + border-radius: 6px; +} + +.metaRow { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.metaLabel { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-primary-fg); + opacity: 0.7; + min-width: 4rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.metaValue { + font-size: 0.875rem; + color: var(--color-primary-fg); +} + +.section { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.sectionTitle { + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-muted, #888); + margin: 0; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.checkboxRow { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + cursor: pointer; +} + +.checkboxRow input[type='checkbox'] { + cursor: pointer; + accent-color: var(--color-primary); +} diff --git a/client/src/components/modals/ExportScenarioModal.tsx b/client/src/components/modals/ExportScenarioModal.tsx new file mode 100644 index 0000000..962aea7 --- /dev/null +++ b/client/src/components/modals/ExportScenarioModal.tsx @@ -0,0 +1,102 @@ +import { useTranslation } from 'react-i18next'; +import { X, Upload } from 'lucide-react'; +import { Modal, Button, UuidBadge } from '../../ui'; +import type { Scenario, ScenarioCredential } from '../../api'; +import styles from './ExportScenarioModal.module.css'; + +export interface ExportScenarioModalProps { + open: boolean; + scenario: Scenario | null; + scenarioCredentials: ScenarioCredential[]; + environment: Pick<{ id: string; name: string }, 'id' | 'name'> | null; + includeEnvironment: boolean; + onIncludeEnvironmentChange: (v: boolean) => void; + includedCredentialIds: Set; + onCredentialToggle: (id: string, checked: boolean) => void; + onClose: () => void; + onExport: () => void; + isExporting: boolean; +} + +export function ExportScenarioModal({ + open, + scenario, + scenarioCredentials, + environment, + includeEnvironment, + onIncludeEnvironmentChange, + includedCredentialIds, + onCredentialToggle, + onClose, + onExport, + isExporting, +}: ExportScenarioModalProps) { + const { t } = useTranslation(); + + return ( + !isExporting && onClose()} + footer={ + <> + + + + } + > + {scenario && ( +
+
+
+ {t('scenarios.field_name')} + {scenario.name} +
+
+ {t('scenarios.field_id')} + + + +
+
+ + {environment && ( +
+

{t('scenarios.export_include_environment')}

+ +
+ )} + + {scenarioCredentials.length > 0 && ( +
+

{t('scenarios.export_include_credentials')}

+ {scenarioCredentials.map((sc) => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/client/src/components/modals/index.ts b/client/src/components/modals/index.ts index b9ca6e9..67a9010 100644 --- a/client/src/components/modals/index.ts +++ b/client/src/components/modals/index.ts @@ -1,4 +1,6 @@ export { DeleteConfirmationModal } from './DeleteConfirmationModal'; export type { DeleteConfirmationModalProps } from './DeleteConfirmationModal'; +export { ExportScenarioModal } from './ExportScenarioModal'; +export type { ExportScenarioModalProps } from './ExportScenarioModal'; export { RunScenarioModal } from './RunScenarioModal'; export type { RunScenarioModalProps } from './RunScenarioModal'; diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 3aa148d..d52e216 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -181,7 +181,12 @@ "run_modal_title": "Select environment", "run_modal_env_label": "Environment", "run_modal_no_env": "No environments found. Create an environment before running a scenario.", - "run_modal_env_required": "Please select an environment" + "run_modal_env_required": "Please select an environment", + "export_modal_title": "Export Scenario", + "export_include_environment": "Include Environment", + "export_include_credentials": "Include Credentials", + "field_name": "Name", + "field_id": "ID" }, "theme": { "switch_to_light": "Switch to light theme", diff --git a/client/src/pages/scenario/ScenarioDetailPage.tsx b/client/src/pages/scenario/ScenarioDetailPage.tsx index 3fb268e..bdc4721 100644 --- a/client/src/pages/scenario/ScenarioDetailPage.tsx +++ b/client/src/pages/scenario/ScenarioDetailPage.tsx @@ -41,6 +41,7 @@ import { useToast, type TableColumn, } from '../../ui'; +import { ExportScenarioModal } from '../../components/modals'; import styles from '../Page.module.css'; export function ScenarioDetailPage() { @@ -74,6 +75,12 @@ export function ScenarioDetailPage() { >(null); const [deleting, setDeleting] = useState(false); + // Export modal state + const [exportModalOpen, setExportModalOpen] = useState(false); + const [includeEnvironment, setIncludeEnvironment] = useState(true); + const [includedCredentialIds, setIncludedCredentialIds] = useState>(new Set()); + const [isExporting, setIsExporting] = useState(false); + useEffect(() => { if (!id) return; void scenarios @@ -137,17 +144,36 @@ export function ScenarioDetailPage() { navigate('/scenarios'); }; + const handleOpenExportModal = () => { + if (!scenario) return; + // Initialise all credential checkboxes to checked + setIncludedCredentialIds(new Set(scenarioCreds.map((sc) => sc.credentialId))); + setIncludeEnvironment(true); + setExportModalOpen(true); + }; + const handleExport = async () => { if (!scenario) return; - const data = await scenarios.exportScenario(scenario.id); - const yamlContent = typeof data === 'string' ? data : yamlStringify(data); - const blob = new Blob([yamlContent], { type: 'application/yaml' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${scenario.name}.yaml`; - a.click(); - URL.revokeObjectURL(url); + setIsExporting(true); + try { + const credIds = Array.from(includedCredentialIds); + const yamlContent = await scenarios.exportScenario(scenario.id, { + includeEnvironment, + credentialIds: credIds, + }); + const blob = new Blob([yamlContent], { type: 'application/yaml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${scenario.name}.yaml`; + a.click(); + URL.revokeObjectURL(url); + setExportModalOpen(false); + } catch (err) { + toast.error((err as Error).message); + } finally { + setIsExporting(false); + } }; const handleDeleteStep = async (stepId: string) => { @@ -368,7 +394,7 @@ export function ScenarioDetailPage() { {t('scenarios.action_runs')} - @@ -628,6 +654,29 @@ export function ScenarioDetailPage() { )} + + e.id === scenario?.environmentId) ?? null + } + includeEnvironment={includeEnvironment} + onIncludeEnvironmentChange={setIncludeEnvironment} + includedCredentialIds={includedCredentialIds} + onCredentialToggle={(credId, checked) => { + setIncludedCredentialIds((prev) => { + const next = new Set(prev); + if (checked) next.add(credId); + else next.delete(credId); + return next; + }); + }} + onClose={() => setExportModalOpen(false)} + onExport={handleExport} + isExporting={isExporting} + /> ); } diff --git a/server/src/mcp/mcp.service.ts b/server/src/mcp/mcp.service.ts index 5a109b8..d6ad2da 100644 --- a/server/src/mcp/mcp.service.ts +++ b/server/src/mcp/mcp.service.ts @@ -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: [ diff --git a/server/src/scenario/dto/scenario-export.dto.ts b/server/src/scenario/dto/scenario-export.dto.ts index d34eef0..45e23cb 100644 --- a/server/src/scenario/dto/scenario-export.dto.ts +++ b/server/src/scenario/dto/scenario-export.dto.ts @@ -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; diff --git a/server/src/scenario/scenario.controller.ts b/server/src/scenario/scenario.controller.ts index 7eb03fa..718f1eb 100644 --- a/server/src/scenario/scenario.controller.ts +++ b/server/src/scenario/scenario.controller.ts @@ -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 = { + 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 ───────────────────────────────────────────────────────────────── diff --git a/server/src/scenario/scenario.service.ts b/server/src/scenario/scenario.service.ts index 1ee042d..9c388d2 100644 --- a/server/src/scenario/scenario.service.ts +++ b/server/src/scenario/scenario.service.ts @@ -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 { + async exportScenario( + id: string, + options: { includeEnvironment: boolean; credentialIds: string[] }, + ): Promise { 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 { - // Upsert: if id provided and entity exists, replace steps; else create new + async importScenario( + payload: ScenarioExportDto | ExportEntity[], + ): Promise { + 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); } }