feat: Add user role retrieval, enhance recommended course filtering and detail, and introduce new k6 load tests.
This commit is contained in:
parent
c118e5c3dc
commit
ef70d2db3f
11 changed files with 515 additions and 139 deletions
|
|
@ -20,10 +20,14 @@ export class RecommendedCoursesController {
|
||||||
@SuccessResponse('200', 'Approved courses retrieved successfully')
|
@SuccessResponse('200', 'Approved courses retrieved successfully')
|
||||||
@Response('401', 'Unauthorized')
|
@Response('401', 'Unauthorized')
|
||||||
@Response('403', 'Forbidden - Admin only')
|
@Response('403', 'Forbidden - Admin only')
|
||||||
public async listApprovedCourses(@Request() request: any): Promise<ListApprovedCoursesResponse> {
|
public async listApprovedCourses(
|
||||||
|
@Request() request: any,
|
||||||
|
@Query() search?: string,
|
||||||
|
@Query() categoryId?: number
|
||||||
|
): Promise<ListApprovedCoursesResponse> {
|
||||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
if (!token) throw new ValidationError('No token provided');
|
if (!token) throw new ValidationError('No token provided');
|
||||||
return await RecommendedCoursesService.listApprovedCourses(token);
|
return await RecommendedCoursesService.listApprovedCourses(token, { search, categoryId });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ import {
|
||||||
ChangePasswordResponse,
|
ChangePasswordResponse,
|
||||||
updateAvatarResponse,
|
updateAvatarResponse,
|
||||||
SendVerifyEmailResponse,
|
SendVerifyEmailResponse,
|
||||||
VerifyEmailResponse
|
VerifyEmailResponse,
|
||||||
|
rolesResponse
|
||||||
} from '../types/user.types';
|
} from '../types/user.types';
|
||||||
import { ChangePassword } from '../types/auth.types';
|
import { ChangePassword } from '../types/auth.types';
|
||||||
import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator";
|
import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator";
|
||||||
|
|
@ -56,6 +57,18 @@ export class UserController {
|
||||||
return await this.userService.updateProfile(token, body);
|
return await this.userService.updateProfile(token, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('roles')
|
||||||
|
@Security('jwt')
|
||||||
|
@SuccessResponse('200', 'Roles retrieved successfully')
|
||||||
|
@Response('401', 'Invalid or expired token')
|
||||||
|
public async getRoles(@Request() request: any): Promise<rolesResponse> {
|
||||||
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
|
if (!token) {
|
||||||
|
throw new ValidationError('No token provided');
|
||||||
|
}
|
||||||
|
return await this.userService.getRoles(token);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change password
|
* Change password
|
||||||
* @summary Change user password using old password
|
* @summary Change user password using old password
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ export class AdminCourseApprovalService {
|
||||||
/**
|
/**
|
||||||
* Get course details for admin review
|
* Get course details for admin review
|
||||||
*/
|
*/
|
||||||
static async getCourseDetail(token: string,courseId: number): Promise<GetCourseDetailForAdminResponse> {
|
static async getCourseDetail(token: string, courseId: number): Promise<GetCourseDetailForAdminResponse> {
|
||||||
try {
|
try {
|
||||||
const course = await prisma.course.findUnique({
|
const course = await prisma.course.findUnique({
|
||||||
where: { id: courseId },
|
where: { id: courseId },
|
||||||
|
|
@ -133,7 +133,11 @@ export class AdminCourseApprovalService {
|
||||||
},
|
},
|
||||||
chapters: {
|
chapters: {
|
||||||
orderBy: { sort_order: 'asc' },
|
orderBy: { sort_order: 'asc' },
|
||||||
include: {
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
sort_order: true,
|
||||||
|
is_published: true,
|
||||||
lessons: {
|
lessons: {
|
||||||
orderBy: { sort_order: 'asc' },
|
orderBy: { sort_order: 'asc' },
|
||||||
select: {
|
select: {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import {
|
||||||
ListApprovedCoursesResponse,
|
ListApprovedCoursesResponse,
|
||||||
GetCourseByIdResponse,
|
GetCourseByIdResponse,
|
||||||
ToggleRecommendedResponse,
|
ToggleRecommendedResponse,
|
||||||
RecommendedCourseData
|
RecommendedCourseData,
|
||||||
|
RecommendedCourseDetailData
|
||||||
} from '../types/RecommendedCourses.types';
|
} from '../types/RecommendedCourses.types';
|
||||||
import { auditService } from './audit.service';
|
import { auditService } from './audit.service';
|
||||||
import { AuditAction } from '@prisma/client';
|
import { AuditAction } from '@prisma/client';
|
||||||
|
|
@ -18,10 +19,24 @@ export class RecommendedCoursesService {
|
||||||
/**
|
/**
|
||||||
* List all approved courses (for admin to manage recommendations)
|
* List all approved courses (for admin to manage recommendations)
|
||||||
*/
|
*/
|
||||||
static async listApprovedCourses(token: string): Promise<ListApprovedCoursesResponse> {
|
static async listApprovedCourses(
|
||||||
|
token: string,
|
||||||
|
filters?: { search?: string; categoryId?: number }
|
||||||
|
): Promise<ListApprovedCoursesResponse> {
|
||||||
try {
|
try {
|
||||||
|
const { search, categoryId } = filters ?? {};
|
||||||
|
|
||||||
const courses = await prisma.course.findMany({
|
const courses = await prisma.course.findMany({
|
||||||
where: { status: 'APPROVED' },
|
where: {
|
||||||
|
status: 'APPROVED',
|
||||||
|
...(categoryId ? { category_id: categoryId } : {}),
|
||||||
|
...(search ? {
|
||||||
|
OR: [
|
||||||
|
{ title: { path: ['th'], string_contains: search } },
|
||||||
|
{ title: { path: ['en'], string_contains: search } }
|
||||||
|
]
|
||||||
|
} : {})
|
||||||
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ is_recommended: 'desc' },
|
{ is_recommended: 'desc' },
|
||||||
{ updated_at: 'desc' }
|
{ updated_at: 'desc' }
|
||||||
|
|
@ -40,9 +55,9 @@ export class RecommendedCoursesService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
chapters: {
|
_count: {
|
||||||
include: {
|
select: {
|
||||||
lessons: true
|
chapters: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -81,8 +96,7 @@ export class RecommendedCoursesService {
|
||||||
is_primary: i.is_primary,
|
is_primary: i.is_primary,
|
||||||
user: i.user
|
user: i.user
|
||||||
})),
|
})),
|
||||||
chapters_count: course.chapters.length,
|
chapters_count: course._count.chapters,
|
||||||
lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0)
|
|
||||||
} as RecommendedCourseData;
|
} as RecommendedCourseData;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -158,7 +172,7 @@ export class RecommendedCoursesService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: RecommendedCourseData = {
|
const data: RecommendedCourseDetailData = {
|
||||||
id: course.id,
|
id: course.id,
|
||||||
title: course.title as { th: string; en: string },
|
title: course.title as { th: string; en: string },
|
||||||
slug: course.slug,
|
slug: course.slug,
|
||||||
|
|
@ -181,8 +195,15 @@ export class RecommendedCoursesService {
|
||||||
is_primary: i.is_primary,
|
is_primary: i.is_primary,
|
||||||
user: i.user
|
user: i.user
|
||||||
})),
|
})),
|
||||||
chapters_count: course.chapters.length,
|
chapters: course.chapters.map(ch => ({
|
||||||
lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0)
|
id: ch.id,
|
||||||
|
title: ch.title as { th: string; en: string },
|
||||||
|
sort_order: ch.sort_order,
|
||||||
|
lessons: ch.lessons.map(l => ({
|
||||||
|
id: l.id,
|
||||||
|
title: l.title as { th: string; en: string }
|
||||||
|
}))
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -103,20 +103,55 @@ export class CoursesService {
|
||||||
const course = await prisma.course.findFirst({
|
const course = await prisma.course.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
status: 'APPROVED' // Only show approved courses to students
|
status: 'APPROVED'
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
chapters: {
|
creator: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
username: true,
|
||||||
lessons: {
|
email: true,
|
||||||
|
profile: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
first_name: true,
|
||||||
title: true,
|
last_name: true,
|
||||||
|
avatar_url: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
instructors: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
profile: {
|
||||||
|
select: {
|
||||||
|
first_name: true,
|
||||||
|
last_name: true,
|
||||||
|
avatar_url: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
select: { id: true, name: true }
|
||||||
|
},
|
||||||
|
chapters: {
|
||||||
|
orderBy: { sort_order: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
sort_order: true,
|
||||||
|
lessons: {
|
||||||
|
orderBy: { sort_order: 'asc' },
|
||||||
|
select: { id: true, title: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -138,12 +173,69 @@ export class CoursesService {
|
||||||
logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`);
|
logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate presigned URL for creator avatar
|
||||||
|
let creator_avatar_url: string | null = null;
|
||||||
|
if (course.creator.profile?.avatar_url) {
|
||||||
|
try {
|
||||||
|
creator_avatar_url = await getPresignedUrl(course.creator.profile.avatar_url, 3600);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to generate presigned URL for creator avatar: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate presigned URLs for instructor avatars
|
||||||
|
const instructorsWithAvatar = await Promise.all(course.instructors.map(async (i) => {
|
||||||
|
let avatar_url: string | null = null;
|
||||||
|
if (i.user.profile?.avatar_url) {
|
||||||
|
try {
|
||||||
|
avatar_url = await getPresignedUrl(i.user.profile.avatar_url, 3600);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to generate presigned URL for instructor avatar: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user_id: i.user_id,
|
||||||
|
is_primary: i.is_primary,
|
||||||
|
user: {
|
||||||
|
...i.user,
|
||||||
|
profile: i.user.profile ? {
|
||||||
|
...i.user.profile,
|
||||||
|
avatar_url
|
||||||
|
} : null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Course fetched successfully',
|
message: 'Course fetched successfully',
|
||||||
data: {
|
data: {
|
||||||
...course,
|
...course,
|
||||||
|
title: course.title as { th: string; en: string },
|
||||||
|
description: course.description as { th: string; en: string },
|
||||||
thumbnail_url: thumbnail_presigned_url,
|
thumbnail_url: thumbnail_presigned_url,
|
||||||
|
creator: {
|
||||||
|
...course.creator,
|
||||||
|
profile: course.creator.profile ? {
|
||||||
|
...course.creator.profile,
|
||||||
|
avatar_url: creator_avatar_url
|
||||||
|
} : null
|
||||||
|
},
|
||||||
|
instructors: instructorsWithAvatar,
|
||||||
|
category: course.category ? {
|
||||||
|
id: course.category.id,
|
||||||
|
name: course.category.name as { th: string; en: string }
|
||||||
|
} : null,
|
||||||
|
chapters: course.chapters.map(ch => ({
|
||||||
|
id: ch.id,
|
||||||
|
title: ch.title as { th: string; en: string },
|
||||||
|
sort_order: ch.sort_order,
|
||||||
|
lessons: ch.lessons.map(l => ({
|
||||||
|
id: l.id,
|
||||||
|
title: l.title as { th: string; en: string }
|
||||||
|
}))
|
||||||
|
}))
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ import {
|
||||||
updateAvatarRequest,
|
updateAvatarRequest,
|
||||||
updateAvatarResponse,
|
updateAvatarResponse,
|
||||||
SendVerifyEmailResponse,
|
SendVerifyEmailResponse,
|
||||||
VerifyEmailResponse
|
VerifyEmailResponse,
|
||||||
|
rolesResponse
|
||||||
} from '../types/user.types';
|
} from '../types/user.types';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
||||||
|
|
@ -212,6 +213,30 @@ export class UserService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRoles(token: string): Promise<rolesResponse> {
|
||||||
|
try {
|
||||||
|
jwt.verify(token, config.jwt.secret);
|
||||||
|
const roles = await prisma.role.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { roles };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof jwt.TokenExpiredError) {
|
||||||
|
logger.error('JWT token expired:', error);
|
||||||
|
throw new UnauthorizedError('Token expired');
|
||||||
|
}
|
||||||
|
if (error instanceof jwt.JsonWebTokenError) {
|
||||||
|
logger.error('Invalid JWT token:', error);
|
||||||
|
throw new UnauthorizedError('Invalid token');
|
||||||
|
}
|
||||||
|
logger.error('Failed to get roles', { error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload avatar picture to MinIO
|
* Upload avatar picture to MinIO
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
import { MultiLanguageText } from './index';
|
import { MultiLanguageText } from './index';
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Request Types
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Response Types
|
// Response Types
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
/** ใช้ใน listApprovedCourses — มีแค่ chapters_count */
|
||||||
export interface RecommendedCourseData {
|
export interface RecommendedCourseData {
|
||||||
id: number;
|
id: number;
|
||||||
title: MultiLanguageText;
|
title: MultiLanguageText;
|
||||||
|
|
@ -41,7 +37,19 @@ export interface RecommendedCourseData {
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
chapters_count: number;
|
chapters_count: number;
|
||||||
lessons_count: number;
|
}
|
||||||
|
|
||||||
|
/** ใช้ใน getCourseById — มี chapters + lessons พร้อมชื่อ */
|
||||||
|
export interface RecommendedCourseDetailData extends Omit<RecommendedCourseData, 'chapters_count'> {
|
||||||
|
chapters: {
|
||||||
|
id: number;
|
||||||
|
title: MultiLanguageText;
|
||||||
|
sort_order: number;
|
||||||
|
lessons: {
|
||||||
|
id: number;
|
||||||
|
title: MultiLanguageText;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListApprovedCoursesResponse {
|
export interface ListApprovedCoursesResponse {
|
||||||
|
|
@ -54,7 +62,7 @@ export interface ListApprovedCoursesResponse {
|
||||||
export interface GetCourseByIdResponse {
|
export interface GetCourseByIdResponse {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
data: RecommendedCourseData;
|
data: RecommendedCourseDetailData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToggleRecommendedResponse {
|
export interface ToggleRecommendedResponse {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Course } from '@prisma/client';
|
import { Course } from '@prisma/client';
|
||||||
|
import { MultiLanguageText } from './index';
|
||||||
|
|
||||||
export interface ListCoursesInput {
|
export interface ListCoursesInput {
|
||||||
category_id?: number;
|
category_id?: number;
|
||||||
|
|
@ -18,8 +19,47 @@ export interface listCourseResponse {
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CourseDetail extends Omit<Course, 'title' | 'description'> {
|
||||||
|
title: MultiLanguageText;
|
||||||
|
description: MultiLanguageText;
|
||||||
|
creator: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
profile: {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
instructors: {
|
||||||
|
user_id: number;
|
||||||
|
is_primary: boolean;
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
profile: {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
category: { id: number; name: MultiLanguageText } | null;
|
||||||
|
chapters: {
|
||||||
|
id: number;
|
||||||
|
title: MultiLanguageText;
|
||||||
|
sort_order: number;
|
||||||
|
lessons: {
|
||||||
|
id: number;
|
||||||
|
title: MultiLanguageText;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface getCourseResponse {
|
export interface getCourseResponse {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
data: Course | null;
|
data: CourseDetail | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,14 @@ export interface ProfileUpdateResponse {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface role {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface rolesResponse {
|
||||||
|
roles: role[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChangePasswordRequest {
|
export interface ChangePasswordRequest {
|
||||||
old_password: string;
|
old_password: string;
|
||||||
|
|
|
||||||
160
Backend/tests/k6/enroll-load-test.js
Normal file
160
Backend/tests/k6/enroll-load-test.js
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
// Backend/tests/k6/enroll-load-test.js
|
||||||
|
//
|
||||||
|
// จำลองนักเรียนหลายคน login แล้ว enroll คอร์สพร้อมกัน
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. Login
|
||||||
|
// 2. Enroll คอร์ส
|
||||||
|
// 3. ตรวจสอบ enrolled courses
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// k6 run -e APP_URL=http://192.168.1.137:4000 -e COURSE_ID=1 tests/k6/enroll-load-test.js
|
||||||
|
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep, group } from 'k6';
|
||||||
|
import { Rate, Trend, Counter } from 'k6/metrics';
|
||||||
|
import { SharedArray } from 'k6/data';
|
||||||
|
|
||||||
|
// ─── Custom Metrics ───────────────────────────────────────────────────────────
|
||||||
|
const errorRate = new Rate('errors');
|
||||||
|
const loginTime = new Trend('login_duration', true);
|
||||||
|
const enrollTime = new Trend('enroll_duration', true);
|
||||||
|
const enrolledCount = new Counter('successful_enrollments');
|
||||||
|
|
||||||
|
// ─── Load student credentials ─────────────────────────────────────────────────
|
||||||
|
const students = new SharedArray('students', function () {
|
||||||
|
return JSON.parse(open('./test-credentials.json')).students;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
const BASE_URL = __ENV.APP_URL || 'http://192.168.1.137:4000';
|
||||||
|
const COURSE_ID = __ENV.COURSE_ID || '1';
|
||||||
|
|
||||||
|
// ─── Test Options ─────────────────────────────────────────────────────────────
|
||||||
|
export const options = {
|
||||||
|
stages: [
|
||||||
|
{ duration: '20s', target: 10 }, // Ramp up
|
||||||
|
{ duration: '1m', target: 30 }, // Increase
|
||||||
|
{ duration: '30s', target: 50 }, // Peak: 50 คน enroll พร้อมกัน
|
||||||
|
{ duration: '30s', target: 0 }, // Ramp down
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
'login_duration': ['p(95)<2000'], // Login < 2s
|
||||||
|
'enroll_duration': ['p(95)<1000'], // Enroll < 1s
|
||||||
|
'errors': ['rate<0.05'],
|
||||||
|
'http_req_failed': ['rate<0.05'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||||
|
function jsonHeaders(token) {
|
||||||
|
const h = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) h['Authorization'] = `Bearer ${token}`;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
export default function () {
|
||||||
|
const student = students[__VU % students.length];
|
||||||
|
let token = null;
|
||||||
|
|
||||||
|
// ── Step 1: Login ──────────────────────────────────────────────────────────
|
||||||
|
group('1. Login', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/auth/login`,
|
||||||
|
JSON.stringify({ email: student.email, password: student.password }),
|
||||||
|
{ headers: jsonHeaders(null) }
|
||||||
|
);
|
||||||
|
|
||||||
|
loginTime.add(res.timings.duration);
|
||||||
|
errorRate.add(res.status !== 200);
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
'login: status 200': (r) => r.status === 200,
|
||||||
|
'login: has token': (r) => { try { return !!r.json('data.token'); } catch { return false; } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
try { token = res.json('data.token'); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`);
|
||||||
|
sleep(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(0.5);
|
||||||
|
|
||||||
|
// ── Step 2: Enroll ─────────────────────────────────────────────────────────
|
||||||
|
group('2. Enroll Course', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/students/courses/${COURSE_ID}/enroll`,
|
||||||
|
null,
|
||||||
|
{ headers: jsonHeaders(token) }
|
||||||
|
);
|
||||||
|
|
||||||
|
enrollTime.add(res.timings.duration);
|
||||||
|
|
||||||
|
// 200 = enrolled, 409 = already enrolled (ถือว่าโอเค)
|
||||||
|
const ok = res.status === 200 || res.status === 409;
|
||||||
|
errorRate.add(!ok);
|
||||||
|
|
||||||
|
if (res.status === 200) enrolledCount.add(1);
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
'enroll: 200 or 409': (r) => r.status === 200 || r.status === 409,
|
||||||
|
'enroll: fast response': (r) => r.timings.duration < 1000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(0.5);
|
||||||
|
|
||||||
|
// ── Step 3: Verify — ดึงรายการคอร์สที่ลงทะเบียน ─────────────────────────
|
||||||
|
group('3. Get Enrolled Courses', () => {
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/students/courses`,
|
||||||
|
{ headers: jsonHeaders(token) }
|
||||||
|
);
|
||||||
|
|
||||||
|
errorRate.add(res.status !== 200);
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
'enrolled courses: status 200': (r) => r.status === 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||||
|
export function handleSummary(data) {
|
||||||
|
const m = data.metrics;
|
||||||
|
const avg = (k) => m[k]?.values?.avg?.toFixed(0) ?? 'N/A';
|
||||||
|
const p95 = (k) => m[k]?.values?.['p(95)']?.toFixed(0) ?? 'N/A';
|
||||||
|
const rate = (k) => ((m[k]?.values?.rate ?? 0) * 100).toFixed(2);
|
||||||
|
const cnt = (k) => m[k]?.values?.count ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout: `
|
||||||
|
╔══════════════════════════════════════════════════════════╗
|
||||||
|
║ Course Enroll — Load Test ║
|
||||||
|
╠══════════════════════════════════════════════════════════╣
|
||||||
|
║ Course ID : ${String(COURSE_ID).padEnd(43)}║
|
||||||
|
╠══════════════════════════════════════════════════════════╣
|
||||||
|
║ RESPONSE TIMES (avg / p95) ║
|
||||||
|
║ Login : ${avg('login_duration')}ms / ${p95('login_duration')}ms
|
||||||
|
║ Enroll : ${avg('enroll_duration')}ms / ${p95('enroll_duration')}ms
|
||||||
|
╠══════════════════════════════════════════════════════════╣
|
||||||
|
║ COUNTS ║
|
||||||
|
║ Total Requests : ${String(cnt('http_reqs')).padEnd(33)}║
|
||||||
|
║ New Enrollments : ${String(cnt('successful_enrollments')).padEnd(33)}║
|
||||||
|
╠══════════════════════════════════════════════════════════╣
|
||||||
|
║ ERROR RATES ║
|
||||||
|
║ HTTP Failed : ${(rate('http_req_failed') + '%').padEnd(39)}║
|
||||||
|
║ Custom Errors : ${(rate('errors') + '%').padEnd(39)}║
|
||||||
|
╚══════════════════════════════════════════════════════════╝
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,7 @@ const progressSaveTime = new Trend('progress_save_duration', true);
|
||||||
const completeLessonTime = new Trend('complete_lesson_duration', true);
|
const completeLessonTime = new Trend('complete_lesson_duration', true);
|
||||||
const completedCount = new Counter('completed_lessons');
|
const completedCount = new Counter('completed_lessons');
|
||||||
const progressSaveCount = new Counter('progress_saves');
|
const progressSaveCount = new Counter('progress_saves');
|
||||||
|
const videoLoadTime = new Trend('video_load_duration', true);
|
||||||
|
|
||||||
// ─── Load student credentials ────────────────────────────────────────────────
|
// ─── Load student credentials ────────────────────────────────────────────────
|
||||||
// อ่านจาก test-credentials.json (50 accounts)
|
// อ่านจาก test-credentials.json (50 accounts)
|
||||||
|
|
@ -42,7 +43,7 @@ const COURSE_ID = __ENV.COURSE_ID || '1';
|
||||||
const LESSON_ID = __ENV.LESSON_ID || '1';
|
const LESSON_ID = __ENV.LESSON_ID || '1';
|
||||||
|
|
||||||
// วีดีโอความยาว (วินาที) — ปรับตามจริง
|
// วีดีโอความยาว (วินาที) — ปรับตามจริง
|
||||||
const VIDEO_DURATION_SECONDS = parseInt(__ENV.VIDEO_DURATION || '300'); // default 5 นาที
|
const VIDEO_DURATION_SECONDS = parseInt(__ENV.VIDEO_DURATION || '98'); // default 5 นาที
|
||||||
|
|
||||||
// save progress interval: ทุก 5 วินาที (เหมือน client จริง)
|
// save progress interval: ทุก 5 วินาที (เหมือน client จริง)
|
||||||
// แต่ในการ test เราจะ simulate เร็วขึ้นโดยใช้ sleep น้อยลง
|
// แต่ในการ test เราจะ simulate เร็วขึ้นโดยใช้ sleep น้อยลง
|
||||||
|
|
@ -63,6 +64,7 @@ export const options = {
|
||||||
'login_duration': ['p(95)<2000'], // Login < 2s
|
'login_duration': ['p(95)<2000'], // Login < 2s
|
||||||
'course_learning_duration': ['p(95)<1000'], // Load course page < 1s
|
'course_learning_duration': ['p(95)<1000'], // Load course page < 1s
|
||||||
'lesson_load_duration': ['p(95)<1000'], // Load lesson < 1s
|
'lesson_load_duration': ['p(95)<1000'], // Load lesson < 1s
|
||||||
|
'video_load_duration': ['p(95)<3000'], // Fetch video from MinIO < 3s
|
||||||
'progress_save_duration': ['p(95)<500'], // Save progress < 500ms (critical — บ่อย)
|
'progress_save_duration': ['p(95)<500'], // Save progress < 500ms (critical — บ่อย)
|
||||||
'complete_lesson_duration': ['p(95)<1000'], // Complete lesson < 1s
|
'complete_lesson_duration': ['p(95)<1000'], // Complete lesson < 1s
|
||||||
|
|
||||||
|
|
@ -79,18 +81,24 @@ function jsonHeaders(token) {
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Per-VU persistent state (จำข้ามรอบ iteration) ──────────────────────────
|
||||||
|
// ตัวแปรนี้อยู่ระดับ module → k6 สร้างแยกต่างหากสำหรับแต่ละ VU
|
||||||
|
// ค่าจะถูกจำไว้ตลอดอายุของ VU (ข้ามหลายรอบ iteration)
|
||||||
|
let vuToken = null; // token ที่ login ไว้แล้ว
|
||||||
|
let vuSetupDone = false; // เคย load course+lesson แล้วหรือยัง
|
||||||
|
let vuProgress = 0; // ตำแหน่งวีดีโอปัจจุบัน (วินาที)
|
||||||
|
let vuCompleted = false; // lesson complete แล้วหรือยัง
|
||||||
|
|
||||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
export default function () {
|
export default function () {
|
||||||
// แต่ละ VU ใช้ account คนละคน (round-robin ถ้า VU มากกว่า 50)
|
|
||||||
const student = students[__VU % students.length];
|
const student = students[__VU % students.length];
|
||||||
|
|
||||||
let token = null;
|
// ── Step 1: Login (ทำครั้งเดียวตอน VU เริ่มต้น หรือถ้า token หาย) ─────────
|
||||||
|
if (!vuToken) {
|
||||||
// ── Step 1: Login ──────────────────────────────────────────────────────────
|
|
||||||
group('1. Login', () => {
|
group('1. Login', () => {
|
||||||
const res = http.post(
|
const res = http.post(
|
||||||
`${BASE_URL}/api/auth/login`,
|
`${BASE_URL}/api/auth/login`,
|
||||||
JSON.stringify({ identifier: student.email, password: student.password }),
|
JSON.stringify({ email: student.email, password: student.password }),
|
||||||
{ headers: jsonHeaders(null) }
|
{ headers: jsonHeaders(null) }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -100,95 +108,87 @@ export default function () {
|
||||||
|
|
||||||
check(res, {
|
check(res, {
|
||||||
'login: status 200': (r) => r.status === 200,
|
'login: status 200': (r) => r.status === 200,
|
||||||
'login: has token': (r) => {
|
'login: has token': (r) => { try { return !!r.json('data.token'); } catch { return false; } },
|
||||||
try { return !!r.json('data.token'); } catch { return false; }
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
try { token = res.json('data.token'); } catch {}
|
try { vuToken = res.json('data.token'); } catch {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!token) {
|
if (!vuToken) {
|
||||||
console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`);
|
console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`);
|
||||||
sleep(2);
|
sleep(2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sleep(1);
|
// ── Step 2 (removed): Enroll ทำผ่าน enroll-load-test.js แยกต่างหาก ─────────
|
||||||
|
|
||||||
// ── Step 2: Enroll (ถ้ายังไม่ได้ enroll) ──────────────────────────────────
|
// ── Step 3+4: Setup — Load course และ open lesson (ทำครั้งเดียวต่อ VU) ─────
|
||||||
group('2. Enroll Course', () => {
|
if (!vuSetupDone) {
|
||||||
const res = http.post(
|
|
||||||
`${BASE_URL}/api/students/courses/${COURSE_ID}/enroll`,
|
|
||||||
null,
|
|
||||||
{ headers: jsonHeaders(token) }
|
|
||||||
);
|
|
||||||
|
|
||||||
// 200 = enrolled OK, 409 = already enrolled (ทั้งคู่ถือว่าดี)
|
|
||||||
const ok = res.status === 200 || res.status === 409;
|
|
||||||
errorRate.add(!ok);
|
|
||||||
check(res, {
|
|
||||||
'enroll: enrolled or already enrolled': (r) => r.status === 200 || r.status === 409,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
sleep(1);
|
|
||||||
|
|
||||||
// ── Step 3: Load course learning page ─────────────────────────────────────
|
|
||||||
group('3. Load Course Learning Page', () => {
|
group('3. Load Course Learning Page', () => {
|
||||||
const res = http.get(
|
const res = http.get(
|
||||||
`${BASE_URL}/api/students/courses/${COURSE_ID}/learn`,
|
`${BASE_URL}/api/students/courses/${COURSE_ID}/learn`,
|
||||||
{ headers: jsonHeaders(token) }
|
{ headers: jsonHeaders(vuToken) }
|
||||||
);
|
);
|
||||||
|
|
||||||
courseLearningTime.add(res.timings.duration);
|
courseLearningTime.add(res.timings.duration);
|
||||||
const ok = res.status === 200;
|
errorRate.add(res.status !== 200);
|
||||||
errorRate.add(!ok);
|
check(res, { 'course learn: status 200': (r) => r.status === 200 });
|
||||||
check(res, {
|
|
||||||
'course learn: status 200': (r) => r.status === 200,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
sleep(1);
|
sleep(1);
|
||||||
|
|
||||||
// ── Step 4: Open lesson (load video content) ───────────────────────────────
|
let videoUrl = null;
|
||||||
group('4. Open Lesson', () => {
|
group('4. Open Lesson', () => {
|
||||||
const res = http.get(
|
const res = http.get(
|
||||||
`${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}`,
|
`${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}`,
|
||||||
{ headers: jsonHeaders(token) }
|
{ headers: jsonHeaders(vuToken) }
|
||||||
);
|
);
|
||||||
|
|
||||||
lessonLoadTime.add(res.timings.duration);
|
lessonLoadTime.add(res.timings.duration);
|
||||||
const ok = res.status === 200;
|
errorRate.add(res.status !== 200);
|
||||||
|
check(res, { 'lesson: status 200': (r) => r.status === 200 });
|
||||||
|
if (res.status === 200) {
|
||||||
|
try { videoUrl = res.json('data.video_url'); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step 4.5: Fetch video จาก MinIO ──────────────────────────────────────
|
||||||
|
if (videoUrl) {
|
||||||
|
group('4.5 Fetch Video from MinIO', () => {
|
||||||
|
const res = http.get(videoUrl, {
|
||||||
|
headers: { 'Range': 'bytes=0-1048575' }, // ขอแค่ 1MB แรก
|
||||||
|
timeout: '10s',
|
||||||
|
});
|
||||||
|
videoLoadTime.add(res.timings.duration);
|
||||||
|
const ok = res.status === 200 || res.status === 206;
|
||||||
errorRate.add(!ok);
|
errorRate.add(!ok);
|
||||||
check(res, {
|
check(res, {
|
||||||
'lesson: status 200': (r) => r.status === 200,
|
'minio video: 200 or 206': (r) => r.status === 200 || r.status === 206,
|
||||||
|
'minio video: fast': (r) => r.timings.duration < 3000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
console.log(`[VU ${__VU}] No video_url returned — skipping MinIO fetch`);
|
||||||
|
}
|
||||||
|
|
||||||
sleep(2); // รอ video buffer เล็กน้อย
|
sleep(2); // รอ buffer เริ่มต้น
|
||||||
|
vuSetupDone = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 5: Save Progress ทีละ tick (ต่อจากตำแหน่งเดิม) ────────────────────
|
||||||
|
// แต่ละ iteration ของ VU = ส่ง progress 1 ครั้ง แล้ว sleep ตาม interval จริง
|
||||||
|
if (!vuCompleted) {
|
||||||
|
vuProgress += PROGRESS_INTERVAL_SECONDS;
|
||||||
|
|
||||||
// ── Step 5: Simulate watching — save progress ทุก PROGRESS_INTERVAL วิ ─────
|
|
||||||
// จำลองการดูวีดีโอ: progress เพิ่มขึ้นเรื่อยๆ จนถึง 95% (auto-complete threshold)
|
|
||||||
group('5. Watch Video (Save Progress)', () => {
|
group('5. Watch Video (Save Progress)', () => {
|
||||||
const targetSeconds = Math.floor(VIDEO_DURATION_SECONDS * 0.95); // ดูถึง 95%
|
|
||||||
const steps = Math.ceil(targetSeconds / PROGRESS_INTERVAL_SECONDS); // จำนวนครั้งที่ save
|
|
||||||
|
|
||||||
// เพื่อไม่ให้ test นาน เกินไป จำกัดไว้ที่ 20 saves per VU iteration
|
|
||||||
const maxSaves = Math.min(steps, 20);
|
|
||||||
|
|
||||||
for (let i = 1; i <= maxSaves; i++) {
|
|
||||||
const currentSeconds = i * PROGRESS_INTERVAL_SECONDS;
|
|
||||||
|
|
||||||
const res = http.post(
|
const res = http.post(
|
||||||
`${BASE_URL}/api/students/lessons/${LESSON_ID}/progress`,
|
`${BASE_URL}/api/students/lessons/${LESSON_ID}/progress`,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
video_progress_seconds: currentSeconds,
|
video_progress_seconds: vuProgress,
|
||||||
video_duration_seconds: VIDEO_DURATION_SECONDS,
|
video_duration_seconds: VIDEO_DURATION_SECONDS,
|
||||||
}),
|
}),
|
||||||
{ headers: jsonHeaders(token) }
|
{ headers: jsonHeaders(vuToken) }
|
||||||
);
|
);
|
||||||
|
|
||||||
progressSaveTime.add(res.timings.duration);
|
progressSaveTime.add(res.timings.duration);
|
||||||
|
|
@ -201,32 +201,32 @@ export default function () {
|
||||||
'progress save: fast': (r) => r.timings.duration < 500,
|
'progress save: fast': (r) => r.timings.duration < 500,
|
||||||
});
|
});
|
||||||
|
|
||||||
// รอ interval จริง (ย่อจาก 5s จริง เหลือ 1s เพื่อให้ test ไม่นานเกิน)
|
console.log(`[VU ${__VU}] progress: ${vuProgress}s / ${VIDEO_DURATION_SECONDS}s (${Math.round(vuProgress / VIDEO_DURATION_SECONDS * 100)}%)`);
|
||||||
sleep(1);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Step 6: Mark lesson complete ───────────────────────────────────────────
|
// ── Step 6: Mark complete เมื่อดูครบ ≥95% ──────────────────────────────
|
||||||
|
if (vuProgress >= VIDEO_DURATION_SECONDS * 0.95) {
|
||||||
group('6. Complete Lesson', () => {
|
group('6. Complete Lesson', () => {
|
||||||
const res = http.post(
|
const res = http.post(
|
||||||
`${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}/complete`,
|
`${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}/complete`,
|
||||||
null,
|
null,
|
||||||
{ headers: jsonHeaders(token) }
|
{ headers: jsonHeaders(vuToken) }
|
||||||
);
|
);
|
||||||
|
|
||||||
completeLessonTime.add(res.timings.duration);
|
completeLessonTime.add(res.timings.duration);
|
||||||
const ok = res.status === 200;
|
|
||||||
|
|
||||||
// 200 = completed now, อาจ 409 ถ้า already completed (ถือว่าโอเค)
|
|
||||||
errorRate.add(res.status !== 200 && res.status !== 409);
|
errorRate.add(res.status !== 200 && res.status !== 409);
|
||||||
if (ok) completedCount.add(1);
|
if (res.status === 200) completedCount.add(1);
|
||||||
|
|
||||||
check(res, {
|
check(res, {
|
||||||
'complete: status 200 or 409': (r) => r.status === 200 || r.status === 409,
|
'complete: status 200 or 409': (r) => r.status === 200 || r.status === 409,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
sleep(1);
|
vuCompleted = true;
|
||||||
|
console.log(`[VU ${__VU}] ✓ Lesson completed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleep ตาม interval จริง — ทุก VU ส่ง progress ทุก PROGRESS_INTERVAL_SECONDS วินาที
|
||||||
|
sleep(PROGRESS_INTERVAL_SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -251,6 +251,7 @@ export function handleSummary(data) {
|
||||||
║ Login : ${avg('login_duration')}ms / ${p95('login_duration')}ms${' '.repeat(Math.max(0, 20 - avg('login_duration').length - p95('login_duration').length))}║
|
║ Login : ${avg('login_duration')}ms / ${p95('login_duration')}ms${' '.repeat(Math.max(0, 20 - avg('login_duration').length - p95('login_duration').length))}║
|
||||||
║ Course Learning Page: ${avg('course_learning_duration')}ms / ${p95('course_learning_duration')}ms${' '.repeat(Math.max(0, 20 - avg('course_learning_duration').length - p95('course_learning_duration').length))}║
|
║ Course Learning Page: ${avg('course_learning_duration')}ms / ${p95('course_learning_duration')}ms${' '.repeat(Math.max(0, 20 - avg('course_learning_duration').length - p95('course_learning_duration').length))}║
|
||||||
║ Lesson Load : ${avg('lesson_load_duration')}ms / ${p95('lesson_load_duration')}ms${' '.repeat(Math.max(0, 20 - avg('lesson_load_duration').length - p95('lesson_load_duration').length))}║
|
║ Lesson Load : ${avg('lesson_load_duration')}ms / ${p95('lesson_load_duration')}ms${' '.repeat(Math.max(0, 20 - avg('lesson_load_duration').length - p95('lesson_load_duration').length))}║
|
||||||
|
║ MinIO Video Fetch : ${avg('video_load_duration')}ms / ${p95('video_load_duration')}ms${' '.repeat(Math.max(0, 20 - avg('video_load_duration').length - p95('video_load_duration').length))}║
|
||||||
║ Save Progress : ${avg('progress_save_duration')}ms / ${p95('progress_save_duration')}ms${' '.repeat(Math.max(0, 20 - avg('progress_save_duration').length - p95('progress_save_duration').length))}║
|
║ Save Progress : ${avg('progress_save_duration')}ms / ${p95('progress_save_duration')}ms${' '.repeat(Math.max(0, 20 - avg('progress_save_duration').length - p95('progress_save_duration').length))}║
|
||||||
║ Complete Lesson : ${avg('complete_lesson_duration')}ms / ${p95('complete_lesson_duration')}ms${' '.repeat(Math.max(0, 20 - avg('complete_lesson_duration').length - p95('complete_lesson_duration').length))}║
|
║ Complete Lesson : ${avg('complete_lesson_duration')}ms / ${p95('complete_lesson_duration')}ms${' '.repeat(Math.max(0, 20 - avg('complete_lesson_duration').length - p95('complete_lesson_duration').length))}║
|
||||||
╠══════════════════════════════════════════════════════════╣
|
╠══════════════════════════════════════════════════════════╣
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue