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
+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);
};
}
+10 -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>
<ToastProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</ToastProvider>
<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}` },
]}
/>
@@ -107,12 +120,12 @@ export function EnvironmentDetailPage() {
<div className={styles.stepsSection}>
<h2 className={styles.sectionHeading}>{t('environments.section_data')}</h2>
<Card>
{dataEntries.length === 0 ? (
<p className={styles.muted}>{t('environments.no_data')}</p>
) : (
<DescriptionList
layout="grid"
items={dataEntries.map(([key, value]) => ({
{dataEntries.length === 0 ? (
<p className={styles.muted}>{t('environments.no_data')}</p>
) : (
<DescriptionList
layout="grid"
items={dataEntries.map(([key, value]) => ({
term: key,
detail: (
<a href={value} target="_blank" rel="noopener noreferrer">
@@ -120,30 +133,21 @@ export function EnvironmentDetailPage() {
</a>
),
}))}
/>
)}
</Card>
/>
)}
</Card>
</div>
</>
)}
<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]}
+61 -17
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' }}>
@@ -250,15 +261,15 @@ export function RunDetailPage() {
</div>
{isExpanded && s.output && (
<div style={{ marginLeft: '24px' }}>
<CodeBlock
<CodeBlock
code={(() => {
try {
return JSON.stringify(JSON.parse(s.output), null, 2);
} catch {
return s.output;
}
})()}
language="json"
})()}
language="json"
/>
</div>
)}
@@ -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>
);
+21 -19
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,16 +84,17 @@ 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 && (() => {
const { preview, truncated } = firstParagraphBlocks(snippet.description, 2);
if (!preview) return null;
return (
<>
<MarkdownContent content={preview} />
{truncated && <p className={styles.muted}>...</p>}
</>
);
})()}
{snippet.description &&
(() => {
const { preview, truncated } = firstParagraphBlocks(snippet.description, 2);
if (!preview) return null;
return (
<>
<MarkdownContent content={preview} />
{truncated && <p className={styles.muted}>...</p>}
</>
);
})()}
</Card>
);
}
@@ -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>}
+34 -45
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,33 +31,38 @@ 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,
),
);
window.setTimeout(() => remove(id), EXIT_ANIMATION_MS);
}, [remove]);
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],
);
const show = useCallback((message: string, variant: ToastVariant = 'info') => {
const now = Date.now();
const dedupeKey = `${variant}:${message}`;
const previousTs = lastShownAtRef.current.get(dedupeKey) ?? 0;
if (now - previousTs < 600) return;
lastShownAtRef.current.set(dedupeKey, now);
const show = useCallback(
(message: string, variant: ToastVariant = 'info') => {
const now = Date.now();
const dedupeKey = `${variant}:${message}`;
const previousTs = lastShownAtRef.current.get(dedupeKey) ?? 0;
if (now - previousTs < 600) return;
lastShownAtRef.current.set(dedupeKey, now);
const id = Date.now() + Math.floor(Math.random() * 1000);
setItems((prev) => [...prev, { id, message, variant, closing: false }]);
window.setTimeout(() => dismiss(id), durationMs);
}, [dismiss, durationMs]);
const id = Date.now() + Math.floor(Math.random() * 1000);
setItems((prev) => [...prev, { id, message, variant, closing: false }]);
window.setTimeout(() => dismiss(id), durationMs);
},
[dismiss, durationMs],
);
const api = useMemo<ToastApi>(() => ({
show,
success: (message: string) => show(message, 'success'),
error: (message: string) => show(message, 'error'),
info: (message: string) => show(message, 'info'),
}), [show]);
const api = useMemo<ToastApi>(
() => ({
show,
success: (message: string) => show(message, 'success'),
error: (message: string) => show(message, 'error'),
info: (message: string) => show(message, 'info'),
}),
[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"}