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:
@@ -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())}` : '';
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user