diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 00000000..6213c8f2 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,40 @@ +name: Build and Deploy to GitHub Pages + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Build Docker image + run: docker build ./portal --file ./portal/Dockerfile --tag my-website-image + + - name: Run Docker container + run: | + docker run --name my-website-container -d my-website-image + # Wait a few seconds to ensure the web server inside the container is fully up and running + sleep 10 + + - name: Copy static content from Docker container + run: | + mkdir -p static-content + docker cp my-website-container:/usr/share/nginx/html/qujata ./static-content + + - name: Stop and remove Docker container + run: | + docker stop my-website-container + docker rm my-website-container + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./static-content/qujata diff --git a/api/README.md b/api/README.md index bd2a2ca4..9e6e8a8d 100644 --- a/api/README.md +++ b/api/README.md @@ -31,8 +31,11 @@ python3 -m src.main curl --location 'http://localhost:3020/qujata-api/analyze' \ --header 'Content-Type: application/json' \ --data '{ + "experimentName": "name", + "description" : "test description", "algorithms": ["kyber512"], - "iterationsCount": 5 + "iterationsCount": [5], + "messageSizes": [10] }' ``` diff --git a/api/src/api/analyze_api.py b/api/src/api/analyze_api.py index f430356e..91dd3028 100644 --- a/api/src/api/analyze_api.py +++ b/api/src/api/analyze_api.py @@ -41,6 +41,10 @@ def __validate(data): for iterations in data['iterationsCount']: if iterations <= 0: raise ApiException('The number of iterations should be greater than 0', INVALID_DATA_MESSAGE, HTTP_STATUS_BAD_REQUEST) + if 'messageSizes' in data: + for message_size in data['messageSizes']: + if message_size < 0: + raise ApiException('The message size should be greater than -1', INVALID_DATA_MESSAGE, HTTP_STATUS_BAD_REQUEST) if process_is_running: raise ApiException('The previous test is still running. Please try again in few minutes', 'Current test is still running', HTTP_STATUS_LOCKED) for algorithm in data['algorithms']: diff --git a/api/src/services/analyze_service.py b/api/src/services/analyze_service.py index d333f141..ef07c43d 100644 --- a/api/src/services/analyze_service.py +++ b/api/src/services/analyze_service.py @@ -9,13 +9,7 @@ from flask import jsonify, current_app import src.services.test_suites_service as test_suites_service import src.services.metrics_service as metrics_service -from src.models.env_info import EnvInfo -from src.models.test_suite import TestSuite -from src.models.test_run import TestRun from src.enums.status import Status -from src.exceptions.exceptions import ApiException - - # constants WAIT_MS = 15 @@ -26,14 +20,16 @@ def analyze(data): start_time = int(datetime.timestamp(datetime.now() - timedelta(seconds=60)) * 1000) iterations_count = data['iterationsCount'] algorithms = data['algorithms'] + message_sizes = data['messageSizes'] if 'messageSizes' in data else [0] first_run = True for algorithm in algorithms: for iterations in iterations_count: - if not first_run: - time.sleep(WAIT_MS) - else: - first_run = False - __create_test_run(algorithm, iterations, test_suite.id) + for message_size in message_sizes: + if not first_run: + time.sleep(WAIT_MS) + else: + first_run = False + __create_test_run(algorithm, iterations, message_size, test_suite.id) # end time is now + 90 sec, to show the graph after the test for sure finished running end_time = int(datetime.timestamp(datetime.now() + timedelta(seconds=90)) * 1000) @@ -45,20 +41,21 @@ def analyze(data): return jsonify({'test_suite_id': test_suite.id}) -def __create_test_run(algorithm, iterations, test_suite_id): +def __create_test_run(algorithm, iterations, message_size, test_suite_id): start_time = datetime.now() metrics_service.start_collecting() - status, status_message = __run(algorithm, iterations) + status, status_message = __run(algorithm, iterations, message_size) metrics_service.stop_collecting() end_time = datetime.now() - test_suites_service.create_test_run(start_time, end_time, algorithm, iterations, test_suite_id, status, status_message, *metrics_service.get_metrics()) + test_suites_service.create_test_run(start_time, end_time, algorithm, iterations, message_size, test_suite_id, status, status_message, *metrics_service.get_metrics()) -def __run(algorithm, iterations): +def __run(algorithm, iterations, message_size): logging.debug('Running test for algorithm: %s ', algorithm) payload = { 'algorithm': algorithm, - 'iterationsCount': iterations + 'iterationsCount': iterations, + 'messageSize': message_size } headers = { 'Content-Type': 'application/json' } response = requests.post(current_app.configurations.curl_url + "/curl", headers=headers, json=payload, timeout=int(current_app.configurations.request_timeout)) diff --git a/api/src/services/cadvisor_service.py b/api/src/services/cadvisor_service.py index 8af6be8c..77383386 100644 --- a/api/src/services/cadvisor_service.py +++ b/api/src/services/cadvisor_service.py @@ -1,7 +1,6 @@ import src.services.k8s_service as k8s_service import requests import pandas as pd -import logging from src.enums.environment import Environment DOCKER_METRICS_URL = "{}/api/v1.3/docker/{}" @@ -28,7 +27,7 @@ def get_metrics_url(service_name): elif __environment == Environment.KUBERNETES.value: return __build_k8s_metrics_url(service_name) else: - raise RuntimeError("Invalid Environemnt: " + __environment) + raise RuntimeError("Invalid Environment: " + __environment) def __build_docker_metrics_url(service_name): diff --git a/api/src/services/metrics_service.py b/api/src/services/metrics_service.py index a53f51a1..37f0a8d8 100644 --- a/api/src/services/metrics_service.py +++ b/api/src/services/metrics_service.py @@ -1,5 +1,3 @@ -from flask import current_app -from src.models.test_run_metric import TestRunMetric from src.utils.metrics_collector import MetricsCollector import logging diff --git a/api/src/services/test_suites_service.py b/api/src/services/test_suites_service.py index 0db4f485..c691f8e6 100644 --- a/api/src/services/test_suites_service.py +++ b/api/src/services/test_suites_service.py @@ -36,7 +36,7 @@ def create_test_suite(data): current_app.database_manager.create(test_suite) return test_suite -def create_test_run(start_time, end_time, algorithm, iterations, test_suite_id, status, status_message, client_metrics, server_metrics): +def create_test_run(start_time, end_time, algorithm, iterations, message_size, test_suite_id, status, status_message, client_metrics, server_metrics): test_run = TestRun( start_time=start_time, end_time=end_time, @@ -44,7 +44,7 @@ def create_test_run(start_time, end_time, algorithm, iterations, test_suite_id, iterations=iterations, status=status, status_message=status_message, - # message_size=1024, + message_size=message_size, test_suite_id=test_suite_id ) current_app.database_manager.create(test_run) diff --git a/api/src/utils/test_suite_serializer.py b/api/src/utils/test_suite_serializer.py index eca362f9..1d13f018 100644 --- a/api/src/utils/test_suite_serializer.py +++ b/api/src/utils/test_suite_serializer.py @@ -5,11 +5,11 @@ def serialize(test_suite): "id": test_suite.id, "name": test_suite.name, "description": test_suite.description, - "codeRelease": test_suite.code_release, + "code_release": test_suite.code_release, "start_time": test_suite.start_time, "end_time": test_suite.end_time, "environment_info": __get_environment_info(test_suite.env_info), - "testRuns": __get_test_runs_metrics(test_suite.test_runs) + "test_runs": __get_test_runs_metrics(test_suite.test_runs) } return response_data @@ -35,6 +35,7 @@ def __get_test_runs_metrics(test_runs): "id": test_run.id, "algorithm": test_run.algorithm, "iterations": test_run.iterations, + "message_size": test_run.message_size, "results": { "averageCPU": round(cpu_avg, 2), "averageMemory": int(memory_avg), diff --git a/api/tests/test_analyze_api.py b/api/tests/test_analyze_api.py index 9003567d..3a9b325d 100644 --- a/api/tests/test_analyze_api.py +++ b/api/tests/test_analyze_api.py @@ -39,7 +39,8 @@ def test_analyze(self, mock_start_collecting, mock_stop_collecting, mock_get_met "algorithms":["kyber512"], "iterationsCount": [1000, 2000], "experimentName": "name", - "description": "name" + "description": "name", + "messageSizes": [100] } # Mock the requests.post call with patch(POST_REQUEST) as mock_post: @@ -78,7 +79,8 @@ def test_analyze_return_general_error(self, mock_start_collecting, mock_stop_col "algorithms":["kyber512"], "iterationsCount": [1000], "experimentName": "name", - "description": "name" + "description": "name", + "messageSizes": [100] } # Mock the requests.post call to raise an exception @@ -98,7 +100,8 @@ def test_analyze_with_invalid_iterations_count(self, mock_start_collecting, mock "algorithms": ["kyber512"], "iterationsCount": [-1], "experimentName": "name", - "description": "name" + "description": "name", + "messageSizes": [100] } response = self.client.post(PATH, data=json.dumps(input_data), @@ -108,13 +111,30 @@ def test_analyze_with_invalid_iterations_count(self, mock_start_collecting, mock self.assertEqual(response_json["error"], INVALID_DATA_PROVIDED) self.assertEqual(response_json["message"], "The number of iterations should be greater than 0") + def test_analyze_with_invalid_message_sizes(self, mock_start_collecting, mock_stop_collecting, mock_get_metrics): + input_data = { + "algorithms": ["kyber512"], + "iterationsCount": [10], + "experimentName": "name", + "description": "name", + "messageSizes": [-1] + } + response = self.client.post(PATH, + data=json.dumps(input_data), + content_type=CONTENT_TYPE) + self.assertEqual(response.status_code, 400) + response_json = json.loads(response.data) + self.assertEqual(response_json["error"], INVALID_DATA_PROVIDED) + self.assertEqual(response_json["message"], "The message size should be greater than -1") + def test_analyze_with_invalid_algorithm(self, mock_start_collecting, mock_stop_collecting, mock_get_metrics): input_data = { "algorithms":["invalid_algorithm"], "iterationsCount": [1000], "experimentName": "name", - "description": "name" + "description": "name", + "messageSizes": [100] } response = self.client.post(PATH, data=json.dumps(input_data), @@ -144,7 +164,8 @@ def test_analyze_with_curl_failure(self, mock_start_collecting, mock_stop_collec "algorithms":["kyber512"], "iterationsCount": [1000], "experimentName": "name", - "description": "name" + "description": "name", + "messageSizes": [100] } # Mock the requests.post call with patch(POST_REQUEST) as mock_post: @@ -160,13 +181,13 @@ def test_analyze_with_curl_failure(self, mock_start_collecting, mock_stop_collec self.assertEqual(actual_test_run[0].status_message, '{"result": "failed"}') - def test_analyze_with_missing_env_info(self, mock_start_collecting, mock_stop_collecting, mock_get_metrics): input_data = { - "algorithms":["kyber512"], + "algorithms": ["kyber512"], "iterationsCount": [1000], "experimentName": "name", - "description": "name" + "description": "name", + "messageSizes": [100] } self.app.database_manager.get_latest.return_value = None response = self.client.post(PATH, @@ -185,7 +206,8 @@ def test_analyze_with_423(self, mock_start_collecting, mock_stop_collecting, moc "algorithms":["kyber512"], "iterationsCount": [1000], "experimentName": "name", - "description": "name" + "description": "name", + "messageSizes": [100] } analyze_api.process_is_running = True # Mock the requests.post call @@ -203,7 +225,8 @@ def test_analyze_sleep_between_tests(self, mock_start_collecting, mock_stop_coll "algorithms":["kyber512","frodo640aes"], "iterationsCount": [1000], "experimentName": "name", - "description": "name" + "description": "name", + "messageSizes": [100] } with patch(GET_REQUEST) as mock_get: mock_get.return_value.status_code = 200 diff --git a/api/tests/test_tests_api.py b/api/tests/test_tests_api.py index ff1e1bd5..8e52b5bb 100644 --- a/api/tests/test_tests_api.py +++ b/api/tests/test_tests_api.py @@ -70,7 +70,7 @@ def test_get_test_suite(self): self.app.database_manager.get_by_id.return_value = test_suite response = self.client.get(TEST_SUITES_GET_URL) result = json.loads(response.data) - expected = {'codeRelease': '1.1.0', 'description': 'description', 'end_time': None, 'environment_info': {'cpu': None, 'cpuArchitecture': None, 'cpuClockSpeed': None, 'cpuCores': None, 'nodeSize': None, 'operatingSystem': None, 'resourceName': None}, 'id': None, 'name': 'name', 'start_time': None, 'testRuns': [{'algorithm': None, 'id': 1, 'iterations': None, 'results': {'averageCPU': 9.0, 'averageMemory': 14}}]} + expected = {'code_release': '1.1.0', 'description': 'description', 'end_time': None, 'environment_info': {'cpu': None, 'cpuArchitecture': None, 'cpuClockSpeed': None, 'cpuCores': None, 'nodeSize': None, 'operatingSystem': None, 'resourceName': None}, 'id': None, 'name': 'name', 'start_time': None, 'test_runs': [{'algorithm': None, 'id': 1, 'iterations': None, 'message_size': None, 'results': {'averageCPU': 9.0, 'averageMemory': 14}}]} self.assertEqual(result, expected) def test_get_test_suite_return_not_found(self): diff --git a/curl/package-lock.json b/curl/package-lock.json index 776a55a5..f591d6e0 100644 --- a/curl/package-lock.json +++ b/curl/package-lock.json @@ -9,13 +9,14 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { - "@nestjs/common": "^9.0.0", - "@nestjs/config": "3.0.0", + "@nestjs/common": "^9.4.3", + "@nestjs/config": "^3.0.0", "@nestjs/core": "^9.0.0", "@nestjs/mapped-types": "*", "@nestjs/platform-express": "^9.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "crypto": "^1.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.2.0" }, @@ -25,7 +26,7 @@ "@nestjs/testing": "^9.4.3", "@types/express": "^4.17.13", "@types/jest": "29.2.4", - "@types/node": "18.11.18", + "@types/node": "^18.11.18", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", @@ -1544,13 +1545,13 @@ } }, "node_modules/@nestjs/common": { - "version": "9.3.12", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-9.3.12.tgz", - "integrity": "sha512-NtrUG2VgCbhmZEO1yRt/Utq16uFRV+xeHAOtdYIsfHGG0ssAV2lVLlvFFAQYh0SQ+KuYY1Gsxd3GK2JFoJCNqQ==", + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-9.4.3.tgz", + "integrity": "sha512-Gd6D4IaYj01o14Bwv81ukidn4w3bPHCblMUq+SmUmWLyosK+XQmInCS09SbDDZyL8jy86PngtBLTdhJ2bXSUig==", "dependencies": { "iterare": "1.2.1", - "tslib": "2.5.0", - "uid": "2.0.1" + "tslib": "2.5.3", + "uid": "2.0.2" }, "funding": { "type": "opencollective", @@ -1575,6 +1576,22 @@ } } }, + "node_modules/@nestjs/common/node_modules/tslib": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.3.tgz", + "integrity": "sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==" + }, + "node_modules/@nestjs/common/node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@nestjs/config": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.0.0.tgz", @@ -3484,6 +3501,12 @@ "node": ">= 8" } }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in." + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/curl/package.json b/curl/package.json index 3188ee74..d3803f75 100644 --- a/curl/package.json +++ b/curl/package.json @@ -19,13 +19,14 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand" }, "dependencies": { - "@nestjs/common": "^9.0.0", - "@nestjs/config": "3.0.0", + "@nestjs/common": "^9.4.3", + "@nestjs/config": "^3.0.0", "@nestjs/core": "^9.0.0", "@nestjs/mapped-types": "*", "@nestjs/platform-express": "^9.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "crypto": "^1.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.2.0" }, @@ -35,7 +36,7 @@ "@nestjs/testing": "^9.4.3", "@types/express": "^4.17.13", "@types/jest": "29.2.4", - "@types/node": "18.11.18", + "@types/node": "^18.11.18", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", diff --git a/curl/scripts/run-curl-loop.sh b/curl/scripts/run-curl-loop.sh index 8cf367cf..52b590e7 100755 --- a/curl/scripts/run-curl-loop.sh +++ b/curl/scripts/run-curl-loop.sh @@ -1,10 +1,11 @@ #!/bin/bash -# This script expects four arguments +# This script expects five arguments nginx_host="$1" nginx_port="$2" iteration_count="$3" algorithm="$4" +payload="$5" num_processes=$(($(getconf _NPROCESSORS_ONLN) * 2)) -seq ${iteration_count} | xargs -P $num_processes -n 1 -I % curl https://${nginx_host}:${nginx_port} -k --curves ${algorithm} -so /dev/null \ No newline at end of file +seq ${iteration_count} | xargs -P $num_processes -n 1 -I % curl https://${nginx_host}:${nginx_port} -k --curves ${algorithm} -XPOST -d "$payload" -H "Content-Type: text/plain" -o /dev/null \ No newline at end of file diff --git a/curl/src/curl/curl.controller.spec.ts b/curl/src/curl/curl.controller.spec.ts index 308f8a45..3f5b4f41 100644 --- a/curl/src/curl/curl.controller.spec.ts +++ b/curl/src/curl/curl.controller.spec.ts @@ -29,6 +29,7 @@ describe('CurlController', () => { const curlRequest: CurlRequest = { algorithm: 'kyber512', iterationsCount: 500, + messageSize: 10 }; const runSpy = jest.spyOn(curlService, 'run'); await curlController.create(curlRequest); @@ -38,6 +39,7 @@ describe('CurlController', () => { const curlRequest: CurlRequest = { algorithm: 'kyber512', iterationsCount: 500, + messageSize: 10 }; const expectedResult = undefined; jest.spyOn(curlService, 'run').mockResolvedValue(expectedResult); @@ -48,6 +50,7 @@ describe('CurlController', () => { const curlRequest: CurlRequest = { algorithm: 'kyber512', iterationsCount: 500, + messageSize: 10 }; const error = new HttpException('Exception', 409); jest.spyOn(curlService, 'run').mockRejectedValue(error); diff --git a/curl/src/curl/curl.service.spec.ts b/curl/src/curl/curl.service.spec.ts index 7c18540e..af771882 100644 --- a/curl/src/curl/curl.service.spec.ts +++ b/curl/src/curl/curl.service.spec.ts @@ -5,6 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { HttpException, HttpStatus } from '@nestjs/common'; import * as shellJS from 'shelljs'; +import {MessageGenerator} from "../utils/message.generator"; jest.mock('shelljs', () => ({ exec: jest.fn(), })); @@ -49,18 +50,20 @@ describe('CurlService', () => { const curlRequest: CurlRequest = { algorithm: 'kyber512', iterationsCount: 1000, + messageSize: 10 }; const validateSpy = jest.spyOn(curlService, 'validate'); const runCurlsSpy = jest.spyOn(curlService, 'runCurls').mockResolvedValue(undefined); await curlService.run(curlRequest); expect(validateSpy).toHaveBeenCalledWith(curlRequest); - expect(runCurlsSpy).toHaveBeenCalledWith(curlRequest.iterationsCount, curlRequest.algorithm); + expect(runCurlsSpy).toHaveBeenCalledWith(curlRequest.iterationsCount, curlRequest.algorithm, expect.any(String)); }); it('should throw an HttpException with status INTERNAL_SERVER_ERROR when runCurls throws an error', async () => { const curlRequest: CurlRequest = { algorithm: 'kyber512', iterationsCount: 1000, + messageSize: 10 }; jest.spyOn(curlService, 'runCurls').mockRejectedValue(new HttpException('runCurls error', 500)); await expect(curlService.run(curlRequest)).rejects.toThrow(HttpException); @@ -74,6 +77,7 @@ describe('CurlService', () => { const curlRequest: CurlRequest = { algorithm: 'unsupported_algorithm', iterationsCount: 1000, + messageSize: 10 }; jest.spyOn(configService, 'get').mockReturnValue(['test_algorithm']); await expect(curlService.run(curlRequest)).rejects.toThrow(HttpException); @@ -86,6 +90,7 @@ describe('CurlService', () => { const curlRequest: CurlRequest = { algorithm: 'kyber512', iterationsCount: 1000, + messageSize: 10 }; jest.spyOn(configService, 'get').mockReturnValue(['test_algorithm']); jest.spyOn(curlService, 'runCurls').mockImplementation(() => { @@ -108,6 +113,7 @@ describe('CurlService', () => { const curlRequest: CurlRequest = { algorithm: 'kyber512', iterationsCount: 1000, + messageSize: 10 }; jest.spyOn(configService, 'get').mockReturnValue(['test_algorithm']); expect(() => curlService['validate'](curlRequest)).not.toThrow(); @@ -118,9 +124,10 @@ describe('CurlService', () => { it('should call execAsync with the correct command', async () => { const iterationsCount = 1000; const algorithm = 'kyber512'; + const message = MessageGenerator.generate(8); const execAsyncSpy = jest.spyOn(curlService, 'execAsync').mockResolvedValue(undefined); - await curlService['runCurls'](iterationsCount, algorithm); - const expectedCommand = curlService['format'](`./scripts/run-curl-loop.sh ${configService.get('nginx.host')} ${configService.get('nginx.port')} ${iterationsCount} ${algorithm}`); + await curlService['runCurls'](iterationsCount, algorithm, message); + const expectedCommand = curlService['format'](`./scripts/run-curl-loop.sh ${configService.get('nginx.host')} ${configService.get('nginx.port')} ${iterationsCount} ${algorithm} ${message}`); expect(execAsyncSpy).toHaveBeenCalledWith(expectedCommand); }); // Add more test cases for error handling in runCurls. diff --git a/curl/src/curl/curl.service.ts b/curl/src/curl/curl.service.ts index 35267fc3..0957a637 100644 --- a/curl/src/curl/curl.service.ts +++ b/curl/src/curl/curl.service.ts @@ -1,8 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; import * as shellJS from 'shelljs'; import { CurlRequest } from '../dto/curl-request.dto'; import { ConfigService } from '@nestjs/config'; -import { HttpException, HttpStatus, NotFoundException } from '@nestjs/common'; +import {MessageGenerator} from '../utils/message.generator'; @Injectable() export class CurlService { @@ -19,8 +19,9 @@ export class CurlService { async run(curlRequest: CurlRequest): Promise { this.validate(curlRequest); - try { - await this.runCurls(curlRequest.iterationsCount, curlRequest.algorithm); + try { + const message = MessageGenerator.generate(curlRequest.messageSize); + await this.runCurls(curlRequest.iterationsCount, curlRequest.algorithm, message); } catch (err) { this.processIsRunning = false; console.error('[CurlService:run] Error occurred: ', err); @@ -39,8 +40,8 @@ export class CurlService { } } - private async runCurls(iterationsCount: number, algorithm: String) { - const curlCommand = this.format(`${this.CURL_SCRIPT_PATH} ${this.configService.get('nginx.host')} ${this.configService.get('nginx.port')} ${iterationsCount} ${algorithm}`); + private async runCurls(iterationsCount: number, algorithm: string, message: string) { + const curlCommand = this.format(`${this.CURL_SCRIPT_PATH} ${this.configService.get('nginx.host')} ${this.configService.get('nginx.port')} ${iterationsCount} ${algorithm} ${message}`); this.processIsRunning = true; await this.execAsync(curlCommand); console.log('[CurlService:run] Finished taking all curl samples'); diff --git a/curl/src/dto/curl-request.dto.spec.ts b/curl/src/dto/curl-request.dto.spec.ts index ddfffb6a..4d110e00 100644 --- a/curl/src/dto/curl-request.dto.spec.ts +++ b/curl/src/dto/curl-request.dto.spec.ts @@ -1,10 +1,12 @@ import { CurlRequest } from './curl-request.dto'; import { validate } from 'class-validator'; + describe('CurlRequest', () => { it('should pass validation when all properties are valid', async () => { const curlRequest = new CurlRequest(); curlRequest.algorithm = 'ExampleAlgorithm'; curlRequest.iterationsCount = 1000; + curlRequest.messageSize = 10; const validationErrors = await validate(curlRequest); expect(validationErrors).toHaveLength(0); }); @@ -13,6 +15,7 @@ describe('CurlRequest', () => { const curlRequest = new CurlRequest(); curlRequest.algorithm = ''; curlRequest.iterationsCount = 1000; + curlRequest.messageSize = 10; const validationErrors = await validate(curlRequest); expect(validationErrors).toHaveLength(1); expect(validationErrors[0].constraints.isNotEmpty).toBeDefined(); @@ -21,18 +24,40 @@ describe('CurlRequest', () => { it('should fail validation when iterationsCount is not a number', async () => { const curlRequest = new CurlRequest(); curlRequest.algorithm = 'ExampleAlgorithm'; - (curlRequest.iterationsCount as any) = 'not a number'; + curlRequest.iterationsCount = 'not a number' as any; + curlRequest.messageSize = 10; const validationErrors = await validate(curlRequest); expect(validationErrors).toHaveLength(1); expect(validationErrors[0].constraints.isNumber).toBeDefined(); }); - + it('should fail validation when iterationsCount is not from the list', async () => { const curlRequest = new CurlRequest(); curlRequest.algorithm = 'ExampleAlgorithm'; curlRequest.iterationsCount = -3; + curlRequest.messageSize = 10; + const validationErrors = await validate(curlRequest); + expect(validationErrors).toHaveLength(1); + expect(validationErrors[0].constraints).toBeDefined(); + }); + + it('should fail validation when messageSize is not a number', async () => { + const curlRequest = new CurlRequest(); + curlRequest.algorithm = 'ExampleAlgorithm'; + curlRequest.iterationsCount = 1000; + curlRequest.messageSize = 'not a number' as any; + const validationErrors = await validate(curlRequest); + expect(validationErrors).toHaveLength(1); + expect(validationErrors[0].constraints.isNumber).toBeDefined(); + }); + + it('should fail validation when messageSize is not from the list', async () => { + const curlRequest = new CurlRequest(); + curlRequest.algorithm = 'ExampleAlgorithm'; + curlRequest.iterationsCount = 1000; + curlRequest.messageSize = -3; const validationErrors = await validate(curlRequest); expect(validationErrors).toHaveLength(1); expect(validationErrors[0].constraints).toBeDefined(); }); -}); \ No newline at end of file +}); diff --git a/curl/src/dto/curl-request.dto.ts b/curl/src/dto/curl-request.dto.ts index 1b6fbae3..f30b7ed8 100644 --- a/curl/src/dto/curl-request.dto.ts +++ b/curl/src/dto/curl-request.dto.ts @@ -1,12 +1,17 @@ -import { IsIn, IsNotEmpty, IsNumber, Min } from 'class-validator'; +import { IsNotEmpty, IsNumber, Min } from 'class-validator'; export class CurlRequest { @IsNotEmpty() - algorithm: String; + algorithm: string; @IsNotEmpty() @IsNumber() @Min(1) iterationsCount: number; + + @IsNotEmpty() + @IsNumber() + @Min(0) + messageSize: number; } diff --git a/curl/src/utils/message.generator.spec.ts b/curl/src/utils/message.generator.spec.ts new file mode 100644 index 00000000..a687fba4 --- /dev/null +++ b/curl/src/utils/message.generator.spec.ts @@ -0,0 +1,26 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MessageGenerator } from './message.generator'; + +describe('MessageGenerator', () => { + let service: MessageGenerator; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [MessageGenerator], + }).compile(); + + service = module.get(MessageGenerator); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generate', () => { + it('should generate a message with the specified size', () => { + const sizeInBytes = 10; + const generatedMessage = MessageGenerator.generate(sizeInBytes); + expect(generatedMessage.length).toBe(sizeInBytes); + }); + }); +}); diff --git a/curl/src/utils/message.generator.ts b/curl/src/utils/message.generator.ts new file mode 100644 index 00000000..05c19fc7 --- /dev/null +++ b/curl/src/utils/message.generator.ts @@ -0,0 +1,9 @@ +import {Injectable} from '@nestjs/common'; + +@Injectable() +export class MessageGenerator { + static generate(sizeInBytes: number) { + // Generate the string by repeating the character 'a' + return 'a'.repeat(sizeInBytes); } + +} diff --git a/curl/tsconfig.json b/curl/tsconfig.json index adb614ca..fd6d15a5 100644 --- a/curl/tsconfig.json +++ b/curl/tsconfig.json @@ -17,5 +17,7 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false - } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] } diff --git a/portal/mock-server/src/all-experiments.json b/portal/mock-server/src/all-experiments.json new file mode 100644 index 00000000..4b9a9061 --- /dev/null +++ b/portal/mock-server/src/all-experiments.json @@ -0,0 +1,120 @@ +[ + { + "id": 15, + "name": "Experiment 1", + "end_time": 1705240065192, + "test_runs": [ + { + "id": 354, + "algorithm": "prime256v1", + "iterations": 100 + }, + { + "id": 355, + "algorithm": "prime256v1", + "iterations": 500 + }, + { + "id": 356, + "algorithm": "prime256v1", + "iterations": 1000 + }, + { + "id": 357, + "algorithm": "p256_kyber512", + "iterations": 100 + }, + { + "id": 358, + "algorithm": "p256_kyber512", + "iterations": 500 + }, + { + "id": 359, + "algorithm": "p256_kyber512", + "iterations": 1000 + }, + { + "id": 360, + "algorithm": "bikel3", + "iterations": 100 + }, + { + "id": 361, + "algorithm": "bikel3", + "iterations": 500 + }, + { + "id": 362, + "algorithm": "bikel3", + "iterations": 1000 + } + ] + }, + { + "id": 16, + "name": "Experiment 2", + "end_time": 1705389926549, + "test_runs": [ + { + "id": 363, + "algorithm": "kyber512", + "iterations": 1000 + }, + { + "id": 364, + "algorithm": "bikel3", + "iterations": 1000 + }, + { + "id": 365, + "algorithm": "prime256v1", + "iterations": 1000 + } + ] + }, + { + "id": 17, + "name": "Experiment 3", + "end_time": 1705389926549, + "test_runs": [ + { + "id": 366, + "algorithm": "prime256v1", + "iterations": 500 + }, + { + "id": 367, + "algorithm": "bikel3", + "iterations": 1000 + }, + { + "id": 368, + "algorithm": "bikel3", + "iterations": 1000 + }, + { + "id": 369, + "algorithm": "prime256v1", + "iterations": 5000 + } + ] + }, + { + "id": 18, + "name": "Experiment 4", + "end_time": 1705389926549, + "test_runs": [ + { + "id": 370, + "algorithm": "kyber512", + "iterations": 500 + }, + { + "id": 371, + "algorithm": "kyber512", + "iterations": 1500 + } + ] + } +] diff --git a/portal/mock-server/src/router.ts b/portal/mock-server/src/router.ts index b6c112c6..5db6a10d 100644 --- a/portal/mock-server/src/router.ts +++ b/portal/mock-server/src/router.ts @@ -37,6 +37,14 @@ router.get('/qujata-api/test_suites/:testSuiteId', async (req: Request, res: Res }, 1500); }); +router.get('/qujata-api/test_suites', async (req: Request, res: Response) => { + console.log(`-${req.method} ${req.url}`); + const data = (await import('./all-experiments.json')).default; + setTimeout(() => { + res.json(data); + }, 1500); +}); + router.put('/qujata-api/test_suites/:testSuiteId', async (req: Request, res: Response) => { console.log(`-${req.method} ${req.url}`); setTimeout(() => { @@ -51,4 +59,11 @@ router.delete('/qujata-api/test_suites/:testSuiteId', async (req: Request, res: }, 1500); }); +router.post('/qujata-api/test_suites/delete', async (req: Request, res: Response) => { + console.log(`-${req.method} ${req.url}`); + setTimeout(() => { + res.status(200).send(); + }, 1500); +}); + export default router; diff --git a/portal/mock-server/src/test.json b/portal/mock-server/src/test.json index 7d9ef1e9..ed4ca2d7 100644 --- a/portal/mock-server/src/test.json +++ b/portal/mock-server/src/test.json @@ -15,7 +15,7 @@ "resourceName": "RELACE_WITH_RESOURCE_NAME" }, - "testRuns": [ + "test_runs": [ { "id":1, "algorithm": "bikel1", diff --git a/portal/package.json b/portal/package.json index 45831012..025ccd6b 100644 --- a/portal/package.json +++ b/portal/package.json @@ -8,6 +8,7 @@ "chart.js": "^4.4.0", "chartjs-plugin-annotation": "^3.0.1", "classnames": "^2.3.2", + "date-fns": "^3.3.0", "lodash": "^4.17.21", "react": "^18.2.0", "react-chartjs-2": "3.1.1", diff --git a/portal/src/app/apis.ts b/portal/src/app/apis.ts index 4897274c..409568eb 100644 --- a/portal/src/app/apis.ts +++ b/portal/src/app/apis.ts @@ -1,5 +1,5 @@ const testSuites = 'test_suites'; - + export const APIS: { [key in keyof typeof API_URLS]: string } = { analyze: 'analyze', algorithms: 'algorithms', @@ -7,8 +7,10 @@ export const APIS: { [key in keyof typeof API_URLS]: string } = { testRunResults: `${testSuites}/:testSuiteId`, editExperiment: `${testSuites}/:testSuiteId`, deleteExperiment: `${testSuites}/:testSuiteId`, + allExperiments: `${testSuites}`, + deleteExperiments: `${testSuites}/delete`, }; - + enum API_URLS { analyze, algorithms, @@ -16,4 +18,6 @@ enum API_URLS { testRunResults, editExperiment, deleteExperiment, + allExperiments, + deleteExperiments } diff --git a/portal/src/app/components/all-experiments/Experiments.module.scss b/portal/src/app/components/all-experiments/Experiments.module.scss new file mode 100644 index 00000000..f60ea790 --- /dev/null +++ b/portal/src/app/components/all-experiments/Experiments.module.scss @@ -0,0 +1,67 @@ +@import "src/styles/variables-keys"; + +.experiments_wrapper { + padding-inline: 80px; + padding-block: 40px; + + .title_options_container { + display: flex; + justify-content: space-between; + align-items: center; + + .experiments_title { + font-size: 20px; + font-family: var($fontMedium); + margin-block-end: 40px; + } + + .options_wrapper { + .trash_icon { + background-color: #F5F1FF; + inline-size: 34px; + block-size: 34px; + border-radius: 50%; + } + } + + .options_wrapper:hover .hover_image { + display: block; + } + + .options_wrapper:hover .default_image { + display: none; + } + + .default_image { + padding-inline: 11px; + display: block; + } + + .hover_image { + display: none; + } + } +} + +.experiments_table { + text-align: left; + + th:first-child, + td:first-child { + text-align: center; + inline-size: 80px; + } +} + +.input_form_item { + display: none; +} + +.input_option { + margin-block-end: -5px; + + .input_option_checkbox_icon { + margin-inline-end: 10px; + cursor: pointer; + } +} diff --git a/portal/src/app/components/all-experiments/Experiments.test.tsx b/portal/src/app/components/all-experiments/Experiments.test.tsx new file mode 100644 index 00000000..6106dd28 --- /dev/null +++ b/portal/src/app/components/all-experiments/Experiments.test.tsx @@ -0,0 +1,44 @@ +import { render } from '@testing-library/react'; +import { Experiments } from './Experiments'; +import { useExperimentsData } from './hooks'; +import { FetchDataStatus, useFetch } from '../../shared/hooks/useFetch'; + +jest.mock('./hooks'); +jest.mock('../../shared/hooks/useFetch'); +jest.mock('react-router-dom', () => ({ + useNavigate: jest.fn(), +})); + +describe('Experiments', () => { + it('renders correctly', () => { + (useExperimentsData as jest.Mock).mockReturnValue({ + test_suites: [{ + id: 15, + name: "Experiment 1", + end_time: 1705240065192, + test_runs: [ + { + id: 354, + algorithm: "prime256v1", + iterations: 100 + }, + { + id: 355, + algorithm: "prime256v1", + iterations: 500 + } + ] + }], + status: FetchDataStatus.Fetching, + }); + (useFetch as jest.Mock).mockReturnValue({ + post: jest.fn(), + status: FetchDataStatus.Fetching, + error: null, + cancelRequest: jest.fn(), + }); + + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); \ No newline at end of file diff --git a/portal/src/app/components/all-experiments/Experiments.tsx b/portal/src/app/components/all-experiments/Experiments.tsx new file mode 100644 index 00000000..024f3b52 --- /dev/null +++ b/portal/src/app/components/all-experiments/Experiments.tsx @@ -0,0 +1,170 @@ +import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import styles from './Experiments.module.scss'; +import cn from 'classnames'; +import { IUseExperimentsData, useExperimentsData } from './hooks'; +import { FetchDataStatus, IHttp, useFetch } from '../../shared/hooks/useFetch'; +import { ALL_EXPERIMENTS_TABLE_EN } from './translate/en'; +import { CellContext } from '@tanstack/react-table'; +import { Table } from '../../shared/components/table'; +import { Button, ButtonActionType, ButtonSize, ButtonStyleType } from '../../shared/components/att-button'; +import { APIS } from '../../apis'; +import { useNavigate } from 'react-router-dom'; +import { useFetchSpinner } from '../../shared/hooks/useFetchSpinner'; +import { useErrorMessage } from '../../hooks/useErrorMessage'; +import { formatDistanceToNow } from 'date-fns'; +import CheckedSvg from '../../../assets/images/checked.svg'; +import UnCheckedSvg from '../../../assets/images/unchecked.svg'; +import TrashSvg from '../../../assets/images/trash.svg'; +import TrashHoverSvg from '../../../assets/images/trash-hover.svg'; +import DuplicateSvg from '../../../assets/images/duplicate.svg'; +import { DeleteExperimentModal } from '../home/components/experiment/components/delete-experiment-modal'; +import { parseExperimentsData } from './utils/parse-experiments-data.utils'; +import { ExperimentData } from './models/experiments.interface'; + +const DeleteAriaLabel: string = ALL_EXPERIMENTS_TABLE_EN.BUTTONS.DELETE; +const DuplicateAriaLabel: string = ALL_EXPERIMENTS_TABLE_EN.TABLE_COLUMNS.LINKS.DUPLICATE; + +export const Experiments: React.FC = () => { + const { testSuites, status }: IUseExperimentsData = useExperimentsData(); + const [openDeleteModal, setOpenDeleteModal] = useState(false); + const [checkedRows, setCheckedRows] = useState>({}); + const experimentsData = useMemo(() => (testSuites ? parseExperimentsData(testSuites): []), [testSuites]); + const navigate = useNavigate(); + + const { post, status: deleteStatus, error: deleteError, cancelRequest: cancelRequestDelete }: IHttp + = useFetch({ url: APIS.deleteExperiments }); + useFetchSpinner(deleteStatus); + useErrorMessage(deleteError); + useEffect(() => cancelRequestDelete, [cancelRequestDelete]); + + const handleDeleteClick: () => void = useCallback((): void => { + setOpenDeleteModal(true); + }, []); + + const handleCloseDeleteExperimentModal: (confirm?: boolean) => void = useCallback((confirm?: boolean): void => { + if (confirm) { + const ids: number[] = Object.keys(checkedRows).map((key: string) => parseInt(key)) + post({ + data: { ids } + }); + } + setOpenDeleteModal(false); + }, [post, checkedRows]); + + const handleCheckboxClick = useCallback((rowInfo: ExperimentData): void => { + const rowId = rowInfo.id as number; + setCheckedRows((prevState: Record) => ({ + ...prevState, + [rowId]: !prevState[rowId], + })); + }, []); + + const handleDuplicateClick = useCallback((row: ExperimentData) => { + // Navigate to the Home Page + navigate('/qujata', { state: { row } }); + }, [navigate]); + + const headers = useMemo(() => { + const columnDefs = [ + { + id: ALL_EXPERIMENTS_TABLE_EN.TABLE_COLUMNS.CHECKBOX, + accessor: () => null, + cell: (cellInfo: CellContext) => { + const rowInfo: ExperimentData = cellInfo.row.original; + return ( +
+ row-option handleCheckboxClick(rowInfo)} + /> + handleCheckboxClick(rowInfo)} + /> +
+ ) + } + }, + { + id: ALL_EXPERIMENTS_TABLE_EN.TABLE_COLUMNS.EXPERIMENT_NAME.ID, + name: ALL_EXPERIMENTS_TABLE_EN.TABLE_COLUMNS.EXPERIMENT_NAME.NAME, + accessor: (row: ExperimentData) => row.name + }, + { + id: ALL_EXPERIMENTS_TABLE_EN.TABLE_COLUMNS.ALGORITHMS.ID, + name: ALL_EXPERIMENTS_TABLE_EN.TABLE_COLUMNS.ALGORITHMS.NAME, + accessor: (row: ExperimentData) => row.algorithms?.join(', ') + }, + { + id: ALL_EXPERIMENTS_TABLE_EN.TABLE_COLUMNS.ITERATIONS.ID, + name: ALL_EXPERIMENTS_TABLE_EN.TABLE_COLUMNS.ITERATIONS.NAME, + accessor: (row: ExperimentData) => row.iterations?.join(', ') + }, + { + id: ALL_EXPERIMENTS_TABLE_EN.TABLE_COLUMNS.DATE.ID, + name: ALL_EXPERIMENTS_TABLE_EN.TABLE_COLUMNS.DATE.NAME, + accessor: (row: ExperimentData) => formatDistanceToNow(row.end_time, { addSuffix: true }) + }, + { + id: ALL_EXPERIMENTS_TABLE_EN.TABLE_COLUMNS.LINKS.DUPLICATE, + accessor: () => null, + cell: (cellInfo: CellContext) => ( + + ) + }, + ]; + + return columnDefs.map(({ id, name, accessor, cell }) => ({ + id, + header: () => {name}, + accessor, + cell: cell || ((cellInfo: CellContext) => {cellInfo.getValue() as ReactNode}) + })); + }, [checkedRows, handleCheckboxClick, handleDuplicateClick]); + + const checkedExperimentNames = experimentsData + .filter((experiment: ExperimentData) => checkedRows[experiment.id]) + .map((experiment: ExperimentData) => experiment.name); + + return ( +
+ <> + { status === FetchDataStatus.Success && +
+ + {Object.values(checkedRows).some((value: boolean) => value) && ( + + )} +
+ } + {experimentsData.length > 0 && } + {openDeleteModal && } + + + ); +} diff --git a/portal/src/app/components/all-experiments/__snapshots__/Experiments.test.tsx.snap b/portal/src/app/components/all-experiments/__snapshots__/Experiments.test.tsx.snap new file mode 100644 index 00000000..8afcf59a --- /dev/null +++ b/portal/src/app/components/all-experiments/__snapshots__/Experiments.test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Experiments renders correctly 1`] = ` +
+
+
+`; diff --git a/portal/src/app/components/all-experiments/hooks/index.ts b/portal/src/app/components/all-experiments/hooks/index.ts new file mode 100644 index 00000000..e1607221 --- /dev/null +++ b/portal/src/app/components/all-experiments/hooks/index.ts @@ -0,0 +1 @@ +export * from './useExperimentsData'; diff --git a/portal/src/app/components/all-experiments/hooks/useExperimentsData.test.ts b/portal/src/app/components/all-experiments/hooks/useExperimentsData.test.ts new file mode 100644 index 00000000..9318385d --- /dev/null +++ b/portal/src/app/components/all-experiments/hooks/useExperimentsData.test.ts @@ -0,0 +1,70 @@ +import { renderHook } from '@testing-library/react'; +import { useFetch } from '../../../shared/hooks/useFetch'; +import { Experiment } from '../models/experiments.interface'; +import { useExperimentsData } from './useExperimentsData'; + +jest.mock('../../../shared/hooks/useFetch', () => ({ + useFetch: jest.fn(), +})); +jest.mock('../../../shared/hooks/useFetchSpinner'); +jest.mock('../../../hooks/useErrorMessage'); + +describe('useExperimentsData', () => { + test('Should be in Success mode', () => { + const allExperimentsMockData: Experiment[] = [ + { + id: 17, + name: "Experiment 3", + end_time: 1705389926549, + test_runs: [ + { + id: 366, + algorithm: "prime256v1", + iterations: 500 + }, + { + id: 367, + algorithm: "bikel3", + iterations: 1000 + }, + { + id: 368, + algorithm: "p256_kyber512", + iterations: 10000 + }, + { + id: 369, + algorithm: "prime256v1", + iterations: 5000 + } + ] + }, + { + id: 18, + name: "Experiment 4", + end_time: 1705389926549, + test_runs: [ + { + id: 370, + algorithm: "kyber512", + iterations: 500 + }, + { + id: 371, + algorithm: "kyber512", + iterations: 1000 + } + ] + } + ]; + + (useFetch as jest.Mock).mockReturnValue({ + get: jest.fn(), + data: allExperimentsMockData, + cancelRequest: jest.fn(), + }); + + const { result } = renderHook(() => useExperimentsData()); + expect(result.current.testSuites.length).toEqual(allExperimentsMockData.length); + }); +}); \ No newline at end of file diff --git a/portal/src/app/components/all-experiments/hooks/useExperimentsData.ts b/portal/src/app/components/all-experiments/hooks/useExperimentsData.ts new file mode 100644 index 00000000..4e872e5e --- /dev/null +++ b/portal/src/app/components/all-experiments/hooks/useExperimentsData.ts @@ -0,0 +1,32 @@ +import { FetchDataStatus, IHttp, useFetch } from '../../../shared/hooks/useFetch'; +import { useEffect, useState } from 'react'; +import { APIS } from '../../../apis'; +import { useFetchSpinner } from '../../../shared/hooks/useFetchSpinner'; +import { useErrorMessage } from '../../../hooks/useErrorMessage'; +import { Experiment } from '../models/experiments.interface'; + +export interface IUseExperimentsData { + testSuites: Experiment[]; + status: FetchDataStatus; +} + +export function useExperimentsData(): IUseExperimentsData { + const [allExperiments, setAllExperiments] = useState([]); + const { get, data, cancelRequest, status, error }: IHttp = useFetch({ url: APIS.allExperiments }); + + useFetchSpinner(status); + useErrorMessage(error); + useEffect(() => { + get(); + return cancelRequest; + }, [get, cancelRequest]); + + + useEffect(() => { + if (data) { + setAllExperiments(data); + } + }, [data, status, allExperiments]); + + return { testSuites: allExperiments, status }; +} diff --git a/portal/src/app/components/all-experiments/models/experiments.interface.ts b/portal/src/app/components/all-experiments/models/experiments.interface.ts new file mode 100644 index 00000000..ab9f8f73 --- /dev/null +++ b/portal/src/app/components/all-experiments/models/experiments.interface.ts @@ -0,0 +1,12 @@ +import { ITestRunResult, ITestRunResultData } from '../../../shared/models/test-run-result.interface'; + +export type TestRunSubset = Pick; +export type Experiment = Pick & { test_runs: TestRunSubset[] }; + +export interface ExperimentData { + id: number; + name: string; + algorithms: string[]; + iterations: number[]; + end_time: number; +}; diff --git a/portal/src/app/components/all-experiments/translate/en.ts b/portal/src/app/components/all-experiments/translate/en.ts new file mode 100644 index 00000000..993412fa --- /dev/null +++ b/portal/src/app/components/all-experiments/translate/en.ts @@ -0,0 +1,28 @@ +export const ALL_EXPERIMENTS_TABLE_EN = { + TITLE: 'All Experiments', + TABLE_COLUMNS: { + CHECKBOX: 'checkbox', + EXPERIMENT_NAME: { + NAME: 'Experiment Name', + ID: 'experimentName' + }, + ALGORITHMS: { + NAME: 'Algorithms', + ID: 'algorithms' + }, + ITERATIONS: { + NAME: 'Iterations', + ID: 'iterations' + }, + DATE: { + NAME: 'Date', + ID: 'date' + }, + LINKS: { + DUPLICATE: 'duplicate', + } + }, + BUTTONS: { + DELETE: 'Delete', + } +} diff --git a/portal/src/app/components/all-experiments/utils/parse-experiments-data.utils.test.ts b/portal/src/app/components/all-experiments/utils/parse-experiments-data.utils.test.ts new file mode 100644 index 00000000..6695e3da --- /dev/null +++ b/portal/src/app/components/all-experiments/utils/parse-experiments-data.utils.test.ts @@ -0,0 +1,33 @@ +import { parseExperimentsData } from './parse-experiments-data.utils'; +import { ITestRunResultData } from '../../../shared/models/test-run-result.interface'; +import { Experiment, ExperimentData } from '../models/experiments.interface'; + +describe('parseExperimentsData', () => { + it('should parse experiments data correctly', () => { + const mockExperiments: Experiment[] = [ + { + id: 1, + name: 'Experiment 1', + test_runs: [ + { algorithm: 'Algorithm 1', iterations: 1000 } as ITestRunResultData, + { algorithm: 'Algorithm 2', iterations: 5000 } as ITestRunResultData, + ], + end_time: 1705240065192, + }, + ]; + + const expectedOutput: ExperimentData[] = [ + { + id: 1, + name: 'Experiment 1', + algorithms: ['Algorithm 1', 'Algorithm 2'], + iterations: [1000, 5000], + end_time: 1705240065192, + }, + ]; + + const result = parseExperimentsData(mockExperiments); + + expect(result).toEqual(expectedOutput); + }); +}); diff --git a/portal/src/app/components/all-experiments/utils/parse-experiments-data.utils.ts b/portal/src/app/components/all-experiments/utils/parse-experiments-data.utils.ts new file mode 100644 index 00000000..3ddfa6ac --- /dev/null +++ b/portal/src/app/components/all-experiments/utils/parse-experiments-data.utils.ts @@ -0,0 +1,24 @@ +import { Experiment, ExperimentData, TestRunSubset } from '../models/experiments.interface'; + +export function parseExperimentsData(test_suites: Experiment[]) { + const experimentsData: ExperimentData[] = []; + + test_suites.forEach((experiment: Experiment) => { + const algorithms = new Set(); + const iterations = new Set(); + experiment.test_runs?.forEach((testRun: TestRunSubset) => { + algorithms.add(testRun.algorithm); + iterations.add(testRun.iterations); + }); + + experimentsData.push({ + id: experiment.id, + name: experiment.name, + algorithms: Array.from(algorithms), + iterations: Array.from(iterations), + end_time: experiment.end_time + }); + }); + + return experimentsData; +} diff --git a/portal/src/app/components/dashboard/components/charts/BarChart/BarChart.module.scss b/portal/src/app/components/dashboard/components/charts/BarChart/BarChart.module.scss index b1f95ab7..e69de29b 100644 --- a/portal/src/app/components/dashboard/components/charts/BarChart/BarChart.module.scss +++ b/portal/src/app/components/dashboard/components/charts/BarChart/BarChart.module.scss @@ -1,8 +0,0 @@ -@import "src/styles/variables-keys"; - -.bar { - background-color: var($primaryWhite); - border: 1px solid #BDC2C7; - max-block-size: 450px; - padding: 18px; -} diff --git a/portal/src/app/components/dashboard/components/charts/BarChart/BarChart.tsx b/portal/src/app/components/dashboard/components/charts/BarChart/BarChart.tsx index e1cb5bb3..016929f0 100644 --- a/portal/src/app/components/dashboard/components/charts/BarChart/BarChart.tsx +++ b/portal/src/app/components/dashboard/components/charts/BarChart/BarChart.tsx @@ -2,9 +2,9 @@ import { ChartData, ChartOptions, TooltipItem, Chart, LegendItem, ChartDataset } import { Bar } from 'react-chartjs-2'; import { useEffect, useRef, useState } from 'react'; import { IDatasets } from './models/BarChart.model'; -import { TITLE_PREFIX, colors, defaultOptions } from './barChart.const'; -import styles from './BarChart.module.scss'; +import { TITLE_PREFIX, defaultOptions } from './barChart.const'; import { uniq } from 'lodash'; +import { getColorByName } from '../utils/charts.utils'; export interface BarChartProps { labels: string[]; @@ -13,10 +13,11 @@ export interface BarChartProps { tooltipKeys: string[]; tooltipLabels: string[]; title?: string; + xAxiosTitle?: string; } export const BarChart: React.FC = (props: BarChartProps) => { - const { labels, data, tooltipKeys, tooltipLabels, keyOfData, title } = props; + const { labels, data, tooltipKeys, tooltipLabels, keyOfData, title, xAxiosTitle } = props; const [dataValues, setDataValues] = useState(); const [datasets, setDatasets] = useState([]); const [algorithmsColors, setAlgorithmsColors] = useState<{[key: string]: string}>(); @@ -28,8 +29,8 @@ export const BarChart: React.FC = (props: BarChartProps) => { const algorithms: string[] = uniq(data.map((item: any) => item.algorithm)); const algorithmColors: {[key: string]: string} = {}; - algorithms.forEach((algorithm, index) => { - algorithmColors[algorithm] = colors[index % colors.length]; + algorithms.forEach((algorithm) => { + algorithmColors[algorithm] = getColorByName(algorithm); }); setAlgorithmsColors(algorithmColors); }, [data, keyOfData]); @@ -99,8 +100,8 @@ export const BarChart: React.FC = (props: BarChartProps) => { }, title: { display: true, - text: title, - align: 'start', + text: xAxiosTitle, + align: 'end', font: { size: 18, weight: '500', @@ -153,7 +154,7 @@ export const BarChart: React.FC = (props: BarChartProps) => { (event.currentTarget as HTMLElement).style.cursor = 'default'; }} > - +
); } diff --git a/portal/src/app/components/dashboard/components/charts/BarChart/barChart.const.ts b/portal/src/app/components/dashboard/components/charts/BarChart/barChart.const.ts index 71d8f560..a703e5c7 100644 --- a/portal/src/app/components/dashboard/components/charts/BarChart/barChart.const.ts +++ b/portal/src/app/components/dashboard/components/charts/BarChart/barChart.const.ts @@ -1,7 +1,7 @@ import { ChartOptions } from 'chart.js'; import { CHARTS_EN } from '../../../../home/components/experiment/components/charts/translate/en'; -export const colors: string[] = ['#086CE1', '#FF8500', '#05BBFF', '#6D3FFC']; +export const colors: string[] = ['#086CE1', '#FF8500', '#05BBFF', '#6D3FFC', '#E2180B', '#8208E1', '#08E145', '#9AB2C9', '#EB00FF', '#FFC700', '#8F5C47', '#05FFFF', '#FA9BF7']; export let defaultOptions: ChartOptions = { responsive: true, diff --git a/portal/src/app/components/dashboard/components/charts/LineChart/LineChart.module.scss b/portal/src/app/components/dashboard/components/charts/LineChart/LineChart.module.scss index 604c501b..e69de29b 100644 --- a/portal/src/app/components/dashboard/components/charts/LineChart/LineChart.module.scss +++ b/portal/src/app/components/dashboard/components/charts/LineChart/LineChart.module.scss @@ -1,8 +0,0 @@ -@import "src/styles/variables-keys"; - -.line_chart { - background-color: var($primaryWhite); - border: 1px solid #BDC2C7; - max-block-size: 450px; - padding: 18px; -} diff --git a/portal/src/app/components/dashboard/components/charts/LineChart/LineChart.tsx b/portal/src/app/components/dashboard/components/charts/LineChart/LineChart.tsx index 03fed0a5..1f478d00 100644 --- a/portal/src/app/components/dashboard/components/charts/LineChart/LineChart.tsx +++ b/portal/src/app/components/dashboard/components/charts/LineChart/LineChart.tsx @@ -1,17 +1,17 @@ import { Line } from 'react-chartjs-2'; import { ChartOptions, Chart, TooltipItem } from 'chart.js'; import { TITLE_PREFIX, defaultOptions } from './LineChart.const'; -import styles from './LineChart.module.scss'; import { useRef } from 'react'; export interface LineChartProps { data: any; tooltipLabel?: string; title?: string; + xAxiosTitle?: string; } export const LineChart: React.FC = (props: LineChartProps) => { - const { data, title, tooltipLabel } = props; + const { data, title, tooltipLabel, xAxiosTitle } = props; const chartRef = useRef>(null); const options: ChartOptions = { @@ -29,7 +29,7 @@ export const LineChart: React.FC = (props: LineChartProps) => { plugins: { title: { display: true, - text: title, + text: xAxiosTitle, align: 'start', font: { size: 18, @@ -86,7 +86,7 @@ export const LineChart: React.FC = (props: LineChartProps) => { (event.currentTarget as HTMLElement).style.cursor = 'default'; }} > - + ); } diff --git a/portal/src/app/components/dashboard/components/charts/utils/charts.utils.test.ts b/portal/src/app/components/dashboard/components/charts/utils/charts.utils.test.ts new file mode 100644 index 00000000..4d698d56 --- /dev/null +++ b/portal/src/app/components/dashboard/components/charts/utils/charts.utils.test.ts @@ -0,0 +1,7 @@ +import { getColorByName } from './charts.utils'; + +describe('Charts Util Test', () => { + test('should get color by name', () => { + expect(getColorByName('bikel1')).toBe('#FF8500'); + }); +}); diff --git a/portal/src/app/components/dashboard/components/charts/utils/charts.utils.ts b/portal/src/app/components/dashboard/components/charts/utils/charts.utils.ts new file mode 100644 index 00000000..158b5246 --- /dev/null +++ b/portal/src/app/components/dashboard/components/charts/utils/charts.utils.ts @@ -0,0 +1,15 @@ +import { colors } from "../BarChart/barChart.const"; + +export function getColorByName(name: string): string { + const firstLetter: string = name.trim().substring(0, 1); + if (isLetter(firstLetter)) { + const numberFromStr: number = firstLetter.toLowerCase().charCodeAt(0) - 97; + return colors[numberFromStr % colors.length]; + } + + return colors[0]; +} + +function isLetter(str: string) { + return str.length === 1 && str.match(/[a-z]/i); +} diff --git a/portal/src/app/components/home/Home.test.tsx b/portal/src/app/components/home/Home.test.tsx index e5b75f25..c76bf9f1 100644 --- a/portal/src/app/components/home/Home.test.tsx +++ b/portal/src/app/components/home/Home.test.tsx @@ -4,6 +4,7 @@ import { SubHeader, SubHeaderProps } from '../sub-header'; import { ProtocolQuery, ProtocolQueryProps } from '../protocol-query'; const mockUseNavigate = jest.fn(); +const mockUseLocation = jest.fn(); jest.mock('../sub-header'); jest.mock('../protocol-query'); @@ -17,6 +18,7 @@ jest.mock('../../hooks/useDashboardData', () => ({ jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: () => mockUseNavigate, + useLocation: () => mockUseLocation, })); describe('Home', () => { diff --git a/portal/src/app/components/home/Home.tsx b/portal/src/app/components/home/Home.tsx index ab4e551b..e05f1b2b 100644 --- a/portal/src/app/components/home/Home.tsx +++ b/portal/src/app/components/home/Home.tsx @@ -5,7 +5,8 @@ import { ProtocolQuery } from "../protocol-query"; import { SubHeader } from "../sub-header"; import { useCallback, useEffect, useState } from 'react'; import styles from './Home.module.scss'; -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; +import { ExperimentData } from "../all-experiments/models/experiments.interface"; export const Home: React.FC = () => { const [isSubHeaderOpen, setIsSubHeaderOpen] = useState(true); @@ -25,6 +26,13 @@ export const Home: React.FC = () => { export const HomeContent: React.FC = () => { const { handleRunQueryClick, status, testSuiteId }: IUseDashboardData = useDashboardData(); const navigate = useNavigate(); + const location = useLocation(); + const [duplicateData, setDuplicateData] = useState(location.state?.row); + + useEffect(() => { + // Clear the state after the duplicate data has been created + setDuplicateData(undefined); + }, []); useEffect(() => { if (status === FetchDataStatus.Success && testSuiteId) { @@ -41,7 +49,12 @@ export const HomeContent: React.FC = () => { return (
- +
); }; diff --git a/portal/src/app/components/home/components/experiment/Experiment.module.scss b/portal/src/app/components/home/components/experiment/Experiment.module.scss index 28406f9f..8fd1714a 100644 --- a/portal/src/app/components/home/components/experiment/Experiment.module.scss +++ b/portal/src/app/components/home/components/experiment/Experiment.module.scss @@ -19,21 +19,3 @@ .table_options_wrapper { position: relative; } - -.spinner_wrapper { - position: sticky; - inset-block-start: 50%; - inset-inline-start: 50%; - text-align: center; -} - -.spinner_overlay { - inline-size: 100%; - block-size: 100%; - position: absolute; - background-color: #fff; - opacity: 0.6; - inset-block-start: 0; - inset-inline-start: 0; - z-index: 4; -} diff --git a/portal/src/app/components/home/components/experiment/Experiment.test.tsx b/portal/src/app/components/home/components/experiment/Experiment.test.tsx index 784fcfcd..d6c31b20 100644 --- a/portal/src/app/components/home/components/experiment/Experiment.test.tsx +++ b/portal/src/app/components/home/components/experiment/Experiment.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, waitFor, within } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import { SubHeader } from './components/sub-header'; import { Charts } from './components/charts'; import { Experiment, ExperimentContent } from './Experiment'; diff --git a/portal/src/app/components/home/components/experiment/Experiment.tsx b/portal/src/app/components/home/components/experiment/Experiment.tsx index b9d36347..24b1b6f8 100644 --- a/portal/src/app/components/home/components/experiment/Experiment.tsx +++ b/portal/src/app/components/home/components/experiment/Experiment.tsx @@ -4,13 +4,10 @@ import { Charts } from './components/charts'; import { SubHeader } from './components/sub-header'; import { useExperimentData } from './components/hooks/useExperimentData'; import { ITestRunResult } from '../../../../shared/models/test-run-result.interface'; -import { FetchDataStatus } from '../../../../shared/hooks/useFetch'; -import { Spinner, SpinnerSize } from '../../../../shared/components/att-spinner'; import { useEffect, useRef, useState } from 'react'; import { EXPERIMENT_EN } from './translate/en'; import { ExperimentTabs } from './components/experiment-tabs'; import { handleSectionScrolling } from './utils'; -import { ISpinner, useSpinnerContext } from '../../../../shared/context/spinner'; import { TableOptions } from './components/table-options'; import { SelectColumnsPopup } from './components/table-options/components/select-columns-popup'; import { SelectedColumnsDefaultData, TableOptionsData } from './components/table-options/constants/table-options.const'; @@ -22,11 +19,11 @@ export type IExperimentData = { } export const Experiment: React.FC = () => { - const { data: testRunData, status } = useExperimentData(); + const { data: testRunData } = useExperimentData(); return (
- {status === FetchDataStatus.Fetching ? renderSpinner() : testRunData && } + {testRunData && }
); } @@ -36,7 +33,6 @@ export const ExperimentContent: React.FC = (props: IExperimentD const [currentSection, setCurrentSection] = useState(EXPERIMENT_EN.TABS.RESULTS_DATA); const [selectedColumns, setSelectedColumns] = useState(SelectedColumnsDefaultData); - const { isSpinnerOn }: ISpinner = useSpinnerContext(); const resultsDataRef = useRef(null); const visualizationRef = useRef(null); const tableOptionsRef = useRef(null); @@ -71,7 +67,6 @@ export const ExperimentContent: React.FC = (props: IExperimentD return ( <> - {isSpinnerOn && renderSpinner()}
@@ -99,13 +94,3 @@ export const ExperimentContent: React.FC = (props: IExperimentD ); } - -function renderSpinner() { - return ( -
-
- -
-
- ); -} diff --git a/portal/src/app/components/home/components/experiment/components/__mocks__/mocks.ts b/portal/src/app/components/home/components/experiment/components/__mocks__/mocks.ts index 04658e61..4308d519 100644 --- a/portal/src/app/components/home/components/experiment/components/__mocks__/mocks.ts +++ b/portal/src/app/components/home/components/experiment/components/__mocks__/mocks.ts @@ -5,8 +5,8 @@ export const MOCK_DATA_FOR_EXPERIMENT: ITestRunResult = { id: 1, name: "TestRun1", description: "TestRun1", - start_time: "2021-07-26T12:00:00.000Z", - end_time: "2021-07-26T12:00:00.000Z", + start_time: 1705240065192, + end_time: 1705240065192, environment_info: { resourceName: "gddn-aks", operatingSystem: "Linux", @@ -17,7 +17,7 @@ export const MOCK_DATA_FOR_EXPERIMENT: ITestRunResult = { nodeSize: "Standard_D4s_v5", codeRelease: "1.1.0", }, - testRuns: [ + test_runs: [ { id: 1, algorithm: "Algorithm1", @@ -56,8 +56,8 @@ export const MOCK_DATA_FOR_EXPERIMENT_TABLE: ExperimentTableProps = { id: 1, name: "TestRun1", description: "TestRun1", - start_time: "2021-07-26T12:00:00.000Z", - end_time: "2021-07-26T12:00:00.000Z", + start_time: 1705240065192, + end_time: 1705240065192, environment_info: { resourceName: "gddn-aks", operatingSystem: "Linux", @@ -68,7 +68,7 @@ export const MOCK_DATA_FOR_EXPERIMENT_TABLE: ExperimentTableProps = { nodeSize: "Standard_D4s_v5", codeRelease: "1.1.0", }, - testRuns: [ + test_runs: [ { id: 1, algorithm: "Algorithm1", @@ -126,8 +126,8 @@ export const MOCK_DATA_FOR_EXPERIMENT_WITH_NO_TEST_RUNS: ExperimentTableProps = id: 1, name: "TestRun1", description: "TestRun1", - start_time: "2021-07-26T12:00:00.000Z", - end_time: "2021-07-26T12:00:00.000Z", + start_time: 1705240065192, + end_time: 1705240065192, environment_info: { resourceName: "gddn-aks", operatingSystem: "Linux", @@ -138,7 +138,7 @@ export const MOCK_DATA_FOR_EXPERIMENT_WITH_NO_TEST_RUNS: ExperimentTableProps = nodeSize: "Standard_D4s_v5", codeRelease: "1.1.0", }, - testRuns: [] + test_runs: [] }, selectedColumns: [ { @@ -163,20 +163,20 @@ export const MOCK_DATA_FOR_EXPERIMENT_WITH_NO_TEST_RUNS: ExperimentTableProps = export const MOCK_SUB_HEADER: ITestRunResult = { id: 1, name: 'name', - description: 'name', - start_time: 'name', - end_time: 'name', + description: 'description', + start_time: 1705240065192, + end_time: 1705240065192, environment_info: { codeRelease: 'codeRelease', - cpu: 'codeRelease', - cpuArchitecture: 'codeRelease', - cpuClockSpeed: 'codeRelease', + cpu: 'cpu', + cpuArchitecture: 'cpuArchitecture', + cpuClockSpeed: 'cpuClockSpeed', cpuCores: 2, - nodeSize: 'codeRelease', - operatingSystem: 'codeRelease', - resourceName: 'codeRelease', + nodeSize: 'nodeSize', + operatingSystem: 'operatingSystem', + resourceName: 'resourceName', }, - testRuns: [ + test_runs: [ { id:1, algorithm: "bikel1", diff --git a/portal/src/app/components/home/components/experiment/components/charts/Charts.tsx b/portal/src/app/components/home/components/experiment/components/charts/Charts.tsx index 2eab6ea3..929e33b5 100644 --- a/portal/src/app/components/home/components/experiment/components/charts/Charts.tsx +++ b/portal/src/app/components/home/components/experiment/components/charts/Charts.tsx @@ -1,51 +1,11 @@ -/* eslint-disable no-null/no-null */ -import { BarChart } from '../../../../../dashboard/components/charts/BarChart'; -import { LineChart } from '../../../../../dashboard/components/charts/LineChart'; import { IExperimentData } from '../../Experiment'; import styles from './Charts.module.scss'; -import { useChartsData } from './hooks/useChartsData'; -import { tooltipKeys, tooltipLabels } from './models/bar-chart.const'; -import { CHARTS_EN } from './translate/en'; -import { getChartTitleByType } from './utils/chart.utils'; +import { DynamicChart } from './components/dynamic-chart'; export const Charts: React.FC = (props: IExperimentData) => { - const { barChartData, barChartLabels, barChartKeysOfData, lineChartData } = useChartsData(props); - return (
-
{CHARTS_EN.TITLE}
- <> -
- {barChartKeysOfData.map((key, index) => ( -
- -
- ))} -
-
- {barChartKeysOfData.map((key, index) => { - const datasets = lineChartData.datasets - .filter(dataset => dataset.data[key]) - .map(dataset => ({ - ...dataset, - data: dataset.data[key] - })); - - if (datasets.length === 0) return null; - - const data = { - labels: lineChartData.labels, - datasets: datasets - }; - - return ( -
- -
- ); - })} -
- +
); } diff --git a/portal/src/app/components/home/components/experiment/components/charts/__mocks__/mocks.ts b/portal/src/app/components/home/components/experiment/components/charts/__mocks__/mocks.ts index 690e7966..102dd207 100644 --- a/portal/src/app/components/home/components/experiment/components/charts/__mocks__/mocks.ts +++ b/portal/src/app/components/home/components/experiment/components/charts/__mocks__/mocks.ts @@ -5,8 +5,8 @@ export const MOCK_DATA_FOR_CHARTS: IExperimentData = { id: 1, name: "TestRun1", description: "TestRun1", - start_time: "2021-07-26T12:00:00.000Z", - end_time: "2021-07-26T12:00:00.000Z", + start_time: 1705240065192, + end_time: 1705240065192, environment_info: { resourceName: "gddn-aks", operatingSystem: "Linux", @@ -17,7 +17,7 @@ export const MOCK_DATA_FOR_CHARTS: IExperimentData = { nodeSize: "Standard_D4s_v5", codeRelease: "1.1.0", }, - testRuns: [ + test_runs: [ { id: 1, algorithm: "Algorithm1", diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/DynamicChart.module.scss b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/DynamicChart.module.scss new file mode 100644 index 00000000..5ab99e00 --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/DynamicChart.module.scss @@ -0,0 +1,28 @@ +@import "src/styles/variables-keys"; + +.chart_wrapper { + inline-size: 880px; + min-block-size: 550px; + background-color: var($primaryWhite); + border: 1px solid #BDC2C7; + padding: 36px; +} + +.chart_filters { + display: flex; + justify-content: space-between; +} + +.select_item { + inline-size: 260px; + margin-inline-start: 16px; +} + +.select_type_item { + inline-size: 187px; +} + +.select_item_wrapper { + display: flex; + align-items: center; +} diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/DynamicChart.test.tsx b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/DynamicChart.test.tsx new file mode 100644 index 00000000..6d6fc957 --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/DynamicChart.test.tsx @@ -0,0 +1,33 @@ +import { render } from '@testing-library/react'; +import { useDynamicChartData } from './hooks/useDynamicChartData'; +import { BarChart } from '../../../../../../../dashboard/components/charts/BarChart/BarChart'; +import { LineChart } from '../../../../../../../dashboard/components/charts/LineChart/LineChart'; + +jest.mock('../../../../../../../dashboard/components/charts/BarChart/BarChart'); +jest.mock('../../../../../../../dashboard/components/charts/LineChart/LineChart'); +jest.mock('../../../../../../../../shared/components/att-select/AttSelect', () => ({ + AttSelect: jest.fn(() =>
Mocked AttSelect
), +})); +jest.mock('./hooks/useDynamicChartData'); + +describe('DynamicChart', () => { + test('should render Charts', async () => { + (BarChart as jest.Mock).mockImplementation(() =>
BarChart
); + (LineChart as jest.Mock).mockImplementation(() =>
LineChart
); + + + (useDynamicChartData as jest.Mock).mockReturnValue({ + yAxiosOptions: [{ + label: 'averageCPU', + value: 'averageCPU' + }, + { + label: 'averageMemory', + value: 'averageMemory' + }], + }); + + const { container } = render(); + expect(container).toBeTruthy(); + }); +}); diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/DynamicChart.tsx b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/DynamicChart.tsx new file mode 100644 index 00000000..39a54ca0 --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/DynamicChart.tsx @@ -0,0 +1,110 @@ +import { ChartType, chartTypeOptions, xAxisTypeOptions } from "./models/dynamic-chart.interface"; +import styles from './DynamicChart.module.scss'; +import { AttSelect, AttSelectOption, OnSelectChanged } from "../../../../../../../../shared/components/att-select"; +import { useCallback, useEffect, useState } from "react"; +import { SelectOptionType } from "../../../../../../../protocol-query"; +import { ITestRunResult } from "../../../../../../../../shared/models/test-run-result.interface"; +import { useDynamicChartData } from "./hooks/useDynamicChartData"; +import { DYNAMIC_CHART_EN } from "./translate/en"; +import { CustomValueContainer } from "./components/custom-value-container"; +import { CustomOption } from "./components/custom-option"; +import { CustomDropdownIndicator } from "./components/custom-dropdown-indicator"; +import { BarChart } from "../../../../../../../dashboard/components/charts/BarChart"; +import { useChartsData } from "../../hooks/useChartsData"; +import { tooltipKeys, tooltipLabels } from "../../models/bar-chart.const"; +import { getTitleByXAxiosValue } from "./utils/dynamic-chart.utils"; +import { LineChart } from "../../../../../../../dashboard/components/charts/LineChart"; +import { getChartTitleByType } from "../../utils/chart.utils"; + +export interface DynamicChartProps { + chartData: ITestRunResult; +} +export const DynamicChart: React.FC = (props: DynamicChartProps) => { + const { chartData } = props; + const { yAxiosOptions } = useDynamicChartData(chartData); + const [chartType, setChartType] = useState(); + const [xAxisValue, setXAxisValue] = useState(); + const [yAxisValue, setYAxisValue] = useState(); + const { barChartData, barChartLabels, lineChartData } = useChartsData({ data: chartData }); + const [lineChartConvertData, setLineChartConvertData] = useState<{labels: number[], datasets: unknown}>(); + + useEffect(() => { + if (lineChartData) { + const datasets = lineChartData.datasets + .filter(dataset => dataset.data[yAxisValue?.value as string]) + .map(dataset => ({ + ...dataset, + data: dataset.data[yAxisValue?.value as string] + })); + + setLineChartConvertData({ + labels: lineChartData.labels, + datasets: datasets.length === 0 ? null : datasets, + }); + } + }, [lineChartData, yAxisValue?.value]); + + const onChartTypeChanged: OnSelectChanged = useCallback((options: SelectOptionType): void => { + const selectedChartType: AttSelectOption = options as AttSelectOption; + setChartType(selectedChartType); + }, []); + + const onXAxisValueChanged: OnSelectChanged = useCallback((options: SelectOptionType): void => { + const selectedXAxisValue: AttSelectOption = options as AttSelectOption; + setXAxisValue(selectedXAxisValue); + }, []); + + const onYAxisValueChanged: OnSelectChanged = useCallback((options: SelectOptionType): void => { + const selectedYAxisValue: AttSelectOption = options as AttSelectOption; + setYAxisValue(selectedYAxisValue); + }, []); + + return ( +
+
+
+ + +
+
+ + +
+
+ +
+
+ + {xAxisValue?.value && chartType?.value && yAxisValue?.value && + <> + {chartType?.value === ChartType.BAR && barChartData && } + {chartType?.value === ChartType.LINE && lineChartConvertData && } + + } +
+ ); +} diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-dropdown-indicator/CustomDropdownIndicator.test.tsx b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-dropdown-indicator/CustomDropdownIndicator.test.tsx new file mode 100644 index 00000000..029bdf4b --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-dropdown-indicator/CustomDropdownIndicator.test.tsx @@ -0,0 +1,30 @@ +import { render, RenderResult } from '@testing-library/react'; +import { CustomDropdownIndicator } from './CustomDropdownIndicator'; +import { DropdownIndicatorProps } from 'react-select'; +import { AttSelectOption } from '../../../../../../../../../../shared/components/att-select'; + +describe('CustomDropdownIndicator', () => { + const mockProps: DropdownIndicatorProps = { + innerProps: undefined as any, + isFocused: false, + isDisabled: false, + clearValue: jest.fn(), + cx: jest.fn(), + getStyles: jest.fn(), + getClassNames: jest.fn(), + getValue: jest.fn(), + hasValue: false, + isMulti: false, + isRtl: false, + options: [], + selectOption: jest.fn(), + selectProps: undefined as any, + setValue: jest.fn(), + theme: undefined as any, + }; + + it('should render CustomDropdownIndicator', () => { + const { container }: RenderResult = render(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-dropdown-indicator/CustomDropdownIndicator.tsx b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-dropdown-indicator/CustomDropdownIndicator.tsx new file mode 100644 index 00000000..83d903f9 --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-dropdown-indicator/CustomDropdownIndicator.tsx @@ -0,0 +1,24 @@ +import { useCallback } from "react"; +import { DropdownIndicatorProps, components } from "react-select"; +import { AttSelectOption } from "../../../../../../../../../../shared/components/att-select"; +import { ReactComponent as ArrowDownSelectorSvg } from '../../../../../../../../../../../assets/images/arrow-down-selector.svg'; + +export const CustomDropdownIndicator: React.FC> = (props) => { + const { selectProps } = props; + + const handleClick: () => void = useCallback((): void => { + if (selectProps.menuIsOpen) { + selectProps.onMenuClose(); + } else { + selectProps.onMenuOpen(); + } + }, [selectProps]); + + return ( +
+ + + +
+ ); +}; diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-dropdown-indicator/__snapshots__/CustomDropdownIndicator.test.tsx.snap b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-dropdown-indicator/__snapshots__/CustomDropdownIndicator.test.tsx.snap new file mode 100644 index 00000000..ad0f259f --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-dropdown-indicator/__snapshots__/CustomDropdownIndicator.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomDropdownIndicator should render CustomDropdownIndicator 1`] = ` +
+
+ + arrow-down-selector.svg + +
+
+`; diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-dropdown-indicator/index.ts b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-dropdown-indicator/index.ts new file mode 100644 index 00000000..ec30bda8 --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-dropdown-indicator/index.ts @@ -0,0 +1 @@ +export * from './CustomDropdownIndicator'; diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-option/CustomOption.module.scss b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-option/CustomOption.module.scss new file mode 100644 index 00000000..21dbc02b --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-option/CustomOption.module.scss @@ -0,0 +1,4 @@ +.icon { + inline-size: 14px; + margin-inline-end: 12px; +} diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-option/CustomOption.test.tsx b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-option/CustomOption.test.tsx new file mode 100644 index 00000000..52b12c7c --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-option/CustomOption.test.tsx @@ -0,0 +1,42 @@ +import { render, RenderResult } from '@testing-library/react'; +import { CustomOption } from './CustomOption'; +import { SelectorCustomOptionProps } from '../../../../../../../../../../shared/components/selector-custom-option'; + +describe('CustomOption', () => { + const mockOption = { value: 'option1', label: 'Option 1' }; + const mockProps: SelectorCustomOptionProps = { + data: mockOption, + isSelected: false, + selectOption: jest.fn(), + label: 'Option 1', + innerProps: {}, + innerRef: jest.fn(), + children: null, + type: 'option', + isDisabled: false, + isFocused: false, + clearValue: jest.fn(), + cx: jest.fn(), + getStyles: jest.fn(), + getClassNames: jest.fn(), + getValue: jest.fn().mockReturnValue([{ label: 'Option 1', value: 'option1' }]), + hasValue: true, + isMulti: true, + isRtl: false, + options: [], + selectProps: expect.any(Object), + setValue: jest.fn(), + theme: expect.any(Object), + onOptionChanged: jest.fn(), + showInputOption: false, + setShowInputOption: jest.fn(), + inputValue: '1111', + setInputValue: jest.fn(), + setMenuIsOpen: jest.fn(), + }; + + it('should render CustomOption', () => { + const { container }: RenderResult = render(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-option/CustomOption.tsx b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-option/CustomOption.tsx new file mode 100644 index 00000000..71ec0987 --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-option/CustomOption.tsx @@ -0,0 +1,24 @@ +import { GroupBase, OptionProps, components } from "react-select"; +import styles from './CustomOption.module.scss'; +import { AttSelectOption } from "../../../../../../../../../../shared/components/att-select"; +import { capitalizeFirstLetter, getIconByValue } from "../../utils/dynamic-chart.utils"; + +export type SelectorCustomOptionProps = OptionProps, true, GroupBase>> & { + onOptionChanged: (option: AttSelectOption) => void; + showInputOption: boolean; + setShowInputOption: (show: boolean) => void; + inputValue: string; + setInputValue: (value: string) => void; + setMenuIsOpen: (isOpen: boolean) => void; +}; + +export const CustomOption: React.FC = (props: SelectorCustomOptionProps) => { + const option: any = props.data; + + return ( + + {option.value} + {capitalizeFirstLetter(option.value)} + + ); +}; diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-option/__snapshots__/CustomOption.test.tsx.snap b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-option/__snapshots__/CustomOption.test.tsx.snap new file mode 100644 index 00000000..6848c4f2 --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-option/__snapshots__/CustomOption.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomOption should render CustomOption 1`] = ` +
+ option1 + + Option1 + +
+`; diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-option/index.ts b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-option/index.ts new file mode 100644 index 00000000..e3f9e38d --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-option/index.ts @@ -0,0 +1 @@ +export * from './CustomOption'; diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-value-container/CustomValueContainer.module.scss b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-value-container/CustomValueContainer.module.scss new file mode 100644 index 00000000..3144f3ce --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-value-container/CustomValueContainer.module.scss @@ -0,0 +1,17 @@ +.icon { + inline-size: 14px; + margin-inline-end: 12px; +} + +.input_wrapper { + display: flex; + align-items: center; +} + +.value { + margin-block-start: 5px; +} + +.placeholder { + color: #878c94; +} diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-value-container/CustomValueContainer.test.tsx b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-value-container/CustomValueContainer.test.tsx new file mode 100644 index 00000000..34bccade --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-value-container/CustomValueContainer.test.tsx @@ -0,0 +1,29 @@ +import { render, RenderResult } from '@testing-library/react'; +import { CustomValueContainer } from './CustomValueContainer'; +import { GroupBase, SetValueAction, ValueContainerProps } from 'react-select'; +import { AttSelectOption } from '../../../../../../../../../../shared/components/att-select'; + +describe('CustomValueContainer', () => { + const mockProps: ValueContainerProps, boolean, GroupBase>> = { + children: undefined, + isDisabled: false, + clearValue: jest.fn(), + cx: jest.fn(), + getStyles: jest.fn(), + getClassNames: jest.fn(), + getValue: jest.fn(), + hasValue: false, + isMulti: false, + isRtl: false, + options: [], + selectOption: jest.fn(), + selectProps: undefined as any, + setValue: jest.fn(), + theme: undefined as any + }; + + it('should render CustomValueContainer', () => { + const { container }: RenderResult = render(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-value-container/CustomValueContainer.tsx b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-value-container/CustomValueContainer.tsx new file mode 100644 index 00000000..44768c58 --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-value-container/CustomValueContainer.tsx @@ -0,0 +1,37 @@ +import { ValueContainerProps, components } from "react-select" +import { ChartType } from "../../models/dynamic-chart.interface"; +import styles from './CustomValueContainer.module.scss'; +import { AttSelectOption } from "../../../../../../../../../../shared/components/att-select"; +import { PropsWithChildren, useCallback, useRef } from "react"; +import cn from 'classnames'; +import { capitalizeFirstLetter, getIconByValue } from "../../utils/dynamic-chart.utils"; +import { useOutsideClick } from "../../../../../../../../../../hooks/useOutsideClick"; + +export const CustomValueContainer: React.FC> = (props: PropsWithChildren>) => { + const placeholder: string = props.selectProps?.placeholder as string; + const inputValue: string = props.selectProps?.inputValue as string; + const containerRef = useRef(null); + + useOutsideClick(containerRef, () => { + if (props.selectProps.menuIsOpen) { + props.selectProps.onMenuClose(); + } + }); + + const handleClick: () => void = useCallback((): void => { + if (!props.selectProps.menuIsOpen) { + props.selectProps.onMenuOpen(); + } + }, [props.selectProps]); + + return ( +
+ +
+ {props.hasValue && {inputValue}} + {props.hasValue && props.getValue() ? capitalizeFirstLetter(props.getValue()[0]?.value) : placeholder} +
+
+
+ ) +} diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-value-container/__snapshots__/CustomValueContainer.test.tsx.snap b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-value-container/__snapshots__/CustomValueContainer.test.tsx.snap new file mode 100644 index 00000000..3de4a472 --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-value-container/__snapshots__/CustomValueContainer.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomValueContainer should render CustomValueContainer 1`] = ` +
+
+
+ +
+
+
+`; diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-value-container/index.ts b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-value-container/index.ts new file mode 100644 index 00000000..351a4ecc --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/components/custom-value-container/index.ts @@ -0,0 +1 @@ +export * from './CustomValueContainer'; diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/hooks/useDynamicChartData.test.ts b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/hooks/useDynamicChartData.test.ts new file mode 100644 index 00000000..e7ec775b --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/hooks/useDynamicChartData.test.ts @@ -0,0 +1,13 @@ +import { act, renderHook } from "@testing-library/react"; +import { useDynamicChartData } from "./useDynamicChartData"; +import { MOCK_DATA_FOR_EXPERIMENT } from "../../../../__mocks__/mocks"; + +describe('useDynamicChartData', () => { + test('should get data', async () => { + + const { result } = renderHook(() => useDynamicChartData(MOCK_DATA_FOR_EXPERIMENT)); + act(() => { + expect(result.current).toEqual( {yAxiosOptions: [{label: "Average CPU", value: "averageCPU"}, {label: "Average Memory", value: "averageMemory"}]}); + }); + }); +}); diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/hooks/useDynamicChartData.ts b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/hooks/useDynamicChartData.ts new file mode 100644 index 00000000..f271beee --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/hooks/useDynamicChartData.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import { AttSelectOption } from "../../../../../../../../../shared/components/att-select"; +import { ITestRunResult } from "../../../../../../../../../shared/models/test-run-result.interface"; + +export interface IUseDynamicChartData { + yAxiosOptions: AttSelectOption[]; +} +export function useDynamicChartData(chartData: ITestRunResult): IUseDynamicChartData { + const [yAxiosOptions, setYAxiosOptions] = useState([]); + + useEffect(() => { + const uniqueKeys = new Set(); + + for (const testRun of chartData.test_runs) { + const results = testRun.results; + for (const key in results) { + uniqueKeys.add(key); + } + } + + if (uniqueKeys.size > 0) { + setYAxiosOptions(Array.from(uniqueKeys).map(key => ({ label: convertLabelByCapitalLetter(key), value: key }))); + } + }, [chartData]); + + return { + yAxiosOptions, + }; +} + +function convertLabelByCapitalLetter(str: string): string { + let isFirstCapital = true; + const result = str.replace(/([A-Z])/g, (match) => { + if (isFirstCapital) { + isFirstCapital = false; + return ` ${match}`; + } + return match; + }).trim(); + return result.charAt(0).toUpperCase() + result.slice(1); +} diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/index.ts b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/index.ts new file mode 100644 index 00000000..53135f2a --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/index.ts @@ -0,0 +1 @@ +export * from './DynamicChart'; diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/models/dynamic-chart.interface.ts b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/models/dynamic-chart.interface.ts new file mode 100644 index 00000000..e501378d --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/models/dynamic-chart.interface.ts @@ -0,0 +1,20 @@ +import { AttSelectOption } from "../../../../../../../../../shared/components/att-select"; + +export enum ChartType { + LINE = 'line', + BAR = 'bar', +} + +export const chartTypeOptions: AttSelectOption[] = Object.keys(ChartType).map((key) => ({ + value: ChartType[key as keyof typeof ChartType], + label: key, +})); + +export enum XAxisType { + NUMBER_OF_ITERATIONS = 'Number of Iterations', +} + +export const xAxisTypeOptions: AttSelectOption[] = Object.keys(XAxisType).map((key) => ({ + value: key, + label: XAxisType[key as keyof typeof XAxisType], +})); diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/translate/en.ts b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/translate/en.ts new file mode 100644 index 00000000..ea6c046b --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/translate/en.ts @@ -0,0 +1,16 @@ +export const DYNAMIC_CHART_EN = { + SELECTORS: { + LABELS: { + Y_AXIOS: 'Y:', + X_AXIOS: 'X:', + }, + PLACEHOLDERS: { + Y_AXIOS: 'Select Y axios', + X_AXIOS: 'Select X axios', + CHART_TYPE: 'Select chart type', + }, + }, + X_VALUES_TITLE: { + ITERATIONS: 'Iterations', + } +}; diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/utils/dynamic-chart.utils.test.ts b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/utils/dynamic-chart.utils.test.ts new file mode 100644 index 00000000..0058e309 --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/utils/dynamic-chart.utils.test.ts @@ -0,0 +1,19 @@ +import { ChartType } from '../models/dynamic-chart.interface'; +import { capitalizeFirstLetter, getIconByValue, getTitleByXAxiosValue } from './dynamic-chart.utils'; +import LineSvg from '../../../../../../../../../../../src/assets/images/line.svg'; +import BarSvg from '../../../../../../../../../../../src/assets/images/bar.svg'; + +describe('Dynamic chart util test', () => { + test('should get icon by value', () => { + expect(getIconByValue(ChartType.LINE)).toBe(LineSvg); + expect(getIconByValue(ChartType.BAR)).toBe(BarSvg); + }); + + test('should capitalize first letter', () => { + expect(capitalizeFirstLetter('test')).toBe('Test'); + }); + + test('should get title by XAxios value', () => { + expect(getTitleByXAxiosValue('NUMBER_OF_ITERATIONS')).toBe('Iterations'); + }); +}); diff --git a/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/utils/dynamic-chart.utils.ts b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/utils/dynamic-chart.utils.ts new file mode 100644 index 00000000..4f20c0f7 --- /dev/null +++ b/portal/src/app/components/home/components/experiment/components/charts/components/dynamic-chart/utils/dynamic-chart.utils.ts @@ -0,0 +1,16 @@ +import { ChartType } from "../models/dynamic-chart.interface"; +import LineSvg from '../../../../../../../../../../../src/assets/images/line.svg'; +import BarSvg from '../../../../../../../../../../../src/assets/images/bar.svg'; +import { DYNAMIC_CHART_EN } from "../translate/en"; + +export function getIconByValue(value: ChartType): string { + return value === ChartType.LINE ? LineSvg : BarSvg; +} + +export function capitalizeFirstLetter(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export function getTitleByXAxiosValue(value: string): string { + return value === 'NUMBER_OF_ITERATIONS' ? DYNAMIC_CHART_EN.X_VALUES_TITLE.ITERATIONS : ''; +} diff --git a/portal/src/app/components/home/components/experiment/components/charts/hooks/useChartsData.test.ts b/portal/src/app/components/home/components/experiment/components/charts/hooks/useChartsData.test.ts index 868b2319..fa0ee268 100644 --- a/portal/src/app/components/home/components/experiment/components/charts/hooks/useChartsData.test.ts +++ b/portal/src/app/components/home/components/experiment/components/charts/hooks/useChartsData.test.ts @@ -53,8 +53,8 @@ describe('useChartsData', () => { ], lineChartData: { labels: [104, 1024], datasets: [ { - backgroundColor: "#05BBFF", - borderColor: "#05BBFF", + backgroundColor: "#086CE1", + borderColor: "#086CE1", borderWidth: 1, data: { averageCPU: [25.5, 2], diff --git a/portal/src/app/components/home/components/experiment/components/charts/hooks/useChartsData.ts b/portal/src/app/components/home/components/experiment/components/charts/hooks/useChartsData.ts index 40475047..33b57c23 100644 --- a/portal/src/app/components/home/components/experiment/components/charts/hooks/useChartsData.ts +++ b/portal/src/app/components/home/components/experiment/components/charts/hooks/useChartsData.ts @@ -4,6 +4,7 @@ import { ITestRunResultData } from "../../../../../../../shared/models/test-run- import { ILineChartData } from "../models/line-chart-data.interface"; import { colors } from "../../../../../../dashboard/components/charts/LineChart/LineChart.const"; import { IExperimentData } from "../../../Experiment"; +import { getColorByName } from "../../../../../../dashboard/components/charts/utils/charts.utils"; export interface IUseChartsData { barChartLabels: string[]; @@ -45,8 +46,8 @@ function processedLineChartData(data: ITestRunResultData[], keysOfData: string[] label: `${algorithm} `, data: data, fill: false, - backgroundColor: colors[index % colors.length], - borderColor: colors[index % colors.length], + backgroundColor: getColorByName(algorithm), + borderColor: getColorByName(algorithm), borderWidth: 1, }; }) @@ -62,8 +63,8 @@ export function useChartsData(props: IExperimentData): IUseChartsData { const [lineChartData, setLineChartData] = useState(); useEffect(() => { - if(props.data && props.data.testRuns.length > 0) { - const testRuns: ITestRunResultData[] = props.data.testRuns; + if(props.data && props.data.test_runs.length > 0) { + const testRuns: ITestRunResultData[] = props.data.test_runs; setBarChartData(testRuns); const labels: string[] = getLabels(testRuns); setBarChartLabels(labels); diff --git a/portal/src/app/components/home/components/experiment/components/delete-experiment-modal/DeleteExperimentModal.test.tsx b/portal/src/app/components/home/components/experiment/components/delete-experiment-modal/DeleteExperimentModal.test.tsx index 4a45342e..ada5f26b 100644 --- a/portal/src/app/components/home/components/experiment/components/delete-experiment-modal/DeleteExperimentModal.test.tsx +++ b/portal/src/app/components/home/components/experiment/components/delete-experiment-modal/DeleteExperimentModal.test.tsx @@ -5,7 +5,7 @@ import { DeleteExperimentModal, DeleteExperimentModalProps } from './DeleteExper describe('EditExperimentModal', () => { test('renders edit Experiment modal correctly', () => { const props: DeleteExperimentModalProps = { - name: 'Test', + name: ['Test'], onClose: jest.fn(), }; const { baseElement }: RenderResult = render(TestMe); @@ -15,7 +15,7 @@ describe('EditExperimentModal', () => { test('click submit button', () => { const handleClose = jest.fn(); const props: DeleteExperimentModalProps = { - name: 'Test', + name: ['Test'], onClose: handleClose, }; const { getByRole }: RenderResult = render(TestMe); diff --git a/portal/src/app/components/home/components/experiment/components/delete-experiment-modal/DeleteExperimentModal.tsx b/portal/src/app/components/home/components/experiment/components/delete-experiment-modal/DeleteExperimentModal.tsx index 70f5b935..2083c2fa 100644 --- a/portal/src/app/components/home/components/experiment/components/delete-experiment-modal/DeleteExperimentModal.tsx +++ b/portal/src/app/components/home/components/experiment/components/delete-experiment-modal/DeleteExperimentModal.tsx @@ -4,18 +4,16 @@ import { BaseModal } from '../../../../../../shared/components/modal'; import { ButtonActionType, ButtonSize, ButtonStyleType, IButton } from '../../../../../../shared/components/att-button'; import { DELETE_EXPERIMENT_MODAL_EN } from './translate/en'; import { BaseModalSize } from '../../../../../../shared/components/modal/base-modal.const'; -import { translateParserService } from '../../../../../../shared/utils/translate-parser'; export interface DeleteExperimentModalProps { onClose: (confirm?: boolean) => void; - name: string; + name: string[]; } export const DeleteExperimentModal: React.FC = (props: DeleteExperimentModalProps) => { const { name, onClose } = props; const [actionButtons, setActionButtons] = useState([]); - const description: string = translateParserService.interpolateString(DELETE_EXPERIMENT_MODAL_EN.DESCRIPTION, { name }); - + const experimentToDelete = name.map((experimentName, index) =>
  • {experimentName}
  • ); useLayoutEffect(() => { const submitButton: IButton = { @@ -37,7 +35,10 @@ export const DeleteExperimentModal: React.FC = (prop actionButton={actionButtons} size={BaseModalSize.SMALL} > -
    {description}
    +
    +

    {DELETE_EXPERIMENT_MODAL_EN.DESCRIPTION}

    +
      {experimentToDelete}
    +
    ); }; diff --git a/portal/src/app/components/home/components/experiment/components/delete-experiment-modal/translate/en.ts b/portal/src/app/components/home/components/experiment/components/delete-experiment-modal/translate/en.ts index 37e635ca..5af4202e 100644 --- a/portal/src/app/components/home/components/experiment/components/delete-experiment-modal/translate/en.ts +++ b/portal/src/app/components/home/components/experiment/components/delete-experiment-modal/translate/en.ts @@ -1,5 +1,5 @@ export const DELETE_EXPERIMENT_MODAL_EN = { SUBMIT_ACTION: 'Confirm', TITLE: 'Delete Experiment', - DESCRIPTION: 'Are you sure you want to delete "{{name}}" experiment?', + DESCRIPTION: 'Are you sure you want to delete the following experiment(s)?' }; diff --git a/portal/src/app/components/home/components/experiment/components/experiment-table/ExperimentTable.module.scss b/portal/src/app/components/home/components/experiment/components/experiment-table/ExperimentTable.module.scss index 19614b4a..f474ca8c 100644 --- a/portal/src/app/components/home/components/experiment/components/experiment-table/ExperimentTable.module.scss +++ b/portal/src/app/components/home/components/experiment/components/experiment-table/ExperimentTable.module.scss @@ -1,5 +1,4 @@ @import "src/styles/variables-keys"; -@import "src/styles/z-index"; .experiment_table_wrapper { font-size: 14px; @@ -8,3 +7,12 @@ display: flex; flex-wrap: wrap; } + +.experiment_table { + text-align: center; + + th:first-child, + td:first-child { + inline-size: 80px; + } +} \ No newline at end of file diff --git a/portal/src/app/components/home/components/experiment/components/experiment-table/ExperimentTable.tsx b/portal/src/app/components/home/components/experiment/components/experiment-table/ExperimentTable.tsx index d468223b..49fa4dc2 100644 --- a/portal/src/app/components/home/components/experiment/components/experiment-table/ExperimentTable.tsx +++ b/portal/src/app/components/home/components/experiment/components/experiment-table/ExperimentTable.tsx @@ -12,9 +12,9 @@ export interface ExperimentTableProps { } export const ExperimentTable: React.FC = (props: ExperimentTableProps) => { - const data = useMemo(() => (props.data ? props.data.testRuns : []), [props.data]); + const data = useMemo(() => (props.data ? props.data.test_runs : []), [props.data]); - const headers: TableColumn[] = useMemo(() => [ + const headers: TableColumn[] = useMemo(() => [ { id: 'hashtag', header: () => {EXPERIMENT_TABLE_EN.TABLE_TITLES.HASHTAG}, @@ -44,7 +44,7 @@ export const ExperimentTable: React.FC = (props: Experimen return (
    -
    +
    ); }; diff --git a/portal/src/app/components/home/components/experiment/components/hooks/useExperimentData.test.ts b/portal/src/app/components/home/components/experiment/components/hooks/useExperimentData.test.ts index c59d722d..38a54179 100644 --- a/portal/src/app/components/home/components/experiment/components/hooks/useExperimentData.test.ts +++ b/portal/src/app/components/home/components/experiment/components/hooks/useExperimentData.test.ts @@ -21,10 +21,10 @@ describe('useExperimentData', () => { (useFetchSpinner as jest.Mock).mockImplementation(() => undefined); (useErrorMessage as jest.Mock).mockImplementation(() => undefined); - const mockDataNumOfTestRuns = MOCK_DATA_FOR_EXPERIMENT.testRuns.length; + const mockDataNumOfTestRuns = MOCK_DATA_FOR_EXPERIMENT.test_runs.length; const { result } = renderHook(() => useExperimentData()); - expect(result.current.data.testRuns.length).toEqual(mockDataNumOfTestRuns); + expect(result.current.data.test_runs.length).toEqual(mockDataNumOfTestRuns); }); test('Should not render data', () => { diff --git a/portal/src/app/components/home/components/experiment/components/hooks/useExperimentData.ts b/portal/src/app/components/home/components/experiment/components/hooks/useExperimentData.ts index 6039ed60..1c0898a7 100644 --- a/portal/src/app/components/home/components/experiment/components/hooks/useExperimentData.ts +++ b/portal/src/app/components/home/components/experiment/components/hooks/useExperimentData.ts @@ -4,14 +4,13 @@ import { replaceParams } from "../../../../../../shared/utils/replaceParams"; import { useParams } from "react-router-dom"; import { ITestRunResult, ITestRunResultData } from "../../../../../../shared/models/test-run-result.interface"; import { TestRunUrlParams } from "../../../../../../shared/models/url-params.interface"; -import { FetchDataStatus, IHttp, useFetch } from "../../../../../../shared/hooks/useFetch"; +import { IHttp, useFetch } from "../../../../../../shared/hooks/useFetch"; import { sortDataByAlgorithm } from "../charts/utils/test-run.utils"; import { useFetchSpinner } from "../../../../../../shared/hooks/useFetchSpinner"; import { useErrorMessage } from "../../../../../../hooks/useErrorMessage"; export interface IUseExperimentData { data: ITestRunResult; - status: FetchDataStatus; } export function useExperimentData(): IUseExperimentData { @@ -29,14 +28,13 @@ export function useExperimentData(): IUseExperimentData { }, [get, cancelRequest]); useEffect(() => { - if (data && data.testRuns) { - const sortedData: ITestRunResultData[] = sortDataByAlgorithm(data.testRuns); - setTestRunData({ ...data, testRuns: sortedData }); + if (data && data.test_runs) { + const sortedData: ITestRunResultData[] = sortDataByAlgorithm(data.test_runs); + setTestRunData({ ...data, test_runs: sortedData }); } }, [data]); return { - data: testRunData, - status, + data: testRunData } as IUseExperimentData; } diff --git a/portal/src/app/components/home/components/experiment/components/sub-header/SubHeader.tsx b/portal/src/app/components/home/components/experiment/components/sub-header/SubHeader.tsx index 6818c4f3..6c884ace 100644 --- a/portal/src/app/components/home/components/experiment/components/sub-header/SubHeader.tsx +++ b/portal/src/app/components/home/components/experiment/components/sub-header/SubHeader.tsx @@ -62,8 +62,8 @@ export const SubHeader: React.FC = (props: SubHeaderProps) => { const handleDownloadClick: () => void = useCallback((): void => { const csvFileName: string = `${SUB_HEADER_EN.CSV_REPORT.FILE_NAME}-${name || ''}.csv`; - downloadCsvFile(mapExperimentDataToCsvDataType(data.testRuns), csvFileName); - }, [data.testRuns, name]); + downloadCsvFile(mapExperimentDataToCsvDataType(data.test_runs), csvFileName); + }, [data.test_runs, name]); const handleCloseEditExperimentModal: (editData?: EditExperimentModalData) => void = useCallback((editData?: EditExperimentModalData): void => { if (editData) { @@ -103,11 +103,11 @@ export const SubHeader: React.FC = (props: SubHeaderProps) => {
    {SUB_HEADER_EN.ALGORITHM}
    -
    {getAlgorithmsName(data.testRuns)}
    +
    {getAlgorithmsName(data.test_runs)}
    {SUB_HEADER_EN.ITERATIONS}
    -
    {getIterations(data.testRuns)}
    +
    {getIterations(data.test_runs)}
    {experimentDescription}
    @@ -136,7 +136,7 @@ export const SubHeader: React.FC = (props: SubHeaderProps) => { {openEditModal && } - {openDeleteModal && } + {openDeleteModal && } ); } diff --git a/portal/src/app/components/home/components/experiment/components/table-options/__snapshots__/TableOptions.test.tsx.snap b/portal/src/app/components/home/components/experiment/components/table-options/__snapshots__/TableOptions.test.tsx.snap index 7ebde19e..da190153 100644 --- a/portal/src/app/components/home/components/experiment/components/table-options/__snapshots__/TableOptions.test.tsx.snap +++ b/portal/src/app/components/home/components/experiment/components/table-options/__snapshots__/TableOptions.test.tsx.snap @@ -6,7 +6,7 @@ exports[`TableOptions renders without crashing 1`] = ` > diff --git a/portal/src/app/components/protocol-query/ProtocolQuery.test.tsx b/portal/src/app/components/protocol-query/ProtocolQuery.test.tsx index c599c9fc..c444fa76 100644 --- a/portal/src/app/components/protocol-query/ProtocolQuery.test.tsx +++ b/portal/src/app/components/protocol-query/ProtocolQuery.test.tsx @@ -6,12 +6,10 @@ import { PROTOCOL_QUERY_EN } from './translate/en'; describe('ProtocolQuery', () => { let props: ProtocolQueryProps; beforeAll(() => { - // Prepare the props for the ProtocolQuery component props = { isFetching: false, - canExportFile: true, onRunClick: jest.fn(), - onDownloadDataClicked: jest.fn(), + setDuplicateData: jest.fn() }; }); diff --git a/portal/src/app/components/protocol-query/ProtocolQuery.tsx b/portal/src/app/components/protocol-query/ProtocolQuery.tsx index 0e87ab5a..c4865ea5 100644 --- a/portal/src/app/components/protocol-query/ProtocolQuery.tsx +++ b/portal/src/app/components/protocol-query/ProtocolQuery.tsx @@ -10,6 +10,8 @@ import { Spinner, SpinnerSize } from '../../shared/components/att-spinner'; import { useGetAlgorithms, useGetIterations } from './hooks'; import { handleAlgorithmsSelection } from './utils'; import { AlgorithmsSelectorCustomOption, IterationsSelectorCustomOption } from '../../shared/components/selector-custom-option'; +import { ExperimentData } from '../all-experiments/models/experiments.interface'; +import { useDuplicateData } from './hooks'; export type SelectOptionType = AttSelectOption | Options | null; type onTextChangedEvent = (e: React.ChangeEvent) => void; @@ -18,13 +20,13 @@ export type OnSelectChanged = (event: SelectOptionType) => void; export interface ProtocolQueryProps { isFetching: boolean; - canExportFile?: boolean; onRunClick: (data: ITestParams) => void; - onDownloadDataClicked?: () => void; + duplicateData?: ExperimentData; + setDuplicateData: (data?: ExperimentData) => void; } export const ProtocolQuery: React.FC = (props: ProtocolQueryProps) => { - const { isFetching, canExportFile, onRunClick, onDownloadDataClicked } = props; + const { isFetching, onRunClick, duplicateData, setDuplicateData } = props; const { algorithmOptions, algosBySection } = useGetAlgorithms(); const { iterationsOptions } = useGetIterations(); @@ -37,7 +39,9 @@ export const ProtocolQuery: React.FC = (props: ProtocolQuery const [showInputOption, setShowInputOption] = useState(false); const [inputValue, setInputValue] = useState(''); const [iterationsMenuIsOpen, setIterationsMenuIsOpen] = useState(false); - + + useDuplicateData({ data: duplicateData, setDuplicateData, setExperimentName, setAlgorithms, setIterationsCount }); + const onSubmitHandler = (event: React.FormEvent) => { event.preventDefault(); onRunClick({ @@ -86,6 +90,7 @@ export const ProtocolQuery: React.FC = (props: ProtocolQuery = (props: ProtocolQuery } - {/* */} ); }; diff --git a/portal/src/app/components/protocol-query/__snapshots__/ProtocolQuery.test.tsx.snap b/portal/src/app/components/protocol-query/__snapshots__/ProtocolQuery.test.tsx.snap index f4846208..73a8f54e 100644 --- a/portal/src/app/components/protocol-query/__snapshots__/ProtocolQuery.test.tsx.snap +++ b/portal/src/app/components/protocol-query/__snapshots__/ProtocolQuery.test.tsx.snap @@ -45,6 +45,7 @@ exports[`ProtocolQuery should render ProtocolQuery 1`] = ` class="input_form_item" placeholder="" required="" + value="" />
    { + it('should set experiment name, algorithms, and iterations count when duplicate data is provided', () => { + const setExperimentName = jest.fn(); + const setAlgorithms = jest.fn(); + const setIterationsCount = jest.fn(); + const setDuplicateData = jest.fn(); + + const duplicateData: ExperimentData = { + id: 1111, + name: 'test', + algorithms: ['algorithm1', 'algorithm2'], + iterations: [1, 2, 3], + end_time: 1705240065192, + }; + + const { rerender } = renderHook((props: DuplicateData) => useDuplicateData(props), { + initialProps: { + data: undefined, + setDuplicateData, + setExperimentName, + setAlgorithms, + setIterationsCount, + } as DuplicateData, + }); + + expect(setExperimentName).not.toHaveBeenCalled(); + expect(setAlgorithms).not.toHaveBeenCalled(); + expect(setIterationsCount).not.toHaveBeenCalled(); + expect(setDuplicateData).not.toHaveBeenCalled(); + + rerender({ + data: duplicateData, + setDuplicateData, + setExperimentName, + setAlgorithms, + setIterationsCount, + }); + + expect(setExperimentName).toHaveBeenCalledWith(duplicateData.name); + expect(setAlgorithms).toHaveBeenCalledWith(duplicateData.algorithms.map(algorithm => ({ label: algorithm, value: algorithm } as AttSelectOption))); + expect(setIterationsCount).toHaveBeenCalledWith(duplicateData.iterations.map(iteration => ({ label: iteration.toString(), value: iteration.toString() } as AttSelectOption))); + expect(setDuplicateData).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/portal/src/app/components/protocol-query/hooks/useDuplicateData.ts b/portal/src/app/components/protocol-query/hooks/useDuplicateData.ts new file mode 100644 index 00000000..8cf59d88 --- /dev/null +++ b/portal/src/app/components/protocol-query/hooks/useDuplicateData.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import { AttSelectOption } from '../../../shared/components/att-select'; +import { ExperimentData } from '../../all-experiments/models/experiments.interface'; + +export type DuplicateData = { + data: ExperimentData | undefined, + setDuplicateData: (data: any) => void, + setExperimentName: (name: string) => void, + setAlgorithms: (options: AttSelectOption[]) => void, + setIterationsCount: (options: AttSelectOption[]) => void +} +export const useDuplicateData = (duplicate: DuplicateData) => { + useEffect(() => { + if (duplicate.data) { + const duplicateData = duplicate.data; + if (duplicateData.name) { + duplicate.setExperimentName(duplicateData.name); + } + if (duplicateData.algorithms) { + const algorithmOptions = duplicateData.algorithms.map((algorithm: string) => { + return { label: algorithm, value: algorithm } as AttSelectOption; + }); + duplicate.setAlgorithms(algorithmOptions); + } + + if (duplicateData.iterations) { + const iterationsOptions = duplicateData.iterations.map((iteration: number) => { + return { label: iteration.toString(), value: iteration.toString() } as AttSelectOption; + }); + duplicate.setIterationsCount(iterationsOptions); + } + duplicate.setDuplicateData(undefined); + } + }, [duplicate]); +}; diff --git a/portal/src/app/components/sub-header/translate/en.ts b/portal/src/app/components/sub-header/translate/en.ts index e2008f60..4bc9e36a 100644 --- a/portal/src/app/components/sub-header/translate/en.ts +++ b/portal/src/app/components/sub-header/translate/en.ts @@ -1,6 +1,6 @@ // eslint-disable-next-line @typescript-eslint/typedef export const SUB_HEADER_EN = { - TITLE: 'That’s what are we doing on each iteration:', + TITLE: 'That’s what we are doing on each iteration:', DESCRIPTIONS: { STEP_1: 'Initiating handshake\nusing the selected\ncryptographic algorithm.', STEP_2: 'Exchanging keys using\nthe selected algorithm to\ncreate a shared secret.', diff --git a/portal/src/app/hooks/useOutsideClick.test.tsx b/portal/src/app/hooks/useOutsideClick.test.tsx new file mode 100644 index 00000000..2aa48728 --- /dev/null +++ b/portal/src/app/hooks/useOutsideClick.test.tsx @@ -0,0 +1,30 @@ +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { MutableRefObject, useRef } from 'react'; +import { useOutsideClick } from './useOutsideClick'; + +interface TestComponentProps { + onClickOutside: () => void +} + +describe('useOutsideClick', () => { + let TestComponent: React.FC; + + beforeEach(() => { + TestComponent = function Component({ onClickOutside }: TestComponentProps) { + const innerElementRef: MutableRefObject = useRef(null); + useOutsideClick(innerElementRef, onClickOutside); + return
    Test Component
    ; + }; + }); + afterEach(cleanup); + + test('should not trigger event when inside element is clicked', () => { + const onClickOutside: jest.Mock = jest.fn(); + const { getByTestId } = render(); + const insideElement: HTMLElement = getByTestId('inside'); + act(() => { + fireEvent.mouseDown(insideElement); + }); + expect(onClickOutside).toHaveBeenCalledTimes(0); + }); +}); diff --git a/portal/src/app/hooks/useOutsideClick.ts b/portal/src/app/hooks/useOutsideClick.ts new file mode 100644 index 00000000..45794b9d --- /dev/null +++ b/portal/src/app/hooks/useOutsideClick.ts @@ -0,0 +1,16 @@ +import { RefObject, useEffect } from "react"; + +export const useOutsideClick = (ref: RefObject, callback: () => void) => { + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + callback(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [ref, callback]); +}; diff --git a/portal/src/app/shared/components/navigation-tab/NavigationTab.module.scss b/portal/src/app/shared/components/navigation-tab/NavigationTab.module.scss index 9e633f09..02d548dc 100644 --- a/portal/src/app/shared/components/navigation-tab/NavigationTab.module.scss +++ b/portal/src/app/shared/components/navigation-tab/NavigationTab.module.scss @@ -7,7 +7,7 @@ .tab { color: var($primaryWhite); - margin-inline-end: 20px; + margin-inline-end: 30px; text-decoration: none; } diff --git a/portal/src/app/shared/components/table/Table.module.scss b/portal/src/app/shared/components/table/Table.module.scss index ca419a35..670fff67 100644 --- a/portal/src/app/shared/components/table/Table.module.scss +++ b/portal/src/app/shared/components/table/Table.module.scss @@ -8,11 +8,6 @@ table { table-layout: fixed; } -th:first-child, -td:first-child { - inline-size: 80px; -} - .table_titles { padding: 16px; background-color: var($attPurple); @@ -25,11 +20,7 @@ td:first-child { .table_content { background-color: var($backgroundColorWhite); - - td { - padding: 16px; - border-block-end: 1px solid var($backgroundColorGray); - text-align: center; - vertical-align: middle; - } + padding: 16px; + border-block-end: 1px solid var($backgroundColorGray); + vertical-align: middle; } diff --git a/portal/src/app/shared/components/table/Table.tsx b/portal/src/app/shared/components/table/Table.tsx index 70766ac8..37386ed5 100644 --- a/portal/src/app/shared/components/table/Table.tsx +++ b/portal/src/app/shared/components/table/Table.tsx @@ -1,4 +1,5 @@ import styles from './Table.module.scss'; +import cn from 'classnames'; import { useMemo, useState } from 'react'; import { Cell, @@ -14,28 +15,28 @@ import { getCoreRowModel, useReactTable, } from '@tanstack/react-table'; -import { ITestRunResultData } from '../../models/test-run-result.interface'; import SortascendingSvg from '../../../../assets/images/sort-ascending.svg'; import SortDescendingSvg from '../../../../assets/images/sort-descending.svg'; const SortAscendingLabel: string = 'ascending'; const SortDescendingLabel: string = 'descending'; -export interface TableColumn { +export interface TableColumn { id: string; - header: (context: HeaderContext) => React.ReactNode; - accessor: (row: ITestRunResultData) => any; - cell?: (cellInfo: CellContext) => JSX.Element; + header: (context: HeaderContext) => React.ReactNode; + accessor: (row: T) => any; + cell?: (cellInfo: CellContext, row?: T) => JSX.Element; } -export interface TableProps { - headers: TableColumn[]; - data: ITestRunResultData[]; +export interface TableProps { + className?: string; + headers: TableColumn[]; + data: T[]; } -export const Table: React.FC = ({ headers, data }) => { +export const Table = ({ headers, data, className }: TableProps) => { const [sorting, setSorting] = useState([]) - const columns: ColumnDef[] = useMemo(() => { + const columns: ColumnDef[] = useMemo(() => { return headers.map(header => ({ id: header.id, header: header.header, @@ -56,11 +57,11 @@ export const Table: React.FC = ({ headers, data }) => { }); return ( -
    +
    - {table.getHeaderGroups().map((headerGroup: HeaderGroup) => ( + {table.getHeaderGroups().map((headerGroup: HeaderGroup) => ( - {headerGroup.headers.map((header: Header) => ( + {headerGroup.headers.map((header: Header) => ( - {table.getRowModel().rows.map((row: Row) => ( + {table.getRowModel().rows.map((row: Row) => ( - {row.getVisibleCells().map((cell: Cell) => ( - ))} diff --git a/portal/src/app/shared/constants/navigation-tabs.const.ts b/portal/src/app/shared/constants/navigation-tabs.const.ts index 12ff222a..0a3d00e2 100644 --- a/portal/src/app/shared/constants/navigation-tabs.const.ts +++ b/portal/src/app/shared/constants/navigation-tabs.const.ts @@ -5,9 +5,8 @@ export const tabs = [ link: '/qujata', title: SHARED_EN.NAVIGATION_TABS.HOME, }, - // { - // link: '/all-experiments', - // title: SHARED_EN.NAVIGATION_TABS.ALL_EXPERIMENTS, - // disabled: true, - // } + { + link: '/qujata/test_suites', + title: SHARED_EN.NAVIGATION_TABS.ALL_EXPERIMENTS, + } ]; diff --git a/portal/src/app/shared/models/test-run-result.interface.ts b/portal/src/app/shared/models/test-run-result.interface.ts index 809f3bd6..2f451f4b 100644 --- a/portal/src/app/shared/models/test-run-result.interface.ts +++ b/portal/src/app/shared/models/test-run-result.interface.ts @@ -24,8 +24,8 @@ export interface ITestRunResult { id: number; name: string; description: string; - start_time: string; - end_time: string; + start_time: number; + end_time: number; environment_info: IEnvironmentInfo; - testRuns: ITestRunResultData[]; + test_runs: ITestRunResultData[]; } diff --git a/portal/src/assets/images/arrow-down-selector.svg b/portal/src/assets/images/arrow-down-selector.svg new file mode 100644 index 00000000..70254ea4 --- /dev/null +++ b/portal/src/assets/images/arrow-down-selector.svg @@ -0,0 +1,3 @@ + diff --git a/portal/src/assets/images/bar.svg b/portal/src/assets/images/bar.svg new file mode 100644 index 00000000..bd0f62e7 --- /dev/null +++ b/portal/src/assets/images/bar.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/portal/src/assets/images/duplicate.svg b/portal/src/assets/images/duplicate.svg new file mode 100644 index 00000000..214d3d93 --- /dev/null +++ b/portal/src/assets/images/duplicate.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/portal/src/assets/images/line.svg b/portal/src/assets/images/line.svg new file mode 100644 index 00000000..5eaef2ea --- /dev/null +++ b/portal/src/assets/images/line.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/portal/src/assets/images/trash-hover.svg b/portal/src/assets/images/trash-hover.svg new file mode 100644 index 00000000..81fe1736 --- /dev/null +++ b/portal/src/assets/images/trash-hover.svg @@ -0,0 +1,4 @@ + + + + diff --git a/portal/src/routes/Root.jsx b/portal/src/routes/Root.jsx index a4964046..c6bf76ca 100644 --- a/portal/src/routes/Root.jsx +++ b/portal/src/routes/Root.jsx @@ -1,12 +1,27 @@ +import styles from './Root.module.scss'; import { GlobalHeader } from '../app/shared/components/global-header/index'; import { Outlet } from 'react-router-dom'; import { tabs } from '../app/shared/constants/navigation-tabs.const'; +import { Spinner, SpinnerSize } from '../app/shared/components/att-spinner'; +import { useSpinnerContext } from '../app/shared/context/spinner'; export default function Root() { + const { isSpinnerOn } = useSpinnerContext(); return ( <> + {isSpinnerOn && renderSpinner()} ); } + +function renderSpinner() { + return ( +
    +
    + +
    +
    + ); +} diff --git a/portal/src/routes/Root.module.scss b/portal/src/routes/Root.module.scss new file mode 100644 index 00000000..a08a79d2 --- /dev/null +++ b/portal/src/routes/Root.module.scss @@ -0,0 +1,19 @@ +@import "src/styles/variables-keys"; + +.spinner_wrapper { + position: sticky; + inset-block-start: 50%; + inset-inline-start: 50%; + text-align: center; +} + +.spinner_overlay { + inline-size: 100%; + block-size: 100%; + position: absolute; + background-color: var($primaryWhite); + opacity: 0.6; + inset-block-start: 0; + inset-inline-start: 0; + z-index: 4; +} diff --git a/portal/src/routes/index.jsx b/portal/src/routes/index.jsx index a84f73d0..c9dabd47 100644 --- a/portal/src/routes/index.jsx +++ b/portal/src/routes/index.jsx @@ -2,26 +2,26 @@ import { createBrowserRouter } from 'react-router-dom'; import Root from './Root'; import { Home } from '../app/components/home/Home'; import { Experiment } from '../app/components/home/components/experiment/Experiment'; +import { Experiments } from '../app/components/all-experiments/Experiments'; -const isAllExperimentTabEnabled = false; export const router = createBrowserRouter([ { - path: '/qujata', - element: , - children: [ - { - path: '', - index: true, - element: , - }, - { - path: 'experiment/:testSuiteId', - element: , - }, - ...(isAllExperimentTabEnabled ? [{ - path: 'All-Experiments', - element:
    All Experiments
    , - }] : []), - ], + path: '/qujata', + element: , + children: [ + { + path: '', + index: true, + element: , + }, + { + path: 'experiment/:testSuiteId', + element: , + }, + { + path: 'test_suites', + element: , + }, + ], }, ]); diff --git a/portal/yarn.lock b/portal/yarn.lock index ddc70411..fcf80b7b 100644 --- a/portal/yarn.lock +++ b/portal/yarn.lock @@ -4034,6 +4034,11 @@ data-urls@^2.0.0: whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" +date-fns@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.3.0.tgz#c1681691cf751a1d371279099a45e71409c7c761" + integrity sha512-xuouT0GuI2W8yXhCMn/AXbSl1Av3wu2hJXxMnnILTY3bYY0UgNK0qOwVXqdFBrobW5qbX1TuOTgMw7c2H2OuhA== + debug@2.6.9, debug@^2.6.0: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" diff --git a/run/docker/docker-compose.yml b/run/docker/docker-compose.yml index 51cbd20e..9c1ce1f1 100644 --- a/run/docker/docker-compose.yml +++ b/run/docker/docker-compose.yml @@ -23,7 +23,7 @@ services: - DEFAULT_GROUPS=prime256v1:secp384r1:frodo640aes:frodo640shake:frodo976aes:frodo976shake:frodo1344aes:frodo1344shake:kyber512:p256_kyber512:kyber768:p384_kyber768:x25519_kyber768:kyber1024:bikel1:bikel3:bikel5:hqc128:hqc192:hqc256 curl: - image: qujata/curl:1.1.0 + image: qujata/curl:1.2.0 container_name: qujata-curl ports: - 3010:3010 @@ -31,7 +31,7 @@ services: - DEFAULT_GROUPS=prime256v1:secp384r1:frodo640aes:frodo640shake:frodo976aes:frodo976shake:frodo1344aes:frodo1344shake:kyber512:p256_kyber512:kyber768:p384_kyber768:x25519_kyber768:kyber1024:bikel1:bikel3:bikel5:hqc128:hqc192:hqc256 api: - image: qujata/api:1.1.0 + image: qujata/api:1.2.0 container_name: qujata-api ports: - 3020:3020 diff --git a/run/kubernetes/charts/api/values.yaml b/run/kubernetes/charts/api/values.yaml index cfe564e8..8f8fab8e 100644 --- a/run/kubernetes/charts/api/values.yaml +++ b/run/kubernetes/charts/api/values.yaml @@ -6,7 +6,7 @@ replicaCount: 1 image: repository: qujata/api - tag: "1.1.0" + tag: "1.2.0" pullPolicy: Always imagePullSecrets: [] diff --git a/run/kubernetes/charts/curl/values.yaml b/run/kubernetes/charts/curl/values.yaml index 5d71536d..8347c9a0 100644 --- a/run/kubernetes/charts/curl/values.yaml +++ b/run/kubernetes/charts/curl/values.yaml @@ -6,7 +6,7 @@ replicaCount: 1 image: repository: qujata/curl - tag: "1.1.0" + tag: "1.2.0" pullPolicy: Always
    = ({ headers, data }) => { ))}
    + {row.getVisibleCells().map((cell: Cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())}