From c1bed6d8129cef7b5cfa3620c0c07d85090e6604 Mon Sep 17 00:00:00 2001 From: Jino Enriquez <47510297+OhJino@users.noreply.github.com> Date: Sun, 19 Oct 2025 22:40:15 -0700 Subject: [PATCH 1/7] Feature/ei 35 (#26) * EI-161 - Defined the Job table schema * EI-161 | removing is_active * EI-161 | Removed extra comma - syntax error * Fixed INTEGRATION typo --------- Co-authored-by: 2omb Finance <2ombfinance@protonmail.com> --- endpoint-insights-sql/job.sql | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 endpoint-insights-sql/job.sql diff --git a/endpoint-insights-sql/job.sql b/endpoint-insights-sql/job.sql new file mode 100644 index 0000000..b368fd9 --- /dev/null +++ b/endpoint-insights-sql/job.sql @@ -0,0 +1,15 @@ +CREATE TABLE job ( + job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + test_type VARCHAR(10) NOT NULL CHECK (test_type IN ('PERF', 'INTEGRATION', 'E2E')), + target_id VARCHAR(1024) NOT NULL, + created_by VARCHAR(255) NOT NULL, + FOREIGN KEY (created_by) REFERENCES users(id), + status VARCHAR(50) DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + started_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE, + config JSONB +); \ No newline at end of file From 71ea4508af8203ea5df28fa8ffbebd10052e160b Mon Sep 17 00:00:00 2001 From: Marcos Pantoja <105100104+Mxrcos13@users.noreply.github.com> Date: Sun, 19 Oct 2025 22:43:50 -0700 Subject: [PATCH 2/7] EI-209 Schedule Table Complete (#18) * Chore: add Marcos to readme * chore: Add Marcos to readme * Revert "Chore: add Marcos to readme" This reverts commit f9c78c992b89e5621f0b75f89986007255f4c81c. * EI-209 Created schedule table on database with attributes based on ERD. * fixed minor ijmport errors and added JPA dependencies to pom.xml file * Revert "fixed minor ijmport errors and added JPA dependencies to pom.xml file" This reverts commit 10425d2c308f937aef45d84e9a000457aff1fbb8. * fixed minor import errors and added JPA dependencies to pom.xml file * chore: add spring test profile disabling auto config for databases * created as commented placeholders for classes that were not implemented yet. * Added sql schedule table script, added lombook @Getter and @Setter support, added JPA @temporal notation. --------- Signed-off-by: Marcos Pantoja <105100104+Mxrcos13@users.noreply.github.com> Co-authored-by: Caleb Brock --- endpoint-insights-api/pom.xml | 12 +++++ .../schedule/JobSchedule.java | 53 +++++++++++++++++++ .../schedule/JobScheduleRepository.java | 10 ++++ .../src/main/resources/scheduletable.sql | 10 ++++ 4 files changed, 85 insertions(+) create mode 100644 endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobSchedule.java create mode 100644 endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobScheduleRepository.java create mode 100644 endpoint-insights-api/src/main/resources/scheduletable.sql diff --git a/endpoint-insights-api/pom.xml b/endpoint-insights-api/pom.xml index 841cefc..ec4c2bd 100644 --- a/endpoint-insights-api/pom.xml +++ b/endpoint-insights-api/pom.xml @@ -78,6 +78,18 @@ 1.18.42 provided + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + jakarta.persistence + jakarta.persistence-api + 3.1.0 + org.springframework.boot diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobSchedule.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobSchedule.java new file mode 100644 index 0000000..b394556 --- /dev/null +++ b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobSchedule.java @@ -0,0 +1,53 @@ +package com.vsp.endpointinsightsapi.schedule; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import java.util.Date; + +@Getter +@Setter +@Entity +@Table(name = "job_schedule") +public class JobSchedule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long scheduleId; + +// @ManyToOne(fetch = FetchType.LAZY) +// @JoinColumn(name = "job_id", nullable = false) +// private Job job; + + @Column(name = "cron_expr", nullable = false, length = 50) + private String cronExpr; + + @Column(name = "timezone", nullable = false, length = 50) + private String timezone = "PDT"; + + @Column(name = "next_run_at") + @Temporal(TemporalType.TIMESTAMP) + private Date nextRunAt; + + @Column(name = "is_enabled", nullable = false) + private Boolean isEnabled = true; + + @Column(name = "created_at", nullable = false, updatable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date createdAt; + + @Column(name = "updated_at") + @Temporal(TemporalType.TIMESTAMP) + private Date updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = new Date(); + updatedAt = new Date(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = new Date(); + } +} \ No newline at end of file diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobScheduleRepository.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobScheduleRepository.java new file mode 100644 index 0000000..7a9c99c --- /dev/null +++ b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobScheduleRepository.java @@ -0,0 +1,10 @@ +package com.vsp.endpointinsightsapi.schedule; + +import com.vsp.endpointinsightsapi.schedule.JobSchedule; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface JobScheduleRepository extends JpaRepository { + +} \ No newline at end of file diff --git a/endpoint-insights-api/src/main/resources/scheduletable.sql b/endpoint-insights-api/src/main/resources/scheduletable.sql new file mode 100644 index 0000000..2d75543 --- /dev/null +++ b/endpoint-insights-api/src/main/resources/scheduletable.sql @@ -0,0 +1,10 @@ +CREATE TABLE job_schedule ( + schedule_id SERIAL PRIMARY KEY, + job_id BIGINT NOT NULL REFERENCES job(job_id) ON DELETE CASCADE, + cron_expr VARCHAR(50) NOT NULL, + timezone VARCHAR(50) NOT NULL DEFAULT 'PDT', + next_run_at TIMESTAMP, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); From 76ee8903a126d3fed2da45b30cee8c361a86bc01 Mon Sep 17 00:00:00 2001 From: Nicholas Cooper Date: Fri, 24 Oct 2025 22:36:30 -0700 Subject: [PATCH 3/7] EI-113: create batch card component (#22) --- endpoint-insights-ui/package-lock.json | 213 +++++++++--- endpoint-insights-ui/package.json | 3 + endpoint-insights-ui/src/app/app.config.ts | 15 +- endpoint-insights-ui/src/app/app.html | 11 +- endpoint-insights-ui/src/app/app.routes.ts | 8 +- endpoint-insights-ui/src/app/app.spec.ts | 37 +- endpoint-insights-ui/src/app/app.ts | 18 +- .../app/batch-component/batch-component.html | 9 + .../app/batch-component/batch-component.scss | 13 + .../app/batch-component/batch-component.ts | 26 ++ .../batch-card/batch-card.component.html | 16 + .../batch-card/batch-card.component.scss | 54 +++ .../batch-card/batch-card.component.ts | 24 ++ .../test-results-card.component.html | 182 ++++++++++ .../test-results-card.component.scss | 325 ++++++++++++++++++ .../test-results-card.component.spec.ts | 173 ++++++++++ .../test-results-card.component.ts | 82 +++++ .../dashboard-component.html | 7 +- .../dashboard-component.ts | 43 ++- .../src/app/models/batch.model.ts | 5 + .../src/app/models/test-record.model.ts | 82 +++++ .../src/app/services/test-record.service.ts | 33 ++ endpoint-insights-ui/src/index.html | 4 +- endpoint-insights-ui/src/main.ts | 7 +- endpoint-insights-ui/src/styles.scss | 80 ++++- 25 files changed, 1359 insertions(+), 111 deletions(-) create mode 100644 endpoint-insights-ui/src/app/batch-component/batch-component.html create mode 100644 endpoint-insights-ui/src/app/batch-component/batch-component.scss create mode 100644 endpoint-insights-ui/src/app/batch-component/batch-component.ts create mode 100644 endpoint-insights-ui/src/app/batch-component/components/batch-card/batch-card.component.html create mode 100644 endpoint-insights-ui/src/app/batch-component/components/batch-card/batch-card.component.scss create mode 100644 endpoint-insights-ui/src/app/batch-component/components/batch-card/batch-card.component.ts create mode 100644 endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.html create mode 100644 endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.scss create mode 100644 endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.spec.ts create mode 100644 endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.ts create mode 100644 endpoint-insights-ui/src/app/models/batch.model.ts create mode 100644 endpoint-insights-ui/src/app/models/test-record.model.ts create mode 100644 endpoint-insights-ui/src/app/services/test-record.service.ts diff --git a/endpoint-insights-ui/package-lock.json b/endpoint-insights-ui/package-lock.json index 696b08f..c8536cc 100644 --- a/endpoint-insights-ui/package-lock.json +++ b/endpoint-insights-ui/package-lock.json @@ -8,10 +8,13 @@ "name": "endpoint-insights-ui", "version": "0.0.1", "dependencies": { + "@angular/animations": "^20.3.7", + "@angular/cdk": "^20.2.9", "@angular/common": "^20.3.0", "@angular/compiler": "^20.3.0", "@angular/core": "^20.3.0", "@angular/forms": "^20.3.0", + "@angular/material": "^20.2.9", "@angular/platform-browser": "^20.3.0", "@angular/router": "^20.3.0", "@ng-bootstrap/ng-bootstrap": "^19.0.1", @@ -220,6 +223,7 @@ }, "node_modules/@ampproject/remapping": { "version": "2.3.0", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -435,6 +439,21 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/animations": { + "version": "20.3.7", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.7.tgz", + "integrity": "sha512-i655RaL0zmLE3OESUlDnRNBDRIMW/67nTQvMqP6V1cQ42l2+SMJtREsxmX6cWt55/qvvgeytAA6aBN4aerBl5A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "20.3.7" + } + }, "node_modules/@angular/build": { "version": "20.3.6", "dev": true, @@ -532,6 +551,21 @@ } } }, + "node_modules/@angular/cdk": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.2.9.tgz", + "integrity": "sha512-rbY1AMz9389WJI29iAjWp4o0QKRQHCrQQUuP0ctNQzh1tgWpwiRLx8N4yabdVdsCA846vPsyKJtBlSNwKMsjJA==", + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^20.0.0 || ^21.0.0", + "@angular/core": "^20.0.0 || ^21.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/cli": { "version": "20.3.6", "dev": true, @@ -566,9 +600,10 @@ } }, "node_modules/@angular/common": { - "version": "20.3.6", + "version": "20.3.7", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.7.tgz", + "integrity": "sha512-uf8dXYTJbedk/wudkt2MfbtvN/T97aEZBtOTq8/IFQQZ3722rag6D+Cg76e5hBccROOn+ueGJX2gpxz02phTwA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -576,12 +611,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.6", + "@angular/core": "20.3.7", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "20.3.6", + "version": "20.3.7", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.7.tgz", + "integrity": "sha512-EouHO15dUsgnFArj0M25R8cOPVoUfiFYSt6iXnMO8+S4dY1fDEmbFqkW5smlP66HL5Gys59Nwb5inejfIWHrLw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -591,9 +628,11 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "20.3.6", + "version": "20.3.7", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.7.tgz", + "integrity": "sha512-viZwWlwc1BAqryRJE0Wq2WgAxDaW9fuwtYHYrOWnIn9sy9KemKmR6RmU9VRydrwUROOlqK49R9+RC1wQ6sYwqA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.28.3", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -612,7 +651,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.6", + "@angular/compiler": "20.3.7", "typescript": ">=5.8 <6.0" }, "peerDependenciesMeta": { @@ -622,9 +661,10 @@ } }, "node_modules/@angular/core": { - "version": "20.3.6", + "version": "20.3.7", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.7.tgz", + "integrity": "sha512-2UuYzC2A5SUtu33tYTN411Wk0WilA+2Uld/GP3O6mragw1O7v/M8pMFmbe9TR5Ah/abRJIocWGlNqeztZmQmrw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -632,7 +672,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.6", + "@angular/compiler": "20.3.7", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0" }, @@ -646,9 +686,10 @@ } }, "node_modules/@angular/forms": { - "version": "20.3.6", + "version": "20.3.7", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.7.tgz", + "integrity": "sha512-uOCGCoqXeAWIlQMWiIeed/W8g8h2tk91YemMI+Ce1VQ/36Xfft40Bouz4eKcvJV6kLXGygdpWjzFGz32CE+3Og==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -656,16 +697,18 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.6", - "@angular/core": "20.3.6", - "@angular/platform-browser": "20.3.6", + "@angular/common": "20.3.7", + "@angular/core": "20.3.7", + "@angular/platform-browser": "20.3.7", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/localize": { - "version": "20.3.6", + "version": "20.3.7", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-20.3.7.tgz", + "integrity": "sha512-FYuuwU9ujiVT+0xjMIutaUT2PErV4AvxeAPWMlYRA1/yQxqn1VyNUd6kHPjAV+yrZg9Q0MDco2/c0Lh8rmAhSA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.28.3", "@types/babel__core": "7.20.5", @@ -681,14 +724,32 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.6", - "@angular/compiler-cli": "20.3.6" + "@angular/compiler": "20.3.7", + "@angular/compiler-cli": "20.3.7" + } + }, + "node_modules/@angular/material": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-20.2.9.tgz", + "integrity": "sha512-xo/ozyRXCoJMi89XLTJI6fdPKBv2wBngWMiCrtTg23+pHbuyA/kDbk3v62eJkDD1xdhC4auXaIHu4Ddf5zTgSA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/cdk": "20.2.9", + "@angular/common": "^20.0.0 || ^21.0.0", + "@angular/core": "^20.0.0 || ^21.0.0", + "@angular/forms": "^20.0.0 || ^21.0.0", + "@angular/platform-browser": "^20.0.0 || ^21.0.0", + "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "20.3.6", + "version": "20.3.7", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.7.tgz", + "integrity": "sha512-AbLtyR7fVEGDYyrz95dP2pc69J5XIjLLsFNAuNQPzNX02WPoAxtrWrNY6UnTzGoSrCc5F52hiL2Uo6yPZTiJcg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -696,9 +757,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "20.3.6", - "@angular/common": "20.3.6", - "@angular/core": "20.3.6" + "@angular/animations": "20.3.7", + "@angular/common": "20.3.7", + "@angular/core": "20.3.7" }, "peerDependenciesMeta": { "@angular/animations": { @@ -707,7 +768,9 @@ } }, "node_modules/@angular/router": { - "version": "20.3.6", + "version": "20.3.7", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.7.tgz", + "integrity": "sha512-Lq7mCNcLP1npmNh2JlNEe02YS2jNnaLnCy/t//o+Qq0c6DGV78JRl7pHubiB2R6XXlgvOcZWg88v94Li+y85Iw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -716,14 +779,15 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.6", - "@angular/core": "20.3.6", - "@angular/platform-browser": "20.3.6", + "@angular/common": "20.3.7", + "@angular/core": "20.3.7", + "@angular/platform-browser": "20.3.7", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@babel/code-frame": { "version": "7.27.1", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -736,6 +800,7 @@ }, "node_modules/@babel/compat-data": { "version": "7.28.4", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -743,8 +808,8 @@ }, "node_modules/@babel/core": { "version": "7.28.3", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -772,10 +837,12 @@ }, "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", + "dev": true, "license": "MIT" }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -783,6 +850,7 @@ }, "node_modules/@babel/generator": { "version": "7.28.3", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.28.3", @@ -808,6 +876,7 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.27.2", + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.27.2", @@ -822,6 +891,7 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -896,6 +966,7 @@ }, "node_modules/@babel/helper-globals": { "version": "7.28.0", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -915,6 +986,7 @@ }, "node_modules/@babel/helper-module-imports": { "version": "7.27.1", + "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.27.1", @@ -926,6 +998,7 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.28.3", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", @@ -1015,6 +1088,7 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1022,6 +1096,7 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.27.1", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1029,6 +1104,7 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.27.1", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1049,6 +1125,7 @@ }, "node_modules/@babel/helpers": { "version": "7.28.4", + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", @@ -1060,6 +1137,7 @@ }, "node_modules/@babel/parser": { "version": "7.28.4", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.4" @@ -2094,6 +2172,7 @@ }, "node_modules/@babel/template": { "version": "7.27.2", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -2106,6 +2185,7 @@ }, "node_modules/@babel/traverse": { "version": "7.28.4", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -2122,6 +2202,7 @@ }, "node_modules/@babel/types": { "version": "7.28.4", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -2799,7 +2880,6 @@ "version": "7.8.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.2.1", "@inquirer/confirm": "^5.1.14", @@ -2999,6 +3079,7 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3007,6 +3088,7 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -3023,10 +3105,12 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -4346,7 +4430,6 @@ "node_modules/@popperjs/core": { "version": "2.11.8", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -4788,6 +4871,7 @@ }, "node_modules/@types/babel__core": { "version": "7.20.5", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", @@ -4799,6 +4883,7 @@ }, "node_modules/@types/babel__generator": { "version": "7.27.0", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" @@ -4806,6 +4891,7 @@ }, "node_modules/@types/babel__template": { "version": "7.4.4", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", @@ -4814,6 +4900,7 @@ }, "node_modules/@types/babel__traverse": { "version": "7.28.0", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" @@ -5197,7 +5284,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5253,7 +5339,6 @@ "version": "8.17.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5351,6 +5436,7 @@ }, "node_modules/ansi-regex": { "version": "6.2.2", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5361,6 +5447,7 @@ }, "node_modules/ansi-styles": { "version": "6.2.3", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5512,6 +5599,7 @@ }, "node_modules/baseline-browser-mapping": { "version": "2.8.17", + "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -5642,6 +5730,7 @@ }, "node_modules/browserslist": { "version": "4.26.3", + "dev": true, "funding": [ { "type": "opencollective", @@ -5657,7 +5746,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -5835,6 +5923,7 @@ }, "node_modules/caniuse-lite": { "version": "1.0.30001751", + "dev": true, "funding": [ { "type": "opencollective", @@ -5869,6 +5958,7 @@ }, "node_modules/chokidar": { "version": "4.0.3", + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -5946,6 +6036,7 @@ }, "node_modules/cliui": { "version": "9.0.1", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^7.2.0", @@ -5958,6 +6049,7 @@ }, "node_modules/cliui/node_modules/wrap-ansi": { "version": "9.0.2", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -6175,6 +6267,7 @@ }, "node_modules/convert-source-map": { "version": "1.9.0", + "dev": true, "license": "MIT" }, "node_modules/cookie": { @@ -6390,6 +6483,7 @@ }, "node_modules/debug": { "version": "4.4.3", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6574,10 +6668,12 @@ }, "node_modules/electron-to-chromium": { "version": "1.5.237", + "dev": true, "license": "ISC" }, "node_modules/emoji-regex": { "version": "10.6.0", + "dev": true, "license": "MIT" }, "node_modules/emojis-list": { @@ -6865,6 +6961,7 @@ }, "node_modules/escalade": { "version": "3.2.0", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6971,7 +7068,6 @@ "version": "5.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -7089,6 +7185,7 @@ }, "node_modules/fdir": { "version": "6.5.0", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -7273,6 +7370,7 @@ }, "node_modules/gensync": { "version": "1.0.0-beta.2", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -7280,6 +7378,7 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -7287,6 +7386,7 @@ }, "node_modules/get-east-asian-width": { "version": "1.4.0", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -8071,8 +8171,7 @@ "node_modules/jasmine-core": { "version": "5.9.0", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/jest-worker": { "version": "27.5.1", @@ -8111,6 +8210,7 @@ }, "node_modules/js-tokens": { "version": "4.0.0", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -8126,6 +8226,7 @@ }, "node_modules/jsesc": { "version": "3.1.0", + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -8149,6 +8250,7 @@ }, "node_modules/json5": { "version": "2.2.3", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -8182,7 +8284,6 @@ "version": "6.4.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -8609,7 +8710,6 @@ "version": "4.4.0", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -8724,7 +8824,6 @@ "version": "9.0.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", @@ -8934,6 +9033,7 @@ }, "node_modules/lru-cache": { "version": "5.1.1", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -9302,6 +9402,7 @@ }, "node_modules/ms": { "version": "2.1.3", + "dev": true, "license": "MIT" }, "node_modules/msgpackr": { @@ -9518,6 +9619,7 @@ }, "node_modules/node-releases": { "version": "2.0.25", + "dev": true, "license": "MIT" }, "node_modules/nopt": { @@ -9990,7 +10092,6 @@ }, "node_modules/parse5": { "version": "8.0.0", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -10036,7 +10137,6 @@ }, "node_modules/parse5/node_modules/entities": { "version": "6.0.1", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -10113,10 +10213,12 @@ }, "node_modules/picocolors": { "version": "1.1.1", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -10171,7 +10273,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10422,6 +10523,7 @@ }, "node_modules/readdirp": { "version": "4.1.2", + "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -10433,6 +10535,7 @@ }, "node_modules/reflect-metadata": { "version": "0.2.2", + "dev": true, "license": "Apache-2.0" }, "node_modules/regenerate": { @@ -10714,7 +10817,6 @@ "node_modules/rxjs": { "version": "7.8.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -10763,7 +10865,6 @@ "version": "1.90.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -10877,6 +10978,7 @@ }, "node_modules/semver": { "version": "7.7.2", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -11535,6 +11637,7 @@ }, "node_modules/string-width": { "version": "7.2.0", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -11596,6 +11699,7 @@ }, "node_modules/strip-ansi": { "version": "7.1.2", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -11818,6 +11922,7 @@ }, "node_modules/tinyglobby": { "version": "0.2.14", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.4.4", @@ -11882,8 +11987,7 @@ }, "node_modules/tslib": { "version": "2.8.1", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tuf-js": { "version": "3.1.0", @@ -11918,9 +12022,8 @@ }, "node_modules/typescript": { "version": "5.9.3", - "devOptional": true, + "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12035,6 +12138,7 @@ }, "node_modules/update-browserslist-db": { "version": "1.1.3", + "dev": true, "funding": [ { "type": "opencollective", @@ -12127,7 +12231,6 @@ "version": "7.1.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -12250,7 +12353,6 @@ "version": "5.101.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -12345,7 +12447,6 @@ "version": "5.2.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -13093,6 +13194,7 @@ }, "node_modules/y18n": { "version": "5.0.8", + "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -13100,10 +13202,12 @@ }, "node_modules/yallist": { "version": "3.1.1", + "dev": true, "license": "ISC" }, "node_modules/yargs": { "version": "18.0.0", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^9.0.1", @@ -13119,6 +13223,7 @@ }, "node_modules/yargs-parser": { "version": "22.0.0", + "dev": true, "license": "ISC", "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" @@ -13150,7 +13255,6 @@ "version": "3.25.76", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -13165,8 +13269,7 @@ }, "node_modules/zone.js": { "version": "0.15.1", - "license": "MIT", - "peer": true + "license": "MIT" } } } diff --git a/endpoint-insights-ui/package.json b/endpoint-insights-ui/package.json index 8a7a169..280f5c4 100644 --- a/endpoint-insights-ui/package.json +++ b/endpoint-insights-ui/package.json @@ -23,10 +23,13 @@ }, "private": true, "dependencies": { + "@angular/animations": "^20.3.7", + "@angular/cdk": "^20.2.9", "@angular/common": "^20.3.0", "@angular/compiler": "^20.3.0", "@angular/core": "^20.3.0", "@angular/forms": "^20.3.0", + "@angular/material": "^20.2.9", "@angular/platform-browser": "^20.3.0", "@angular/router": "^20.3.0", "@ng-bootstrap/ng-bootstrap": "^19.0.1", diff --git a/endpoint-insights-ui/src/app/app.config.ts b/endpoint-insights-ui/src/app/app.config.ts index d953f4c..e6b1db6 100644 --- a/endpoint-insights-ui/src/app/app.config.ts +++ b/endpoint-insights-ui/src/app/app.config.ts @@ -1,12 +1,11 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core'; -import { provideRouter } from '@angular/router'; - +import { ApplicationConfig } from '@angular/core'; +import { provideRouter, withEnabledBlockingInitialNavigation } from '@angular/router'; import { routes } from './app.routes'; +//import { provideAnimations } from '@angular/platform-browser/animations'; export const appConfig: ApplicationConfig = { - providers: [ - provideBrowserGlobalErrorListeners(), - provideZoneChangeDetection({ eventCoalescing: true }), - provideRouter(routes) - ] + providers: [ + provideRouter(routes, withEnabledBlockingInitialNavigation()), + //provideAnimations(), + ], }; diff --git a/endpoint-insights-ui/src/app/app.html b/endpoint-insights-ui/src/app/app.html index a1e6ff7..50f68c6 100644 --- a/endpoint-insights-ui/src/app/app.html +++ b/endpoint-insights-ui/src/app/app.html @@ -1,3 +1,8 @@ -
- -
+
+

{{ title() }}

+ +
+ diff --git a/endpoint-insights-ui/src/app/app.routes.ts b/endpoint-insights-ui/src/app/app.routes.ts index 4023dc7..4fe0b5d 100644 --- a/endpoint-insights-ui/src/app/app.routes.ts +++ b/endpoint-insights-ui/src/app/app.routes.ts @@ -1,8 +1,8 @@ import { Routes } from '@angular/router'; -import {PageNotFoundComponent} from "./page-not-found-component/page-not-found-component"; -import {DashboardComponent} from "./dashboard-component/dashboard-component"; +import { DashboardComponent } from './dashboard-component/dashboard-component'; export const routes: Routes = [ - { path: '', component: DashboardComponent }, - { path: '**', component: PageNotFoundComponent } + { path: '', component: DashboardComponent, pathMatch: 'full' }, + { path: 'batches', loadComponent: () => import('./batch-component/batch-component').then(m => m.BatchComponent) }, + { path: '**', loadComponent: () => import('./page-not-found-component/page-not-found-component').then(m => m.PageNotFoundComponent) }, ]; diff --git a/endpoint-insights-ui/src/app/app.spec.ts b/endpoint-insights-ui/src/app/app.spec.ts index 91d56d5..060c162 100644 --- a/endpoint-insights-ui/src/app/app.spec.ts +++ b/endpoint-insights-ui/src/app/app.spec.ts @@ -1,23 +1,26 @@ +// app.spec.ts import { TestBed } from '@angular/core/testing'; -import { App } from './app'; +import { RouterTestingModule } from '@angular/router/testing'; +import { AppComponent } from './app'; describe('App', () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [App], - }).compileComponents(); - }); + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, // <— provides Router, ActivatedRoute, etc. + AppComponent, // standalone component + ], + }).compileComponents(); + }); - it('should create the app', () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); - }); + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + expect(fixture.componentInstance).toBeTruthy(); + }); - it('should render title', () => { - const fixture = TestBed.createComponent(App); - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.container')).toBeTruthy(); - }); + it('should render title', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('endpoint-insights-ui'); // or whatever selector you assert + }); }); diff --git a/endpoint-insights-ui/src/app/app.ts b/endpoint-insights-ui/src/app/app.ts index e64dad6..283712f 100644 --- a/endpoint-insights-ui/src/app/app.ts +++ b/endpoint-insights-ui/src/app/app.ts @@ -1,12 +1,12 @@ -import { Component, signal } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import {Component, signal} from '@angular/core'; +import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; @Component({ - selector: 'app-root', - imports: [RouterOutlet], - templateUrl: './app.html', - styleUrl: './app.scss' + selector: 'app-root', + standalone: true, + imports: [RouterOutlet, RouterLink, RouterLinkActive], + templateUrl: './app.html', }) -export class App { - protected readonly title = signal('endpoint-insights-ui'); -} +export class AppComponent { + readonly title = signal('endpoint-insights-ui'); +} \ No newline at end of file diff --git a/endpoint-insights-ui/src/app/batch-component/batch-component.html b/endpoint-insights-ui/src/app/batch-component/batch-component.html new file mode 100644 index 0000000..586bcdb --- /dev/null +++ b/endpoint-insights-ui/src/app/batch-component/batch-component.html @@ -0,0 +1,9 @@ +
+
+ + +
+
diff --git a/endpoint-insights-ui/src/app/batch-component/batch-component.scss b/endpoint-insights-ui/src/app/batch-component/batch-component.scss new file mode 100644 index 0000000..af1e542 --- /dev/null +++ b/endpoint-insights-ui/src/app/batch-component/batch-component.scss @@ -0,0 +1,13 @@ +.grid { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 12px; +} + +app-batch-card { + grid-column: span 6; +} + +@media (max-width: 900px) { + app-batch-card { grid-column: span 12; } +} diff --git a/endpoint-insights-ui/src/app/batch-component/batch-component.ts b/endpoint-insights-ui/src/app/batch-component/batch-component.ts new file mode 100644 index 0000000..9651bbe --- /dev/null +++ b/endpoint-insights-ui/src/app/batch-component/batch-component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Batch } from '../models/batch.model'; +import { BatchCardComponent } from './components/batch-card/batch-card.component'; + +@Component({ + selector: 'app-batches', + standalone: true, + imports: [CommonModule, BatchCardComponent,], + templateUrl: './batch-component.html', + styleUrls: ['./batch-component.scss'], +}) +export class BatchComponent { + // mock data for now; I will need to fetch this from the server later + batches: Batch[] = [ + { id: 'B-2025-00123', title: 'Nightly ETL (US-East)', createdIso: '2025-10-17T02:13:00Z' }, + { id: 'B-2025-00124', title: 'Customer Backfill – Oct', createdIso: '2025-10-18T15:45:00Z' }, + ]; + + onConfigure(batch: Batch) { + // gotta hook this up to the server later + console.log('Configure clicked:', batch); + } + + trackById = (_: number, b: Batch) => b.id; +} diff --git a/endpoint-insights-ui/src/app/batch-component/components/batch-card/batch-card.component.html b/endpoint-insights-ui/src/app/batch-component/components/batch-card/batch-card.component.html new file mode 100644 index 0000000..4b6a511 --- /dev/null +++ b/endpoint-insights-ui/src/app/batch-component/components/batch-card/batch-card.component.html @@ -0,0 +1,16 @@ + +
+
+

{{ batch.title }}

+
Batch ID{{ batch.id }}
+
Date{{ formattedDate() }}
+
+ +
+ +
+
+
diff --git a/endpoint-insights-ui/src/app/batch-component/components/batch-card/batch-card.component.scss b/endpoint-insights-ui/src/app/batch-component/components/batch-card/batch-card.component.scss new file mode 100644 index 0000000..17c5e01 --- /dev/null +++ b/endpoint-insights-ui/src/app/batch-component/components/batch-card/batch-card.component.scss @@ -0,0 +1,54 @@ +.batch-card { + border-radius: 12px; + padding: 10px 12px; +} + +.row { + display: grid; + grid-template-columns: 1fr auto; + align-items: start; + gap: 12px; +} + +.meta { + display: grid; + gap: 6px; +} + +.title { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + letter-spacing: .2px; +} + +.kv { + display: grid; + grid-template-columns: 110px 1fr; + gap: 8px; + align-items: baseline; + + .label { color: #6b7280; } + .value { font-weight: 600; } +} + +.actions { + display: flex; + align-items: center; + justify-content: flex-end; + min-width: 160px; + + button[mat-stroked-button] { + display: inline-flex; + gap: 6px; + border-radius: 999px; + padding: 0 14px; + } +} +.batch-card { + transition: transform .06s ease, box-shadow .12s ease; +} +.batch-card:hover { + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(2, 12, 27, .08); +} \ No newline at end of file diff --git a/endpoint-insights-ui/src/app/batch-component/components/batch-card/batch-card.component.ts b/endpoint-insights-ui/src/app/batch-component/components/batch-card/batch-card.component.ts new file mode 100644 index 0000000..11d42f3 --- /dev/null +++ b/endpoint-insights-ui/src/app/batch-component/components/batch-card/batch-card.component.ts @@ -0,0 +1,24 @@ +import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { Batch } from '../../../models/batch.model'; + +@Component({ + selector: 'app-batch-card', + standalone: true, + imports: [CommonModule, MatCardModule, MatButtonModule, MatIconModule], + templateUrl: './batch-card.component.html', + styleUrls: ['./batch-card.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BatchCardComponent { + @Input({ required: true }) batch!: Batch; + @Output() configure = new EventEmitter(); + + formattedDate(): string { + const d = new Date(this.batch?.createdIso ?? ''); + return isNaN(d.valueOf()) ? '—' : d.toLocaleString(); + } +} diff --git a/endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.html b/endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.html new file mode 100644 index 0000000..7298d9a --- /dev/null +++ b/endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.html @@ -0,0 +1,182 @@ + + + + + + + {{ test.name }} + + + + + + {{ test.status }} + + + + + P95 Latency: {{ num(test.latencyMsP95, ' ms') }} + Volume (last minute): {{ num(test.volume1m) }} + Error Rate: {{ pct(test.errorRatePct) }} + + + + +

+ {{ test.description }} +

+ +
+
+ + + + + Latency + + + + + + + + + + +
P50{{ num(test.latencyMsP50, ' ms') }}
P95{{ num(test.latencyMsP95, ' ms') }}
P99{{ num(test.latencyMsP99, ' ms') }}
+
+
+ + + + + + Volume + + + + + + + + + +
Last 1 minute{{ num(test.volume1m) }}
Last 5 minutes{{ num(test.volume5m) }}
+
+
+ + + + + + HTTP Responses / Errors + + + + + + + + + + + + + + + + + +
Code{{ row.code }}Count{{ row.count }}
+ + +
+ Aggregate Error Rate + {{ pct(test.errorRatePct) }} +
+
+
+ + + + + + Thresholds + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameWarn ThresholdFail Threshold
Latency (ms){{ num(test.thresholds?.latencyMs?.warn, ' ms') }}{{ num(test.thresholds?.latencyMs?.fail, ' ms') }}
Error Rate (%){{ pct(test.thresholds?.errorRatePct?.warn) }}{{ pct(test.thresholds?.errorRatePct?.fail) }}
Volume / min{{ num(test.thresholds?.volumePerMin?.warn) }}{{ num(test.thresholds?.volumePerMin?.fail) }}
+
+
+ + + + Status + + + + + + + +
State{{ test.status }}
Last Run{{ lastRun() }}
ID{{ test.id }}
+
+
+
+
+
+
+
diff --git a/endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.scss b/endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.scss new file mode 100644 index 0000000..90653dc --- /dev/null +++ b/endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.scss @@ -0,0 +1,325 @@ +/* ================================ + Variables + ================================ */ +$banner-pass: #d6f2d6; +$banner-pass-b: #a8dea8; + +$banner-warn: #fff1b8; +$banner-warn-b: #ffd666; + +$banner-fail: #fad2d2; +$banner-fail-b: #f1a8a8; + +$banner-unknown: #e9ecf1; +$banner-unknown-b: #cfd6df; + +$neutral-bg: #ffffff; +$neutral-border: #e5e7eb; + +$panel-title-bg: #0b2335; +$panel-title-fg: #ffffff; + +/* brand colors for chips */ +$chip-pass: #2e7d32; +$chip-warn: #b26a00; +$chip-fail: #c62828; +$chip-unk: #455a64; + +/* ================================ + Card shell + ================================ */ +.test-results-card { + padding: 8px 8px 12px; + border-radius: 12px; + overflow: hidden; + transition: background-color .2s ease, box-shadow .2s ease, border-color .2s ease; + + .mat-accordion, + .mat-expansion-panel, + .mat-expansion-panel-header, + .mat-expansion-panel-content, + .mat-expansion-panel-body { + background: transparent; + box-shadow: none; + } + + .mat-expansion-panel { + margin: 0; + border-radius: 0; + } +} + +/* ================================ + Collapsed banner + ================================ */ +.test-results-card.pass:not(.is-expanded) { background: #fff; border: 2px solid #7fcf8b; } +.test-results-card.warn:not(.is-expanded) { background: #fff; border: 2px solid #ffb74d; } +.test-results-card.unknown:not(.is-expanded) { background: #fff; border: 2px solid #9eabb4; } +.test-results-card.fail:not(.is-expanded) { background: $banner-fail; border: 1px solid $banner-fail-b; } + +:host ::ng-deep .mat-mdc-chip.chip-pass .mdc-evolution-chip__text-label, +:host ::ng-deep .mat-mdc-chip.chip-warn .mdc-evolution-chip__text-label, +:host ::ng-deep .mat-mdc-chip.chip-fail .mdc-evolution-chip__text-label, +:host ::ng-deep .mat-mdc-chip.chip-unknown .mdc-evolution-chip__text-label { + color: #fff !important; +} +/* ================================ + Expanded state + ================================ */ +.test-results-card.is-expanded { + background: $neutral-bg; + border: 1px solid $neutral-border; + + .mat-expansion-panel-header { + margin: 0; + border-radius: 12px 12px 0 0; + padding: 8px 16px; + min-height: 56px; + box-shadow: none; + background: #fff; + align-items: center; + overflow: visible; + } + + &.pass .mat-expansion-panel-header { border: 2px solid #7fcf8b; } + &.warn .mat-expansion-panel-header { border: 2px solid #ffb74d; } + &.unknown .mat-expansion-panel-header { border: 2px solid #9eabb4; } + &.fail .mat-expansion-panel-header { background: $banner-fail; border: 1px solid $banner-fail-b; } +} + +/* ================================ + Banner layout + ================================ */ +.test-results-card .mat-expansion-panel-header .mat-content { + display: flex; + align-items: center; + gap: 12px 16px; + min-width: 0; +} + +.test-results-card .mat-expansion-panel-header .mat-expansion-panel-header-title { + display: inline-flex; + align-items: center; + gap: .5rem; + white-space: nowrap; + flex: 0 1 auto; + min-width: 0; +} + +.summary { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 12px 16px; + white-space: nowrap; + overflow-x: auto; + overflow-y: hidden; + max-width: 100%; + -webkit-overflow-scrolling: touch; + padding-bottom: 2px; +} + +.summary mat-chip-set, +.summary .kv { flex: 0 0 auto; } + +.summary .kv { color: rgba(0,0,0,.75); } +.summary .kv strong { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.title { display: flex; align-items: center; gap: .5rem; } +.name { font-weight: 600; letter-spacing: .2px; } + +/* ================================ + Chips + Support both "chip-pass" and "pass" + class names returned by chipClass() + ================================ */ + +/* PASS */ +.mat-mdc-chip.chip-pass, +.mat-mdc-chip.pass { + --mdc-chip-elevated-container-color: #2e7d32; + --mdc-chip-flat-container-color: #2e7d32; + --mdc-chip-label-text-color: #ffffff; + background-color: $chip-pass; +} + +/* WARN */ +.mat-mdc-chip.chip-warn, +.mat-mdc-chip.warn { + --mdc-chip-elevated-container-color: #b26a00; + --mdc-chip-flat-container-color: #b26a00; + --mdc-chip-label-text-color: #fff8e1; + background-color: $chip-warn; +} + +/* FAIL */ +.mat-mdc-chip.chip-fail, +.mat-mdc-chip.fail { + --mdc-chip-elevated-container-color: #c62828; + --mdc-chip-flat-container-color: #c62828; + --mdc-chip-label-text-color: #fff5f5; + background-color: $chip-fail; +} + +/* UNKNOWN */ +.mat-mdc-chip.chip-unknown, +.mat-mdc-chip.unknown { + --mdc-chip-elevated-container-color: #455a64; + --mdc-chip-flat-container-color: #455a64; + --mdc-chip-label-text-color: #f0f0f0; + background-color: $chip-unk; +} + +/* Force readable label text regardless of theme quirks */ +.mat-mdc-chip.chip-pass .mdc-evolution-chip__text-label, +.mat-mdc-chip.chip-warn .mdc-evolution-chip__text-label, +.mat-mdc-chip.chip-fail .mdc-evolution-chip__text-label, +.mat-mdc-chip.chip-unknown .mdc-evolution-chip__text-label, +.mat-mdc-chip.pass .mdc-evolution-chip__text-label, +.mat-mdc-chip.warn .mdc-evolution-chip__text-label, +.mat-mdc-chip.fail .mdc-evolution-chip__text-label, +.mat-mdc-chip.unknown .mdc-evolution-chip__text-label { + color: #fff !important; +} + +/* chip geometry */ +.test-results-card .mdc-evolution-chip { + border-radius: 10px; + padding: 0 10px; + min-height: 28px; + font-weight: 600; +} + +/* ================================ + Body and subpanels + ================================ */ +.body { padding: 14px; } + +.grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + align-items: start; +} + +.panel { + grid-column: span 1; + border-radius: 10px; + align-self: start; + height: auto; + + .mdc-card { min-height: 0; height: auto; padding: 0; } +} + +/* Subpanel title: dark when expanded */ +.test-results-card.is-expanded .panel .mat-mdc-card-title { + background: $panel-title-bg; + color: $panel-title-fg; + margin: 0; + padding: 8px 16px; + border-top-left-radius: 10px; + border-top-right-radius: 10px; +} + +/* Subpanel content */ +.panel .mat-mdc-card-content { + padding: 6px 12px 8px 12px; + margin: 0; +} + +/* Inline tooltip icon wrapper */ +.title-with-hint { display: inline-flex; align-items: center; gap: 6px; } +.title-with-hint .q { font-size: 18px; opacity: .85; transform: translateY(1px); } +.title-with-hint .q:hover { opacity: 1; } + +/* ================================ + Tables + ================================ */ +.http-table { width: 100%; } + +.test-results-card .mat-card-content table { + width: 100%; + border-collapse: collapse; +} + +.test-results-card .mat-card-content th, +.test-results-card .mat-card-content td { + padding: 4px 6px; + font-size: 0.85rem; + line-height: 1.1rem; +} + +.test-results-card .mat-card-content th { + font-weight: 600; + color: #374151; +} + +.test-results-card .mat-card-content td { + color: #111827; + vertical-align: top; +} + +/* MDC data table rhythm */ +.test-results-card .mat-mdc-table th.mdc-data-table__header-cell, +.test-results-card .mat-mdc-table td.mdc-data-table__cell { + padding: 6px 8px; + font-variant-numeric: tabular-nums; + border-bottom: 1px solid rgba(0,0,0,.06); +} + +/* Subtable helpers */ +.subtable { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 0.9rem; + margin-bottom: 0; + + th, td { + padding: 4px 8px; + line-height: 1.15rem; + border-bottom: 1px solid rgba(0,0,0,.06); + vertical-align: top; + } + + th { + width: 55%; + text-align: left; + color: #6b7280; + font-weight: 600; + } + + td.num { + text-align: right; + font-variant-numeric: tabular-nums; + color: #111827; + } + + tr:last-child th, + tr:last-child td { + border-bottom: 0; + padding-bottom: 2px; + } +} + +/* ================================ + Misc + ================================ */ +.sp { margin: 8px 0; } +.description { margin: 8px 12px 0 12px; color: rgba(0,0,0,.72); } + +/* Hover affordance on outlined banners */ +.test-results-card.pass:not(.is-expanded), +.test-results-card.warn:not(.is-expanded), +.test-results-card.unknown:not(.is-expanded) { + transition: box-shadow .15s ease, border-color .15s ease; +} + +.test-results-card.pass:not(.is-expanded):hover { box-shadow: 0 4px 14px rgba(46,125,50,.12); } +.test-results-card.warn:not(.is-expanded):hover { box-shadow: 0 4px 14px rgba(178,106,0,.12); } +.test-results-card.unknown:not(.is-expanded):hover { box-shadow: 0 4px 14px rgba(69,90,100,.12); } + + diff --git a/endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.spec.ts b/endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.spec.ts new file mode 100644 index 0000000..24f2342 --- /dev/null +++ b/endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.spec.ts @@ -0,0 +1,173 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatExpansionPanelHarness } from '@angular/material/expansion/testing'; + +import { TestResultsCardComponent } from './test-results-card.component'; +import { TestRecord } from '../../models/test-record.model'; + +describe('TestResultsCardComponent', () => { + let fixture: ComponentFixture; + let component: TestResultsCardComponent; + + // convenience setter + function setTest(t: Partial) { + component.test = { + id: 'test-1', + name: 'Login API', + status: 'PASS', + // defaults; individual tests overwrite + latencyMsP50: 42, + latencyMsP95: 120, + latencyMsP99: 210, + volume1m: 530, + volume5m: 2510, + errorRatePct: 0.4, + thresholds: { + latencyMs: { warn: 200, fail: 400 }, + errorRatePct: { warn: 1.0, fail: 2.5 }, + volumePerMin: { warn: 100, fail: 20 }, + }, + httpBreakdown: [{ code: 200, count: 2478 }, { code: 401, count: 8 }], + ...t, + }; + fixture.detectChanges(); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, // make expansion instant/predictable + MatExpansionModule, // use metadata (often pulled via a component, but explicit is fine) + TestResultsCardComponent, // standalone component (brings its deps) + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TestResultsCardComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + setTest({}); // uses defaults + expect(component).toBeTruthy(); + }); + + it('renders the name in the banner', () => { + setTest({ name: 'Test Name' }); + const nameEl = fixture.nativeElement.querySelector('.name') as HTMLElement; + expect(nameEl?.textContent).toContain('Test Name'); + }); + + it('shows status chip text and applies PASS styling class', () => { + setTest({ status: 'PASS' }); + const host = fixture.nativeElement.querySelector('mat-card') as HTMLElement; // .test-results-card on host + const chip = fixture.nativeElement.querySelector('mat-chip') as HTMLElement; + + // host banner class + expect(host.className).toContain('pass'); + // chip class + expect(chip.className).toContain('chip-pass'); + // chip text + expect(chip.textContent?.trim()).toBe('PASS'); + }); + + it('applies FAIL styling when status=FAIL', () => { + setTest({ status: 'FAIL' }); + const host = fixture.nativeElement.querySelector('mat-card') as HTMLElement; + const chip = fixture.nativeElement.querySelector('mat-chip') as HTMLElement; + expect(host.className).toContain('fail'); + expect(chip.className).toContain('chip-fail'); + expect(chip.textContent?.trim()).toBe('FAIL'); + }); + + it('renders banner KVs with full labels', () => { + setTest({ latencyMsP95: 123, volume1m: 456, errorRatePct: 7.89 }); + const text = (fixture.nativeElement as HTMLElement).textContent || ''; + expect(text).toContain('P95 Latency'); + expect(text).toContain('Volume (last minute)'); + expect(text).toContain('Error Rate'); + expect(text).toContain('123 ms'); + expect(text).toContain('456'); + // pct is formatted by a component; tolerate rounding like "7.89%" + expect(text).toMatch(/7\.?8?9?%/); + }); + + it('expands when header is clicked and shows description', async () => { + setTest({ description: 'Auth flow responsiveness.' }); + const loader = TestbedHarnessEnvironment.loader(fixture); + const panel = await loader.getHarness(MatExpansionPanelHarness); + + // initially collapsed + expect(await panel.isExpanded()).toBeFalse(); + + await panel.expand(); + fixture.detectChanges(); + + expect(await panel.isExpanded()).toBeTrue(); + + // description should now be visible + const desc = fixture.nativeElement.querySelector('.description') as HTMLElement; + expect(desc?.textContent).toContain('Auth flow responsiveness.'); + }); + + it('adds .is-expanded class to host when expanded', async () => { + setTest({}); + const loader = TestbedHarnessEnvironment.loader(fixture); + const panel = await loader.getHarness(MatExpansionPanelHarness); + + const host = fixture.nativeElement.querySelector('mat-card') as HTMLElement; + expect(host.classList.contains('is-expanded')).toBeFalse(); + + await panel.expand(); + fixture.detectChanges(); + + expect(host.classList.contains('is-expanded')).toBeTrue(); + }); + + it('renders Thresholds table with Name | Warn | Fail columns', async () => { + setTest({ + thresholds: { + latencyMs: { warn: 200, fail: 400 }, + errorRatePct: { warn: 1.5, fail: 3.5 }, + volumePerMin: { warn: 100, fail: 20 }, + }, + }); + const loader = TestbedHarnessEnvironment.loader(fixture); + const panel = await loader.getHarness(MatExpansionPanelHarness); + await panel.expand(); + fixture.detectChanges(); + + const text = (fixture.nativeElement as HTMLElement).textContent || ''; + expect(text).toContain('Thresholds'); + expect(text).toContain('Name'); + expect(text).toContain('Warn Threshold'); + expect(text).toContain('Fail Threshold'); + + // spot-check a couple of values + expect(text).toContain('Latency (ms)'); + expect(text).toContain('200 ms'); + expect(text).toContain('400 ms'); + + expect(text).toContain('Error Rate (%)'); + expect(text).toMatch(/1\.5%/); + expect(text).toMatch(/3\.5%/); + }); + + it('renders HTTP responses table rows from httpBreakdown', async () => { + setTest({ httpBreakdown: [{ code: 200, count: 5 }, { code: 500, count: 2 }] }); + const loader = TestbedHarnessEnvironment.loader(fixture); + const panel = await loader.getHarness(MatExpansionPanelHarness); + await panel.expand(); + fixture.detectChanges(); + + const rows = fixture.nativeElement.querySelectorAll('table.http-table tr[mat-row]'); + // 2 data rows expected + expect(rows.length).toBe(2); + const text = (fixture.nativeElement as HTMLElement).textContent || ''; + expect(text).toContain('200'); + expect(text).toContain('5'); + expect(text).toContain('500'); + expect(text).toContain('2'); + }); +}); diff --git a/endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.ts b/endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.ts new file mode 100644 index 0000000..5471df9 --- /dev/null +++ b/endpoint-insights-ui/src/app/components/test-results-card/test-results-card.component.ts @@ -0,0 +1,82 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TestRecord, TestStatus } from '../../models/test-record.model'; + +// Angular Material +import { MatCardModule } from '@angular/material/card'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatTableModule } from '@angular/material/table'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +@Component({ + selector: 'app-test-results-card', + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatExpansionModule, + MatChipsModule, + MatTableModule, + MatButtonModule, + MatDividerModule, + MatIconModule, + MatTooltipModule, + MatIconModule, + ], + templateUrl: './test-results-card.component.html', + styleUrls: ['./test-results-card.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TestResultsCardComponent { + @Input({ required: true }) test!: TestRecord; + @Input() defaultExpanded = false; + + @Output() toggled = new EventEmitter(); + expanded = signal(false); + + ngOnInit() { this.expanded.set(this.defaultExpanded); } + onOpened() { this.expanded.set(true); this.toggled.emit(true); } + onClosed() { this.expanded.set(false); this.toggled.emit(false); } + + statusClass(s: TestStatus) { + return ({ PASS: 'pass', WARN: 'warn', FAIL: 'fail', UNKNOWN: 'unknown' } as const)[s] ?? 'unknown'; + } + + chipClass(status: string): string { + switch ((status || '').toLowerCase()) { + case 'pass': return 'chip-pass'; + case 'warn': return 'chip-warn'; + case 'fail': return 'chip-fail'; + default: return 'chip-unknown'; + } + } + + + num(n?: number, suffix = '') { return (n ?? n === 0) ? `${n}${suffix}` : '—'; } + pct(n?: number) { return (typeof n === 'number') ? `${n.toFixed(1)}%` : '—'; } + lastRun() { + if (!this.test?.lastRunIso) return '—'; + const d = new Date(this.test.lastRunIso); + return isNaN(d.valueOf()) ? '—' : d.toLocaleString(); + } + + // Strict-template-safe formatters + formatLatencyWF(): string { + const cfg = this.test?.thresholds?.latencyMs; + return cfg ? `${cfg.warn} / ${cfg.fail} ms` : '—'; + } + formatErrorPctWF(): string { + const cfg = this.test?.thresholds?.errorRatePct; + return cfg ? `${cfg.warn}% / ${cfg.fail}%` : '—'; + } + formatVolumeWF(): string { + const cfg = this.test?.thresholds?.volumePerMin; + return cfg ? `${cfg.warn} / ${cfg.fail}` : '—'; + } + + httpColumns = ['code', 'count']; +} diff --git a/endpoint-insights-ui/src/app/dashboard-component/dashboard-component.html b/endpoint-insights-ui/src/app/dashboard-component/dashboard-component.html index 2ab6c9f..774f91c 100644 --- a/endpoint-insights-ui/src/app/dashboard-component/dashboard-component.html +++ b/endpoint-insights-ui/src/app/dashboard-component/dashboard-component.html @@ -1,2 +1,7 @@

Endpoint Insights Dashboard

-

Hi :)

\ No newline at end of file +
+ + +
diff --git a/endpoint-insights-ui/src/app/dashboard-component/dashboard-component.ts b/endpoint-insights-ui/src/app/dashboard-component/dashboard-component.ts index 86224a9..057e78e 100644 --- a/endpoint-insights-ui/src/app/dashboard-component/dashboard-component.ts +++ b/endpoint-insights-ui/src/app/dashboard-component/dashboard-component.ts @@ -1,11 +1,44 @@ import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TestResultsCardComponent } from '../components/test-results-card/test-results-card.component'; +import { TestRecord } from '../models/test-record.model'; @Component({ - selector: 'app-dashboard-component', - imports: [], - templateUrl: './dashboard-component.html', - styleUrl: './dashboard-component.scss' + selector: 'app-dashboard', + standalone: true, + imports: [ CommonModule, TestResultsCardComponent,], + templateUrl: './dashboard-component.html', + styleUrls: ['./dashboard-component.scss'], }) -export class DashboardComponent { +export class DashboardComponent { //Currently just a mock data for now; I will need to fetch this from the server later + tests: TestRecord[] = [ + { + id: 'login-api', + name: 'Login API', + description: 'Auth flow correctness & responsiveness.', + status: 'PASS', + lastRunIso: new Date().toISOString(), + latencyMsP50: 42, latencyMsP95: 120, latencyMsP99: 210, + volume1m: 530, volume5m: 2510, + httpBreakdown: [{ code: 200, count: 2478 }, { code: 401, count: 8 }, { code: 500, count: 1 }], + errorRatePct: 0.4, + thresholds: { latencyMs: { warn: 200, fail: 400 }, errorRatePct: { warn: 1.0, fail: 2.5 }, volumePerMin: { warn: 100, fail: 20 } } + }, + { + id: 'payments', + name: 'Payments', + description: 'Card authorization and capture path.', + status: 'FAIL', + lastRunIso: new Date().toISOString(), + latencyMsP50: 320, latencyMsP95: 900, latencyMsP99: 1900, + volume1m: 75, volume5m: 450, + httpBreakdown: [{ code: 200, count: 240 }, { code: 429, count: 22 }, { code: 500, count: 31 }], + errorRatePct: 9.8, + thresholds: { latencyMs: { warn: 250, fail: 600 }, errorRatePct: { warn: 2.0, fail: 5.0 }, volumePerMin: { warn: 60, fail: 30 } } + } + ]; + + trackById = (_: number, t: TestRecord) => t.id; } + diff --git a/endpoint-insights-ui/src/app/models/batch.model.ts b/endpoint-insights-ui/src/app/models/batch.model.ts new file mode 100644 index 0000000..2ca271d --- /dev/null +++ b/endpoint-insights-ui/src/app/models/batch.model.ts @@ -0,0 +1,5 @@ +export interface Batch { + id: string; // Batch ID + title: string; // Name + createdIso: string; // ISO date string +} diff --git a/endpoint-insights-ui/src/app/models/test-record.model.ts b/endpoint-insights-ui/src/app/models/test-record.model.ts new file mode 100644 index 0000000..26fff65 --- /dev/null +++ b/endpoint-insights-ui/src/app/models/test-record.model.ts @@ -0,0 +1,82 @@ +/** + * Overall test result category. + * - PASS: thresholds all satisfied + * - WARN: performance near limits + * - FAIL: threshold breached + * - UNKNOWN: not enough data / not yet run + */ +export type TestStatus = 'PASS' | 'WARN' | 'FAIL' | 'UNKNOWN'; + +/** HTTP status code summary for a given test run. */ +export interface HttpBreakdown { + /** e.g. 200, 401, 500 */ + code: number; + /** how many responses returned with that code */ + count: number; +} + +/** + * Warning and failure thresholds for different metrics. + * Used to color-code results and determine status. + */ +export interface ThresholdConfig { + latencyMs?: { warn: number; fail: number }; + errorRatePct?: { warn: number; fail: number }; + volumePerMin?: { warn: number; fail: number }; +} + +/** + * Represents one monitored API or system test result. + */ +export interface TestRecord { + /** Unique ID used for routing / keys */ + id: string; + /** Human-readable name (e.g., “Login API”) */ + name: string; + /** Optional short description of what the test checks */ + description?: string; + + /** Current computed status (PASS/WARN/FAIL/UNKNOWN) */ + status: TestStatus; + + /** + * ISO-8601 timestamp of the most recent run. + * Example: "2025-10-19T22:14:00Z" + * Stored as a string so the client can easily format + * it in the user’s local time zone. + */ + lastRunIso?: string; + + /** Median latency (p50), in milliseconds */ + latencyMsP50?: number; + /** 95th percentile latency, in milliseconds */ + latencyMsP95?: number; + /** 99th percentile latency, in milliseconds */ + latencyMsP99?: number; + + /** + * Requests handled in the last 1 minute window. + * Gives a quick read on current traffic volume. + */ + volume1m?: number; + + /** + * Requests handled in the last 5 minute window. + * Useful for spotting short-term spikes or drops. + */ + volume5m?: number; + + /** Breakdown of HTTP responses by status code */ + httpBreakdown?: HttpBreakdown[]; + + /** + * Percentage of total requests that failed + * (non-2xx responses or test errors). + * Expressed as a raw percentage (e.g., 1.2 for 1.2%). + */ + errorRatePct?: number; + + /** Threshold configuration used to evaluate PASS/WARN/FAIL */ + thresholds?: ThresholdConfig; +} + diff --git a/endpoint-insights-ui/src/app/services/test-record.service.ts b/endpoint-insights-ui/src/app/services/test-record.service.ts new file mode 100644 index 0000000..624cc32 --- /dev/null +++ b/endpoint-insights-ui/src/app/services/test-record.service.ts @@ -0,0 +1,33 @@ +export type TestStatus = 'PASS' | 'WARN' | 'FAIL' | 'UNKNOWN'; + +export interface HttpBreakdown { + code: number; + count: number; +} + +export interface ThresholdConfig { + latencyMs?: { warn: number; fail: number }; + errorRatePct?: { warn: number; fail: number }; + volumePerMin?: { warn: number; fail: number }; +} + +export interface TestRecord { + id: string; + name: string; + description?: string; + + status: TestStatus; + lastRunIso?: string; + + latencyMsP50?: number; + latencyMsP95?: number; + latencyMsP99?: number; + + volume1m?: number; + volume5m?: number; + + httpBreakdown?: HttpBreakdown[]; + errorRatePct?: number; + + thresholds?: ThresholdConfig; +} diff --git a/endpoint-insights-ui/src/index.html b/endpoint-insights-ui/src/index.html index e74fd66..4910d8c 100644 --- a/endpoint-insights-ui/src/index.html +++ b/endpoint-insights-ui/src/index.html @@ -2,10 +2,12 @@ - EndpointInsightsUi + Endpoint Insights + + diff --git a/endpoint-insights-ui/src/main.ts b/endpoint-insights-ui/src/main.ts index 6bb4616..e47684e 100644 --- a/endpoint-insights-ui/src/main.ts +++ b/endpoint-insights-ui/src/main.ts @@ -1,8 +1,5 @@ -/// - import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app'; import { appConfig } from './app/app.config'; -import { App } from './app/app'; -bootstrapApplication(App, appConfig) - .catch((err) => console.error(err)); +bootstrapApplication(AppComponent, appConfig); diff --git a/endpoint-insights-ui/src/styles.scss b/endpoint-insights-ui/src/styles.scss index 13ea46a..0e6f829 100644 --- a/endpoint-insights-ui/src/styles.scss +++ b/endpoint-insights-ui/src/styles.scss @@ -1,4 +1,78 @@ -/* You can add global styles to this file, and also import other style files */ +/* ===== Base reset-ish touches */ +:root { + --topbar-bg: #0b2335; /* deep slate */ + --topbar-fg: #ffffff; + --topbar-fg-dim: rgba(255,255,255,.80); + --topbar-active: #67e8f9; /* cyan accent */ + --page-bg: #f7f9fc; + --card-radius: 12px; +} -/* Importing Bootstrap SCSS file. */ -@import 'bootstrap/scss/bootstrap'; +html, body { height: 100%; } +body { + margin: 0; + font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, 'Helvetica Neue', Arial, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; + background: var(--page-bg); + color: #111827; +} + +/* ===== App chrome */ +.topbar { + position: sticky; top: 0; z-index: 100; + background: var(--topbar-bg); + color: var(--topbar-fg); + border-bottom: 1px solid rgba(255,255,255,.12); +} + +.topbar-nav { + display: flex; gap: .5rem; + align-items: center; + padding: .6rem 1rem; +} + +.nav-link { + display: inline-flex; + align-items: center; + gap: .4rem; + padding: .35rem .75rem; + border-radius: 999px; + color: var(--topbar-fg-dim); + text-decoration: none; + font-weight: 600; + letter-spacing: .2px; + transition: background-color .15s ease, color .15s ease, transform .02s ease; +} + +.nav-link:hover { color: var(--topbar-fg); background: rgba(255,255,255,.08); } +.nav-link:active { transform: translateY(1px); } + +.nav-link.active { + color: #06202f; + background: var(--topbar-active); +} + +/* ===== Routed page container */ +.page { + padding: 16px; + min-height: calc(100dvh - 56px); +} + +/* ===== Subtle card helpers (optional) */ +.mat-elevation-z1, .mat-mdc-card, .test-results-card, .batch-card { + border-radius: var(--card-radius); +} + +/* ===== Utility (optional) */ +.container-fluid { width: 100%; padding-left: 12px; padding-right: 12px; } +.py-3 { padding-top: 1rem; padding-bottom: 1rem; } + +/* Solid tooltip surface (MDC) */ +.cdk-overlay-container .mat-mdc-tooltip .mdc-tooltip__surface { + background-color: #0b2335; /* VSP dark */ + color: #ffffff; + opacity: 1; + border-radius: 8px; + padding: 8px 10px; + letter-spacing: .2px; + box-shadow: 0 8px 24px rgba(0,0,0,.22); +} From b42f43680800e7c0a064e1193d949926f5c1ef03 Mon Sep 17 00:00:00 2001 From: cGod778 Date: Fri, 24 Oct 2025 22:37:31 -0700 Subject: [PATCH 4/7] EI-41: stub batch controller endpoints (#17) Co-authored-by: Brynn Crowley --- endpoint-insights-api/pom.xml | 33 ++------ .../controller/BatchesController.java | 44 +++++++++-- .../dto/BatchRequestDTO.java | 16 ++++ .../dto/BatchResponseDTO.java | 17 +++++ .../endpointinsightsapi/model/TestBatch.java | 41 ---------- .../schedule/JobSchedule.java | 53 ------------- .../schedule/JobScheduleRepository.java | 10 --- .../testbatchtable/TestBatchTable.java | 40 ---------- .../src/main/resources/application.properties | 3 + .../src/main/resources/application.yaml | 31 -------- .../src/main/resources/application.yml | 17 ----- .../src/main/resources/batchtable.sql | 13 ---- .../src/main/resources/scheduletable.sql | 10 --- .../EndpointInsightsApiApplicationTests.java | 3 +- .../controller/BatchesControllerUnitTest.java | 75 ++++++++++++++++++- .../controller/JobControllerTest.java | 2 - .../controller/JobsControllerUnitTest.java | 3 +- endpoint-insights-sql/job.sql | 15 ---- endpoint-insights-sql/user.sql | 9 --- package-lock.json | 6 -- 20 files changed, 154 insertions(+), 287 deletions(-) create mode 100644 endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/dto/BatchRequestDTO.java create mode 100644 endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/dto/BatchResponseDTO.java delete mode 100644 endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/TestBatch.java delete mode 100644 endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobSchedule.java delete mode 100644 endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobScheduleRepository.java delete mode 100644 endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/testbatchtable/TestBatchTable.java create mode 100644 endpoint-insights-api/src/main/resources/application.properties delete mode 100644 endpoint-insights-api/src/main/resources/application.yaml delete mode 100644 endpoint-insights-api/src/main/resources/application.yml delete mode 100644 endpoint-insights-api/src/main/resources/batchtable.sql delete mode 100644 endpoint-insights-api/src/main/resources/scheduletable.sql delete mode 100644 endpoint-insights-sql/job.sql delete mode 100644 endpoint-insights-sql/user.sql delete mode 100644 package-lock.json diff --git a/endpoint-insights-api/pom.xml b/endpoint-insights-api/pom.xml index ec4c2bd..9063581 100644 --- a/endpoint-insights-api/pom.xml +++ b/endpoint-insights-api/pom.xml @@ -6,7 +6,7 @@ org.springframework.boot spring-boot-starter-parent 4.0.0-M3 - + com.vsp endpoint-insights-api @@ -35,6 +35,7 @@ org.springframework.boot spring-boot-starter-webmvc
+ ch.qos.logback logback-core @@ -61,16 +62,12 @@ spring-boot-starter-test test + org.junit.jupiter junit-jupiter test - - org.postgresql - postgresql - runtime - org.projectlombok @@ -78,29 +75,15 @@ 1.18.42 provided - - - org.springframework.boot - spring-boot-starter-data-jpa - - - - - jakarta.persistence - jakarta.persistence-api - 3.1.0 - - org.springframework.boot - spring-boot-starter-data-jpa - compile + com.fasterxml.jackson.core + jackson-databind + 2.17.2 - - unit-tests @@ -125,8 +108,6 @@ - - @@ -154,6 +135,7 @@ + org.apache.maven.plugins maven-compiler-plugin @@ -167,6 +149,7 @@ + diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/controller/BatchesController.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/controller/BatchesController.java index 8c930c9..70d46da 100644 --- a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/controller/BatchesController.java +++ b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/controller/BatchesController.java @@ -1,16 +1,46 @@ package com.vsp.endpointinsightsapi.controller; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import com.vsp.endpointinsightsapi.dto.BatchRequestDTO; +import com.vsp.endpointinsightsapi.dto.BatchResponseDTO; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; @RestController @RequestMapping("/api/batches") -@Validated public class BatchesController { - private final static Logger LOG = LoggerFactory.getLogger(BatchesController.class); + @GetMapping + public ResponseEntity> listBatches() { + List batches = List.of( + new BatchResponseDTO(1L, "Daily API Tests", "ACTIVE"), + new BatchResponseDTO(2L, "Weekly Regression", "INACTIVE") + ); + return ResponseEntity.ok(batches); + } + + @GetMapping("/{id}") + public ResponseEntity getBatch(@PathVariable Long id) { + BatchResponseDTO batch = new BatchResponseDTO(id, "Example Batch " + id, "ACTIVE"); + return ResponseEntity.ok(batch); + } + + @PostMapping + public ResponseEntity createBatch(@RequestBody BatchRequestDTO request) { + BatchResponseDTO created = new BatchResponseDTO(99L, request.getName(), "CREATED"); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @PutMapping("/{id}") + public ResponseEntity updateBatch(@PathVariable Long id, @RequestBody BatchRequestDTO request) { + BatchResponseDTO updated = new BatchResponseDTO(id, request.getName(), "UPDATED"); + return ResponseEntity.ok(updated); + } + @DeleteMapping("/{id}") + public ResponseEntity deleteBatch(@PathVariable Long id) { + return ResponseEntity.noContent().build(); + } } diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/dto/BatchRequestDTO.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/dto/BatchRequestDTO.java new file mode 100644 index 0000000..7ead13b --- /dev/null +++ b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/dto/BatchRequestDTO.java @@ -0,0 +1,16 @@ +package com.vsp.endpointinsightsapi.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class BatchRequestDTO { + private String name; + private String description; +} diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/dto/BatchResponseDTO.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/dto/BatchResponseDTO.java new file mode 100644 index 0000000..5e5ebea --- /dev/null +++ b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/dto/BatchResponseDTO.java @@ -0,0 +1,17 @@ +package com.vsp.endpointinsightsapi.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class BatchResponseDTO { + private Long id; + private String name; + private String status; +} diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/TestBatch.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/TestBatch.java deleted file mode 100644 index 42321a3..0000000 --- a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/TestBatch.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.vsp.endpointinsightsapi.model; - - -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.LocalDate; -import java.time.LocalDateTime; - -@Data -@Entity -@AllArgsConstructor -@NoArgsConstructor -@Table(name = "test_batch") -public class TestBatch { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - //TODO: Create ManyToMany with jobs entity once made - - @Column(name = "batch_name", nullable = false) - String batchName; - - @Column(name = "schedule_id") - Long scheduleId; - - @Column(name = "start_time") - @Temporal(TemporalType.TIMESTAMP) - LocalDate startTime; - - @Temporal(TemporalType.TIMESTAMP) - @Column(name = "last_time_run") - LocalDate lastTimeRun; - - @Column(name = "active") - Boolean active; -} diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobSchedule.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobSchedule.java deleted file mode 100644 index b394556..0000000 --- a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobSchedule.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.vsp.endpointinsightsapi.schedule; - -import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; -import java.util.Date; - -@Getter -@Setter -@Entity -@Table(name = "job_schedule") -public class JobSchedule { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long scheduleId; - -// @ManyToOne(fetch = FetchType.LAZY) -// @JoinColumn(name = "job_id", nullable = false) -// private Job job; - - @Column(name = "cron_expr", nullable = false, length = 50) - private String cronExpr; - - @Column(name = "timezone", nullable = false, length = 50) - private String timezone = "PDT"; - - @Column(name = "next_run_at") - @Temporal(TemporalType.TIMESTAMP) - private Date nextRunAt; - - @Column(name = "is_enabled", nullable = false) - private Boolean isEnabled = true; - - @Column(name = "created_at", nullable = false, updatable = false) - @Temporal(TemporalType.TIMESTAMP) - private Date createdAt; - - @Column(name = "updated_at") - @Temporal(TemporalType.TIMESTAMP) - private Date updatedAt; - - @PrePersist - protected void onCreate() { - createdAt = new Date(); - updatedAt = new Date(); - } - - @PreUpdate - protected void onUpdate() { - updatedAt = new Date(); - } -} \ No newline at end of file diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobScheduleRepository.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobScheduleRepository.java deleted file mode 100644 index 7a9c99c..0000000 --- a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/schedule/JobScheduleRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.vsp.endpointinsightsapi.schedule; - -import com.vsp.endpointinsightsapi.schedule.JobSchedule; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface JobScheduleRepository extends JpaRepository { - -} \ No newline at end of file diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/testbatchtable/TestBatchTable.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/testbatchtable/TestBatchTable.java deleted file mode 100644 index 7d99fe8..0000000 --- a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/testbatchtable/TestBatchTable.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.vsp.endpointinsightsapi.testbatchtable; - -import jakarta.persistence.*; -import java.time.LocalDateTime; -import java.util.Date; - -import lombok.Getter; -import lombok.Setter; - -//import com.vsp.endpointinsightsapi.schedule.JobSchedule; -//import com.vsp.endpointinsightsapi.testrun.TestRun; - -@Getter -@Setter -@Entity -@Table(name = "test_batch") -public class TestBatchTable { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "batch_id") - private Long batchId; - -// @ManyToOne -// @JoinColumn(name = "schedule_id", nullable = false) -// private JobSchedule jobSchedule; - -// @OneToMany(mappedBy = "testBatch", cascade = CascadeType.ALL) -// private List testRuns; - - @Column(name = "created_at") - @Temporal(TemporalType.TIMESTAMP) - private Date createdAt = new Date(); - - @Column(name = "status") - private String status; - - @Column(name = "notes") - private String notes; -} diff --git a/endpoint-insights-api/src/main/resources/application.properties b/endpoint-insights-api/src/main/resources/application.properties new file mode 100644 index 0000000..f764801 --- /dev/null +++ b/endpoint-insights-api/src/main/resources/application.properties @@ -0,0 +1,3 @@ +spring: + application: + name: endpoint-insights-api diff --git a/endpoint-insights-api/src/main/resources/application.yaml b/endpoint-insights-api/src/main/resources/application.yaml deleted file mode 100644 index f856915..0000000 --- a/endpoint-insights-api/src/main/resources/application.yaml +++ /dev/null @@ -1,31 +0,0 @@ -spring: - application: - name: endpoint-insights-api - datasource: - url: jdbc:${DB_URI} - username: ${DB_NAME} - password: ${DB_PASSWORD} - - jpa: - database-platform: - org.hibernate.dialect.PostgreSQLDialect - hibernate: - ddl-auto: update - show-sql: true - properties: - hibernate: - format_sql: true - - ---- -spring: - config: - activate: - on-profile: test - autoconfigure: - exclude: - - org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration - - org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration - - org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration - - org.springframework.boot.data.jpa.autoconfigure.JpaRepositoriesAutoConfiguration - diff --git a/endpoint-insights-api/src/main/resources/application.yml b/endpoint-insights-api/src/main/resources/application.yml deleted file mode 100644 index b9aaad7..0000000 --- a/endpoint-insights-api/src/main/resources/application.yml +++ /dev/null @@ -1,17 +0,0 @@ -spring: - application: - name: endpoint-insights-api - - datasource: - url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME} - username: ${DB_USER} - password: ${DB_PASSWORD} - - jpa: - database-platform: org.hibernate.dialect.PostgreSQLDialect - hibernate: - ddl-auto: update - show-sql: true - properties: - hibernate: - format_sql: true \ No newline at end of file diff --git a/endpoint-insights-api/src/main/resources/batchtable.sql b/endpoint-insights-api/src/main/resources/batchtable.sql deleted file mode 100644 index 0c89029..0000000 --- a/endpoint-insights-api/src/main/resources/batchtable.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE test_batch ( - - batch_id SERIAL PRIMARY KEY, - schedule_id BIGINT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - status VARCHAR(255), - notes VARCHAR(255), - - CONSTRAINT fk_job_schedule - FOREIGN KEY(schedule_id) - REFERENCES job_schedule(schedule_id) - ON DELETE CASCADE -); diff --git a/endpoint-insights-api/src/main/resources/scheduletable.sql b/endpoint-insights-api/src/main/resources/scheduletable.sql deleted file mode 100644 index 2d75543..0000000 --- a/endpoint-insights-api/src/main/resources/scheduletable.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE job_schedule ( - schedule_id SERIAL PRIMARY KEY, - job_id BIGINT NOT NULL REFERENCES job(job_id) ON DELETE CASCADE, - cron_expr VARCHAR(50) NOT NULL, - timezone VARCHAR(50) NOT NULL DEFAULT 'PDT', - next_run_at TIMESTAMP, - is_enabled BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); diff --git a/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/EndpointInsightsApiApplicationTests.java b/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/EndpointInsightsApiApplicationTests.java index 131f787..9ef2831 100644 --- a/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/EndpointInsightsApiApplicationTests.java +++ b/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/EndpointInsightsApiApplicationTests.java @@ -2,13 +2,12 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; @SpringBootTest -@ActiveProfiles("test") class EndpointInsightsApiApplicationTests { @Test void contextLoads() { } + } diff --git a/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/controller/BatchesControllerUnitTest.java b/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/controller/BatchesControllerUnitTest.java index f1954c7..e03aac5 100644 --- a/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/controller/BatchesControllerUnitTest.java +++ b/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/controller/BatchesControllerUnitTest.java @@ -1,20 +1,87 @@ package com.vsp.endpointinsightsapi.controller; -import org.junit.jupiter.api.Tag; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.vsp.endpointinsightsapi.dto.BatchRequestDTO; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.test.context.ActiveProfiles; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.context.ActiveProfiles; + + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(BatchesController.class) @ActiveProfiles("test") class BatchesControllerUnitTest { + @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + @TestConfiguration + static class TestConfig { + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } + } + + @Test + void shouldReturnListOfBatches() throws Exception { + mockMvc.perform(get("/api/batches")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThan(0)))) + .andExpect(jsonPath("$[0].id", notNullValue())) + .andExpect(jsonPath("$[0].name", not(emptyString()))) + .andExpect(jsonPath("$[0].status", not(emptyString()))); + } + + @Test + void shouldReturnBatchById() throws Exception { + mockMvc.perform(get("/api/batches/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(1))) + .andExpect(jsonPath("$.name", containsString("Example Batch"))) + .andExpect(jsonPath("$.status", is("ACTIVE"))); + } + @Test - void contextLoad() { + void shouldCreateBatch() throws Exception { + BatchRequestDTO request = new BatchRequestDTO("New Batch", "Test batch description"); + + mockMvc.perform(post("/api/batches") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id", is(99))) + .andExpect(jsonPath("$.name", is("New Batch"))) + .andExpect(jsonPath("$.status", is("CREATED"))); + } + @Test + void shouldUpdateBatch() throws Exception { + BatchRequestDTO request = new BatchRequestDTO("Updated Batch", "Updated description"); + + mockMvc.perform(put("/api/batches/2") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(2))) + .andExpect(jsonPath("$.name", is("Updated Batch"))) + .andExpect(jsonPath("$.status", is("UPDATED"))); + } + + @Test + void shouldDeleteBatch() throws Exception { + mockMvc.perform(delete("/api/batches/1")) + .andExpect(status().isNoContent()); } -} \ No newline at end of file +} diff --git a/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/controller/JobControllerTest.java b/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/controller/JobControllerTest.java index 379ab6c..98dba53 100644 --- a/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/controller/JobControllerTest.java +++ b/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/controller/JobControllerTest.java @@ -8,7 +8,6 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import tools.jackson.databind.ObjectMapper; @@ -18,7 +17,6 @@ @WebMvcTest(JobsController.class) @AutoConfigureWebMvc -@ActiveProfiles("test") public class JobControllerTest { @Autowired diff --git a/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/controller/JobsControllerUnitTest.java b/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/controller/JobsControllerUnitTest.java index 3427cd8..1926c94 100644 --- a/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/controller/JobsControllerUnitTest.java +++ b/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/controller/JobsControllerUnitTest.java @@ -1,13 +1,12 @@ package com.vsp.endpointinsightsapi.controller; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @WebMvcTest(JobsController.class) -@ActiveProfiles("test") class JobsControllerUnitTest { @Autowired private MockMvc mockMvc; diff --git a/endpoint-insights-sql/job.sql b/endpoint-insights-sql/job.sql deleted file mode 100644 index b368fd9..0000000 --- a/endpoint-insights-sql/job.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE job ( - job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - description TEXT, - test_type VARCHAR(10) NOT NULL CHECK (test_type IN ('PERF', 'INTEGRATION', 'E2E')), - target_id VARCHAR(1024) NOT NULL, - created_by VARCHAR(255) NOT NULL, - FOREIGN KEY (created_by) REFERENCES users(id), - status VARCHAR(50) DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED')), - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - started_at TIMESTAMP WITH TIME ZONE, - updated_at TIMESTAMP WITH TIME ZONE, - completed_at TIMESTAMP WITH TIME ZONE, - config JSONB -); \ No newline at end of file diff --git a/endpoint-insights-sql/user.sql b/endpoint-insights-sql/user.sql deleted file mode 100644 index 73dd3b3..0000000 --- a/endpoint-insights-sql/user.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE users ( - id SERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL, - email VARCHAR(255) NOT NULL UNIQUE, - role VARCHAR(100) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - -); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index f5988db..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "EndpointInsights", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} From 1ab124b3a6da684bf24fe5b33f3e21f656e51136 Mon Sep 17 00:00:00 2001 From: dcarello <64613016+dcarello@users.noreply.github.com> Date: Tue, 28 Oct 2025 18:21:08 -0700 Subject: [PATCH 5/7] feat: create job entity (EI-137) (#21) * EI-137: Job Entity Created Job Entity and added necessary dependency to pom.xml * EI-137: Updates to Job Entity Updated entity using lombok getter/setter. Put enums in separate file. Annotated with @Temporal * fix: clean up git merge remains Signed-off-by: Brynn Crowley * feat: fix controller stub, add entity tests Signed-off-by: Brynn Crowley * test: add more tests for job entity Signed-off-by: Brynn Crowley * EI-137 Fixed Build Error Build kept trying to connect to DB causing build fail. Added a test profile to exclude DataSource (similar to whats in main already) --------- Signed-off-by: Brynn Crowley Co-authored-by: Brynn Crowley --- endpoint-insights-api/pom.xml | 12 +++ .../controller/JobsController.java | 21 ++++- .../vsp/endpointinsightsapi/model/Job.java | 81 ++++++++++++++++++- .../model/enums/JobStatus.java | 5 ++ .../model/enums/TestType.java | 5 ++ .../src/main/resources/application.yaml | 30 +++++++ .../EndpointInsightsApiApplicationTests.java | 2 + .../model/JobEntityTest.java | 39 +++++++++ 8 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/enums/JobStatus.java create mode 100644 endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/enums/TestType.java create mode 100644 endpoint-insights-api/src/main/resources/application.yaml create mode 100644 endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/model/JobEntityTest.java diff --git a/endpoint-insights-api/pom.xml b/endpoint-insights-api/pom.xml index 9063581..0ee7df1 100644 --- a/endpoint-insights-api/pom.xml +++ b/endpoint-insights-api/pom.xml @@ -69,6 +69,12 @@ test + + org.postgresql + postgresql + runtime + + org.projectlombok lombok @@ -82,6 +88,12 @@ 2.17.2 + + + org.springframework.boot + spring-boot-starter-data-jpa + + diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/controller/JobsController.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/controller/JobsController.java index 4484117..f631ed7 100644 --- a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/controller/JobsController.java +++ b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/controller/JobsController.java @@ -1,6 +1,7 @@ package com.vsp.endpointinsightsapi.controller; import com.vsp.endpointinsightsapi.model.*; +import com.vsp.endpointinsightsapi.model.enums.JobStatus; import com.vsp.endpointinsightsapi.validation.ErrorMessages; import com.vsp.endpointinsightsapi.validation.Patterns; import jakarta.validation.Valid; @@ -12,6 +13,8 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; @RestController @@ -30,7 +33,7 @@ public class JobsController { @PostMapping public ResponseEntity createJob(@RequestBody @Valid JobCreateRequest request) { LOG.info("Creating job"); - return ResponseEntity.ok(new Job("1")); + return ResponseEntity.ok(new Job()); } /** @@ -50,7 +53,13 @@ public ResponseEntity updateJob( @Pattern(regexp = Patterns.JOB_ID_PATTERN, message = ErrorMessages.JOB_ID_INVALID_FORMAT) String jobId) { LOG.info("Updating job"); - return ResponseEntity.ok(new Job(jobId)); + + Job updatedJob = new Job(); + updatedJob.setJobId(jobId); + updatedJob.setName("Updated Job #" + jobId); + updatedJob.setDescription("This is a stub for job #" + jobId); + + return ResponseEntity.ok(updatedJob); } /** @@ -74,7 +83,13 @@ public ResponseEntity getJob( @NotNull(message = ErrorMessages.JOB_ID_REQUIRED) @Pattern(regexp = Patterns.JOB_ID_PATTERN, message = ErrorMessages.JOB_ID_INVALID_FORMAT) String jobId) { - return ResponseEntity.ok(new Job(jobId)); + + Job job = new Job(); + job.setJobId(jobId); + job.setName("Job #" + jobId); + job.setDescription("This is a stub for job #" + jobId); + + return ResponseEntity.ok(job); } /** diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/Job.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/Job.java index 064924c..321530f 100644 --- a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/Job.java +++ b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/Job.java @@ -1,15 +1,90 @@ package com.vsp.endpointinsightsapi.model; +import jakarta.persistence.*; + +import java.util.Date; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; +//import com.vsp.endpointinsightsapi.user.User; // adjust imports/package names for when created +//import com.vsp.endpointinsightsapi.target.TestTarget; // adjust imports/package names for when created +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import java.util.Map; +import com.vsp.endpointinsightsapi.model.enums.JobStatus; +import com.vsp.endpointinsightsapi.model.enums.TestType; -@AllArgsConstructor @Getter @Setter +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "job") public class Job { - private final String jobId; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "job_id") + private String jobId; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "description") + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "test_type", nullable = false, length = 20) + private TestType testType; + + // Uncomment when the TestTarget and User Entities are created + /* + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "target_id") + private TestTarget testTarget; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "created_by") + private User createdBy; +*/ + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private JobStatus status = JobStatus.PENDING; + + @Column(name = "created_at", nullable = false, updatable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date createdAt; + + @Column(name = "updated_at", nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date updatedAt; + + @Column(name = "started_at", nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date startedAt; + + @Column(name = "completed_at", nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date completedAt; + + // JSONB config: arbitrary key/value settings for the job + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "config", columnDefinition = "jsonb") + private Map config; + + + @PrePersist + void onCreate() { + this.createdAt = new Date(); + this.updatedAt = new Date(); + } - //todo: implement + @PreUpdate + void onUpdate() { + this.updatedAt = new Date(); + } } diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/enums/JobStatus.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/enums/JobStatus.java new file mode 100644 index 0000000..8918458 --- /dev/null +++ b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/enums/JobStatus.java @@ -0,0 +1,5 @@ +package com.vsp.endpointinsightsapi.model.enums; + +public enum JobStatus { + PENDING, RUNNING, COMPLETED, FAILED +} diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/enums/TestType.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/enums/TestType.java new file mode 100644 index 0000000..cc1ff70 --- /dev/null +++ b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/enums/TestType.java @@ -0,0 +1,5 @@ +package com.vsp.endpointinsightsapi.model.enums; + +public enum TestType { + PERF, INTEGRATION, E2E +} diff --git a/endpoint-insights-api/src/main/resources/application.yaml b/endpoint-insights-api/src/main/resources/application.yaml new file mode 100644 index 0000000..528a016 --- /dev/null +++ b/endpoint-insights-api/src/main/resources/application.yaml @@ -0,0 +1,30 @@ +spring: + application: + name: endpoint-insights-api + datasource: + url: jdbc:${DB_URI} + username: ${DB_NAME} + password: ${DB_PASSWORD} + + jpa: + database-platform: + org.hibernate.dialect.PostgreSQLDialect + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + + +--- +spring: + config: + activate: + on-profile: test + autoconfigure: + exclude: + - org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration + - org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration + - org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration + - org.springframework.boot.data.jpa.autoconfigure.JpaRepositoriesAutoConfiguration \ No newline at end of file diff --git a/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/EndpointInsightsApiApplicationTests.java b/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/EndpointInsightsApiApplicationTests.java index 9ef2831..ca8ab0d 100644 --- a/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/EndpointInsightsApiApplicationTests.java +++ b/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/EndpointInsightsApiApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class EndpointInsightsApiApplicationTests { @Test diff --git a/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/model/JobEntityTest.java b/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/model/JobEntityTest.java new file mode 100644 index 0000000..d0fe0c6 --- /dev/null +++ b/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/model/JobEntityTest.java @@ -0,0 +1,39 @@ +package com.vsp.endpointinsightsapi.model; + +import com.vsp.endpointinsightsapi.model.enums.JobStatus; +import org.junit.jupiter.api.Test; + +import java.util.Date; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +public class JobEntityTest { + @Test + void shouldSetTimestampsOnCreate() { + Job job = new Job(); + job.onCreate(); + + assertThat(job.getCreatedAt()).isNotNull(); + assertThat(job.getUpdatedAt()).isNotNull(); + } + + @Test + void shouldUpdateTimestampOnUpdate() { + Job job = new Job(); + job.onCreate(); + + Date initialTime = new Date(System.currentTimeMillis() - 1000); + job.setUpdatedAt(initialTime); + + job.onUpdate(); + + assertThat(job.getUpdatedAt()).isAfter(initialTime); + } + + @Test + void shouldDefaultToPendingStatus() { + Job job = new Job(); + assertThat(job.getStatus()).isEqualTo(JobStatus.PENDING); + } + +} From 896a997ee8131c9e55fc6633e1525ef3326a1065 Mon Sep 17 00:00:00 2001 From: Marcos Pantoja <105100104+Mxrcos13@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:31:26 -0700 Subject: [PATCH 6/7] EI-20 Created Login page UI (#30) Built login page UI --- endpoint-insights-ui/public/VSP_Logo.png | Bin 0 -> 31507 bytes endpoint-insights-ui/src/app/app.html | 5 +- endpoint-insights-ui/src/app/app.routes.ts | 3 +- .../app/login-component/login-component.html | 15 ++++++ .../app/login-component/login-component.scss | 44 ++++++++++++++++++ .../login-component/login-component.spec.ts | 23 +++++++++ .../app/login-component/login-component.ts | 11 +++++ 7 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 endpoint-insights-ui/public/VSP_Logo.png create mode 100644 endpoint-insights-ui/src/app/login-component/login-component.html create mode 100644 endpoint-insights-ui/src/app/login-component/login-component.scss create mode 100644 endpoint-insights-ui/src/app/login-component/login-component.spec.ts create mode 100644 endpoint-insights-ui/src/app/login-component/login-component.ts diff --git a/endpoint-insights-ui/public/VSP_Logo.png b/endpoint-insights-ui/public/VSP_Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..2b6ec723221f91b48efa478e2c5be1fd828e11de GIT binary patch literal 31507 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 U##0aWzgq#-m9<|~E82$rA5CeP4FCWD literal 0 HcmV?d00001 diff --git a/endpoint-insights-ui/src/app/app.html b/endpoint-insights-ui/src/app/app.html index 50f68c6..794c097 100644 --- a/endpoint-insights-ui/src/app/app.html +++ b/endpoint-insights-ui/src/app/app.html @@ -1,8 +1,9 @@

{{ title() }}

diff --git a/endpoint-insights-ui/src/app/app.routes.ts b/endpoint-insights-ui/src/app/app.routes.ts index 4fe0b5d..6687784 100644 --- a/endpoint-insights-ui/src/app/app.routes.ts +++ b/endpoint-insights-ui/src/app/app.routes.ts @@ -1,7 +1,8 @@ import { Routes } from '@angular/router'; import { DashboardComponent } from './dashboard-component/dashboard-component'; - +import { LoginComponent } from './login-component/login-component'; export const routes: Routes = [ + { path: 'login', component: LoginComponent }, { path: '', component: DashboardComponent, pathMatch: 'full' }, { path: 'batches', loadComponent: () => import('./batch-component/batch-component').then(m => m.BatchComponent) }, { path: '**', loadComponent: () => import('./page-not-found-component/page-not-found-component').then(m => m.PageNotFoundComponent) }, diff --git a/endpoint-insights-ui/src/app/login-component/login-component.html b/endpoint-insights-ui/src/app/login-component/login-component.html new file mode 100644 index 0000000..bd75649 --- /dev/null +++ b/endpoint-insights-ui/src/app/login-component/login-component.html @@ -0,0 +1,15 @@ + diff --git a/endpoint-insights-ui/src/app/login-component/login-component.scss b/endpoint-insights-ui/src/app/login-component/login-component.scss new file mode 100644 index 0000000..7fa5ccb --- /dev/null +++ b/endpoint-insights-ui/src/app/login-component/login-component.scss @@ -0,0 +1,44 @@ +.header{ + background: lightgray; + text-align: center; + padding: 10px; +} + +.card{ + background: #fff; + border-radius: 12px; + box-shadow: 0 4px 10px rgba(0,0,0,0.1); + padding: 1.5rem; + max-width: 400px; + margin: 2rem auto; + transition: box-shadow 0.2s ease; +} + +.logo{ + width: 225px; + height: auto; + border: 1px solid #000000; + display: block; + padding: 20px; + margin: 0 auto 20px; +} +.welcome{ + text-align: center; + padding-bottom: 40px; +} +.welcome-text{ + font-weight: bold; +} +.button-container{ + text-align: center; +} + +.login-button{ + background: #000000; + color: white; + border: none; + padding: 0.6rem 1.2rem; + border-radius: 6px; + cursor: pointer; + width: 400px; +} diff --git a/endpoint-insights-ui/src/app/login-component/login-component.spec.ts b/endpoint-insights-ui/src/app/login-component/login-component.spec.ts new file mode 100644 index 0000000..39192bc --- /dev/null +++ b/endpoint-insights-ui/src/app/login-component/login-component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginComponent } from './login-component'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LoginComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/endpoint-insights-ui/src/app/login-component/login-component.ts b/endpoint-insights-ui/src/app/login-component/login-component.ts new file mode 100644 index 0000000..35f0c99 --- /dev/null +++ b/endpoint-insights-ui/src/app/login-component/login-component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-login', + imports: [], + templateUrl: './login-component.html', + styleUrl: './login-component.scss' +}) +export class LoginComponent { + +} From 0257c8a6dabc5c5371276fc4cdcf60395ea48f51 Mon Sep 17 00:00:00 2001 From: Cdog778 Date: Sun, 30 Nov 2025 18:54:54 -0800 Subject: [PATCH 7/7] Update README.md --- README.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7d26346..9ded773 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Build Status](https://jenkins.crowleybrynn.com/buildStatus/icon?job=EndpointInsights-UnitTesting%2Fdevelop&subject=Develop%20Branch%20Tests)](https://jenkins.crowleybrynn.com/job/EndpointInsights-UnitTesting/job/develop/) [![codecov](https://codecov.io/gh/git-stuff-done/EndpointInsights/graph/badge.svg?token=4FFNP3JSPE)](https://codecov.io/gh/git-stuff-done/EndpointInsights) - + ## Overview Endpoint Insights is a performance and integration testing dashboard that we are building for our senior project. @@ -134,6 +134,73 @@ Code coverage reposts are automatically generated and published to CodeCov when ## Deployment TBD +## Entity Relationship Diagram (ERD) + +```mermaid +erDiagram + JOB { + uuid job_id PK + string name + string description + string git_url + string run_command + string compile_command + string test_type + string status + jsonb config + string created_by + timestamp created_date + string updated_by + timestamp updated_date + } + + TEST_BATCH { + uuid id PK + string batch_name + bigint schedule_id + date start_time + date last_time_run + boolean active + string created_by + timestamp created_date + string updated_by + timestamp updated_date + } + + BATCH_JOBS { + uuid batch_id PK, FK + uuid job_id PK, FK + } + + TEST_RESULT { + uuid result_id PK + int job_type + } + + PERF_TEST_RESULT { + uuid result_id PK, FK + int p50_latency_ms + int p95_latency_ms + int p99_latency_ms + int volume_last_minute + int volume_last_5_minutes + float error_rate_percent + } + + PERF_TEST_RESULT_CODE { + uuid result_id PK, FK + int error_code PK + int count + } + + JOB ||--o{ BATCH_JOBS : "is linked to" + TEST_BATCH ||--o{ BATCH_JOBS : "groups jobs" + + TEST_RESULT ||--|| PERF_TEST_RESULT : "performance details" + PERF_TEST_RESULT ||--o{ PERF_TEST_RESULT_CODE : "per error code" +``` + + ## Contributing ### 1. Create a branch sourced from `develop` - Name your branch after your Jira story dedicated to the code implementation.