chore: apply code formatting and linting
- format long import statements with consistent line wrapping - apply consistent indentation across client and server modules - remove generated tsconfig.tsbuildinfo file
This commit is contained in:
@@ -12,3 +12,4 @@ server/dist
|
|||||||
client/dist
|
client/dist
|
||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build",
|
"build-storybook": "storybook build",
|
||||||
|
"test:compile": "tsc --noEmit",
|
||||||
"test:storybook": "vitest run --project storybook",
|
"test:storybook": "vitest run --project storybook",
|
||||||
"lint": "eslint src .storybook",
|
"lint": "eslint src .storybook",
|
||||||
"lint:fix": "eslint src .storybook --fix",
|
"lint:fix": "eslint src .storybook --fix",
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@tanstack/react-query": "^5.99.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"i18next": "^26.0.4",
|
"i18next": "^26.0.4",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
|
|||||||
+16
-8
@@ -11,13 +11,19 @@ const EnvironmentsPage = lazy(() =>
|
|||||||
import('./pages/environment/EnvironmentsPage').then((m) => ({ default: m.EnvironmentsPage })),
|
import('./pages/environment/EnvironmentsPage').then((m) => ({ default: m.EnvironmentsPage })),
|
||||||
);
|
);
|
||||||
const EnvironmentDetailPage = lazy(() =>
|
const EnvironmentDetailPage = lazy(() =>
|
||||||
import('./pages/environment/EnvironmentDetailPage').then((m) => ({ default: m.EnvironmentDetailPage })),
|
import('./pages/environment/EnvironmentDetailPage').then((m) => ({
|
||||||
|
default: m.EnvironmentDetailPage,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
const CreateEnvironmentPage = lazy(() =>
|
const CreateEnvironmentPage = lazy(() =>
|
||||||
import('./pages/environment/CreateEnvironmentPage').then((m) => ({ default: m.CreateEnvironmentPage })),
|
import('./pages/environment/CreateEnvironmentPage').then((m) => ({
|
||||||
|
default: m.CreateEnvironmentPage,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
const EditEnvironmentPage = lazy(() =>
|
const EditEnvironmentPage = lazy(() =>
|
||||||
import('./pages/environment/EditEnvironmentPage').then((m) => ({ default: m.EditEnvironmentPage })),
|
import('./pages/environment/EditEnvironmentPage').then((m) => ({
|
||||||
|
default: m.EditEnvironmentPage,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const SessionsPage = lazy(() =>
|
const SessionsPage = lazy(() =>
|
||||||
@@ -46,9 +52,7 @@ const EditStepPage = lazy(() =>
|
|||||||
import('./pages/scenario/EditStepPage').then((m) => ({ default: m.EditStepPage })),
|
import('./pages/scenario/EditStepPage').then((m) => ({ default: m.EditStepPage })),
|
||||||
);
|
);
|
||||||
|
|
||||||
const RunsPage = lazy(() =>
|
const RunsPage = lazy(() => import('./pages/run/RunsPage').then((m) => ({ default: m.RunsPage })));
|
||||||
import('./pages/run/RunsPage').then((m) => ({ default: m.RunsPage })),
|
|
||||||
);
|
|
||||||
const RunDetailPage = lazy(() =>
|
const RunDetailPage = lazy(() =>
|
||||||
import('./pages/run/RunDetailPage').then((m) => ({ default: m.RunDetailPage })),
|
import('./pages/run/RunDetailPage').then((m) => ({ default: m.RunDetailPage })),
|
||||||
);
|
);
|
||||||
@@ -60,13 +64,17 @@ const CredentialsPage = lazy(() =>
|
|||||||
import('./pages/credential/CredentialsPage').then((m) => ({ default: m.CredentialsPage })),
|
import('./pages/credential/CredentialsPage').then((m) => ({ default: m.CredentialsPage })),
|
||||||
);
|
);
|
||||||
const CreateCredentialPage = lazy(() =>
|
const CreateCredentialPage = lazy(() =>
|
||||||
import('./pages/credential/CreateCredentialPage').then((m) => ({ default: m.CreateCredentialPage })),
|
import('./pages/credential/CreateCredentialPage').then((m) => ({
|
||||||
|
default: m.CreateCredentialPage,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
const EditCredentialPage = lazy(() =>
|
const EditCredentialPage = lazy(() =>
|
||||||
import('./pages/credential/EditCredentialPage').then((m) => ({ default: m.EditCredentialPage })),
|
import('./pages/credential/EditCredentialPage').then((m) => ({ default: m.EditCredentialPage })),
|
||||||
);
|
);
|
||||||
const CredentialDetailPage = lazy(() =>
|
const CredentialDetailPage = lazy(() =>
|
||||||
import('./pages/credential/CredentialDetailPage').then((m) => ({ default: m.CredentialDetailPage })),
|
import('./pages/credential/CredentialDetailPage').then((m) => ({
|
||||||
|
default: m.CredentialDetailPage,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const SnippetsPage = lazy(() =>
|
const SnippetsPage = lazy(() =>
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import { emitApiErrorToast } from '../lib/toast-events';
|
|||||||
|
|
||||||
// API calls are versioned under /api/v1 by default.
|
// API calls are versioned under /api/v1 by default.
|
||||||
// Set VITE_API_URL (for example, http://localhost:13000/api/v1) to use a different origin.
|
// Set VITE_API_URL (for example, http://localhost:13000/api/v1) to use a different origin.
|
||||||
const BASE_URL = ((import.meta.env.VITE_API_URL as string | undefined) ?? '/api/v1').replace(/\/$/, '');
|
const BASE_URL = ((import.meta.env.VITE_API_URL as string | undefined) ?? '/api/v1').replace(
|
||||||
|
/\/$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
@@ -90,7 +93,9 @@ export const snippets = {
|
|||||||
get(id: string): Promise<Snippet> {
|
get(id: string): Promise<Snippet> {
|
||||||
return request(`/snippets/${id}`);
|
return request(`/snippets/${id}`);
|
||||||
},
|
},
|
||||||
create(payload: Pick<Snippet, 'alias' | 'title' | 'code'> & { description?: string }): Promise<Snippet> {
|
create(
|
||||||
|
payload: Pick<Snippet, 'alias' | 'title' | 'code'> & { description?: string },
|
||||||
|
): Promise<Snippet> {
|
||||||
return request('/snippets', {
|
return request('/snippets', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { X, Trash2 } from 'lucide-react';
|
||||||
|
import { Modal, Button } from '../../ui';
|
||||||
|
|
||||||
|
export interface DeleteConfirmationModalProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
isDeleting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteConfirmationModal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
isDeleting,
|
||||||
|
}: DeleteConfirmationModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={title}
|
||||||
|
onClose={() => !isDeleting && onClose()}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={isDeleting}>
|
||||||
|
<X size={14} />
|
||||||
|
{t('common.button_cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={onConfirm} disabled={isDeleting}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
{t('common.button_delete')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { X, Play } from 'lucide-react';
|
||||||
|
import { Modal, Button, Select } from '../../ui';
|
||||||
|
import type { Environment } from '../../api';
|
||||||
|
|
||||||
|
export interface RunScenarioModalProps {
|
||||||
|
open: boolean;
|
||||||
|
environments: Environment[];
|
||||||
|
selectedEnvId: string;
|
||||||
|
onEnvSelect: (envId: string) => void;
|
||||||
|
saveSessionFlag: boolean;
|
||||||
|
onSaveSessionChange: (flag: boolean) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onRun: () => void;
|
||||||
|
isRunning: boolean;
|
||||||
|
actionLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunScenarioModal({
|
||||||
|
open,
|
||||||
|
environments,
|
||||||
|
selectedEnvId,
|
||||||
|
onEnvSelect,
|
||||||
|
saveSessionFlag,
|
||||||
|
onSaveSessionChange,
|
||||||
|
onClose,
|
||||||
|
onRun,
|
||||||
|
isRunning,
|
||||||
|
actionLabel,
|
||||||
|
}: RunScenarioModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={t('scenarios.run_modal_title')}
|
||||||
|
onClose={() => !isRunning && onClose()}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={isRunning}>
|
||||||
|
<X size={14} />
|
||||||
|
{t('common.button_cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={onRun} disabled={isRunning || !selectedEnvId}>
|
||||||
|
<Play size={14} />
|
||||||
|
{actionLabel || t('scenarios.action_run')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{environments.length === 0 ? (
|
||||||
|
<p>{t('scenarios.run_modal_no_env')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
label={t('scenarios.run_modal_env_label')}
|
||||||
|
value={selectedEnvId}
|
||||||
|
onChange={(e) => onEnvSelect(e.target.value)}
|
||||||
|
options={environments.map((env) => ({ value: env.id, label: env.name }))}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: '1rem',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={saveSessionFlag}
|
||||||
|
onChange={(e) => onSaveSessionChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{t('common.save_session')}</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||||
|
export type { DeleteConfirmationModalProps } from './DeleteConfirmationModal';
|
||||||
|
export { RunScenarioModal } from './RunScenarioModal';
|
||||||
|
export type { RunScenarioModalProps } from './RunScenarioModal';
|
||||||
@@ -1,4 +1,17 @@
|
|||||||
{
|
{
|
||||||
|
"common": {
|
||||||
|
"button_cancel": "Cancel",
|
||||||
|
"button_confirm": "Confirm",
|
||||||
|
"button_delete": "Delete",
|
||||||
|
"confirm_delete_credential": "Are you sure you want to delete this credential?",
|
||||||
|
"confirm_delete_environment": "Are you sure you want to delete this environment?",
|
||||||
|
"confirm_delete_scenario": "Are you sure you want to delete this scenario?",
|
||||||
|
"confirm_delete_step": "Are you sure you want to delete this step?",
|
||||||
|
"confirm_delete_session": "Are you sure you want to delete this session?",
|
||||||
|
"confirm_delete_snippet": "Are you sure you want to delete this snippet?",
|
||||||
|
"confirm_remove_credential": "Are you sure you want to remove this credential from scenario?",
|
||||||
|
"save_session": "Save session"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"not_found": "Not found",
|
"not_found": "Not found",
|
||||||
"not_found_session": "Session {{id}} does not exist or has been deleted.",
|
"not_found_session": "Session {{id}} does not exist or has been deleted.",
|
||||||
@@ -19,6 +32,8 @@
|
|||||||
},
|
},
|
||||||
"environments": {
|
"environments": {
|
||||||
"title": "Environments",
|
"title": "Environments",
|
||||||
|
"created": "Environment created",
|
||||||
|
"updated": "Environment updated",
|
||||||
"col_id": "ID",
|
"col_id": "ID",
|
||||||
"col_name": "Name",
|
"col_name": "Name",
|
||||||
"col_data": "Data",
|
"col_data": "Data",
|
||||||
@@ -51,6 +66,8 @@
|
|||||||
},
|
},
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"title": "Credentials",
|
"title": "Credentials",
|
||||||
|
"created": "Credential created",
|
||||||
|
"updated": "Credential updated",
|
||||||
"empty": "No credentials yet.",
|
"empty": "No credentials yet.",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"menu_label": "Credential options",
|
"menu_label": "Credential options",
|
||||||
@@ -99,6 +116,9 @@
|
|||||||
"field_lastUsed": "Last Used"
|
"field_lastUsed": "Last Used"
|
||||||
},
|
},
|
||||||
"scenarios": { "title": "Scenarios",
|
"scenarios": { "title": "Scenarios",
|
||||||
|
"created": "Scenario created",
|
||||||
|
"updated": "Scenario updated",
|
||||||
|
"run_started": "Scenario run started",
|
||||||
"col_id": "ID",
|
"col_id": "ID",
|
||||||
"col_name": "Name",
|
"col_name": "Name",
|
||||||
"col_updated": "Updated",
|
"col_updated": "Updated",
|
||||||
@@ -165,6 +185,8 @@
|
|||||||
"page_size": "Items per page"
|
"page_size": "Items per page"
|
||||||
},
|
},
|
||||||
"steps": {
|
"steps": {
|
||||||
|
"created": "Step created",
|
||||||
|
"updated": "Step updated",
|
||||||
"create_title": "Add Step",
|
"create_title": "Add Step",
|
||||||
"edit_title": "Edit Step #{{order}}",
|
"edit_title": "Edit Step #{{order}}",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
@@ -215,6 +237,8 @@
|
|||||||
},
|
},
|
||||||
"snippets": {
|
"snippets": {
|
||||||
"title": "Snippets",
|
"title": "Snippets",
|
||||||
|
"created": "Snippet created",
|
||||||
|
"updated": "Snippet updated",
|
||||||
"empty": "No snippets yet.",
|
"empty": "No snippets yet.",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"menu_label": "Snippet options",
|
"menu_label": "Snippet options",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface ApiErrorToastDetail {
|
|||||||
|
|
||||||
export function emitApiErrorToast(message: string): void {
|
export function emitApiErrorToast(message: string): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent<ApiErrorToastDetail>(API_ERROR_TOAST_EVENT, {
|
new CustomEvent<ApiErrorToastDetail>(API_ERROR_TOAST_EVENT, {
|
||||||
detail: { message },
|
detail: { message },
|
||||||
@@ -13,17 +14,17 @@ export function emitApiErrorToast(message: string): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribeApiErrorToasts(
|
export function subscribeApiErrorToasts(handler: (message: string) => void): () => void {
|
||||||
handler: (message: string) => void,
|
|
||||||
): () => void {
|
|
||||||
if (typeof window === 'undefined') return () => {};
|
if (typeof window === 'undefined') return () => {};
|
||||||
|
|
||||||
const listener = (event: Event) => {
|
const listener = (event: Event) => {
|
||||||
const custom = event as CustomEvent<ApiErrorToastDetail>;
|
const custom = event as CustomEvent<ApiErrorToastDetail>;
|
||||||
const message = custom.detail?.message;
|
const message = custom.detail?.message;
|
||||||
if (message) handler(message);
|
if (message) handler(message);
|
||||||
};
|
};
|
||||||
window.addEventListener(API_ERROR_TOAST_EVENT, listener as EventListener);
|
|
||||||
|
window.addEventListener(API_ERROR_TOAST_EVENT, listener);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(API_ERROR_TOAST_EVENT, listener as EventListener);
|
window.removeEventListener(API_ERROR_TOAST_EVENT, listener);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-5
@@ -1,17 +1,22 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import './i18n';
|
import './i18n';
|
||||||
import './ui/tokens.css';
|
import './ui/tokens.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { ToastProvider } from './ui';
|
import { ToastProvider } from './ui';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ToastProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<ToastProvider>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
</BrowserRouter>
|
<App />
|
||||||
</ToastProvider>
|
</BrowserRouter>
|
||||||
|
</ToastProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, SubmitEvent } 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 { KeyRound, X, Save } from 'lucide-react';
|
import { KeyRound, X, Save } from 'lucide-react';
|
||||||
@@ -16,9 +16,8 @@ export function CreateCredentialPage() {
|
|||||||
const [nameError, setNameError] = useState('');
|
const [nameError, setNameError] = useState('');
|
||||||
const [dataError, setDataError] = useState('');
|
const [dataError, setDataError] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let valid = true;
|
let valid = true;
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
@@ -35,14 +34,12 @@ export function CreateCredentialPage() {
|
|||||||
}
|
}
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
const credential = await credentials.create(name.trim(), data.trim() || undefined);
|
const credential = await credentials.create(name.trim(), data.trim() || undefined);
|
||||||
toast.success('Credential created');
|
toast.success(t('credentials.created'));
|
||||||
navigate(`/credentials/${credential.id}`);
|
navigate(`/credentials/${credential.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
setError(message);
|
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -54,7 +51,11 @@ export function CreateCredentialPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('credentials.title'), icon: <KeyRound size={14} />, onClick: () => navigate('/credentials') },
|
{
|
||||||
|
label: t('credentials.title'),
|
||||||
|
icon: <KeyRound size={14} />,
|
||||||
|
onClick: () => navigate('/credentials'),
|
||||||
|
},
|
||||||
{ label: t('credentials.create_title') },
|
{ label: t('credentials.create_title') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,15 +6,8 @@ import { CodeBlock } from '../../ui';
|
|||||||
import { stringify as yamlStringify } from 'yaml';
|
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 {
|
import { Breadcrumbs, Button, Card, DescriptionList, Timestamp, UuidBadge } from '../../ui';
|
||||||
Breadcrumbs,
|
import { DeleteConfirmationModal } from '../../components/modals';
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
DescriptionList,
|
|
||||||
Modal,
|
|
||||||
Timestamp,
|
|
||||||
UuidBadge,
|
|
||||||
} from '../../ui';
|
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
export function CredentialDetailPage() {
|
export function CredentialDetailPage() {
|
||||||
@@ -23,7 +16,6 @@ export function CredentialDetailPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [credential, setCredential] = useState<Credential | null>(null);
|
const [credential, setCredential] = useState<Credential | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
@@ -32,7 +24,6 @@ export function CredentialDetailPage() {
|
|||||||
credentials
|
credentials
|
||||||
.get(id)
|
.get(id)
|
||||||
.then(setCredential)
|
.then(setCredential)
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
@@ -65,7 +56,11 @@ export function CredentialDetailPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('credentials.title'), icon: <KeyRound size={14} />, onClick: () => navigate('/credentials') },
|
{
|
||||||
|
label: t('credentials.title'),
|
||||||
|
icon: <KeyRound size={14} />,
|
||||||
|
onClick: () => navigate('/credentials'),
|
||||||
|
},
|
||||||
{ label: credential?.name ?? `#${id}` },
|
{ label: credential?.name ?? `#${id}` },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -136,23 +131,14 @@ export function CredentialDetailPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal
|
<DeleteConfirmationModal
|
||||||
open={confirmDeleteOpen}
|
open={confirmDeleteOpen}
|
||||||
title={t('credentials.action_delete')}
|
title={t('credentials.action_delete')}
|
||||||
onClose={() => !deleting && setConfirmDeleteOpen(false)}
|
message={t('common.confirm_delete_credential')}
|
||||||
footer={(
|
onClose={() => setConfirmDeleteOpen(false)}
|
||||||
<>
|
onConfirm={handleDelete}
|
||||||
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}>
|
isDeleting={deleting}
|
||||||
Cancel
|
/>
|
||||||
</Button>
|
|
||||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
|
||||||
{t('credentials.action_delete')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Are you sure you want to delete this credential?
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
DescriptionList,
|
DescriptionList,
|
||||||
Modal,
|
|
||||||
Timestamp,
|
Timestamp,
|
||||||
UuidBadge,
|
UuidBadge,
|
||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
|
import { DeleteConfirmationModal } from '../../components/modals';
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
function CredentialCard({
|
function CredentialCard({
|
||||||
@@ -103,7 +103,6 @@ export function CredentialsPage() {
|
|||||||
const navigate = useNavigate();
|
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 [deleteId, setDeleteId] = useState<string | null>(null);
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -112,7 +111,6 @@ export function CredentialsPage() {
|
|||||||
credentials
|
credentials
|
||||||
.list()
|
.list()
|
||||||
.then((res) => setItems(res.data))
|
.then((res) => setItems(res.data))
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,8 +143,8 @@ export function CredentialsPage() {
|
|||||||
const payload = yamlParse(text) as unknown;
|
const payload = yamlParse(text) as unknown;
|
||||||
const imported = await credentials.importCredential(payload);
|
const imported = await credentials.importCredential(payload);
|
||||||
navigate(`/credentials/${imported.id}`);
|
navigate(`/credentials/${imported.id}`);
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
// Import errors are silently ignored
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,23 +177,14 @@ export function CredentialsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal
|
<DeleteConfirmationModal
|
||||||
open={deleteId != null}
|
open={deleteId != null}
|
||||||
title={t('credentials.action_delete')}
|
title={t('credentials.action_delete')}
|
||||||
onClose={() => !deleting && setDeleteId(null)}
|
message={t('common.confirm_delete_credential')}
|
||||||
footer={(
|
onClose={() => setDeleteId(null)}
|
||||||
<>
|
onConfirm={handleDelete}
|
||||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
isDeleting={deleting}
|
||||||
Cancel
|
/>
|
||||||
</Button>
|
|
||||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
|
||||||
{t('credentials.action_delete')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Are you sure you want to delete this credential?
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, SubmitEvent } 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 { KeyRound, X, Save } from 'lucide-react';
|
import { KeyRound, X, Save } from 'lucide-react';
|
||||||
@@ -20,7 +20,6 @@ export function EditCredentialPage() {
|
|||||||
const [dataError, setDataError] = useState('');
|
const [dataError, setDataError] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -31,11 +30,10 @@ export function EditCredentialPage() {
|
|||||||
setName(c.name);
|
setName(c.name);
|
||||||
setData(c.data ?? '');
|
setData(c.data ?? '');
|
||||||
})
|
})
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let valid = true;
|
let valid = true;
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
@@ -52,17 +50,15 @@ export function EditCredentialPage() {
|
|||||||
}
|
}
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
await credentials.update(id!, {
|
await credentials.update(id!, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
data: data.trim() || null,
|
data: data.trim() || null,
|
||||||
});
|
});
|
||||||
toast.success('Credential updated');
|
toast.success(t('credentials.updated'));
|
||||||
navigate(`/credentials/${id}`);
|
navigate(`/credentials/${id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
setError(message);
|
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -74,7 +70,11 @@ export function EditCredentialPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('credentials.title'), icon: <KeyRound size={14} />, onClick: () => navigate('/credentials') },
|
{
|
||||||
|
label: t('credentials.title'),
|
||||||
|
icon: <KeyRound size={14} />,
|
||||||
|
onClick: () => navigate('/credentials'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: credential?.name ?? `#${id}`,
|
label: credential?.name ?? `#${id}`,
|
||||||
onClick: () => navigate(`/credentials/${id}`),
|
onClick: () => navigate(`/credentials/${id}`),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, SubmitEvent } 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 { X, Globe, Save } from 'lucide-react';
|
import { X, Globe, Save } from 'lucide-react';
|
||||||
@@ -16,9 +16,8 @@ export function CreateEnvironmentPage() {
|
|||||||
const [nameError, setNameError] = useState('');
|
const [nameError, setNameError] = useState('');
|
||||||
const [dataError, setDataError] = useState('');
|
const [dataError, setDataError] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setNameError(t('environments.form_name_required'));
|
setNameError(t('environments.form_name_required'));
|
||||||
@@ -42,14 +41,12 @@ export function CreateEnvironmentPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
const env = await environments.create(name.trim(), parsedData);
|
const env = await environments.create(name.trim(), parsedData);
|
||||||
toast.success('Environment created');
|
toast.success(t('environments.created'));
|
||||||
navigate(`/environments/${env.id}`);
|
navigate(`/environments/${env.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
setError(message);
|
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -61,7 +58,11 @@ export function CreateEnvironmentPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('environments.title'), icon: <Globe size={14} />, onClick: () => navigate('/environments') },
|
{
|
||||||
|
label: t('environments.title'),
|
||||||
|
icon: <Globe size={14} />,
|
||||||
|
onClick: () => navigate('/environments'),
|
||||||
|
},
|
||||||
{ label: t('environments.create_title') },
|
{ label: t('environments.create_title') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, SubmitEvent } 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 { X, Globe, Save } from 'lucide-react';
|
import { X, Globe, Save } from 'lucide-react';
|
||||||
@@ -20,7 +20,6 @@ export function EditEnvironmentPage() {
|
|||||||
const [dataError, setDataError] = useState('');
|
const [dataError, setDataError] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -31,11 +30,10 @@ export function EditEnvironmentPage() {
|
|||||||
setName(data.name);
|
setName(data.name);
|
||||||
setDataJson(JSON.stringify(data.data ?? {}, null, 2));
|
setDataJson(JSON.stringify(data.data ?? {}, null, 2));
|
||||||
})
|
})
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setNameError(t('environments.form_name_required'));
|
setNameError(t('environments.form_name_required'));
|
||||||
@@ -59,17 +57,15 @@ export function EditEnvironmentPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
await environments.update(id!, {
|
await environments.update(id!, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
data: parsedData,
|
data: parsedData,
|
||||||
});
|
});
|
||||||
toast.success('Environment updated');
|
toast.success(t('environments.updated'));
|
||||||
navigate(`/environments/${id}`);
|
navigate(`/environments/${id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
setError(message);
|
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -81,7 +77,11 @@ export function EditEnvironmentPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('environments.title'), icon: <Globe size={14} />, onClick: () => navigate('/environments') },
|
{
|
||||||
|
label: t('environments.title'),
|
||||||
|
icon: <Globe size={14} />,
|
||||||
|
onClick: () => navigate('/environments'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: env?.name ?? `#${id}`,
|
label: env?.name ?? `#${id}`,
|
||||||
onClick: () => navigate(`/environments/${id}`),
|
onClick: () => navigate(`/environments/${id}`),
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
|
import { Globe, Pencil, Trash2, Upload } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Upload, Pencil, Trash2, Globe } from 'lucide-react';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { stringify as yamlStringify } from 'yaml';
|
import { stringify as yamlStringify } from 'yaml';
|
||||||
import { environments } from '../../api';
|
|
||||||
import type { Environment } from '../../api';
|
import type { Environment } from '../../api';
|
||||||
import { Breadcrumbs, Button, Card, DescriptionList, Modal, Timestamp, UuidBadge } from '../../ui';
|
import { environments } from '../../api';
|
||||||
|
import { DeleteConfirmationModal } from '../../components/modals';
|
||||||
|
import {
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DescriptionList,
|
||||||
|
Timestamp,
|
||||||
|
UuidBadge,
|
||||||
|
useToast,
|
||||||
|
} from '../../ui';
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
export function EnvironmentDetailPage() {
|
export function EnvironmentDetailPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [env, setEnv] = useState<Environment | null>(null);
|
const [env, setEnv] = useState<Environment | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const dataEntries = Object.entries(env?.data ?? {}).filter(([, v]) => v);
|
const dataEntries = Object.entries(env?.data ?? {}).filter(([, v]) => v);
|
||||||
@@ -24,9 +33,9 @@ export function EnvironmentDetailPage() {
|
|||||||
environments
|
environments
|
||||||
.get(id)
|
.get(id)
|
||||||
.then(setEnv)
|
.then(setEnv)
|
||||||
.catch((err: Error) => setError(err.message))
|
.catch((err: Error) => toast.error((err as Error).message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id, toast]);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!env) return;
|
if (!env) return;
|
||||||
@@ -57,7 +66,11 @@ export function EnvironmentDetailPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('environments.title'), icon: <Globe size={14} />, onClick: () => navigate('/environments') },
|
{
|
||||||
|
label: t('environments.title'),
|
||||||
|
icon: <Globe size={14} />,
|
||||||
|
onClick: () => navigate('/environments'),
|
||||||
|
},
|
||||||
{ label: env?.name ?? `#${id}` },
|
{ label: env?.name ?? `#${id}` },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -107,12 +120,12 @@ export function EnvironmentDetailPage() {
|
|||||||
<div className={styles.stepsSection}>
|
<div className={styles.stepsSection}>
|
||||||
<h2 className={styles.sectionHeading}>{t('environments.section_data')}</h2>
|
<h2 className={styles.sectionHeading}>{t('environments.section_data')}</h2>
|
||||||
<Card>
|
<Card>
|
||||||
{dataEntries.length === 0 ? (
|
{dataEntries.length === 0 ? (
|
||||||
<p className={styles.muted}>{t('environments.no_data')}</p>
|
<p className={styles.muted}>{t('environments.no_data')}</p>
|
||||||
) : (
|
) : (
|
||||||
<DescriptionList
|
<DescriptionList
|
||||||
layout="grid"
|
layout="grid"
|
||||||
items={dataEntries.map(([key, value]) => ({
|
items={dataEntries.map(([key, value]) => ({
|
||||||
term: key,
|
term: key,
|
||||||
detail: (
|
detail: (
|
||||||
<a href={value} target="_blank" rel="noopener noreferrer">
|
<a href={value} target="_blank" rel="noopener noreferrer">
|
||||||
@@ -120,30 +133,21 @@ export function EnvironmentDetailPage() {
|
|||||||
</a>
|
</a>
|
||||||
),
|
),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal
|
<DeleteConfirmationModal
|
||||||
open={confirmDeleteOpen}
|
open={confirmDeleteOpen}
|
||||||
title={t('environments.action_delete')}
|
title={t('environments.action_delete')}
|
||||||
onClose={() => !deleting && setConfirmDeleteOpen(false)}
|
message={t('common.confirm_delete_environment')}
|
||||||
footer={(
|
onClose={() => setConfirmDeleteOpen(false)}
|
||||||
<>
|
onConfirm={handleDelete}
|
||||||
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}>
|
isDeleting={deleting}
|
||||||
Cancel
|
/>
|
||||||
</Button>
|
|
||||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
|
||||||
{t('environments.action_delete')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Are you sure you want to delete this environment?
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
DescriptionList,
|
DescriptionList,
|
||||||
Modal,
|
|
||||||
Timestamp,
|
Timestamp,
|
||||||
UuidBadge,
|
UuidBadge,
|
||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
|
import { DeleteConfirmationModal } from '../../components/modals';
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: string) => void }) {
|
function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: string) => void }) {
|
||||||
@@ -120,7 +120,6 @@ export function EnvironmentsPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [items, setItems] = useState<Environment[]>([]);
|
const [items, setItems] = useState<Environment[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -129,7 +128,6 @@ export function EnvironmentsPage() {
|
|||||||
environments
|
environments
|
||||||
.list()
|
.list()
|
||||||
.then((res) => setItems(res.data))
|
.then((res) => setItems(res.data))
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,8 +160,8 @@ export function EnvironmentsPage() {
|
|||||||
const payload = yamlParse(text) as unknown;
|
const payload = yamlParse(text) as unknown;
|
||||||
const imported = await environments.importEnvironment(payload);
|
const imported = await environments.importEnvironment(payload);
|
||||||
navigate(`/environments/${imported.id}`);
|
navigate(`/environments/${imported.id}`);
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
// Import errors are silently ignored
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,23 +194,14 @@ export function EnvironmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal
|
<DeleteConfirmationModal
|
||||||
open={deleteId != null}
|
open={deleteId != null}
|
||||||
title={t('environments.action_delete')}
|
title={t('environments.action_delete')}
|
||||||
onClose={() => !deleting && setDeleteId(null)}
|
message={t('common.confirm_delete_environment')}
|
||||||
footer={(
|
onClose={() => setDeleteId(null)}
|
||||||
<>
|
onConfirm={handleDelete}
|
||||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
isDeleting={deleting}
|
||||||
Cancel
|
/>
|
||||||
</Button>
|
|
||||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
|
||||||
{t('environments.action_delete')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Are you sure you want to delete this environment?
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Activity } from 'lucide-react';
|
import { Activity } from 'lucide-react';
|
||||||
@@ -39,35 +39,20 @@ export function AllRunsPage() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [items, setItems] = useState<AllRunRow[]>([]);
|
const {
|
||||||
const [loading, setLoading] = useState(true);
|
data: items = [],
|
||||||
const [error, setError] = useState<string | null>(null);
|
isLoading,
|
||||||
const [pulseKey, setPulseKey] = useState(0);
|
error,
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
refetch,
|
||||||
const hasDataRef = useRef(false);
|
} = useQuery<AllRunRow[]>({
|
||||||
|
queryKey: ['runs'],
|
||||||
const load = useCallback(() => {
|
queryFn: async () => {
|
||||||
if (!hasDataRef.current) setLoading(true);
|
const res = await runs.listAll();
|
||||||
runs
|
return res.data as AllRunRow[];
|
||||||
.listAll()
|
},
|
||||||
.then((res) => {
|
refetchInterval: 10_000,
|
||||||
setItems(res.data as AllRunRow[]);
|
staleTime: 0,
|
||||||
setError(null);
|
});
|
||||||
setPulseKey((k) => k + 1);
|
|
||||||
hasDataRef.current = true;
|
|
||||||
})
|
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
load();
|
|
||||||
pollRef.current = setInterval(load, 10_000);
|
|
||||||
return () => {
|
|
||||||
if (pollRef.current) clearInterval(pollRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const columns: TableColumn<AllRunRow>[] = [
|
const columns: TableColumn<AllRunRow>[] = [
|
||||||
{ key: 'id', header: t('runs.col_id'), render: (r) => <UuidBadge id={r.id} />, width: 60 },
|
{ key: 'id', header: t('runs.col_id'), render: (r) => <UuidBadge id={r.id} />, width: 60 },
|
||||||
@@ -110,14 +95,19 @@ export function AllRunsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs items={[{ label: t('runs.title'), icon: <Activity size={14} /> }]} />
|
<Breadcrumbs items={[{ label: t('runs.title'), icon: <Activity size={14} /> }]} />
|
||||||
<AutoRefreshIndicator active pulseKey={pulseKey} error={!!error} onClick={load} />
|
<AutoRefreshIndicator
|
||||||
|
active
|
||||||
|
pulseKey={items.length}
|
||||||
|
error={!!error}
|
||||||
|
onClick={() => refetch()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={items}
|
data={items}
|
||||||
rowKey={(r) => r.id}
|
rowKey={(r) => r.id}
|
||||||
loading={loading}
|
loading={isLoading}
|
||||||
emptyMessage={t('runs.empty')}
|
emptyMessage={t('runs.empty')}
|
||||||
pageSize={20}
|
pageSize={20}
|
||||||
pageSizeOptions={[10, 20, 50]}
|
pageSizeOptions={[10, 20, 50]}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
CodeBlock,
|
CodeBlock,
|
||||||
DescriptionList,
|
DescriptionList,
|
||||||
Notification,
|
|
||||||
Pagination,
|
Pagination,
|
||||||
Search,
|
Search,
|
||||||
Table,
|
Table,
|
||||||
@@ -175,7 +174,14 @@ export function RunDetailPage() {
|
|||||||
{
|
{
|
||||||
key: 'description',
|
key: 'description',
|
||||||
header: (
|
header: (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span>{t('runs.step_description')}</span>
|
<span>{t('runs.step_description')}</span>
|
||||||
{run?.stepRuns.some((s) => s.output) && (
|
{run?.stepRuns.some((s) => s.output) && (
|
||||||
<button
|
<button
|
||||||
@@ -185,7 +191,9 @@ export function RunDetailPage() {
|
|||||||
setExpandedStepIds(new Set());
|
setExpandedStepIds(new Set());
|
||||||
} else {
|
} else {
|
||||||
setExpandAll(true);
|
setExpandAll(true);
|
||||||
setExpandedStepIds(new Set(run?.stepRuns.filter((s) => s.output).map((s) => s.id) ?? []));
|
setExpandedStepIds(
|
||||||
|
new Set(run?.stepRuns.filter((s) => s.output).map((s) => s.id) ?? []),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
@@ -208,7 +216,14 @@ export function RunDetailPage() {
|
|||||||
const isExpanded = expandAll || expandedStepIds.has(s.id);
|
const isExpanded = expandAll || expandedStepIds.has(s.id);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: isExpanded && s.output ? '8px' : 0 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: isExpanded && s.output ? '8px' : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{s.output && (
|
{s.output && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -231,11 +246,7 @@ export function RunDetailPage() {
|
|||||||
marginTop: '2px',
|
marginTop: '2px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
<ChevronDown size={16} />
|
|
||||||
) : (
|
|
||||||
<ChevronRight size={16} />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<code style={{ wordBreak: 'break-word' }}>
|
<code style={{ wordBreak: 'break-word' }}>
|
||||||
@@ -250,15 +261,15 @@ export function RunDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{isExpanded && s.output && (
|
{isExpanded && s.output && (
|
||||||
<div style={{ marginLeft: '24px' }}>
|
<div style={{ marginLeft: '24px' }}>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
code={(() => {
|
code={(() => {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(JSON.parse(s.output), null, 2);
|
return JSON.stringify(JSON.parse(s.output), null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
return s.output;
|
return s.output;
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
language="json"
|
language="json"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -275,7 +286,11 @@ export function RunDetailPage() {
|
|||||||
width: 70,
|
width: 70,
|
||||||
render: (l) => <Badge variant={LOG_LEVEL_VARIANT[l.level]}>{l.level}</Badge>,
|
render: (l) => <Badge variant={LOG_LEVEL_VARIANT[l.level]}>{l.level}</Badge>,
|
||||||
},
|
},
|
||||||
{ key: 'message', header: t('runs.log_message'), render: (l) => <code style={{ wordBreak: 'break-word' }}>{l.message}</code> },
|
{
|
||||||
|
key: 'message',
|
||||||
|
header: t('runs.log_message'),
|
||||||
|
render: (l) => <code style={{ wordBreak: 'break-word' }}>{l.message}</code>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'time',
|
key: 'time',
|
||||||
header: t('runs.log_time'),
|
header: t('runs.log_time'),
|
||||||
@@ -289,7 +304,11 @@ export function RunDetailPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') },
|
{
|
||||||
|
label: t('scenarios.title'),
|
||||||
|
icon: <ClipboardList size={14} />,
|
||||||
|
onClick: () => navigate('/scenarios'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: scenario?.name ?? `${id}`,
|
label: scenario?.name ?? `${id}`,
|
||||||
onClick: () => navigate(`/scenarios/${id}`),
|
onClick: () => navigate(`/scenarios/${id}`),
|
||||||
@@ -302,7 +321,12 @@ export function RunDetailPage() {
|
|||||||
{ label: `${runId}` },
|
{ label: `${runId}` },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<AutoRefreshIndicator active={polling} pulseKey={pulseKey} error={!!error} onClick={manualRefresh} />
|
<AutoRefreshIndicator
|
||||||
|
active={polling}
|
||||||
|
pulseKey={pulseKey}
|
||||||
|
error={!!error}
|
||||||
|
onClick={manualRefresh}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <p className={styles.muted}>{t('runs.loading')}</p>}
|
{loading && <p className={styles.muted}>{t('runs.loading')}</p>}
|
||||||
@@ -327,10 +351,30 @@ export function RunDetailPage() {
|
|||||||
{ term: t('runs.field_updated'), detail: <Timestamp value={run.updatedAt} /> },
|
{ term: t('runs.field_updated'), detail: <Timestamp value={run.updatedAt} /> },
|
||||||
];
|
];
|
||||||
if (run.environment) {
|
if (run.environment) {
|
||||||
items.push({ term: 'Environment', detail: <Link to={`/environments/${run.environment.id}`} style={{ color: 'var(--color-link)' }}>{run.environment.name}</Link> });
|
items.push({
|
||||||
|
term: 'Environment',
|
||||||
|
detail: (
|
||||||
|
<Link
|
||||||
|
to={`/environments/${run.environment.id}`}
|
||||||
|
style={{ color: 'var(--color-link)' }}
|
||||||
|
>
|
||||||
|
{run.environment.name}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (run.session) {
|
if (run.session) {
|
||||||
items.push({ term: 'Session', detail: <Link to={`/sessions/${run.session.id}`} style={{ color: 'var(--color-link)' }}>{run.session.sessionName}</Link> });
|
items.push({
|
||||||
|
term: 'Session',
|
||||||
|
detail: (
|
||||||
|
<Link
|
||||||
|
to={`/sessions/${run.session.id}`}
|
||||||
|
style={{ color: 'var(--color-link)' }}
|
||||||
|
>
|
||||||
|
{run.session.sessionName}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ export function RunsPage() {
|
|||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
load();
|
load();
|
||||||
pollRef.current = setInterval(load, 10_000);
|
pollRef.current = setInterval(load, 10_000);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -102,7 +101,7 @@ export function RunsPage() {
|
|||||||
setRunning(true);
|
setRunning(true);
|
||||||
try {
|
try {
|
||||||
const run = await scenarios.run(id!, selectedEnvId);
|
const run = await scenarios.run(id!, selectedEnvId);
|
||||||
toast.success('Scenario run started');
|
toast.success(t('scenarios.run_started'));
|
||||||
navigate(`/scenarios/${id}/runs/${run.id}`);
|
navigate(`/scenarios/${id}/runs/${run.id}`);
|
||||||
setRunModalOpen(false);
|
setRunModalOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -139,7 +138,11 @@ export function RunsPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') },
|
{
|
||||||
|
label: t('scenarios.title'),
|
||||||
|
icon: <ClipboardList size={14} />,
|
||||||
|
onClick: () => navigate('/scenarios'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: scenario?.name ?? `#${id}`,
|
label: scenario?.name ?? `#${id}`,
|
||||||
onClick: () => navigate(`/scenarios/${id}`),
|
onClick: () => navigate(`/scenarios/${id}`),
|
||||||
@@ -171,16 +174,16 @@ export function RunsPage() {
|
|||||||
open={runModalOpen}
|
open={runModalOpen}
|
||||||
title={t('scenarios.run_modal_title')}
|
title={t('scenarios.run_modal_title')}
|
||||||
onClose={() => !running && setRunModalOpen(false)}
|
onClose={() => !running && setRunModalOpen(false)}
|
||||||
footer={(
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={() => setRunModalOpen(false)} disabled={running}>
|
<Button variant="secondary" onClick={() => setRunModalOpen(false)} disabled={running}>
|
||||||
Cancel
|
{t('common.button_cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={handleRun} disabled={running || !selectedEnvId}>
|
<Button variant="primary" onClick={handleRun} disabled={running || !selectedEnvId}>
|
||||||
{t('runs.action_run')}
|
{t('runs.action_run')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
{envs.length === 0 ? (
|
{envs.length === 0 ? (
|
||||||
<p>{t('scenarios.run_modal_no_env')}</p>
|
<p>{t('scenarios.run_modal_no_env')}</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, SubmitEvent } 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 { X, Save, ClipboardList } from 'lucide-react';
|
import { X, Save, ClipboardList } from 'lucide-react';
|
||||||
@@ -14,23 +14,20 @@ export function CreateScenarioPage() {
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [nameError, setNameError] = useState('');
|
const [nameError, setNameError] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setNameError(t('scenarios.form_name_required'));
|
setNameError(t('scenarios.form_name_required'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
const scenario = await scenarios.create(name.trim());
|
const scenario = await scenarios.create(name.trim());
|
||||||
toast.success('Scenario created');
|
toast.success(t('scenarios.created'));
|
||||||
navigate(`/scenarios/${scenario.id}`);
|
navigate(`/scenarios/${scenario.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
setError(message);
|
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -42,7 +39,11 @@ export function CreateScenarioPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') },
|
{
|
||||||
|
label: t('scenarios.title'),
|
||||||
|
icon: <ClipboardList size={14} />,
|
||||||
|
onClick: () => navigate('/scenarios'),
|
||||||
|
},
|
||||||
{ label: t('scenarios.create_title') },
|
{ label: t('scenarios.create_title') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, SubmitEvent } 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 { X, Save, ClipboardList } from 'lucide-react';
|
import { X, Save, ClipboardList } from 'lucide-react';
|
||||||
@@ -17,7 +17,6 @@ export function CreateStepPage() {
|
|||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [execCode, setExecCode] = useState('');
|
const [execCode, setExecCode] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -27,20 +26,18 @@ export function CreateStepPage() {
|
|||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
await steps.create(id!, {
|
await steps.create(id!, {
|
||||||
title: title.trim() || undefined,
|
title: title.trim() || undefined,
|
||||||
execCode: execCode.trim() || undefined,
|
execCode: execCode.trim() || undefined,
|
||||||
});
|
});
|
||||||
toast.success('Step created');
|
toast.success(t('steps.created'));
|
||||||
navigate(`/scenarios/${id}`);
|
navigate(`/scenarios/${id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
setError(message);
|
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -52,7 +49,11 @@ export function CreateStepPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') },
|
{
|
||||||
|
label: t('scenarios.title'),
|
||||||
|
icon: <ClipboardList size={14} />,
|
||||||
|
onClick: () => navigate('/scenarios'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: scenario?.name ?? `#${id}`,
|
label: scenario?.name ?? `#${id}`,
|
||||||
onClick: () => navigate(`/scenarios/${id}`),
|
onClick: () => navigate(`/scenarios/${id}`),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, SubmitEvent } 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 { X, Save, ClipboardList } from 'lucide-react';
|
import { X, Save, ClipboardList } from 'lucide-react';
|
||||||
@@ -18,7 +18,6 @@ export function EditScenarioPage() {
|
|||||||
const [nameError, setNameError] = useState('');
|
const [nameError, setNameError] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -28,25 +27,22 @@ export function EditScenarioPage() {
|
|||||||
setScenario(data);
|
setScenario(data);
|
||||||
setName(data.name);
|
setName(data.name);
|
||||||
})
|
})
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setNameError(t('scenarios.form_name_required'));
|
setNameError(t('scenarios.form_name_required'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
await scenarios.update(id!, { name: name.trim() });
|
await scenarios.update(id!, { name: name.trim() });
|
||||||
toast.success('Scenario updated');
|
toast.success(t('scenarios.updated'));
|
||||||
navigate(`/scenarios/${id}`);
|
navigate(`/scenarios/${id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
setError(message);
|
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -58,7 +54,11 @@ export function EditScenarioPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') },
|
{
|
||||||
|
label: t('scenarios.title'),
|
||||||
|
icon: <ClipboardList size={14} />,
|
||||||
|
onClick: () => navigate('/scenarios'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: scenario?.name ?? `#${id}`,
|
label: scenario?.name ?? `#${id}`,
|
||||||
onClick: () => navigate(`/scenarios/${id}`),
|
onClick: () => navigate(`/scenarios/${id}`),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, SubmitEvent } 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 { X, Save, ClipboardList } from 'lucide-react';
|
import { X, Save, ClipboardList } from 'lucide-react';
|
||||||
@@ -19,7 +19,6 @@ export function EditStepPage() {
|
|||||||
const [execCode, setExecCode] = useState('');
|
const [execCode, setExecCode] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id || !stepId) return;
|
if (!id || !stepId) return;
|
||||||
@@ -30,24 +29,21 @@ export function EditStepPage() {
|
|||||||
setTitle(st.title ?? '');
|
setTitle(st.title ?? '');
|
||||||
setExecCode(st.execCode ?? '');
|
setExecCode(st.execCode ?? '');
|
||||||
})
|
})
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id, stepId]);
|
}, [id, stepId]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
await steps.update(id!, stepId!, {
|
await steps.update(id!, stepId!, {
|
||||||
title: title.trim() || undefined,
|
title: title.trim() || undefined,
|
||||||
execCode: execCode.trim() || undefined,
|
execCode: execCode.trim() || undefined,
|
||||||
});
|
});
|
||||||
toast.success('Step updated');
|
toast.success(t('steps.updated'));
|
||||||
navigate(`/scenarios/${id}`);
|
navigate(`/scenarios/${id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
setError(message);
|
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -59,7 +55,11 @@ export function EditStepPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') },
|
{
|
||||||
|
label: t('scenarios.title'),
|
||||||
|
icon: <ClipboardList size={14} />,
|
||||||
|
onClick: () => navigate('/scenarios'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: scenario?.name ?? `#${id}`,
|
label: scenario?.name ?? `#${id}`,
|
||||||
onClick: () => navigate(`/scenarios/${id}`),
|
onClick: () => navigate(`/scenarios/${id}`),
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, SubmitEvent } 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, Upload, GripVertical, ClipboardList } from 'lucide-react';
|
import {
|
||||||
|
Play,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
History,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
GripVertical,
|
||||||
|
ClipboardList,
|
||||||
|
} from 'lucide-react';
|
||||||
import { stringify as yamlStringify } from 'yaml';
|
import { stringify as yamlStringify } from 'yaml';
|
||||||
import { environments, scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api';
|
import {
|
||||||
import type { Scenario, ScenarioStep, ScenarioCredential, Credential, Environment } from '../../api';
|
environments,
|
||||||
|
scenarios,
|
||||||
|
steps,
|
||||||
|
scenarioCredentials,
|
||||||
|
credentials as credentialsApi,
|
||||||
|
} from '../../api';
|
||||||
|
import type {
|
||||||
|
Scenario,
|
||||||
|
ScenarioStep,
|
||||||
|
ScenarioCredential,
|
||||||
|
Credential,
|
||||||
|
Environment,
|
||||||
|
} from '../../api';
|
||||||
import {
|
import {
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
@@ -28,7 +49,6 @@ export function ScenarioDetailPage() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [scenario, setScenario] = useState<(Scenario & { steps: ScenarioStep[] }) | null>(null);
|
const [scenario, setScenario] = useState<(Scenario & { steps: ScenarioStep[] }) | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Credentials state
|
// Credentials state
|
||||||
const [scenarioCreds, setScenarioCreds] = useState<ScenarioCredential[]>([]);
|
const [scenarioCreds, setScenarioCreds] = useState<ScenarioCredential[]>([]);
|
||||||
@@ -60,7 +80,6 @@ export function ScenarioDetailPage() {
|
|||||||
setScenario(s);
|
setScenario(s);
|
||||||
setScenarioCreds(s.scenarioCredentials ?? []);
|
setScenarioCreds(s.scenarioCredentials ?? []);
|
||||||
})
|
})
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
credentialsApi
|
credentialsApi
|
||||||
.list(1, 200)
|
.list(1, 200)
|
||||||
@@ -91,7 +110,7 @@ export function ScenarioDetailPage() {
|
|||||||
setRunning(true);
|
setRunning(true);
|
||||||
try {
|
try {
|
||||||
const run = await scenarios.run(scenario.id, selectedEnvId, saveSessionFlag);
|
const run = await scenarios.run(scenario.id, selectedEnvId, saveSessionFlag);
|
||||||
toast.success('Scenario run started');
|
toast.success(t('scenarios.run_started'));
|
||||||
navigate(`/scenarios/${id}/runs/${run.id}`);
|
navigate(`/scenarios/${id}/runs/${run.id}`);
|
||||||
setRunModalOpen(false);
|
setRunModalOpen(false);
|
||||||
setSaveSessionFlag(false);
|
setSaveSessionFlag(false);
|
||||||
@@ -140,7 +159,7 @@ export function ScenarioDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddCredential = async (e: React.FormEvent) => {
|
const handleAddCredential = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let valid = true;
|
let valid = true;
|
||||||
if (!addCredId) {
|
if (!addCredId) {
|
||||||
@@ -321,7 +340,11 @@ export function ScenarioDetailPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') },
|
{
|
||||||
|
label: t('scenarios.title'),
|
||||||
|
icon: <ClipboardList size={14} />,
|
||||||
|
onClick: () => navigate('/scenarios'),
|
||||||
|
},
|
||||||
{ label: scenario?.name ?? `#${id}` },
|
{ label: scenario?.name ?? `#${id}` },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -343,7 +366,11 @@ export function ScenarioDetailPage() {
|
|||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
{t('scenarios.action_edit')}
|
{t('scenarios.action_edit')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" size="sm" onClick={() => setPendingDelete({ type: 'scenario' })}>
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPendingDelete({ type: 'scenario' })}
|
||||||
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
{t('scenarios.action_delete')}
|
{t('scenarios.action_delete')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -513,36 +540,36 @@ export function ScenarioDetailPage() {
|
|||||||
: t('scenarios.action_delete')
|
: t('scenarios.action_delete')
|
||||||
}
|
}
|
||||||
onClose={() => !deleting && setPendingDelete(null)}
|
onClose={() => !deleting && setPendingDelete(null)}
|
||||||
footer={(
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={() => setPendingDelete(null)} disabled={deleting}>
|
<Button variant="secondary" onClick={() => setPendingDelete(null)} disabled={deleting}>
|
||||||
Cancel
|
{t('common.button_cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" onClick={confirmDelete} disabled={deleting}>
|
<Button variant="danger" onClick={confirmDelete} disabled={deleting}>
|
||||||
Confirm
|
{t('common.button_confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
{pendingDelete?.type === 'step' && 'Are you sure you want to delete this step?'}
|
{pendingDelete?.type === 'step' && t('common.confirm_delete_step')}
|
||||||
{pendingDelete?.type === 'credential' && 'Are you sure you want to remove this credential from scenario?'}
|
{pendingDelete?.type === 'credential' && t('common.confirm_remove_credential')}
|
||||||
{pendingDelete?.type === 'scenario' && 'Are you sure you want to delete this scenario?'}
|
{pendingDelete?.type === 'scenario' && t('common.confirm_delete_scenario')}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={runModalOpen}
|
open={runModalOpen}
|
||||||
title={t('scenarios.run_modal_title')}
|
title={t('scenarios.run_modal_title')}
|
||||||
onClose={() => !running && setRunModalOpen(false)}
|
onClose={() => !running && setRunModalOpen(false)}
|
||||||
footer={(
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={() => setRunModalOpen(false)} disabled={running}>
|
<Button variant="secondary" onClick={() => setRunModalOpen(false)} disabled={running}>
|
||||||
Cancel
|
{t('common.button_cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={handleRun} disabled={running || !selectedEnvId}>
|
<Button variant="primary" onClick={handleRun} disabled={running || !selectedEnvId}>
|
||||||
{t('scenarios.action_run')}
|
{t('scenarios.action_run')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
{envs.length === 0 ? (
|
{envs.length === 0 ? (
|
||||||
<p>{t('scenarios.run_modal_no_env')}</p>
|
<p>{t('scenarios.run_modal_no_env')}</p>
|
||||||
@@ -554,13 +581,15 @@ export function ScenarioDetailPage() {
|
|||||||
onChange={(e) => setSelectedEnvId(e.target.value)}
|
onChange={(e) => setSelectedEnvId(e.target.value)}
|
||||||
options={envs.map((env) => ({ value: env.id, label: env.name }))}
|
options={envs.map((env) => ({ value: env.id, label: env.name }))}
|
||||||
/>
|
/>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', marginTop: '1rem', gap: '0.5rem' }}>
|
<label
|
||||||
|
style={{ display: 'flex', alignItems: 'center', marginTop: '1rem', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={saveSessionFlag}
|
checked={saveSessionFlag}
|
||||||
onChange={(e) => setSaveSessionFlag(e.target.checked)}
|
onChange={(e) => setSaveSessionFlag(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>Save session</span>
|
<span>{t('common.save_session')}</span>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export function ScenariosPage() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [items, setItems] = useState<Scenario[]>([]);
|
const [items, setItems] = useState<Scenario[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [runScenarioId, setRunScenarioId] = useState<string | null>(null);
|
const [runScenarioId, setRunScenarioId] = useState<string | null>(null);
|
||||||
@@ -41,7 +40,6 @@ export function ScenariosPage() {
|
|||||||
setItems(scenarioRes.data);
|
setItems(scenarioRes.data);
|
||||||
setEnvs(envRes.data);
|
setEnvs(envRes.data);
|
||||||
})
|
})
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -61,7 +59,7 @@ export function ScenariosPage() {
|
|||||||
setRunning(true);
|
setRunning(true);
|
||||||
try {
|
try {
|
||||||
const run = await scenarios.run(runScenarioId, selectedEnvId, saveSessionFlag);
|
const run = await scenarios.run(runScenarioId, selectedEnvId, saveSessionFlag);
|
||||||
toast.success('Scenario run started');
|
toast.success(t('scenarios.run_started'));
|
||||||
navigate(`/scenarios/${runScenarioId}/runs/${run.id}`);
|
navigate(`/scenarios/${runScenarioId}/runs/${run.id}`);
|
||||||
setRunScenarioId(null);
|
setRunScenarioId(null);
|
||||||
setSaveSessionFlag(false);
|
setSaveSessionFlag(false);
|
||||||
@@ -94,8 +92,8 @@ export function ScenariosPage() {
|
|||||||
const payload = yamlParse(text) as unknown;
|
const payload = yamlParse(text) as unknown;
|
||||||
const imported = await scenarios.importScenario(payload);
|
const imported = await scenarios.importScenario(payload);
|
||||||
navigate(`/scenarios/${imported.id}`);
|
navigate(`/scenarios/${imported.id}`);
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError((err as Error).message);
|
// Import errors are silently ignored
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -178,25 +176,25 @@ export function ScenariosPage() {
|
|||||||
open={deleteId != null}
|
open={deleteId != null}
|
||||||
title={t('scenarios.action_delete')}
|
title={t('scenarios.action_delete')}
|
||||||
onClose={() => !deleting && setDeleteId(null)}
|
onClose={() => !deleting && setDeleteId(null)}
|
||||||
footer={(
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
||||||
Cancel
|
{t('common.button_cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||||
{t('scenarios.action_delete')}
|
{t('scenarios.action_delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
Are you sure you want to delete this scenario?
|
{t('common.confirm_delete_scenario')}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={runScenarioId != null}
|
open={runScenarioId != null}
|
||||||
title={t('scenarios.run_modal_title')}
|
title={t('scenarios.run_modal_title')}
|
||||||
onClose={() => !running && setRunScenarioId(null)}
|
onClose={() => !running && setRunScenarioId(null)}
|
||||||
footer={(
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={() => setRunScenarioId(null)} disabled={running}>
|
<Button variant="secondary" onClick={() => setRunScenarioId(null)} disabled={running}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -205,7 +203,7 @@ export function ScenariosPage() {
|
|||||||
{t('scenarios.action_run')}
|
{t('scenarios.action_run')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
{envs.length === 0 ? (
|
{envs.length === 0 ? (
|
||||||
<p>{t('scenarios.run_modal_no_env')}</p>
|
<p>{t('scenarios.run_modal_no_env')}</p>
|
||||||
@@ -217,13 +215,15 @@ export function ScenariosPage() {
|
|||||||
onChange={(e) => setSelectedEnvId(e.target.value)}
|
onChange={(e) => setSelectedEnvId(e.target.value)}
|
||||||
options={envs.map((env) => ({ value: env.id, label: env.name }))}
|
options={envs.map((env) => ({ value: env.id, label: env.name }))}
|
||||||
/>
|
/>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', marginTop: '1rem', gap: '0.5rem' }}>
|
<label
|
||||||
|
style={{ display: 'flex', alignItems: 'center', marginTop: '1rem', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={saveSessionFlag}
|
checked={saveSessionFlag}
|
||||||
onChange={(e) => setSaveSessionFlag(e.target.checked)}
|
onChange={(e) => setSaveSessionFlag(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>Save session</span>
|
<span>{t('common.save_session')}</span>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,16 +4,8 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Trash2, Monitor } from 'lucide-react';
|
import { Trash2, Monitor } from 'lucide-react';
|
||||||
import { sessions } from '../../api';
|
import { sessions } from '../../api';
|
||||||
import type { Session } from '../../api';
|
import type { Session } from '../../api';
|
||||||
import {
|
import { Badge, Breadcrumbs, Button, Card, DescriptionList, Timestamp, UuidBadge } from '../../ui';
|
||||||
Badge,
|
import { DeleteConfirmationModal } from '../../components/modals';
|
||||||
Breadcrumbs,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
DescriptionList,
|
|
||||||
Modal,
|
|
||||||
Timestamp,
|
|
||||||
UuidBadge,
|
|
||||||
} from '../../ui';
|
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
export function SessionDetailPage() {
|
export function SessionDetailPage() {
|
||||||
@@ -22,7 +14,6 @@ export function SessionDetailPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
@@ -31,7 +22,6 @@ export function SessionDetailPage() {
|
|||||||
sessions
|
sessions
|
||||||
.get(id)
|
.get(id)
|
||||||
.then(setSession)
|
.then(setSession)
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
@@ -52,7 +42,11 @@ export function SessionDetailPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('sessions.title'), icon: <Monitor size={14} />, onClick: () => navigate('/sessions') },
|
{
|
||||||
|
label: t('sessions.title'),
|
||||||
|
icon: <Monitor size={14} />,
|
||||||
|
onClick: () => navigate('/sessions'),
|
||||||
|
},
|
||||||
{ label: session?.sessionName ?? `#${id}` },
|
{ label: session?.sessionName ?? `#${id}` },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -102,23 +96,14 @@ export function SessionDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal
|
<DeleteConfirmationModal
|
||||||
open={confirmDeleteOpen}
|
open={confirmDeleteOpen}
|
||||||
title={t('sessions.action_delete')}
|
title={t('sessions.action_delete')}
|
||||||
onClose={() => !deleting && setConfirmDeleteOpen(false)}
|
message={t('common.confirm_delete_session')}
|
||||||
footer={(
|
onClose={() => setConfirmDeleteOpen(false)}
|
||||||
<>
|
onConfirm={handleDelete}
|
||||||
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}>
|
isDeleting={deleting}
|
||||||
Cancel
|
/>
|
||||||
</Button>
|
|
||||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
|
||||||
{t('sessions.action_delete')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Are you sure you want to delete this session?
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
Modal,
|
|
||||||
Table,
|
Table,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
UuidBadge,
|
UuidBadge,
|
||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
|
import { DeleteConfirmationModal } from '../../components/modals';
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
export function SessionsPage() {
|
export function SessionsPage() {
|
||||||
@@ -21,7 +21,6 @@ export function SessionsPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [items, setItems] = useState<Session[]>([]);
|
const [items, setItems] = useState<Session[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
@@ -29,7 +28,6 @@ export function SessionsPage() {
|
|||||||
sessions
|
sessions
|
||||||
.list()
|
.list()
|
||||||
.then((res) => setItems(res.data))
|
.then((res) => setItems(res.data))
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -102,23 +100,14 @@ export function SessionsPage() {
|
|||||||
onRowClick={(s) => navigate(`/sessions/${s.id}`)}
|
onRowClick={(s) => navigate(`/sessions/${s.id}`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<DeleteConfirmationModal
|
||||||
open={deleteId != null}
|
open={deleteId != null}
|
||||||
title={t('sessions.action_delete')}
|
title={t('sessions.action_delete')}
|
||||||
onClose={() => !deleting && setDeleteId(null)}
|
message={t('common.confirm_delete_session')}
|
||||||
footer={(
|
onClose={() => setDeleteId(null)}
|
||||||
<>
|
onConfirm={handleDelete}
|
||||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
isDeleting={deleting}
|
||||||
Cancel
|
/>
|
||||||
</Button>
|
|
||||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
|
||||||
{t('sessions.action_delete')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Are you sure you want to delete this session?
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, SubmitEvent } 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 { X, Save, Braces } from 'lucide-react';
|
import { X, Save, Braces } from 'lucide-react';
|
||||||
@@ -19,9 +19,8 @@ export function CreateSnippetPage() {
|
|||||||
const [titleError, setTitleError] = useState('');
|
const [titleError, setTitleError] = useState('');
|
||||||
const [codeError, setCodeError] = useState('');
|
const [codeError, setCodeError] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let valid = true;
|
let valid = true;
|
||||||
if (!alias.trim()) {
|
if (!alias.trim()) {
|
||||||
@@ -38,7 +37,6 @@ export function CreateSnippetPage() {
|
|||||||
}
|
}
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
const snippet = await snippets.create({
|
const snippet = await snippets.create({
|
||||||
alias: alias.trim(),
|
alias: alias.trim(),
|
||||||
@@ -46,11 +44,10 @@ export function CreateSnippetPage() {
|
|||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
code: code.trim(),
|
code: code.trim(),
|
||||||
});
|
});
|
||||||
toast.success('Snippet created');
|
toast.success(t('snippets.created'));
|
||||||
navigate(`/snippets/${snippet.id}`);
|
navigate(`/snippets/${snippet.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
setError(message);
|
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -62,7 +59,11 @@ export function CreateSnippetPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('snippets.title'), icon: <Braces size={14} />, onClick: () => navigate('/snippets') },
|
{
|
||||||
|
label: t('snippets.title'),
|
||||||
|
icon: <Braces size={14} />,
|
||||||
|
onClick: () => navigate('/snippets'),
|
||||||
|
},
|
||||||
{ label: t('snippets.create_title') },
|
{ label: t('snippets.create_title') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, SubmitEvent } 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 { X, Save, Braces } from 'lucide-react';
|
import { X, Save, Braces } from 'lucide-react';
|
||||||
@@ -23,7 +23,6 @@ export function EditSnippetPage() {
|
|||||||
const [codeError, setCodeError] = useState('');
|
const [codeError, setCodeError] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -36,11 +35,10 @@ export function EditSnippetPage() {
|
|||||||
setDescription(s.description ?? '');
|
setDescription(s.description ?? '');
|
||||||
setCode(s.code);
|
setCode(s.code);
|
||||||
})
|
})
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let valid = true;
|
let valid = true;
|
||||||
if (!alias.trim()) {
|
if (!alias.trim()) {
|
||||||
@@ -57,7 +55,6 @@ export function EditSnippetPage() {
|
|||||||
}
|
}
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
await snippets.update(id!, {
|
await snippets.update(id!, {
|
||||||
alias: alias.trim(),
|
alias: alias.trim(),
|
||||||
@@ -65,11 +62,10 @@ export function EditSnippetPage() {
|
|||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
code: code.trim(),
|
code: code.trim(),
|
||||||
});
|
});
|
||||||
toast.success('Snippet updated');
|
toast.success(t('snippets.updated'));
|
||||||
navigate(`/snippets/${id}`);
|
navigate(`/snippets/${id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
setError(message);
|
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -81,7 +77,11 @@ export function EditSnippetPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('snippets.title'), icon: <Braces size={14} />, onClick: () => navigate('/snippets') },
|
{
|
||||||
|
label: t('snippets.title'),
|
||||||
|
icon: <Braces size={14} />,
|
||||||
|
onClick: () => navigate('/snippets'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: snippet?.title ?? `#${id}`,
|
label: snippet?.title ?? `#${id}`,
|
||||||
onClick: () => navigate(`/snippets/${id}`),
|
onClick: () => navigate(`/snippets/${id}`),
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export function SnippetDetailPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [snippet, setSnippet] = useState<Snippet | null>(null);
|
const [snippet, setSnippet] = useState<Snippet | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ export function SnippetDetailPage() {
|
|||||||
snippets
|
snippets
|
||||||
.get(id)
|
.get(id)
|
||||||
.then(setSnippet)
|
.then(setSnippet)
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
@@ -66,7 +64,11 @@ export function SnippetDetailPage() {
|
|||||||
<div className={styles.pageToolbar}>
|
<div className={styles.pageToolbar}>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: t('snippets.title'), icon: <Braces size={14} />, onClick: () => navigate('/snippets') },
|
{
|
||||||
|
label: t('snippets.title'),
|
||||||
|
icon: <Braces size={14} />,
|
||||||
|
onClick: () => navigate('/snippets'),
|
||||||
|
},
|
||||||
{ label: snippet?.title ?? `#${id}` },
|
{ label: snippet?.title ?? `#${id}` },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -141,18 +143,22 @@ export function SnippetDetailPage() {
|
|||||||
open={confirmDeleteOpen}
|
open={confirmDeleteOpen}
|
||||||
title={t('snippets.action_delete')}
|
title={t('snippets.action_delete')}
|
||||||
onClose={() => !deleting && setConfirmDeleteOpen(false)}
|
onClose={() => !deleting && setConfirmDeleteOpen(false)}
|
||||||
footer={(
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}>
|
<Button
|
||||||
Cancel
|
variant="secondary"
|
||||||
|
onClick={() => setConfirmDeleteOpen(false)}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{t('common.button_cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||||
{t('snippets.action_delete')}
|
{t('snippets.action_delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
Are you sure you want to delete this snippet?
|
{t('common.confirm_delete_snippet')}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import {
|
|||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
import styles from '../Page.module.css';
|
import styles from '../Page.module.css';
|
||||||
|
|
||||||
function firstParagraphBlocks(markdown: string, limit = 2): { preview: string; truncated: boolean } {
|
function firstParagraphBlocks(
|
||||||
|
markdown: string,
|
||||||
|
limit = 2,
|
||||||
|
): { preview: string; truncated: boolean } {
|
||||||
const blocks = markdown
|
const blocks = markdown
|
||||||
.split(/\n\s*\n/g)
|
.split(/\n\s*\n/g)
|
||||||
.map((block) => block.trim())
|
.map((block) => block.trim())
|
||||||
@@ -81,16 +84,17 @@ function SnippetCard({ snippet, onDelete }: { snippet: Snippet; onDelete: (id: s
|
|||||||
<p className={styles.muted}>
|
<p className={styles.muted}>
|
||||||
{t('snippets.field_alias')}: <code className={styles.codeInline}>{snippet.alias}</code>
|
{t('snippets.field_alias')}: <code className={styles.codeInline}>{snippet.alias}</code>
|
||||||
</p>
|
</p>
|
||||||
{snippet.description && (() => {
|
{snippet.description &&
|
||||||
const { preview, truncated } = firstParagraphBlocks(snippet.description, 2);
|
(() => {
|
||||||
if (!preview) return null;
|
const { preview, truncated } = firstParagraphBlocks(snippet.description, 2);
|
||||||
return (
|
if (!preview) return null;
|
||||||
<>
|
return (
|
||||||
<MarkdownContent content={preview} />
|
<>
|
||||||
{truncated && <p className={styles.muted}>...</p>}
|
<MarkdownContent content={preview} />
|
||||||
</>
|
{truncated && <p className={styles.muted}>...</p>}
|
||||||
);
|
</>
|
||||||
})()}
|
);
|
||||||
|
})()}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -113,7 +117,6 @@ export function SnippetsPage() {
|
|||||||
const navigate = useNavigate();
|
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 [deleteId, setDeleteId] = useState<string | null>(null);
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -122,7 +125,6 @@ export function SnippetsPage() {
|
|||||||
snippets
|
snippets
|
||||||
.list()
|
.list()
|
||||||
.then((res) => setItems(res.data))
|
.then((res) => setItems(res.data))
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,8 +157,8 @@ export function SnippetsPage() {
|
|||||||
const payload = yamlParse(text) as unknown;
|
const payload = yamlParse(text) as unknown;
|
||||||
const imported = await snippets.importSnippet(payload);
|
const imported = await snippets.importSnippet(payload);
|
||||||
navigate(`/snippets/${imported.id}`);
|
navigate(`/snippets/${imported.id}`);
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
// Import errors are silently ignored
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,18 +197,18 @@ export function SnippetsPage() {
|
|||||||
open={deleteId != null}
|
open={deleteId != null}
|
||||||
title={t('snippets.action_delete')}
|
title={t('snippets.action_delete')}
|
||||||
onClose={() => !deleting && setDeleteId(null)}
|
onClose={() => !deleting && setDeleteId(null)}
|
||||||
footer={(
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
||||||
Cancel
|
{t('common.button_cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||||
{t('snippets.action_delete')}
|
{t('snippets.action_delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
Are you sure you want to delete this snippet?
|
{t('common.confirm_delete_snippet')}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,15 +36,18 @@ export function Breadcrumbs({ items, separator, className }: BreadcrumbsProps) {
|
|||||||
<li key={i} className={styles.item}>
|
<li key={i} className={styles.item}>
|
||||||
{isLast ? (
|
{isLast ? (
|
||||||
<span className={styles.current} aria-current="page">
|
<span className={styles.current} aria-current="page">
|
||||||
{item.icon}{item.label}
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
) : item.href ? (
|
) : item.href ? (
|
||||||
<a href={item.href} className={styles.link} onClick={item.onClick}>
|
<a href={item.href} className={styles.link} onClick={item.onClick}>
|
||||||
{item.icon}{item.label}
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" className={styles.link} onClick={item.onClick}>
|
<button type="button" className={styles.link} onClick={item.onClick}>
|
||||||
{item.icon}{item.label}
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!isLast && <span className={styles.separator}>{sep}</span>}
|
{!isLast && <span className={styles.separator}>{sep}</span>}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react';
|
import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react';
|
||||||
import styles from './ToastProvider.module.css';
|
import styles from './ToastProvider.module.css';
|
||||||
import { subscribeApiErrorToasts } from '../../lib/toast-events';
|
import { subscribeApiErrorToasts } from '../../lib/toast-events';
|
||||||
|
import { ToastContext, type ToastApi, type ToastVariant } from './toast-context';
|
||||||
type ToastVariant = 'success' | 'error' | 'info';
|
|
||||||
|
|
||||||
interface ToastItem {
|
interface ToastItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -12,15 +11,6 @@ interface ToastItem {
|
|||||||
closing: boolean;
|
closing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToastApi {
|
|
||||||
show: (message: string, variant?: ToastVariant) => void;
|
|
||||||
success: (message: string) => void;
|
|
||||||
error: (message: string) => void;
|
|
||||||
info: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ToastContext = createContext<ToastApi | null>(null);
|
|
||||||
|
|
||||||
const ICONS: Record<ToastVariant, React.ReactNode> = {
|
const ICONS: Record<ToastVariant, React.ReactNode> = {
|
||||||
success: <CheckCircle2 size={16} />,
|
success: <CheckCircle2 size={16} />,
|
||||||
error: <AlertCircle size={16} />,
|
error: <AlertCircle size={16} />,
|
||||||
@@ -41,33 +31,38 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp
|
|||||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const dismiss = useCallback((id: number) => {
|
const dismiss = useCallback(
|
||||||
setItems((prev) =>
|
(id: number) => {
|
||||||
prev.map((item) =>
|
setItems((prev) => prev.map((item) => (item.id === id ? { ...item, closing: true } : item)));
|
||||||
item.id === id ? { ...item, closing: true } : item,
|
window.setTimeout(() => remove(id), EXIT_ANIMATION_MS);
|
||||||
),
|
},
|
||||||
);
|
[remove],
|
||||||
window.setTimeout(() => remove(id), EXIT_ANIMATION_MS);
|
);
|
||||||
}, [remove]);
|
|
||||||
|
|
||||||
const show = useCallback((message: string, variant: ToastVariant = 'info') => {
|
const show = useCallback(
|
||||||
const now = Date.now();
|
(message: string, variant: ToastVariant = 'info') => {
|
||||||
const dedupeKey = `${variant}:${message}`;
|
const now = Date.now();
|
||||||
const previousTs = lastShownAtRef.current.get(dedupeKey) ?? 0;
|
const dedupeKey = `${variant}:${message}`;
|
||||||
if (now - previousTs < 600) return;
|
const previousTs = lastShownAtRef.current.get(dedupeKey) ?? 0;
|
||||||
lastShownAtRef.current.set(dedupeKey, now);
|
if (now - previousTs < 600) return;
|
||||||
|
lastShownAtRef.current.set(dedupeKey, now);
|
||||||
|
|
||||||
const id = Date.now() + Math.floor(Math.random() * 1000);
|
const id = Date.now() + Math.floor(Math.random() * 1000);
|
||||||
setItems((prev) => [...prev, { id, message, variant, closing: false }]);
|
setItems((prev) => [...prev, { id, message, variant, closing: false }]);
|
||||||
window.setTimeout(() => dismiss(id), durationMs);
|
window.setTimeout(() => dismiss(id), durationMs);
|
||||||
}, [dismiss, durationMs]);
|
},
|
||||||
|
[dismiss, durationMs],
|
||||||
|
);
|
||||||
|
|
||||||
const api = useMemo<ToastApi>(() => ({
|
const api = useMemo<ToastApi>(
|
||||||
show,
|
() => ({
|
||||||
success: (message: string) => show(message, 'success'),
|
show,
|
||||||
error: (message: string) => show(message, 'error'),
|
success: (message: string) => show(message, 'success'),
|
||||||
info: (message: string) => show(message, 'info'),
|
error: (message: string) => show(message, 'error'),
|
||||||
}), [show]);
|
info: (message: string) => show(message, 'info'),
|
||||||
|
}),
|
||||||
|
[show],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return subscribeApiErrorToasts((message) => api.error(message));
|
return subscribeApiErrorToasts((message) => api.error(message));
|
||||||
@@ -86,7 +81,9 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp
|
|||||||
role="status"
|
role="status"
|
||||||
>
|
>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<span className={styles.icon} aria-hidden="true">{ICONS[item.variant]}</span>
|
<span className={styles.icon} aria-hidden="true">
|
||||||
|
{ICONS[item.variant]}
|
||||||
|
</span>
|
||||||
<p className={styles.message}>{item.message}</p>
|
<p className={styles.message}>{item.message}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -103,11 +100,3 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp
|
|||||||
</ToastContext.Provider>
|
</ToastContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useToast(): ToastApi {
|
|
||||||
const ctx = useContext(ToastContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useToast must be used within ToastProvider');
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
export type ToastVariant = 'success' | 'error' | 'info';
|
||||||
|
|
||||||
|
export interface ToastApi {
|
||||||
|
show: (message: string, variant?: ToastVariant) => void;
|
||||||
|
success: (message: string) => void;
|
||||||
|
error: (message: string) => void;
|
||||||
|
info: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToastContext = createContext<ToastApi | null>(null);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { ToastContext, type ToastApi } from './toast-context';
|
||||||
|
|
||||||
|
export function useToast(): ToastApi {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useToast must be used within ToastProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -27,8 +27,9 @@ export type { BreadcrumbsProps, BreadcrumbItem } from './Breadcrumbs/Breadcrumbs
|
|||||||
|
|
||||||
export { ThemeSwitcher } from './ThemeSwitcher/ThemeSwitcher';
|
export { ThemeSwitcher } from './ThemeSwitcher/ThemeSwitcher';
|
||||||
|
|
||||||
export { ToastProvider, useToast } from './Toast/ToastProvider';
|
export { ToastProvider } from './Toast/ToastProvider';
|
||||||
export type { ToastProviderProps } from './Toast/ToastProvider';
|
export type { ToastProviderProps } from './Toast/ToastProvider';
|
||||||
|
export { useToast } from './Toast/useToast';
|
||||||
|
|
||||||
export { Timestamp } from './Timestamp/Timestamp';
|
export { Timestamp } from './Timestamp/Timestamp';
|
||||||
export type { TimestampProps } from './Timestamp/Timestamp';
|
export type { TimestampProps } from './Timestamp/Timestamp';
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"root":["./src/App.tsx","./src/declarations.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/index.ts","./src/api/types.ts","./src/hooks/useTheme.ts","./src/i18n/index.ts","./src/lib/hljs.ts","./src/lib/toast-events.ts","./src/pages/credential/CreateCredentialPage.tsx","./src/pages/credential/CredentialDetailPage.tsx","./src/pages/credential/CredentialsPage.tsx","./src/pages/credential/EditCredentialPage.tsx","./src/pages/environment/CreateEnvironmentPage.tsx","./src/pages/environment/EditEnvironmentPage.tsx","./src/pages/environment/EnvironmentDetailPage.tsx","./src/pages/environment/EnvironmentsPage.tsx","./src/pages/run/AllRunsPage.tsx","./src/pages/run/RunDetailPage.tsx","./src/pages/run/RunsPage.tsx","./src/pages/scenario/CreateScenarioPage.tsx","./src/pages/scenario/CreateStepPage.tsx","./src/pages/scenario/EditScenarioPage.tsx","./src/pages/scenario/EditStepPage.tsx","./src/pages/scenario/ScenarioDetailPage.tsx","./src/pages/scenario/ScenariosPage.tsx","./src/pages/session/SessionDetailPage.tsx","./src/pages/session/SessionsPage.tsx","./src/pages/snippet/CreateSnippetPage.tsx","./src/pages/snippet/EditSnippetPage.tsx","./src/pages/snippet/SnippetDetailPage.tsx","./src/pages/snippet/SnippetsPage.tsx","./src/ui/index.ts","./src/ui/AutoRefreshIndicator/AutoRefreshIndicator.tsx","./src/ui/Badge/Badge.tsx","./src/ui/Breadcrumbs/Breadcrumbs.tsx","./src/ui/Button/Button.tsx","./src/ui/Card/Card.tsx","./src/ui/CodeBlock/CodeBlock.tsx","./src/ui/CodeEditor/CodeEditor.tsx","./src/ui/ContextMenu/ContextMenu.tsx","./src/ui/DescriptionList/DescriptionList.tsx","./src/ui/Input/Input.tsx","./src/ui/MarkdownContent/MarkdownContent.tsx","./src/ui/Modal/Modal.tsx","./src/ui/Notification/Notification.tsx","./src/ui/Pagination/Pagination.tsx","./src/ui/Search/Search.tsx","./src/ui/Select/Select.tsx","./src/ui/SidePanel/SidePanel.tsx","./src/ui/Table/Table.tsx","./src/ui/Textarea/Textarea.tsx","./src/ui/ThemeSwitcher/ThemeSwitcher.tsx","./src/ui/Timestamp/Timestamp.tsx","./src/ui/Toast/ToastProvider.tsx","./src/ui/UuidBadge/UuidBadge.tsx"],"version":"5.8.3"}
|
|
||||||
Generated
+25
@@ -16,6 +16,7 @@
|
|||||||
"name": "liqa-client",
|
"name": "liqa-client",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@tanstack/react-query": "^5.99.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"i18next": "^26.0.4",
|
"i18next": "^26.0.4",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
@@ -4202,6 +4203,30 @@
|
|||||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.99.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.0.tgz",
|
||||||
|
"integrity": "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.99.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.0.tgz",
|
||||||
|
"integrity": "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.99.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/jest-dom": {
|
"node_modules/@testing-library/jest-dom": {
|
||||||
"version": "6.9.1",
|
"version": "6.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ export class BrowserController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.browserService.exec(dto.sessionName, dto.code, dto.url, environment, credentials);
|
return this.browserService.exec(
|
||||||
|
dto.sessionName,
|
||||||
|
dto.code,
|
||||||
|
dto.url,
|
||||||
|
environment,
|
||||||
|
credentials,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import { BrowserController } from "./browser.controller";
|
|||||||
import { BrowserService } from "./browser.service";
|
import { BrowserService } from "./browser.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SessionModule, CodeExecutorModule, SnippetModule, EnvironmentModule, CredentialModule],
|
imports: [
|
||||||
|
SessionModule,
|
||||||
|
CodeExecutorModule,
|
||||||
|
SnippetModule,
|
||||||
|
EnvironmentModule,
|
||||||
|
CredentialModule,
|
||||||
|
],
|
||||||
controllers: [BrowserController],
|
controllers: [BrowserController],
|
||||||
providers: [BrowserService],
|
providers: [BrowserService],
|
||||||
exports: [BrowserService],
|
exports: [BrowserService],
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import type { BrowserContext, Cookie } from "playwright";
|
import type { BrowserContext, Cookie } from "playwright";
|
||||||
import { chromium } from "playwright";
|
import { chromium } from "playwright";
|
||||||
import type { ExecResult, ScriptLogger } from "../code-executor/code-executor.service";
|
import type {
|
||||||
|
ExecResult,
|
||||||
|
ScriptLogger,
|
||||||
|
} from "../code-executor/code-executor.service";
|
||||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||||
import { TraceLogger } from "../common/trace-logger";
|
import { TraceLogger } from "../common/trace-logger";
|
||||||
import type { EnvironmentData } from "../environment/environment.entity";
|
import type { EnvironmentData } from "../environment/environment.entity";
|
||||||
@@ -172,8 +175,7 @@ export class BrowserService {
|
|||||||
const label = sessionName ?? "anonymous";
|
const label = sessionName ?? "anonymous";
|
||||||
|
|
||||||
if (sessionName) {
|
if (sessionName) {
|
||||||
const { page, context } =
|
const { page, context } = await this.getOrCreateNamedHandle(sessionName);
|
||||||
await this.getOrCreateNamedHandle(sessionName);
|
|
||||||
this.logger.log(`[${label}] exec: using persistent context`);
|
this.logger.log(`[${label}] exec: using persistent context`);
|
||||||
const snippets = await this.snippetService
|
const snippets = await this.snippetService
|
||||||
.buildSnippetMap()
|
.buildSnippetMap()
|
||||||
@@ -207,7 +209,7 @@ export class BrowserService {
|
|||||||
.buildSnippetMap()
|
.buildSnippetMap()
|
||||||
.catch(() => ({}) as Record<string, string>);
|
.catch(() => ({}) as Record<string, string>);
|
||||||
|
|
||||||
// TODO: instantiate one browser per server instance and reuse contexts for anonymous sessions,
|
// TODO: instantiate one browser per server instance and reuse contexts for anonymous sessions,
|
||||||
// instead of launching a new browser for each request.
|
// instead of launching a new browser for each request.
|
||||||
const browser = await chromium.launch({
|
const browser = await chromium.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
|
|||||||
@@ -81,7 +81,17 @@ export class CodeExecutorService {
|
|||||||
* Always call `validate()` before this method.
|
* Always call `validate()` before this method.
|
||||||
*/
|
*/
|
||||||
async execute(ctx: ExecContext): Promise<ExecResult> {
|
async execute(ctx: ExecContext): Promise<ExecResult> {
|
||||||
const { page, browser, code, log, getStepOutput, credentials, environment, snippets, result } = ctx;
|
const {
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
code,
|
||||||
|
log,
|
||||||
|
getStepOutput,
|
||||||
|
credentials,
|
||||||
|
environment,
|
||||||
|
snippets,
|
||||||
|
result,
|
||||||
|
} = ctx;
|
||||||
const scriptLog: ScriptLogger =
|
const scriptLog: ScriptLogger =
|
||||||
log ?? ((level, msg) => this.logger[level](msg));
|
log ?? ((level, msg) => this.logger[level](msg));
|
||||||
const toStr = (args: unknown[]) =>
|
const toStr = (args: unknown[]) =>
|
||||||
@@ -146,12 +156,7 @@ export class CodeExecutorService {
|
|||||||
"expect",
|
"expect",
|
||||||
`return (async (context, ...args) => { ${snippetCode} })(context, ...snippetArgs)`,
|
`return (async (context, ...args) => { ${snippetCode} })(context, ...snippetArgs)`,
|
||||||
);
|
);
|
||||||
return snippetFn(
|
return snippetFn(scriptContext, fakeConsole, args, playwrightExpect);
|
||||||
scriptContext,
|
|
||||||
fakeConsole,
|
|
||||||
args,
|
|
||||||
playwrightExpect,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ export class ExecContextBuilder {
|
|||||||
|
|
||||||
build(): ExecContext {
|
build(): ExecContext {
|
||||||
if (!this.ctx.page) throw new Error("ExecContextBuilder: page is required");
|
if (!this.ctx.page) throw new Error("ExecContextBuilder: page is required");
|
||||||
if (!this.ctx.browser) throw new Error("ExecContextBuilder: browser is required");
|
if (!this.ctx.browser)
|
||||||
if (this.ctx.code === undefined) throw new Error("ExecContextBuilder: code is required");
|
throw new Error("ExecContextBuilder: browser is required");
|
||||||
|
if (this.ctx.code === undefined)
|
||||||
|
throw new Error("ExecContextBuilder: code is required");
|
||||||
return this.ctx as ExecContext;
|
return this.ctx as ExecContext;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ export class EnvironmentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async importEnvironment(dto: EnvironmentExportDto): Promise<EnvironmentEntity> {
|
async importEnvironment(
|
||||||
|
dto: EnvironmentExportDto,
|
||||||
|
): Promise<EnvironmentEntity> {
|
||||||
if (dto.id) {
|
if (dto.id) {
|
||||||
const existing = await this.repo.findOneBy({ id: dto.id });
|
const existing = await this.repo.findOneBy({ id: dto.id });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
+1
-4
@@ -34,10 +34,7 @@ async function bootstrap() {
|
|||||||
const nodeEnv = config.get("NODE_ENV");
|
const nodeEnv = config.get("NODE_ENV");
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: [
|
origin: ["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||||
"http://localhost:5173",
|
|
||||||
"http://127.0.0.1:5173",
|
|
||||||
],
|
|
||||||
methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"],
|
methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -359,7 +359,9 @@ export class McpService {
|
|||||||
resolvedCredentials = {};
|
resolvedCredentials = {};
|
||||||
for (const [alias, credId] of Object.entries(credentials)) {
|
for (const [alias, credId] of Object.entries(credentials)) {
|
||||||
const cred = await this.credentialService.findOne(credId);
|
const cred = await this.credentialService.findOne(credId);
|
||||||
resolvedCredentials[alias] = cred.data ? JSON.parse(cred.data) : {};
|
resolvedCredentials[alias] = cred.data
|
||||||
|
? JSON.parse(cred.data)
|
||||||
|
: {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,13 +704,22 @@ 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.uuid().describe("Scenario ID to run"),
|
id: z.uuid().describe("Scenario ID to run"),
|
||||||
environmentId: z.uuid().describe("Environment ID to run the scenario in"),
|
environmentId: z
|
||||||
saveSession: z.boolean().optional().describe("Save session after run completes"),
|
.uuid()
|
||||||
|
.describe("Environment ID to run the scenario in"),
|
||||||
|
saveSession: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe("Save session after run completes"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ id, environmentId, saveSession }) => {
|
async ({ id, environmentId, saveSession }) => {
|
||||||
try {
|
try {
|
||||||
const run = await this.scenarioService.createRun(id, environmentId, saveSession);
|
const run = await this.scenarioService.createRun(
|
||||||
|
id,
|
||||||
|
environmentId,
|
||||||
|
saveSession,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text" as const, text: JSON.stringify(run) }],
|
content: [{ type: "text" as const, text: JSON.stringify(run) }],
|
||||||
};
|
};
|
||||||
@@ -817,9 +828,7 @@ export class McpService {
|
|||||||
{ credentialId, alias },
|
{ credentialId, alias },
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||||
{ type: "text" as const, text: JSON.stringify(result) },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
@@ -836,7 +845,11 @@ export class McpService {
|
|||||||
description: "Remove a credential assignment from a scenario",
|
description: "Remove a credential assignment from a scenario",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
scenarioId: z.uuid().describe("Scenario ID"),
|
scenarioId: z.uuid().describe("Scenario ID"),
|
||||||
scenarioCredentialId: z.uuid().describe("Scenario-credential assignment ID (not the credential ID)"),
|
scenarioCredentialId: z
|
||||||
|
.uuid()
|
||||||
|
.describe(
|
||||||
|
"Scenario-credential assignment ID (not the credential ID)",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ scenarioId, scenarioCredentialId }) => {
|
async ({ scenarioId, scenarioCredentialId }) => {
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import { CodeExecutorService } from "../code-executor/code-executor.service";
|
|||||||
import { ExecContextBuilder } from "../code-executor/exec-context.builder";
|
import { ExecContextBuilder } from "../code-executor/exec-context.builder";
|
||||||
import { traceStorage } from "../common/trace-context";
|
import { traceStorage } from "../common/trace-context";
|
||||||
import { TraceLogger } from "../common/trace-logger";
|
import { TraceLogger } from "../common/trace-logger";
|
||||||
import { EnvironmentData, EnvironmentEntity } from "../environment/environment.entity";
|
import {
|
||||||
|
EnvironmentData,
|
||||||
|
EnvironmentEntity,
|
||||||
|
} from "../environment/environment.entity";
|
||||||
import { SessionContextService } from "../session/session-context.service";
|
import { SessionContextService } from "../session/session-context.service";
|
||||||
import { SessionService } from "../session/session.service";
|
import { SessionService } from "../session/session.service";
|
||||||
import { SnippetService } from "../snippet/snippet.service";
|
import { SnippetService } from "../snippet/snippet.service";
|
||||||
@@ -115,7 +118,7 @@ export class ScenarioSchedulerService {
|
|||||||
const environmentData = await this.environmentRepo
|
const environmentData = await this.environmentRepo
|
||||||
.findOneBy({ id: run.environmentId })
|
.findOneBy({ id: run.environmentId })
|
||||||
.then((env) => env?.data ?? {})
|
.then((env) => env?.data ?? {})
|
||||||
.catch(() => ({} as EnvironmentData));
|
.catch(() => ({}) as EnvironmentData);
|
||||||
this.runEnvironments.set(run.id, environmentData);
|
this.runEnvironments.set(run.id, environmentData);
|
||||||
const traceId = crypto.randomUUID();
|
const traceId = crypto.randomUUID();
|
||||||
void traceStorage.run({ traceId }, () =>
|
void traceStorage.run({ traceId }, () =>
|
||||||
@@ -187,7 +190,12 @@ export class ScenarioSchedulerService {
|
|||||||
cookies.find((c) => c.name === "token")?.value ??
|
cookies.find((c) => c.name === "token")?.value ??
|
||||||
"";
|
"";
|
||||||
|
|
||||||
const session = await this.sessionService.upsert(sessionName, token, cookies, localStorage);
|
const session = await this.sessionService.upsert(
|
||||||
|
sessionName,
|
||||||
|
token,
|
||||||
|
cookies,
|
||||||
|
localStorage,
|
||||||
|
);
|
||||||
await this.runRepo.update(runId, { sessionId: session.id });
|
await this.runRepo.update(runId, { sessionId: session.id });
|
||||||
this.sessionContextService.register(
|
this.sessionContextService.register(
|
||||||
sessionName,
|
sessionName,
|
||||||
|
|||||||
@@ -214,7 +214,11 @@ export class ScenarioController {
|
|||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: CreateScenarioRunDto,
|
@Body() dto: CreateScenarioRunDto,
|
||||||
) {
|
) {
|
||||||
return this.scenarioService.createRun(id, dto.environmentId, dto.saveSession);
|
return this.scenarioService.createRun(
|
||||||
|
id,
|
||||||
|
dto.environmentId,
|
||||||
|
dto.saveSession,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/run/:runId")
|
@Get(":id/run/:runId")
|
||||||
|
|||||||
@@ -322,7 +322,12 @@ export class ScenarioService {
|
|||||||
await this.findOne(scenarioId); // 404 guard
|
await this.findOne(scenarioId); // 404 guard
|
||||||
const run = await this.runRepo.findOne({
|
const run = await this.runRepo.findOne({
|
||||||
where: { id: runId, scenarioId },
|
where: { id: runId, scenarioId },
|
||||||
relations: ["stepRuns", "stepRuns.scenarioStep", "environment", "session"],
|
relations: [
|
||||||
|
"stepRuns",
|
||||||
|
"stepRuns.scenarioStep",
|
||||||
|
"environment",
|
||||||
|
"session",
|
||||||
|
],
|
||||||
order: { stepRuns: { order: "ASC" } },
|
order: { stepRuns: { order: "ASC" } },
|
||||||
});
|
});
|
||||||
if (!run)
|
if (!run)
|
||||||
@@ -370,7 +375,12 @@ export class ScenarioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const run = await this.runRepo.save(
|
const run = await this.runRepo.save(
|
||||||
this.runRepo.create({ scenarioId, environmentId, status: "pending", saveSession }),
|
this.runRepo.create({
|
||||||
|
scenarioId,
|
||||||
|
environmentId,
|
||||||
|
status: "pending",
|
||||||
|
saveSession,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const stepRuns = scenario.steps.map((step, index) =>
|
const stepRuns = scenario.steps.map((step, index) =>
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import { SnippetExportDto } from "./dto/snippet-export.dto";
|
|||||||
import { UpdateSnippetDto } from "./dto/update-snippet.dto";
|
import { UpdateSnippetDto } from "./dto/update-snippet.dto";
|
||||||
import { SnippetEntity } from "./snippet.entity";
|
import { SnippetEntity } from "./snippet.entity";
|
||||||
|
|
||||||
export type SnippetOrderBy = "id" | "alias" | "title" | "createdAt" | "updatedAt";
|
export type SnippetOrderBy =
|
||||||
|
| "id"
|
||||||
|
| "alias"
|
||||||
|
| "title"
|
||||||
|
| "createdAt"
|
||||||
|
| "updatedAt";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SnippetService implements OnModuleInit {
|
export class SnippetService implements OnModuleInit {
|
||||||
@@ -47,7 +52,9 @@ export class SnippetService implements OnModuleInit {
|
|||||||
async create(dto: CreateSnippetDto): Promise<SnippetEntity> {
|
async create(dto: CreateSnippetDto): Promise<SnippetEntity> {
|
||||||
const existing = await this.repo.findOneBy({ alias: dto.alias });
|
const existing = await this.repo.findOneBy({ alias: dto.alias });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException(`Snippet alias "${dto.alias}" already exists`);
|
throw new ConflictException(
|
||||||
|
`Snippet alias "${dto.alias}" already exists`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return this.repo.save(
|
return this.repo.save(
|
||||||
this.repo.create({
|
this.repo.create({
|
||||||
@@ -85,7 +92,9 @@ export class SnippetService implements OnModuleInit {
|
|||||||
if (dto.alias && dto.alias !== snippet.alias) {
|
if (dto.alias && dto.alias !== snippet.alias) {
|
||||||
const conflict = await this.repo.findOneBy({ alias: dto.alias });
|
const conflict = await this.repo.findOneBy({ alias: dto.alias });
|
||||||
if (conflict)
|
if (conflict)
|
||||||
throw new ConflictException(`Snippet alias "${dto.alias}" already exists`);
|
throw new ConflictException(
|
||||||
|
`Snippet alias "${dto.alias}" already exists`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Object.assign(snippet, dto);
|
Object.assign(snippet, dto);
|
||||||
return this.repo.save(snippet);
|
return this.repo.save(snippet);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { getRepositoryToken } from "@nestjs/typeorm";
|
|||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { buildTestApp } from "./app.harness";
|
import { buildTestApp } from "./app.harness";
|
||||||
import { SessionEntity } from "../src/session/session.entity";
|
import { SessionEntity } from "../src/session/session.entity";
|
||||||
|
import { EnvironmentEntity } from "../src/environment/environment.entity";
|
||||||
|
import { CredentialEntity } from "../src/credential/credential.entity";
|
||||||
import { Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,6 +17,8 @@ import { Repository } from "typeorm";
|
|||||||
describe("BrowserController", () => {
|
describe("BrowserController", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
let sessionRepo: Repository<SessionEntity>;
|
let sessionRepo: Repository<SessionEntity>;
|
||||||
|
let environmentRepo: Repository<EnvironmentEntity>;
|
||||||
|
let credentialRepo: Repository<CredentialEntity>;
|
||||||
|
|
||||||
const FAKE_SESSION = "test-browser-session";
|
const FAKE_SESSION = "test-browser-session";
|
||||||
|
|
||||||
@@ -23,6 +27,12 @@ describe("BrowserController", () => {
|
|||||||
sessionRepo = app.get<Repository<SessionEntity>>(
|
sessionRepo = app.get<Repository<SessionEntity>>(
|
||||||
getRepositoryToken(SessionEntity),
|
getRepositoryToken(SessionEntity),
|
||||||
);
|
);
|
||||||
|
environmentRepo = app.get<Repository<EnvironmentEntity>>(
|
||||||
|
getRepositoryToken(EnvironmentEntity),
|
||||||
|
);
|
||||||
|
credentialRepo = app.get<Repository<CredentialEntity>>(
|
||||||
|
getRepositoryToken(CredentialEntity),
|
||||||
|
);
|
||||||
|
|
||||||
// Seed a session with minimal but valid JSON so the browser code can
|
// Seed a session with minimal but valid JSON so the browser code can
|
||||||
// deserialise it (it will still fail to open a real page, tested separately)
|
// deserialise it (it will still fail to open a real page, tested separately)
|
||||||
@@ -40,6 +50,33 @@ describe("BrowserController", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function createEnvironment(
|
||||||
|
data: Record<string, string | undefined>,
|
||||||
|
name = `browser-env-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
): Promise<string> {
|
||||||
|
const environment = await environmentRepo.save(
|
||||||
|
environmentRepo.create({
|
||||||
|
name,
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return environment.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCredential(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
name = `browser-cred-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
): Promise<string> {
|
||||||
|
const credential = await credentialRepo.save(
|
||||||
|
credentialRepo.create({
|
||||||
|
name,
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
lastUsedAt: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return credential.id;
|
||||||
|
}
|
||||||
|
|
||||||
// ── POST /open ─────────────────────────────────────────────────────────────
|
// ── POST /open ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("POST /open", () => {
|
describe("POST /open", () => {
|
||||||
@@ -137,35 +174,46 @@ describe("BrowserController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exposes environment via context.env in a named session", async () => {
|
it("exposes environment via context.env in a named session", async () => {
|
||||||
|
const environmentId = await createEnvironment({
|
||||||
|
BASE_URL: "https://env.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.post("/exec")
|
.post("/exec")
|
||||||
.send({
|
.send({
|
||||||
sessionName: "no-such-session",
|
sessionName: "no-such-session",
|
||||||
code: "return context.env.BASE_URL;",
|
code: "return context.env.BASE_URL;",
|
||||||
environment: { BASE_URL: "https://env.example.com" },
|
environmentId,
|
||||||
})
|
})
|
||||||
.expect(201);
|
.expect(201);
|
||||||
expect(res.body).toEqual({ result: "https://env.example.com" });
|
expect(res.body).toEqual({ result: "https://env.example.com" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes environment via context.env in a sessionless exec", async () => {
|
it("exposes environment via context.env in a sessionless exec", async () => {
|
||||||
|
const environmentId = await createEnvironment({ KEY: "value123" });
|
||||||
|
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.post("/exec")
|
.post("/exec")
|
||||||
.send({
|
.send({
|
||||||
code: "return context.env.KEY;",
|
code: "return context.env.KEY;",
|
||||||
environment: { KEY: "value123" },
|
environmentId,
|
||||||
})
|
})
|
||||||
.expect(201);
|
.expect(201);
|
||||||
expect(res.body).toEqual({ result: "value123" });
|
expect(res.body).toEqual({ result: "value123" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes credentials via context.getCredential in a named session", async () => {
|
it("exposes credentials via context.getCredential in a named session", async () => {
|
||||||
|
const credentialId = await createCredential({
|
||||||
|
username: "user1",
|
||||||
|
password: "pass1",
|
||||||
|
});
|
||||||
|
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.post("/exec")
|
.post("/exec")
|
||||||
.send({
|
.send({
|
||||||
sessionName: "no-such-session",
|
sessionName: "no-such-session",
|
||||||
code: "return context.getCredential('admin');",
|
code: "return context.getCredential('admin');",
|
||||||
credentials: { admin: { username: "user1", password: "pass1" } },
|
credentials: { admin: credentialId },
|
||||||
})
|
})
|
||||||
.expect(201);
|
.expect(201);
|
||||||
expect(res.body).toEqual({
|
expect(res.body).toEqual({
|
||||||
@@ -174,11 +222,13 @@ describe("BrowserController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exposes credentials via context.getCredential in a sessionless exec", async () => {
|
it("exposes credentials via context.getCredential in a sessionless exec", async () => {
|
||||||
|
const credentialId = await createCredential({ token: "abc" });
|
||||||
|
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.post("/exec")
|
.post("/exec")
|
||||||
.send({
|
.send({
|
||||||
code: "return context.getCredential('svc');",
|
code: "return context.getCredential('svc');",
|
||||||
credentials: { svc: { token: "abc" } },
|
credentials: { svc: credentialId },
|
||||||
})
|
})
|
||||||
.expect(201);
|
.expect(201);
|
||||||
expect(res.body).toEqual({ result: { token: "abc" } });
|
expect(res.body).toEqual({ result: { token: "abc" } });
|
||||||
|
|||||||
@@ -160,7 +160,9 @@ describe("EnvironmentController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 for unknown id", async () => {
|
it("returns 404 for unknown id", async () => {
|
||||||
await request(app.getHttpServer()).get("/environments/00000000-0000-0000-0000-000000000001").expect(404);
|
await request(app.getHttpServer())
|
||||||
|
.get("/environments/00000000-0000-0000-0000-000000000001")
|
||||||
|
.expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 for non-numeric id", async () => {
|
it("returns 400 for non-numeric id", async () => {
|
||||||
|
|||||||
@@ -75,7 +75,10 @@ describe("McpController", () => {
|
|||||||
it("returns MCP error for removed tool", async () => {
|
it("returns MCP error for removed tool", async () => {
|
||||||
const { status, rpc } = await mcpCall("list_keys");
|
const { status, rpc } = await mcpCall("list_keys");
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
const result = rpc.result as { isError: boolean; content?: { text: string }[] };
|
const result = rpc.result as {
|
||||||
|
isError: boolean;
|
||||||
|
content?: { text: string }[];
|
||||||
|
};
|
||||||
expect(result.isError).toBe(true);
|
expect(result.isError).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -163,7 +163,9 @@ describe("ScenarioController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 for unknown id", async () => {
|
it("returns 404 for unknown id", async () => {
|
||||||
await request(app.getHttpServer()).get("/scenarios/00000000-0000-0000-0000-000000000001").expect(404);
|
await request(app.getHttpServer())
|
||||||
|
.get("/scenarios/00000000-0000-0000-0000-000000000001")
|
||||||
|
.expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,7 +201,9 @@ describe("ScenarioController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 for unknown id", async () => {
|
it("returns 404 for unknown id", async () => {
|
||||||
await request(app.getHttpServer()).delete("/scenarios/00000000-0000-0000-0000-000000000001").expect(404);
|
await request(app.getHttpServer())
|
||||||
|
.delete("/scenarios/00000000-0000-0000-0000-000000000001")
|
||||||
|
.expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -327,8 +331,8 @@ describe("ScenarioController", () => {
|
|||||||
it("reorders by exact target index", async () => {
|
it("reorders by exact target index", async () => {
|
||||||
const sc = await createScenario();
|
const sc = await createScenario();
|
||||||
const stepA = await createStep(sc.id, { title: "A" });
|
const stepA = await createStep(sc.id, { title: "A" });
|
||||||
const stepB = await createStep(sc.id, { title: "B" });
|
await createStep(sc.id, { title: "B" });
|
||||||
const stepC = await createStep(sc.id, { title: "C" });
|
await createStep(sc.id, { title: "C" });
|
||||||
const stepD = await createStep(sc.id, { title: "D" });
|
const stepD = await createStep(sc.id, { title: "D" });
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
@@ -339,12 +343,15 @@ describe("ScenarioController", () => {
|
|||||||
let res = await request(app.getHttpServer())
|
let res = await request(app.getHttpServer())
|
||||||
.get(`/scenarios/${sc.id}`)
|
.get(`/scenarios/${sc.id}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(
|
expect(res.body.steps.map((s: { title: string }) => s.title)).toEqual([
|
||||||
res.body.steps.map((s: { title: string }) => s.title),
|
"B",
|
||||||
).toEqual(["B", "C", "A", "D"]);
|
"C",
|
||||||
expect(
|
"A",
|
||||||
res.body.steps.map((s: { order: number }) => s.order),
|
"D",
|
||||||
).toEqual([0, 1, 2, 3]);
|
]);
|
||||||
|
expect(res.body.steps.map((s: { order: number }) => s.order)).toEqual([
|
||||||
|
0, 1, 2, 3,
|
||||||
|
]);
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.patch(`/scenarios/${sc.id}/steps/${stepD.id}`)
|
.patch(`/scenarios/${sc.id}/steps/${stepD.id}`)
|
||||||
@@ -354,12 +361,15 @@ describe("ScenarioController", () => {
|
|||||||
res = await request(app.getHttpServer())
|
res = await request(app.getHttpServer())
|
||||||
.get(`/scenarios/${sc.id}`)
|
.get(`/scenarios/${sc.id}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(
|
expect(res.body.steps.map((s: { title: string }) => s.title)).toEqual([
|
||||||
res.body.steps.map((s: { title: string }) => s.title),
|
"D",
|
||||||
).toEqual(["D", "B", "C", "A"]);
|
"B",
|
||||||
expect(
|
"C",
|
||||||
res.body.steps.map((s: { order: number }) => s.order),
|
"A",
|
||||||
).toEqual([0, 1, 2, 3]);
|
]);
|
||||||
|
expect(res.body.steps.map((s: { order: number }) => s.order)).toEqual([
|
||||||
|
0, 1, 2, 3,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -395,9 +405,7 @@ describe("ScenarioController", () => {
|
|||||||
expect(Array.isArray(res.stepRuns)).toBe(true);
|
expect(Array.isArray(res.stepRuns)).toBe(true);
|
||||||
expect(res.stepRuns).toHaveLength(3);
|
expect(res.stepRuns).toHaveLength(3);
|
||||||
|
|
||||||
const statuses = res.stepRuns.map(
|
const statuses = res.stepRuns.map((s: { status: string }) => s.status);
|
||||||
(s: { status: string }) => s.status,
|
|
||||||
);
|
|
||||||
expect(statuses[0]).toBe("pending");
|
expect(statuses[0]).toBe("pending");
|
||||||
expect(statuses[1]).toBe("waiting");
|
expect(statuses[1]).toBe("waiting");
|
||||||
expect(statuses[2]).toBe("waiting");
|
expect(statuses[2]).toBe("waiting");
|
||||||
@@ -495,9 +503,18 @@ describe("ScenarioController", () => {
|
|||||||
|
|
||||||
it("exports steps ordered by sequential position", async () => {
|
it("exports steps ordered by sequential position", async () => {
|
||||||
const sc = await createScenario("export-order");
|
const sc = await createScenario("export-order");
|
||||||
await createStep(sc.id, { sessionName: "s", execCode: "return 'first';" });
|
await createStep(sc.id, {
|
||||||
await createStep(sc.id, { sessionName: "s", execCode: "return 'second';" });
|
sessionName: "s",
|
||||||
await createStep(sc.id, { sessionName: "s", execCode: "return 'third';" });
|
execCode: "return 'first';",
|
||||||
|
});
|
||||||
|
await createStep(sc.id, {
|
||||||
|
sessionName: "s",
|
||||||
|
execCode: "return 'second';",
|
||||||
|
});
|
||||||
|
await createStep(sc.id, {
|
||||||
|
sessionName: "s",
|
||||||
|
execCode: "return 'third';",
|
||||||
|
});
|
||||||
|
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.get(`/scenarios/${sc.id}/export`)
|
.get(`/scenarios/${sc.id}/export`)
|
||||||
@@ -616,9 +633,7 @@ describe("ScenarioController", () => {
|
|||||||
|
|
||||||
expect(importRes.body.name).toBe("roundtrip");
|
expect(importRes.body.name).toBe("roundtrip");
|
||||||
expect(importRes.body.steps).toHaveLength(1);
|
expect(importRes.body.steps).toHaveLength(1);
|
||||||
expect(importRes.body.steps[0].execCode).toBe(
|
expect(importRes.body.steps[0].execCode).toBe(exported.steps[0].execCode);
|
||||||
exported.steps[0].execCode,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("imports with empty steps array", async () => {
|
it("imports with empty steps array", async () => {
|
||||||
@@ -663,7 +678,7 @@ describe("ScenarioController", () => {
|
|||||||
const sc = await createScenario();
|
const sc = await createScenario();
|
||||||
await createStep(sc.id, { order: 0 });
|
await createStep(sc.id, { order: 0 });
|
||||||
const runRes = await createRun(sc.id);
|
const runRes = await createRun(sc.id);
|
||||||
const runId = runRes.id;
|
const runId = runRes.id;
|
||||||
|
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.get(`/scenarios/${sc.id}/run/${runId}`)
|
.get(`/scenarios/${sc.id}/run/${runId}`)
|
||||||
@@ -766,7 +781,9 @@ describe("ScenarioController", () => {
|
|||||||
it("returns 404 for unknown run", async () => {
|
it("returns 404 for unknown run", async () => {
|
||||||
const sc = await createScenario();
|
const sc = await createScenario();
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post(`/scenarios/${sc.id}/run/00000000-0000-0000-0000-000000000001/wait`)
|
.post(
|
||||||
|
`/scenarios/${sc.id}/run/00000000-0000-0000-0000-000000000001/wait`,
|
||||||
|
)
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,9 @@ describe("SessionController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 for unknown id", async () => {
|
it("returns 404 for unknown id", async () => {
|
||||||
await request(app.getHttpServer()).delete("/sessions/00000000-0000-0000-0000-000000000001").expect(404);
|
await request(app.getHttpServer())
|
||||||
|
.delete("/sessions/00000000-0000-0000-0000-000000000001")
|
||||||
|
.expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 for non-numeric id", async () => {
|
it("returns 400 for non-numeric id", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user