From e6aacc899dc3a731d358f5d6c8dc603f68de52f3 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Tue, 14 Apr 2026 23:53:45 +0300 Subject: [PATCH] feat(scenarios,environments): add markdown description field - add optional description to scenario and environment entities - expose description in create/update DTOs, MCP tools, and export DTOs - render description as markdown on detail pages - add description editor (CodeEditor) to create/edit forms for both resources --- client/src/api/client.ts | 12 ++--- client/src/api/types.ts | 2 + client/src/i18n/locales/en.json | 11 ++++- .../environment/CreateEnvironmentPage.tsx | 16 ++++++- .../pages/environment/EditEnvironmentPage.tsx | 12 +++++ .../environment/EnvironmentDetailPage.tsx | 12 +++++ .../src/pages/scenario/CreateScenarioPage.tsx | 14 +++++- .../src/pages/scenario/EditScenarioPage.tsx | 18 ++++++- .../src/pages/scenario/ScenarioDetailPage.tsx | 12 +++++ server/Dockerfile | 2 +- .../environment/dto/create-environment.dto.ts | 9 +++- .../environment/dto/environment-export.dto.ts | 5 ++ server/src/environment/environment.entity.ts | 3 ++ server/src/environment/environment.service.ts | 27 +++++++++-- server/src/mcp/mcp.service.ts | 48 +++++++++++++++---- .../src/scenario/dto/create-scenario.dto.ts | 9 +++- .../src/scenario/dto/scenario-export.dto.ts | 5 ++ .../src/scenario/dto/update-scenario.dto.ts | 5 ++ server/src/scenario/scenario.entity.ts | 3 ++ server/src/scenario/scenario.service.ts | 13 ++++- 20 files changed, 206 insertions(+), 32 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 22ef526..dcbc6c6 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -133,13 +133,13 @@ export const environments = { get(id: string): Promise { return request(`/environments/${id}`); }, - create(name: string, data: Environment['data']): Promise { + create(name: string, data: Environment['data'], description?: string): Promise { return request('/environments', { method: 'POST', - body: JSON.stringify({ name, data }), + body: JSON.stringify({ name, description, data }), }); }, - update(id: string, patch: Partial>): Promise { + update(id: string, patch: Partial>): Promise { return request(`/environments/${id}`, { method: 'PATCH', body: JSON.stringify(patch), @@ -182,13 +182,13 @@ export const scenarios = { get(id: string): Promise { return request(`/scenarios/${id}`); }, - create(name: string): Promise { + create(name: string, description?: string): Promise { return request('/scenarios', { method: 'POST', - body: JSON.stringify({ name }), + body: JSON.stringify({ name, description }), }); }, - update(id: string, patch: Partial>): Promise { + update(id: string, patch: Partial>): Promise { return request(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(patch), diff --git a/client/src/api/types.ts b/client/src/api/types.ts index 09f5d2c..dbf6812 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -16,6 +16,7 @@ export interface EnvironmentData { export interface Environment { id: string; name: string; + description?: string; data: EnvironmentData; createdAt: string; updatedAt: string; @@ -82,6 +83,7 @@ export interface ScenarioCredential { export interface Scenario { id: string; name: string; + description?: string; steps?: ScenarioStep[]; scenarioCredentials?: ScenarioCredential[]; createdAt: string; diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index c802540..8b8a878 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -36,6 +36,7 @@ "updated": "Environment updated", "col_id": "ID", "col_name": "Name", + "col_description": "Description", "col_data": "Data", "col_updated": "Updated", "empty": "No environments yet.", @@ -56,10 +57,13 @@ "form_name": "Name", "form_name_placeholder": "e.g. staging, production", "form_name_required": "Name is required", + "form_description": "Description", + "form_description_placeholder": "Describe this environment", "form_data": "Data (JSON)", "form_data_invalid_json": "Must be a valid JSON object", "back": "Environments", "field_id": "ID", + "field_description": "Description", "field_created": "Created", "field_updated": "Updated", "section_data": "Data" @@ -115,12 +119,14 @@ "field_updated": "Updated", "field_lastUsed": "Last Used" }, - "scenarios": { "title": "Scenarios", + "scenarios": { + "title": "Scenarios", "created": "Scenario created", "updated": "Scenario updated", "run_started": "Scenario run started", "col_id": "ID", "col_name": "Name", + "col_description": "Description", "col_updated": "Updated", "empty": "No scenarios yet.", "loading": "Loading…", @@ -128,6 +134,7 @@ "action_delete": "Delete", "field_id": "ID", "field_name": "Name", + "field_description": "Description", "field_steps": "Steps", "field_created": "Created", "field_updated": "Updated", @@ -148,6 +155,8 @@ "action_cancel": "Cancel", "create_title": "New Scenario", "edit_title": "Edit Scenario", + "form_description": "Description", + "form_description_placeholder": "Describe this scenario", "form_name": "Name", "form_name_placeholder": "e.g. Login flow", "form_name_required": "Name is required", diff --git a/client/src/pages/environment/CreateEnvironmentPage.tsx b/client/src/pages/environment/CreateEnvironmentPage.tsx index 95937b2..601c700 100644 --- a/client/src/pages/environment/CreateEnvironmentPage.tsx +++ b/client/src/pages/environment/CreateEnvironmentPage.tsx @@ -12,6 +12,7 @@ export function CreateEnvironmentPage() { const toast = useToast(); const [name, setName] = useState(''); + const [description, setDescription] = useState(''); const [dataJson, setDataJson] = useState('{\n "id": "https://id.example.com"\n}'); const [nameError, setNameError] = useState(''); const [dataError, setDataError] = useState(''); @@ -42,7 +43,11 @@ export function CreateEnvironmentPage() { } setSaving(true); try { - const env = await environments.create(name.trim(), parsedData); + const env = await environments.create( + name.trim(), + parsedData, + description.trim() || undefined, + ); toast.success(t('environments.created')); navigate(`/environments/${env.id}`); } catch (err) { @@ -83,6 +88,15 @@ export function CreateEnvironmentPage() { required autoFocus /> +
+ + +
(null); const [name, setName] = useState(''); + const [description, setDescription] = useState(''); const [dataJson, setDataJson] = useState('{}'); const [nameError, setNameError] = useState(''); const [dataError, setDataError] = useState(''); @@ -28,6 +29,7 @@ export function EditEnvironmentPage() { .then((data) => { setEnv(data); setName(data.name); + setDescription(data.description || ''); setDataJson(JSON.stringify(data.data ?? {}, null, 2)); }) .finally(() => setLoading(false)); @@ -60,6 +62,7 @@ export function EditEnvironmentPage() { try { await environments.update(id!, { name: name.trim(), + description: description.trim() || undefined, data: parsedData, }); toast.success(t('environments.updated')); @@ -109,6 +112,15 @@ export function EditEnvironmentPage() { required autoFocus /> +
+ + +
+ {env.description && ( +
+
+

{t('environments.field_description')}

+
+ + + +
+ )} +

{t('environments.section_data')}

diff --git a/client/src/pages/scenario/CreateScenarioPage.tsx b/client/src/pages/scenario/CreateScenarioPage.tsx index 00ee982..4ac6c6d 100644 --- a/client/src/pages/scenario/CreateScenarioPage.tsx +++ b/client/src/pages/scenario/CreateScenarioPage.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { X, Save, ClipboardList } from 'lucide-react'; import { scenarios } from '../../api'; -import { Breadcrumbs, Button, Card, Input, useToast } from '../../ui'; +import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui'; import styles from '../Page.module.css'; export function CreateScenarioPage() { @@ -12,6 +12,7 @@ export function CreateScenarioPage() { const toast = useToast(); const [name, setName] = useState(''); + const [description, setDescription] = useState(''); const [nameError, setNameError] = useState(''); const [saving, setSaving] = useState(false); @@ -23,7 +24,7 @@ export function CreateScenarioPage() { } setSaving(true); try { - const scenario = await scenarios.create(name.trim()); + const scenario = await scenarios.create(name.trim(), description.trim() || undefined); toast.success(t('scenarios.created')); navigate(`/scenarios/${scenario.id}`); } catch (err) { @@ -64,6 +65,15 @@ export function CreateScenarioPage() { required autoFocus /> +
+ + +
diff --git a/client/src/pages/scenario/EditScenarioPage.tsx b/client/src/pages/scenario/EditScenarioPage.tsx index b9b2cc0..0b68ceb 100644 --- a/client/src/pages/scenario/EditScenarioPage.tsx +++ b/client/src/pages/scenario/EditScenarioPage.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { X, Save, ClipboardList } from 'lucide-react'; import { scenarios } from '../../api'; import type { Scenario } from '../../api'; -import { Breadcrumbs, Button, Card, Input, useToast } from '../../ui'; +import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui'; import styles from '../Page.module.css'; export function EditScenarioPage() { @@ -15,6 +15,7 @@ export function EditScenarioPage() { const [scenario, setScenario] = useState(null); const [name, setName] = useState(''); + const [description, setDescription] = useState(''); const [nameError, setNameError] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -26,6 +27,7 @@ export function EditScenarioPage() { .then((data) => { setScenario(data); setName(data.name); + setDescription(data.description || ''); }) .finally(() => setLoading(false)); }, [id]); @@ -38,7 +40,10 @@ export function EditScenarioPage() { } setSaving(true); try { - await scenarios.update(id!, { name: name.trim() }); + await scenarios.update(id!, { + name: name.trim(), + description: description.trim() || undefined, + }); toast.success(t('scenarios.updated')); navigate(`/scenarios/${id}`); } catch (err) { @@ -86,6 +91,15 @@ export function EditScenarioPage() { required autoFocus /> +
+ + +
diff --git a/client/src/pages/scenario/ScenarioDetailPage.tsx b/client/src/pages/scenario/ScenarioDetailPage.tsx index ea0df13..6971626 100644 --- a/client/src/pages/scenario/ScenarioDetailPage.tsx +++ b/client/src/pages/scenario/ScenarioDetailPage.tsx @@ -37,6 +37,7 @@ import { Table, Timestamp, UuidBadge, + MarkdownContent, useToast, type TableColumn, } from '../../ui'; @@ -400,6 +401,17 @@ export function ScenarioDetailPage() { /> + {scenario.description && ( +
+
+

{t('scenarios.field_description')}

+
+ + + +
+ )} + {scenario.steps.length > 0 && (
diff --git a/server/Dockerfile b/server/Dockerfile index 04a22c4..38c81f7 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -35,7 +35,7 @@ RUN npm ci --omit=dev -w server COPY --from=builder /app/server/dist ./dist # Runtime directories (keys and SQLite DB mounted via volumes) -RUN mkdir -p data keys +RUN mkdir -p data EXPOSE 3000 diff --git a/server/src/environment/dto/create-environment.dto.ts b/server/src/environment/dto/create-environment.dto.ts index 5d2b42b..2ba875f 100644 --- a/server/src/environment/dto/create-environment.dto.ts +++ b/server/src/environment/dto/create-environment.dto.ts @@ -1,5 +1,5 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsNotEmpty, IsObject, IsString } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsNotEmpty, IsObject, IsOptional, IsString } from "class-validator"; import { EnvironmentData } from "../environment.entity"; export class CreateEnvironmentDto { @@ -8,6 +8,11 @@ export class CreateEnvironmentDto { @IsNotEmpty() name: string; + @ApiPropertyOptional({ description: "Optional markdown description" }) + @IsOptional() + @IsString() + description?: string; + @ApiProperty({ description: "Generic key-value map for environment metadata", example: { diff --git a/server/src/environment/dto/environment-export.dto.ts b/server/src/environment/dto/environment-export.dto.ts index ecc1852..6453d1b 100644 --- a/server/src/environment/dto/environment-export.dto.ts +++ b/server/src/environment/dto/environment-export.dto.ts @@ -25,6 +25,11 @@ export class EnvironmentExportDto { @IsNotEmpty() name: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + @ApiProperty() @IsObject() data: EnvironmentData; diff --git a/server/src/environment/environment.entity.ts b/server/src/environment/environment.entity.ts index 22a222d..dc3d491 100644 --- a/server/src/environment/environment.entity.ts +++ b/server/src/environment/environment.entity.ts @@ -18,6 +18,9 @@ export class EnvironmentEntity { @Column({ unique: true }) name: string; + @Column("text", { nullable: true }) + description: string | null; + @Column("simple-json") data: EnvironmentData; diff --git a/server/src/environment/environment.service.ts b/server/src/environment/environment.service.ts index 6c7e0f7..4ca14e9 100644 --- a/server/src/environment/environment.service.ts +++ b/server/src/environment/environment.service.ts @@ -71,6 +71,7 @@ export class EnvironmentService { kind: "environment", id: env.id, name: env.name, + description: env.description ?? undefined, data: env.data, }; } @@ -81,18 +82,36 @@ export class EnvironmentService { if (dto.id) { const existing = await this.repo.findOneBy({ id: dto.id }); if (existing) { - Object.assign(existing, { name: dto.name, data: dto.data }); + Object.assign(existing, { + name: dto.name, + description: dto.description ?? null, + data: dto.data, + }); return this.repo.save(existing); } return this.repo.save( - this.repo.create({ id: dto.id, name: dto.name, data: dto.data }), + this.repo.create({ + id: dto.id, + name: dto.name, + description: dto.description ?? null, + data: dto.data, + }), ); } const byName = await this.repo.findOneBy({ name: dto.name }); if (byName) { - Object.assign(byName, { data: dto.data }); + Object.assign(byName, { + description: dto.description ?? null, + data: dto.data, + }); return this.repo.save(byName); } - return this.repo.save(this.repo.create({ name: dto.name, data: dto.data })); + return this.repo.save( + this.repo.create({ + name: dto.name, + description: dto.description ?? null, + data: dto.data, + }), + ); } } diff --git a/server/src/mcp/mcp.service.ts b/server/src/mcp/mcp.service.ts index 4e8f646..3f0a5d0 100644 --- a/server/src/mcp/mcp.service.ts +++ b/server/src/mcp/mcp.service.ts @@ -181,15 +181,20 @@ export class McpService { name: z .string() .describe("Unique environment name, e.g. liquio-diia-stg"), + description: z + .string() + .optional() + .describe("Optional markdown description"), data: z .record(z.string(), z.string()) .describe("Map of string keys to string values"), }, }, - async ({ name, data }) => { + async ({ name, description, data }) => { try { const env = await this.environmentService.create({ name, + description, data: data as EnvironmentData, }); return { @@ -207,20 +212,26 @@ export class McpService { server.registerTool( "update_environment", { - description: "Update an existing environment (name and/or data)", + description: + "Update an existing environment (name, description, and/or data)", inputSchema: { id: z.uuid().describe("Environment ID to update"), name: z.string().optional().describe("New name"), + description: z + .string() + .optional() + .describe("New markdown description"), data: z .record(z.string(), z.string()) .optional() .describe("New data map"), }, }, - async ({ id, name, data }) => { + async ({ id, name, description, data }) => { try { const env = await this.environmentService.update(id, { name, + description, data: data as EnvironmentData | undefined, }); return { @@ -457,11 +468,18 @@ export class McpService { description: "Create a new scenario", inputSchema: { name: z.string().describe("Scenario name"), + description: z + .string() + .optional() + .describe("Optional markdown description"), }, }, - async ({ name }) => { + async ({ name, description }) => { try { - const scenario = await this.scenarioService.create({ name }); + const scenario = await this.scenarioService.create({ + name, + description, + }); return { content: [ { type: "text" as const, text: JSON.stringify(scenario) }, @@ -479,15 +497,22 @@ export class McpService { server.registerTool( "update_scenario", { - description: "Update a scenario name", + description: "Update a scenario name and/or description", inputSchema: { id: z.uuid().describe("Scenario ID"), name: z.string().optional().describe("New name"), + description: z + .string() + .optional() + .describe("New markdown description"), }, }, - async ({ id, name }) => { + async ({ id, name, description }) => { try { - const scenario = await this.scenarioService.update(id, { name }); + const scenario = await this.scenarioService.update(id, { + name, + description, + }); return { content: [ { type: "text" as const, text: JSON.stringify(scenario) }, @@ -951,6 +976,10 @@ export class McpService { "Import a scenario from an export payload, creating a new scenario with all its steps", inputSchema: { name: z.string().describe("Scenario name"), + description: z + .string() + .optional() + .describe("Optional markdown description"), steps: z .array( z.object({ @@ -967,10 +996,11 @@ export class McpService { .describe("Ordered list of steps"), }, }, - async ({ name, steps }) => { + async ({ name, description, steps }) => { try { const scenario = await this.scenarioService.importScenario({ name, + description, steps: steps as Parameters< typeof this.scenarioService.importScenario >[0]["steps"], diff --git a/server/src/scenario/dto/create-scenario.dto.ts b/server/src/scenario/dto/create-scenario.dto.ts index b62a60c..428a342 100644 --- a/server/src/scenario/dto/create-scenario.dto.ts +++ b/server/src/scenario/dto/create-scenario.dto.ts @@ -1,9 +1,14 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsNotEmpty, IsString } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsNotEmpty, IsOptional, IsString } from "class-validator"; export class CreateScenarioDto { @ApiProperty({ example: "Login and verify cabinet" }) @IsString() @IsNotEmpty() name: string; + + @ApiPropertyOptional({ description: "Optional markdown description" }) + @IsOptional() + @IsString() + description?: string; } diff --git a/server/src/scenario/dto/scenario-export.dto.ts b/server/src/scenario/dto/scenario-export.dto.ts index 12ed015..d34eef0 100644 --- a/server/src/scenario/dto/scenario-export.dto.ts +++ b/server/src/scenario/dto/scenario-export.dto.ts @@ -46,6 +46,11 @@ export class ScenarioExportDto { @IsNotEmpty() name: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + @ApiProperty({ type: [ScenarioStepExportDto] }) @IsArray() @ValidateNested({ each: true }) diff --git a/server/src/scenario/dto/update-scenario.dto.ts b/server/src/scenario/dto/update-scenario.dto.ts index 5ea98ed..7d920cf 100644 --- a/server/src/scenario/dto/update-scenario.dto.ts +++ b/server/src/scenario/dto/update-scenario.dto.ts @@ -7,4 +7,9 @@ export class UpdateScenarioDto { @IsString() @IsNotEmpty() name?: string; + + @ApiPropertyOptional({ description: "Optional markdown description" }) + @IsOptional() + @IsString() + description?: string; } diff --git a/server/src/scenario/scenario.entity.ts b/server/src/scenario/scenario.entity.ts index e22b66c..1338894 100644 --- a/server/src/scenario/scenario.entity.ts +++ b/server/src/scenario/scenario.entity.ts @@ -17,6 +17,9 @@ export class ScenarioEntity { @Column() name: string; + @Column("text", { nullable: true }) + description: string | null; + @OneToMany(() => ScenarioStepEntity, (step) => step.scenario, { cascade: true, eager: false, diff --git a/server/src/scenario/scenario.service.ts b/server/src/scenario/scenario.service.ts index b7d2c76..0751c7a 100644 --- a/server/src/scenario/scenario.service.ts +++ b/server/src/scenario/scenario.service.ts @@ -410,6 +410,7 @@ export class ScenarioService { kind: "scenario", id: scenario.id, name: scenario.name, + description: scenario.description ?? undefined, steps: scenario.steps.map((s) => ({ title: s.title, execCode: s.execCode, @@ -424,17 +425,25 @@ export class ScenarioService { const existing = await this.scenarioRepo.findOneBy({ id: dto.id }); if (existing) { existing.name = dto.name; + existing.description = dto.description ?? null; 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 }), + this.scenarioRepo.create({ + id: dto.id, + name: dto.name, + description: dto.description ?? null, + }), ); } } else { scenario = await this.scenarioRepo.save( - this.scenarioRepo.create({ name: dto.name }), + this.scenarioRepo.create({ + name: dto.name, + description: dto.description ?? null, + }), ); } if (dto.steps.length > 0) {