diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 31b4971..4766f14 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -118,13 +118,13 @@ export const environments = { get(id: string): Promise { return request(`/environments/${id}`); }, - create(name: string, urls: Environment['urls']): Promise { + create(name: string, data: Environment['data']): Promise { return request('/environments', { method: 'POST', - body: JSON.stringify({ name, urls }), + body: JSON.stringify({ name, data }), }); }, - update(id: string, patch: Partial>): Promise { + update(id: string, patch: Partial>): Promise { return request(`/environments/${id}`, { method: 'PATCH', body: JSON.stringify(patch), diff --git a/client/src/api/types.ts b/client/src/api/types.ts index c3f980f..3c3af0d 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -9,17 +9,14 @@ export interface PaginatedResponse { // ── Environments ────────────────────────────────────────────────────────────── -export interface EnvironmentUrls { - id_url?: string; - cabinet_url?: string; - admin_url?: string; +export interface EnvironmentData { [key: string]: string | undefined; } export interface Environment { id: string; name: string; - urls: EnvironmentUrls; + data: EnvironmentData; createdAt: string; updatedAt: string; } @@ -67,7 +64,6 @@ export interface ScenarioStep { scenarioId: string; order: number; title: string | null; - sessionName: string | null; execCode: string | null; validateCode: string | null; createdAt: string; diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index dbd5d27..5388a0c 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -21,11 +21,11 @@ "title": "Environments", "col_id": "ID", "col_name": "Name", - "col_urls": "URLs", + "col_data": "Data", "col_updated": "Updated", "empty": "No environments yet.", "loading": "Loading…", - "no_urls": "No URLs configured.", + "no_data": "No data configured.", "menu_label": "Environment options", "action_view": "View details", "action_delete": "Delete", @@ -41,14 +41,13 @@ "form_name": "Name", "form_name_placeholder": "e.g. staging, production", "form_name_required": "Name is required", - "form_id_url": "ID URL", - "form_cabinet_url": "Cabinet URL", - "form_admin_url": "Admin URL", + "form_data": "Data (JSON)", + "form_data_invalid_json": "Must be a valid JSON object", "back": "Environments", "field_id": "ID", "field_created": "Created", "field_updated": "Updated", - "section_urls": "URLs" + "section_data": "Data" }, "credentials": { "title": "Credentials", diff --git a/client/src/pages/environment/CreateEnvironmentPage.tsx b/client/src/pages/environment/CreateEnvironmentPage.tsx index c6f290b..9ea9e2f 100644 --- a/client/src/pages/environment/CreateEnvironmentPage.tsx +++ b/client/src/pages/environment/CreateEnvironmentPage.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { X, Globe, Save } from 'lucide-react'; import { environments } from '../../api'; -import { Breadcrumbs, Button, Card, Input } from '../../ui'; +import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui'; import styles from '../Page.module.css'; export function CreateEnvironmentPage() { @@ -11,10 +11,9 @@ export function CreateEnvironmentPage() { const navigate = useNavigate(); const [name, setName] = useState(''); - const [idUrl, setIdUrl] = useState(''); - const [cabinetUrl, setCabinetUrl] = useState(''); - const [adminUrl, setAdminUrl] = useState(''); + const [dataJson, setDataJson] = useState('{\n "id": "https://id.example.com"\n}'); const [nameError, setNameError] = useState(''); + const [dataError, setDataError] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -24,14 +23,27 @@ export function CreateEnvironmentPage() { setNameError(t('environments.form_name_required')); return; } + let parsedData: Record; + try { + const raw = dataJson.trim() ? JSON.parse(dataJson.trim()) : {}; + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new Error('invalid'); + } + parsedData = Object.fromEntries( + Object.entries(raw as Record).map(([k, v]) => [ + k, + v == null ? undefined : String(v), + ]), + ); + setDataError(''); + } catch { + setDataError(t('environments.form_data_invalid_json')); + return; + } setSaving(true); setError(null); try { - const env = await environments.create(name.trim(), { - id_url: idUrl.trim() || undefined, - cabinet_url: cabinetUrl.trim() || undefined, - admin_url: adminUrl.trim() || undefined, - }); + const env = await environments.create(name.trim(), parsedData); navigate(`/environments/${env.id}`); } catch (err) { setError((err as Error).message); @@ -68,27 +80,20 @@ export function CreateEnvironmentPage() { required autoFocus /> - setIdUrl(e.target.value)} - /> - setCabinetUrl(e.target.value)} - /> - setAdminUrl(e.target.value)} - /> +
+ + { + setDataJson(v); + setDataError(''); + }} + rows={8} + error={!!dataError} + /> + {dataError && {dataError}} +
diff --git a/client/src/pages/environment/EditEnvironmentPage.tsx b/client/src/pages/environment/EditEnvironmentPage.tsx index 825583c..03b6185 100644 --- a/client/src/pages/environment/EditEnvironmentPage.tsx +++ b/client/src/pages/environment/EditEnvironmentPage.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { X, Globe, Save } from 'lucide-react'; import { environments } from '../../api'; import type { Environment } from '../../api'; -import { Breadcrumbs, Button, Card, Input } from '../../ui'; +import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui'; import styles from '../Page.module.css'; export function EditEnvironmentPage() { @@ -14,10 +14,9 @@ export function EditEnvironmentPage() { const [env, setEnv] = useState(null); const [name, setName] = useState(''); - const [idUrl, setIdUrl] = useState(''); - const [cabinetUrl, setCabinetUrl] = useState(''); - const [adminUrl, setAdminUrl] = useState(''); + const [dataJson, setDataJson] = useState('{}'); const [nameError, setNameError] = useState(''); + const [dataError, setDataError] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -29,9 +28,7 @@ export function EditEnvironmentPage() { .then((data) => { setEnv(data); setName(data.name); - setIdUrl(data.urls.id_url ?? ''); - setCabinetUrl(data.urls.cabinet_url ?? ''); - setAdminUrl(data.urls.admin_url ?? ''); + setDataJson(JSON.stringify(data.data ?? {}, null, 2)); }) .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); @@ -43,16 +40,29 @@ export function EditEnvironmentPage() { setNameError(t('environments.form_name_required')); return; } + let parsedData: Record; + try { + const raw = dataJson.trim() ? JSON.parse(dataJson.trim()) : {}; + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new Error('invalid'); + } + parsedData = Object.fromEntries( + Object.entries(raw as Record).map(([k, v]) => [ + k, + v == null ? undefined : String(v), + ]), + ); + setDataError(''); + } catch { + setDataError(t('environments.form_data_invalid_json')); + return; + } setSaving(true); setError(null); try { await environments.update(id!, { name: name.trim(), - urls: { - id_url: idUrl.trim() || undefined, - cabinet_url: cabinetUrl.trim() || undefined, - admin_url: adminUrl.trim() || undefined, - }, + data: parsedData, }); navigate(`/environments/${id}`); } catch (err) { @@ -96,27 +106,20 @@ export function EditEnvironmentPage() { required autoFocus /> - setIdUrl(e.target.value)} - /> - setCabinetUrl(e.target.value)} - /> - setAdminUrl(e.target.value)} - /> +
+ + { + setDataJson(v); + setDataError(''); + }} + rows={8} + error={!!dataError} + /> + {dataError && {dataError}} +
diff --git a/client/src/pages/environment/EnvironmentDetailPage.tsx b/client/src/pages/environment/EnvironmentDetailPage.tsx index 70c1eba..db7242e 100644 --- a/client/src/pages/environment/EnvironmentDetailPage.tsx +++ b/client/src/pages/environment/EnvironmentDetailPage.tsx @@ -105,14 +105,14 @@ export function EnvironmentDetailPage() {
-

{t('environments.section_urls')}

+

{t('environments.section_data')}

- {Object.entries(env.urls).filter(([, v]) => v).length === 0 ? ( -

{t('environments.no_urls')}

+ {Object.entries(env.data).filter(([, v]) => v).length === 0 ? ( +

{t('environments.no_data')}

) : ( v) .map(([key, value]) => ({ term: key, diff --git a/client/src/pages/environment/EnvironmentsPage.tsx b/client/src/pages/environment/EnvironmentsPage.tsx index 2bdcc3b..4bb5c26 100644 --- a/client/src/pages/environment/EnvironmentsPage.tsx +++ b/client/src/pages/environment/EnvironmentsPage.tsx @@ -20,7 +20,7 @@ import styles from '../Page.module.css'; function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: string) => void }) { const { t } = useTranslation(); const navigate = useNavigate(); - const urlEntries = Object.entries(env.urls).filter(([, v]) => v); + const dataEntries = Object.entries(env.data).filter(([, v]) => v); const handleExport = async () => { const data = await environments.exportEnvironment(env.id); @@ -87,15 +87,15 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: s footer={footer} onClick={() => navigate(`/environments/${env.id}`)} > - {urlEntries.length > 0 && ( + {dataEntries.length > 0 && ( ({ term: key, detail: value }))} + items={dataEntries.map(([key, value]) => ({ term: key, detail: value }))} /> )} - {urlEntries.length === 0 && ( -

{t('environments.no_urls')}

+ {dataEntries.length === 0 && ( +

{t('environments.no_data')}

)}
); diff --git a/server/src/code-executor/code-executor.service.ts b/server/src/code-executor/code-executor.service.ts index 3fdef76..4f66568 100644 --- a/server/src/code-executor/code-executor.service.ts +++ b/server/src/code-executor/code-executor.service.ts @@ -7,7 +7,7 @@ import { TraceLogger } from "../common/trace-logger"; import { parse } from "acorn"; import type { Page, BrowserContext } from "playwright"; import { dumpDom } from "./dom-helpers"; -import type { EnvironmentUrls } from "../environment/environment.entity"; +import type { EnvironmentData } from "../environment/environment.entity"; export interface ExecResult { result: unknown; @@ -49,7 +49,7 @@ export class CodeExecutorService { log?: ScriptLogger, getStepOutput?: (order: number) => Promise, credentials?: Record, - environment?: EnvironmentUrls | null, + environment?: EnvironmentData | null, snippets?: Record | null, result?: unknown, ): Promise { @@ -61,7 +61,7 @@ export class CodeExecutorService { .join(" "); const credMap: Record = credentials ?? {}; - const envUrls: EnvironmentUrls = environment ?? {}; + const envData: EnvironmentData = environment ?? {}; const snippetMap: Record = snippets ?? {}; // pageHelpers is referenced by runSnippet, so we declare it as a var first. @@ -91,14 +91,14 @@ export class CodeExecutorService { } return credMap[alias]; }, - /** All URLs defined for the current environment (may be empty if no environment is set). */ - env: { ...envUrls }, - /** Returns the URL for the given key, or throws if it is not defined. */ + /** All values defined for the current environment (may be empty if no environment is set). */ + env: { ...envData }, + /** Returns the value for the given key, or throws if it is not defined. */ getEnvUrl: (key: string): string => { - const value = envUrls[key]; + const value = envData[key]; if (value == null) { throw new Error( - `Environment URL "${key}" is not defined for this environment`, + `Environment value "${key}" is not defined for this environment`, ); } return value; diff --git a/server/src/environment/dto/create-environment.dto.ts b/server/src/environment/dto/create-environment.dto.ts index b356001..5d2b42b 100644 --- a/server/src/environment/dto/create-environment.dto.ts +++ b/server/src/environment/dto/create-environment.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from "@nestjs/swagger"; import { IsNotEmpty, IsObject, IsString } from "class-validator"; -import { EnvironmentUrls } from "../environment.entity"; +import { EnvironmentData } from "../environment.entity"; export class CreateEnvironmentDto { @ApiProperty({ example: "liquio-diia-stg" }) @@ -9,13 +9,13 @@ export class CreateEnvironmentDto { name: string; @ApiProperty({ - description: "Map of URL identifiers to URL strings", + description: "Generic key-value map for environment metadata", example: { - id_url: "https://id-liquio-diia-stg.kitsoft.ua/", - cabinet_url: "https://cabinet-liquio-diia-stg.kitsoft.ua/", - admin_url: "https://admin-liquio-diia-stg.kitsoft.ua/", + id: "https://id-liquio-diia-stg.kitsoft.ua/", + cabinet: "https://cabinet-liquio-diia-stg.kitsoft.ua/", + feature_flag: "enabled", }, }) @IsObject() - urls: EnvironmentUrls; + data: EnvironmentData; } diff --git a/server/src/environment/dto/environment-export.dto.ts b/server/src/environment/dto/environment-export.dto.ts index 5c1f1bf..ecc1852 100644 --- a/server/src/environment/dto/environment-export.dto.ts +++ b/server/src/environment/dto/environment-export.dto.ts @@ -7,7 +7,7 @@ import { IsString, IsUUID, } from "class-validator"; -import { EnvironmentUrls } from "../environment.entity"; +import { EnvironmentData } from "../environment.entity"; export class EnvironmentExportDto { @ApiPropertyOptional() @@ -27,5 +27,5 @@ export class EnvironmentExportDto { @ApiProperty() @IsObject() - urls: EnvironmentUrls; + data: EnvironmentData; } diff --git a/server/src/environment/environment.entity.ts b/server/src/environment/environment.entity.ts index d0edc64..3fbf1f1 100644 --- a/server/src/environment/environment.entity.ts +++ b/server/src/environment/environment.entity.ts @@ -6,10 +6,7 @@ import { UpdateDateColumn, } from "typeorm"; -export interface EnvironmentUrls { - id_url?: string; - cabinet_url?: string; - admin_url?: string; +export interface EnvironmentData { [key: string]: string | undefined; } @@ -22,7 +19,7 @@ export class EnvironmentEntity { name: string; @Column("simple-json") - urls: EnvironmentUrls; + data: EnvironmentData; @CreateDateColumn() createdAt: Date; diff --git a/server/src/environment/environment.service.ts b/server/src/environment/environment.service.ts index b5bf9f2..ad0e676 100644 --- a/server/src/environment/environment.service.ts +++ b/server/src/environment/environment.service.ts @@ -71,7 +71,7 @@ export class EnvironmentService { kind: "environment", id: env.id, name: env.name, - urls: env.urls, + data: env.data, }; } @@ -79,18 +79,18 @@ export class EnvironmentService { if (dto.id) { const existing = await this.repo.findOneBy({ id: dto.id }); if (existing) { - Object.assign(existing, { name: dto.name, urls: dto.urls }); + Object.assign(existing, { name: dto.name, data: dto.data }); return this.repo.save(existing); } return this.repo.save( - this.repo.create({ id: dto.id, name: dto.name, urls: dto.urls }), + this.repo.create({ id: dto.id, name: dto.name, data: dto.data }), ); } const byName = await this.repo.findOneBy({ name: dto.name }); if (byName) { - Object.assign(byName, { urls: dto.urls }); + Object.assign(byName, { data: dto.data }); return this.repo.save(byName); } - return this.repo.save(this.repo.create({ name: dto.name, urls: dto.urls })); + return this.repo.save(this.repo.create({ name: dto.name, data: dto.data })); } } diff --git a/server/src/mcp/mcp.service.ts b/server/src/mcp/mcp.service.ts index 16d71c8..c6542f5 100644 --- a/server/src/mcp/mcp.service.ts +++ b/server/src/mcp/mcp.service.ts @@ -6,7 +6,7 @@ import type { Request, Response } from "express"; import { SessionService } from "../session/session.service"; import { SessionContextService } from "../session/session-context.service"; import { EnvironmentService } from "../environment/environment.service"; -import type { EnvironmentUrls } from "../environment/environment.entity"; +import type { EnvironmentData } from "../environment/environment.entity"; import { BrowserService } from "../browser/browser.service"; import { CodeExecutorService } from "../code-executor/code-executor.service"; import { ScenarioService } from "../scenario/scenario.service"; @@ -174,23 +174,21 @@ export class McpService { server.registerTool( "create_environment", { - description: "Create a new named environment with a set of URLs", + description: "Create a new named environment with generic data", inputSchema: { name: z .string() .describe("Unique environment name, e.g. liquio-diia-stg"), - urls: z + data: z .record(z.string(), z.string()) - .describe( - "Map of URL keys to URL strings (id_url, cabinet_url, admin_url, …)", - ), + .describe("Map of string keys to string values"), }, }, - async ({ name, urls }) => { + async ({ name, data }) => { try { const env = await this.environmentService.create({ name, - urls: urls as EnvironmentUrls, + data: data as EnvironmentData, }); return { content: [{ type: "text" as const, text: JSON.stringify(env) }], @@ -207,21 +205,21 @@ export class McpService { server.registerTool( "update_environment", { - description: "Update an existing environment (name and/or urls)", + description: "Update an existing environment (name and/or data)", inputSchema: { id: z.string().uuid().describe("Environment ID to update"), name: z.string().optional().describe("New name"), - urls: z + data: z .record(z.string(), z.string()) .optional() - .describe("New URLs map"), + .describe("New data map"), }, }, - async ({ id, name, urls }) => { + async ({ id, name, data }) => { try { const env = await this.environmentService.update(id, { name, - urls: urls as EnvironmentUrls | undefined, + data: data as EnvironmentData | undefined, }); return { content: [{ type: "text" as const, text: JSON.stringify(env) }], diff --git a/server/test/environment.controller.spec.ts b/server/test/environment.controller.spec.ts index f354acf..c4e3939 100644 --- a/server/test/environment.controller.spec.ts +++ b/server/test/environment.controller.spec.ts @@ -19,44 +19,44 @@ describe("EnvironmentController", () => { it("creates an environment and returns 201", async () => { const res = await request(app.getHttpServer()) .post("/environments") - .send({ name: "env-a", urls: { id_url: "https://id.example.com" } }) + .send({ name: "env-a", data: { id: "https://id.example.com" } }) .expect(201); expect(res.body.id).toBeDefined(); expect(res.body.name).toBe("env-a"); - expect(res.body.urls.id_url).toBe("https://id.example.com"); + expect(res.body.data.id).toBe("https://id.example.com"); }); it("returns 400 when name is missing", async () => { await request(app.getHttpServer()) .post("/environments") - .send({ urls: { id_url: "https://id.example.com" } }) + .send({ data: { id: "https://id.example.com" } }) .expect(400); }); - it("returns 400 when urls is missing", async () => { + it("returns 400 when data is missing", async () => { await request(app.getHttpServer()) .post("/environments") - .send({ name: "env-no-urls" }) + .send({ name: "env-no-data" }) .expect(400); }); - it("returns 400 when urls is not an object", async () => { + it("returns 400 when data is not an object", async () => { await request(app.getHttpServer()) .post("/environments") - .send({ name: "env-bad-urls", urls: "not-an-object" }) + .send({ name: "env-bad-data", data: "not-an-object" }) .expect(400); }); it("returns 409 when name already exists", async () => { await request(app.getHttpServer()) .post("/environments") - .send({ name: "env-duplicate", urls: {} }) + .send({ name: "env-duplicate", data: {} }) .expect(201); await request(app.getHttpServer()) .post("/environments") - .send({ name: "env-duplicate", urls: {} }) + .send({ name: "env-duplicate", data: {} }) .expect(409); }); }); @@ -78,11 +78,11 @@ describe("EnvironmentController", () => { // seed two extra environments await request(app.getHttpServer()) .post("/environments") - .send({ name: "env-page-1", urls: {} }) + .send({ name: "env-page-1", data: {} }) .expect(201); await request(app.getHttpServer()) .post("/environments") - .send({ name: "env-page-2", urls: {} }) + .send({ name: "env-page-2", data: {} }) .expect(201); const res = await request(app.getHttpServer()) @@ -109,10 +109,10 @@ describe("EnvironmentController", () => { it("orders by name ASC", async () => { await request(app.getHttpServer()) .post("/environments") - .send({ name: "zzz-env", urls: {} }); + .send({ name: "zzz-env", data: {} }); await request(app.getHttpServer()) .post("/environments") - .send({ name: "aaa-env", urls: {} }); + .send({ name: "aaa-env", data: {} }); const res = await request(app.getHttpServer()) .get("/environments?orderBy=name&orderDir=ASC") @@ -148,7 +148,7 @@ describe("EnvironmentController", () => { .post("/environments") .send({ name: "env-get-one", - urls: { cabinet_url: "https://cabinet.example.com" }, + data: { cabinet: "https://cabinet.example.com" }, }) .expect(201); @@ -174,7 +174,7 @@ describe("EnvironmentController", () => { it("updates name and returns 200", async () => { const created = await request(app.getHttpServer()) .post("/environments") - .send({ name: "env-patch-me", urls: {} }) + .send({ name: "env-patch-me", data: {} }) .expect(201); const res = await request(app.getHttpServer()) @@ -199,7 +199,7 @@ describe("EnvironmentController", () => { it("deletes and returns 204", async () => { const created = await request(app.getHttpServer()) .post("/environments") - .send({ name: "env-delete-me", urls: {} }) + .send({ name: "env-delete-me", data: {} }) .expect(201); await request(app.getHttpServer()) diff --git a/server/test/mcp.controller.spec.ts b/server/test/mcp.controller.spec.ts index 9baa5d6..ba04ae2 100644 --- a/server/test/mcp.controller.spec.ts +++ b/server/test/mcp.controller.spec.ts @@ -118,7 +118,7 @@ describe("McpController", () => { it("creates an environment via MCP", async () => { const { status, rpc } = await mcpCall("create_environment", { name: "mcp-test-env", - urls: { id_url: "https://id.example.com" }, + data: { id: "https://id.example.com" }, }); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] };