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:
@@ -216,8 +216,19 @@ export const scenarios = {
|
|||||||
): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
||||||
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
|
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
|
||||||
},
|
},
|
||||||
exportScenario(id: string): Promise<string> {
|
exportScenario(
|
||||||
return request(`/scenarios/${id}/export`);
|
id: string,
|
||||||
|
options: { includeEnvironment: boolean; credentialIds: string[] } = {
|
||||||
|
includeEnvironment: false,
|
||||||
|
credentialIds: [],
|
||||||
|
},
|
||||||
|
): Promise<string> {
|
||||||
|
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<Scenario> {
|
importScenario(payload: unknown): Promise<Scenario> {
|
||||||
return request('/scenarios/import', {
|
return request('/scenarios/import', {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<string>;
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={t('scenarios.export_modal_title')}
|
||||||
|
onClose={() => !isExporting && onClose()}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={isExporting}>
|
||||||
|
<X size={14} />
|
||||||
|
{t('common.button_cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={onExport} disabled={isExporting}>
|
||||||
|
<Upload size={14} />
|
||||||
|
{t('scenarios.action_export')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{scenario && (
|
||||||
|
<div className={styles.exportModal}>
|
||||||
|
<div className={styles.scenarioMeta}>
|
||||||
|
<div className={styles.metaRow}>
|
||||||
|
<span className={styles.metaLabel}>{t('scenarios.field_name')}</span>
|
||||||
|
<span className={styles.metaValue}>{scenario.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.metaRow}>
|
||||||
|
<span className={styles.metaLabel}>{t('scenarios.field_id')}</span>
|
||||||
|
<span className={styles.metaValue}>
|
||||||
|
<UuidBadge id={scenario.id} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{environment && (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h4 className={styles.sectionTitle}>{t('scenarios.export_include_environment')}</h4>
|
||||||
|
<label className={styles.checkboxRow}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeEnvironment}
|
||||||
|
onChange={(e) => onIncludeEnvironmentChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{environment.name}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scenarioCredentials.length > 0 && (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h4 className={styles.sectionTitle}>{t('scenarios.export_include_credentials')}</h4>
|
||||||
|
{scenarioCredentials.map((sc) => (
|
||||||
|
<label key={sc.credentialId} className={styles.checkboxRow}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includedCredentialIds.has(sc.credentialId)}
|
||||||
|
onChange={(e) => onCredentialToggle(sc.credentialId, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{sc.credential?.name ?? sc.alias}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||||
export type { DeleteConfirmationModalProps } from './DeleteConfirmationModal';
|
export type { DeleteConfirmationModalProps } from './DeleteConfirmationModal';
|
||||||
|
export { ExportScenarioModal } from './ExportScenarioModal';
|
||||||
|
export type { ExportScenarioModalProps } from './ExportScenarioModal';
|
||||||
export { RunScenarioModal } from './RunScenarioModal';
|
export { RunScenarioModal } from './RunScenarioModal';
|
||||||
export type { RunScenarioModalProps } from './RunScenarioModal';
|
export type { RunScenarioModalProps } from './RunScenarioModal';
|
||||||
|
|||||||
@@ -181,7 +181,12 @@
|
|||||||
"run_modal_title": "Select environment",
|
"run_modal_title": "Select environment",
|
||||||
"run_modal_env_label": "Environment",
|
"run_modal_env_label": "Environment",
|
||||||
"run_modal_no_env": "No environments found. Create an environment before running a scenario.",
|
"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": {
|
"theme": {
|
||||||
"switch_to_light": "Switch to light theme",
|
"switch_to_light": "Switch to light theme",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
|
import { ExportScenarioModal } from '../../components/modals';
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
export function ScenarioDetailPage() {
|
export function ScenarioDetailPage() {
|
||||||
@@ -74,6 +75,12 @@ export function ScenarioDetailPage() {
|
|||||||
>(null);
|
>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// Export modal state
|
||||||
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||||
|
const [includeEnvironment, setIncludeEnvironment] = useState(true);
|
||||||
|
const [includedCredentialIds, setIncludedCredentialIds] = useState<Set<string>>(new Set());
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
void scenarios
|
void scenarios
|
||||||
@@ -137,17 +144,36 @@ export function ScenarioDetailPage() {
|
|||||||
navigate('/scenarios');
|
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 () => {
|
const handleExport = async () => {
|
||||||
if (!scenario) return;
|
if (!scenario) return;
|
||||||
const data = await scenarios.exportScenario(scenario.id);
|
setIsExporting(true);
|
||||||
const yamlContent = typeof data === 'string' ? data : yamlStringify(data);
|
try {
|
||||||
const blob = new Blob([yamlContent], { type: 'application/yaml' });
|
const credIds = Array.from(includedCredentialIds);
|
||||||
const url = URL.createObjectURL(blob);
|
const yamlContent = await scenarios.exportScenario(scenario.id, {
|
||||||
const a = document.createElement('a');
|
includeEnvironment,
|
||||||
a.href = url;
|
credentialIds: credIds,
|
||||||
a.download = `${scenario.name}.yaml`;
|
});
|
||||||
a.click();
|
const blob = new Blob([yamlContent], { type: 'application/yaml' });
|
||||||
URL.revokeObjectURL(url);
|
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) => {
|
const handleDeleteStep = async (stepId: string) => {
|
||||||
@@ -368,7 +394,7 @@ export function ScenarioDetailPage() {
|
|||||||
<History size={14} />
|
<History size={14} />
|
||||||
{t('scenarios.action_runs')}
|
{t('scenarios.action_runs')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={handleExport}>
|
<Button variant="secondary" size="sm" onClick={handleOpenExportModal}>
|
||||||
<Upload size={14} />
|
<Upload size={14} />
|
||||||
{t('scenarios.action_export')}
|
{t('scenarios.action_export')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -628,6 +654,29 @@ export function ScenarioDetailPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<ExportScenarioModal
|
||||||
|
open={exportModalOpen}
|
||||||
|
scenario={scenario}
|
||||||
|
scenarioCredentials={scenarioCreds}
|
||||||
|
environment={
|
||||||
|
scenario?.environment ?? envs.find((e) => 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}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -965,7 +965,10 @@ export class McpService {
|
|||||||
},
|
},
|
||||||
async ({ id }) => {
|
async ({ id }) => {
|
||||||
try {
|
try {
|
||||||
const exported = await this.scenarioService.exportScenario(id);
|
const exported = await this.scenarioService.exportScenario(id, {
|
||||||
|
includeEnvironment: false,
|
||||||
|
credentialIds: [],
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{ type: "text" as const, text: JSON.stringify(exported) },
|
{ type: "text" as const, text: JSON.stringify(exported) },
|
||||||
@@ -1010,11 +1013,10 @@ export class McpService {
|
|||||||
async ({ name, description, steps }) => {
|
async ({ name, description, steps }) => {
|
||||||
try {
|
try {
|
||||||
const scenario = await this.scenarioService.importScenario({
|
const scenario = await this.scenarioService.importScenario({
|
||||||
|
kind: "scenario",
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
steps: steps as Parameters<
|
steps: steps as { title: string | null; execCode: string | null }[],
|
||||||
typeof this.scenarioService.importScenario
|
|
||||||
>[0]["steps"],
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
import { EnvironmentData } from "../../environment/environment.entity";
|
||||||
|
|
||||||
export class ScenarioStepExportDto {
|
export class ScenarioStepExportDto {
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@@ -24,6 +26,65 @@ export class ScenarioStepExportDto {
|
|||||||
execCode: string | null;
|
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 {
|
export class ScenarioExportDto {
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -51,9 +112,27 @@ export class ScenarioExportDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
environmentId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [ScenarioCredentialMappingDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ScenarioCredentialMappingDto)
|
||||||
|
credentials?: ScenarioCredentialMappingDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: [ScenarioStepExportDto] })
|
@ApiProperty({ type: [ScenarioStepExportDto] })
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => ScenarioStepExportDto)
|
@Type(() => ScenarioStepExportDto)
|
||||||
steps: 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 { CreateScenarioDto } from "./dto/create-scenario.dto";
|
||||||
import { PaginationQueryDto } from "./dto/pagination-query.dto";
|
import { PaginationQueryDto } from "./dto/pagination-query.dto";
|
||||||
import { RunsQueryDto } from "./dto/runs-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 { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
|
||||||
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
|
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
|
||||||
import { ScenarioOrderBy, ScenarioService } from "./scenario.service";
|
import { ScenarioOrderBy, ScenarioService } from "./scenario.service";
|
||||||
@@ -40,10 +40,10 @@ export class ScenarioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("import")
|
@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" })
|
@ApiResponse({ status: 201, description: "Scenario imported" })
|
||||||
importScenario(@Body() dto: ScenarioExportDto) {
|
importScenario(@Body() payload: unknown) {
|
||||||
return this.scenarioService.importScenario(dto);
|
return this.scenarioService.importScenario(payload as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -96,11 +96,34 @@ export class ScenarioController {
|
|||||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||||
async exportScenario(
|
async exportScenario(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Query("includeEnvironment") includeEnvironment: string,
|
||||||
|
@Query("credentialIds") credentialIds: string | string[] | undefined,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@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");
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Steps ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import { AddScenarioCredentialDto } from "./dto/add-scenario-credential.dto";
|
|||||||
import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
|
import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
|
||||||
import { CreateScenarioDto } from "./dto/create-scenario.dto";
|
import { CreateScenarioDto } from "./dto/create-scenario.dto";
|
||||||
import { RunsQueryDto } from "./dto/runs-query.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 { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
|
||||||
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
|
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
|
||||||
import { ScenarioCredentialEntity } from "./scenario-credential.entity";
|
import { ScenarioCredentialEntity } from "./scenario-credential.entity";
|
||||||
@@ -405,30 +410,135 @@ export class ScenarioService {
|
|||||||
|
|
||||||
// ── Export / Import ───────────────────────────────────────────────────────
|
// ── 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);
|
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",
|
kind: "scenario",
|
||||||
id: scenario.id,
|
id: scenario.id,
|
||||||
name: scenario.name,
|
name: scenario.name,
|
||||||
description: scenario.description ?? undefined,
|
description: scenario.description ?? undefined,
|
||||||
|
environmentId: scenario.environmentId ?? undefined,
|
||||||
|
credentials: credentialMappings.length > 0 ? credentialMappings : undefined,
|
||||||
steps: scenario.steps.map((s) => ({
|
steps: scenario.steps.map((s) => ({
|
||||||
title: s.title,
|
title: s.title,
|
||||||
execCode: s.execCode,
|
execCode: s.execCode,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
entities.push(scenarioDto);
|
||||||
|
|
||||||
|
return entities;
|
||||||
}
|
}
|
||||||
|
|
||||||
async importScenario(dto: ScenarioExportDto): Promise<ScenarioEntity> {
|
async importScenario(
|
||||||
// Upsert: if id provided and entity exists, replace steps; else create new
|
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;
|
let scenario: ScenarioEntity;
|
||||||
if (dto.id) {
|
if (dto.id) {
|
||||||
const existing = await this.scenarioRepo.findOneBy({ id: dto.id });
|
const existing = await this.scenarioRepo.findOneBy({ id: dto.id });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.name = dto.name;
|
existing.name = dto.name;
|
||||||
existing.description = dto.description ?? null;
|
existing.description = dto.description ?? null;
|
||||||
|
existing.environmentId = dto.environmentId ?? existing.environmentId;
|
||||||
scenario = await this.scenarioRepo.save(existing);
|
scenario = await this.scenarioRepo.save(existing);
|
||||||
// Delete old steps and recreate
|
|
||||||
await this.stepRepo.delete({ scenarioId: scenario.id });
|
await this.stepRepo.delete({ scenarioId: scenario.id });
|
||||||
} else {
|
} else {
|
||||||
scenario = await this.scenarioRepo.save(
|
scenario = await this.scenarioRepo.save(
|
||||||
@@ -436,6 +546,7 @@ export class ScenarioService {
|
|||||||
id: dto.id,
|
id: dto.id,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
description: dto.description ?? null,
|
description: dto.description ?? null,
|
||||||
|
environmentId: dto.environmentId ?? null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -444,6 +555,7 @@ export class ScenarioService {
|
|||||||
this.scenarioRepo.create({
|
this.scenarioRepo.create({
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
description: dto.description ?? null,
|
description: dto.description ?? null,
|
||||||
|
environmentId: dto.environmentId ?? null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -459,6 +571,18 @@ export class ScenarioService {
|
|||||||
await this.stepRepo.save(steps);
|
await this.stepRepo.save(steps);
|
||||||
await this.normalizeStepOrder(scenario.id);
|
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);
|
return this.findOne(scenario.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user