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
+30 -10
View File
@@ -10,6 +10,7 @@ import {
Button,
Table,
type TableColumn,
type SortDirection,
Timestamp,
UuidBadge,
} from '../../ui';
@@ -23,25 +24,36 @@ export function SessionsPage() {
const [loading, setLoading] = useState(true);
const [deleteId, setDeleteId] = useState<string | null>(null);
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
.list()
.then((res) => setItems(res.data))
.list(p, limit, orderBy, orderDir === 'asc' ? 'ASC' : 'DESC')
.then((res) => { setItems(res.data); setTotal(res.total); })
.finally(() => setLoading(false));
}, []);
useEffect(() => {
load();
}, [load]);
load(sortBy, sortDir, page, pageSize);
}, [load, sortBy, sortDir, page, pageSize]);
const handleSort = (col: string, dir: SortDirection) => {
setSortBy(col);
setSortDir(dir);
setPage(1);
};
const handleDelete = async () => {
if (!deleteId) return;
setDeleting(true);
try {
await sessions.remove(deleteId);
setLoading(true);
load();
load(sortBy, sortDir, page, pageSize);
setDeleteId(null);
} finally {
setDeleting(false);
@@ -50,17 +62,19 @@ export function SessionsPage() {
const columns: TableColumn<Session>[] = [
{ 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',
header: t('sessions.col_status'),
width: 100,
sortable: true,
render: (s) => <Badge variant={s.status === 'open' ? 'success' : 'error'}>{s.status}</Badge>,
},
{
key: 'lastUsed',
key: 'lastUsedAt',
header: t('sessions.col_lastUsed'),
width: 160,
sortable: true,
render: (s) => (s.lastUsedAt ? <Timestamp value={s.lastUsedAt} /> : '—'),
},
{
@@ -95,7 +109,13 @@ export function SessionsPage() {
rowKey={(s) => s.id}
loading={loading}
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]}
onRowClick={(s) => navigate(`/sessions/${s.id}`)}
/>