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
+16 -11
View File
@@ -287,12 +287,13 @@ export class ScenarioService {
await this.findOne(scenarioId); // 404 guard
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const orderBy = query.orderBy ?? "createdAt";
const orderDir = query.orderDir ?? "DESC";
const where: Record<string, unknown> = { scenarioId };
if (query.status) where["status"] = query.status;
const [data, total] = await this.runRepo.findAndCount({
where,
relations: ["stepRuns"],
order: { createdAt: "DESC" },
order: { [orderBy]: orderDir },
skip: (page - 1) * limit,
take: limit,
});
@@ -306,15 +307,19 @@ export class ScenarioService {
> {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const where: Record<string, unknown> = {};
if (query.status) where["status"] = query.status;
const [data, total] = await this.runRepo.findAndCount({
where,
relations: ["stepRuns", "scenario"],
order: { createdAt: "DESC" },
skip: (page - 1) * limit,
take: limit,
});
const orderBy = query.orderBy ?? "createdAt";
const orderDir = query.orderDir ?? "DESC";
const qb = this.runRepo
.createQueryBuilder("run")
.leftJoinAndSelect("run.scenario", "scenario");
if (query.status) qb.where("run.status = :status", { status: query.status });
if (orderBy === "scenario.name") {
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<
ScenarioRunEntity & { scenario: ScenarioEntity }
>;