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

- all entities export a kind field (credential/snippet/scenario) for safe type checking on import
- import upserts by id: overwrites if id exists, creates with explicit id otherwise
- scenario export now includes id and step ids; import deletes old steps before recreating
- add GET /:id/export and POST /import endpoints to credential and snippet controllers
- add UuidBadge ui component: shortens uuid to first+last 4 hex chars, tooltip, clipboard copy with animated ClipboardCheck icon pop
- apply UuidBadge across all entity id display sites (detail pages, card footers, table columns)
- add export/import buttons to CredentialsPage, SnippetsPage, CredentialDetailPage, SnippetDetailPage
This commit is contained in:
2026-04-10 13:09:09 +03:00
parent 1164289173
commit 32be7c0a59
54 changed files with 728 additions and 272 deletions
+2 -1
View File
@@ -20,7 +20,8 @@
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-i18next": "^17.0.2", "react-i18next": "^17.0.2",
"react-router-dom": "^7.14.0" "react-router-dom": "^7.14.0",
"yaml": "^2.8.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
+55 -28
View File
@@ -39,7 +39,7 @@ export const credentials = {
list(page = 1, limit = 50): Promise<PaginatedResponse<Credential>> { list(page = 1, limit = 50): Promise<PaginatedResponse<Credential>> {
return request(`/credentials?page=${page}&limit=${limit}`); return request(`/credentials?page=${page}&limit=${limit}`);
}, },
get(id: number): Promise<Credential> { get(id: string): Promise<Credential> {
return request(`/credentials/${id}`); return request(`/credentials/${id}`);
}, },
create(name: string, data?: string): Promise<Credential> { create(name: string, data?: string): Promise<Credential> {
@@ -48,15 +48,24 @@ export const credentials = {
body: JSON.stringify({ name, data }), body: JSON.stringify({ name, data }),
}); });
}, },
update(id: number, patch: Partial<Pick<Credential, 'name' | 'data'>>): Promise<Credential> { update(id: string, patch: Partial<Pick<Credential, 'name' | 'data'>>): Promise<Credential> {
return request(`/credentials/${id}`, { return request(`/credentials/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(patch), body: JSON.stringify(patch),
}); });
}, },
remove(id: number): Promise<void> { remove(id: string): Promise<void> {
return request(`/credentials/${id}`, { method: 'DELETE' }); return request(`/credentials/${id}`, { method: 'DELETE' });
}, },
exportCredential(id: string): Promise<unknown> {
return request(`/credentials/${id}/export`);
},
importCredential(payload: unknown): Promise<Credential> {
return request('/credentials/import', {
method: 'POST',
body: JSON.stringify(payload),
});
},
}; };
// ── Snippets ────────────────────────────────────────────────────────────────── // ── Snippets ──────────────────────────────────────────────────────────────────
@@ -65,7 +74,7 @@ export const snippets = {
list(page = 1, limit = 50): Promise<PaginatedResponse<Snippet>> { list(page = 1, limit = 50): Promise<PaginatedResponse<Snippet>> {
return request(`/snippets?page=${page}&limit=${limit}`); return request(`/snippets?page=${page}&limit=${limit}`);
}, },
get(id: number): Promise<Snippet> { get(id: string): Promise<Snippet> {
return request(`/snippets/${id}`); return request(`/snippets/${id}`);
}, },
create(payload: Pick<Snippet, 'name' | 'code'> & { description?: string }): Promise<Snippet> { create(payload: Pick<Snippet, 'name' | 'code'> & { description?: string }): Promise<Snippet> {
@@ -74,15 +83,24 @@ export const snippets = {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
}, },
update(id: number, patch: Partial<Pick<Snippet, 'name' | 'description' | 'code'>>): Promise<Snippet> { update(id: string, patch: Partial<Pick<Snippet, 'name' | 'description' | 'code'>>): Promise<Snippet> {
return request(`/snippets/${id}`, { return request(`/snippets/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(patch), body: JSON.stringify(patch),
}); });
}, },
remove(id: number): Promise<void> { remove(id: string): Promise<void> {
return request(`/snippets/${id}`, { method: 'DELETE' }); return request(`/snippets/${id}`, { method: 'DELETE' });
}, },
exportSnippet(id: string): Promise<unknown> {
return request(`/snippets/${id}/export`);
},
importSnippet(payload: unknown): Promise<Snippet> {
return request('/snippets/import', {
method: 'POST',
body: JSON.stringify(payload),
});
},
}; };
// ── Environments ────────────────────────────────────────────────────────────── // ── Environments ──────────────────────────────────────────────────────────────
@@ -91,7 +109,7 @@ export const environments = {
list(page = 1, limit = 50): Promise<PaginatedResponse<Environment>> { list(page = 1, limit = 50): Promise<PaginatedResponse<Environment>> {
return request(`/environments?page=${page}&limit=${limit}`); return request(`/environments?page=${page}&limit=${limit}`);
}, },
get(id: number): Promise<Environment> { get(id: string): Promise<Environment> {
return request(`/environments/${id}`); return request(`/environments/${id}`);
}, },
create(name: string, urls: Environment['urls']): Promise<Environment> { create(name: string, urls: Environment['urls']): Promise<Environment> {
@@ -100,13 +118,13 @@ export const environments = {
body: JSON.stringify({ name, urls }), body: JSON.stringify({ name, urls }),
}); });
}, },
update(id: number, patch: Partial<Pick<Environment, 'name' | 'urls'>>): Promise<Environment> { update(id: string, patch: Partial<Pick<Environment, 'name' | 'urls'>>): Promise<Environment> {
return request(`/environments/${id}`, { return request(`/environments/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(patch), body: JSON.stringify(patch),
}); });
}, },
remove(id: number): Promise<void> { remove(id: string): Promise<void> {
return request(`/environments/${id}`, { method: 'DELETE' }); return request(`/environments/${id}`, { method: 'DELETE' });
}, },
}; };
@@ -125,10 +143,10 @@ export const sessions = {
list(page = 1, limit = 50): Promise<PaginatedResponse<Session>> { list(page = 1, limit = 50): Promise<PaginatedResponse<Session>> {
return request(`/sessions?page=${page}&limit=${limit}`); return request(`/sessions?page=${page}&limit=${limit}`);
}, },
get(id: number): Promise<Session> { get(id: string): Promise<Session> {
return request(`/sessions/${id}`); return request(`/sessions/${id}`);
}, },
remove(id: number): Promise<void> { remove(id: string): Promise<void> {
return request(`/sessions/${id}`, { method: 'DELETE' }); return request(`/sessions/${id}`, { method: 'DELETE' });
}, },
}; };
@@ -139,7 +157,7 @@ export const scenarios = {
list(page = 1, limit = 50): Promise<PaginatedResponse<Scenario>> { list(page = 1, limit = 50): Promise<PaginatedResponse<Scenario>> {
return request(`/scenarios?page=${page}&limit=${limit}`); return request(`/scenarios?page=${page}&limit=${limit}`);
}, },
get(id: number): Promise<Scenario & { steps: ScenarioStep[] }> { get(id: string): Promise<Scenario & { steps: ScenarioStep[] }> {
return request(`/scenarios/${id}`); return request(`/scenarios/${id}`);
}, },
create(name: string): Promise<Scenario> { create(name: string): Promise<Scenario> {
@@ -148,41 +166,50 @@ export const scenarios = {
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}); });
}, },
update(id: number, patch: Partial<Pick<Scenario, 'name'>>): Promise<Scenario> { update(id: string, patch: Partial<Pick<Scenario, 'name'>>): Promise<Scenario> {
return request(`/scenarios/${id}`, { return request(`/scenarios/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(patch), body: JSON.stringify(patch),
}); });
}, },
remove(id: number): Promise<void> { remove(id: string): Promise<void> {
return request(`/scenarios/${id}`, { method: 'DELETE' }); return request(`/scenarios/${id}`, { method: 'DELETE' });
}, },
run(id: number): Promise<ScenarioRun> { run(id: string): Promise<ScenarioRun> {
return request(`/scenarios/${id}/run`, { method: 'POST' }); return request(`/scenarios/${id}/run`, { method: 'POST' });
}, },
getRun(scenarioId: number, runId: number): Promise<ScenarioRunDetail> { getRun(scenarioId: string, runId: string): Promise<ScenarioRunDetail> {
return request(`/scenarios/${scenarioId}/run/${runId}`); return request(`/scenarios/${scenarioId}/run/${runId}`);
}, },
waitForRun(scenarioId: number, runId: number): Promise<ScenarioRunDetail> { waitForRun(scenarioId: string, runId: string): Promise<ScenarioRunDetail> {
return request(`/scenarios/${scenarioId}/run/${runId}/wait`, { method: 'POST' }); return request(`/scenarios/${scenarioId}/run/${runId}/wait`, { method: 'POST' });
}, },
listRuns(scenarioId: number, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> { listRuns(scenarioId: string, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`); return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
}, },
exportScenario(id: string): Promise<unknown> {
return request(`/scenarios/${id}/export`);
},
importScenario(payload: unknown): Promise<Scenario> {
return request('/scenarios/import', {
method: 'POST',
body: JSON.stringify(payload),
});
},
}; };
// ── Runs ───────────────────────────────────────────────────────────────────── // ── Runs ─────────────────────────────────────────────────────────────────────
export const runs = { export const runs = {
listAll(page = 1, limit = 20, status?: string): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[]; scenario: { id: number; name: string } }>> { listAll(page = 1, limit = 20, status?: string): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[]; scenario: { id: string; name: string } }>> {
const q = new URLSearchParams({ page: String(page), limit: String(limit) }); const q = new URLSearchParams({ page: String(page), limit: String(limit) });
if (status) q.set('status', status); if (status) q.set('status', status);
return request(`/scenarios/runs?${q}`); return request(`/scenarios/runs?${q}`);
}, },
list(scenarioId: number, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> { list(scenarioId: string, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`); return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
}, },
get(scenarioId: number, runId: number): Promise<ScenarioRunDetail> { get(scenarioId: string, runId: string): Promise<ScenarioRunDetail> {
return request(`/scenarios/${scenarioId}/run/${runId}`); return request(`/scenarios/${scenarioId}/run/${runId}`);
}, },
}; };
@@ -190,16 +217,16 @@ export const runs = {
// ── Scenario Credentials ────────────────────────────────────────────────────── // ── Scenario Credentials ──────────────────────────────────────────────────────
export const scenarioCredentials = { export const scenarioCredentials = {
list(scenarioId: number): Promise<ScenarioCredential[]> { list(scenarioId: string): Promise<ScenarioCredential[]> {
return request(`/scenarios/${scenarioId}/credentials`); return request(`/scenarios/${scenarioId}/credentials`);
}, },
add(scenarioId: number, credentialId: number, alias: string): Promise<ScenarioCredential> { add(scenarioId: string, credentialId: string, alias: string): Promise<ScenarioCredential> {
return request(`/scenarios/${scenarioId}/credentials`, { return request(`/scenarios/${scenarioId}/credentials`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ credentialId, alias }), body: JSON.stringify({ credentialId, alias }),
}); });
}, },
remove(scenarioId: number, scCredId: number): Promise<void> { remove(scenarioId: string, scCredId: string): Promise<void> {
return request(`/scenarios/${scenarioId}/credentials/${scCredId}`, { return request(`/scenarios/${scenarioId}/credentials/${scCredId}`, {
method: 'DELETE', method: 'DELETE',
}); });
@@ -224,22 +251,22 @@ export interface UpdateStepPayload {
} }
export const steps = { export const steps = {
get(scenarioId: number, stepId: number): Promise<ScenarioStep> { get(scenarioId: string, stepId: string): Promise<ScenarioStep> {
return request(`/scenarios/${scenarioId}/steps/${stepId}`); return request(`/scenarios/${scenarioId}/steps/${stepId}`);
}, },
create(scenarioId: number, payload: CreateStepPayload): Promise<ScenarioStep> { create(scenarioId: string, payload: CreateStepPayload): Promise<ScenarioStep> {
return request(`/scenarios/${scenarioId}/steps`, { return request(`/scenarios/${scenarioId}/steps`, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
}, },
update(scenarioId: number, stepId: number, payload: UpdateStepPayload): Promise<ScenarioStep> { update(scenarioId: string, stepId: string, payload: UpdateStepPayload): Promise<ScenarioStep> {
return request(`/scenarios/${scenarioId}/steps/${stepId}`, { return request(`/scenarios/${scenarioId}/steps/${stepId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
}, },
remove(scenarioId: number, stepId: number): Promise<void> { remove(scenarioId: string, stepId: string): Promise<void> {
return request(`/scenarios/${scenarioId}/steps/${stepId}`, { method: 'DELETE' }); return request(`/scenarios/${scenarioId}/steps/${stepId}`, { method: 'DELETE' });
}, },
}; };
+18 -18
View File
@@ -17,7 +17,7 @@ export interface EnvironmentUrls {
} }
export interface Environment { export interface Environment {
id: number; id: string;
name: string; name: string;
urls: EnvironmentUrls; urls: EnvironmentUrls;
createdAt: string; createdAt: string;
@@ -27,7 +27,7 @@ export interface Environment {
// ── Credentials ─────────────────────────────────────────────────────────────── // ── Credentials ───────────────────────────────────────────────────────────────
export interface Credential { export interface Credential {
id: number; id: string;
name: string; name: string;
data: string | null; data: string | null;
lastUsedAt: string | null; lastUsedAt: string | null;
@@ -38,7 +38,7 @@ export interface Credential {
// ── Snippets ────────────────────────────────────────────────────────────────── // ── Snippets ──────────────────────────────────────────────────────────────────
export interface Snippet { export interface Snippet {
id: number; id: string;
name: string; name: string;
description: string | null; description: string | null;
code: string; code: string;
@@ -57,7 +57,7 @@ export interface KeysResponse {
export type SessionStatus = 'open' | 'closed'; export type SessionStatus = 'open' | 'closed';
export interface Session { export interface Session {
id: number; id: string;
sessionName: string; sessionName: string;
token: string; token: string;
status: SessionStatus; status: SessionStatus;
@@ -71,8 +71,8 @@ export interface Session {
export type StepType = 'login' | 'exec' | 'sign'; export type StepType = 'login' | 'exec' | 'sign';
export interface ScenarioStep { export interface ScenarioStep {
id: number; id: string;
scenarioId: number; scenarioId: string;
order: number; order: number;
type: StepType; type: StepType;
title: string | null; title: string | null;
@@ -84,16 +84,16 @@ export interface ScenarioStep {
} }
export interface ScenarioCredential { export interface ScenarioCredential {
id: number; id: string;
scenarioId: number; scenarioId: string;
credentialId: number; credentialId: string;
alias: string; alias: string;
credential: Credential; credential: Credential;
createdAt: string; createdAt: string;
} }
export interface Scenario { export interface Scenario {
id: number; id: string;
name: string; name: string;
steps?: ScenarioStep[]; steps?: ScenarioStep[];
scenarioCredentials?: ScenarioCredential[]; scenarioCredentials?: ScenarioCredential[];
@@ -108,8 +108,8 @@ export type RunStepStatus = 'waiting' | 'pending' | 'in_progress' | 'pass' | 'fa
export type LogLevel = 'log' | 'warn' | 'error'; export type LogLevel = 'log' | 'warn' | 'error';
export interface ScenarioRun { export interface ScenarioRun {
id: number; id: string;
scenarioId: number; scenarioId: string;
scenario?: Pick<Scenario, 'id' | 'name'>; scenario?: Pick<Scenario, 'id' | 'name'>;
status: ScenarioRunStatus; status: ScenarioRunStatus;
stepRuns?: ScenarioRunStep[]; stepRuns?: ScenarioRunStep[];
@@ -118,9 +118,9 @@ export interface ScenarioRun {
} }
export interface ScenarioRunStep { export interface ScenarioRunStep {
id: number; id: string;
runId: number; runId: string;
scenarioStepId: number; scenarioStepId: string;
scenarioStep: ScenarioStep; scenarioStep: ScenarioStep;
status: RunStepStatus; status: RunStepStatus;
order: number; order: number;
@@ -131,9 +131,9 @@ export interface ScenarioRunStep {
} }
export interface ScenarioRunLog { export interface ScenarioRunLog {
id: number; id: string;
runId: number; runId: string;
stepRunId: number | null; stepRunId: string | null;
level: LogLevel; level: LogLevel;
message: string; message: string;
createdAt: string; createdAt: string;
+11 -2
View File
@@ -71,7 +71,10 @@
"field_last_used": "Last Used", "field_last_used": "Last Used",
"field_created": "Created", "field_created": "Created",
"field_updated": "Updated", "field_updated": "Updated",
"section_data": "Data" "section_data": "Data",
"action_export": "Export",
"action_import": "Import",
"import_error": "Failed to import credential"
}, },
"keys": { "keys": {
"col_name": "Key name", "col_name": "Key name",
@@ -116,6 +119,9 @@
"action_add": "New scenario", "action_add": "New scenario",
"action_edit": "Edit", "action_edit": "Edit",
"action_runs": "Runs", "action_runs": "Runs",
"action_export": "Export",
"action_import": "Import",
"import_error": "Failed to import scenario",
"action_save": "Create", "action_save": "Create",
"action_update": "Save changes", "action_update": "Save changes",
"action_cancel": "Cancel", "action_cancel": "Cancel",
@@ -228,6 +234,9 @@
"field_description": "Description", "field_description": "Description",
"field_created": "Created", "field_created": "Created",
"field_updated": "Updated", "field_updated": "Updated",
"section_code": "Code" "section_code": "Code",
"action_export": "Export",
"action_import": "Import",
"import_error": "Failed to import snippet"
} }
} }
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 { credentials } from '../../api';
import type { Credential } 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'; import styles from '../Page.module.css';
export function CredentialDetailPage() { export function CredentialDetailPage() {
@@ -18,7 +19,7 @@ export function CredentialDetailPage() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
credentials credentials
.get(Number(id)) .get(id)
.then(setCredential) .then(setCredential)
.catch((err: Error) => setError(err.message)) .catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -30,6 +31,18 @@ export function CredentialDetailPage() {
navigate('/credentials'); 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 ( return (
<div> <div>
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
@@ -41,6 +54,10 @@ export function CredentialDetailPage() {
/> />
{credential && ( {credential && (
<div className={styles.toolbarActions}> <div className={styles.toolbarActions}>
<Button variant="secondary" size="sm" onClick={handleExport}>
<Download size={14} />
{t('credentials.action_export')}
</Button>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -74,7 +91,7 @@ export function CredentialDetailPage() {
<DescriptionList <DescriptionList
layout="comfortable" layout="comfortable"
items={[ items={[
{ term: t('credentials.field_id'), detail: credential.id }, { term: t('credentials.field_id'), detail: <UuidBadge id={credential.id} /> },
{ {
term: t('credentials.field_last_used'), term: t('credentials.field_last_used'),
detail: credential.lastUsedAt ? ( detail: credential.lastUsedAt ? (
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Settings, Pencil, Trash2, Plus } from 'lucide-react'; import { Settings, Pencil, Trash2, Plus, Upload } from 'lucide-react';
import { parse as yamlParse } from 'yaml';
import { credentials } from '../../api'; import { credentials } from '../../api';
import type { Credential } from '../../api'; import type { Credential } 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'; import styles from '../Page.module.css';
function CredentialCard({ function CredentialCard({
@@ -12,7 +13,7 @@ function CredentialCard({
onDelete, onDelete,
}: { }: {
credential: Credential; credential: Credential;
onDelete: (id: number) => void; onDelete: (id: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -48,7 +49,7 @@ function CredentialCard({
const footer = ( const footer = (
<div className={styles.envCardFooter}> <div className={styles.envCardFooter}>
<span className={styles.envCardId}>#{credential.id}</span> <UuidBadge id={credential.id} />
<Timestamp value={credential.updatedAt} /> <Timestamp value={credential.updatedAt} />
</div> </div>
); );
@@ -90,9 +91,11 @@ function AddCredentialCard() {
export function CredentialsPage() { export function CredentialsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const [items, setItems] = useState<Credential[]>([]); const [items, setItems] = useState<Credential[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const load = () => { const load = () => {
credentials credentials
@@ -106,14 +109,43 @@ export function CredentialsPage() {
load(); load();
}, []); }, []);
const handleDelete = async (id: number) => { const handleDelete = async (id: string) => {
await credentials.remove(id); await credentials.remove(id);
setItems((prev) => prev.filter((c) => c.id !== id)); setItems((prev) => prev.filter((c) => c.id !== id));
}; };
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
try {
const text = await file.text();
const payload = yamlParse(text) as unknown;
const imported = await credentials.importCredential(payload);
navigate(`/credentials/${imported.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
return ( return (
<div> <div>
<Breadcrumbs items={[{ label: t('credentials.title') }]} className={styles.breadcrumbs} /> <div className={styles.pageToolbar}>
<Breadcrumbs items={[{ label: t('credentials.title') }]} className={styles.breadcrumbs} />
<div className={styles.toolbarActions}>
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml,.json"
style={{ display: 'none' }}
onChange={handleImport}
/>
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
<Upload size={14} />
{t('credentials.action_import')}
</Button>
</div>
</div>
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
{loading ? ( {loading ? (
<p className={styles.muted}>{t('credentials.loading')}</p> <p className={styles.muted}>{t('credentials.loading')}</p>
@@ -23,7 +23,7 @@ export function EditCredentialPage() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
credentials credentials
.get(Number(id)) .get(id)
.then((c) => { .then((c) => {
setCredential(c); setCredential(c);
setName(c.name); setName(c.name);
@@ -52,7 +52,7 @@ export function EditCredentialPage() {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await credentials.update(Number(id), { await credentials.update(id!, {
name: name.trim(), name: name.trim(),
data: data.trim() || null, data: data.trim() || null,
}); });
@@ -24,7 +24,7 @@ export function EditEnvironmentPage() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
environments environments
.get(Number(id)) .get(id)
.then((data) => { .then((data) => {
setEnv(data); setEnv(data);
setName(data.name); setName(data.name);
@@ -45,7 +45,7 @@ export function EditEnvironmentPage() {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await environments.update(Number(id), { await environments.update(id!, {
name: name.trim(), name: name.trim(),
urls: { urls: {
id_url: idUrl.trim() || undefined, id_url: idUrl.trim() || undefined,
@@ -18,7 +18,7 @@ export function EnvironmentDetailPage() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
environments environments
.get(Number(id)) .get(id)
.then(setEnv) .then(setEnv)
.catch((err: Error) => setError(err.message)) .catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -4,10 +4,10 @@ import { useTranslation } from 'react-i18next';
import { Settings, ExternalLink, Pencil, Trash2, Plus } from 'lucide-react'; import { Settings, ExternalLink, Pencil, Trash2, Plus } from 'lucide-react';
import { environments } from '../../api'; import { environments } from '../../api';
import type { Environment } 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'; 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 { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const urlEntries = Object.entries(env.urls).filter(([, v]) => v); const urlEntries = Object.entries(env.urls).filter(([, v]) => v);
@@ -48,7 +48,7 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: n
const footer = ( const footer = (
<div className={styles.envCardFooter}> <div className={styles.envCardFooter}>
<span className={styles.envCardId}>#{env.id}</span> <UuidBadge id={env.id} />
<Timestamp value={env.updatedAt} /> <Timestamp value={env.updatedAt} />
</div> </div>
); );
@@ -106,7 +106,7 @@ export function EnvironmentsPage() {
load(); load();
}, []); }, []);
const handleDelete = async (id: number) => { const handleDelete = async (id: string) => {
await environments.remove(id); await environments.remove(id);
setItems((prev) => prev.filter((e) => e.id !== id)); setItems((prev) => prev.filter((e) => e.id !== id));
}; };
+2 -2
View File
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { runs } from '../../api'; import { runs } from '../../api';
import type { ScenarioRun, ScenarioRunStep, ScenarioRunStatus } 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 type { BadgeVariant } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
@@ -57,7 +57,7 @@ export function AllRunsPage() {
}, []); }, []);
const columns: TableColumn<AllRunRow>[] = [ const columns: TableColumn<AllRunRow>[] = [
{ key: 'id', header: t('runs.col_id'), render: (r) => r.id, width: 60 }, { key: 'id', header: t('runs.col_id'), render: (r) => <UuidBadge id={r.id} />, width: 60 },
{ {
key: 'scenario', key: 'scenario',
header: t('runs.col_scenario'), header: t('runs.col_scenario'),
+4 -3
View File
@@ -21,6 +21,7 @@ import {
Notification, Notification,
Table, Table,
Timestamp, Timestamp,
UuidBadge,
type TableColumn, type TableColumn,
} from '../../ui'; } from '../../ui';
import type { BadgeVariant } from '../../ui'; import type { BadgeVariant } from '../../ui';
@@ -82,7 +83,7 @@ export function RunDetailPage() {
if (!id || !runId) return; if (!id || !runId) return;
let cancelled = false; 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]) => { .then(([sc, r]) => {
if (cancelled) return; if (cancelled) return;
setScenario(sc); setScenario(sc);
@@ -91,7 +92,7 @@ export function RunDetailPage() {
setPolling(true); setPolling(true);
pollRef.current = setInterval(async () => { pollRef.current = setInterval(async () => {
try { try {
const updated = await runs.get(Number(id), Number(runId)); const updated = await runs.get(id, runId);
if (cancelled) return; if (cancelled) return;
setRun(updated); setRun(updated);
setPulseKey((k) => k + 1); setPulseKey((k) => k + 1);
@@ -183,7 +184,7 @@ export function RunDetailPage() {
<DescriptionList <DescriptionList
layout="comfortable" layout="comfortable"
items={[ items={[
{ term: t('runs.field_id'), detail: run.id }, { term: t('runs.field_id'), detail: <UuidBadge id={run.id} /> },
{ {
term: t('runs.field_status'), term: t('runs.field_status'),
detail: ( detail: (
+4 -4
View File
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Play } from 'lucide-react'; import { Play } from 'lucide-react';
import { scenarios, runs } from '../../api'; import { scenarios, runs } from '../../api';
import type { Scenario, ScenarioRun, ScenarioRunStep, ScenarioRunStatus } 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 type { BadgeVariant } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
@@ -39,7 +39,7 @@ export function RunsPage() {
const load = useCallback(() => { const load = useCallback(() => {
if (!id) return; if (!id) return;
setLoading(true); setLoading(true);
Promise.all([scenarios.get(Number(id)), runs.list(Number(id))]) Promise.all([scenarios.get(id!), runs.list(id!)])
.then(([sc, res]) => { .then(([sc, res]) => {
setScenario(sc); setScenario(sc);
setItems(res.data); setItems(res.data);
@@ -58,12 +58,12 @@ export function RunsPage() {
}, [load]); }, [load]);
const handleRun = async () => { const handleRun = async () => {
const run = await scenarios.run(Number(id)); const run = await scenarios.run(id!);
navigate(`/scenarios/${id}/runs/${run.id}`); navigate(`/scenarios/${id}/runs/${run.id}`);
}; };
const columns: TableColumn<RunRow>[] = [ const columns: TableColumn<RunRow>[] = [
{ key: 'id', header: t('runs.col_id'), render: (r) => r.id, width: 60 }, { key: 'id', header: t('runs.col_id'), render: (r) => <UuidBadge id={r.id} />, width: 60 },
{ {
key: 'status', key: 'status',
header: t('runs.col_status'), header: t('runs.col_status'),
+2 -2
View File
@@ -26,7 +26,7 @@ export function CreateStepPage() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
scenarios.get(Number(id)).then(setScenario).catch(() => null); scenarios.get(id).then(setScenario).catch(() => null);
}, [id]); }, [id]);
const validate = (): boolean => { const validate = (): boolean => {
@@ -42,7 +42,7 @@ export function CreateStepPage() {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await steps.create(Number(id), { await steps.create(id!, {
order: Number(order), order: Number(order),
title: title.trim() || undefined, title: title.trim() || undefined,
type, type,
@@ -21,7 +21,7 @@ export function EditScenarioPage() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
scenarios scenarios
.get(Number(id)) .get(id)
.then((data) => { .then((data) => {
setScenario(data); setScenario(data);
setName(data.name); setName(data.name);
@@ -39,7 +39,7 @@ export function EditScenarioPage() {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await scenarios.update(Number(id), { name: name.trim() }); await scenarios.update(id!, { name: name.trim() });
navigate(`/scenarios/${id}`); navigate(`/scenarios/${id}`);
} catch (err) { } catch (err) {
setError((err as Error).message); setError((err as Error).message);
+2 -2
View File
@@ -27,7 +27,7 @@ export function EditStepPage() {
useEffect(() => { useEffect(() => {
if (!id || !stepId) return; 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]) => { .then(([sc, st]) => {
setScenario(sc); setScenario(sc);
setStep(st); setStep(st);
@@ -56,7 +56,7 @@ export function EditStepPage() {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await steps.update(Number(id), Number(stepId), { await steps.update(id!, stepId!, {
order: Number(order), order: Number(order),
title: title.trim() || undefined, title: title.trim() || undefined,
type, type,
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 { scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api';
import type { Scenario, ScenarioStep, ScenarioCredential, Credential } from '../../api'; import type { Scenario, ScenarioStep, ScenarioCredential, Credential } from '../../api';
import { import {
@@ -15,6 +16,7 @@ import {
Select, Select,
Table, Table,
Timestamp, Timestamp,
UuidBadge,
type TableColumn, type TableColumn,
} from '../../ui'; } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
@@ -46,7 +48,7 @@ export function ScenarioDetailPage() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
scenarios scenarios
.get(Number(id)) .get(id)
.then((s) => { .then((s) => {
setScenario(s); setScenario(s);
setScenarioCreds(s.scenarioCredentials ?? []); setScenarioCreds(s.scenarioCredentials ?? []);
@@ -71,8 +73,20 @@ export function ScenarioDetailPage() {
navigate('/scenarios'); navigate('/scenarios');
}; };
const handleDeleteStep = async (stepId: number) => { const handleExport = async () => {
await steps.remove(Number(id), stepId); 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) => setScenario((prev) =>
prev ? { ...prev, steps: prev.steps.filter((s) => s.id !== stepId) } : prev, prev ? { ...prev, steps: prev.steps.filter((s) => s.id !== stepId) } : prev,
); );
@@ -86,9 +100,9 @@ export function ScenarioDetailPage() {
if (!valid) return; if (!valid) return;
setAddCredSaving(true); setAddCredSaving(true);
try { 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 // 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); setScenarioCreds(full);
void sc; void sc;
setAddCredOpen(false); setAddCredOpen(false);
@@ -101,8 +115,8 @@ export function ScenarioDetailPage() {
} }
}; };
const handleRemoveCredential = async (scCredId: number) => { const handleRemoveCredential = async (scCredId: string) => {
await scenarioCredentials.remove(Number(id), scCredId); await scenarioCredentials.remove(id!, scCredId);
setScenarioCreds((prev) => prev.filter((sc) => sc.id !== scCredId)); setScenarioCreds((prev) => prev.filter((sc) => sc.id !== scCredId));
}; };
@@ -212,6 +226,10 @@ export function ScenarioDetailPage() {
<History size={14} /> <History size={14} />
{t('scenarios.action_runs')} {t('scenarios.action_runs')}
</Button> </Button>
<Button variant="secondary" size="sm" onClick={handleExport}>
<Download size={14} />
{t('scenarios.action_export')}
</Button>
<Button variant="secondary" size="sm" onClick={() => navigate(`/scenarios/${id}/edit`)}> <Button variant="secondary" size="sm" onClick={() => navigate(`/scenarios/${id}/edit`)}>
<Pencil size={14} /> <Pencil size={14} />
{t('scenarios.action_edit')} {t('scenarios.action_edit')}
@@ -240,7 +258,7 @@ export function ScenarioDetailPage() {
<DescriptionList <DescriptionList
layout="comfortable" layout="comfortable"
items={[ items={[
{ term: t('scenarios.field_id'), detail: scenario.id }, { term: t('scenarios.field_id'), detail: <UuidBadge id={scenario.id} /> },
{ term: t('scenarios.field_name'), detail: scenario.name }, { term: t('scenarios.field_name'), detail: scenario.name },
{ term: t('scenarios.field_steps'), detail: scenario.steps.length }, { term: t('scenarios.field_steps'), detail: scenario.steps.length },
{ {
@@ -296,7 +314,8 @@ export function ScenarioDetailPage() {
</div> </div>
{addCredOpen && ( {addCredOpen && (
<Card className={styles.formCard} style={{ marginBottom: 'var(--space-4)' }}> <div style={{ marginBottom: 'var(--space-4)' }}>
<Card className={styles.formCard}>
<form onSubmit={handleAddCredential} noValidate> <form onSubmit={handleAddCredential} noValidate>
<div className={styles.formFields}> <div className={styles.formFields}>
<Select <Select
@@ -332,6 +351,7 @@ export function ScenarioDetailPage() {
</div> </div>
</form> </form>
</Card> </Card>
</div>
)} )}
{scenarioCreds.length > 0 ? ( {scenarioCreds.length > 0 ? (
+34 -6
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Play, Plus, Trash2 } from 'lucide-react'; import { Play, Plus, Trash2, Upload } from 'lucide-react';
import { parse as yamlParse } from 'yaml';
import { scenarios } from '../../api'; import { scenarios } from '../../api';
import type { Scenario } from '../../api'; import type { Scenario } from '../../api';
import { Breadcrumbs, Button, Table, type TableColumn, Timestamp } from '../../ui'; import { Breadcrumbs, Button, Table, type TableColumn, Timestamp, UuidBadge } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
export function ScenariosPage() { export function ScenariosPage() {
@@ -14,6 +15,8 @@ export function ScenariosPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const load = useCallback(() => { const load = useCallback(() => {
scenarios scenarios
.list() .list()
@@ -26,19 +29,33 @@ export function ScenariosPage() {
load(); load();
}, [load]); }, [load]);
const handleRun = async (id: number) => { const handleRun = async (id: string) => {
const run = await scenarios.run(id); const run = await scenarios.run(id);
navigate(`/scenarios/${id}/runs/${run.id}`); navigate(`/scenarios/${id}/runs/${run.id}`);
}; };
const handleDelete = async (id: number) => { const handleDelete = async (id: string) => {
await scenarios.remove(id); await scenarios.remove(id);
setLoading(true); setLoading(true);
load(); load();
}; };
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
try {
const text = await file.text();
const payload = yamlParse(text) as unknown;
const imported = await scenarios.importScenario(payload);
navigate(`/scenarios/${imported.id}`);
} catch (err) {
setError((err as Error).message);
}
};
const columns: TableColumn<Scenario>[] = [ const columns: TableColumn<Scenario>[] = [
{ key: 'id', header: t('scenarios.col_id'), render: (s) => s.id, width: 60 }, { key: 'id', header: t('scenarios.col_id'), render: (s) => <UuidBadge id={s.id} />, width: 60 },
{ key: 'name', header: t('scenarios.col_name'), render: (s) => s.name }, { key: 'name', header: t('scenarios.col_name'), render: (s) => s.name },
{ {
key: 'updated', key: 'updated',
@@ -74,6 +91,17 @@ export function ScenariosPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs items={[{ label: t('scenarios.title') }]} className={styles.breadcrumbs} /> <Breadcrumbs items={[{ label: t('scenarios.title') }]} className={styles.breadcrumbs} />
<div className={styles.toolbarActions}> <div className={styles.toolbarActions}>
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml,.json"
style={{ display: 'none' }}
onChange={handleImport}
/>
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
<Upload size={14} />
{t('scenarios.action_import')}
</Button>
<Button size="sm" onClick={() => navigate('/scenarios/new')}> <Button size="sm" onClick={() => navigate('/scenarios/new')}>
<Plus size={14} /> <Plus size={14} />
{t('scenarios.action_add')} {t('scenarios.action_add')}
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { sessions } from '../../api'; import { sessions } from '../../api';
import type { Session } from '../../api'; import type { Session } from '../../api';
import { Badge, Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp } from '../../ui'; import { Badge, Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp, UuidBadge } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
export function SessionDetailPage() { export function SessionDetailPage() {
@@ -18,7 +18,7 @@ export function SessionDetailPage() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
sessions sessions
.get(Number(id)) .get(id)
.then(setSession) .then(setSession)
.catch((err: Error) => setError(err.message)) .catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -62,7 +62,7 @@ export function SessionDetailPage() {
<DescriptionList <DescriptionList
layout="comfortable" layout="comfortable"
items={[ items={[
{ term: t('sessions.field_id'), detail: session.id }, { term: t('sessions.field_id'), detail: <UuidBadge id={session.id} /> },
{ term: t('sessions.field_name'), detail: session.sessionName }, { term: t('sessions.field_name'), detail: session.sessionName },
{ {
term: t('sessions.field_status'), term: t('sessions.field_status'),
+3 -3
View File
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { sessions } from '../../api'; import { sessions } from '../../api';
import type { Session } from '../../api'; import type { Session } from '../../api';
import { Badge, Breadcrumbs, Button, Table, type TableColumn, Timestamp } from '../../ui'; import { Badge, Breadcrumbs, Button, Table, type TableColumn, Timestamp, UuidBadge } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
export function SessionsPage() { export function SessionsPage() {
@@ -26,14 +26,14 @@ export function SessionsPage() {
load(); load();
}, [load]); }, [load]);
const handleDelete = async (id: number) => { const handleDelete = async (id: string) => {
await sessions.remove(id); await sessions.remove(id);
setLoading(true); setLoading(true);
load(); load();
}; };
const columns: TableColumn<Session>[] = [ const columns: TableColumn<Session>[] = [
{ key: 'id', header: t('sessions.col_id'), render: (s) => s.id, width: 60 }, { key: 'id', header: t('sessions.col_id'), render: (s) => <UuidBadge id={s.id} />, width: 60 },
{ key: 'name', header: t('sessions.col_name'), render: (s) => s.sessionName }, { key: 'name', header: t('sessions.col_name'), render: (s) => s.sessionName },
{ {
key: 'status', key: 'status',
+2 -2
View File
@@ -24,7 +24,7 @@ export function EditSnippetPage() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
snippets snippets
.get(Number(id)) .get(id)
.then((s) => { .then((s) => {
setSnippet(s); setSnippet(s);
setName(s.name); setName(s.name);
@@ -50,7 +50,7 @@ export function EditSnippetPage() {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await snippets.update(Number(id), { await snippets.update(id!, {
name: name.trim(), name: name.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
code: code.trim(), code: code.trim(),
+21 -4
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 { snippets } from '../../api'; import { snippets } from '../../api';
import type { Snippet } from '../../api'; import type { Snippet } 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'; import styles from '../Page.module.css';
export function SnippetDetailPage() { export function SnippetDetailPage() {
@@ -18,7 +19,7 @@ export function SnippetDetailPage() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
snippets snippets
.get(Number(id)) .get(id)
.then(setSnippet) .then(setSnippet)
.catch((err: Error) => setError(err.message)) .catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -30,6 +31,18 @@ export function SnippetDetailPage() {
navigate('/snippets'); navigate('/snippets');
}; };
const handleExport = async () => {
if (!snippet) return;
const data = await snippets.exportSnippet(snippet.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 = `snippet-${snippet.name}.yaml`;
a.click();
URL.revokeObjectURL(url);
};
return ( return (
<div> <div>
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
@@ -41,6 +54,10 @@ export function SnippetDetailPage() {
/> />
{snippet && ( {snippet && (
<div className={styles.toolbarActions}> <div className={styles.toolbarActions}>
<Button variant="secondary" size="sm" onClick={handleExport}>
<Download size={14} />
{t('snippets.action_export')}
</Button>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -74,7 +91,7 @@ export function SnippetDetailPage() {
<DescriptionList <DescriptionList
layout="comfortable" layout="comfortable"
items={[ items={[
{ term: t('snippets.field_id'), detail: snippet.id }, { term: t('snippets.field_id'), detail: <UuidBadge id={snippet.id} /> },
{ term: t('snippets.field_name'), detail: snippet.name }, { term: t('snippets.field_name'), detail: snippet.name },
{ {
term: t('snippets.field_description'), term: t('snippets.field_description'),
+36 -6
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Code, Pencil, Trash2, Plus } from 'lucide-react'; import { Code, Pencil, Trash2, Plus, Upload } from 'lucide-react';
import { parse as yamlParse } from 'yaml';
import { snippets } from '../../api'; import { snippets } from '../../api';
import type { Snippet } from '../../api'; import type { Snippet } from '../../api';
import { Breadcrumbs, Button, Card, ContextMenu, Timestamp } from '../../ui'; import { Breadcrumbs, Button, Card, ContextMenu, Timestamp, UuidBadge } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
function SnippetCard({ function SnippetCard({
@@ -12,7 +13,7 @@ function SnippetCard({
onDelete, onDelete,
}: { }: {
snippet: Snippet; snippet: Snippet;
onDelete: (id: number) => void; onDelete: (id: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -48,7 +49,7 @@ function SnippetCard({
const footer = ( const footer = (
<div className={styles.envCardFooter}> <div className={styles.envCardFooter}>
<span className={styles.envCardId}>#{snippet.id}</span> <UuidBadge id={snippet.id} />
<Timestamp value={snippet.updatedAt} /> <Timestamp value={snippet.updatedAt} />
</div> </div>
); );
@@ -83,9 +84,11 @@ function AddSnippetCard() {
export function SnippetsPage() { export function SnippetsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const [items, setItems] = useState<Snippet[]>([]); const [items, setItems] = useState<Snippet[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const load = () => { const load = () => {
snippets snippets
@@ -99,15 +102,42 @@ export function SnippetsPage() {
load(); load();
}, []); }, []);
const handleDelete = async (id: number) => { const handleDelete = async (id: string) => {
await snippets.remove(id); await snippets.remove(id);
load(); load();
}; };
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
try {
const text = await file.text();
const payload = yamlParse(text) as unknown;
const imported = await snippets.importSnippet(payload);
navigate(`/snippets/${imported.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
return ( return (
<div> <div>
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs items={[{ label: t('snippets.title') }]} /> <Breadcrumbs items={[{ label: t('snippets.title') }]} />
<div className={styles.toolbarActions}>
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml,.json"
style={{ display: 'none' }}
onChange={handleImport}
/>
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
<Upload size={14} />
{t('snippets.action_import')}
</Button>
</div>
</div> </div>
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
@@ -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)); }
}
+36
View File
@@ -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 (
<span className={styles.wrapper} title={copied ? 'Copied!' : id}>
<Badge variant="neutral">
<button type="button" className={styles.button} onClick={handleClick}>
{short}
</button>
</Badge>
{copied && (
<span className={styles.pop} aria-hidden="true">
<ClipboardCheck size={13} />
</span>
)}
</span>
);
}
+3
View File
@@ -44,3 +44,6 @@ export type { NotificationProps, NotificationVariant } from './Notification/Noti
export { Table } from './Table/Table'; export { Table } from './Table/Table';
export type { TableProps, TableColumn } from './Table/Table'; export type { TableProps, TableColumn } from './Table/Table';
export { UuidBadge } from './UuidBadge/UuidBadge';
export type { UuidBadgeProps } from './UuidBadge/UuidBadge';
+17 -1
View File
@@ -21,7 +21,8 @@
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-i18next": "^17.0.2", "react-i18next": "^17.0.2",
"react-router-dom": "^7.14.0" "react-router-dom": "^7.14.0",
"yaml": "^2.8.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
@@ -14314,6 +14315,21 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+21 -4
View File
@@ -5,7 +5,7 @@ import {
Get, Get,
HttpCode, HttpCode,
Param, Param,
ParseIntPipe, ParseUUIDPipe,
Patch, Patch,
Post, Post,
Query, Query,
@@ -14,6 +14,7 @@ import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { CredentialService } from "./credential.service"; import { CredentialService } from "./credential.service";
import { CreateCredentialDto } from "./dto/create-credential.dto"; import { CreateCredentialDto } from "./dto/create-credential.dto";
import { UpdateCredentialDto } from "./dto/update-credential.dto"; import { UpdateCredentialDto } from "./dto/update-credential.dto";
import { CredentialExportDto } from "./dto/credential-export.dto";
import { PaginationQueryDto } from "../common/dto/pagination.dto"; import { PaginationQueryDto } from "../common/dto/pagination.dto";
import { CredentialOrderBy } from "./credential.service"; import { CredentialOrderBy } from "./credential.service";
@@ -29,6 +30,13 @@ export class CredentialController {
return this.credentialService.create(dto); 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() @Get()
@ApiOperation({ summary: "List all credentials (paginated)" }) @ApiOperation({ summary: "List all credentials (paginated)" })
@ApiResponse({ status: 200, description: "Paginated credentials" }) @ApiResponse({ status: 200, description: "Paginated credentials" })
@@ -36,11 +44,20 @@ export class CredentialController {
return this.credentialService.findAll(query); 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") @Get(":id")
@ApiOperation({ summary: "Get credential by ID" }) @ApiOperation({ summary: "Get credential by ID" })
@ApiResponse({ status: 200, description: "Credential record" }) @ApiResponse({ status: 200, description: "Credential record" })
@ApiResponse({ status: 404, description: "Credential not found" }) @ApiResponse({ status: 404, description: "Credential not found" })
findOne(@Param("id", ParseIntPipe) id: number) { findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.credentialService.findOne(id); return this.credentialService.findOne(id);
} }
@@ -49,7 +66,7 @@ export class CredentialController {
@ApiResponse({ status: 200, description: "Credential updated" }) @ApiResponse({ status: 200, description: "Credential updated" })
@ApiResponse({ status: 404, description: "Credential not found" }) @ApiResponse({ status: 404, description: "Credential not found" })
update( update(
@Param("id", ParseIntPipe) id: number, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCredentialDto, @Body() dto: UpdateCredentialDto,
) { ) {
return this.credentialService.update(id, dto); return this.credentialService.update(id, dto);
@@ -60,7 +77,7 @@ export class CredentialController {
@ApiOperation({ summary: "Delete a credential" }) @ApiOperation({ summary: "Delete a credential" })
@ApiResponse({ status: 204, description: "Credential deleted" }) @ApiResponse({ status: 204, description: "Credential deleted" })
@ApiResponse({ status: 404, description: "Credential not found" }) @ApiResponse({ status: 404, description: "Credential not found" })
remove(@Param("id", ParseIntPipe) id: number) { remove(@Param("id", ParseUUIDPipe) id: string) {
return this.credentialService.remove(id); return this.credentialService.remove(id);
} }
} }
+2 -2
View File
@@ -8,8 +8,8 @@ import {
@Entity("credentials") @Entity("credentials")
export class CredentialEntity { export class CredentialEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn("uuid")
id: number; id: string;
@Column() @Column()
name: string; name: string;
+24 -3
View File
@@ -4,6 +4,7 @@ import { Repository } from "typeorm";
import { CredentialEntity } from "./credential.entity"; import { CredentialEntity } from "./credential.entity";
import { CreateCredentialDto } from "./dto/create-credential.dto"; import { CreateCredentialDto } from "./dto/create-credential.dto";
import { UpdateCredentialDto } from "./dto/update-credential.dto"; import { UpdateCredentialDto } from "./dto/update-credential.dto";
import { CredentialExportDto } from "./dto/credential-export.dto";
import { import {
PaginationQueryDto, PaginationQueryDto,
PaginatedResult, PaginatedResult,
@@ -37,20 +38,40 @@ export class CredentialService {
return { data, total, page, limit }; return { data, total, page, limit };
} }
async findOne(id: number): Promise<CredentialEntity> { async findOne(id: string): Promise<CredentialEntity> {
const credential = await this.repo.findOneBy({ id }); const credential = await this.repo.findOneBy({ id });
if (!credential) throw new NotFoundException(`Credential ${id} not found`); if (!credential) throw new NotFoundException(`Credential ${id} not found`);
return credential; return credential;
} }
async update(id: number, dto: UpdateCredentialDto): Promise<CredentialEntity> { async update(id: string, dto: UpdateCredentialDto): Promise<CredentialEntity> {
const credential = await this.findOne(id); const credential = await this.findOne(id);
Object.assign(credential, dto); Object.assign(credential, dto);
return this.repo.save(credential); return this.repo.save(credential);
} }
async remove(id: number): Promise<void> { async remove(id: string): Promise<void> {
await this.findOne(id); await this.findOne(id);
await this.repo.delete(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<CredentialEntity> {
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 }),
);
}
} }
@@ -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;
}
@@ -5,7 +5,7 @@ import {
Get, Get,
HttpCode, HttpCode,
Param, Param,
ParseIntPipe, ParseUUIDPipe,
Patch, Patch,
Post, Post,
Query, Query,
@@ -41,7 +41,7 @@ export class EnvironmentController {
@ApiOperation({ summary: "Get environment by ID" }) @ApiOperation({ summary: "Get environment by ID" })
@ApiResponse({ status: 200, description: "Environment record" }) @ApiResponse({ status: 200, description: "Environment record" })
@ApiResponse({ status: 404, description: "Environment not found" }) @ApiResponse({ status: 404, description: "Environment not found" })
findOne(@Param("id", ParseIntPipe) id: number) { findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.environmentService.findOne(id); return this.environmentService.findOne(id);
} }
@@ -50,7 +50,7 @@ export class EnvironmentController {
@ApiResponse({ status: 200, description: "Environment updated" }) @ApiResponse({ status: 200, description: "Environment updated" })
@ApiResponse({ status: 404, description: "Environment not found" }) @ApiResponse({ status: 404, description: "Environment not found" })
update( update(
@Param("id", ParseIntPipe) id: number, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateEnvironmentDto, @Body() dto: UpdateEnvironmentDto,
) { ) {
return this.environmentService.update(id, dto); return this.environmentService.update(id, dto);
@@ -61,7 +61,7 @@ export class EnvironmentController {
@ApiOperation({ summary: "Delete an environment" }) @ApiOperation({ summary: "Delete an environment" })
@ApiResponse({ status: 204, description: "Environment deleted" }) @ApiResponse({ status: 204, description: "Environment deleted" })
@ApiResponse({ status: 404, description: "Environment not found" }) @ApiResponse({ status: 404, description: "Environment not found" })
remove(@Param("id", ParseIntPipe) id: number) { remove(@Param("id", ParseUUIDPipe) id: string) {
return this.environmentService.remove(id); return this.environmentService.remove(id);
} }
} }
+2 -2
View File
@@ -15,8 +15,8 @@ export interface EnvironmentUrls {
@Entity("environments") @Entity("environments")
export class EnvironmentEntity { export class EnvironmentEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn("uuid")
id: number; id: string;
@Column({ unique: true }) @Column({ unique: true })
name: string; name: string;
@@ -45,14 +45,14 @@ export class EnvironmentService {
return { data, total, page, limit }; return { data, total, page, limit };
} }
async findOne(id: number): Promise<EnvironmentEntity> { async findOne(id: string): Promise<EnvironmentEntity> {
const env = await this.repo.findOneBy({ id }); const env = await this.repo.findOneBy({ id });
if (!env) throw new NotFoundException(`Environment ${id} not found`); if (!env) throw new NotFoundException(`Environment ${id} not found`);
return env; return env;
} }
async update( async update(
id: number, id: string,
dto: UpdateEnvironmentDto, dto: UpdateEnvironmentDto,
): Promise<EnvironmentEntity> { ): Promise<EnvironmentEntity> {
const env = await this.findOne(id); const env = await this.findOne(id);
@@ -60,7 +60,7 @@ export class EnvironmentService {
return this.repo.save(env); return this.repo.save(env);
} }
async remove(id: number): Promise<void> { async remove(id: string): Promise<void> {
await this.findOne(id); await this.findOne(id);
await this.repo.delete(id); await this.repo.delete(id);
} }
+22 -22
View File
@@ -135,7 +135,7 @@ export class McpService {
{ {
description: "Delete a session by numeric ID (closes it first if open)", description: "Delete a session by numeric ID (closes it first if open)",
inputSchema: { inputSchema: {
id: z.number().int().describe("Session ID to delete"), id: z.string().uuid().describe("Session ID to delete"),
}, },
}, },
async ({ id }) => { async ({ id }) => {
@@ -200,7 +200,7 @@ export class McpService {
{ {
description: "Get an environment record by ID", description: "Get an environment record by ID",
inputSchema: { inputSchema: {
id: z.number().int().describe("Environment ID"), id: z.string().uuid().describe("Environment ID"),
}, },
}, },
async ({ id }) => { async ({ id }) => {
@@ -258,7 +258,7 @@ export class McpService {
{ {
description: "Update an existing environment (name and/or urls)", description: "Update an existing environment (name and/or urls)",
inputSchema: { 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"), name: z.string().optional().describe("New name"),
urls: z urls: z
.record(z.string(), z.string()) .record(z.string(), z.string())
@@ -289,7 +289,7 @@ export class McpService {
{ {
description: "Delete an environment by ID", description: "Delete an environment by ID",
inputSchema: { inputSchema: {
id: z.number().int().describe("Environment ID to delete"), id: z.string().uuid().describe("Environment ID to delete"),
}, },
}, },
async ({ id }) => { async ({ id }) => {
@@ -443,7 +443,7 @@ export class McpService {
{ {
description: "Get a scenario with its steps by ID", description: "Get a scenario with its steps by ID",
inputSchema: { inputSchema: {
id: z.number().int().describe("Scenario ID"), id: z.string().uuid().describe("Scenario ID"),
}, },
}, },
async ({ id }) => { async ({ id }) => {
@@ -493,7 +493,7 @@ export class McpService {
{ {
description: "Update a scenario name", description: "Update a scenario name",
inputSchema: { inputSchema: {
id: z.number().int().describe("Scenario ID"), id: z.string().uuid().describe("Scenario ID"),
name: z.string().optional().describe("New name"), name: z.string().optional().describe("New name"),
}, },
}, },
@@ -519,7 +519,7 @@ export class McpService {
{ {
description: "Delete a scenario by ID", description: "Delete a scenario by ID",
inputSchema: { inputSchema: {
id: z.number().int().describe("Scenario ID"), id: z.string().uuid().describe("Scenario ID"),
}, },
}, },
async ({ id }) => { async ({ id }) => {
@@ -544,7 +544,7 @@ export class McpService {
{ {
description: "Add a step to a scenario", description: "Add a step to a scenario",
inputSchema: { inputSchema: {
scenarioId: z.number().int().describe("Parent scenario ID"), scenarioId: z.string().uuid().describe("Parent scenario ID"),
order: z order: z
.number() .number()
.int() .int()
@@ -582,8 +582,8 @@ export class McpService {
{ {
description: "Get a single step of a scenario", description: "Get a single step of a scenario",
inputSchema: { inputSchema: {
scenarioId: z.number().int().describe("Scenario ID"), scenarioId: z.string().uuid().describe("Scenario ID"),
stepId: z.number().int().describe("Step ID"), stepId: z.string().uuid().describe("Step ID"),
}, },
}, },
async ({ scenarioId, stepId }) => { async ({ scenarioId, stepId }) => {
@@ -606,8 +606,8 @@ export class McpService {
{ {
description: "Update a step within a scenario", description: "Update a step within a scenario",
inputSchema: { inputSchema: {
scenarioId: z.number().int().describe("Scenario ID"), scenarioId: z.string().uuid().describe("Scenario ID"),
stepId: z.number().int().describe("Step ID"), stepId: z.string().uuid().describe("Step ID"),
order: z order: z
.number() .number()
.int() .int()
@@ -647,8 +647,8 @@ export class McpService {
{ {
description: "Delete a step from a scenario", description: "Delete a step from a scenario",
inputSchema: { inputSchema: {
scenarioId: z.number().int().describe("Scenario ID"), scenarioId: z.string().uuid().describe("Scenario ID"),
stepId: z.number().int().describe("Step ID"), stepId: z.string().uuid().describe("Step ID"),
}, },
}, },
async ({ scenarioId, stepId }) => { async ({ scenarioId, stepId }) => {
@@ -677,7 +677,7 @@ export class McpService {
description: description:
"List runs for a scenario (paginated, optionally filtered by status)", "List runs for a scenario (paginated, optionally filtered by status)",
inputSchema: { inputSchema: {
scenarioId: z.number().int().describe("Scenario ID"), scenarioId: z.string().uuid().describe("Scenario ID"),
status: z status: z
.enum(["pending", "in_progress", "pass", "fail"]) .enum(["pending", "in_progress", "pass", "fail"])
.optional() .optional()
@@ -720,7 +720,7 @@ export class McpService {
{ {
description: "Trigger an immediate run of a scenario by ID", description: "Trigger an immediate run of a scenario by ID",
inputSchema: { inputSchema: {
id: z.number().int().describe("Scenario ID to run"), id: z.string().uuid().describe("Scenario ID to run"),
}, },
}, },
async ({ id }) => { async ({ id }) => {
@@ -744,8 +744,8 @@ export class McpService {
description: description:
"Get a specific scenario run with all step runs and their outputs", "Get a specific scenario run with all step runs and their outputs",
inputSchema: { inputSchema: {
scenarioId: z.number().int().describe("Scenario ID"), scenarioId: z.string().uuid().describe("Scenario ID"),
runId: z.number().int().describe("Run ID"), runId: z.string().uuid().describe("Run ID"),
}, },
}, },
async ({ scenarioId, runId }) => { async ({ scenarioId, runId }) => {
@@ -769,8 +769,8 @@ export class McpService {
description: description:
"Block until a scenario run reaches pass or fail (max 5 min), then return the full run with step outputs", "Block until a scenario run reaches pass or fail (max 5 min), then return the full run with step outputs",
inputSchema: { inputSchema: {
scenarioId: z.number().int().describe("Scenario ID"), scenarioId: z.string().uuid().describe("Scenario ID"),
runId: z.number().int().describe("Run ID"), runId: z.string().uuid().describe("Run ID"),
}, },
}, },
async ({ scenarioId, runId }) => { async ({ scenarioId, runId }) => {
@@ -794,7 +794,7 @@ export class McpService {
description: description:
"Export a scenario as a portable JSON payload (name + steps)", "Export a scenario as a portable JSON payload (name + steps)",
inputSchema: { inputSchema: {
id: z.number().int().describe("Scenario ID to export"), id: z.string().uuid().describe("Scenario ID to export"),
}, },
}, },
async ({ id }) => { async ({ id }) => {
@@ -826,7 +826,7 @@ export class McpService {
z.object({ z.object({
order: z.number().int().min(0).describe("Execution order"), order: z.number().int().min(0).describe("Execution order"),
type: z.enum(["login", "exec", "sign"]).describe("Step type"), 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 execCode: z
.string() .string()
.nullable() .nullable()
@@ -1,12 +1,10 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { IsInt, IsNotEmpty, IsPositive, IsString } from "class-validator"; import { IsNotEmpty, IsString, IsUUID } from "class-validator";
export class AddScenarioCredentialDto { export class AddScenarioCredentialDto {
@ApiProperty({ example: 1 }) @ApiProperty({ example: "uuid-here" })
@IsInt() @IsUUID()
@IsPositive() credentialId: string
credentialId: number;
@ApiProperty({ example: "api_key" }) @ApiProperty({ example: "api_key" })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
+20 -2
View File
@@ -7,6 +7,7 @@ import {
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
IsUUID,
Min, Min,
ValidateNested, ValidateNested,
} from "class-validator"; } from "class-validator";
@@ -22,10 +23,11 @@ export class ScenarioStepExportDto {
@IsIn(["login", "exec", "sign"]) @IsIn(["login", "exec", "sign"])
type: StepType; type: StepType;
@ApiProperty() @ApiPropertyOptional()
@IsOptional()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
sessionName: string | null; title: string | null;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@@ -41,6 +43,22 @@ export class ScenarioStepExportDto {
} }
export class ScenarioExportDto { export class ScenarioExportDto {
@ApiPropertyOptional()
@IsOptional()
@IsIn(["scenario"])
kind?: "scenario";
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
id?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@IsNotEmpty()
title?: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@@ -13,14 +13,14 @@ import { CredentialEntity } from "../credential/credential.entity";
@Entity("scenario_credentials") @Entity("scenario_credentials")
@Unique(["scenarioId", "alias"]) @Unique(["scenarioId", "alias"])
export class ScenarioCredentialEntity { export class ScenarioCredentialEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn("uuid")
id: number; id: string;
@Column() @Column("text")
scenarioId: number; scenarioId: string;
@Column() @Column("text")
credentialId: number; credentialId: string;
@Column() @Column()
alias: string; alias: string;
@@ -13,18 +13,18 @@ export type LogLevel = "log" | "warn" | "error";
@Entity("scenario_run_logs") @Entity("scenario_run_logs")
export class ScenarioRunLogEntity { export class ScenarioRunLogEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn("uuid")
id: number; id: string;
@Column() @Column("text")
runId: number; runId: string;
@ManyToOne(() => ScenarioRunEntity, { onDelete: "CASCADE" }) @ManyToOne(() => ScenarioRunEntity, { onDelete: "CASCADE" })
@JoinColumn({ name: "runId" }) @JoinColumn({ name: "runId" })
run: ScenarioRunEntity; run: ScenarioRunEntity;
@Column({ nullable: true }) @Column({ type: "text", nullable: true })
stepRunId: number | null; stepRunId: string | null;
@ManyToOne(() => ScenarioRunStepEntity, { @ManyToOne(() => ScenarioRunStepEntity, {
onDelete: "SET NULL", onDelete: "SET NULL",
@@ -20,11 +20,11 @@ export type RunStepStatus =
@Entity("scenario_run_steps") @Entity("scenario_run_steps")
export class ScenarioRunStepEntity { export class ScenarioRunStepEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn("uuid")
id: number; id: string;
@Column() @Column("text")
runId: number; runId: string;
@ManyToOne(() => ScenarioRunEntity, (run) => run.stepRuns, { @ManyToOne(() => ScenarioRunEntity, (run) => run.stepRuns, {
onDelete: "CASCADE", onDelete: "CASCADE",
@@ -32,8 +32,8 @@ export class ScenarioRunStepEntity {
@JoinColumn({ name: "runId" }) @JoinColumn({ name: "runId" })
run: ScenarioRunEntity; run: ScenarioRunEntity;
@Column() @Column("text")
scenarioStepId: number; scenarioStepId: string;
@ManyToOne(() => ScenarioStepEntity, { onDelete: "CASCADE" }) @ManyToOne(() => ScenarioStepEntity, { onDelete: "CASCADE" })
@JoinColumn({ name: "scenarioStepId" }) @JoinColumn({ name: "scenarioStepId" })
+4 -4
View File
@@ -15,11 +15,11 @@ export type RunStatus = "pending" | "in_progress" | "pass" | "fail";
@Entity("scenario_runs") @Entity("scenario_runs")
export class ScenarioRunEntity { export class ScenarioRunEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn("uuid")
id: number; id: string;
@Column() @Column("text")
scenarioId: number; scenarioId: string;
@ManyToOne(() => ScenarioEntity, { onDelete: "CASCADE" }) @ManyToOne(() => ScenarioEntity, { onDelete: "CASCADE" })
@JoinColumn({ name: "scenarioId" }) @JoinColumn({ name: "scenarioId" })
@@ -33,14 +33,14 @@ interface BrowserHandle {
@Injectable() @Injectable()
export class ScenarioSchedulerService { export class ScenarioSchedulerService {
private readonly logger = new TraceLogger(ScenarioSchedulerService.name); private readonly logger = new TraceLogger(ScenarioSchedulerService.name);
private readonly activeRuns = new Set<number>(); private readonly activeRuns = new Set<string>();
private readonly runBrowsers = new Map<number, BrowserHandle>(); private readonly runBrowsers = new Map<string, BrowserHandle>();
// Cache credential maps per run (built once when a run starts) // Cache credential maps per run (built once when a run starts)
private readonly runCredentials = new Map<number, Record<string, unknown>>(); private readonly runCredentials = new Map<string, Record<string, unknown>>();
// Cache environment URLs per run (resolved from the first login step) // Cache environment URLs per run (resolved from the first login step)
private readonly runEnvironments = new Map<number, EnvironmentUrls>(); private readonly runEnvironments = new Map<string, EnvironmentUrls>();
// Cache snippet code map per run (built once when a run starts) // Cache snippet code map per run (built once when a run starts)
private readonly runSnippets = new Map<number, Record<string, string>>(); private readonly runSnippets = new Map<string, Record<string, string>>();
constructor( constructor(
@InjectRepository(ScenarioRunEntity) @InjectRepository(ScenarioRunEntity)
@@ -57,8 +57,8 @@ export class ScenarioSchedulerService {
) {} ) {}
private persistLog( private persistLog(
runId: number, runId: string,
stepRunId: number | null, stepRunId: string | null,
level: "log" | "warn" | "error", level: "log" | "warn" | "error",
message: string, message: string,
): void { ): 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) => { return (level, msg) => {
this.logger[level](`StepRun #${stepRunId} script: ${msg}`); this.logger[level](`StepRun #${stepRunId} script: ${msg}`);
this.persistLog(runId, stepRunId, level, msg); this.persistLog(runId, stepRunId, level, msg);
@@ -98,7 +98,7 @@ export class ScenarioSchedulerService {
* no login step or the environment cannot be found. * no login step or the environment cannot be found.
*/ */
private async resolveRunEnvironment( private async resolveRunEnvironment(
scenarioId: number, scenarioId: string,
): Promise<EnvironmentUrls | null> { ): Promise<EnvironmentUrls | null> {
try { try {
const scenario = await this.scenarioService.findOne(scenarioId); const scenario = await this.scenarioService.findOne(scenarioId);
@@ -147,7 +147,7 @@ export class ScenarioSchedulerService {
} }
} }
private async processRunToCompletion(runId: number): Promise<void> { private async processRunToCompletion(runId: string): Promise<void> {
try { try {
let stepRun = await this.runStepRepo.findOne({ let stepRun = await this.runStepRepo.findOne({
where: { runId, status: "pending" }, where: { runId, status: "pending" },
@@ -207,7 +207,7 @@ export class ScenarioSchedulerService {
// ── Shared browser per run ───────────────────────────────────────────────── // ── Shared browser per run ─────────────────────────────────────────────────
private async getOrCreateBrowserHandle(runId: number): Promise<BrowserHandle> { private async getOrCreateBrowserHandle(runId: string): Promise<BrowserHandle> {
const existing = this.runBrowsers.get(runId); const existing = this.runBrowsers.get(runId);
if (existing) return existing; if (existing) return existing;
@@ -224,7 +224,7 @@ export class ScenarioSchedulerService {
return handle; return handle;
} }
private async closeBrowserHandle(runId: number): Promise<void> { private async closeBrowserHandle(runId: string): Promise<void> {
const handle = this.runBrowsers.get(runId); const handle = this.runBrowsers.get(runId);
if (!handle) return; if (!handle) return;
this.runBrowsers.delete(runId); this.runBrowsers.delete(runId);
+4 -4
View File
@@ -13,11 +13,11 @@ export type StepType = "login" | "exec" | "sign";
@Entity("scenario_steps") @Entity("scenario_steps")
export class ScenarioStepEntity { export class ScenarioStepEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn("uuid")
id: number; id: string;
@Column() @Column("text")
scenarioId: number; scenarioId: string;
@ManyToOne(() => ScenarioEntity, (scenario) => scenario.steps, { @ManyToOne(() => ScenarioEntity, (scenario) => scenario.steps, {
onDelete: "CASCADE", onDelete: "CASCADE",
+22 -22
View File
@@ -5,7 +5,7 @@ import {
Get, Get,
HttpCode, HttpCode,
Param, Param,
ParseIntPipe, ParseUUIDPipe,
Patch, Patch,
Post, Post,
Query, Query,
@@ -61,7 +61,7 @@ export class ScenarioController {
@ApiOperation({ summary: "Get a scenario with its steps" }) @ApiOperation({ summary: "Get a scenario with its steps" })
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: "Scenario not found" }) @ApiResponse({ status: 404, description: "Scenario not found" })
findOne(@Param("id", ParseIntPipe) id: number) { findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.scenarioService.findOne(id); return this.scenarioService.findOne(id);
} }
@@ -70,7 +70,7 @@ export class ScenarioController {
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: "Scenario not found" }) @ApiResponse({ status: 404, description: "Scenario not found" })
update( update(
@Param("id", ParseIntPipe) id: number, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateScenarioDto, @Body() dto: UpdateScenarioDto,
) { ) {
return this.scenarioService.update(id, dto); return this.scenarioService.update(id, dto);
@@ -81,7 +81,7 @@ export class ScenarioController {
@ApiOperation({ summary: "Delete a scenario and all its steps" }) @ApiOperation({ summary: "Delete a scenario and all its steps" })
@ApiResponse({ status: 204 }) @ApiResponse({ status: 204 })
@ApiResponse({ status: 404, description: "Scenario not found" }) @ApiResponse({ status: 404, description: "Scenario not found" })
remove(@Param("id", ParseIntPipe) id: number) { remove(@Param("id", ParseUUIDPipe) id: string) {
return this.scenarioService.remove(id); return this.scenarioService.remove(id);
} }
@@ -89,7 +89,7 @@ export class ScenarioController {
@ApiOperation({ summary: "Export a scenario as a portable JSON payload" }) @ApiOperation({ summary: "Export a scenario as a portable JSON payload" })
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: "Scenario not found" }) @ApiResponse({ status: 404, description: "Scenario not found" })
exportScenario(@Param("id", ParseIntPipe) id: number) { exportScenario(@Param("id", ParseUUIDPipe) id: string) {
return this.scenarioService.exportScenario(id); return this.scenarioService.exportScenario(id);
} }
@@ -100,7 +100,7 @@ export class ScenarioController {
@ApiResponse({ status: 201, description: "Step created" }) @ApiResponse({ status: 201, description: "Step created" })
@ApiResponse({ status: 404, description: "Scenario not found" }) @ApiResponse({ status: 404, description: "Scenario not found" })
createStep( createStep(
@Param("id", ParseIntPipe) id: number, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: CreateScenarioStepDto, @Body() dto: CreateScenarioStepDto,
) { ) {
return this.scenarioService.createStep(id, dto); return this.scenarioService.createStep(id, dto);
@@ -111,8 +111,8 @@ export class ScenarioController {
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: "Scenario or step not found" }) @ApiResponse({ status: 404, description: "Scenario or step not found" })
findStep( findStep(
@Param("id", ParseIntPipe) id: number, @Param("id", ParseUUIDPipe) id: string,
@Param("stepId", ParseIntPipe) stepId: number, @Param("stepId", ParseUUIDPipe) stepId: string,
) { ) {
return this.scenarioService.findStep(id, stepId); return this.scenarioService.findStep(id, stepId);
} }
@@ -122,8 +122,8 @@ export class ScenarioController {
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: "Scenario or step not found" }) @ApiResponse({ status: 404, description: "Scenario or step not found" })
updateStep( updateStep(
@Param("id", ParseIntPipe) id: number, @Param("id", ParseUUIDPipe) id: string,
@Param("stepId", ParseIntPipe) stepId: number, @Param("stepId", ParseUUIDPipe) stepId: string,
@Body() dto: UpdateScenarioStepDto, @Body() dto: UpdateScenarioStepDto,
) { ) {
return this.scenarioService.updateStep(id, stepId, dto); return this.scenarioService.updateStep(id, stepId, dto);
@@ -135,8 +135,8 @@ export class ScenarioController {
@ApiResponse({ status: 204 }) @ApiResponse({ status: 204 })
@ApiResponse({ status: 404, description: "Scenario or step not found" }) @ApiResponse({ status: 404, description: "Scenario or step not found" })
removeStep( removeStep(
@Param("id", ParseIntPipe) id: number, @Param("id", ParseUUIDPipe) id: string,
@Param("stepId", ParseIntPipe) stepId: number, @Param("stepId", ParseUUIDPipe) stepId: string,
) { ) {
return this.scenarioService.removeStep(id, stepId); return this.scenarioService.removeStep(id, stepId);
} }
@@ -147,7 +147,7 @@ export class ScenarioController {
@ApiOperation({ summary: "List credentials assigned to a scenario" }) @ApiOperation({ summary: "List credentials assigned to a scenario" })
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: "Scenario not found" }) @ApiResponse({ status: 404, description: "Scenario not found" })
findScenarioCredentials(@Param("id", ParseIntPipe) id: number) { findScenarioCredentials(@Param("id", ParseUUIDPipe) id: string) {
return this.scenarioService.findScenarioCredentials(id); return this.scenarioService.findScenarioCredentials(id);
} }
@@ -157,7 +157,7 @@ export class ScenarioController {
@ApiResponse({ status: 404, description: "Scenario or credential not found" }) @ApiResponse({ status: 404, description: "Scenario or credential not found" })
@ApiResponse({ status: 409, description: "Alias already used in this scenario" }) @ApiResponse({ status: 409, description: "Alias already used in this scenario" })
addScenarioCredential( addScenarioCredential(
@Param("id", ParseIntPipe) id: number, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: AddScenarioCredentialDto, @Body() dto: AddScenarioCredentialDto,
) { ) {
return this.scenarioService.addScenarioCredential(id, dto); return this.scenarioService.addScenarioCredential(id, dto);
@@ -169,8 +169,8 @@ export class ScenarioController {
@ApiResponse({ status: 204 }) @ApiResponse({ status: 204 })
@ApiResponse({ status: 404, description: "Scenario or credential assignment not found" }) @ApiResponse({ status: 404, description: "Scenario or credential assignment not found" })
removeScenarioCredential( removeScenarioCredential(
@Param("id", ParseIntPipe) id: number, @Param("id", ParseUUIDPipe) id: string,
@Param("scCredId", ParseIntPipe) scCredId: number, @Param("scCredId", ParseUUIDPipe) scCredId: string,
) { ) {
return this.scenarioService.removeScenarioCredential(id, scCredId); return this.scenarioService.removeScenarioCredential(id, scCredId);
} }
@@ -184,7 +184,7 @@ export class ScenarioController {
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: "Scenario not found" }) @ApiResponse({ status: 404, description: "Scenario not found" })
findRuns( findRuns(
@Param("id", ParseIntPipe) id: number, @Param("id", ParseUUIDPipe) id: string,
@Query() query: RunsQueryDto, @Query() query: RunsQueryDto,
) { ) {
return this.scenarioService.findRuns(id, query); return this.scenarioService.findRuns(id, query);
@@ -194,7 +194,7 @@ export class ScenarioController {
@ApiOperation({ summary: "Create a new run for a scenario" }) @ApiOperation({ summary: "Create a new run for a scenario" })
@ApiResponse({ status: 201, description: "Run created with step runs" }) @ApiResponse({ status: 201, description: "Run created with step runs" })
@ApiResponse({ status: 404, description: "Scenario not found" }) @ApiResponse({ status: 404, description: "Scenario not found" })
createRun(@Param("id", ParseIntPipe) id: number) { createRun(@Param("id", ParseUUIDPipe) id: string) {
return this.scenarioService.createRun(id); return this.scenarioService.createRun(id);
} }
@@ -203,8 +203,8 @@ export class ScenarioController {
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: "Scenario or run not found" }) @ApiResponse({ status: 404, description: "Scenario or run not found" })
findRun( findRun(
@Param("id", ParseIntPipe) id: number, @Param("id", ParseUUIDPipe) id: string,
@Param("runId", ParseIntPipe) runId: number, @Param("runId", ParseUUIDPipe) runId: string,
) { ) {
return this.scenarioService.findRun(id, runId); return this.scenarioService.findRun(id, runId);
} }
@@ -218,8 +218,8 @@ export class ScenarioController {
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
@ApiResponse({ status: 404, description: "Scenario or run not found" }) @ApiResponse({ status: 404, description: "Scenario or run not found" })
waitForRun( waitForRun(
@Param("id", ParseIntPipe) id: number, @Param("id", ParseUUIDPipe) id: string,
@Param("runId", ParseIntPipe) runId: number, @Param("runId", ParseUUIDPipe) runId: string,
) { ) {
return this.scenarioService.waitForRun(id, runId); return this.scenarioService.waitForRun(id, runId);
} }
+2 -2
View File
@@ -11,8 +11,8 @@ import { ScenarioCredentialEntity } from "./scenario-credential.entity";
@Entity("scenarios") @Entity("scenarios")
export class ScenarioEntity { export class ScenarioEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn("uuid")
id: number; id: string;
@Column() @Column()
name: string; name: string;
+44 -26
View File
@@ -63,7 +63,7 @@ export class ScenarioService {
return { data, total, page, limit }; return { data, total, page, limit };
} }
async findOne(id: number): Promise<ScenarioEntity> { async findOne(id: string): Promise<ScenarioEntity> {
const scenario = await this.scenarioRepo.findOne({ const scenario = await this.scenarioRepo.findOne({
where: { id }, where: { id },
relations: ["steps", "scenarioCredentials", "scenarioCredentials.credential"], relations: ["steps", "scenarioCredentials", "scenarioCredentials.credential"],
@@ -73,13 +73,13 @@ export class ScenarioService {
return scenario; return scenario;
} }
async update(id: number, dto: UpdateScenarioDto): Promise<ScenarioEntity> { async update(id: string, dto: UpdateScenarioDto): Promise<ScenarioEntity> {
const scenario = await this.findOne(id); const scenario = await this.findOne(id);
Object.assign(scenario, dto); Object.assign(scenario, dto);
return this.scenarioRepo.save(scenario); return this.scenarioRepo.save(scenario);
} }
async remove(id: number): Promise<void> { async remove(id: string): Promise<void> {
await this.findOne(id); await this.findOne(id);
await this.scenarioRepo.delete(id); await this.scenarioRepo.delete(id);
} }
@@ -87,7 +87,7 @@ export class ScenarioService {
// ── Steps ───────────────────────────────────────────────────────────────── // ── Steps ─────────────────────────────────────────────────────────────────
async createStep( async createStep(
scenarioId: number, scenarioId: string,
dto: CreateScenarioStepDto, dto: CreateScenarioStepDto,
): Promise<ScenarioStepEntity> { ): Promise<ScenarioStepEntity> {
await this.findOne(scenarioId); await this.findOne(scenarioId);
@@ -102,8 +102,8 @@ export class ScenarioService {
} }
async findStep( async findStep(
scenarioId: number, scenarioId: string,
stepId: number, stepId: string,
): Promise<ScenarioStepEntity> { ): Promise<ScenarioStepEntity> {
const step = await this.stepRepo.findOneBy({ id: stepId, scenarioId }); const step = await this.stepRepo.findOneBy({ id: stepId, scenarioId });
if (!step) if (!step)
@@ -114,8 +114,8 @@ export class ScenarioService {
} }
async updateStep( async updateStep(
scenarioId: number, scenarioId: string,
stepId: number, stepId: string,
dto: UpdateScenarioStepDto, dto: UpdateScenarioStepDto,
): Promise<ScenarioStepEntity> { ): Promise<ScenarioStepEntity> {
const step = await this.findStep(scenarioId, stepId); const step = await this.findStep(scenarioId, stepId);
@@ -123,7 +123,7 @@ export class ScenarioService {
return this.stepRepo.save(step); return this.stepRepo.save(step);
} }
async removeStep(scenarioId: number, stepId: number): Promise<void> { async removeStep(scenarioId: string, stepId: string): Promise<void> {
await this.findStep(scenarioId, stepId); await this.findStep(scenarioId, stepId);
await this.stepRepo.delete(stepId); await this.stepRepo.delete(stepId);
} }
@@ -131,7 +131,7 @@ export class ScenarioService {
// ── Scenario Credentials ────────────────────────────────────────────────── // ── Scenario Credentials ──────────────────────────────────────────────────
async findScenarioCredentials( async findScenarioCredentials(
scenarioId: number, scenarioId: string,
): Promise<ScenarioCredentialEntity[]> { ): Promise<ScenarioCredentialEntity[]> {
await this.findOne(scenarioId); // 404 guard await this.findOne(scenarioId); // 404 guard
return this.scenarioCredRepo.find({ return this.scenarioCredRepo.find({
@@ -142,7 +142,7 @@ export class ScenarioService {
} }
async addScenarioCredential( async addScenarioCredential(
scenarioId: number, scenarioId: string,
dto: AddScenarioCredentialDto, dto: AddScenarioCredentialDto,
): Promise<ScenarioCredentialEntity> { ): Promise<ScenarioCredentialEntity> {
await this.findOne(scenarioId); // 404 guard await this.findOne(scenarioId); // 404 guard
@@ -168,8 +168,8 @@ export class ScenarioService {
} }
async removeScenarioCredential( async removeScenarioCredential(
scenarioId: number, scenarioId: string,
scCredId: number, scCredId: string,
): Promise<void> { ): Promise<void> {
await this.findOne(scenarioId); // 404 guard await this.findOne(scenarioId); // 404 guard
const sc = await this.scenarioCredRepo.findOneBy({ 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. * Builds a map of alias → parsed credential data for use in code execution.
* Returns null values for credentials with no data. * Returns null values for credentials with no data.
*/ */
async buildCredentialMap(scenarioId: number): Promise<Record<string, unknown>> { async buildCredentialMap(scenarioId: string): Promise<Record<string, unknown>> {
const scs = await this.findScenarioCredentials(scenarioId); const scs = await this.findScenarioCredentials(scenarioId);
const map: Record<string, unknown> = {}; const map: Record<string, unknown> = {};
for (const sc of scs) { for (const sc of scs) {
@@ -207,7 +207,7 @@ export class ScenarioService {
// ── Runs ────────────────────────────────────────────────────────────────── // ── Runs ──────────────────────────────────────────────────────────────────
async findRuns( async findRuns(
scenarioId: number, scenarioId: string,
query: RunsQueryDto, query: RunsQueryDto,
): Promise<PaginatedResult<ScenarioRunEntity>> { ): Promise<PaginatedResult<ScenarioRunEntity>> {
await this.findOne(scenarioId); // 404 guard await this.findOne(scenarioId); // 404 guard
@@ -245,8 +245,8 @@ export class ScenarioService {
} }
async findRun( async findRun(
scenarioId: number, scenarioId: string,
runId: number, runId: string,
): Promise<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> { ): Promise<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> {
await this.findOne(scenarioId); // 404 guard await this.findOne(scenarioId); // 404 guard
const run = await this.runRepo.findOne({ const run = await this.runRepo.findOne({
@@ -266,8 +266,8 @@ export class ScenarioService {
} }
async waitForRun( async waitForRun(
scenarioId: number, scenarioId: string,
runId: number, runId: string,
timeoutMs = 300_000, timeoutMs = 300_000,
): Promise<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> { ): Promise<ScenarioRunEntity & { logs: ScenarioRunLogEntity[] }> {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
@@ -285,7 +285,7 @@ export class ScenarioService {
return this.findRun(scenarioId, runId); return this.findRun(scenarioId, runId);
} }
async createRun(scenarioId: number): Promise<ScenarioRunEntity> { async createRun(scenarioId: string): Promise<ScenarioRunEntity> {
const scenario = await this.findOne(scenarioId); const scenario = await this.findOne(scenarioId);
const run = await this.runRepo.save( const run = await this.runRepo.save(
@@ -313,14 +313,16 @@ export class ScenarioService {
// ── Export / Import ─────────────────────────────────────────────────────── // ── Export / Import ───────────────────────────────────────────────────────
async exportScenario(id: number): Promise<ScenarioExportDto> { async exportScenario(id: string): Promise<ScenarioExportDto> {
const scenario = await this.findOne(id); const scenario = await this.findOne(id);
return { return {
kind: "scenario",
id: scenario.id,
name: scenario.name, name: scenario.name,
steps: scenario.steps.map((s) => ({ steps: scenario.steps.map((s) => ({
order: s.order, order: s.order,
type: s.type, type: s.type,
sessionName: s.sessionName, title: s.title,
execCode: s.execCode, execCode: s.execCode,
validateCode: s.validateCode, validateCode: s.validateCode,
})), })),
@@ -328,16 +330,32 @@ export class ScenarioService {
} }
async importScenario(dto: ScenarioExportDto): Promise<ScenarioEntity> { async importScenario(dto: ScenarioExportDto): Promise<ScenarioEntity> {
const scenario = await this.scenarioRepo.save( // Upsert: if id provided and entity exists, replace steps; else create new
this.scenarioRepo.create({ name: dto.name }), 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) { if (dto.steps.length > 0) {
const steps = dto.steps.map((s) => const steps = dto.steps.map((s) =>
this.stepRepo.create({ this.stepRepo.create({
scenarioId: scenario.id, scenarioId: scenario.id,
order: s.order, order: s.order,
type: s.type, type: s.type,
sessionName: s.sessionName, title: s.title ?? null,
execCode: s.execCode ?? null, execCode: s.execCode ?? null,
validateCode: s.validateCode ?? null, validateCode: s.validateCode ?? null,
}), }),
@@ -110,7 +110,7 @@ export class SessionContextService implements OnModuleDestroy {
* Close the context (if open) and delete the session record from DB. * Close the context (if open) and delete the session record from DB.
* Throws 404 if the session ID does not exist. * Throws 404 if the session ID does not exist.
*/ */
async delete(id: number): Promise<void> { async delete(id: string): Promise<void> {
const session = await this.sessionService.findById(id); const session = await this.sessionService.findById(id);
if (!session) { if (!session) {
throw new NotFoundException(`Session ${id} not found`); throw new NotFoundException(`Session ${id} not found`);
+3 -3
View File
@@ -5,7 +5,7 @@ import {
HttpCode, HttpCode,
NotFoundException, NotFoundException,
Param, Param,
ParseIntPipe, ParseUUIDPipe,
Query, Query,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
@@ -38,7 +38,7 @@ export class SessionController {
@ApiOperation({ summary: "Get a session by ID" }) @ApiOperation({ summary: "Get a session by ID" })
@ApiResponse({ status: 200, description: "Session found" }) @ApiResponse({ status: 200, description: "Session found" })
@ApiResponse({ status: 404, description: "Session not 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); const session = await this.sessionService.findById(id);
if (!session) throw new NotFoundException(`Session ${id} not found`); if (!session) throw new NotFoundException(`Session ${id} not found`);
const { const {
@@ -58,7 +58,7 @@ export class SessionController {
@ApiOperation({ summary: "Delete a session by ID (closes it first if open)" }) @ApiOperation({ summary: "Delete a session by ID (closes it first if open)" })
@ApiResponse({ status: 204, description: "Session deleted" }) @ApiResponse({ status: 204, description: "Session deleted" })
@ApiResponse({ status: 404, description: "Session not found" }) @ApiResponse({ status: 404, description: "Session not found" })
async remove(@Param("id", ParseIntPipe) id: number): Promise<void> { async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
await this.sessionContextService.delete(id); await this.sessionContextService.delete(id);
} }
} }
+2 -2
View File
@@ -10,8 +10,8 @@ export type SessionStatus = "open" | "closed";
@Entity("sessions") @Entity("sessions")
export class SessionEntity { export class SessionEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn("uuid")
id: number; id: string;
@Column({ unique: true }) @Column({ unique: true })
sessionName: string; sessionName: string;
+2 -2
View File
@@ -72,7 +72,7 @@ export class SessionService implements OnApplicationBootstrap {
return this.repo.findOneBy({ sessionName }); return this.repo.findOneBy({ sessionName });
} }
findById(id: number): Promise<SessionEntity | null> { findById(id: string): Promise<SessionEntity | null> {
return this.repo.findOneBy({ id }); return this.repo.findOneBy({ id });
} }
@@ -137,7 +137,7 @@ export class SessionService implements OnApplicationBootstrap {
return { data, total, page, limit }; return { data, total, page, limit };
} }
async remove(id: number): Promise<void> { async remove(id: string): Promise<void> {
await this.repo.delete(id); await this.repo.delete(id);
} }
} }
@@ -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;
}
+21 -4
View File
@@ -5,7 +5,7 @@ import {
Get, Get,
HttpCode, HttpCode,
Param, Param,
ParseIntPipe, ParseUUIDPipe,
Patch, Patch,
Post, Post,
Query, Query,
@@ -14,6 +14,7 @@ import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { SnippetService, SnippetOrderBy } from "./snippet.service"; import { SnippetService, SnippetOrderBy } from "./snippet.service";
import { CreateSnippetDto } from "./dto/create-snippet.dto"; import { CreateSnippetDto } from "./dto/create-snippet.dto";
import { UpdateSnippetDto } from "./dto/update-snippet.dto"; import { UpdateSnippetDto } from "./dto/update-snippet.dto";
import { SnippetExportDto } from "./dto/snippet-export.dto";
import { PaginationQueryDto } from "../common/dto/pagination.dto"; import { PaginationQueryDto } from "../common/dto/pagination.dto";
@ApiTags("snippets") @ApiTags("snippets")
@@ -29,6 +30,13 @@ export class SnippetController {
return this.snippetService.create(dto); 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() @Get()
@ApiOperation({ summary: "List all snippets (paginated)" }) @ApiOperation({ summary: "List all snippets (paginated)" })
@ApiResponse({ status: 200, description: "Paginated snippets" }) @ApiResponse({ status: 200, description: "Paginated snippets" })
@@ -36,11 +44,20 @@ export class SnippetController {
return this.snippetService.findAll(query); 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") @Get(":id")
@ApiOperation({ summary: "Get snippet by ID" }) @ApiOperation({ summary: "Get snippet by ID" })
@ApiResponse({ status: 200, description: "Snippet record" }) @ApiResponse({ status: 200, description: "Snippet record" })
@ApiResponse({ status: 404, description: "Snippet not found" }) @ApiResponse({ status: 404, description: "Snippet not found" })
findOne(@Param("id", ParseIntPipe) id: number) { findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.snippetService.findOne(id); return this.snippetService.findOne(id);
} }
@@ -50,7 +67,7 @@ export class SnippetController {
@ApiResponse({ status: 404, description: "Snippet not found" }) @ApiResponse({ status: 404, description: "Snippet not found" })
@ApiResponse({ status: 409, description: "Name already taken" }) @ApiResponse({ status: 409, description: "Name already taken" })
update( update(
@Param("id", ParseIntPipe) id: number, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateSnippetDto, @Body() dto: UpdateSnippetDto,
) { ) {
return this.snippetService.update(id, dto); return this.snippetService.update(id, dto);
@@ -61,7 +78,7 @@ export class SnippetController {
@ApiOperation({ summary: "Delete a snippet" }) @ApiOperation({ summary: "Delete a snippet" })
@ApiResponse({ status: 204, description: "Snippet deleted" }) @ApiResponse({ status: 204, description: "Snippet deleted" })
@ApiResponse({ status: 404, description: "Snippet not found" }) @ApiResponse({ status: 404, description: "Snippet not found" })
remove(@Param("id", ParseIntPipe) id: number) { remove(@Param("id", ParseUUIDPipe) id: string) {
return this.snippetService.remove(id); return this.snippetService.remove(id);
} }
} }
+2 -2
View File
@@ -8,8 +8,8 @@ import {
@Entity("snippets") @Entity("snippets")
export class SnippetEntity { export class SnippetEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn("uuid")
id: number; id: string;
/** Unique identifier used to call the snippet: helpers.runSnippet('mySnippet', ...) */ /** Unique identifier used to call the snippet: helpers.runSnippet('mySnippet', ...) */
@Column({ unique: true }) @Column({ unique: true })
+43 -3
View File
@@ -8,6 +8,7 @@ import { Repository } from "typeorm";
import { SnippetEntity } from "./snippet.entity"; import { SnippetEntity } from "./snippet.entity";
import { CreateSnippetDto } from "./dto/create-snippet.dto"; import { CreateSnippetDto } from "./dto/create-snippet.dto";
import { UpdateSnippetDto } from "./dto/update-snippet.dto"; import { UpdateSnippetDto } from "./dto/update-snippet.dto";
import { SnippetExportDto } from "./dto/snippet-export.dto";
import { import {
PaginationQueryDto, PaginationQueryDto,
PaginatedResult, PaginatedResult,
@@ -47,13 +48,13 @@ export class SnippetService {
return { data, total, page, limit }; return { data, total, page, limit };
} }
async findOne(id: number): Promise<SnippetEntity> { async findOne(id: string): Promise<SnippetEntity> {
const snippet = await this.repo.findOneBy({ id }); const snippet = await this.repo.findOneBy({ id });
if (!snippet) throw new NotFoundException(`Snippet ${id} not found`); if (!snippet) throw new NotFoundException(`Snippet ${id} not found`);
return snippet; return snippet;
} }
async update(id: number, dto: UpdateSnippetDto): Promise<SnippetEntity> { async update(id: string, dto: UpdateSnippetDto): Promise<SnippetEntity> {
const snippet = await this.findOne(id); const snippet = await this.findOne(id);
if (dto.name && dto.name !== snippet.name) { if (dto.name && dto.name !== snippet.name) {
const conflict = await this.repo.findOneBy({ name: dto.name }); const conflict = await this.repo.findOneBy({ name: dto.name });
@@ -63,7 +64,7 @@ export class SnippetService {
return this.repo.save(snippet); return this.repo.save(snippet);
} }
async remove(id: number): Promise<void> { async remove(id: string): Promise<void> {
await this.findOne(id); await this.findOne(id);
await this.repo.delete(id); await this.repo.delete(id);
} }
@@ -73,4 +74,43 @@ export class SnippetService {
const { data } = await this.findAll({ limit: 1000 }); const { data } = await this.findAll({ limit: 1000 });
return Object.fromEntries(data.map((s) => [s.name, s.code])); 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<SnippetEntity> {
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,
}),
);
}
} }