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
@@ -11,8 +11,8 @@ import {
Query,
} from "@nestjs/common";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { PaginationQueryDto } from "../common/dto/pagination.dto";
import { CredentialOrderBy, CredentialService } from "./credential.service";
import { CredentialQueryDto } from "./dto/credential-query.dto";
import { CredentialService } from "./credential.service";
import { CreateCredentialDto } from "./dto/create-credential.dto";
import { CredentialExportDto } from "./dto/credential-export.dto";
import { UpdateCredentialDto } from "./dto/update-credential.dto";
@@ -39,7 +39,7 @@ export class CredentialController {
@Get()
@ApiOperation({ summary: "List all credentials (paginated)" })
@ApiResponse({ status: 200, description: "Paginated credentials" })
findAll(@Query() query: PaginationQueryDto<CredentialOrderBy>) {
findAll(@Query() query: CredentialQueryDto) {
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,
} from "@nestjs/common";
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 { EnvironmentExportDto } from "./dto/environment-export.dto";
import { UpdateEnvironmentDto } from "./dto/update-environment.dto";
import { EnvironmentOrderBy, EnvironmentService } from "./environment.service";
import { EnvironmentService } from "./environment.service";
@ApiTags("environments")
@Controller("environments")
@@ -40,7 +40,7 @@ export class EnvironmentController {
@Get()
@ApiOperation({ summary: "List all environments (paginated)" })
@ApiResponse({ status: 200, description: "Paginated environments" })
findAll(@Query() query: PaginationQueryDto<EnvironmentOrderBy>) {
findAll(@Query() query: EnvironmentQueryDto) {
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";
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 { Type } from "class-transformer";
import { IsIn, IsInt, IsOptional, Min } from "class-validator";
import { IsIn, IsOptional } from "class-validator";
import { RunStatus } from "../scenario-run.entity";
import { PaginationQueryDto } from "../../common/dto/pagination.dto";
export class RunsQueryDto {
@ApiPropertyOptional({ example: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
export type RunOrderBy = "createdAt" | "status" | "scenario.name";
@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()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number = 20;
@IsIn(RUN_ORDER_BY)
declare orderBy?: RunOrderBy;
@ApiPropertyOptional({
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 { CreateScenarioStepDto } from "./dto/create-scenario-step.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 { ExportEntity } from "./dto/scenario-export.dto";
import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
import { ScenarioOrderBy, ScenarioService } from "./scenario.service";
import { ScenarioService } from "./scenario.service";
@ApiTags("scenarios")
@Controller("scenarios")
@@ -49,7 +49,7 @@ export class ScenarioController {
@Get()
@ApiOperation({ summary: "List all scenarios (paginated)" })
@ApiResponse({ status: 200 })
findAll(@Query() query: PaginationQueryDto<ScenarioOrderBy>) {
findAll(@Query() query: ScenarioQueryDto) {
return this.scenarioService.findAll(query);
}
+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 }
>;
+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,
} from "@nestjs/common";
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 { SessionOrderBy, SessionService } from "./session.service";
import { SessionService } from "./session.service";
function maskToken(token: string, head = 6, tail = 4): string {
if (token.length <= head + tail + 1) return token;
@@ -29,7 +29,7 @@ export class SessionController {
@Get()
@ApiOperation({ summary: "List all stored sessions (paginated)" })
@ApiResponse({ status: 200, description: "Paginated sessions" })
findAll(@Query() query: PaginationQueryDto<SessionOrderBy>) {
findAll(@Query() query: SessionQueryDto) {
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,
} from "@nestjs/common";
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 { SnippetExportDto } from "./dto/snippet-export.dto";
import { UpdateSnippetDto } from "./dto/update-snippet.dto";
import { SnippetOrderBy, SnippetService } from "./snippet.service";
import { SnippetService } from "./snippet.service";
@ApiTags("snippets")
@Controller("snippets")
@@ -40,7 +40,7 @@ export class SnippetController {
@Get()
@ApiOperation({ summary: "List all snippets (paginated)" })
@ApiResponse({ status: 200, description: "Paginated snippets" })
findAll(@Query() query: PaginationQueryDto<SnippetOrderBy>) {
findAll(@Query() query: SnippetQueryDto) {
return this.snippetService.findAll(query);
}