Compare commits

..

9 commits

Author SHA1 Message Date
MathieuSevignyLavallee
1e67762b5e cleanup 2024-12-06 19:12:29 -05:00
d3199b9f3f adds env for network name 2024-12-06 18:35:32 -05:00
d9e5d6a91f Merge branch 'stress-test-socket' of github.com:ets-cfuhrman-pfe/EvalueTonSavoir into stress-test-socket 2024-12-06 18:19:25 -05:00
d4e13b8c36 template to add env variables 2024-12-06 18:19:23 -05:00
MathieuSevignyLavallee
3744bf4347 typo gitignore 2024-12-06 11:26:35 -05:00
MathieuSevignyLavallee
ec0cc48ae7 Create test.txt 2024-12-06 11:25:15 -05:00
MathieuSevignyLavallee
20fe4e673a Revert "gitignore"
This reverts commit 6d988c347f.
2024-12-06 11:24:57 -05:00
MathieuSevignyLavallee
6d988c347f gitignore 2024-12-06 11:21:10 -05:00
MathieuSevignyLavallee
80610c3a6e pas terminer a besoin de pofinage 2024-12-05 20:24:56 -05:00
9 changed files with 189 additions and 480 deletions

View file

@ -63,6 +63,12 @@ services:
networks:
- quiz_network
restart: always
#environment:
# - PORT=8000
# - FRONTEND_HOST=frontend
# - FRONTEND_PORT=5173
# - BACKEND_HOST=backend
# - BACKEND_PORT=3000
mongo:
image: mongo

5
nginx/.env.example Normal file
View file

@ -0,0 +1,5 @@
PORT=80
FRONTEND_HOST=frontend
FRONTEND_PORT=5173
BACKEND_HOST=backend
BACKEND_PORT=3000

View file

@ -1,20 +1,16 @@
# Stage 1: Build stage
FROM nginx:1.27-alpine AS builder
# Install required packages
RUN apk add --no-cache nginx-mod-http-js nginx-mod-http-keyval
# Stage 2: Final stage
FROM alpine:3.19
# Copy Nginx and NJS modules from builder
COPY --from=builder /usr/sbin/nginx /usr/sbin/
COPY --from=builder /usr/lib/nginx/modules/ /usr/lib/nginx/modules/
COPY --from=builder /etc/nginx/ /etc/nginx/
COPY --from=builder /usr/lib/nginx/ /usr/lib/nginx/
# Install required runtime dependencies
# Install gettext for envsubst and other dependencies
RUN apk add --no-cache \
gettext \
nginx-mod-http-js \
nginx-mod-http-keyval \
pcre2 \
ca-certificates \
pcre \
@ -24,15 +20,30 @@ RUN apk add --no-cache \
libxml2 \
libedit \
geoip \
libxslt \
&& mkdir -p /var/cache/nginx \
libxslt
# Create base nginx directory
RUN mkdir -p /etc/nginx
# Copy Nginx and NJS modules from builder
COPY --from=builder /usr/sbin/nginx /usr/sbin/
COPY --from=builder /usr/lib/nginx/modules/ /usr/lib/nginx/modules/
RUN rm -rf /etc/nginx/*
COPY --from=builder /etc/nginx/ /etc/nginx/
COPY --from=builder /usr/lib/nginx/ /usr/lib/nginx/
# Setup directories and permissions
RUN mkdir -p /var/cache/nginx \
&& mkdir -p /var/log/nginx \
&& mkdir -p /etc/nginx/conf.d \
&& mkdir -p /etc/nginx/njs \
&& ln -sf /dev/stdout /var/log/nginx/access.log \
&& ln -sf /dev/stderr /var/log/nginx/error.log \
&& addgroup -S nginx \
&& adduser -D -S -h /var/cache/nginx -s /sbin/nologin -G nginx nginx
&& mkdir -p /etc/nginx/templates \
&& chown -R nginx:nginx /var/cache/nginx \
&& chown -R nginx:nginx /var/log/nginx \
&& chown -R nginx:nginx /etc/nginx \
&& touch /var/run/nginx.pid \
&& chown nginx:nginx /var/run/nginx.pid \
&& chmod 777 /var/log/nginx
# Copy necessary libraries from builder
COPY --from=builder /usr/lib/libxml2.so* /usr/lib/
@ -45,25 +56,29 @@ RUN echo 'load_module modules/ngx_http_js_module.so;' > /tmp/nginx.conf && \
cat /etc/nginx/nginx.conf >> /tmp/nginx.conf && \
mv /tmp/nginx.conf /etc/nginx/nginx.conf
# Copy our configuration
COPY conf.d/default.conf /etc/nginx/conf.d/
# Copy configurations
COPY templates/default.conf /etc/nginx/templates/
COPY njs/main.js /etc/nginx/njs/
COPY entrypoint.sh /entrypoint.sh
# Set proper permissions
RUN chown -R nginx:nginx /var/cache/nginx \
&& chown -R nginx:nginx /var/log/nginx \
&& chown -R nginx:nginx /etc/nginx/conf.d \
&& touch /var/run/nginx.pid \
&& chown -R nginx:nginx /var/run/nginx.pid
ENV PORT=80 \
FRONTEND_HOST=frontend \
FRONTEND_PORT=5173 \
BACKEND_HOST=backend \
BACKEND_PORT=3000
# Verify the configuration
# RUN nginx -t --dry-run
# Set final permissions
RUN chmod +x /entrypoint.sh && \
chown -R nginx:nginx /etc/nginx && \
chown -R nginx:nginx /var/log/nginx && \
chown -R nginx:nginx /var/cache/nginx && \
chmod 755 /etc/nginx && \
chmod 777 /etc/nginx/conf.d && \
chmod 644 /etc/nginx/templates/default.conf && \
chmod 644 /etc/nginx/conf.d/default.conf
# Switch to non-root user
# Switch to nginx user
USER nginx
# Expose HTTP port
EXPOSE 80
# Start Nginx
CMD ["nginx", "-g", "daemon off;"]
# Start Nginx using entrypoint script
ENTRYPOINT ["/entrypoint.sh"]

10
nginx/entrypoint.sh Normal file
View file

@ -0,0 +1,10 @@
#!/bin/sh
# entrypoint.sh
# We are already running as nginx user
envsubst '${PORT} ${FRONTEND_HOST} ${FRONTEND_PORT} ${BACKEND_HOST} ${BACKEND_PORT}' \
< /etc/nginx/templates/default.conf \
> /etc/nginx/conf.d/default.conf
# Start nginx
exec nginx -g "daemon off;"

View file

@ -8,15 +8,15 @@ map $http_upgrade $connection_upgrade {
}
upstream frontend {
server frontend:5173;
server ${FRONTEND_HOST}:${FRONTEND_PORT};
}
upstream backend {
server backend:3000;
server ${BACKEND_HOST}:${BACKEND_PORT};
}
server {
listen 80;
listen ${PORT};
set $proxy_target "";

View file

@ -8,7 +8,7 @@ class DockerRoomProvider extends BaseRoomProvider {
const dockerSocket = process.env.DOCKER_SOCKET || "/var/run/docker.sock";
this.docker = new Docker({ socketPath: dockerSocket });
this.docker_network = 'evaluetonsavoir_quiz_network';
this.docker_network = process.env.QUIZ_NETWORK_NAME || 'evaluetonsavoir_quiz_network';
}
async syncInstantiatedRooms() {

View file

@ -39,14 +39,12 @@ export class Watcher extends RoomParticipant {
}
this.checkRessourceInterval = setInterval(() => this.checkRessource(), intervalMs);
console.log(`Started resource checking for room ${this.roomName}.`);
}
stopCheckingResources() {
if (this.checkRessourceInterval) {
clearInterval(this.checkRessourceInterval);
this.checkRessourceInterval = null;
console.log(`Stopped resource checking for room ${this.roomName}.`);
}
}

View file

@ -5,255 +5,157 @@ import { Watcher } from './class/watcher.js';
import dotenv from 'dotenv';
import generateMetricsReport from './utility/metrics_generator.js';
// Load environment variables
dotenv.config();
const BASE_URL = process.env.BASE_URL || 'http://localhost';
const user = {
username: process.env.USER_EMAIL || 'admin@admin.com',
password: process.env.USER_PASSWORD || 'admin'
const config = {
baseUrl: process.env.BASE_URL || 'http://msevignyl.duckdns.org',
auth: {
username: process.env.USER_EMAIL || 'admin@admin.com',
password: process.env.USER_PASSWORD || 'admin'
},
rooms: {
count: parseInt(process.env.NUMBER_ROOMS || '5'),
usersPerRoom: parseInt(process.env.USERS_PER_ROOM || '60'),
batchSize: 5,
batchDelay: 250
},
simulation: {
maxMessages: parseInt(process.env.MAX_MESSAGES || '20'),
messageInterval: parseInt(process.env.CONVERSATION_INTERVAL || '1000'),
responseTimeout: 5000
}
};
const numberRooms = parseInt(process.env.NUMBER_ROOMS || '4');
const usersPerRoom = parseInt(process.env.USERS_PER_ROOM || '60');
const roomAssociations = {};
const maxMessages = parseInt(process.env.MAX_MESSAGES || '20');
const conversationInterval = parseInt(process.env.CONVERSATION_INTERVAL || '1000');
const batchSize = 5;
const batchDelay = 250;
const roomDelay = 500;
/**
* Creates a room and immediately connects a teacher to it.
*/
async function createRoomWithTeacher(token, index) {
const rooms = new Map();
async function setupRoom(token, index) {
try {
const room = await createRoomContainer(BASE_URL, token);
if (!room?.id) {
throw new Error('Room creation failed');
}
const room = await createRoomContainer(config.baseUrl, token);
if (!room?.id) throw new Error('Room creation failed');
// Initialize room associations
roomAssociations[room.id] = { watcher: null, teacher: null, students: [] };
// Create and connect teacher immediately
const teacher = new Teacher(`teacher_${index}`, room.id);
roomAssociations[room.id].teacher = teacher;
const watcher = new Watcher(`watcher_${index}`, room.id);
await Promise.all([
teacher.connectToRoom(config.baseUrl)
.catch(err => console.warn(`Teacher ${index} connection failed:`, err.message)),
watcher.connectToRoom(config.baseUrl)
.catch(err => console.warn(`Watcher ${index} connection failed:`, err.message))
]);
// Connect teacher to room
await teacher.connectToRoom(BASE_URL);
const students = Array.from({ length: config.rooms.usersPerRoom - 2 },
(_, i) => new Student(`student_${index}_${i}`, room.id));
rooms.set(room.id, { teacher, watcher, students });
return room.id;
} catch (err) {
console.warn(`Failed to create/connect room ${index + 1}:`, err.message);
console.warn(`Room ${index} setup failed:`, err.message);
return null;
}
}
/**
* Creates rooms and connects teachers with controlled concurrency.
*/
async function createRoomContainers() {
console.log('Attempting login or register to get token');
const token = await attemptLoginOrRegister(BASE_URL, user.username, user.password);
if (!token) throw new Error('Failed to login or register.');
async function connectParticipants(roomId) {
const { students } = rooms.get(roomId);
const participants = [...students];
console.log('Room creation with immediate teacher connection');
const roomPromises = Array.from({ length: numberRooms }, (_, index) =>
createRoomWithTeacher(token, index)
for (let i = 0; i < participants.length; i += config.rooms.batchSize) {
const batch = participants.slice(i, i + config.rooms.batchSize);
await Promise.all(batch.map(p =>
Promise.race([
p.connectToRoom(config.baseUrl),
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 10000))
]).catch(err => console.warn(`Connection failed for ${p.username}:`, err.message))
));
await new Promise(resolve => setTimeout(resolve, config.rooms.batchDelay));
}
}
async function simulate() {
const simulations = Array.from(rooms.entries()).map(async ([roomId, { teacher, students }]) => {
const connectedStudents = students.filter(student => student.socket?.connected);
const expectedResponses = connectedStudents.length;
for (let i = 0; i < config.simulation.maxMessages; i++) {
const initialMessages = teacher.nbrMessageReceived;
teacher.broadcastMessage(`Message ${i + 1} from ${teacher.username}`);
try {
await Promise.race([
new Promise(resolve => {
const checkResponses = setInterval(() => {
const receivedResponses = teacher.nbrMessageReceived - initialMessages;
if (receivedResponses >= expectedResponses) {
clearInterval(checkResponses);
resolve();
}
}, 100);
})
]);
} catch (error) {
console.error(`Error in room ${roomId} message ${i + 1}:`, error);
}
await new Promise(resolve => setTimeout(resolve, config.simulation.messageInterval));
}
});
// Wait for all simulations to complete
await Promise.all(simulations);
console.log('All room simulations completed');
}
async function generateReport() {
const data = Object.fromEntries(
Array.from(rooms.entries()).map(([id, { watcher }]) => [
id,
watcher.roomRessourcesData
])
);
const results = await Promise.allSettled(roomPromises);
const successfulRooms = results.filter(r => r.status === 'fulfilled' && r.value).length;
console.log(`Total rooms created and connected (${numberRooms}): ${successfulRooms}`);
console.log('Finished room creation and teacher connection');
return generateMetricsReport(data);
}
/**
* Adds remaining participants (watcher, students) to rooms.
*/
function addRemainingUsers() {
console.log('Adding remaining room participants');
Object.keys(roomAssociations).forEach((roomId, roomIndex) => {
const participants = roomAssociations[roomId];
// Add watcher
participants.watcher = new Watcher(`watcher_${roomIndex}`, roomId);
// Add students
for (let i = 0; i < usersPerRoom - 2; i++) {
participants.students.push(new Student(`student_${roomIndex}_${i}`, roomId));
}
});
console.log('Finished adding remaining room participants');
}
/**
* Connects remaining participants to their respective rooms.
*/
async function connectRemainingParticipants(baseUrl) {
console.log('Connecting remaining participants in batches');
for (const [roomId, participants] of Object.entries(roomAssociations)) {
console.log(`Processing room ${roomId}`);
const remainingParticipants = [
participants.watcher,
...participants.students
].filter(Boolean);
// Connect in smaller batches with longer delays
for (let i = 0; i < remainingParticipants.length; i += batchSize) {
const batch = remainingParticipants.slice(i, i + batchSize);
// Add connection timeout handling
const batchPromises = batch.map(participant =>
Promise.race([
participant.connectToRoom(baseUrl),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Connection timeout')), 10000)
)
]).catch(err => {
console.warn(
`Failed to connect ${participant.username} in room ${roomId}:`,
err.message
);
return null;
})
);
await Promise.all(batchPromises);
// Cleanup disconnected sockets
batch.forEach(participant => {
if (!participant.socket?.connected) {
participant.disconnect();
}
});
await new Promise(resolve => setTimeout(resolve, batchDelay));
}
// Add delay between rooms
await new Promise(resolve => setTimeout(resolve, roomDelay));
function cleanup() {
for (const { teacher, watcher, students } of rooms.values()) {
[teacher, watcher, ...students].forEach(p => p?.disconnect());
}
console.log('Finished connecting remaining participants');
}
// Rest of the code remains the same
async function simulateParticipants() {
const conversationPromises = Object.entries(roomAssociations).map(async ([roomId, participants]) => {
const { teacher, students } = participants;
if (!teacher || students.length === 0) {
console.warn(`Room ${roomId} has no teacher or students to simulate.`);
return;
}
console.log(`Starting simulation for room ${roomId}`);
await new Promise(resolve => setTimeout(resolve, 2000));
for (let i = 0; i < maxMessages; i++) {
const teacherMessage = `Message ${i + 1} from ${teacher.username}`;
teacher.broadcastMessage(teacherMessage);
await new Promise(resolve => setTimeout(resolve, conversationInterval));
}
console.log(`Finished simulation for room ${roomId}`);
});
await Promise.all(conversationPromises);
}
function disconnectParticipants() {
console.time('Disconnecting participants');
Object.values(roomAssociations).forEach(participants => {
participants.teacher?.disconnect();
participants.watcher?.disconnect();
participants.students.forEach(student => student.disconnect());
});
console.timeEnd('Disconnecting participants');
console.log('All participants disconnected successfully.');
}
async function wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function generateExecutionData() {
console.log('Generating execution data');
const allRoomsData = {};
for (const [roomId, participants] of Object.entries(roomAssociations)) {
if (participants.watcher?.roomRessourcesData.length > 0) {
// Add phase markers to the data
const data = participants.watcher.roomRessourcesData;
const simulationStartIdx = 20; // Assuming first 20 samples are baseline
const simulationEndIdx = data.length - 20; // Last 20 samples are post-simulation
data.forEach((sample, index) => {
if (index < simulationStartIdx) {
sample.phase = 'baseline';
} else if (index > simulationEndIdx) {
sample.phase = 'post-simulation';
} else {
sample.phase = 'simulation';
}
});
allRoomsData[roomId] = data;
}
}
const result = await generateMetricsReport(allRoomsData);
console.log(`Generated metrics in ${result.outputDir}`);
console.log('Finished generating execution data');
}
async function main() {
try {
await createRoomContainers();
addRemainingUsers();
await connectRemainingParticipants(BASE_URL);
const token = await attemptLoginOrRegister(config.baseUrl, config.auth.username, config.auth.password);
if (!token) throw new Error('Authentication failed');
// Wait for initial baseline metrics
console.log('Collecting baseline metrics...');
await wait(5000);
console.log('Creating rooms...');
const roomIds = await Promise.all(
Array.from({ length: config.rooms.count }, (_, i) => setupRoom(token, i))
);
await simulateParticipants();
console.log('Connecting participants...');
await Promise.all(roomIds.filter(Boolean).map(connectParticipants));
console.log('Waiting for system to stabilize...');
await wait(5000); // 5 second delay
await generateExecutionData();
console.log('All tasks completed successfully!');
console.log('Retrieving baseline metrics...');
await new Promise(resolve => setTimeout(resolve, 10000)); // Baseline metrics
console.log('Starting simulation across all rooms...');
await simulate();
console.log('Simulation complete. Waiting for system stabilization...');
await new Promise(resolve => setTimeout(resolve, 10000)); // System stabilization
console.log('All simulations finished, generating final report...');
const folderName = await generateReport();
console.log(`Metrics report generated in ${folderName.outputDir}`);
console.log('All done!');
} catch (error) {
console.error('Error:', error.message);
} finally {
cleanup();
}
}
// Graceful shutdown handlers
process.on('SIGINT', () => {
console.log('Process interrupted (Ctrl+C).');
disconnectParticipants();
process.exit(0);
});
process.on('exit', disconnectParticipants);
process.on('uncaughtException', err => {
console.error('Uncaught Exception:', err);
disconnectParticipants();
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
disconnectParticipants();
process.exit(1);
['SIGINT', 'exit', 'uncaughtException', 'unhandledRejection'].forEach(event => {
process.on(event, cleanup);
});
main();

View file

@ -1,227 +0,0 @@
import fs from "fs";
import path from "path";
import { ChartJSNodeCanvas } from "chartjs-node-canvas";
// Ensure a directory exists, creating it if necessary
function ensureDirectoryExists(directory) {
try {
fs.mkdirSync(directory, { recursive: true });
} catch (err) {
console.error(`Error creating directory ${directory}:`, err.message);
throw err;
}
}
// Write metrics to a JSON file
export function writeMetricsToFile(metrics) {
if (!metrics.endTime) {
console.error("Error: metrics.endTime is not defined. Ensure it is set before calling this function.");
return;
}
const directory = `./metrics/${metrics.startTime.toISOString().replace(/[:.]/g, "-")}`;
const filename = path.join(directory, "metrics_report.json");
// Ensure the directory exists
ensureDirectoryExists(directory);
const metricsData = {
summary: {
roomsCreated: metrics.roomsCreated,
roomsFailed: metrics.roomsFailed,
teachersConnected: metrics.teachersConnected,
teachersFailed: metrics.teachersFailed,
studentsConnected: metrics.studentsConnected,
studentsFailed: metrics.studentsFailed,
},
messages: {
messagesSent: metrics.messagesSent,
messagesReceived: metrics.messagesReceived,
throughput: metrics.throughput.toFixed(2), // Messages per second
},
latencies: {
totalLatency: metrics.totalLatency,
averageLatency: metrics.teachersConnected + metrics.studentsConnected
? (metrics.totalLatency / (metrics.teachersConnected + metrics.studentsConnected)).toFixed(2)
: null,
maxLatency: metrics.maxLatency,
minLatency: metrics.minLatency,
},
timing: {
startTime: metrics.startTime?.toISOString(),
endTime: metrics.endTime?.toISOString(),
executionTimeInSeconds: metrics.endTime && metrics.startTime
? (metrics.endTime - metrics.startTime) / 1000
: null,
},
};
// Write metrics to a file
fs.writeFile(filename, JSON.stringify(metricsData, null, 4), (err) => {
if (err) {
console.error(`Error writing metrics to file:`, err.message);
} else {
console.log(`Metrics saved to file: ${filename}`);
}
});
}
// Generate charts for resource data
export async function generateGraphs(resourceData, metrics) {
if (!metrics.endTime) {
console.error("Error: metrics.endTime is not defined. Ensure it is set before calling this function.");
return;
}
const directory = `./metrics/${metrics.startTime.toISOString().replace(/[:.]/g, "-")}`;
ensureDirectoryExists(directory);
const chartJSNodeCanvas = new ChartJSNodeCanvas({ width: 800, height: 600 });
// Aggregated data for all containers
const aggregatedTimestamps = [];
const aggregatedMemoryUsage = [];
const aggregatedCpuUserPercentage = [];
const aggregatedCpuSystemPercentage = [];
// Generate charts for individual containers
for (const [roomId, data] of Object.entries(resourceData)) {
if (!data || !data.length) {
console.warn(`No data available for room ${roomId}. Skipping individual charts.`);
continue;
}
// Extract data
const timestamps = data.map((point) => new Date(point.timestamp).toLocaleTimeString());
const memoryUsage = data.map((point) => (point.memory.rss || 0) / (1024 * 1024)); // MB
const cpuUserPercentage = data.map((point) => point.cpu.userPercentage || 0);
const cpuSystemPercentage = data.map((point) => point.cpu.systemPercentage || 0);
// Update aggregated data
data.forEach((point, index) => {
if (!aggregatedTimestamps[index]) {
aggregatedTimestamps[index] = timestamps[index];
aggregatedMemoryUsage[index] = 0;
aggregatedCpuUserPercentage[index] = 0;
aggregatedCpuSystemPercentage[index] = 0;
}
aggregatedMemoryUsage[index] += memoryUsage[index];
aggregatedCpuUserPercentage[index] += cpuUserPercentage[index];
aggregatedCpuSystemPercentage[index] += cpuSystemPercentage[index];
});
// Memory usage chart
await saveChart(
chartJSNodeCanvas,
{
labels: timestamps,
datasets: [
{
label: "Memory Usage (MB)",
data: memoryUsage,
borderColor: "blue",
fill: false,
},
],
},
"Time",
"Memory Usage (MB)",
path.join(directory, `memory-usage-room-${roomId}.png`)
);
// CPU usage chart
await saveChart(
chartJSNodeCanvas,
{
labels: timestamps,
datasets: [
{
label: "CPU User Usage (%)",
data: cpuUserPercentage,
borderColor: "red",
fill: false,
},
{
label: "CPU System Usage (%)",
data: cpuSystemPercentage,
borderColor: "orange",
fill: false,
},
],
},
"Time",
"CPU Usage (%)",
path.join(directory, `cpu-usage-room-${roomId}.png`)
);
console.log(`Charts generated for room ${roomId}`);
}
// Ensure aggregated data is not empty
if (!aggregatedTimestamps.length) {
console.error("Error: Aggregated data is empty. Verify container data.");
return;
}
// Aggregated memory usage chart
await saveChart(
chartJSNodeCanvas,
{
labels: aggregatedTimestamps,
datasets: [
{
label: "Total Memory Usage (MB)",
data: aggregatedMemoryUsage,
borderColor: "blue",
fill: false,
},
],
},
"Time",
"Memory Usage (MB)",
path.join(directory, "aggregated-memory-usage.png")
);
// Aggregated CPU usage chart
await saveChart(
chartJSNodeCanvas,
{
labels: aggregatedTimestamps,
datasets: [
{
label: "Total CPU User Usage (%)",
data: aggregatedCpuUserPercentage,
borderColor: "red",
fill: false,
},
{
label: "Total CPU System Usage (%)",
data: aggregatedCpuSystemPercentage,
borderColor: "orange",
fill: false,
},
],
},
"Time",
"CPU Usage (%)",
path.join(directory, "aggregated-cpu-usage.png")
);
console.log("Aggregated charts generated.");
}
// Helper function to save a chart
async function saveChart(chartJSNodeCanvas, data, xLabel, yLabel, outputFile) {
const chartConfig = {
type: "line",
data,
options: {
scales: {
x: { title: { display: true, text: xLabel } },
y: { title: { display: true, text: yLabel } },
},
},
};
const chartBuffer = await chartJSNodeCanvas.renderToBuffer(chartConfig);
fs.writeFileSync(outputFile, chartBuffer);
}