feat(export-import): add yaml export/import with id upsert for credentials, snippets, and scenarios

- all entities export a kind field (credential/snippet/scenario) for safe type checking on import
- import upserts by id: overwrites if id exists, creates with explicit id otherwise
- scenario export now includes id and step ids; import deletes old steps before recreating
- add GET /:id/export and POST /import endpoints to credential and snippet controllers
- add UuidBadge ui component: shortens uuid to first+last 4 hex chars, tooltip, clipboard copy with animated ClipboardCheck icon pop
- apply UuidBadge across all entity id display sites (detail pages, card footers, table columns)
- add export/import buttons to CredentialsPage, SnippetsPage, CredentialDetailPage, SnippetDetailPage
This commit is contained in:
2026-04-10 13:09:09 +03:00
parent 1164289173
commit 32be7c0a59
54 changed files with 728 additions and 272 deletions
+44 -26
View File
@@ -63,7 +63,7 @@ export class ScenarioService {
return { data, total, page, limit };
}
async findOne(id: number): Promise<ScenarioEntity> {
async findOne(id: string): Promise<ScenarioEntity> {
const scenario = await this.scenarioRepo.findOne({
where: { id },
relations: ["steps", "scenarioCredentials", "scenarioCredentials.credential"],
@@ -73,13 +73,13 @@ export class ScenarioService {
return scenario;
}
async update(id: number, dto: UpdateScenarioDto): Promise<ScenarioEntity> {
async update(id: string, dto: UpdateScenarioDto): Promise<ScenarioEntity> {
const scenario = await this.findOne(id);
Object.assign(scenario, dto);
return this.scenarioRepo.save(scenario);
}
async remove(id: number): Promise<void> {
async remove(id: string): Promise<void> {
await this.findOne(id);
await this.scenarioRepo.delete(id);
}
@@ -87,7 +87,7 @@ export class ScenarioService {
// ── Steps ─────────────────────────────────────────────────────────────────
async createStep(
scenarioId: number,
scenarioId: string,
dto: CreateScenarioStepDto,
): Promise<ScenarioStepEntity> {
await this.findOne(scenarioId);
@@ -102,8 +102,8 @@ export class ScenarioService {
}
async findStep(
scenarioId: number,
stepId: number,
scenarioId: string,
stepId: string,
): Promise<ScenarioStepEntity> {
const step = await this.stepRepo.findOneBy({ id: stepId, scenarioId });
if (!step)
@@ -114,8 +114,8 @@ export class ScenarioService {
}
async updateStep(
scenarioId: number,
stepId: number,
scenarioId: string,
stepId: string,
dto: UpdateScenarioStepDto,
): Promise<ScenarioStepEntity> {
const step = await this.findStep(scenarioId, stepId);
@@ -123,7 +123,7 @@ export class ScenarioService {
return this.stepRepo.save(step);
}
async removeStep(scenarioId: number, stepId: number): Promise<void> {
async removeStep(scenarioId: string, stepId: string): Promise<void> {
await this.findStep(scenarioId, stepId);
await this.stepRepo.delete(stepId);
}
@@ -131,7 +131,7 @@ export class ScenarioService {
// ── Scenario Credentials ──────────────────────────────────────────────────
async findScenarioCredentials(
scenarioId: number,
scenarioId: string,
): Promise<ScenarioCredentialEntity[]> {
await this.findOne(scenarioId); // 404 guard
return this.scenarioCredRepo.find({
@@ -142,7 +142,7 @@ export class ScenarioService {
}
async addScenarioCredential(
scenarioId: number,
scenarioId: string,
dto: AddScenarioCredentialDto,
): Promise<ScenarioCredentialEntity> {
await this.findOne(scenarioId); // 404 guard
@@ -168,8 +168,8 @@ export class ScenarioService {
}
async removeScenarioCredential(
scenarioId: number,
scCredId: number,
scenarioId: string,
scCredId: string,
): Promise<void> {
await this.findOne(scenarioId); // 404 guard
const sc = await this.scenarioCredRepo.findOneBy({
@@ -187,7 +187,7 @@ export class ScenarioService {
* Builds a map of alias → parsed credential data for use in code execution.
* Returns null values for credentials with no data.
*/
async buildCredentialMap(scenarioId: number): Promise<Record<string, unknown>> {
async buildCredentialMap(scenarioId: string): Promise<Record<string, unknown>> {
const scs = await this.findScenarioCredentials(scenarioId);
const map: Record<string, unknown> = {};
for (const sc of scs) {
@@ -207,7 +207,7 @@ export class ScenarioService {
// ── Runs ──────────────────────────────────────────────────────────────────
async findRuns(
scenarioId: number,
scenarioId: string,
query: RunsQueryDto,
): Promise<PaginatedResult<ScenarioRunEntity>> {
await this.findOne(scenarioId); // 404 guard
@@ -245,8 +245,8 @@ export class ScenarioService {
}
async findRun(
scenarioId: number,
runId: number,
scenarioId: string,
runId: string,
): Promise<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> {
await this.findOne(scenarioId); // 404 guard
const run = await this.runRepo.findOne({
@@ -266,8 +266,8 @@ export class ScenarioService {
}
async waitForRun(
scenarioId: number,
runId: number,
scenarioId: string,
runId: string,
timeoutMs = 300_000,
): Promise<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> {
const deadline = Date.now() + timeoutMs;
@@ -285,7 +285,7 @@ export class ScenarioService {
return this.findRun(scenarioId, runId);
}
async createRun(scenarioId: number): Promise<ScenarioRunEntity> {
async createRun(scenarioId: string): Promise<ScenarioRunEntity> {
const scenario = await this.findOne(scenarioId);
const run = await this.runRepo.save(
@@ -313,14 +313,16 @@ export class ScenarioService {
// ── Export / Import ───────────────────────────────────────────────────────
async exportScenario(id: number): Promise<ScenarioExportDto> {
async exportScenario(id: string): Promise<ScenarioExportDto> {
const scenario = await this.findOne(id);
return {
kind: "scenario",
id: scenario.id,
name: scenario.name,
steps: scenario.steps.map((s) => ({
order: s.order,
type: s.type,
sessionName: s.sessionName,
title: s.title,
execCode: s.execCode,
validateCode: s.validateCode,
})),
@@ -328,16 +330,32 @@ export class ScenarioService {
}
async importScenario(dto: ScenarioExportDto): Promise<ScenarioEntity> {
const scenario = await this.scenarioRepo.save(
this.scenarioRepo.create({ name: dto.name }),
);
// Upsert: if id provided and entity exists, replace steps; else create new
let scenario: ScenarioEntity;
if (dto.id) {
const existing = await this.scenarioRepo.findOneBy({ id: dto.id });
if (existing) {
existing.name = dto.name;
scenario = await this.scenarioRepo.save(existing);
// Delete old steps and recreate
await this.stepRepo.delete({ scenarioId: scenario.id });
} else {
scenario = await this.scenarioRepo.save(
this.scenarioRepo.create({ id: dto.id, name: dto.name }),
);
}
} else {
scenario = await this.scenarioRepo.save(
this.scenarioRepo.create({ name: dto.name }),
);
}
if (dto.steps.length > 0) {
const steps = dto.steps.map((s) =>
this.stepRepo.create({
scenarioId: scenario.id,
order: s.order,
type: s.type,
sessionName: s.sessionName,
title: s.title ?? null,
execCode: s.execCode ?? null,
validateCode: s.validateCode ?? null,
}),