feat(table,runs,sessions): add server-side sorting and pagination

- add sortable column support to Table with asc/desc/unsorted icons
- active sort icon uses --color-primary; no header background change
- Table supports server-side mode via total/page/onPageChange props
- wire server-side sort+pagination in ScenariosPage, AllRunsPage, RunsPage, SessionsPage
- remove steps count column from run tables
- fix server: RunsQueryDto now extends PaginationQueryDto with orderBy/orderDir
- fix findAllRuns to use QueryBuilder; sort by scenario.name via JOIN
- add per-resource typed query DTOs with @IsIn allowlist on orderBy
- prevents SQL injection and returns 400 for unknown orderBy values
This commit is contained in:
2026-04-15 12:18:47 +03:00
parent f268d0e3eb
commit ad155e269c
20 changed files with 456 additions and 125 deletions
+15 -6
View File
@@ -162,8 +162,8 @@ export const environments = {
// ── Sessions ────────────────────────────────────────────────────────────────── // ── Sessions ──────────────────────────────────────────────────────────────────
export const sessions = { export const sessions = {
list(page = 1, limit = 50): Promise<PaginatedResponse<Session>> { list(page = 1, limit = 50, orderBy = 'id', orderDir: 'ASC' | 'DESC' = 'DESC'): Promise<PaginatedResponse<Session>> {
return request(`/sessions?page=${page}&limit=${limit}`); return request(`/sessions?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`);
}, },
get(id: string): Promise<Session> { get(id: string): Promise<Session> {
return request(`/sessions/${id}`); return request(`/sessions/${id}`);
@@ -176,8 +176,13 @@ export const sessions = {
// ── Scenarios ───────────────────────────────────────────────────────────────── // ── Scenarios ─────────────────────────────────────────────────────────────────
export const scenarios = { export const scenarios = {
list(page = 1, limit = 50): Promise<PaginatedResponse<Scenario>> { list(
return request(`/scenarios?page=${page}&limit=${limit}&orderBy=updatedAt&orderDir=DESC`); page = 1,
limit = 50,
orderBy = 'updatedAt',
orderDir: 'ASC' | 'DESC' = 'DESC',
): Promise<PaginatedResponse<Scenario>> {
return request(`/scenarios?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`);
}, },
get(id: string): Promise<Scenario & { steps: ScenarioStep[] }> { get(id: string): Promise<Scenario & { steps: ScenarioStep[] }> {
return request(`/scenarios/${id}`); return request(`/scenarios/${id}`);
@@ -245,12 +250,14 @@ export const runs = {
page = 1, page = 1,
limit = 20, limit = 20,
status?: string, status?: string,
orderBy = 'createdAt',
orderDir: 'ASC' | 'DESC' = 'DESC',
): Promise< ): Promise<
PaginatedResponse< PaginatedResponse<
ScenarioRun & { stepRuns: ScenarioRunStep[]; scenario: { id: string; name: string } } ScenarioRun & { stepRuns: ScenarioRunStep[]; scenario: { id: string; name: string } }
> >
> { > {
const q = new URLSearchParams({ page: String(page), limit: String(limit) }); const q = new URLSearchParams({ page: String(page), limit: String(limit), orderBy, orderDir });
if (status) q.set('status', status); if (status) q.set('status', status);
return request(`/scenarios/runs?${q}`); return request(`/scenarios/runs?${q}`);
}, },
@@ -258,8 +265,10 @@ export const runs = {
scenarioId: string, scenarioId: string,
page = 1, page = 1,
limit = 20, limit = 20,
orderBy = 'createdAt',
orderDir: 'ASC' | 'DESC' = 'DESC',
): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> { ): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`); return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}&orderBy=${orderBy}&orderDir=${orderDir}`);
}, },
get(scenarioId: string, runId: string, q?: string): Promise<ScenarioRunDetail> { get(scenarioId: string, runId: string, q?: string): Promise<ScenarioRunDetail> {
const qs = q?.trim() ? `?q=${encodeURIComponent(q.trim())}` : ''; const qs = q?.trim() ? `?q=${encodeURIComponent(q.trim())}` : '';
+42 -18
View File
@@ -1,9 +1,10 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Activity } from 'lucide-react'; import { Activity } from 'lucide-react';
import { runs } from '../../api'; import { runs } from '../../api';
import type { ScenarioRun, ScenarioRunStep, ScenarioRunStatus } from '../../api'; import type { ScenarioRun, ScenarioRunStatus } from '../../api';
import { import {
AutoRefreshIndicator, AutoRefreshIndicator,
Badge, Badge,
@@ -12,6 +13,7 @@ import {
Timestamp, Timestamp,
UuidBadge, UuidBadge,
type TableColumn, type TableColumn,
type SortDirection,
} from '../../ui'; } from '../../ui';
import type { BadgeVariant } from '../../ui'; import type { BadgeVariant } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
@@ -31,7 +33,6 @@ const STATUS_LABEL: Record<ScenarioRunStatus, string> = {
}; };
type AllRunRow = ScenarioRun & { type AllRunRow = ScenarioRun & {
stepRuns: ScenarioRunStep[];
scenario: { id: number; name: string }; scenario: { id: number; name: string };
}; };
@@ -39,26 +40,46 @@ export function AllRunsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [sortBy, setSortBy] = useState('createdAt');
const [sortDir, setSortDir] = useState<SortDirection>('desc');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const { const {
data: items = [], data,
isLoading, isLoading,
error, error,
refetch, refetch,
} = useQuery<AllRunRow[]>({ } = useQuery({
queryKey: ['runs'], queryKey: ['runs', sortBy, sortDir, page, pageSize],
queryFn: async () => { queryFn: () =>
const res = await runs.listAll(); runs.listAll(page, pageSize, undefined, sortBy, sortDir === 'asc' ? 'ASC' : 'DESC'),
return res.data as AllRunRow[];
},
refetchInterval: 10_000, refetchInterval: 10_000,
staleTime: 0, staleTime: 0,
}); });
const items = (data?.data ?? []) as AllRunRow[];
const total = data?.total ?? 0;
const handleSort = (key: string, dir: SortDirection) => {
setSortBy(key);
setSortDir(dir);
setPage(1);
};
const handlePageChange = (p: number) => setPage(p);
const handlePageSizeChange = (size: number) => {
setPageSize(size);
setPage(1);
};
const columns: TableColumn<AllRunRow>[] = [ const columns: TableColumn<AllRunRow>[] = [
{ key: 'id', header: t('runs.col_id'), render: (r) => <UuidBadge id={r.id} />, width: 60 }, { key: 'id', header: t('runs.col_id'), render: (r) => <UuidBadge id={r.id} />, width: 60 },
{ {
key: 'scenario', key: 'scenario.name',
header: t('runs.col_scenario'), header: t('runs.col_scenario'),
sortable: true,
render: (r) => ( render: (r) => (
<span <span
className={styles.linkCell} className={styles.linkCell}
@@ -75,18 +96,14 @@ export function AllRunsPage() {
key: 'status', key: 'status',
header: t('runs.col_status'), header: t('runs.col_status'),
width: 110, width: 110,
sortable: true,
render: (r) => <Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>, render: (r) => <Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>,
}, },
{ {
key: 'steps', key: 'createdAt',
header: t('runs.col_steps'),
width: 80,
render: (r) => r.stepRuns.length,
},
{
key: 'created',
header: t('runs.col_created'), header: t('runs.col_created'),
width: 160, width: 160,
sortable: true,
render: (r) => <Timestamp value={r.createdAt} />, render: (r) => <Timestamp value={r.createdAt} />,
}, },
]; ];
@@ -109,9 +126,16 @@ export function AllRunsPage() {
rowKey={(r) => r.id} rowKey={(r) => r.id}
loading={isLoading} loading={isLoading}
emptyMessage={t('runs.empty')} emptyMessage={t('runs.empty')}
pageSize={20} pageSize={pageSize}
pageSizeOptions={[10, 20, 50]} pageSizeOptions={[10, 20, 50]}
onRowClick={(r) => navigate(`/scenarios/${r.scenarioId}/runs/${r.id}`)} onRowClick={(r) => navigate(`/scenarios/${r.scenarioId}/runs/${r.id}`)}
sortBy={sortBy}
sortDir={sortDir}
onSort={handleSort}
total={total}
page={page}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/> />
</div> </div>
); );
+66 -18
View File
@@ -7,7 +7,6 @@ import type {
Environment, Environment,
Scenario, Scenario,
ScenarioRun, ScenarioRun,
ScenarioRunStep,
ScenarioRunStatus, ScenarioRunStatus,
} from '../../api'; } from '../../api';
import { import {
@@ -22,6 +21,7 @@ import {
UuidBadge, UuidBadge,
useToast, useToast,
type TableColumn, type TableColumn,
type SortDirection,
} from '../../ui'; } from '../../ui';
import type { BadgeVariant } from '../../ui'; import type { BadgeVariant } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
@@ -40,7 +40,7 @@ const STATUS_LABEL: Record<ScenarioRunStatus, string> = {
fail: 'Fail', fail: 'Fail',
}; };
type RunRow = ScenarioRun & { stepRuns: ScenarioRunStep[] }; type RunRow = ScenarioRun;
export function RunsPage() { export function RunsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -60,29 +60,74 @@ export function RunsPage() {
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const hasDataRef = useRef(false); const hasDataRef = useRef(false);
const load = useCallback(() => { const [sortBy, setSortBy] = useState('createdAt');
if (!id) return; const [sortDir, setSortDir] = useState<SortDirection>('desc');
if (!hasDataRef.current) setLoading(true); const [page, setPage] = useState(1);
Promise.all([scenarios.get(id!), runs.list(id!)]) const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
// Keep refs so the poll interval always reads current values
const sortByRef = useRef(sortBy);
const sortDirRef = useRef(sortDir);
const pageRef = useRef(page);
const pageSizeRef = useRef(pageSize);
sortByRef.current = sortBy;
sortDirRef.current = sortDir;
pageRef.current = page;
pageSizeRef.current = pageSize;
const load = useCallback(
(orderBy: string, orderDir: SortDirection, p: number, limit: number) => {
if (!id) return;
if (!hasDataRef.current) setLoading(true);
Promise.all([
scenarios.get(id!),
runs.list(id!, p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC'),
])
.then(([sc, res]) => { .then(([sc, res]) => {
setScenario(sc); setScenario(sc);
setItems(res.data); setItems(res.data);
setTotal(res.total);
setError(null); setError(null);
setPulseKey((k) => k + 1); setPulseKey((k) => k + 1);
hasDataRef.current = true; hasDataRef.current = true;
}) })
.catch((err: Error) => setError(err.message)) .catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); },
[id],
);
useEffect(() => { useEffect(() => {
load(); load('createdAt', 'desc', 1, 20);
pollRef.current = setInterval(load, 10_000); pollRef.current = setInterval(
() => load(sortByRef.current, sortDirRef.current, pageRef.current, pageSizeRef.current),
10_000,
);
return () => { return () => {
if (pollRef.current) clearInterval(pollRef.current); if (pollRef.current) clearInterval(pollRef.current);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [load]); }, [load]);
const handleSort = (key: string, dir: SortDirection) => {
setSortBy(key);
setSortDir(dir);
setPage(1);
load(key, dir, 1, pageSize);
};
const handlePageChange = (p: number) => {
setPage(p);
load(sortBy, sortDir, p, pageSize);
};
const handlePageSizeChange = (size: number) => {
setPageSize(size);
setPage(1);
load(sortBy, sortDir, 1, size);
};
useEffect(() => { useEffect(() => {
environments environments
.list(1, 200) .list(1, 200)
@@ -117,18 +162,14 @@ export function RunsPage() {
key: 'status', key: 'status',
header: t('runs.col_status'), header: t('runs.col_status'),
width: 100, width: 100,
sortable: true,
render: (r) => <Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>, render: (r) => <Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>,
}, },
{ {
key: 'steps', key: 'createdAt',
header: t('runs.col_steps'),
width: 80,
render: (r) => r.stepRuns.length,
},
{
key: 'created',
header: t('runs.col_created'), header: t('runs.col_created'),
width: 160, width: 160,
sortable: true,
render: (r) => <Timestamp value={r.createdAt} />, render: (r) => <Timestamp value={r.createdAt} />,
}, },
]; ];
@@ -151,7 +192,7 @@ export function RunsPage() {
]} ]}
/> />
<div className={styles.toolbarActions}> <div className={styles.toolbarActions}>
<AutoRefreshIndicator active pulseKey={pulseKey} error={!!error} onClick={load} /> <AutoRefreshIndicator active pulseKey={pulseKey} error={!!error} onClick={() => load(sortBy, sortDir, page, pageSize)} />
<Button size="sm" onClick={() => setRunModalOpen(true)}> <Button size="sm" onClick={() => setRunModalOpen(true)}>
<Play size={14} /> <Play size={14} />
{t('runs.action_run')} {t('runs.action_run')}
@@ -165,9 +206,16 @@ export function RunsPage() {
rowKey={(r) => r.id} rowKey={(r) => r.id}
loading={loading} loading={loading}
emptyMessage={t('runs.empty')} emptyMessage={t('runs.empty')}
pageSize={20} pageSize={pageSize}
pageSizeOptions={[10, 20, 50]} pageSizeOptions={[10, 20, 50]}
onRowClick={(r) => navigate(`/scenarios/${id}/runs/${r.id}`)} onRowClick={(r) => navigate(`/scenarios/${id}/runs/${r.id}`)}
sortBy={sortBy}
sortDir={sortDir}
onSort={handleSort}
total={total}
page={page}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/> />
<Modal <Modal
+47 -8
View File
@@ -12,6 +12,7 @@ import {
Select, Select,
Table, Table,
type TableColumn, type TableColumn,
type SortDirection,
Timestamp, Timestamp,
UuidBadge, UuidBadge,
useToast, useToast,
@@ -34,10 +35,22 @@ export function ScenariosPage() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const load = useCallback(() => { const [sortBy, setSortBy] = useState('updatedAt');
Promise.all([scenarios.list(), environments.list(1, 200)]) const [sortDir, setSortDir] = useState<SortDirection>('desc');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const load = useCallback(
(orderBy: string, orderDir: SortDirection, p: number, limit: number) => {
setLoading(true);
Promise.all([
scenarios.list(p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC'),
environments.list(1, 200),
])
.then(([scenarioRes, envRes]) => { .then(([scenarioRes, envRes]) => {
setItems(scenarioRes?.data ?? []); setItems(scenarioRes?.data ?? []);
setTotal(scenarioRes?.total ?? 0);
setEnvs(envRes?.data ?? []); setEnvs(envRes?.data ?? []);
}) })
.catch((err) => { .catch((err) => {
@@ -47,9 +60,28 @@ export function ScenariosPage() {
}, [toast]); }, [toast]);
useEffect(() => { useEffect(() => {
load(); load('updatedAt', 'desc', 1, 10);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [load]); }, [load]);
const handleSort = (key: string, dir: SortDirection) => {
setSortBy(key);
setSortDir(dir);
setPage(1);
load(key, dir, 1, pageSize);
};
const handlePageChange = (p: number) => {
setPage(p);
load(sortBy, sortDir, p, pageSize);
};
const handlePageSizeChange = (size: number) => {
setPageSize(size);
setPage(1);
load(sortBy, sortDir, 1, size);
};
const openRunModal = (id: string) => { const openRunModal = (id: string) => {
setRunScenarioId(id); setRunScenarioId(id);
const scenario = items.find((s) => s.id === id); const scenario = items.find((s) => s.id === id);
@@ -79,8 +111,7 @@ export function ScenariosPage() {
setDeleting(true); setDeleting(true);
try { try {
await scenarios.remove(deleteId); await scenarios.remove(deleteId);
setLoading(true); load(sortBy, sortDir, page, pageSize);
load();
setDeleteId(null); setDeleteId(null);
} finally { } finally {
setDeleting(false); setDeleting(false);
@@ -103,11 +134,12 @@ export function ScenariosPage() {
const columns: TableColumn<Scenario>[] = [ const columns: TableColumn<Scenario>[] = [
{ key: 'id', header: t('scenarios.col_id'), render: (s) => <UuidBadge id={s.id} />, width: 60 }, { key: 'id', header: t('scenarios.col_id'), render: (s) => <UuidBadge id={s.id} />, width: 60 },
{ key: 'name', header: t('scenarios.col_name'), render: (s) => s.name }, { key: 'name', header: t('scenarios.col_name'), render: (s) => s.name, sortable: true },
{ {
key: 'updated', key: 'updatedAt',
header: t('scenarios.col_updated'), header: t('scenarios.col_updated'),
width: 140, width: 140,
sortable: true,
render: (s) => <Timestamp value={s.updatedAt} />, render: (s) => <Timestamp value={s.updatedAt} />,
}, },
{ {
@@ -171,9 +203,16 @@ export function ScenariosPage() {
rowKey={(s) => s.id} rowKey={(s) => s.id}
loading={loading} loading={loading}
emptyMessage={t('scenarios.empty')} emptyMessage={t('scenarios.empty')}
pageSize={10} pageSize={pageSize}
pageSizeOptions={[10, 25, 50]} pageSizeOptions={[10, 25, 50]}
onRowClick={(s) => navigate(`/scenarios/${s.id}`)} onRowClick={(s) => navigate(`/scenarios/${s.id}`)}
sortBy={sortBy}
sortDir={sortDir}
onSort={handleSort}
total={total}
page={page}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/> />
<Modal <Modal
+30 -10
View File
@@ -10,6 +10,7 @@ import {
Button, Button,
Table, Table,
type TableColumn, type TableColumn,
type SortDirection,
Timestamp, Timestamp,
UuidBadge, UuidBadge,
} from '../../ui'; } from '../../ui';
@@ -23,25 +24,36 @@ export function SessionsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [sortBy, setSortBy] = useState('id');
const [sortDir, setSortDir] = useState<SortDirection>('desc');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const load = useCallback(() => { const load = useCallback((orderBy: string, orderDir: SortDirection, p: number, limit: number) => {
setLoading(true);
sessions sessions
.list() .list(p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC')
.then((res) => setItems(res.data)) .then((res) => { setItems(res.data); setTotal(res.total); })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => { useEffect(() => {
load(); load(sortBy, sortDir, page, pageSize);
}, [load]); }, [load, sortBy, sortDir, page, pageSize]);
const handleSort = (col: string, dir: SortDirection) => {
setSortBy(col);
setSortDir(dir);
setPage(1);
};
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteId) return; if (!deleteId) return;
setDeleting(true); setDeleting(true);
try { try {
await sessions.remove(deleteId); await sessions.remove(deleteId);
setLoading(true); load(sortBy, sortDir, page, pageSize);
load();
setDeleteId(null); setDeleteId(null);
} finally { } finally {
setDeleting(false); setDeleting(false);
@@ -50,17 +62,19 @@ export function SessionsPage() {
const columns: TableColumn<Session>[] = [ const columns: TableColumn<Session>[] = [
{ key: 'id', header: t('sessions.col_id'), render: (s) => <UuidBadge id={s.id} />, width: 60 }, { key: 'id', header: t('sessions.col_id'), render: (s) => <UuidBadge id={s.id} />, width: 60 },
{ key: 'name', header: t('sessions.col_name'), render: (s) => s.sessionName }, { key: 'sessionName', header: t('sessions.col_name'), sortable: true, render: (s) => s.sessionName },
{ {
key: 'status', key: 'status',
header: t('sessions.col_status'), header: t('sessions.col_status'),
width: 100, width: 100,
sortable: true,
render: (s) => <Badge variant={s.status === 'open' ? 'success' : 'error'}>{s.status}</Badge>, render: (s) => <Badge variant={s.status === 'open' ? 'success' : 'error'}>{s.status}</Badge>,
}, },
{ {
key: 'lastUsed', key: 'lastUsedAt',
header: t('sessions.col_lastUsed'), header: t('sessions.col_lastUsed'),
width: 160, width: 160,
sortable: true,
render: (s) => (s.lastUsedAt ? <Timestamp value={s.lastUsedAt} /> : '—'), render: (s) => (s.lastUsedAt ? <Timestamp value={s.lastUsedAt} /> : '—'),
}, },
{ {
@@ -95,7 +109,13 @@ export function SessionsPage() {
rowKey={(s) => s.id} rowKey={(s) => s.id}
loading={loading} loading={loading}
emptyMessage={t('sessions.empty')} emptyMessage={t('sessions.empty')}
pageSize={10} sortBy={sortBy}
sortDir={sortDir}
onSort={handleSort}
total={total}
page={page}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
pageSizeOptions={[10, 25, 50]} pageSizeOptions={[10, 25, 50]}
onRowClick={(s) => navigate(`/sessions/${s.id}`)} onRowClick={(s) => navigate(`/sessions/${s.id}`)}
/> />
+29
View File
@@ -27,11 +27,40 @@
background: var(--color-secondary-fg); background: var(--color-secondary-fg);
} }
.thContent {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.thSortable {
cursor: pointer;
user-select: none;
}
.thSortable:hover {
background: color-mix(in srgb, var(--color-secondary-fg) 80%, var(--color-secondary-border) 20%);
}
.sortIcon {
opacity: 0.4;
flex-shrink: 0;
}
.sortIconActive {
opacity: 1;
color: var(--color-primary);
}
[data-theme="dark"] .th { [data-theme="dark"] .th {
background: var(--color-secondary); background: var(--color-secondary);
color: var(--color-secondary-fg); color: var(--color-secondary-fg);
} }
[data-theme="dark"] .thSortable:hover {
background: color-mix(in srgb, var(--color-secondary) 80%, var(--color-secondary-border) 20%);
}
/* Rounded top corners on first/last header cells */ /* Rounded top corners on first/last header cells */
.th:first-child { border-radius: var(--radius-lg) 0 0 0; } .th:first-child { border-radius: var(--radius-lg) 0 0 0; }
.th:last-child { border-radius: 0 var(--radius-lg) 0 0; } .th:last-child { border-radius: 0 var(--radius-lg) 0 0; }
+120 -24
View File
@@ -1,13 +1,18 @@
import { useState, type HTMLAttributes, type MouseEvent, type ReactNode } from 'react'; import { useState, type HTMLAttributes, type MouseEvent, type ReactNode } from 'react';
import { ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react';
import { Pagination } from '../Pagination/Pagination'; import { Pagination } from '../Pagination/Pagination';
import styles from './Table.module.css'; import styles from './Table.module.css';
export type SortDirection = 'asc' | 'desc';
export interface TableColumn<T> { export interface TableColumn<T> {
key: string; key: string;
header: ReactNode; header: ReactNode;
render: (row: T) => ReactNode; render: (row: T) => ReactNode;
width?: string | number; width?: string | number;
align?: 'left' | 'center' | 'right'; align?: 'left' | 'center' | 'right';
/** If true, a sort icon is shown and clicking the header triggers onSort */
sortable?: boolean;
} }
export interface TableProps<T> { export interface TableProps<T> {
@@ -17,7 +22,10 @@ export interface TableProps<T> {
loading?: boolean; loading?: boolean;
emptyMessage?: string; emptyMessage?: string;
className?: string; className?: string;
/** Enable built-in client-side pagination with this default page size */ /**
* Client-side pagination: pass a default page size to enable.
* For server-side pagination use `total` + `page` + `onPageChange` instead.
*/
pageSize?: number; pageSize?: number;
/** Offer a page-size selector when pagination is enabled */ /** Offer a page-size selector when pagination is enabled */
pageSizeOptions?: number[]; pageSizeOptions?: number[];
@@ -25,6 +33,21 @@ export interface TableProps<T> {
onRowClick?: (row: T) => void; onRowClick?: (row: T) => void;
/** Optional per-row props, useful for drag-and-drop and row-level attributes */ /** Optional per-row props, useful for drag-and-drop and row-level attributes */
getRowProps?: (row: T) => HTMLAttributes<HTMLTableRowElement>; getRowProps?: (row: T) => HTMLAttributes<HTMLTableRowElement>;
/** Currently sorted column key (controlled) */
sortBy?: string;
/** Current sort direction (controlled) */
sortDir?: SortDirection;
/** Called when the user clicks a sortable column header */
onSort?: (key: string, dir: SortDirection) => void;
// ── Server-side pagination (controlled) ──────────────────────────────────
/** Total record count from server. Presence switches to server-side pagination mode. */
total?: number;
/** Controlled current page (1-based). Required when `total` is set. */
page?: number;
/** Called when the user navigates to a new page. Required when `total` is set. */
onPageChange?: (page: number) => void;
/** Called when the user changes page size. Optional for server-side pagination. */
onPageSizeChange?: (size: number) => void;
} }
export function Table<T>({ export function Table<T>({
@@ -38,34 +61,85 @@ export function Table<T>({
pageSizeOptions, pageSizeOptions,
onRowClick, onRowClick,
getRowProps, getRowProps,
sortBy,
sortDir,
onSort,
total,
page: controlledPage,
onPageChange,
onPageSizeChange,
}: TableProps<T>) { }: TableProps<T>) {
const [page, setPage] = useState(1); const [internalPage, setInternalPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize ?? 0); const [pageSize, setPageSize] = useState(defaultPageSize ?? 10);
const paginated = defaultPageSize !== undefined && !loading; const serverMode = total !== undefined;
const visibleData = paginated ? data.slice((page - 1) * pageSize, page * pageSize) : data;
// Reset to page 1 when data changes and current page would be empty const handleSortClick = (key: string) => {
const lastPage = pageSize > 0 ? Math.ceil(data.length / pageSize) : 1; if (!onSort) return;
const safePage = Math.min(page, Math.max(1, lastPage)); const nextDir: SortDirection =
sortBy === key && sortDir === 'asc' ? 'desc' : 'asc';
onSort(key, nextDir);
};
// Client-side pagination
const clientPaginated = !serverMode && defaultPageSize !== undefined && !loading;
const visibleData = clientPaginated
? data.slice((internalPage - 1) * pageSize, internalPage * pageSize)
: data;
const lastClientPage = pageSize > 0 ? Math.ceil(data.length / pageSize) : 1;
const safeInternalPage = Math.min(internalPage, Math.max(1, lastClientPage));
// Server-side pagination display values
const serverPage = controlledPage ?? 1;
const serverTotal = total ?? 0;
return ( return (
<div className={`${styles.wrapper} ${className ?? ''}`}> <div className={`${styles.wrapper} ${className ?? ''}`}>
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>
<tr> <tr>
{columns.map((col) => ( {columns.map((col) => {
<th const isSorted = col.sortable && sortBy === col.key;
key={col.key} const SortIcon = isSorted
className={styles.th} ? sortDir === 'asc'
style={{ ? ArrowUp
width: col.width, : ArrowDown
textAlign: col.align ?? 'left', : ArrowUpDown;
}}
> return (
{col.header} <th
</th> key={col.key}
))} className={[
styles.th,
col.sortable ? styles.thSortable : '',
]
.filter(Boolean)
.join(' ')}
style={{
width: col.width,
textAlign: col.align ?? 'left',
}}
onClick={col.sortable ? () => handleSortClick(col.key) : undefined}
>
<span className={styles.thContent}>
{col.header}
{col.sortable && (
<SortIcon
size={12}
className={[
styles.sortIcon,
isSorted ? styles.sortIconActive : '',
]
.filter(Boolean)
.join(' ')}
aria-hidden="true"
/>
)}
</span>
</th>
);
})}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -122,17 +196,39 @@ export function Table<T>({
)} )}
</tbody> </tbody>
</table> </table>
{paginated && data.length > 0 && ( {serverMode && serverTotal > 0 && onPageChange && (
<Pagination <Pagination
page={safePage} page={serverPage}
pageSize={pageSize}
total={serverTotal}
onPageChange={onPageChange}
onPageSizeChange={
onPageSizeChange
? (size) => {
setPageSize(size);
onPageSizeChange(size);
}
: pageSizeOptions
? (size) => {
setPageSize(size);
onPageChange(1);
}
: undefined
}
pageSizeOptions={pageSizeOptions}
/>
)}
{clientPaginated && data.length > 0 && (
<Pagination
page={safeInternalPage}
pageSize={pageSize} pageSize={pageSize}
total={data.length} total={data.length}
onPageChange={setPage} onPageChange={setInternalPage}
onPageSizeChange={ onPageSizeChange={
pageSizeOptions pageSizeOptions
? (size) => { ? (size) => {
setPageSize(size); setPageSize(size);
setPage(1); setInternalPage(1);
} }
: undefined : undefined
} }
+1 -1
View File
@@ -62,7 +62,7 @@ export { Modal } from './Modal/Modal';
export type { ModalProps } from './Modal/Modal'; export type { ModalProps } from './Modal/Modal';
export { Table } from './Table/Table'; export { Table } from './Table/Table';
export type { TableProps, TableColumn } from './Table/Table'; export type { TableProps, TableColumn, SortDirection } from './Table/Table';
export { UuidBadge } from './UuidBadge/UuidBadge'; export { UuidBadge } from './UuidBadge/UuidBadge';
export type { UuidBadgeProps } from './UuidBadge/UuidBadge'; export type { UuidBadgeProps } from './UuidBadge/UuidBadge';
@@ -11,8 +11,8 @@ import {
Query, Query,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { PaginationQueryDto } from "../common/dto/pagination.dto"; import { CredentialQueryDto } from "./dto/credential-query.dto";
import { CredentialOrderBy, CredentialService } from "./credential.service"; import { CredentialService } from "./credential.service";
import { CreateCredentialDto } from "./dto/create-credential.dto"; import { CreateCredentialDto } from "./dto/create-credential.dto";
import { CredentialExportDto } from "./dto/credential-export.dto"; import { CredentialExportDto } from "./dto/credential-export.dto";
import { UpdateCredentialDto } from "./dto/update-credential.dto"; import { UpdateCredentialDto } from "./dto/update-credential.dto";
@@ -39,7 +39,7 @@ export class CredentialController {
@Get() @Get()
@ApiOperation({ summary: "List all credentials (paginated)" }) @ApiOperation({ summary: "List all credentials (paginated)" })
@ApiResponse({ status: 200, description: "Paginated credentials" }) @ApiResponse({ status: 200, description: "Paginated credentials" })
findAll(@Query() query: PaginationQueryDto<CredentialOrderBy>) { findAll(@Query() query: CredentialQueryDto) {
return this.credentialService.findAll(query); return this.credentialService.findAll(query);
} }
@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional } from "class-validator";
import { PaginationQueryDto } from "../../common/dto/pagination.dto";
import { CredentialOrderBy } from "../credential.service";
const CREDENTIAL_ORDER_BY: CredentialOrderBy[] = ["id", "name", "lastUsedAt"];
export class CredentialQueryDto extends PaginationQueryDto<CredentialOrderBy> {
@ApiPropertyOptional({ enum: CREDENTIAL_ORDER_BY })
@IsOptional()
@IsIn(CREDENTIAL_ORDER_BY)
declare orderBy?: CredentialOrderBy;
}
@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional } from "class-validator";
import { PaginationQueryDto } from "../../common/dto/pagination.dto";
import { EnvironmentOrderBy } from "../environment.service";
const ENVIRONMENT_ORDER_BY: EnvironmentOrderBy[] = ["id", "name", "createdAt", "updatedAt"];
export class EnvironmentQueryDto extends PaginationQueryDto<EnvironmentOrderBy> {
@ApiPropertyOptional({ enum: ENVIRONMENT_ORDER_BY })
@IsOptional()
@IsIn(ENVIRONMENT_ORDER_BY)
declare orderBy?: EnvironmentOrderBy;
}
@@ -11,11 +11,11 @@ import {
Query, Query,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { PaginationQueryDto } from "../common/dto/pagination.dto"; import { EnvironmentQueryDto } from "./dto/environment-query.dto";
import { CreateEnvironmentDto } from "./dto/create-environment.dto"; import { CreateEnvironmentDto } from "./dto/create-environment.dto";
import { EnvironmentExportDto } from "./dto/environment-export.dto"; import { EnvironmentExportDto } from "./dto/environment-export.dto";
import { UpdateEnvironmentDto } from "./dto/update-environment.dto"; import { UpdateEnvironmentDto } from "./dto/update-environment.dto";
import { EnvironmentOrderBy, EnvironmentService } from "./environment.service"; import { EnvironmentService } from "./environment.service";
@ApiTags("environments") @ApiTags("environments")
@Controller("environments") @Controller("environments")
@@ -40,7 +40,7 @@ export class EnvironmentController {
@Get() @Get()
@ApiOperation({ summary: "List all environments (paginated)" }) @ApiOperation({ summary: "List all environments (paginated)" })
@ApiResponse({ status: 200, description: "Paginated environments" }) @ApiResponse({ status: 200, description: "Paginated environments" })
findAll(@Query() query: PaginationQueryDto<EnvironmentOrderBy>) { findAll(@Query() query: EnvironmentQueryDto) {
return this.environmentService.findAll(query); return this.environmentService.findAll(query);
} }
@@ -1 +1,15 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional } from "class-validator";
import { PaginationQueryDto } from "../../common/dto/pagination.dto";
import { ScenarioOrderBy } from "../scenario.service";
export { PaginationQueryDto } from "../../common/dto/pagination.dto"; export { PaginationQueryDto } from "../../common/dto/pagination.dto";
const SCENARIO_ORDER_BY: ScenarioOrderBy[] = ["id", "name", "createdAt", "updatedAt"];
export class ScenarioQueryDto extends PaginationQueryDto<ScenarioOrderBy> {
@ApiPropertyOptional({ enum: SCENARIO_ORDER_BY })
@IsOptional()
@IsIn(SCENARIO_ORDER_BY)
declare orderBy?: ScenarioOrderBy;
}
+9 -14
View File
@@ -1,22 +1,17 @@
import { ApiPropertyOptional } from "@nestjs/swagger"; import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer"; import { IsIn, IsOptional } from "class-validator";
import { IsIn, IsInt, IsOptional, Min } from "class-validator";
import { RunStatus } from "../scenario-run.entity"; import { RunStatus } from "../scenario-run.entity";
import { PaginationQueryDto } from "../../common/dto/pagination.dto";
export class RunsQueryDto { export type RunOrderBy = "createdAt" | "status" | "scenario.name";
@ApiPropertyOptional({ example: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ example: 20, default: 20 }) const RUN_ORDER_BY: RunOrderBy[] = ["createdAt", "status", "scenario.name"];
export class RunsQueryDto extends PaginationQueryDto<RunOrderBy> {
@ApiPropertyOptional({ enum: RUN_ORDER_BY })
@IsOptional() @IsOptional()
@Type(() => Number) @IsIn(RUN_ORDER_BY)
@IsInt() declare orderBy?: RunOrderBy;
@Min(1)
limit?: number = 20;
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: ["pending", "in_progress", "pass", "fail"], enum: ["pending", "in_progress", "pass", "fail"],
+3 -3
View File
@@ -18,12 +18,12 @@ import { AddScenarioCredentialDto } from "./dto/add-scenario-credential.dto";
import { CreateScenarioRunDto } from "./dto/create-scenario-run.dto"; import { CreateScenarioRunDto } from "./dto/create-scenario-run.dto";
import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto"; import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
import { CreateScenarioDto } from "./dto/create-scenario.dto"; import { CreateScenarioDto } from "./dto/create-scenario.dto";
import { PaginationQueryDto } from "./dto/pagination-query.dto"; import { ScenarioQueryDto } from "./dto/pagination-query.dto";
import { RunsQueryDto } from "./dto/runs-query.dto"; import { RunsQueryDto } from "./dto/runs-query.dto";
import { ExportEntity } from "./dto/scenario-export.dto"; import { ExportEntity } from "./dto/scenario-export.dto";
import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto"; import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
import { UpdateScenarioDto } from "./dto/update-scenario.dto"; import { UpdateScenarioDto } from "./dto/update-scenario.dto";
import { ScenarioOrderBy, ScenarioService } from "./scenario.service"; import { ScenarioService } from "./scenario.service";
@ApiTags("scenarios") @ApiTags("scenarios")
@Controller("scenarios") @Controller("scenarios")
@@ -49,7 +49,7 @@ export class ScenarioController {
@Get() @Get()
@ApiOperation({ summary: "List all scenarios (paginated)" }) @ApiOperation({ summary: "List all scenarios (paginated)" })
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
findAll(@Query() query: PaginationQueryDto<ScenarioOrderBy>) { findAll(@Query() query: ScenarioQueryDto) {
return this.scenarioService.findAll(query); return this.scenarioService.findAll(query);
} }
+16 -11
View File
@@ -287,12 +287,13 @@ export class ScenarioService {
await this.findOne(scenarioId); // 404 guard await this.findOne(scenarioId); // 404 guard
const page = query.page ?? 1; const page = query.page ?? 1;
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const orderBy = query.orderBy ?? "createdAt";
const orderDir = query.orderDir ?? "DESC";
const where: Record<string, unknown> = { scenarioId }; const where: Record<string, unknown> = { scenarioId };
if (query.status) where["status"] = query.status; if (query.status) where["status"] = query.status;
const [data, total] = await this.runRepo.findAndCount({ const [data, total] = await this.runRepo.findAndCount({
where, where,
relations: ["stepRuns"], order: { [orderBy]: orderDir },
order: { createdAt: "DESC" },
skip: (page - 1) * limit, skip: (page - 1) * limit,
take: limit, take: limit,
}); });
@@ -306,15 +307,19 @@ export class ScenarioService {
> { > {
const page = query.page ?? 1; const page = query.page ?? 1;
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const where: Record<string, unknown> = {}; const orderBy = query.orderBy ?? "createdAt";
if (query.status) where["status"] = query.status; const orderDir = query.orderDir ?? "DESC";
const [data, total] = await this.runRepo.findAndCount({ const qb = this.runRepo
where, .createQueryBuilder("run")
relations: ["stepRuns", "scenario"], .leftJoinAndSelect("run.scenario", "scenario");
order: { createdAt: "DESC" }, if (query.status) qb.where("run.status = :status", { status: query.status });
skip: (page - 1) * limit, if (orderBy === "scenario.name") {
take: limit, qb.orderBy("scenario.name", orderDir);
}); } else {
qb.orderBy(`run.${orderBy}`, orderDir);
}
qb.skip((page - 1) * limit).take(limit);
const [data, total] = await qb.getManyAndCount();
return { data, total, page, limit } as PaginatedResult< return { data, total, page, limit } as PaginatedResult<
ScenarioRunEntity & { scenario: ScenarioEntity } ScenarioRunEntity & { scenario: ScenarioEntity }
>; >;
+13
View File
@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional } from "class-validator";
import { PaginationQueryDto } from "../common/dto/pagination.dto";
import { SessionOrderBy } from "./session.service";
const SESSION_ORDER_BY: SessionOrderBy[] = ["id", "sessionName", "status", "lastUsedAt", "createdAt", "updatedAt"];
export class SessionQueryDto extends PaginationQueryDto<SessionOrderBy> {
@ApiPropertyOptional({ enum: SESSION_ORDER_BY })
@IsOptional()
@IsIn(SESSION_ORDER_BY)
declare orderBy?: SessionOrderBy;
}
+3 -3
View File
@@ -9,9 +9,9 @@ import {
Query, Query,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { PaginationQueryDto } from "../common/dto/pagination.dto"; import { SessionQueryDto } from "./session-query.dto";
import { SessionContextService } from "./session-context.service"; import { SessionContextService } from "./session-context.service";
import { SessionOrderBy, SessionService } from "./session.service"; import { SessionService } from "./session.service";
function maskToken(token: string, head = 6, tail = 4): string { function maskToken(token: string, head = 6, tail = 4): string {
if (token.length <= head + tail + 1) return token; if (token.length <= head + tail + 1) return token;
@@ -29,7 +29,7 @@ export class SessionController {
@Get() @Get()
@ApiOperation({ summary: "List all stored sessions (paginated)" }) @ApiOperation({ summary: "List all stored sessions (paginated)" })
@ApiResponse({ status: 200, description: "Paginated sessions" }) @ApiResponse({ status: 200, description: "Paginated sessions" })
findAll(@Query() query: PaginationQueryDto<SessionOrderBy>) { findAll(@Query() query: SessionQueryDto) {
return this.sessionService.findAll(query); return this.sessionService.findAll(query);
} }
@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional } from "class-validator";
import { PaginationQueryDto } from "../../common/dto/pagination.dto";
import { SnippetOrderBy } from "../snippet.service";
const SNIPPET_ORDER_BY: SnippetOrderBy[] = ["id", "alias", "title"];
export class SnippetQueryDto extends PaginationQueryDto<SnippetOrderBy> {
@ApiPropertyOptional({ enum: SNIPPET_ORDER_BY })
@IsOptional()
@IsIn(SNIPPET_ORDER_BY)
declare orderBy?: SnippetOrderBy;
}
+3 -3
View File
@@ -11,11 +11,11 @@ import {
Query, Query,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { PaginationQueryDto } from "../common/dto/pagination.dto"; import { SnippetQueryDto } from "./dto/snippet-query.dto";
import { CreateSnippetDto } from "./dto/create-snippet.dto"; import { CreateSnippetDto } from "./dto/create-snippet.dto";
import { SnippetExportDto } from "./dto/snippet-export.dto"; import { SnippetExportDto } from "./dto/snippet-export.dto";
import { UpdateSnippetDto } from "./dto/update-snippet.dto"; import { UpdateSnippetDto } from "./dto/update-snippet.dto";
import { SnippetOrderBy, SnippetService } from "./snippet.service"; import { SnippetService } from "./snippet.service";
@ApiTags("snippets") @ApiTags("snippets")
@Controller("snippets") @Controller("snippets")
@@ -40,7 +40,7 @@ export class SnippetController {
@Get() @Get()
@ApiOperation({ summary: "List all snippets (paginated)" }) @ApiOperation({ summary: "List all snippets (paginated)" })
@ApiResponse({ status: 200, description: "Paginated snippets" }) @ApiResponse({ status: 200, description: "Paginated snippets" })
findAll(@Query() query: PaginationQueryDto<SnippetOrderBy>) { findAll(@Query() query: SnippetQueryDto) {
return this.snippetService.findAll(query); return this.snippetService.findAll(query);
} }