From 32be7c0a59f44b10b9eafa26e990a865eef20f3b Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Fri, 10 Apr 2026 13:09:09 +0300 Subject: [PATCH] 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 --- client/package.json | 3 +- client/src/api/client.ts | 83 ++++++++++++------- client/src/api/types.ts | 36 ++++---- client/src/i18n/locales/en.json | 13 ++- .../pages/credential/CredentialDetailPage.tsx | 25 +++++- .../src/pages/credential/CredentialsPage.tsx | 46 ++++++++-- .../pages/credential/EditCredentialPage.tsx | 4 +- .../pages/environment/EditEnvironmentPage.tsx | 4 +- .../environment/EnvironmentDetailPage.tsx | 2 +- .../pages/environment/EnvironmentsPage.tsx | 8 +- client/src/pages/run/AllRunsPage.tsx | 4 +- client/src/pages/run/RunDetailPage.tsx | 7 +- client/src/pages/run/RunsPage.tsx | 8 +- client/src/pages/scenario/CreateStepPage.tsx | 4 +- .../src/pages/scenario/EditScenarioPage.tsx | 4 +- client/src/pages/scenario/EditStepPage.tsx | 4 +- .../src/pages/scenario/ScenarioDetailPage.tsx | 40 ++++++--- client/src/pages/scenario/ScenariosPage.tsx | 40 +++++++-- .../src/pages/session/SessionDetailPage.tsx | 6 +- client/src/pages/session/SessionsPage.tsx | 6 +- client/src/pages/snippet/EditSnippetPage.tsx | 4 +- .../src/pages/snippet/SnippetDetailPage.tsx | 25 +++++- client/src/pages/snippet/SnippetsPage.tsx | 42 ++++++++-- client/src/ui/UuidBadge/UuidBadge.module.css | 38 +++++++++ client/src/ui/UuidBadge/UuidBadge.tsx | 36 ++++++++ client/src/ui/index.ts | 3 + package-lock.json | 18 +++- .../src/credential/credential.controller.ts | 25 +++++- server/src/credential/credential.entity.ts | 4 +- server/src/credential/credential.service.ts | 27 +++++- .../credential/dto/credential-export.dto.ts | 24 ++++++ .../src/environment/environment.controller.ts | 8 +- server/src/environment/environment.entity.ts | 4 +- server/src/environment/environment.service.ts | 6 +- server/src/mcp/mcp.service.ts | 44 +++++----- .../dto/add-scenario-credential.dto.ts | 10 +-- .../src/scenario/dto/scenario-export.dto.ts | 22 ++++- .../scenario/scenario-credential.entity.ts | 12 +-- .../src/scenario/scenario-run-log.entity.ts | 12 +-- .../src/scenario/scenario-run-step.entity.ts | 12 +-- server/src/scenario/scenario-run.entity.ts | 8 +- .../scenario/scenario-scheduler.service.ts | 24 +++--- server/src/scenario/scenario-step.entity.ts | 8 +- server/src/scenario/scenario.controller.ts | 44 +++++----- server/src/scenario/scenario.entity.ts | 4 +- server/src/scenario/scenario.service.ts | 70 ++++++++++------ server/src/session/session-context.service.ts | 2 +- server/src/session/session.controller.ts | 6 +- server/src/session/session.entity.ts | 4 +- server/src/session/session.service.ts | 4 +- server/src/snippet/dto/snippet-export.dto.ts | 28 +++++++ server/src/snippet/snippet.controller.ts | 25 +++++- server/src/snippet/snippet.entity.ts | 4 +- server/src/snippet/snippet.service.ts | 46 +++++++++- 54 files changed, 728 insertions(+), 272 deletions(-) create mode 100644 client/src/ui/UuidBadge/UuidBadge.module.css create mode 100644 client/src/ui/UuidBadge/UuidBadge.tsx create mode 100644 server/src/credential/dto/credential-export.dto.ts create mode 100644 server/src/snippet/dto/snippet-export.dto.ts diff --git a/client/package.json b/client/package.json index e311f5e..e136da7 100644 --- a/client/package.json +++ b/client/package.json @@ -20,7 +20,8 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^17.0.2", - "react-router-dom": "^7.14.0" + "react-router-dom": "^7.14.0", + "yaml": "^2.8.3" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/client/src/api/client.ts b/client/src/api/client.ts index e3f09bc..762a293 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -39,7 +39,7 @@ export const credentials = { list(page = 1, limit = 50): Promise> { return request(`/credentials?page=${page}&limit=${limit}`); }, - get(id: number): Promise { + get(id: string): Promise { return request(`/credentials/${id}`); }, create(name: string, data?: string): Promise { @@ -48,15 +48,24 @@ export const credentials = { body: JSON.stringify({ name, data }), }); }, - update(id: number, patch: Partial>): Promise { + update(id: string, patch: Partial>): Promise { return request(`/credentials/${id}`, { method: 'PATCH', body: JSON.stringify(patch), }); }, - remove(id: number): Promise { + remove(id: string): Promise { return request(`/credentials/${id}`, { method: 'DELETE' }); }, + exportCredential(id: string): Promise { + return request(`/credentials/${id}/export`); + }, + importCredential(payload: unknown): Promise { + return request('/credentials/import', { + method: 'POST', + body: JSON.stringify(payload), + }); + }, }; // ── Snippets ────────────────────────────────────────────────────────────────── @@ -65,7 +74,7 @@ export const snippets = { list(page = 1, limit = 50): Promise> { return request(`/snippets?page=${page}&limit=${limit}`); }, - get(id: number): Promise { + get(id: string): Promise { return request(`/snippets/${id}`); }, create(payload: Pick & { description?: string }): Promise { @@ -74,15 +83,24 @@ export const snippets = { body: JSON.stringify(payload), }); }, - update(id: number, patch: Partial>): Promise { + update(id: string, patch: Partial>): Promise { return request(`/snippets/${id}`, { method: 'PATCH', body: JSON.stringify(patch), }); }, - remove(id: number): Promise { + remove(id: string): Promise { return request(`/snippets/${id}`, { method: 'DELETE' }); }, + exportSnippet(id: string): Promise { + return request(`/snippets/${id}/export`); + }, + importSnippet(payload: unknown): Promise { + return request('/snippets/import', { + method: 'POST', + body: JSON.stringify(payload), + }); + }, }; // ── Environments ────────────────────────────────────────────────────────────── @@ -91,7 +109,7 @@ export const environments = { list(page = 1, limit = 50): Promise> { return request(`/environments?page=${page}&limit=${limit}`); }, - get(id: number): Promise { + get(id: string): Promise { return request(`/environments/${id}`); }, create(name: string, urls: Environment['urls']): Promise { @@ -100,13 +118,13 @@ export const environments = { body: JSON.stringify({ name, urls }), }); }, - update(id: number, patch: Partial>): Promise { + update(id: string, patch: Partial>): Promise { return request(`/environments/${id}`, { method: 'PATCH', body: JSON.stringify(patch), }); }, - remove(id: number): Promise { + remove(id: string): Promise { return request(`/environments/${id}`, { method: 'DELETE' }); }, }; @@ -125,10 +143,10 @@ export const sessions = { list(page = 1, limit = 50): Promise> { return request(`/sessions?page=${page}&limit=${limit}`); }, - get(id: number): Promise { + get(id: string): Promise { return request(`/sessions/${id}`); }, - remove(id: number): Promise { + remove(id: string): Promise { return request(`/sessions/${id}`, { method: 'DELETE' }); }, }; @@ -139,7 +157,7 @@ export const scenarios = { list(page = 1, limit = 50): Promise> { return request(`/scenarios?page=${page}&limit=${limit}`); }, - get(id: number): Promise { + get(id: string): Promise { return request(`/scenarios/${id}`); }, create(name: string): Promise { @@ -148,41 +166,50 @@ export const scenarios = { body: JSON.stringify({ name }), }); }, - update(id: number, patch: Partial>): Promise { + update(id: string, patch: Partial>): Promise { return request(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(patch), }); }, - remove(id: number): Promise { + remove(id: string): Promise { return request(`/scenarios/${id}`, { method: 'DELETE' }); }, - run(id: number): Promise { + run(id: string): Promise { return request(`/scenarios/${id}/run`, { method: 'POST' }); }, - getRun(scenarioId: number, runId: number): Promise { + getRun(scenarioId: string, runId: string): Promise { return request(`/scenarios/${scenarioId}/run/${runId}`); }, - waitForRun(scenarioId: number, runId: number): Promise { + waitForRun(scenarioId: string, runId: string): Promise { return request(`/scenarios/${scenarioId}/run/${runId}/wait`, { method: 'POST' }); }, - listRuns(scenarioId: number, page = 1, limit = 20): Promise> { + listRuns(scenarioId: string, page = 1, limit = 20): Promise> { return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`); }, + exportScenario(id: string): Promise { + return request(`/scenarios/${id}/export`); + }, + importScenario(payload: unknown): Promise { + return request('/scenarios/import', { + method: 'POST', + body: JSON.stringify(payload), + }); + }, }; // ── Runs ───────────────────────────────────────────────────────────────────── export const runs = { - listAll(page = 1, limit = 20, status?: string): Promise> { + listAll(page = 1, limit = 20, status?: string): Promise> { const q = new URLSearchParams({ page: String(page), limit: String(limit) }); if (status) q.set('status', status); return request(`/scenarios/runs?${q}`); }, - list(scenarioId: number, page = 1, limit = 20): Promise> { + list(scenarioId: string, page = 1, limit = 20): Promise> { return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`); }, - get(scenarioId: number, runId: number): Promise { + get(scenarioId: string, runId: string): Promise { return request(`/scenarios/${scenarioId}/run/${runId}`); }, }; @@ -190,16 +217,16 @@ export const runs = { // ── Scenario Credentials ────────────────────────────────────────────────────── export const scenarioCredentials = { - list(scenarioId: number): Promise { + list(scenarioId: string): Promise { return request(`/scenarios/${scenarioId}/credentials`); }, - add(scenarioId: number, credentialId: number, alias: string): Promise { + add(scenarioId: string, credentialId: string, alias: string): Promise { return request(`/scenarios/${scenarioId}/credentials`, { method: 'POST', body: JSON.stringify({ credentialId, alias }), }); }, - remove(scenarioId: number, scCredId: number): Promise { + remove(scenarioId: string, scCredId: string): Promise { return request(`/scenarios/${scenarioId}/credentials/${scCredId}`, { method: 'DELETE', }); @@ -224,22 +251,22 @@ export interface UpdateStepPayload { } export const steps = { - get(scenarioId: number, stepId: number): Promise { + get(scenarioId: string, stepId: string): Promise { return request(`/scenarios/${scenarioId}/steps/${stepId}`); }, - create(scenarioId: number, payload: CreateStepPayload): Promise { + create(scenarioId: string, payload: CreateStepPayload): Promise { return request(`/scenarios/${scenarioId}/steps`, { method: 'POST', body: JSON.stringify(payload), }); }, - update(scenarioId: number, stepId: number, payload: UpdateStepPayload): Promise { + update(scenarioId: string, stepId: string, payload: UpdateStepPayload): Promise { return request(`/scenarios/${scenarioId}/steps/${stepId}`, { method: 'PATCH', body: JSON.stringify(payload), }); }, - remove(scenarioId: number, stepId: number): Promise { + remove(scenarioId: string, stepId: string): Promise { return request(`/scenarios/${scenarioId}/steps/${stepId}`, { method: 'DELETE' }); }, }; diff --git a/client/src/api/types.ts b/client/src/api/types.ts index 823cd71..04f8ff4 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -17,7 +17,7 @@ export interface EnvironmentUrls { } export interface Environment { - id: number; + id: string; name: string; urls: EnvironmentUrls; createdAt: string; @@ -27,7 +27,7 @@ export interface Environment { // ── Credentials ─────────────────────────────────────────────────────────────── export interface Credential { - id: number; + id: string; name: string; data: string | null; lastUsedAt: string | null; @@ -38,7 +38,7 @@ export interface Credential { // ── Snippets ────────────────────────────────────────────────────────────────── export interface Snippet { - id: number; + id: string; name: string; description: string | null; code: string; @@ -57,7 +57,7 @@ export interface KeysResponse { export type SessionStatus = 'open' | 'closed'; export interface Session { - id: number; + id: string; sessionName: string; token: string; status: SessionStatus; @@ -71,8 +71,8 @@ export interface Session { export type StepType = 'login' | 'exec' | 'sign'; export interface ScenarioStep { - id: number; - scenarioId: number; + id: string; + scenarioId: string; order: number; type: StepType; title: string | null; @@ -84,16 +84,16 @@ export interface ScenarioStep { } export interface ScenarioCredential { - id: number; - scenarioId: number; - credentialId: number; + id: string; + scenarioId: string; + credentialId: string; alias: string; credential: Credential; createdAt: string; } export interface Scenario { - id: number; + id: string; name: string; steps?: ScenarioStep[]; scenarioCredentials?: ScenarioCredential[]; @@ -108,8 +108,8 @@ export type RunStepStatus = 'waiting' | 'pending' | 'in_progress' | 'pass' | 'fa export type LogLevel = 'log' | 'warn' | 'error'; export interface ScenarioRun { - id: number; - scenarioId: number; + id: string; + scenarioId: string; scenario?: Pick; status: ScenarioRunStatus; stepRuns?: ScenarioRunStep[]; @@ -118,9 +118,9 @@ export interface ScenarioRun { } export interface ScenarioRunStep { - id: number; - runId: number; - scenarioStepId: number; + id: string; + runId: string; + scenarioStepId: string; scenarioStep: ScenarioStep; status: RunStepStatus; order: number; @@ -131,9 +131,9 @@ export interface ScenarioRunStep { } export interface ScenarioRunLog { - id: number; - runId: number; - stepRunId: number | null; + id: string; + runId: string; + stepRunId: string | null; level: LogLevel; message: string; createdAt: string; diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index e4595ff..e766446 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -71,7 +71,10 @@ "field_last_used": "Last Used", "field_created": "Created", "field_updated": "Updated", - "section_data": "Data" + "section_data": "Data", + "action_export": "Export", + "action_import": "Import", + "import_error": "Failed to import credential" }, "keys": { "col_name": "Key name", @@ -116,6 +119,9 @@ "action_add": "New scenario", "action_edit": "Edit", "action_runs": "Runs", + "action_export": "Export", + "action_import": "Import", + "import_error": "Failed to import scenario", "action_save": "Create", "action_update": "Save changes", "action_cancel": "Cancel", @@ -228,6 +234,9 @@ "field_description": "Description", "field_created": "Created", "field_updated": "Updated", - "section_code": "Code" + "section_code": "Code", + "action_export": "Export", + "action_import": "Import", + "import_error": "Failed to import snippet" } } diff --git a/client/src/pages/credential/CredentialDetailPage.tsx b/client/src/pages/credential/CredentialDetailPage.tsx index 41fe258..5ac28e8 100644 --- a/client/src/pages/credential/CredentialDetailPage.tsx +++ b/client/src/pages/credential/CredentialDetailPage.tsx @@ -1,10 +1,11 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Pencil, Trash2 } from 'lucide-react'; +import { Download, Pencil, Trash2 } from 'lucide-react'; +import { stringify as yamlStringify } from 'yaml'; import { credentials } from '../../api'; import type { Credential } from '../../api'; -import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp } from '../../ui'; +import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp, UuidBadge } from '../../ui'; import styles from '../Page.module.css'; export function CredentialDetailPage() { @@ -18,7 +19,7 @@ export function CredentialDetailPage() { useEffect(() => { if (!id) return; credentials - .get(Number(id)) + .get(id) .then(setCredential) .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); @@ -30,6 +31,18 @@ export function CredentialDetailPage() { navigate('/credentials'); }; + const handleExport = async () => { + if (!credential) return; + const data = await credentials.exportCredential(credential.id); + const blob = new Blob([yamlStringify(data)], { type: 'application/yaml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `credential-${credential.name}.yaml`; + a.click(); + URL.revokeObjectURL(url); + }; + return (
@@ -41,6 +54,10 @@ export function CredentialDetailPage() { /> {credential && (
+ +
+
{error &&

{error}

} {loading ? (

{t('credentials.loading')}

diff --git a/client/src/pages/credential/EditCredentialPage.tsx b/client/src/pages/credential/EditCredentialPage.tsx index 0ccbffa..e9ecc03 100644 --- a/client/src/pages/credential/EditCredentialPage.tsx +++ b/client/src/pages/credential/EditCredentialPage.tsx @@ -23,7 +23,7 @@ export function EditCredentialPage() { useEffect(() => { if (!id) return; credentials - .get(Number(id)) + .get(id) .then((c) => { setCredential(c); setName(c.name); @@ -52,7 +52,7 @@ export function EditCredentialPage() { setSaving(true); setError(null); try { - await credentials.update(Number(id), { + await credentials.update(id!, { name: name.trim(), data: data.trim() || null, }); diff --git a/client/src/pages/environment/EditEnvironmentPage.tsx b/client/src/pages/environment/EditEnvironmentPage.tsx index 782d09d..9e7e070 100644 --- a/client/src/pages/environment/EditEnvironmentPage.tsx +++ b/client/src/pages/environment/EditEnvironmentPage.tsx @@ -24,7 +24,7 @@ export function EditEnvironmentPage() { useEffect(() => { if (!id) return; environments - .get(Number(id)) + .get(id) .then((data) => { setEnv(data); setName(data.name); @@ -45,7 +45,7 @@ export function EditEnvironmentPage() { setSaving(true); setError(null); try { - await environments.update(Number(id), { + await environments.update(id!, { name: name.trim(), urls: { id_url: idUrl.trim() || undefined, diff --git a/client/src/pages/environment/EnvironmentDetailPage.tsx b/client/src/pages/environment/EnvironmentDetailPage.tsx index de0d64d..47121ab 100644 --- a/client/src/pages/environment/EnvironmentDetailPage.tsx +++ b/client/src/pages/environment/EnvironmentDetailPage.tsx @@ -18,7 +18,7 @@ export function EnvironmentDetailPage() { useEffect(() => { if (!id) return; environments - .get(Number(id)) + .get(id) .then(setEnv) .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); diff --git a/client/src/pages/environment/EnvironmentsPage.tsx b/client/src/pages/environment/EnvironmentsPage.tsx index d378973..1f12f7f 100644 --- a/client/src/pages/environment/EnvironmentsPage.tsx +++ b/client/src/pages/environment/EnvironmentsPage.tsx @@ -4,10 +4,10 @@ import { useTranslation } from 'react-i18next'; import { Settings, ExternalLink, Pencil, Trash2, Plus } from 'lucide-react'; import { environments } from '../../api'; import type { Environment } from '../../api'; -import { Breadcrumbs, Button, Card, ContextMenu, DescriptionList, Timestamp } from '../../ui'; +import { Breadcrumbs, Button, Card, ContextMenu, DescriptionList, Timestamp, UuidBadge } from '../../ui'; import styles from '../Page.module.css'; -function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: number) => void }) { +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); @@ -48,7 +48,7 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: n const footer = (
- #{env.id} +
); @@ -106,7 +106,7 @@ export function EnvironmentsPage() { load(); }, []); - const handleDelete = async (id: number) => { + const handleDelete = async (id: string) => { await environments.remove(id); setItems((prev) => prev.filter((e) => e.id !== id)); }; diff --git a/client/src/pages/run/AllRunsPage.tsx b/client/src/pages/run/AllRunsPage.tsx index eed53c0..d1a5c3a 100644 --- a/client/src/pages/run/AllRunsPage.tsx +++ b/client/src/pages/run/AllRunsPage.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { runs } from '../../api'; import type { ScenarioRun, ScenarioRunStep, ScenarioRunStatus } from '../../api'; -import { AutoRefreshIndicator, Badge, Table, Timestamp, type TableColumn } from '../../ui'; +import { AutoRefreshIndicator, Badge, Table, Timestamp, UuidBadge, type TableColumn } from '../../ui'; import type { BadgeVariant } from '../../ui'; import styles from '../Page.module.css'; @@ -57,7 +57,7 @@ export function AllRunsPage() { }, []); const columns: TableColumn[] = [ - { key: 'id', header: t('runs.col_id'), render: (r) => r.id, width: 60 }, + { key: 'id', header: t('runs.col_id'), render: (r) => , width: 60 }, { key: 'scenario', header: t('runs.col_scenario'), diff --git a/client/src/pages/run/RunDetailPage.tsx b/client/src/pages/run/RunDetailPage.tsx index 7b2f1f6..2a52cb9 100644 --- a/client/src/pages/run/RunDetailPage.tsx +++ b/client/src/pages/run/RunDetailPage.tsx @@ -21,6 +21,7 @@ import { Notification, Table, Timestamp, + UuidBadge, type TableColumn, } from '../../ui'; import type { BadgeVariant } from '../../ui'; @@ -82,7 +83,7 @@ export function RunDetailPage() { if (!id || !runId) return; let cancelled = false; - Promise.all([scenarios.get(Number(id)), runs.get(Number(id), Number(runId))]) + Promise.all([scenarios.get(id), runs.get(id, runId)]) .then(([sc, r]) => { if (cancelled) return; setScenario(sc); @@ -91,7 +92,7 @@ export function RunDetailPage() { setPolling(true); pollRef.current = setInterval(async () => { try { - const updated = await runs.get(Number(id), Number(runId)); + const updated = await runs.get(id, runId); if (cancelled) return; setRun(updated); setPulseKey((k) => k + 1); @@ -183,7 +184,7 @@ export function RunDetailPage() { }, { term: t('runs.field_status'), detail: ( diff --git a/client/src/pages/run/RunsPage.tsx b/client/src/pages/run/RunsPage.tsx index 3268844..dd3da1d 100644 --- a/client/src/pages/run/RunsPage.tsx +++ b/client/src/pages/run/RunsPage.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { Play } from 'lucide-react'; import { scenarios, runs } from '../../api'; import type { Scenario, ScenarioRun, ScenarioRunStep, ScenarioRunStatus } from '../../api'; -import { AutoRefreshIndicator, Badge, Breadcrumbs, Button, Table, Timestamp, type TableColumn } from '../../ui'; +import { AutoRefreshIndicator, Badge, Breadcrumbs, Button, Table, Timestamp, UuidBadge, type TableColumn } from '../../ui'; import type { BadgeVariant } from '../../ui'; import styles from '../Page.module.css'; @@ -39,7 +39,7 @@ export function RunsPage() { const load = useCallback(() => { if (!id) return; setLoading(true); - Promise.all([scenarios.get(Number(id)), runs.list(Number(id))]) + Promise.all([scenarios.get(id!), runs.list(id!)]) .then(([sc, res]) => { setScenario(sc); setItems(res.data); @@ -58,12 +58,12 @@ export function RunsPage() { }, [load]); const handleRun = async () => { - const run = await scenarios.run(Number(id)); + const run = await scenarios.run(id!); navigate(`/scenarios/${id}/runs/${run.id}`); }; const columns: TableColumn[] = [ - { key: 'id', header: t('runs.col_id'), render: (r) => r.id, width: 60 }, + { key: 'id', header: t('runs.col_id'), render: (r) => , width: 60 }, { key: 'status', header: t('runs.col_status'), diff --git a/client/src/pages/scenario/CreateStepPage.tsx b/client/src/pages/scenario/CreateStepPage.tsx index 64d8f56..add271d 100644 --- a/client/src/pages/scenario/CreateStepPage.tsx +++ b/client/src/pages/scenario/CreateStepPage.tsx @@ -26,7 +26,7 @@ export function CreateStepPage() { useEffect(() => { if (!id) return; - scenarios.get(Number(id)).then(setScenario).catch(() => null); + scenarios.get(id).then(setScenario).catch(() => null); }, [id]); const validate = (): boolean => { @@ -42,7 +42,7 @@ export function CreateStepPage() { setSaving(true); setError(null); try { - await steps.create(Number(id), { + await steps.create(id!, { order: Number(order), title: title.trim() || undefined, type, diff --git a/client/src/pages/scenario/EditScenarioPage.tsx b/client/src/pages/scenario/EditScenarioPage.tsx index abc4ec1..43fb7e4 100644 --- a/client/src/pages/scenario/EditScenarioPage.tsx +++ b/client/src/pages/scenario/EditScenarioPage.tsx @@ -21,7 +21,7 @@ export function EditScenarioPage() { useEffect(() => { if (!id) return; scenarios - .get(Number(id)) + .get(id) .then((data) => { setScenario(data); setName(data.name); @@ -39,7 +39,7 @@ export function EditScenarioPage() { setSaving(true); setError(null); try { - await scenarios.update(Number(id), { name: name.trim() }); + await scenarios.update(id!, { name: name.trim() }); navigate(`/scenarios/${id}`); } catch (err) { setError((err as Error).message); diff --git a/client/src/pages/scenario/EditStepPage.tsx b/client/src/pages/scenario/EditStepPage.tsx index 3860afa..ebe9788 100644 --- a/client/src/pages/scenario/EditStepPage.tsx +++ b/client/src/pages/scenario/EditStepPage.tsx @@ -27,7 +27,7 @@ export function EditStepPage() { useEffect(() => { if (!id || !stepId) return; - Promise.all([scenarios.get(Number(id)), steps.get(Number(id), Number(stepId))]) + Promise.all([scenarios.get(id), steps.get(id, stepId)]) .then(([sc, st]) => { setScenario(sc); setStep(st); @@ -56,7 +56,7 @@ export function EditStepPage() { setSaving(true); setError(null); try { - await steps.update(Number(id), Number(stepId), { + await steps.update(id!, stepId!, { order: Number(order), title: title.trim() || undefined, type, diff --git a/client/src/pages/scenario/ScenarioDetailPage.tsx b/client/src/pages/scenario/ScenarioDetailPage.tsx index e620719..377cb6d 100644 --- a/client/src/pages/scenario/ScenarioDetailPage.tsx +++ b/client/src/pages/scenario/ScenarioDetailPage.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Play, Pencil, Plus, History, Trash2 } from 'lucide-react'; +import { Play, Pencil, Plus, History, Trash2, Download } from 'lucide-react'; +import { stringify as yamlStringify } from 'yaml'; import { scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api'; import type { Scenario, ScenarioStep, ScenarioCredential, Credential } from '../../api'; import { @@ -15,6 +16,7 @@ import { Select, Table, Timestamp, + UuidBadge, type TableColumn, } from '../../ui'; import styles from '../Page.module.css'; @@ -46,7 +48,7 @@ export function ScenarioDetailPage() { useEffect(() => { if (!id) return; scenarios - .get(Number(id)) + .get(id) .then((s) => { setScenario(s); setScenarioCreds(s.scenarioCredentials ?? []); @@ -71,8 +73,20 @@ export function ScenarioDetailPage() { navigate('/scenarios'); }; - const handleDeleteStep = async (stepId: number) => { - await steps.remove(Number(id), stepId); + const handleExport = async () => { + if (!scenario) return; + const data = await scenarios.exportScenario(scenario.id); + const blob = new Blob([yamlStringify(data)], { 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); + }; + + const handleDeleteStep = async (stepId: string) => { + await steps.remove(id!, stepId); setScenario((prev) => prev ? { ...prev, steps: prev.steps.filter((s) => s.id !== stepId) } : prev, ); @@ -86,9 +100,9 @@ export function ScenarioDetailPage() { if (!valid) return; setAddCredSaving(true); try { - const sc = await scenarioCredentials.add(Number(id), Number(addCredId), addAlias.trim()); + const sc = await scenarioCredentials.add(id!, addCredId, addAlias.trim()); // Re-fetch the credential object since the response may not include it - const full = await scenarioCredentials.list(Number(id)); + const full = await scenarioCredentials.list(id!); setScenarioCreds(full); void sc; setAddCredOpen(false); @@ -101,8 +115,8 @@ export function ScenarioDetailPage() { } }; - const handleRemoveCredential = async (scCredId: number) => { - await scenarioCredentials.remove(Number(id), scCredId); + const handleRemoveCredential = async (scCredId: string) => { + await scenarioCredentials.remove(id!, scCredId); setScenarioCreds((prev) => prev.filter((sc) => sc.id !== scCredId)); }; @@ -212,6 +226,10 @@ export function ScenarioDetailPage() { {t('scenarios.action_runs')} +
{addCredOpen && ( - +
+
+ +
{error &&

{error}

} diff --git a/client/src/ui/UuidBadge/UuidBadge.module.css b/client/src/ui/UuidBadge/UuidBadge.module.css new file mode 100644 index 0000000..b74b70e --- /dev/null +++ b/client/src/ui/UuidBadge/UuidBadge.module.css @@ -0,0 +1,38 @@ +.wrapper { + display: inline-flex; + align-items: center; + position: relative; +} + +.button { + all: unset; + cursor: pointer; + font: inherit; + color: inherit; + letter-spacing: 0.02em; +} + +.button:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +.pop { + position: absolute; + left: calc(100% + 4px); + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + color: var(--color-success-fg); + animation: popFade 1s ease forwards; + pointer-events: none; +} + +@keyframes popFade { + 0% { opacity: 0; transform: translateY(calc(-50% - 4px)); } + 20% { opacity: 1; transform: translateY(-50%); } + 70% { opacity: 1; transform: translateY(-50%); } + 100% { opacity: 0; transform: translateY(calc(-50% - 4px)); } +} diff --git a/client/src/ui/UuidBadge/UuidBadge.tsx b/client/src/ui/UuidBadge/UuidBadge.tsx new file mode 100644 index 0000000..6d97ce1 --- /dev/null +++ b/client/src/ui/UuidBadge/UuidBadge.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react'; +import { ClipboardCheck } from 'lucide-react'; +import { Badge } from '../Badge/Badge'; +import styles from './UuidBadge.module.css'; + +export interface UuidBadgeProps { + id: string; +} + +export function UuidBadge({ id }: UuidBadgeProps) { + const [copied, setCopied] = useState(false); + const short = `${id.slice(0, 4)}…${id.slice(-4)}`; + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(id).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1000); + }); + }; + + return ( + + + + + {copied && ( + + )} + + ); +} diff --git a/client/src/ui/index.ts b/client/src/ui/index.ts index 4a4a33f..a4fdba1 100644 --- a/client/src/ui/index.ts +++ b/client/src/ui/index.ts @@ -44,3 +44,6 @@ export type { NotificationProps, NotificationVariant } from './Notification/Noti export { Table } from './Table/Table'; export type { TableProps, TableColumn } from './Table/Table'; + +export { UuidBadge } from './UuidBadge/UuidBadge'; +export type { UuidBadgeProps } from './UuidBadge/UuidBadge'; diff --git a/package-lock.json b/package-lock.json index a6ace46..2314547 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^17.0.2", - "react-router-dom": "^7.14.0" + "react-router-dom": "^7.14.0", + "yaml": "^2.8.3" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -14314,6 +14315,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/server/src/credential/credential.controller.ts b/server/src/credential/credential.controller.ts index ad54568..136c2c4 100644 --- a/server/src/credential/credential.controller.ts +++ b/server/src/credential/credential.controller.ts @@ -5,7 +5,7 @@ import { Get, HttpCode, Param, - ParseIntPipe, + ParseUUIDPipe, Patch, Post, Query, @@ -14,6 +14,7 @@ import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { CredentialService } from "./credential.service"; import { CreateCredentialDto } from "./dto/create-credential.dto"; import { UpdateCredentialDto } from "./dto/update-credential.dto"; +import { CredentialExportDto } from "./dto/credential-export.dto"; import { PaginationQueryDto } from "../common/dto/pagination.dto"; import { CredentialOrderBy } from "./credential.service"; @@ -29,6 +30,13 @@ export class CredentialController { return this.credentialService.create(dto); } + @Post("import") + @ApiOperation({ summary: "Import a credential (upsert by id)" }) + @ApiResponse({ status: 201, description: "Credential imported" }) + async importCredential(@Body() dto: CredentialExportDto) { + return this.credentialService.importCredential(dto); + } + @Get() @ApiOperation({ summary: "List all credentials (paginated)" }) @ApiResponse({ status: 200, description: "Paginated credentials" }) @@ -36,11 +44,20 @@ export class CredentialController { return this.credentialService.findAll(query); } + @Get(":id/export") + @ApiOperation({ summary: "Export a credential as a plain object" }) + @ApiResponse({ status: 200, description: "Credential export payload" }) + @ApiResponse({ status: 404, description: "Credential not found" }) + async exportCredential(@Param("id", ParseUUIDPipe) id: string) { + const credential = await this.credentialService.findOne(id); + return this.credentialService.exportCredential(credential); + } + @Get(":id") @ApiOperation({ summary: "Get credential by ID" }) @ApiResponse({ status: 200, description: "Credential record" }) @ApiResponse({ status: 404, description: "Credential not found" }) - findOne(@Param("id", ParseIntPipe) id: number) { + findOne(@Param("id", ParseUUIDPipe) id: string) { return this.credentialService.findOne(id); } @@ -49,7 +66,7 @@ export class CredentialController { @ApiResponse({ status: 200, description: "Credential updated" }) @ApiResponse({ status: 404, description: "Credential not found" }) update( - @Param("id", ParseIntPipe) id: number, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateCredentialDto, ) { return this.credentialService.update(id, dto); @@ -60,7 +77,7 @@ export class CredentialController { @ApiOperation({ summary: "Delete a credential" }) @ApiResponse({ status: 204, description: "Credential deleted" }) @ApiResponse({ status: 404, description: "Credential not found" }) - remove(@Param("id", ParseIntPipe) id: number) { + remove(@Param("id", ParseUUIDPipe) id: string) { return this.credentialService.remove(id); } } diff --git a/server/src/credential/credential.entity.ts b/server/src/credential/credential.entity.ts index 669200f..e97a2ac 100644 --- a/server/src/credential/credential.entity.ts +++ b/server/src/credential/credential.entity.ts @@ -8,8 +8,8 @@ import { @Entity("credentials") export class CredentialEntity { - @PrimaryGeneratedColumn() - id: number; + @PrimaryGeneratedColumn("uuid") + id: string; @Column() name: string; diff --git a/server/src/credential/credential.service.ts b/server/src/credential/credential.service.ts index 7a6c31f..4ddbe50 100644 --- a/server/src/credential/credential.service.ts +++ b/server/src/credential/credential.service.ts @@ -4,6 +4,7 @@ import { Repository } from "typeorm"; import { CredentialEntity } from "./credential.entity"; import { CreateCredentialDto } from "./dto/create-credential.dto"; import { UpdateCredentialDto } from "./dto/update-credential.dto"; +import { CredentialExportDto } from "./dto/credential-export.dto"; import { PaginationQueryDto, PaginatedResult, @@ -37,20 +38,40 @@ export class CredentialService { return { data, total, page, limit }; } - async findOne(id: number): Promise { + async findOne(id: string): Promise { const credential = await this.repo.findOneBy({ id }); if (!credential) throw new NotFoundException(`Credential ${id} not found`); return credential; } - async update(id: number, dto: UpdateCredentialDto): Promise { + async update(id: string, dto: UpdateCredentialDto): Promise { const credential = await this.findOne(id); Object.assign(credential, dto); return this.repo.save(credential); } - async remove(id: number): Promise { + async remove(id: string): Promise { await this.findOne(id); await this.repo.delete(id); } + + exportCredential(credential: CredentialEntity): CredentialExportDto { + return { kind: "credential", id: credential.id, name: credential.name, data: credential.data }; + } + + async importCredential(dto: CredentialExportDto): Promise { + if (dto.id) { + const existing = await this.repo.findOneBy({ id: dto.id }); + if (existing) { + Object.assign(existing, { name: dto.name, data: dto.data ?? null }); + return this.repo.save(existing); + } + return this.repo.save( + this.repo.create({ id: dto.id, name: dto.name, data: dto.data ?? null }), + ); + } + return this.repo.save( + this.repo.create({ name: dto.name, data: dto.data ?? null }), + ); + } } diff --git a/server/src/credential/dto/credential-export.dto.ts b/server/src/credential/dto/credential-export.dto.ts new file mode 100644 index 0000000..8d96231 --- /dev/null +++ b/server/src/credential/dto/credential-export.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator"; + +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; +} diff --git a/server/src/environment/environment.controller.ts b/server/src/environment/environment.controller.ts index 70849fa..a345a9f 100644 --- a/server/src/environment/environment.controller.ts +++ b/server/src/environment/environment.controller.ts @@ -5,7 +5,7 @@ import { Get, HttpCode, Param, - ParseIntPipe, + ParseUUIDPipe, Patch, Post, Query, @@ -41,7 +41,7 @@ export class EnvironmentController { @ApiOperation({ summary: "Get environment by ID" }) @ApiResponse({ status: 200, description: "Environment record" }) @ApiResponse({ status: 404, description: "Environment not found" }) - findOne(@Param("id", ParseIntPipe) id: number) { + findOne(@Param("id", ParseUUIDPipe) id: string) { return this.environmentService.findOne(id); } @@ -50,7 +50,7 @@ export class EnvironmentController { @ApiResponse({ status: 200, description: "Environment updated" }) @ApiResponse({ status: 404, description: "Environment not found" }) update( - @Param("id", ParseIntPipe) id: number, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateEnvironmentDto, ) { return this.environmentService.update(id, dto); @@ -61,7 +61,7 @@ export class EnvironmentController { @ApiOperation({ summary: "Delete an environment" }) @ApiResponse({ status: 204, description: "Environment deleted" }) @ApiResponse({ status: 404, description: "Environment not found" }) - remove(@Param("id", ParseIntPipe) id: number) { + remove(@Param("id", ParseUUIDPipe) id: string) { return this.environmentService.remove(id); } } diff --git a/server/src/environment/environment.entity.ts b/server/src/environment/environment.entity.ts index 3ffc86f..d0edc64 100644 --- a/server/src/environment/environment.entity.ts +++ b/server/src/environment/environment.entity.ts @@ -15,8 +15,8 @@ export interface EnvironmentUrls { @Entity("environments") export class EnvironmentEntity { - @PrimaryGeneratedColumn() - id: number; + @PrimaryGeneratedColumn("uuid") + id: string; @Column({ unique: true }) name: string; diff --git a/server/src/environment/environment.service.ts b/server/src/environment/environment.service.ts index 152ed06..0063aa3 100644 --- a/server/src/environment/environment.service.ts +++ b/server/src/environment/environment.service.ts @@ -45,14 +45,14 @@ export class EnvironmentService { return { data, total, page, limit }; } - async findOne(id: number): Promise { + async findOne(id: string): Promise { const env = await this.repo.findOneBy({ id }); if (!env) throw new NotFoundException(`Environment ${id} not found`); return env; } async update( - id: number, + id: string, dto: UpdateEnvironmentDto, ): Promise { const env = await this.findOne(id); @@ -60,7 +60,7 @@ export class EnvironmentService { return this.repo.save(env); } - async remove(id: number): Promise { + async remove(id: string): Promise { await this.findOne(id); await this.repo.delete(id); } diff --git a/server/src/mcp/mcp.service.ts b/server/src/mcp/mcp.service.ts index ad88d36..3612294 100644 --- a/server/src/mcp/mcp.service.ts +++ b/server/src/mcp/mcp.service.ts @@ -135,7 +135,7 @@ export class McpService { { description: "Delete a session by numeric ID (closes it first if open)", inputSchema: { - id: z.number().int().describe("Session ID to delete"), + id: z.string().uuid().describe("Session ID to delete"), }, }, async ({ id }) => { @@ -200,7 +200,7 @@ export class McpService { { description: "Get an environment record by ID", inputSchema: { - id: z.number().int().describe("Environment ID"), + id: z.string().uuid().describe("Environment ID"), }, }, async ({ id }) => { @@ -258,7 +258,7 @@ export class McpService { { description: "Update an existing environment (name and/or urls)", inputSchema: { - id: z.number().int().describe("Environment ID to update"), + id: z.string().uuid().describe("Environment ID to update"), name: z.string().optional().describe("New name"), urls: z .record(z.string(), z.string()) @@ -289,7 +289,7 @@ export class McpService { { description: "Delete an environment by ID", inputSchema: { - id: z.number().int().describe("Environment ID to delete"), + id: z.string().uuid().describe("Environment ID to delete"), }, }, async ({ id }) => { @@ -443,7 +443,7 @@ export class McpService { { description: "Get a scenario with its steps by ID", inputSchema: { - id: z.number().int().describe("Scenario ID"), + id: z.string().uuid().describe("Scenario ID"), }, }, async ({ id }) => { @@ -493,7 +493,7 @@ export class McpService { { description: "Update a scenario name", inputSchema: { - id: z.number().int().describe("Scenario ID"), + id: z.string().uuid().describe("Scenario ID"), name: z.string().optional().describe("New name"), }, }, @@ -519,7 +519,7 @@ export class McpService { { description: "Delete a scenario by ID", inputSchema: { - id: z.number().int().describe("Scenario ID"), + id: z.string().uuid().describe("Scenario ID"), }, }, async ({ id }) => { @@ -544,7 +544,7 @@ export class McpService { { description: "Add a step to a scenario", inputSchema: { - scenarioId: z.number().int().describe("Parent scenario ID"), + scenarioId: z.string().uuid().describe("Parent scenario ID"), order: z .number() .int() @@ -582,8 +582,8 @@ export class McpService { { description: "Get a single step of a scenario", inputSchema: { - scenarioId: z.number().int().describe("Scenario ID"), - stepId: z.number().int().describe("Step ID"), + scenarioId: z.string().uuid().describe("Scenario ID"), + stepId: z.string().uuid().describe("Step ID"), }, }, async ({ scenarioId, stepId }) => { @@ -606,8 +606,8 @@ export class McpService { { description: "Update a step within a scenario", inputSchema: { - scenarioId: z.number().int().describe("Scenario ID"), - stepId: z.number().int().describe("Step ID"), + scenarioId: z.string().uuid().describe("Scenario ID"), + stepId: z.string().uuid().describe("Step ID"), order: z .number() .int() @@ -647,8 +647,8 @@ export class McpService { { description: "Delete a step from a scenario", inputSchema: { - scenarioId: z.number().int().describe("Scenario ID"), - stepId: z.number().int().describe("Step ID"), + scenarioId: z.string().uuid().describe("Scenario ID"), + stepId: z.string().uuid().describe("Step ID"), }, }, async ({ scenarioId, stepId }) => { @@ -677,7 +677,7 @@ export class McpService { description: "List runs for a scenario (paginated, optionally filtered by status)", inputSchema: { - scenarioId: z.number().int().describe("Scenario ID"), + scenarioId: z.string().uuid().describe("Scenario ID"), status: z .enum(["pending", "in_progress", "pass", "fail"]) .optional() @@ -720,7 +720,7 @@ export class McpService { { description: "Trigger an immediate run of a scenario by ID", inputSchema: { - id: z.number().int().describe("Scenario ID to run"), + id: z.string().uuid().describe("Scenario ID to run"), }, }, async ({ id }) => { @@ -744,8 +744,8 @@ export class McpService { description: "Get a specific scenario run with all step runs and their outputs", inputSchema: { - scenarioId: z.number().int().describe("Scenario ID"), - runId: z.number().int().describe("Run ID"), + scenarioId: z.string().uuid().describe("Scenario ID"), + runId: z.string().uuid().describe("Run ID"), }, }, async ({ scenarioId, runId }) => { @@ -769,8 +769,8 @@ export class McpService { description: "Block until a scenario run reaches pass or fail (max 5 min), then return the full run with step outputs", inputSchema: { - scenarioId: z.number().int().describe("Scenario ID"), - runId: z.number().int().describe("Run ID"), + scenarioId: z.string().uuid().describe("Scenario ID"), + runId: z.string().uuid().describe("Run ID"), }, }, async ({ scenarioId, runId }) => { @@ -794,7 +794,7 @@ export class McpService { description: "Export a scenario as a portable JSON payload (name + steps)", inputSchema: { - id: z.number().int().describe("Scenario ID to export"), + id: z.string().uuid().describe("Scenario ID to export"), }, }, async ({ id }) => { @@ -826,7 +826,7 @@ export class McpService { z.object({ order: z.number().int().min(0).describe("Execution order"), type: z.enum(["login", "exec", "sign"]).describe("Step type"), - sessionName: z.string().describe("Session name"), + title: z.string().nullable().optional().describe("Step title"), execCode: z .string() .nullable() diff --git a/server/src/scenario/dto/add-scenario-credential.dto.ts b/server/src/scenario/dto/add-scenario-credential.dto.ts index 0b42ffe..cfb7cb6 100644 --- a/server/src/scenario/dto/add-scenario-credential.dto.ts +++ b/server/src/scenario/dto/add-scenario-credential.dto.ts @@ -1,12 +1,10 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsInt, IsNotEmpty, IsPositive, IsString } from "class-validator"; +import { IsNotEmpty, IsString, IsUUID } from "class-validator"; export class AddScenarioCredentialDto { - @ApiProperty({ example: 1 }) - @IsInt() - @IsPositive() - credentialId: number; - + @ApiProperty({ example: "uuid-here" }) + @IsUUID() + credentialId: string @ApiProperty({ example: "api_key" }) @IsString() @IsNotEmpty() diff --git a/server/src/scenario/dto/scenario-export.dto.ts b/server/src/scenario/dto/scenario-export.dto.ts index 7197885..4b99f8d 100644 --- a/server/src/scenario/dto/scenario-export.dto.ts +++ b/server/src/scenario/dto/scenario-export.dto.ts @@ -7,6 +7,7 @@ import { IsNotEmpty, IsOptional, IsString, + IsUUID, Min, ValidateNested, } from "class-validator"; @@ -22,10 +23,11 @@ export class ScenarioStepExportDto { @IsIn(["login", "exec", "sign"]) type: StepType; - @ApiProperty() + @ApiPropertyOptional() + @IsOptional() @IsString() @IsNotEmpty() - sessionName: string | null; + title: string | null; @ApiPropertyOptional() @IsOptional() @@ -41,6 +43,22 @@ export class ScenarioStepExportDto { } export class ScenarioExportDto { + @ApiPropertyOptional() + @IsOptional() + @IsIn(["scenario"]) + kind?: "scenario"; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + id?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + title?: string; + @ApiProperty() @IsString() @IsNotEmpty() diff --git a/server/src/scenario/scenario-credential.entity.ts b/server/src/scenario/scenario-credential.entity.ts index bc0464a..7a57564 100644 --- a/server/src/scenario/scenario-credential.entity.ts +++ b/server/src/scenario/scenario-credential.entity.ts @@ -13,14 +13,14 @@ import { CredentialEntity } from "../credential/credential.entity"; @Entity("scenario_credentials") @Unique(["scenarioId", "alias"]) export class ScenarioCredentialEntity { - @PrimaryGeneratedColumn() - id: number; + @PrimaryGeneratedColumn("uuid") + id: string; - @Column() - scenarioId: number; + @Column("text") + scenarioId: string; - @Column() - credentialId: number; + @Column("text") + credentialId: string; @Column() alias: string; diff --git a/server/src/scenario/scenario-run-log.entity.ts b/server/src/scenario/scenario-run-log.entity.ts index c11d5db..b694469 100644 --- a/server/src/scenario/scenario-run-log.entity.ts +++ b/server/src/scenario/scenario-run-log.entity.ts @@ -13,18 +13,18 @@ export type LogLevel = "log" | "warn" | "error"; @Entity("scenario_run_logs") export class ScenarioRunLogEntity { - @PrimaryGeneratedColumn() - id: number; + @PrimaryGeneratedColumn("uuid") + id: string; - @Column() - runId: number; + @Column("text") + runId: string; @ManyToOne(() => ScenarioRunEntity, { onDelete: "CASCADE" }) @JoinColumn({ name: "runId" }) run: ScenarioRunEntity; - @Column({ nullable: true }) - stepRunId: number | null; + @Column({ type: "text", nullable: true }) + stepRunId: string | null; @ManyToOne(() => ScenarioRunStepEntity, { onDelete: "SET NULL", diff --git a/server/src/scenario/scenario-run-step.entity.ts b/server/src/scenario/scenario-run-step.entity.ts index a5e29f3..e6d94cf 100644 --- a/server/src/scenario/scenario-run-step.entity.ts +++ b/server/src/scenario/scenario-run-step.entity.ts @@ -20,11 +20,11 @@ export type RunStepStatus = @Entity("scenario_run_steps") export class ScenarioRunStepEntity { - @PrimaryGeneratedColumn() - id: number; + @PrimaryGeneratedColumn("uuid") + id: string; - @Column() - runId: number; + @Column("text") + runId: string; @ManyToOne(() => ScenarioRunEntity, (run) => run.stepRuns, { onDelete: "CASCADE", @@ -32,8 +32,8 @@ export class ScenarioRunStepEntity { @JoinColumn({ name: "runId" }) run: ScenarioRunEntity; - @Column() - scenarioStepId: number; + @Column("text") + scenarioStepId: string; @ManyToOne(() => ScenarioStepEntity, { onDelete: "CASCADE" }) @JoinColumn({ name: "scenarioStepId" }) diff --git a/server/src/scenario/scenario-run.entity.ts b/server/src/scenario/scenario-run.entity.ts index f93dcca..857fa38 100644 --- a/server/src/scenario/scenario-run.entity.ts +++ b/server/src/scenario/scenario-run.entity.ts @@ -15,11 +15,11 @@ export type RunStatus = "pending" | "in_progress" | "pass" | "fail"; @Entity("scenario_runs") export class ScenarioRunEntity { - @PrimaryGeneratedColumn() - id: number; + @PrimaryGeneratedColumn("uuid") + id: string; - @Column() - scenarioId: number; + @Column("text") + scenarioId: string; @ManyToOne(() => ScenarioEntity, { onDelete: "CASCADE" }) @JoinColumn({ name: "scenarioId" }) diff --git a/server/src/scenario/scenario-scheduler.service.ts b/server/src/scenario/scenario-scheduler.service.ts index 5cf3507..15a7425 100644 --- a/server/src/scenario/scenario-scheduler.service.ts +++ b/server/src/scenario/scenario-scheduler.service.ts @@ -33,14 +33,14 @@ interface BrowserHandle { @Injectable() export class ScenarioSchedulerService { private readonly logger = new TraceLogger(ScenarioSchedulerService.name); - private readonly activeRuns = new Set(); - private readonly runBrowsers = new Map(); + private readonly activeRuns = new Set(); + private readonly runBrowsers = new Map(); // Cache credential maps per run (built once when a run starts) - private readonly runCredentials = new Map>(); + private readonly runCredentials = new Map>(); // Cache environment URLs per run (resolved from the first login step) - private readonly runEnvironments = new Map(); + private readonly runEnvironments = new Map(); // Cache snippet code map per run (built once when a run starts) - private readonly runSnippets = new Map>(); + private readonly runSnippets = new Map>(); constructor( @InjectRepository(ScenarioRunEntity) @@ -57,8 +57,8 @@ export class ScenarioSchedulerService { ) {} private persistLog( - runId: number, - stepRunId: number | null, + runId: string, + stepRunId: string | null, level: "log" | "warn" | "error", message: string, ): void { @@ -67,7 +67,7 @@ export class ScenarioSchedulerService { ); } - private stepLogger(stepRunId: number, runId: number): ScriptLogger { + private stepLogger(stepRunId: string, runId: string): ScriptLogger { return (level, msg) => { this.logger[level](`StepRun #${stepRunId} script: ${msg}`); this.persistLog(runId, stepRunId, level, msg); @@ -98,7 +98,7 @@ export class ScenarioSchedulerService { * no login step or the environment cannot be found. */ private async resolveRunEnvironment( - scenarioId: number, + scenarioId: string, ): Promise { try { const scenario = await this.scenarioService.findOne(scenarioId); @@ -147,7 +147,7 @@ export class ScenarioSchedulerService { } } - private async processRunToCompletion(runId: number): Promise { + private async processRunToCompletion(runId: string): Promise { try { let stepRun = await this.runStepRepo.findOne({ where: { runId, status: "pending" }, @@ -207,7 +207,7 @@ export class ScenarioSchedulerService { // ── Shared browser per run ───────────────────────────────────────────────── - private async getOrCreateBrowserHandle(runId: number): Promise { + private async getOrCreateBrowserHandle(runId: string): Promise { const existing = this.runBrowsers.get(runId); if (existing) return existing; @@ -224,7 +224,7 @@ export class ScenarioSchedulerService { return handle; } - private async closeBrowserHandle(runId: number): Promise { + private async closeBrowserHandle(runId: string): Promise { const handle = this.runBrowsers.get(runId); if (!handle) return; this.runBrowsers.delete(runId); diff --git a/server/src/scenario/scenario-step.entity.ts b/server/src/scenario/scenario-step.entity.ts index 1bdf6a6..57d9a27 100644 --- a/server/src/scenario/scenario-step.entity.ts +++ b/server/src/scenario/scenario-step.entity.ts @@ -13,11 +13,11 @@ export type StepType = "login" | "exec" | "sign"; @Entity("scenario_steps") export class ScenarioStepEntity { - @PrimaryGeneratedColumn() - id: number; + @PrimaryGeneratedColumn("uuid") + id: string; - @Column() - scenarioId: number; + @Column("text") + scenarioId: string; @ManyToOne(() => ScenarioEntity, (scenario) => scenario.steps, { onDelete: "CASCADE", diff --git a/server/src/scenario/scenario.controller.ts b/server/src/scenario/scenario.controller.ts index e0715ed..e870b6c 100644 --- a/server/src/scenario/scenario.controller.ts +++ b/server/src/scenario/scenario.controller.ts @@ -5,7 +5,7 @@ import { Get, HttpCode, Param, - ParseIntPipe, + ParseUUIDPipe, Patch, Post, Query, @@ -61,7 +61,7 @@ export class ScenarioController { @ApiOperation({ summary: "Get a scenario with its steps" }) @ApiResponse({ status: 200 }) @ApiResponse({ status: 404, description: "Scenario not found" }) - findOne(@Param("id", ParseIntPipe) id: number) { + findOne(@Param("id", ParseUUIDPipe) id: string) { return this.scenarioService.findOne(id); } @@ -70,7 +70,7 @@ export class ScenarioController { @ApiResponse({ status: 200 }) @ApiResponse({ status: 404, description: "Scenario not found" }) update( - @Param("id", ParseIntPipe) id: number, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateScenarioDto, ) { return this.scenarioService.update(id, dto); @@ -81,7 +81,7 @@ export class ScenarioController { @ApiOperation({ summary: "Delete a scenario and all its steps" }) @ApiResponse({ status: 204 }) @ApiResponse({ status: 404, description: "Scenario not found" }) - remove(@Param("id", ParseIntPipe) id: number) { + remove(@Param("id", ParseUUIDPipe) id: string) { return this.scenarioService.remove(id); } @@ -89,7 +89,7 @@ export class ScenarioController { @ApiOperation({ summary: "Export a scenario as a portable JSON payload" }) @ApiResponse({ status: 200 }) @ApiResponse({ status: 404, description: "Scenario not found" }) - exportScenario(@Param("id", ParseIntPipe) id: number) { + exportScenario(@Param("id", ParseUUIDPipe) id: string) { return this.scenarioService.exportScenario(id); } @@ -100,7 +100,7 @@ export class ScenarioController { @ApiResponse({ status: 201, description: "Step created" }) @ApiResponse({ status: 404, description: "Scenario not found" }) createStep( - @Param("id", ParseIntPipe) id: number, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: CreateScenarioStepDto, ) { return this.scenarioService.createStep(id, dto); @@ -111,8 +111,8 @@ export class ScenarioController { @ApiResponse({ status: 200 }) @ApiResponse({ status: 404, description: "Scenario or step not found" }) findStep( - @Param("id", ParseIntPipe) id: number, - @Param("stepId", ParseIntPipe) stepId: number, + @Param("id", ParseUUIDPipe) id: string, + @Param("stepId", ParseUUIDPipe) stepId: string, ) { return this.scenarioService.findStep(id, stepId); } @@ -122,8 +122,8 @@ export class ScenarioController { @ApiResponse({ status: 200 }) @ApiResponse({ status: 404, description: "Scenario or step not found" }) updateStep( - @Param("id", ParseIntPipe) id: number, - @Param("stepId", ParseIntPipe) stepId: number, + @Param("id", ParseUUIDPipe) id: string, + @Param("stepId", ParseUUIDPipe) stepId: string, @Body() dto: UpdateScenarioStepDto, ) { return this.scenarioService.updateStep(id, stepId, dto); @@ -135,8 +135,8 @@ export class ScenarioController { @ApiResponse({ status: 204 }) @ApiResponse({ status: 404, description: "Scenario or step not found" }) removeStep( - @Param("id", ParseIntPipe) id: number, - @Param("stepId", ParseIntPipe) stepId: number, + @Param("id", ParseUUIDPipe) id: string, + @Param("stepId", ParseUUIDPipe) stepId: string, ) { return this.scenarioService.removeStep(id, stepId); } @@ -147,7 +147,7 @@ export class ScenarioController { @ApiOperation({ summary: "List credentials assigned to a scenario" }) @ApiResponse({ status: 200 }) @ApiResponse({ status: 404, description: "Scenario not found" }) - findScenarioCredentials(@Param("id", ParseIntPipe) id: number) { + findScenarioCredentials(@Param("id", ParseUUIDPipe) id: string) { return this.scenarioService.findScenarioCredentials(id); } @@ -157,7 +157,7 @@ export class ScenarioController { @ApiResponse({ status: 404, description: "Scenario or credential not found" }) @ApiResponse({ status: 409, description: "Alias already used in this scenario" }) addScenarioCredential( - @Param("id", ParseIntPipe) id: number, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: AddScenarioCredentialDto, ) { return this.scenarioService.addScenarioCredential(id, dto); @@ -169,8 +169,8 @@ export class ScenarioController { @ApiResponse({ status: 204 }) @ApiResponse({ status: 404, description: "Scenario or credential assignment not found" }) removeScenarioCredential( - @Param("id", ParseIntPipe) id: number, - @Param("scCredId", ParseIntPipe) scCredId: number, + @Param("id", ParseUUIDPipe) id: string, + @Param("scCredId", ParseUUIDPipe) scCredId: string, ) { return this.scenarioService.removeScenarioCredential(id, scCredId); } @@ -184,7 +184,7 @@ export class ScenarioController { @ApiResponse({ status: 200 }) @ApiResponse({ status: 404, description: "Scenario not found" }) findRuns( - @Param("id", ParseIntPipe) id: number, + @Param("id", ParseUUIDPipe) id: string, @Query() query: RunsQueryDto, ) { return this.scenarioService.findRuns(id, query); @@ -194,7 +194,7 @@ export class ScenarioController { @ApiOperation({ summary: "Create a new run for a scenario" }) @ApiResponse({ status: 201, description: "Run created with step runs" }) @ApiResponse({ status: 404, description: "Scenario not found" }) - createRun(@Param("id", ParseIntPipe) id: number) { + createRun(@Param("id", ParseUUIDPipe) id: string) { return this.scenarioService.createRun(id); } @@ -203,8 +203,8 @@ export class ScenarioController { @ApiResponse({ status: 200 }) @ApiResponse({ status: 404, description: "Scenario or run not found" }) findRun( - @Param("id", ParseIntPipe) id: number, - @Param("runId", ParseIntPipe) runId: number, + @Param("id", ParseUUIDPipe) id: string, + @Param("runId", ParseUUIDPipe) runId: string, ) { return this.scenarioService.findRun(id, runId); } @@ -218,8 +218,8 @@ export class ScenarioController { @ApiResponse({ status: 200 }) @ApiResponse({ status: 404, description: "Scenario or run not found" }) waitForRun( - @Param("id", ParseIntPipe) id: number, - @Param("runId", ParseIntPipe) runId: number, + @Param("id", ParseUUIDPipe) id: string, + @Param("runId", ParseUUIDPipe) runId: string, ) { return this.scenarioService.waitForRun(id, runId); } diff --git a/server/src/scenario/scenario.entity.ts b/server/src/scenario/scenario.entity.ts index e7c0ccb..de0c5d1 100644 --- a/server/src/scenario/scenario.entity.ts +++ b/server/src/scenario/scenario.entity.ts @@ -11,8 +11,8 @@ import { ScenarioCredentialEntity } from "./scenario-credential.entity"; @Entity("scenarios") export class ScenarioEntity { - @PrimaryGeneratedColumn() - id: number; + @PrimaryGeneratedColumn("uuid") + id: string; @Column() name: string; diff --git a/server/src/scenario/scenario.service.ts b/server/src/scenario/scenario.service.ts index 5cc94a7..4c7acf3 100644 --- a/server/src/scenario/scenario.service.ts +++ b/server/src/scenario/scenario.service.ts @@ -63,7 +63,7 @@ export class ScenarioService { return { data, total, page, limit }; } - async findOne(id: number): Promise { + async findOne(id: string): Promise { 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 { + async update(id: string, dto: UpdateScenarioDto): Promise { const scenario = await this.findOne(id); Object.assign(scenario, dto); return this.scenarioRepo.save(scenario); } - async remove(id: number): Promise { + async remove(id: string): Promise { 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 { await this.findOne(scenarioId); @@ -102,8 +102,8 @@ export class ScenarioService { } async findStep( - scenarioId: number, - stepId: number, + scenarioId: string, + stepId: string, ): Promise { 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 { 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 { + async removeStep(scenarioId: string, stepId: string): Promise { 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 { 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 { await this.findOne(scenarioId); // 404 guard @@ -168,8 +168,8 @@ export class ScenarioService { } async removeScenarioCredential( - scenarioId: number, - scCredId: number, + scenarioId: string, + scCredId: string, ): Promise { 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> { + async buildCredentialMap(scenarioId: string): Promise> { const scs = await this.findScenarioCredentials(scenarioId); const map: Record = {}; for (const sc of scs) { @@ -207,7 +207,7 @@ export class ScenarioService { // ── Runs ────────────────────────────────────────────────────────────────── async findRuns( - scenarioId: number, + scenarioId: string, query: RunsQueryDto, ): Promise> { await this.findOne(scenarioId); // 404 guard @@ -245,8 +245,8 @@ export class ScenarioService { } async findRun( - scenarioId: number, - runId: number, + scenarioId: string, + runId: string, ): Promise { 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 { const deadline = Date.now() + timeoutMs; @@ -285,7 +285,7 @@ export class ScenarioService { return this.findRun(scenarioId, runId); } - async createRun(scenarioId: number): Promise { + async createRun(scenarioId: string): Promise { 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 { + async exportScenario(id: string): Promise { 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 { - 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, }), diff --git a/server/src/session/session-context.service.ts b/server/src/session/session-context.service.ts index 8366a72..4377aff 100644 --- a/server/src/session/session-context.service.ts +++ b/server/src/session/session-context.service.ts @@ -110,7 +110,7 @@ export class SessionContextService implements OnModuleDestroy { * Close the context (if open) and delete the session record from DB. * Throws 404 if the session ID does not exist. */ - async delete(id: number): Promise { + async delete(id: string): Promise { const session = await this.sessionService.findById(id); if (!session) { throw new NotFoundException(`Session ${id} not found`); diff --git a/server/src/session/session.controller.ts b/server/src/session/session.controller.ts index 5c65895..57d1afc 100644 --- a/server/src/session/session.controller.ts +++ b/server/src/session/session.controller.ts @@ -5,7 +5,7 @@ import { HttpCode, NotFoundException, Param, - ParseIntPipe, + ParseUUIDPipe, Query, } from "@nestjs/common"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; @@ -38,7 +38,7 @@ export class SessionController { @ApiOperation({ summary: "Get a session by ID" }) @ApiResponse({ status: 200, description: "Session found" }) @ApiResponse({ status: 404, description: "Session not found" }) - async findOne(@Param("id", ParseIntPipe) id: number) { + async findOne(@Param("id", ParseUUIDPipe) id: string) { const session = await this.sessionService.findById(id); if (!session) throw new NotFoundException(`Session ${id} not found`); const { @@ -58,7 +58,7 @@ export class SessionController { @ApiOperation({ summary: "Delete a session by ID (closes it first if open)" }) @ApiResponse({ status: 204, description: "Session deleted" }) @ApiResponse({ status: 404, description: "Session not found" }) - async remove(@Param("id", ParseIntPipe) id: number): Promise { + async remove(@Param("id", ParseUUIDPipe) id: string): Promise { await this.sessionContextService.delete(id); } } diff --git a/server/src/session/session.entity.ts b/server/src/session/session.entity.ts index fb5132b..99f1f73 100644 --- a/server/src/session/session.entity.ts +++ b/server/src/session/session.entity.ts @@ -10,8 +10,8 @@ export type SessionStatus = "open" | "closed"; @Entity("sessions") export class SessionEntity { - @PrimaryGeneratedColumn() - id: number; + @PrimaryGeneratedColumn("uuid") + id: string; @Column({ unique: true }) sessionName: string; diff --git a/server/src/session/session.service.ts b/server/src/session/session.service.ts index 1c563d0..fe9c3a5 100644 --- a/server/src/session/session.service.ts +++ b/server/src/session/session.service.ts @@ -72,7 +72,7 @@ export class SessionService implements OnApplicationBootstrap { return this.repo.findOneBy({ sessionName }); } - findById(id: number): Promise { + findById(id: string): Promise { return this.repo.findOneBy({ id }); } @@ -137,7 +137,7 @@ export class SessionService implements OnApplicationBootstrap { return { data, total, page, limit }; } - async remove(id: number): Promise { + async remove(id: string): Promise { await this.repo.delete(id); } } diff --git a/server/src/snippet/dto/snippet-export.dto.ts b/server/src/snippet/dto/snippet-export.dto.ts new file mode 100644 index 0000000..621aea6 --- /dev/null +++ b/server/src/snippet/dto/snippet-export.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator"; + +export class SnippetExportDto { + @ApiPropertyOptional() + @IsOptional() + @IsIn(["snippet"]) + kind?: "snippet"; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + id?: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string | null; + + @ApiProperty() + @IsString() + code: string; +} diff --git a/server/src/snippet/snippet.controller.ts b/server/src/snippet/snippet.controller.ts index ade9911..ac07f9a 100644 --- a/server/src/snippet/snippet.controller.ts +++ b/server/src/snippet/snippet.controller.ts @@ -5,7 +5,7 @@ import { Get, HttpCode, Param, - ParseIntPipe, + ParseUUIDPipe, Patch, Post, Query, @@ -14,6 +14,7 @@ import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { SnippetService, SnippetOrderBy } from "./snippet.service"; import { CreateSnippetDto } from "./dto/create-snippet.dto"; import { UpdateSnippetDto } from "./dto/update-snippet.dto"; +import { SnippetExportDto } from "./dto/snippet-export.dto"; import { PaginationQueryDto } from "../common/dto/pagination.dto"; @ApiTags("snippets") @@ -29,6 +30,13 @@ export class SnippetController { return this.snippetService.create(dto); } + @Post("import") + @ApiOperation({ summary: "Import a snippet (upsert by id)" }) + @ApiResponse({ status: 201, description: "Snippet imported" }) + async importSnippet(@Body() dto: SnippetExportDto) { + return this.snippetService.importSnippet(dto); + } + @Get() @ApiOperation({ summary: "List all snippets (paginated)" }) @ApiResponse({ status: 200, description: "Paginated snippets" }) @@ -36,11 +44,20 @@ export class SnippetController { return this.snippetService.findAll(query); } + @Get(":id/export") + @ApiOperation({ summary: "Export a snippet as a plain object" }) + @ApiResponse({ status: 200, description: "Snippet export payload" }) + @ApiResponse({ status: 404, description: "Snippet not found" }) + async exportSnippet(@Param("id", ParseUUIDPipe) id: string) { + const snippet = await this.snippetService.findOne(id); + return this.snippetService.exportSnippet(snippet); + } + @Get(":id") @ApiOperation({ summary: "Get snippet by ID" }) @ApiResponse({ status: 200, description: "Snippet record" }) @ApiResponse({ status: 404, description: "Snippet not found" }) - findOne(@Param("id", ParseIntPipe) id: number) { + findOne(@Param("id", ParseUUIDPipe) id: string) { return this.snippetService.findOne(id); } @@ -50,7 +67,7 @@ export class SnippetController { @ApiResponse({ status: 404, description: "Snippet not found" }) @ApiResponse({ status: 409, description: "Name already taken" }) update( - @Param("id", ParseIntPipe) id: number, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateSnippetDto, ) { return this.snippetService.update(id, dto); @@ -61,7 +78,7 @@ export class SnippetController { @ApiOperation({ summary: "Delete a snippet" }) @ApiResponse({ status: 204, description: "Snippet deleted" }) @ApiResponse({ status: 404, description: "Snippet not found" }) - remove(@Param("id", ParseIntPipe) id: number) { + remove(@Param("id", ParseUUIDPipe) id: string) { return this.snippetService.remove(id); } } diff --git a/server/src/snippet/snippet.entity.ts b/server/src/snippet/snippet.entity.ts index 50cd532..57c0af5 100644 --- a/server/src/snippet/snippet.entity.ts +++ b/server/src/snippet/snippet.entity.ts @@ -8,8 +8,8 @@ import { @Entity("snippets") export class SnippetEntity { - @PrimaryGeneratedColumn() - id: number; + @PrimaryGeneratedColumn("uuid") + id: string; /** Unique identifier used to call the snippet: helpers.runSnippet('mySnippet', ...) */ @Column({ unique: true }) diff --git a/server/src/snippet/snippet.service.ts b/server/src/snippet/snippet.service.ts index d12716b..4e56bc2 100644 --- a/server/src/snippet/snippet.service.ts +++ b/server/src/snippet/snippet.service.ts @@ -8,6 +8,7 @@ import { Repository } from "typeorm"; import { SnippetEntity } from "./snippet.entity"; import { CreateSnippetDto } from "./dto/create-snippet.dto"; import { UpdateSnippetDto } from "./dto/update-snippet.dto"; +import { SnippetExportDto } from "./dto/snippet-export.dto"; import { PaginationQueryDto, PaginatedResult, @@ -47,13 +48,13 @@ export class SnippetService { return { data, total, page, limit }; } - async findOne(id: number): Promise { + async findOne(id: string): Promise { const snippet = await this.repo.findOneBy({ id }); if (!snippet) throw new NotFoundException(`Snippet ${id} not found`); return snippet; } - async update(id: number, dto: UpdateSnippetDto): Promise { + async update(id: string, dto: UpdateSnippetDto): Promise { const snippet = await this.findOne(id); if (dto.name && dto.name !== snippet.name) { const conflict = await this.repo.findOneBy({ name: dto.name }); @@ -63,7 +64,7 @@ export class SnippetService { return this.repo.save(snippet); } - async remove(id: number): Promise { + async remove(id: string): Promise { await this.findOne(id); await this.repo.delete(id); } @@ -73,4 +74,43 @@ export class SnippetService { const { data } = await this.findAll({ limit: 1000 }); return Object.fromEntries(data.map((s) => [s.name, s.code])); } + + exportSnippet(snippet: SnippetEntity): SnippetExportDto { + return { + kind: "snippet", + id: snippet.id, + name: snippet.name, + description: snippet.description, + code: snippet.code, + }; + } + + async importSnippet(dto: SnippetExportDto): Promise { + if (dto.id) { + const existing = await this.repo.findOneBy({ id: dto.id }); + if (existing) { + Object.assign(existing, { + name: dto.name, + description: dto.description ?? null, + code: dto.code, + }); + return this.repo.save(existing); + } + return this.repo.save( + this.repo.create({ + id: dto.id, + name: dto.name, + description: dto.description ?? null, + code: dto.code, + }), + ); + } + return this.repo.save( + this.repo.create({ + name: dto.name, + description: dto.description ?? null, + code: dto.code, + }), + ); + } }