diff --git a/client/src/App.tsx b/client/src/App.tsx index 022988f..e5f5eee 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -8,6 +8,7 @@ import { KeysPage } from './pages/KeysPage'; import { SessionsPage } from './pages/SessionsPage'; import { SessionDetailPage } from './pages/SessionDetailPage'; import { ScenariosPage } from './pages/ScenariosPage'; +import { ScenarioDetailPage } from './pages/ScenarioDetailPage'; import { NavLink, Navigate, Route, Routes } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Globe, KeyRound, Monitor, ClipboardList } from 'lucide-react'; @@ -61,6 +62,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 2effa9e..8828468 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -3,6 +3,7 @@ "not_found": "Not found", "not_found_session": "Session {{id}} does not exist or has been deleted.", "not_found_environment": "Environment {{id}} does not exist or has been deleted.", + "not_found_scenario": "Scenario {{id}} does not exist or has been deleted.", "generic": "Something went wrong. Please try again." }, "nav": { @@ -70,8 +71,19 @@ "col_name": "Name", "col_updated": "Updated", "empty": "No scenarios yet.", + "loading": "Loading…", "action_run": "Run", - "action_delete": "Delete" + "action_delete": "Delete", + "field_id": "ID", + "field_name": "Name", + "field_steps": "Steps", + "field_created": "Created", + "field_updated": "Updated", + "steps_heading": "Steps", + "step_order": "#", + "step_type": "Type", + "step_session": "Session", + "step_updated": "Updated" }, "theme": { "switch_to_light": "Switch to light theme", diff --git a/client/src/pages/CreateEnvironmentPage.tsx b/client/src/pages/CreateEnvironmentPage.tsx index faad86e..ff9947e 100644 --- a/client/src/pages/CreateEnvironmentPage.tsx +++ b/client/src/pages/CreateEnvironmentPage.tsx @@ -91,11 +91,7 @@ export function CreateEnvironmentPage() {
- @@ -68,6 +72,7 @@ export function EnvironmentDetailPage() { <>
{t('environments.no_urls')}

) : ( v) .map(([key, value]) => ({ diff --git a/client/src/pages/EnvironmentsPage.tsx b/client/src/pages/EnvironmentsPage.tsx index 1f701cc..b52aab1 100644 --- a/client/src/pages/EnvironmentsPage.tsx +++ b/client/src/pages/EnvironmentsPage.tsx @@ -15,32 +15,34 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: n const header = (
{env.name} -
e.stopPropagation()}> - - - } - items={[ - { - label: t('environments.action_view'), - icon: , - onClick: () => navigate(`/environments/${env.id}`), - }, - { - label: t('environments.action_edit'), - icon: , - onClick: () => navigate(`/environments/${env.id}/edit`), - }, - { - label: t('environments.action_delete'), - icon: , - variant: 'danger', - onClick: () => onDelete(env.id), - }, - ]} - />
+
e.stopPropagation()}> + + + + } + items={[ + { + label: t('environments.action_view'), + icon: , + onClick: () => navigate(`/environments/${env.id}`), + }, + { + label: t('environments.action_edit'), + icon: , + onClick: () => navigate(`/environments/${env.id}/edit`), + }, + { + label: t('environments.action_delete'), + icon: , + variant: 'danger', + onClick: () => onDelete(env.id), + }, + ]} + /> +
); @@ -60,7 +62,11 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: n onClick={() => navigate(`/environments/${env.id}`)} > {urlEntries.length > 0 && ( - ({ term: key, detail: value }))} /> + ({ term: key, detail: value }))} + /> )} {urlEntries.length === 0 && (

{t('environments.no_urls')}

diff --git a/client/src/pages/Page.module.css b/client/src/pages/Page.module.css index 95c51c6..a2b9f1e 100644 --- a/client/src/pages/Page.module.css +++ b/client/src/pages/Page.module.css @@ -5,6 +5,17 @@ color: var(--color-text); } +.sectionHeading { + margin: var(--space-6) 0 var(--space-3); + font-size: var(--font-size-md); + font-weight: 600; + color: var(--color-text); +} + +.stepsSection { + margin-top: var(--space-2); +} + .breadcrumbs { margin-bottom: var(--space-4); } diff --git a/client/src/pages/ScenarioDetailPage.tsx b/client/src/pages/ScenarioDetailPage.tsx new file mode 100644 index 0000000..4ccd90e --- /dev/null +++ b/client/src/pages/ScenarioDetailPage.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Play, Trash2 } from 'lucide-react'; +import { scenarios } from '../api'; +import type { Scenario, ScenarioStep } from '../api'; +import { + Badge, + Breadcrumbs, + Button, + Card, + DescriptionList, + Notification, + Table, + Timestamp, + type TableColumn, +} from '../ui'; +import styles from './Page.module.css'; + +const STEP_TYPE_VARIANT: Record = { + login: 'success', + exec: 'info', + sign: 'warning', +}; + +export function ScenarioDetailPage() { + const { t } = useTranslation(); + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [scenario, setScenario] = useState<(Scenario & { steps: ScenarioStep[] }) | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!id) return; + scenarios + .get(Number(id)) + .then(setScenario) + .catch((err: Error) => setError(err.message)) + .finally(() => setLoading(false)); + }, [id]); + + const handleRun = async () => { + if (!scenario) return; + await scenarios.run(scenario.id); + }; + + const handleDelete = async () => { + if (!scenario) return; + await scenarios.remove(scenario.id); + navigate('/scenarios'); + }; + + const stepColumns: TableColumn[] = [ + { key: 'order', header: t('scenarios.step_order'), render: (s) => s.order, width: 60 }, + { + key: 'type', + header: t('scenarios.step_type'), + width: 90, + render: (s) => {s.type}, + }, + { key: 'session', header: t('scenarios.step_session'), render: (s) => s.sessionName }, + { + key: 'updated', + header: t('scenarios.step_updated'), + width: 140, + render: (s) => , + }, + ]; + + return ( +
+
+ navigate('/scenarios') }, + { label: scenario?.name ?? `#${id}` }, + ]} + /> + {scenario && ( +
+ + +
+ )} +
+ + {error && ( + + {error.startsWith('404') ? t('errors.not_found_scenario', { id }) : error} + + )} + {loading &&

{t('scenarios.loading')}

} + + {scenario && ( + <> + + , + }, + { + term: t('scenarios.field_updated'), + detail: , + }, + ]} + /> + + + {scenario.steps.length > 0 && ( +
+

{t('scenarios.steps_heading')}

+ s.id} + loading={false} + emptyMessage="" + /> + + )} + + )} + + ); +} diff --git a/client/src/pages/ScenariosPage.tsx b/client/src/pages/ScenariosPage.tsx index 770dda3..306c071 100644 --- a/client/src/pages/ScenariosPage.tsx +++ b/client/src/pages/ScenariosPage.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { Play, Trash2 } from 'lucide-react'; import { scenarios } from '../api'; import type { Scenario } from '../api'; import { Breadcrumbs, Button, Table, type TableColumn, Timestamp } from '../ui'; @@ -7,6 +9,7 @@ import styles from './Page.module.css'; export function ScenariosPage() { const { t } = useTranslation(); + const navigate = useNavigate(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -45,15 +48,20 @@ export function ScenariosPage() { { key: 'actions', header: '', - width: 140, + width: 100, align: 'right', render: (s) => ( - - ), @@ -72,6 +80,7 @@ export function ScenariosPage() { emptyMessage={t('scenarios.empty')} pageSize={10} pageSizeOptions={[10, 25, 50]} + onRowClick={(s) => navigate(`/scenarios/${s.id}`)} /> ); diff --git a/client/src/pages/SessionDetailPage.tsx b/client/src/pages/SessionDetailPage.tsx index e473400..6789ee2 100644 --- a/client/src/pages/SessionDetailPage.tsx +++ b/client/src/pages/SessionDetailPage.tsx @@ -60,6 +60,7 @@ export function SessionDetailPage() { {session && ( ), }, - { term: t('sessions.field_token'), detail: {session.token} }, + { + term: t('sessions.field_token'), + detail: {session.token}, + }, { term: t('sessions.field_lastUsed'), detail: session.lastUsedAt ? : '—', diff --git a/client/src/pages/SessionsPage.tsx b/client/src/pages/SessionsPage.tsx index a8a6a20..1bf58de 100644 --- a/client/src/pages/SessionsPage.tsx +++ b/client/src/pages/SessionsPage.tsx @@ -39,9 +39,7 @@ export function SessionsPage() { key: 'status', header: t('sessions.col_status'), width: 100, - render: (s) => ( - {s.status} - ), + render: (s) => {s.status}, }, { key: 'lastUsed', diff --git a/client/src/ui/DescriptionList/DescriptionList.module.css b/client/src/ui/DescriptionList/DescriptionList.module.css index 0aedb33..ba1e0c8 100644 --- a/client/src/ui/DescriptionList/DescriptionList.module.css +++ b/client/src/ui/DescriptionList/DescriptionList.module.css @@ -45,3 +45,35 @@ max-width: 100%; } +/* ── Layout modifiers ───────────────────────────────────────── */ + +.inline .row { + flex-direction: row; + align-items: baseline; + gap: var(--space-3); +} + +.inline .term { + min-width: 120px; +} + +.comfortable { + gap: 0; +} + +.comfortable .row { + padding: var(--space-4) 0; +} + +.comfortable .row:first-child { + padding-top: 0; +} + +.comfortable .term { + margin-bottom: var(--space-1); +} + +.comfortable .detail { + font-size: var(--font-size-md); +} + diff --git a/client/src/ui/DescriptionList/DescriptionList.tsx b/client/src/ui/DescriptionList/DescriptionList.tsx index 1cf8c31..45c532c 100644 --- a/client/src/ui/DescriptionList/DescriptionList.tsx +++ b/client/src/ui/DescriptionList/DescriptionList.tsx @@ -15,14 +15,19 @@ export interface DescriptionListItem { export interface DescriptionListProps { items: DescriptionListItem[]; - /** 'compact' stacks term above detail; 'inline' places them side by side (default) */ - layout?: 'inline' | 'compact'; + /** 'compact' stacks term above detail; 'inline' places them side by side; 'comfortable' adds generous spacing with dividers (default) */ + layout?: 'inline' | 'compact' | 'comfortable'; /** Truncate detail values with ellipsis; full value shown in a tooltip on hover */ truncate?: boolean; className?: string; } -export function DescriptionList({ items, layout = 'inline', truncate, className }: DescriptionListProps) { +export function DescriptionList({ + items, + layout = 'inline', + truncate, + className, +}: DescriptionListProps) { return (
{items.map((item, i) => ( diff --git a/client/src/ui/Notification/Notification.tsx b/client/src/ui/Notification/Notification.tsx index 9caba60..02db21c 100644 --- a/client/src/ui/Notification/Notification.tsx +++ b/client/src/ui/Notification/Notification.tsx @@ -11,19 +11,14 @@ export interface NotificationProps { } const ICONS: Record = { - info: , + info: , success: , - error: , + error: , warning: , - note: , + note: , }; -export function Notification({ - variant = 'info', - title, - children, - className, -}: NotificationProps) { +export function Notification({ variant = 'info', title, children, className }: NotificationProps) { return (
{ICONS[variant]} diff --git a/client/src/ui/Table/Table.tsx b/client/src/ui/Table/Table.tsx index da80138..3d520c7 100644 --- a/client/src/ui/Table/Table.tsx +++ b/client/src/ui/Table/Table.tsx @@ -82,7 +82,9 @@ export function Table({ visibleData.map((row) => (
onRowClick(row) : undefined} > {columns.map((col) => (