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