From 26feb22ab55f9a21a1c7b4bf7b8571d4f6bda967 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 18 Oct 2025 21:23:54 -0700 Subject: [PATCH 01/35] base test batch entity complete --- endpoint-insights-api/pom.xml | 6 ++++ .../endpointinsightsapi/model/TestBatch.java | 28 +++++++++++++++++++ endpoint-insights-ui/package-lock.json | 4 +-- package-lock.json | 6 ++++ 4 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/TestBatch.java create mode 100644 package-lock.json diff --git a/endpoint-insights-api/pom.xml b/endpoint-insights-api/pom.xml index 7d647ae..8f595ab 100644 --- a/endpoint-insights-api/pom.xml +++ b/endpoint-insights-api/pom.xml @@ -52,6 +52,12 @@ 1.18.42 provided + + org.springframework.boot + spring-boot-starter-data-jpa + 4.0.0-M3 + compile + 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 new file mode 100644 index 0000000..bf3b336 --- /dev/null +++ b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/TestBatch.java @@ -0,0 +1,28 @@ +package com.vsp.endpointinsightsapi.model; + + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Entity +@AllArgsConstructor +@NoArgsConstructor +public class TestBatch { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + //TODO: Create OneToMany with jobs entity once made + + String batchName; + Long scheduleId; + LocalDateTime startTime; + LocalDateTime lastTimeRun; + +} diff --git a/endpoint-insights-ui/package-lock.json b/endpoint-insights-ui/package-lock.json index cd224d5..c6ada09 100644 --- a/endpoint-insights-ui/package-lock.json +++ b/endpoint-insights-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "endpoint-insights-ui", - "version": "0.0.0", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "endpoint-insights-ui", - "version": "0.0.0", + "version": "0.0.1", "dependencies": { "@angular/common": "^20.3.0", "@angular/compiler": "^20.3.0", diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f5988db --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "EndpointInsights", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From 86417e02d79f8d34ddd9c9e6d9604269229c932f Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 19 Oct 2025 12:26:22 -0700 Subject: [PATCH 02/35] creation of temp sql folder to show work done on DB in commmits --- endpoint-insights-sql/user.sql | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 endpoint-insights-sql/user.sql diff --git a/endpoint-insights-sql/user.sql b/endpoint-insights-sql/user.sql new file mode 100644 index 0000000..73dd3b3 --- /dev/null +++ b/endpoint-insights-sql/user.sql @@ -0,0 +1,9 @@ +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 From f30a49937f6d0cf4f9468aa75c1893df910ba4c5 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 19 Oct 2025 12:56:15 -0700 Subject: [PATCH 03/35] added column names for table creation --- .../com/vsp/endpointinsightsapi/model/TestBatch.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 index bf3b336..0a8c1b1 100644 --- 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 @@ -12,17 +12,27 @@ @Entity @AllArgsConstructor @NoArgsConstructor +@Table(name = "test_batch") public class TestBatch { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - //TODO: Create OneToMany with jobs entity once made + //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") LocalDateTime startTime; + + @Column(name = "last_time_run") LocalDateTime lastTimeRun; + @Column(name = "active") + Boolean active; } From e5c29e3a0bc9a04de19cb33c4d1a1e2b9cc56ae2 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 19 Oct 2025 19:31:47 -0700 Subject: [PATCH 04/35] added temporal to dates --- .../main/java/com/vsp/endpointinsightsapi/model/TestBatch.java | 2 ++ 1 file changed, 2 insertions(+) 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 index 0a8c1b1..eefa52e 100644 --- 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 @@ -28,8 +28,10 @@ public class TestBatch { Long scheduleId; @Column(name = "start_time") + @Temporal(TemporalType.DATE) LocalDateTime startTime; + @Temporal(TemporalType.DATE) @Column(name = "last_time_run") LocalDateTime lastTimeRun; From 77b4e0ecb503eba9be7eb438388d7c436081db1c Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 19 Oct 2025 19:46:59 -0700 Subject: [PATCH 05/35] adjustments to pom.xml --- endpoint-insights-api/pom.xml | 1 - .../EndpointInsightsApiApplicationTests.java | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/endpoint-insights-api/pom.xml b/endpoint-insights-api/pom.xml index 57e32e3..c8a21dc 100644 --- a/endpoint-insights-api/pom.xml +++ b/endpoint-insights-api/pom.xml @@ -62,7 +62,6 @@ org.springframework.boot spring-boot-starter-data-jpa - 4.0.0-M3 compile 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..c61a59d 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 @@ -3,11 +3,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest +//@SpringBootTest class EndpointInsightsApiApplicationTests { @Test void contextLoads() { } - } From 4ec22542554c918507d7cd914192f9c30de59aef Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 19 Oct 2025 21:37:07 -0700 Subject: [PATCH 06/35] edit DB configs --- endpoint-insights-api/pom.xml | 69 ++----------------- .../endpointinsightsapi/model/TestBatch.java | 5 +- .../src/main/resources/application.properties | 3 - .../src/main/resources/application.yaml | 18 +++++ .../EndpointInsightsApiApplicationTests.java | 2 +- 5 files changed, 26 insertions(+), 71 deletions(-) delete mode 100644 endpoint-insights-api/src/main/resources/application.properties create mode 100644 endpoint-insights-api/src/main/resources/application.yaml diff --git a/endpoint-insights-api/pom.xml b/endpoint-insights-api/pom.xml index 6527306..a3445e7 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 @@ -28,7 +28,6 @@ 25 - 5.14.2 @@ -36,17 +35,6 @@ spring-boot-starter-webmvc - - ch.qos.logback - logback-core - 1.5.19 - - - ch.qos.logback - logback-classic - 1.5.19 - - org.springframework spring-web @@ -63,12 +51,10 @@ test - - - org.junit.jupiter - junit-jupiter - test + org.postgresql + postgresql + runtime @@ -85,58 +71,12 @@ - - - unit-tests - - true - - - - - org.apache.maven.plugins - maven-surefire-plugin - - - @{argLine} - -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar - -Xshare:off - - - - - - - - org.springframework.boot spring-boot-maven-plugin - - - org.jacoco - jacoco-maven-plugin - 0.8.14 - - - default-prepare-agent - - prepare-agent - - - - generate-code-coverage-report - test - - report - - - - - org.apache.maven.plugins maven-compiler-plugin @@ -150,7 +90,6 @@ - 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 index eefa52e..6ce3eac 100644 --- 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 @@ -6,6 +6,7 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.time.LocalDate; import java.time.LocalDateTime; @Data @@ -29,11 +30,11 @@ public class TestBatch { @Column(name = "start_time") @Temporal(TemporalType.DATE) - LocalDateTime startTime; + LocalDate startTime; @Temporal(TemporalType.DATE) @Column(name = "last_time_run") - LocalDateTime lastTimeRun; + LocalDate lastTimeRun; @Column(name = "active") Boolean active; diff --git a/endpoint-insights-api/src/main/resources/application.properties b/endpoint-insights-api/src/main/resources/application.properties deleted file mode 100644 index f764801..0000000 --- a/endpoint-insights-api/src/main/resources/application.properties +++ /dev/null @@ -1,3 +0,0 @@ -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 new file mode 100644 index 0000000..c4a61ea --- /dev/null +++ b/endpoint-insights-api/src/main/resources/application.yaml @@ -0,0 +1,18 @@ +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 \ 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 c61a59d..ee96cc2 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 @@ -3,7 +3,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -//@SpringBootTest +@SpringBootTest class EndpointInsightsApiApplicationTests { @Test From d6b8006db0af600e25ec08bfc656ae55b39310b4 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 19 Oct 2025 21:47:44 -0700 Subject: [PATCH 07/35] fixed error with build plugin --- endpoint-insights-api/pom.xml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/endpoint-insights-api/pom.xml b/endpoint-insights-api/pom.xml index a3445e7..c0eb82f 100644 --- a/endpoint-insights-api/pom.xml +++ b/endpoint-insights-api/pom.xml @@ -77,6 +77,27 @@ org.springframework.boot spring-boot-maven-plugin + + + org.jacoco + jacoco-maven-plugin + 0.8.14 + + + default-prepare-agent + + prepare-agent + + + + generate-code-coverage-report + test + + report + + + + org.apache.maven.plugins maven-compiler-plugin From 08532f13ae085e4a78fe695ab4036348d12dec3e Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 19 Oct 2025 22:01:56 -0700 Subject: [PATCH 08/35] Build POM fix --- endpoint-insights-api/pom.xml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/endpoint-insights-api/pom.xml b/endpoint-insights-api/pom.xml index c0eb82f..b41c770 100644 --- a/endpoint-insights-api/pom.xml +++ b/endpoint-insights-api/pom.xml @@ -35,6 +35,22 @@ spring-boot-starter-webmvc + + org.springframework.boot + spring-boot-starter-webmvc + + + + ch.qos.logback + logback-core + 1.5.19 + + + ch.qos.logback + logback-classic + 1.5.19 + + org.springframework spring-web @@ -69,6 +85,13 @@ compile + + org.junit.jupiter + junit-jupiter + test + + + From 89109feb0e6fe30ed72b63e22d3c453319d4d68a Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 19 Oct 2025 22:12:14 -0700 Subject: [PATCH 09/35] Build corrections again --- endpoint-insights-api/pom.xml | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/endpoint-insights-api/pom.xml b/endpoint-insights-api/pom.xml index b41c770..a838aed 100644 --- a/endpoint-insights-api/pom.xml +++ b/endpoint-insights-api/pom.xml @@ -28,6 +28,7 @@ 25 + 5.14.2 @@ -79,6 +80,7 @@ 1.18.42 provided + org.springframework.boot spring-boot-starter-data-jpa @@ -91,9 +93,36 @@ test - + + + + + unit-tests + + true + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + @{argLine} + -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar + -Xshare:off + + + + + + + + + + From 1f9610db4e4badef0059facecb533711001e3e77 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 19 Oct 2025 22:19:43 -0700 Subject: [PATCH 10/35] adjust yaml --- .../src/main/resources/application.yaml | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/endpoint-insights-api/src/main/resources/application.yaml b/endpoint-insights-api/src/main/resources/application.yaml index 20f201f..f856915 100644 --- a/endpoint-insights-api/src/main/resources/application.yaml +++ b/endpoint-insights-api/src/main/resources/application.yaml @@ -6,6 +6,17 @@ spring: 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: @@ -18,12 +29,3 @@ spring: - org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration - org.springframework.boot.data.jpa.autoconfigure.JpaRepositoriesAutoConfiguration - 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 From 472ea4d5553d4c11580913f4bb707bc586c51887 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 19 Oct 2025 22:26:33 -0700 Subject: [PATCH 11/35] pom fix again --- endpoint-insights-api/pom.xml | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/endpoint-insights-api/pom.xml b/endpoint-insights-api/pom.xml index a838aed..841cefc 100644 --- a/endpoint-insights-api/pom.xml +++ b/endpoint-insights-api/pom.xml @@ -35,12 +35,6 @@ org.springframework.boot spring-boot-starter-webmvc - - - org.springframework.boot - spring-boot-starter-webmvc - - ch.qos.logback logback-core @@ -67,7 +61,11 @@ spring-boot-starter-test test - + + org.junit.jupiter + junit-jupiter + test + org.postgresql postgresql @@ -87,12 +85,6 @@ compile - - org.junit.jupiter - junit-jupiter - test - - From 8f240efa1be877bbeba3883d9f3354244757736d Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 19 Oct 2025 22:27:55 -0700 Subject: [PATCH 12/35] change temporal --- .../java/com/vsp/endpointinsightsapi/model/TestBatch.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 6ce3eac..42321a3 100644 --- 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 @@ -29,10 +29,10 @@ public class TestBatch { Long scheduleId; @Column(name = "start_time") - @Temporal(TemporalType.DATE) + @Temporal(TemporalType.TIMESTAMP) LocalDate startTime; - @Temporal(TemporalType.DATE) + @Temporal(TemporalType.TIMESTAMP) @Column(name = "last_time_run") LocalDate lastTimeRun; 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 13/35] 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 14/35] 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 15/35] 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 16/35] 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 7e5b8ac71ae6fd983b92891e0f92133a2a5ebe2a Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 26 Oct 2025 20:01:00 -0700 Subject: [PATCH 17/35] Toast UI complete --- .../endpointinsightsapi/model/TestBatch.java | 2 - endpoint-insights-ui/package-lock.json | 68 +------------------ .../app/batch-component/batch-component.ts | 11 ++- .../toast-notification/toast.component.scss | 1 + .../toast-notification/toast.component.ts | 31 +++++++++ .../src/app/common/constants.ts | 3 + .../src/app/services/toast.service.ts | 34 ++++++++++ endpoint-insights-ui/src/styles.scss | 63 +++++++++++++++++ 8 files changed, 142 insertions(+), 71 deletions(-) create mode 100644 endpoint-insights-ui/src/app/batch-component/components/toast-notification/toast.component.scss create mode 100644 endpoint-insights-ui/src/app/batch-component/components/toast-notification/toast.component.ts create mode 100644 endpoint-insights-ui/src/app/common/constants.ts create mode 100644 endpoint-insights-ui/src/app/services/toast.service.ts 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 index 42321a3..d66b932 100644 --- 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 @@ -29,10 +29,8 @@ public class TestBatch { Long scheduleId; @Column(name = "start_time") - @Temporal(TemporalType.TIMESTAMP) LocalDate startTime; - @Temporal(TemporalType.TIMESTAMP) @Column(name = "last_time_run") LocalDate lastTimeRun; diff --git a/endpoint-insights-ui/package-lock.json b/endpoint-insights-ui/package-lock.json index c8536cc..dc1ab83 100644 --- a/endpoint-insights-ui/package-lock.json +++ b/endpoint-insights-ui/package-lock.json @@ -223,7 +223,6 @@ }, "node_modules/@ampproject/remapping": { "version": "2.3.0", - "dev": true, "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -631,7 +630,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", "dependencies": { "@babel/core": "7.28.3", @@ -707,7 +705,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", "dependencies": { "@babel/core": "7.28.3", @@ -787,7 +784,6 @@ }, "node_modules/@babel/code-frame": { "version": "7.27.1", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -800,7 +796,6 @@ }, "node_modules/@babel/compat-data": { "version": "7.28.4", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -808,7 +803,6 @@ }, "node_modules/@babel/core": { "version": "7.28.3", - "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", @@ -837,12 +831,10 @@ }, "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" @@ -850,7 +842,6 @@ }, "node_modules/@babel/generator": { "version": "7.28.3", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.28.3", @@ -876,7 +867,6 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.27.2", - "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.27.2", @@ -891,7 +881,6 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -966,7 +955,6 @@ }, "node_modules/@babel/helper-globals": { "version": "7.28.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -986,7 +974,6 @@ }, "node_modules/@babel/helper-module-imports": { "version": "7.27.1", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.27.1", @@ -998,7 +985,6 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.28.3", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", @@ -1088,7 +1074,6 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1096,7 +1081,6 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.27.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1104,7 +1088,6 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.27.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1125,7 +1108,6 @@ }, "node_modules/@babel/helpers": { "version": "7.28.4", - "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", @@ -1137,7 +1119,6 @@ }, "node_modules/@babel/parser": { "version": "7.28.4", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.4" @@ -2172,7 +2153,6 @@ }, "node_modules/@babel/template": { "version": "7.27.2", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -2185,7 +2165,6 @@ }, "node_modules/@babel/traverse": { "version": "7.28.4", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -2202,7 +2181,6 @@ }, "node_modules/@babel/types": { "version": "7.28.4", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -3079,7 +3057,6 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3088,7 +3065,6 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -3105,12 +3081,10 @@ }, "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", @@ -4871,7 +4845,6 @@ }, "node_modules/@types/babel__core": { "version": "7.20.5", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", @@ -4883,7 +4856,6 @@ }, "node_modules/@types/babel__generator": { "version": "7.27.0", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" @@ -4891,7 +4863,6 @@ }, "node_modules/@types/babel__template": { "version": "7.4.4", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", @@ -4900,7 +4871,6 @@ }, "node_modules/@types/babel__traverse": { "version": "7.28.0", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" @@ -5436,7 +5406,6 @@ }, "node_modules/ansi-regex": { "version": "6.2.2", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5447,7 +5416,6 @@ }, "node_modules/ansi-styles": { "version": "6.2.3", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5599,7 +5567,6 @@ }, "node_modules/baseline-browser-mapping": { "version": "2.8.17", - "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -5730,7 +5697,6 @@ }, "node_modules/browserslist": { "version": "4.26.3", - "dev": true, "funding": [ { "type": "opencollective", @@ -5923,7 +5889,6 @@ }, "node_modules/caniuse-lite": { "version": "1.0.30001751", - "dev": true, "funding": [ { "type": "opencollective", @@ -5958,7 +5923,6 @@ }, "node_modules/chokidar": { "version": "4.0.3", - "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -6036,7 +6000,6 @@ }, "node_modules/cliui": { "version": "9.0.1", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^7.2.0", @@ -6049,7 +6012,6 @@ }, "node_modules/cliui/node_modules/wrap-ansi": { "version": "9.0.2", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -6267,7 +6229,6 @@ }, "node_modules/convert-source-map": { "version": "1.9.0", - "dev": true, "license": "MIT" }, "node_modules/cookie": { @@ -6483,7 +6444,6 @@ }, "node_modules/debug": { "version": "4.4.3", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6668,12 +6628,10 @@ }, "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": { @@ -6961,7 +6919,6 @@ }, "node_modules/escalade": { "version": "3.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7185,7 +7142,6 @@ }, "node_modules/fdir": { "version": "6.5.0", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -7370,7 +7326,6 @@ }, "node_modules/gensync": { "version": "1.0.0-beta.2", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -7378,7 +7333,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -7386,7 +7340,6 @@ }, "node_modules/get-east-asian-width": { "version": "1.4.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -8210,7 +8163,6 @@ }, "node_modules/js-tokens": { "version": "4.0.0", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -8226,7 +8178,6 @@ }, "node_modules/jsesc": { "version": "3.1.0", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -8250,7 +8201,6 @@ }, "node_modules/json5": { "version": "2.2.3", - "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -9033,7 +8983,6 @@ }, "node_modules/lru-cache": { "version": "5.1.1", - "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -9402,7 +9351,6 @@ }, "node_modules/ms": { "version": "2.1.3", - "dev": true, "license": "MIT" }, "node_modules/msgpackr": { @@ -9619,7 +9567,6 @@ }, "node_modules/node-releases": { "version": "2.0.25", - "dev": true, "license": "MIT" }, "node_modules/nopt": { @@ -10213,12 +10160,10 @@ }, "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" @@ -10523,7 +10468,6 @@ }, "node_modules/readdirp": { "version": "4.1.2", - "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -10535,7 +10479,6 @@ }, "node_modules/reflect-metadata": { "version": "0.2.2", - "dev": true, "license": "Apache-2.0" }, "node_modules/regenerate": { @@ -10978,7 +10921,6 @@ }, "node_modules/semver": { "version": "7.7.2", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -11637,7 +11579,6 @@ }, "node_modules/string-width": { "version": "7.2.0", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -11699,7 +11640,6 @@ }, "node_modules/strip-ansi": { "version": "7.1.2", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -11922,7 +11862,6 @@ }, "node_modules/tinyglobby": { "version": "0.2.14", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.4.4", @@ -12022,7 +11961,7 @@ }, "node_modules/typescript": { "version": "5.9.3", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12138,7 +12077,6 @@ }, "node_modules/update-browserslist-db": { "version": "1.1.3", - "dev": true, "funding": [ { "type": "opencollective", @@ -13194,7 +13132,6 @@ }, "node_modules/y18n": { "version": "5.0.8", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -13202,12 +13139,10 @@ }, "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", @@ -13223,7 +13158,6 @@ }, "node_modules/yargs-parser": { "version": "22.0.0", - "dev": true, "license": "ISC", "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" diff --git a/endpoint-insights-ui/src/app/batch-component/batch-component.ts b/endpoint-insights-ui/src/app/batch-component/batch-component.ts index 9651bbe..6664ab7 100644 --- a/endpoint-insights-ui/src/app/batch-component/batch-component.ts +++ b/endpoint-insights-ui/src/app/batch-component/batch-component.ts @@ -2,15 +2,20 @@ 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'; +import {ToastService} from "../services/toast.service"; @Component({ selector: 'app-batches', standalone: true, - imports: [CommonModule, BatchCardComponent,], + imports: [CommonModule, BatchCardComponent], templateUrl: './batch-component.html', styleUrls: ['./batch-component.scss'], }) export class BatchComponent { + + constructor(private toastService: ToastService) { + } + // 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' }, @@ -18,8 +23,10 @@ export class BatchComponent { ]; onConfigure(batch: Batch) { - // gotta hook this up to the server later + this.toastService.onSuccess("action was a success"); + // 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/toast-notification/toast.component.scss b/endpoint-insights-ui/src/app/batch-component/components/toast-notification/toast.component.scss new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/endpoint-insights-ui/src/app/batch-component/components/toast-notification/toast.component.scss @@ -0,0 +1 @@ + diff --git a/endpoint-insights-ui/src/app/batch-component/components/toast-notification/toast.component.ts b/endpoint-insights-ui/src/app/batch-component/components/toast-notification/toast.component.ts new file mode 100644 index 0000000..7565e61 --- /dev/null +++ b/endpoint-insights-ui/src/app/batch-component/components/toast-notification/toast.component.ts @@ -0,0 +1,31 @@ +import {Component, Inject} from '@angular/core'; +import {MAT_SNACK_BAR_DATA, MatSnackBarRef} from '@angular/material/snack-bar'; +import {MatIcon} from "@angular/material/icon"; +import {MatIconButton} from "@angular/material/button"; + +@Component({ + selector: 'toast', + template: ` +
+
+

{{ data.type }}

+ {{ data.message }} +
+ +
+ `, + imports: [ + MatIcon, + MatIconButton + ], + +}) +export class ToastComponent { + constructor(@Inject(MAT_SNACK_BAR_DATA) public data: {type: string, message: string}, private toast: MatSnackBarRef) {} + + close() { + this.toast.dismiss(); + } +} \ No newline at end of file diff --git a/endpoint-insights-ui/src/app/common/constants.ts b/endpoint-insights-ui/src/app/common/constants.ts new file mode 100644 index 0000000..4837874 --- /dev/null +++ b/endpoint-insights-ui/src/app/common/constants.ts @@ -0,0 +1,3 @@ + +// Notifications +export const TOAST_TIMEOUT: number = 4000; diff --git a/endpoint-insights-ui/src/app/services/toast.service.ts b/endpoint-insights-ui/src/app/services/toast.service.ts new file mode 100644 index 0000000..6f3042f --- /dev/null +++ b/endpoint-insights-ui/src/app/services/toast.service.ts @@ -0,0 +1,34 @@ +import {Injectable} from "@angular/core"; +import {MatSnackBar} from "@angular/material/snack-bar"; +import {TOAST_TIMEOUT} from "../common/constants"; +import {ToastComponent} from "../batch-component/components/toast-notification/toast.component"; + +@Injectable({providedIn: 'root'}) + +export class ToastService{ + + constructor(private toast: MatSnackBar) {} + + onSuccess(message: string, duration: number = TOAST_TIMEOUT){ + this.toast.openFromComponent(ToastComponent, { + horizontalPosition: "right", + verticalPosition: "top", + duration, + panelClass: ['toast-success'], + data: {type: 'Success:', message} + }) + } + + onError(message: string, duration: number = TOAST_TIMEOUT){ + this.toast.openFromComponent(ToastComponent, { + horizontalPosition: "right", + verticalPosition: "top", + duration, + panelClass: ['toast-error'], + data: {type: 'Error:', message} + }) + } + +} + + diff --git a/endpoint-insights-ui/src/styles.scss b/endpoint-insights-ui/src/styles.scss index 0e6f829..dbe36ba 100644 --- a/endpoint-insights-ui/src/styles.scss +++ b/endpoint-insights-ui/src/styles.scss @@ -76,3 +76,66 @@ body { letter-spacing: .2px; box-shadow: 0 8px 24px rgba(0,0,0,.22); } + +// Toast Notifications + +.toast-success, +.toast-error { + background: transparent !important; + box-shadow: none !important; + padding: 0 !important; + max-width: 500px !important; + + .mat-mdc-snack-bar-container, + .mdc-snackbar__surface { + background: transparent !important; + box-shadow: none !important; + padding: 0 !important; + } +} + +// Toast Styling ** Mats library requires global styling ** +.toast-success .toast { + background-color: #4caf50; + color: #fff; +} + +.toast-error .toast { + background-color: #f44336; + color: #fff; +} + +.toast { + display: flex; + position: relative; + justify-content: space-between; + align-items: flex-start; + border-radius: 4px; + padding: 16px; + min-width: 300px; +} + +.toast-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +} + +.type { + font-weight: bold; + font-size: 1.2rem; + margin: 0; +} + +.content { + font-size: 1rem; + margin: 0; +} + +.close { + position: absolute; + margin-left: 8px; +} + + 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 18/35] 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 19/35] 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 zcmdSBXIGP7&^L;LfJ(79I))-use)7yF(AbPNC_Z4ROvn79~A`^NhlJ!G^MwIbPxpr zLm-qus7fb}mFqrUdfl|hd|$KMn;}f_BjPawE8BJIPiA!FMC9o?wgt5p zjt~E^hdolom0yS#GqHAkApE#L05>%2ZrKogyc?DYXG3yA0#c?*_EvTXWy`Dbl1hEK z5P~0qywg=IPBh-K`^Cvl%KNM{YbvGMK73sr`l#|KQf5p;5h2UMeDhyHb@C_Rx zX_H1gtQDmZBefcm>RW8I_0bj|VR+T2<`cGYK<&S0Lqz4!4f6GWk`lst^g zg1T_oyat*s&|$ps-SsI5`NVkDyaqDB%&g%EAh*_o0@_v!O?6{22+{T&nQq?dk;#Qp zKf~HCjAUI_OCdz*R!46PNgpo3{>&p_1Yn=|3X)PaW7u#qSSt1n8 zrrD8Bamnp>u@qvO>|~h_s2P$!2vRAqPwbPhPSQ5;zaXknGiNW`NebaAh{*U!^fpT? zReo}L^WlcgQ9xp_RO=-_7wa84SJ!Sgf;Mm2{ZOVMN&GJfinnw5`-+&!-Hj{w^_%CQ z7F(BxCwaH3$8v|fvU<}{U@e{NT5mK$gZ*d>Br&d0EB{@N!hO#aDkDGiEWcPMucJ`h zfXcx;wJcIR=Hy(A$@X4gS()OL;?Wr0P)N1dI0ubHvXJ^iCG7$c^uc4{7pPuIFb zlh_!q0yov_HP7MMt>Zm6RJoc>6ga2F9rCbYwOhc=j;|_; zGrIh@=GWhyIZFYBh1*dddt`*5R9G!ro`ZsDu^O`eom{T%xFda5QG>n5%{FQycb}jAQ)Ja_gxA-_zq@!*xgWD)XHkdz0F* zRP^4Qyi4%R@jKiUUF3oIZ!sDtl79_$3wqarJ~ZeSCe6%ma`UUM3_B9LtZZ#CvCYF& zlL3kJ+%aIJh`G>suLY?V>aUH(|KmFyjwQ(xMoLdQ6r@>wmvZK0!w5(Vux`A zHQp%y!L;Qrul3)`G@2kwgK*RDyz+3*cUAV}&@*M4%_?BETJA{I!>qXS4ZjIj?D-RR zVcoArt7bCi5u5)0KOND~o$55Ucl!AzMa~y{Y_$ABGiJpvJNqZu|Cm%K-==Y2Gm``& z1tUA$eve{j_shbS<&-DM@L{9)eFY}_8~(!%oy22nBS~087W#2{c*2-qwM#Fz32i0p znP2u#_&a`)TgBb4rXY;Wl+~gaXAE5!_}7xuJU8W-7I4x9B_U}R*(NvmFVQ@}KdU(7 z-caw;*YxW_unYrYieu7xN%@ufmCp1}YPOfaDKCYEL#~OR81EO)-ekB++eN>m`l0Q^ z{MQ$@e4<6q%>*gx8P{u&vR>t!1!&tUNu_z&MRx6(dRec()o{%W&JOQTkqGkVz7bH^u=W@A#NRw%kxDI zDuuSHr(gZqR9-?I121=9Q;lnW>Z;cuJ3b@was^Y+8c+jcggPuY@SyF;N3EiL8}u)L z_tY-#$1d6$-XYXueT`3Zw$bf*)eZf~OOXmU6)pb_*rnT;GhCFUdHdgsssi>%m5I$B zZ)evZ1!n>p+T|Lbm|!x;sn^AwFVY2V_!{rp(p2Of7so&D*N`n|Dz}PgaAndKDs^&r zEwL!{O^xi7Mzi-Yy`5`AV`G<<*F@U)1T3v&Fxpcu$6WFy5y!u}_^aLr_M)b_N~E2H zuHuJ2u-q`qPVRF!pNee7SBs81mTe+2zfA#mBKW%}!8_@vB9*#^P8x|inyq+BQn^h9 zq+Ie74PG?g8hUu^-r;@$w$+VSqLcP!)PH<&GmLn zThM0T#?@is$?!r39q`TiGbQosM z)Fs!IIUpvCYeXjTm;ts8ocm-7I2ZUyk55CI4@i6pN67d3Qc^JwvldC8d;YrXQ=3Nw-cb9tSNo(F~WR6>{APZOV{RsJ-mT`4S(F!ps`GwXyX_2ThKOTcN1+ z)GGXYZfMp!x50PF454&43sDlFMx%0QVOp##ovYL|#E!STx;`oQs)0eLy%$ zf4O;d8yO)H9L2CAi2&bI4D_2MM>K(mrP;E^vg|n%tfvZk<`Hr9LN0kG6aHR~F`>x$ zLa&gj)9zYWxOhU(F(KDWBVxZN?BwjzqjLgM1UVHte8Z8^aOxPrY|Hfeowhe>G?ia^ zl}6dy6KijDK8HW+j!G7^9Nsin*)t3HFUf&g9qxLe=QIIx(*NFZ=lCJVcEkUWnIo?R zDBRoAD%r=)yI%19wZ2UwV|AUnG2NL^ta^<;mH?MypWUXGS`1xB&v*r^S}s`22JPg` zXTswH_IQIV@7IpqcCsl(Vs*z*Q(oxw<(epU5Yr`9sW1cV0lY;-8W0SF?b>JG8+x`c z*3J{RFdwzrvqoxQOSnANv{wmSyPHqQ&OND%&CPAmu<8nI)>9AFOKP-Sfs3r%Z2? zBOU8H$P@T7i_7)PBXYJHESY11Ch`3j=%Rfd*Mc|c!a}xkLI#MAXw;yYEk_RAzV0B` zlMA+4P$@w?KW)>&&22a7C9;ATK_`vy#IoIB7|luQUrh4#p)IcWb{9rMGT}w2x*=!T zeJ_-~<%`Zi}Q9zmdiTHl{MdXmrW$_b^4OMb^67jYfN{0 zIkSgQGoS0s+qfwgkB9)8-F~W}vj@7?FI#j9haF+LaOdy6Sa8y-q|J%IyZeOt@VmW` zs*LvaQR9p9a=GOa_~cC-k;6fD_sRwq1rX&^G-=3#0ix;|J6k163m$VXn2aMK?T+XG zt6qO+AUsHpp$~`p&Xy#PdGosIkeJ$ar~-leC(MXe&ta*VwZhX&A1}n93c`!F$ChcMZ+?4ycjvTWlw|Cr>tx^2FAvg9>U>-vI! zGq4doUD5hnXHHdr#rfT2)nK{t>|xk$X4i;;T-No9pV}Nvptr2|Z!I&(!W7{d1Umc0 zX5*ZPI5K|91IMZ2gK+42A*aSMB*&Ogw)r~R`CrZEYEc_{W#|)2BpuAQt^mWo;xaxg z!$&%Jo3ky2o(Y!0wmasz;?w}>chcZRLh?i4C)0#Y=R4n%aSs{G>LKa-=mZ2> zWKmYV!&S%`Jl!)UkoYcVTM0e$Yaym9sY3RYiKyt^91Q96qVE4Lg=XBl@#ezS#jf@X zjYj!QRsj{p@D1K=iX(d4F|S^qX@4jVTdO0yTk6XttI$T%j7pHFa2a}9ahU7tD~xJY z^|mLxS)hrOFpk{Itr$?ugRw-fTx*Au`BNQ{PeXm{ieNM*n~IbivR@}wEPgZem44=g zxu;sMbM}3*bAEqdt{L&Fqq}y!2HOEU$iLvHL=<;IGZOkr85K{ihL(>vNigaq+}c7{ zv=zgNPATtm?5nD}bH$fVR~Tn6>4Wo3rTfx^M_0SD2mqWFk9+BbS0vjAj_ZNF|9|t{Ey{|VNLPL z{SxW_RsD}C#9fTw&we*BHJyF^I*(C#eZ70vrZ*>X(iLrG`>^)IktBkIl}<2%#?9JK zIitv|MW$s0hh>!dpaU;bCLC?*$coE{lmi%#Ie9r<|JI0*W3f@WxkFe!A10k1tlDuB zr72(T-o@daz_o#0WhQ=7|9$%)c0!|8?^uE9TqJ-=l`gN-c!)Tfi0Uga23a4!zuj}4 zAO1C!1nE^K^LeHPWKZJR8js)I$h(+-M^5aY7MgrrWY1l5y<3qt^d1ML#voqYH-^49 zDh~|D=bOZrBH?okS9g1>46@uqc>v|GX5t2r=5tE)Ftvt$O``n)GTeK2ciRE2mc<$# z>3}(zUNe5T*C{`aG4sWT`Uj?eL-JsU#k6GAI^ZdbN0ifP_iB5yQ~V_4;C@v@t}&Ea zik{A55jLtqPb(Qkeb~E!Uh=a{dOnYz?R#VnM2B0vMstBRydJ0~nGtnS#mZ`{Ju=0e zVN`YGCVVq}QWqIoXs_Th@3IUO-+jU%^Nad87rZ5}1opKE)|6WRKh`O+=#>4lCZr>+ zc4vovMb3^lRJL=r?7_A=n&O)DEhb6kIkwqpU`)<%I%H_t*Vd&iKenmVm(RP^=bt0_ zSEn3WhMacYFA7Ml1FF}4|AeBSO<8^!CQ(J&y+QE_(!VP_Zk=8TZk&FH%aUFTa874+ z$@fZR?2(mriRc@=vwjYkglAJOvYqP4P{{U&eU^<-?ed4y33x&0h6o!sEJ*V*&CJ1P zZyq}S{>Ew%dkK2lQwF;$lk{pe7$Bq7CJXUw@jfYNhsE(WxLk*_Na%)uO7@VDRpM0L zixVl8Xgh!bZnhU=oHJ?y(1yk|Ce(0brE^dKSaR*;HflpSi`xKtlHTs=c-t?QFf_R6 zFN0M_tB<$HB+WKR9E7BGLehHIaE*GNLoxABa5tc5*Xjz*7~|?e%*D^tKckThBM<5b zKLibduZ`aQ-?~%Y<(R10VeW3%6RwFu9SiJ1R6?-ct&@x(d&^+!+K%wj7JV3Xub{?- za8}xn-i;C$&UbomJ-MC{bUsZ#cw8f&M0;iKL;Xuokw$AEqj#0;_3J?ZH?4i$Al&@= zhzEdib8i}6F%>&sEO7t;g!ca6OVF?S0kNNUZ{e}i@Tp8^@a-k~81yIM_1-1SJB!w2 zgp64LBnp14E^cr~#-{GK?W(xxr$pz3C)L+U3tZY3xLwPa)F+Y74)Sk*(bJ@kSr^L| zZm=Ls{Y&XX5dPz#)B=yi)Bu!YCPtSd=xuPG`3avoz?nhqYwo$>yhwnFX&aPq8QzZh zBhzm9V{-EcCG$(1{P5%dVp<>7qgN?7szzw}G=(^q~6`5x8HN z9%2!Ilpx6p1X$Ky*l>GT8^(Pz4@4Z=x^eBBEMSZ7Te8NH1xzp&#l{iPFe7TN^*F`% z%Iv;<(l5j5j_D38HO_>qnmWd9ej6qpq7uNAM$8l`yP>>1?DGe{T|a;T7sH2Ldi!( zfP>La-BeV;PlKK*)L$Y+^M*(oQ$xUz`^DOz)YGELI|UaD(?o*e-n*NsqV17Wx@1L> zl6FPfVay7dHIcTmol5o-?k&ThL4=8#tL+!-EIVEMEGuo1uaxWmgmh~Z?KJ6Cre`D1 z(e%I?x@L!(tgJozKKQdao0$ySDFbuNy~8Q0>spWut4G{D>+HkQ*rxD=Ca59w+J*}& zeg^38Z2R3lt|bWH;0)m5IKSBpQf%NK&}Pxh9s_3c*!JHdGq4D0|4gAh4MUoHE`NC= z-Qv6*E(QR6rfQh7_4*@uE?_T076OR`i9c^<;0xR$hd>@2=%i+3FT@y}Q&zPE8=sNvxK6G9ue1aL?KK|Q{Jv$5S&U)5+il)8od;X=(F~{&biQe z$5FO(`>xuG4*G(boTIz^EY>##6H=UchX0_}mn+Uh;4mnfiUJq}1fOU>@iAfEnuNA6 z!+M&Sx^NZRZ=0dz%x<6ASu#Z0nT5wZB-HtGiI)`o)0f0Pxidi$3ljynrW=ft zc)`yECk9+92uw_ZL>x9=zV*hfPVlxrKv(KTOwYPI^~*5aj!A**UVpfunAl&GsYzW@ zq~)nK6Ou;E_w@b?utIyX3#?@|&SZ&{ZoAqR&a7*3N4U(Z->4+6J{}-1qsc9CpGOb>BH0+6Nz0vu4yX1<9Ys1>O$1yUwVg6-r;RFz@m0&~Vs3v)=~qL~@TDP#P^h&%(IU zU-0FY>fdo~jEl^J%Qpmmizpxc3x+x9t?sT!|F30nxr`&FmKBy_rNzCG; zmG$xPF6r*LtG8qFRI&po;=Re1(QH>cRc>ibXOsU-2LHb>G|5_cMG?!JSbxf*HHXB} zvAd8}m&!pGti3v>ggeXRG3+9Pg|rs{XveH+YR7d4A))F<9Fc5u`Y^j)g0_43KTw=x z4xo6-vAsqh(wn=H=cf0)Q_1c%!EsWZ%3SQwSBmM9=boxjtI^YS|2hJ-1MLP7=-u9A z!R<_6F0OTiY$w2jL@QiAZ_z_j*X*IBEa{9s1E#f?{4C!=QX~A2!9=7vU;Jwh&fSS% zGR;=PzEsP4kRjeTys}fs89K`5Xo9G7nQVYe@5r&N56DZ8#%0-Q{3rX|9fq{@<6vUXSEG}R7SKkecWyF z(9Kw&xrc5h#iQ$$JVhX4Dn{D+3ozVSD3*x#VPzgCi02f+mpt;p~ zn4`wAZ*E$&tiIN(ke|fO^$gytbNZFu-Oz!uVnj~ybO*R_I&4y z?~!4U@4e8zsw0%I!O@Q2_taBrv3FNe>Q8YTsYjDWADJ zDlYQ{Mwj9N1AVv~`FCiLM-kn<0L=OZD|`Dkhbh#-G0inkg+_DO_w zJpsKR-#Nj5Wqx|#MNmGRyDV0K}UN(BQR)v_tt(0YRz()r9_v#^W>I~r#A z^@2X+)#e$f@u1Sub@^`A8x$L6 zO@EgBJ9Af?6~MzLWyZ^q1@_f?Rq_)m*Fu@N(|s=JKXZy>R$je!`zd%KEQnR=pF|-% z&V*$a9vyOU+7Wf`NhjHI?H3l+P*1TrS{?Fr+)5+H8tI1V!OM}BU+^on0{%+ugf>po z&f-6a!<_sK2XPZ`Kd*{$4uF)0@&MIvw+r^IVHvYC>dWIKPWu-`e53^%fl6COE}sMu6E6NW&%z+XA|`3QbJj9jC26 zaXaSIqWE8++|3e7b1$>z7I%_CuTU-99X31zm>vnLD2@ov4n07}y>E=pDNOYTi|ANh zD{C%Y(k=f*ZY{;Y3haq{N=ERM^#dn@y0flx<#K5*vsq=Y8f1GWa4WjM;JDV^UY<%% zrEwPS&e;_ytTsv`=q-&7X6VsWu^?2!t2V|(TpsSHQ!H1e{UYKq>m933Un)XE^wwCm zN#WO#Ez8ioiD>E#^bCLdvQs<`bTz>Fp(|-JK{mRTmg0a^(QTR}7a}|$W1D)?jNQ+cXzRCLz z(skXGB({Nr($h1XwB()6pYfBvV{z~bJ-JQwV`Aq%0O4xRi$QdWJi=tud;UuSc|NrT z?Wx5lnWo#O&mN4BZmBk!t^QDMq&b*X@eBU(2aGG!kx9OTy7CP1=nAwQa*6_{*0a>H zJt{n6%boG!G2xW_tN*xuSM$>`Do+M;K)sbv}NnQB3iz6wU_%17P|b&PIYqZPZ)luUHd9sZR}7g&|ZbW?=he4L11J^m6o|DlJCsNn^Po zX(9=&lOfvDuMuC-b{5qaD$@(DDEvfhx;sVrT#~v*J|J3~Ae5-hlwI>8OVq7Kgd!R- zS`gy8kBO(5+vj`2D-3pjXN89^-#Lh#`Yi7H@YUyvK}F!2AM8;Zp+7{Bl|=K)`1qkU z7wB;@eSC66XwLZTgf5Ze6=b_cwO124*;dglLMX*vcv*~Wd=Zb53H2i=0n+Ir89&d) zoxd6pFjO$3+WX6!AeAow+B87E!%s9Nvd#-%qv4EtxZNvPPH0-VUFB8K4hnD2mr?&2a54OY?zGpE zR67YGq|bU+?r4lL6fwWENh3VRl)>!Mo{~PrYZM!dGekGz?a?EMXCvKi0ZH{1^8gj6ExBF^b&;PhRT*ek&JsG+1THi z@Sv^2vYknpafAvimWN#qu`h@fIn9wzJ6qw^bI;?6%(}>6u3w{j7{ZWyP|^Cttm4|e z?PV|Y-ezw7~QCk}Bvue6>7&Iet2~Ga}SsQt!2A zS|*4t62%eBieZhyns!zw z1BNiz77bDVtNac>aIJ-UnjTg@bYFGA+RBt7`|6^@!7AN|0N5!Bw0{3*(n>d?MSCOo z30CUMzc-ZbfDh#MuAQRkA)Sn%^*~ZUOaxtwGG9I#dw;J2-cu(<+CdW};NQK2iBg~m zrB+QO36cQq0vOS_kdgLk>TlW&uP-o~k%3IxHRMPOzyJT$-) z8>b?huD)xVM9I(>;V!$><1~?PnvMMv&lsf&Y`aETZr~ufMu#@D7G=PINdKh*=1RU= z5nBZ7(woCQgjW`#u4OYr7Eh|@AE75atHX1h)J5(N^Dg7CFqDw(a-N8_vE;vXM=9Ct$pLBr`;Tyq?t>% zhf!pcvUX=d=c3T5A=@9!drhhIF}g;Fkj*Z0l?DEjzCK+_VY}-C~-Fy`3F0e2P`neUu#P-R;1-vIy2sL&shcD-uz2OZH7zQb5G#|!vYEPyq^0#cFB{kA0PQv!gJHR+Bby3ovzH|SPFmQr68--QaZQC$ zpuf6V^RGWiw-16QXf#Fr@F8E}{tHZ$MoQ_O0fQS1T-M~K-Qmy)rfnbf*S-TVaDbLFm`^{1}gnSnkfbhk!RE@ z;2WDD0;OvbeO16s2HKOR-s74RW?)1z*qvjVp7+CgS)o0G=yNXZUu&pa7v#N?Ct@@3 zwpU#|n6CdCKN2L;Zx$Z*>*#PG{WK~T!qkwUNvk$2b|K1cq4>h~<8O~>q!sT8AN7kp zf{Th2sH@Uu;PysN<@{L|Iv-85-lmgaUqsuN-n%5H(kIDAHzmAp51CDU#s3~p=%>w> z>AYhN7?w(J{H?)Vo(I#P4S%J%tTXrTZw3#tAJqZR!UiDUHw|Uj}oq*73V+sNj*`PpNO!7*Y!ut~T2GHBajzzo^l) zm9F|fg`S$w+qOw4vKQiBxh9}0kT{r0XJHQvTCe=bE|1w>84oHMoRTDt(4|op0UVqK z6J$vHumj>v6l7JZlWP#U>>vEW7CEyQy#7mal@GgJJ)-@9_I5?20YfTWiJK>9$ZJYi zH>xng=tHo!XCf#!M}Bz?)`NAi?l@e;ZDf9M5W>lZhVZ*>fc!t5#2repAl*>9b)?>R)@y~g z3RmQ;HNHK$`A*%Higp;#+W7}r=}M$&Apv{Let&XjJ?9}b4bM`7=pw%)Nx4yfL%DBO zv7_pUEx!q~%R8JFg4GrvUI*QjTkLDKeICBIeuTVKEKb=zLzs^HVeK(~z~t*_lWynL zG`+;|+%J01(|Fr=!Y*VSeUrd6o4PqLs$LIMwj7<>2&vlU-rEtgqx(=f{ILRq7-ePW z+2t(XuC7x^edel^0h6O*qdj*iAs_bNX%?oL5F{z6TQB(4++&|7x4Z)x$ z)gq|dcCLeU`YD>DqeTgE-C=xyo!q$|&5eg=zI$GnIl|^bUa2!xZIXP%-J$fMx%2VO zXr4)a4tj47o?)opm>JWw;cor5TweIDP;Ti^FCH2f@bL zZ0l!D8}$cp1>t)I=YT^2@k2sW_^NdXwEWRF>0@zReV|xk>N8upoEk)*aJw!~u*T9^rZ=x{V2Qpi3EX;L3)D|4ze? zIHa_d{~U06!Aml}C^R+Z6Eo~EVArp`G39=+c|aXsg)|x|Qfcn~#oNW=b1?H>W2C@+ zzsvl}D_|{41}6Tv19^UbGiZW}Y-=dZv%VxJp@i}#+1v1p*-ct2aYqyQ+P4i7RPo}| zd#SO;#*D6(P9bB|ASG@~fkZ7eJX3M#o|$iPL3(mlwVSU8-MVYQz6y7=yXS7V1n<*R z9SqN*HS#|Rxz$n6^JYV>fy|KHyNT(2t+=U^`{gk!kwfn zOzsq%4?WtbOKKiam19C#Z*Ym@Q4w!k1Cpl3t6A@LSr1&$@0)zKY}k?B-zrPKKc_#w zw#A7M$(W0e-RygS-WAU$g)Zu%pLGQlC%yL#343SAhw}Eqw};44%8T$LHds=8AGe)S zhr?VUsH&pjDpzNHbONiv-MQ68cla<2#jL5A`%CUiK(9)%zl(2Ht~>`OxV{~VTz@Ib zX#OQDc|3Q1iDd&8lw=z}Y+{xhf_~Yu5qEi7=vvyU@@4|l=GWAoryOS)1aBOfQ`vm^ ziSbLLO;dSd6?rMhJ=MNstH%C8U+MB5elw`B3TkinZBGdsgm||3q&1V9On@}LZ<{Rl z-jnv^icHS75MBX!gQvE6J{kJMCUkM(c-y`lF=TDuJ~L+>-!|m0L_NhGzX(-m)VJxL zEtTVGmBhce+L?F0N2KeE`_b>^ENjgvZc-B~Vo8J_K1rEfY)vjGUfWrVY2Rb*nrJzV z)f$0Pc7d-dx^Rj-uRH!NYZ_2uFK~m`Y~rn%spKUG+u2mJP(36_>D4UA{y=(QAk`W4 z9av+mfNl7e757p4Tm3F7Pr79imWIuMu%5-0L#kt*GX{2xcuual-c$B4?J0&e;!Z8zYkB{OCzjgW;L`frMo4 z8+Sd5!M*fc7H|C>DSHyJ?h(m()ex=bYEqR<*@f@(WOwH~#r2I=B;|hDcK0z|9<1y3 zS3XwDVh-dFHRi+epK_}r zXSFw1W+h0}Q|Pbq?syk!pfbR{Iv++(0w+%2ODRI_>6@P<+J<1OC7L<8jFze-CRca( zD9F7k5@mfQF_4=u6XKu5rc^}@o}G1ogL^aA-&kd>cWBrb+Pxs@Zp@`i)xuMsN@|e* zldn#RK`2(lGbp8)EE*Oh2!+089SZtV(Sg=-j-7pC*(NyOQ*gH?$Acly`n3klnfMb;4!VyGB@c8vERbG>r>~g z?T&UxbzyP?lV(=ezxwh;KUk=Hua;FgjxQ*1?)M_;Z|)I;$2DCX`{g6tvvx#O#gnbZBZi=JXyEWT?q?~U7bw#pyxtQ)&3mtVnDxNj#=3KA4(rEv~ueUWl zZ`DbDp!|vu=46JdTp;Q|pT)LnsHS+^WO#$)TKMe)UFq-v=|`azZWHcw+{*8tEFFvY z=&JvoD_1qNQAZ3cJRW^;CNlQW@a?ndK_SeiCqCsP_n1<`PiP8sn>gN;>i`?t=+otc-P&BuYO zZe}Z&X;%e;mV?Nbdz3dvy{eN*M`Orf?l)0{e>JbVTt_r?$5%X0UEFPAFFXfbVEfNG zqo05JNVmy)*DPI zVVjyx>^0GmpLE1_1{C!c~zt`n-I$sChQb%v&u->Bit5+D8BZtIzde$`2ZYWG?g;keL%c-qR?bM0$8?4@GB!3U ztdqj=W1*Kf`$DP`0Qp0;`yiuW7yi-%h?4_?ze{<)nUl?zPbVKVJ`s?zGX2dO**va% z=C!vrSx&J+Z9CY!RpAx`#Z2nBJD8v5b!$6ob<`(RMhvfJu%^XL@3@2({~p|Sn2I)v zg!nN1G>y5#W*8t`fN9>rT9MXsrgS&nylhX1VGV9U7fbrfr)(V#Nk- zT1Lnj8E(v@6&OhSer!$`zagr*+4P(RttzwiRr~9>qT6V9lA6?4Fs|`I#pG>+8fL@E z9VP!;C+{K0cT{X=V-I19f7zQ`eb_KB)jAuOkQGw|)Y7oPa^6~>xWwd`N# zt-2v)$Eh=1Kvpaa_%`SyCbb2ohPv1>44JKVu%qfI z$(>Wssb`P(1KFsAb)UqTGxIAk8iR7AI%OWacQIJ|>|-&M*zjKj(f_aF_})7H(l#~|~Z{j~D5hk^;yzNaW&mA8f# zIXE`0TvN>}#mhlrhcA+b*zjFmwVh)GPMvGw`@5bY)Yutmul>56DI9ji-6I5dU^=$B zE8mJ{^SU+G_k*-StOY$c&_zVKhBSi<*ub(@0lfP#RprXH^+wOScReKOu~KF8kguX{ zZSPFJO!&o4iP3xUrjq5S<6{j4NGAyod_#W&qp|7;Dj>;!l;4)y8yO0)?24WqwH&`` z9NoJgu}VXX=ER&8rD9YueF5x=VL;6AkwUwbiVG6c>Li~XWrKB+zmke;PXoP+3GQeN zw*5!~oYB0fY7LXXz8~!=uJp4C6--o`lY-BOxF@Ar4p6e!ot@9>rY>|T4~C#R2Z}S- zY zHy6ecORm@&ceKoS_f_~02adxIY?1_& zT2>tDKU1Z-m09ajN2~>ZlD1jwfsx{#BE-_Z_9@ZAbtH-_yZRz&0h5cN^tR$Kd!oFy5a-*I|8LTB*L4VaE)9Di~4qQxs#9}Id425M_%bQ>jdDEdkVYNTD3pP+QZY_?~OGTa6RNn_?S>c9BD33PN^y6-q{yoqJ-ssdO{J(+u_54?xG}~gG1V&);2^p z+0Wxb=(dunpu#oW1=Qj}&EP<#NmBd})~e5Nn^@|OI*>HP2Vlf+D=Zb zA`fM=xTH#4?dlcahy*U{F3slWCVn25-6ZDPAyp9|TeXCtazO;?2HKqIhno}vP}ssYCb5ztlT zIvlU$jK*U|j$x(FMM11~NlOuXlWMDV>6ZD?$`++yMCA{YfP-#5N-s-%pNmJyc@S^Q ze*K|pp_?Zv15x%9@oXaOYp(2fB=Fu^H?L@eZ!?fF9rYv$75bWPIcM@0oRU0zNq}1Y zM9c#8GTx|7Eq8>>s-sEsy8YHmn<7|V2zkW;eY_qe@%Cw9nsUcNG*3N2)$jzk9XM69&w(NvXNNg>gWj-oDv^_WVr1xfA~fonK`V(cy|<@@9-4Js6Dh*EddJK>t@E43 zwl>YlkU7(}k>f94jy|%Vs0tWZ$(5lW4nLu>RCD&dx$AEo3-4@y9ci(%o(sDlW~ijBlTUw!aNj6_iM~k6O~K_G!v#eyC3FyQohTQ|m3nda zj_(OVL0#H0SxfXQppz4nuO!^JaN1bl-An|)n}vpB7vh=;y(3*O7Dm;sY>I}M3=hih z3s!}zf~%8XzOc}7WhuxH&yl^{rG{#nljS+e{Zt1Er_=>6c;CBh;If*&oCX(UuBdLU z7a_E^)QR!dL*?7pxJ|`Zu3_uHjbDuV;bC%l34ze*Sj9g;hGxSu>iYTDUD@7R5+r7_ zZj+8U6TB z`~ibmc#mWJx5`Sr2duj2VoPe@arDEo5{DfBI%pi17_M=eVwaxGH!IG&@+;s`kx3hT zHZR6+S~}kCrv5F5bcA*<)9&|)jeWVJ_*s^^iUz^~eY_t65hHlYvdXP`EEL-EAQi>d zTV&FGJ0>vj)^&>-ZO&;qW~EU-*6Hm}uK72M@#abT&KL2%0_M98M6WcJ&;=gm9?%{Br`*=ndXQLm8q%`l*+xzsT{mVMzr3;Vu3#)1FoTZw1aV!PYw_0>2MV8 zdySo&{#_x?Ztm>P`VeClV4+lK-U^e4=Io$UNAK3Y=YjFm66HkO3AoyI!7j!bk!zba zs0yJ=rKYx_D)v?xKJRW+OTjaAT~iOH!L>3Dbx#h#4Q2T4@8udXD|ZuBH*xOGa3C`* zFwa@g3}|^T!nt~1jZu>D&F*I!{@2$Voy*5N)cl8uj42;u%jD%f#)31If;h94=^wwD zjj2q^g}Igw5L&>olIL3h#WEPyp&l2@X{Wvbz8CvWm|GE`Rb>HtZMQWGeGe1c?Ss zl@;^mH62a%rmnqrhtn$=t`_5qHjOP6+_}5ICDytxisSXBFX!sImM0U;9ydg5ZIdjn z8XFNxU8fNRtQ;rwoi28JJxb#V@{0GpZ~8?4wy9B3ro5?V8e(IxZbCETUShL>bHJ~B zM@%`ZY{JtkPv99Vze_3Ui@#i0{HxKD}0Xx=fB)_3VBQVJ>a@1TUOmp%CgX0GYKf}Z&2oV17%|U?>^(@V;xA0X}AOU3mw@RAx>hQDaJPi*B+RCn>iRC z+c*CaV;jPApo~jY4irysJ?bw8etmRx`B|w}aZKB!8~laLw(`20EVEl14uesT*Ob2sz#tGtfaUqk;3`+3Cf(xvukA351Nca<6KfRP)XcXd#U zeH~Zn4HuU5v(9(EW=yD~rnHE^ZD-q2O1ssjT zhMmlHu9Q8xE#x4TE9FDs*NeFHhb=R_W?wFvvfF+?x?8SO;`P-$C4BU>?S)If|C?_a zzV~Nhjh@l(?bNADd=Es=#;>gp1#!=`vK_l#c4-o=p$jRCrI;3Yat1|;u(kG=_jhz?KWu?l{q!xm<+mk)?k}{bh>D#aE_+E`*YM*Z_y1GURmU~?eg9Dc!iQ4n8r?13NK1FOv=R$N@io_p`f_c`Z2d40Yna2@%EMC{+cG=igh zHA}opeu)m4!gcIFxNRMW#0oZz`)05%?cQ#S+_vwYDjCJ70VQuAtj0HI1`m_I%UJ_| z1#wss88LO&5543bfbwn+z^Dyl_qIN2T`3rycor&OR5OoaOZ(;ys90@MV36AW;<;Ja zCG^-9Ie01^bK18dR7%!bAT(sfn|CvWTD8by;Nh@eDDaQGzLEVS9w9$`eIYHp;In-i z`*K3#zj=#I>H8|W3Fc1jD;ps#zuVWMpO+^UoF=xcemU=yUF3$kyvH5;-WV8mT()GT zga6K*p%Fb#Y~THB9F}wc`}#I9l?n02g2j5nqE?jKnx-kdLVsUZDWyt>QJ6xAJ(Zo$~&Q-v8I}#_(sDC9?+z7 zv{<9xn@I|PfW4K33R`B(+8!<$`nt4kJK(#{RF& zh8s^sxVDZpeMi@~OQq!~Tnjb}{pcUS1Ap$MAFUN0Q>H|u5W}BZX?8$m8Qi(V>(Sl} zQ%Ec3<76e17&XJ#jgPZtLs~nr%kR`{zb86>na^{y*z8?QhrJrK@geDWUS6T{D$53S zDXFtyn60p61H=_persF3$$8L8PXBJ*UoRKU(;HU0c$K* z^?d8A-I)Gz($MA?AKE2&P-rvk=ir&!s?vx0 zQiqKEWYduM#EqazkV~1x+{I~dWVVB7Ley`Sa3t|M|8$H9is6B(ml>tY+lJNkNz}A2 zuk6jlFt{D!kHuMlVyq8cBS`o*OmO(UCmzYm5&hL~7PMlauQ3!$<9OFkoA=|b2uJv! zWYE=#mEWO;D+8>Y_kBteHNpBC( zm3pqJ3mmg!KaGpjg#IQhUTuU=eJ5{bc$DvXA&#<#!2x7_i*+BGE1sTaP;Ht!Yd3}_ z!OYUMmCD6M0YWivy)SZ*R3&jlYs3_6e6oia$5?Xfqo3)WBG$Mh)np-$&YQk+Lc zHz{1%8XWYC4$xDq(f=6tf6Iag8j3d|fZgsT1;uigzo@O6maE@9vKb(XnNS?fq!Y@M z_zx9cDo*QxNA~u35HTgqdU4Kd(5q8yS;~b@l9z$84bba4l zkc0aX?f#Ig@TPnMuWL~lk=d=so}$7-^$l_lK7{TF)@)`?Kq!#bpzPYr+|c z-i>UW`&vlGXB7^>F%vhEiyGfj`tqe|qgN!14Y5E?r}D~q6~%oS&9`H*X=i&-Q+dGv z-F_iW8KGya+kxZ$Pa{aUu3osXmVt%qT&hn0rh_Bk=B^lWW|DF0iS3s0Q!Nb>I}<7* z3{`y@hA-m>cZI)rQ&Y%O&8|ENWs$Augh?0DL<)L~d;U63yW`jYXZbu+07mPC8IL3y zSqcmqKT%>w%r^95NzNjMm?yMu@)Q?P`vZ0^HSJ?Q$$j-tb7t*c*7O&t`v#J~uH*%S zI-K@pFw{j1l9V>~?&tRuLpSl*~2! zWB(ppe0a(?(DE4Uks@0kPzH0}@q`{aZfS2< zSr&ABc!{_k*?^a)APZ*!8QuCJnUX&w(x8z(|KO0#qCjde%Fe+A;ccKUK_=J71=Jj# zGZ(ujBlVjx#)|Lyl8WRM@l1APbzHW?TY;k)-ntTjvV0l4lYI}2_IJ2*5?5LK+t!}~ zUb=xmmQzY!JO4Vq>CU-pn}i=t2g@ilN!T=mW;>j8t9$ zFmFq8M|e>?bm)*jjTWp{!lS1$N4%YVz?F*IF95U=PkFRg^-%x{S@Z@0{2E{6PW~-J zaw$Imo5KP)d?>c^qRP-0~KIXhJr8kqwS=3`_a5C#RM@K?pIb_u;MGrwu1#g2xw^{O}M-B)ehAr zoz?Sr*A}C8NMELpVhCr7P1*7`=o7f1-QDf$@XT6lL`-0Em!f}F5wCeiv<6#EfR#RW z4A=p|T#Mpvb~kWdxQoAVxi;U2zE=d1s$rnoaFVa@+vJDdpOj-$^&x|DG*Mg{=IXo( z2=%4f2meMd%6bUp6SO*v__v}4yhN=gN%{DRlzuaw3+;e*8L;uQW>bdT5BWnAtwBKq zVCg@c@?SBYX*9k4|L0s&WjX4mrEOIGSDwg=wz%BYN{2CZgT576hF;_MO!GHN9o z#om@x8yUH3@42^tx+u&PIZ%M%1q3y&1VZV%F7S#7kpG@6{na;6gjc0R{&UvWzUV z$$WXYVuPn}9rN#Ke#&*%!>1!>tdaCUhtV55bNjeUqN;H6GTgC#z_j&D&)e~*S7O!R zfqBl}M!F5(JSXmBX;X!$H~jaEMcEv#WBXg*WUwiEuv_!-4k$qf6*pf?7zku$Ea=>#_AO3AA-j>8DRh84Jc+t|>s25Y7n9 z*x*NbYA9Thy*7cZ^yJNQ78yQfXc38GRkaefXWgW@K1E>LUWMu(1jel+aMGI-)Rbs* z7W%_v3tg6h`3REg%~OFIk%I8R(Wk9m)c0w9t5>z6=9&;(cb`u`h#i_20=6!U8|&km6%zJYNcb{tFYL!;a`Ov)wUYUS>8-#*kG=;wN&jx4)l* z1=ciaI%{uQIkYCOVmz;^e>FfJtOPxuE+e3|Die!dQb0LGwxX!1JmzcaWjV}u9Hyl1XvCdD{#~|R4Ra9CWGl+RH z3bikIe3c_6^f0Qdi(UUjQ9QMQtT)k;u1pHmR<}c%D+tp4_};xZs?n;=o4N~dB=bEA zHXz+f`yzZows*+%&-NQ6T*1<6bSY!)9f{&OUik_xu68tKp(h3rg^gH2YmHydok%eE2AkG9RJ(v9 znP97&lb6lGZ>d*yeNL3hvj?n@dZA~9_Kx7|(u8eGtBW7qRWTcG_Lodxe#ZfA8aW$~ zviHuM4yy>FelCC;6X@rN8<*j*cPsHmY08=sf(=hbRZ4RPls`O5uYouAWhFnkqh$mO zJABU0)x++<07)FXj4FVr)XgmRjDxF?7@&+)%P=PHD<`y}Hkw`$SPLC*;E{gZF+2{5 zIP*`kO4nduywbt5d#0G7cutdR0o4H|CrkcOPOt-kRuts1<=0IQ3)Rd3$s6tX-=_QX zr^y8V21-Lj_#TruSe8i*bwYASer9W4tK!*do#S0JT;zgOQYCLP?-xO!4m%Q(br^oW zVPJ5jgTQL#wI)tLa)_evpb61(dooW#vb?W)JZ0e1%kaNcd5&RD8yLUss-FZsipbJ0E1J z(MAlOZkYMkWb?DCpAOjgmSHXqd$UdJDnAXk0waldxWi>Ph=JU}a!GkF#E62nz=x%Z zS>0y3ai%)&%@`grT+!I$B{clD6t+#c@gne#rKeGME#1=F<4)~O@F;maU#Pc>O^ zztXTuL{*%)e*DN1RNtfKBu5N94`QybCeG{J5)8vtU)|pgKDyz52>N3LbP=uMjDOx5$My8a&r*g)Mtb2udxcqL00S|okF8MxB^M<`@5Cnn`^KpHw*%u0MHR*T%s~P*bFi8 z&Vih(41&cWE+}q#7Qn zIGL$5w(TStKzfr1NExY?THDH#VtWUGk!`EV0d>=0y2Mi>I?n*9;3KIUIrn&huqK$C zr#~^YQXVcV<-pK~K6Mu5&`ncFqC^Mbt!v9B-D_MKD+Amvs*ScE*z*E>j2TAxx~Bw7 zNLo!dB|M`z?@bx17IUtLa`nA~>3>`{7Tx%LJsD=Fzjr+ygqSRKVhMf%8vD1ZNgc}(Hpp@g?G^{>ld z(e7pWju%PV&p#0UKkx&V8T!izXn+$xtl*&Xy`vN;Bh%v_)o0)AesW(mmIn|}00y2i zMa>3qmej9p@%Nkjlx!Wl#PL2g0e6yu)|l<%+#|6uh*Z5fOh$Ik8(HSAWfF)KP77$t15IO>WWCbnp$1^FCUz$qX0R zi&Z5&F^!P)fuuHE+hpj^W?fdyz1}eR{?R4Re9OeCExr+}oVz04D51sU@!{~vF43~5 znN8CQ>p-jr`r=x6mbp~&OPKIxxL6}%Cy}`B(8$3GkLzaS+?o>9LsUl;lc6F@5og1h zmmf74#6+`sx=Kv@Gk<@!o(s?7FW+=y##K>C=&&V4uPVaWZ;s5&Nmf5()px$b0Xq%( z7{uCUa-Dy`bPUbN9f4!+T%5Cr?+>++Oe5UfYapy`L?Heux`7T~$|p~fg8+XlQ!9s+ zTb*6PvgNf?7Co*ZH<7%cYSWR`Zt9qP1>aQ>pKaYlpt(0)1py)e+hu*CaHc$baT;y^ zmflF-JyH;3jRX(^`PMzqvQ*iQ!sPq?H?<{QooV~{H5vxq4SK4+`Vw7 z-{zBIk>0*7Ud!%*=FS-WgkeR%rd72Sz3FS57U9+$SHh$C$^YwXq3SNM0JZRZyik3Z zVa$X!+0BtQTY#Tf z*AU=XuzI|X>A^qH@=>tbJ`NJ+P32&zc3n}_t$AI(o#H3juc$Psn7RUQkDv(Zz$H5%;D>|T7wJYNB1TP1; z-yt57&cssbSR@duTa(!^9w7<5PhU3VOw9d2;v@;q>w?3K^Y0tSPdW-chfl@mmM(c` z_Rs0FOR%7@m(nXjz%k_`!kDc0v$;yVS@ZS~sug1xUbcb*O~eSb>yRBT_17B)_{|mk z#@{}?oh5P@U$4$KLxy)wU)FmIfYx+7gjUqadibO(UIdryh7-3ZBpCN)R6qcyTh`&t zH^3-PtM9p7j@0Ie2v52%u?eED!;|B&hUDT|os>^m*u|3x$D194F~h49gC@ z#M>_&79LiU+DsA&bA5m>7U#Szz?NAKBI1O&6Gc0NWrxVPWDqp0i$5`s>b0d;@SVx# zP4pB)%hqMg+hA*QtsTA~-N$?s1m~s9jp*wubw@!0&$p4i={3Dv*LA z0T(ve5Am0yCXG+5;rj??WJ3mb0xA!{IWiJIhcSHtJlvs0v*KSf;SB&<(Zdad5rP96 zlQyX~UPULlJ!4QFA=FS;=A*$%BO+%It^Tf{%da5Dk>}2B5NKzoRAc4~o89Cf<`W?NM z_)Yo7r4kcY9$e{T%em@ju1ZVBqWpo!Z~X_t;IlqH#fjCB)iv%)EG&DJe}+y zaUjHYtcl5aM)g#Xem{5Mpui^nuYY&steN~f;81qRS96~+YYrl}zCfs(eW)oS*X#7w za)m(5#~LK1*#kJi#pk)8U`8{-`d3>|%1~?wOGv0Imnerl>*q55yAl7uV&?#+*y z3!y${sdM_l?~(w9tw+yOugATAmC2LTht3sBaI*%TuATEpQ%gt?O|5xzXt$3Lnx^XR z%(}{dG>;U7$vUuqd!+mlE%5t_@@}RICtelxru;?dS2L9lCb6_apC?ZwJ*smCz6E#4 zQn+kl*gn-ZE0w818Ky8_$#?+y068M3ciOuGY$)fwqO<&{xp%?Ozpic{vjuPHi`r7# z$HfN{jU}{3NpaSo_6UNP!X@h#CEgfgO1l8?m^>7}wHyZhn2$GcJYPdEwY(ki z^%;<~Y7!J9QeqGZwZqk-C)NWfaxKdqC^l!IYitj%hjmX8Dqqa371v)(b7P- z+b^+?m`T4CT5%@QxuXufXA}Toer)nqw;yWN(j~pN>JQGlPmb@)0IAp$kR-?pDkQz2 zrN~6QRc=(H;z_=mbcJ&1V#HNJXm1d1q#%C7l_ww}G?GEQ9>iMP_$!<#E&k6Bp)Vt6 zhl{iEMvI^f@&p__+)Y6sj6h2o_Hf~O=2G8o(0InHs4V*AnGtpMbHhAFdNW7fB1-8Bb? zF%?RsV3`1M*(gmrNY?6okMl15${RN!OAf@WBEc0apNrB#$1< zdOoQ9W!m%Ftpth8S@iY*EREW?o6|3T^bGR!fW`x5o&;#m0U|1&j2AI^- zlp{vb6cF(jo>pGmyzHKpeos=pVGy)f`1qMT8CAnX+lE;b;Su#)AnNMJ+3m3aa7L6O z9)-3;p@6~T7+_Vod-aq}Vs1P7qwz+wpbS2)-Y+OArL?}oF;k2BMeoPNeroujGLdog zDNlr$`KRl0Ktwi1THAY{^X4Jrhq0ACn1j&*XR`Y>CN1t7laRI zy&pRpr-lZKob0=Pzs8hzi$DDS$IRdONY&c2 zQD~;=0lSwVd~Mz_B>cqTmM^cUrQnn1oZykn zF_b-eFubmG97p4a_{Y0U&ADUl1HVWe<`w+j>>1d}5E3OVEu zkFy4y;>Eob;;{Fmef@+O-mppRl_lrv!p$d-^Xh%%U7VvKuc9prhQ*lJ%BcX5-~sh) zBr-BWE@MSjl0z#1bSAYgquy?qBLoWUtL46QaJR{3CCig0YijcU0yTqdwpjq*+?%p~ z@fQke`PD=!4!=id2lr0$82ZNkE9&qQ7~h5YopBq)L2}^uj_}tiG&FGjr2l z#$+$xtcll-A@oA+%_=kx-AFN+Goh@ElQ-Eyt&VjHq$MYn^(0?^RXzMzftK-`cB3&3 zZT-@s2QAp?iTNGpqlAjS{)8=nN?P)nj=0SquUpTT&xjJW<~`}t5*AA#V2 z`Tc^J3??&f%KDev4-o{QRwVYjML0bc-yL#`3&6q^y=>t9l!u#7vZc^W>wUvs`#4Wg zh&h=9tAOa9C;-y~%q?FBhdaL~yWQgz5o|D-dMsxGj?;K^a(`8?^Qy8t{3u2vEnoDFJ$KZ&f}Fyq}aa z%mUE#&wEOxRJXIuhMOLLYd0cO(?}x;9aDm)q>|q1kdMCxvIvO#f)3Hm#;O_+qe&~y z>u&F>YjK|4x2)S)xw)y%7q@5AlJRh!anOaLJT_(evN{wk9lrv^jED|_YK324hlw68 zpQ!I5$Dgp#{j=$X*Q;=lO2|Plh8|&M6!#)Tmp;BmEH3uWQh2oo^*xLxG>)1XPkXImbT^9 zDJVKyGWG64D6_X6MPZ{<7GOMtG7R`*P?7tJdvj&PTyKUp`oZ&`udAfEw5J=V%BQ~L z9Rlcikp_#f-&~)`A*{oTg*?lG%8}#sl>75WfE-Y5u?CE;MNJ1^ncy%wWsJzIn)S}W z%BkPN*xH@QB31?(=XuXawdo*KHIBo^*(cS*f(Rxa$yIQtJLp=H74VtxJ5DB!$r!L3 zb31Y`f1%5=)G%Xl`-30IoLviEBfqc1Y5I^r3gj2X-XsL|{jY_oM^~TdM%5&{z6YAS z>I9VXm8jVFC-J!_YbyyHH+&}%WpR7#aMksQdJtL6NA>=EmY+~4)3*Yc{(Cvfw_;~6 zIk>E8Ne^#*n$KXhS#smKD&pRIDF1;cj7e}nWt;zJ#mZmEV`5s%HF;{!2L->+)eOuO zx2&&dc0ENSBb$)q&xQ;(W!{PD+FdB(+1bSo%B47e_!W+`2+F6VV#hnnx~2^mHKT4w zy)}XiX{LX~D5aepdH|qGPjZof3LKhHpmDRN++e4CUv1+iwAc6A=6c>~r~R*Asq+#* z!xwew6#y0Z8mwDJEN*J+@W#)!bu@Cp8A1UjwbJRKpN^k80wi`m3}?{Sz3};q_nEh)JzFT!zs_E8;(NL z8Jc(79cBM)jyX{aHlk@|;lA(fe4Rt`)u;9zbB$+8)Le<>uIsBL-Skcv*!pH+Gzg2i z9zSMYSyn7MveWQdf6S8Hn#003zkAEn3Ojvm=$)Cd9d~!1p}BJ^S`n7UN}Qv4-RwC} zv64)c>^WcoNr(N;&4IepJOgnUW8m^lP8Dcw%8&JjkFsXAL_xHb1*^^hVFWlcV;2= zR{X?aUS5YJ8@L9eGa{ow>8JA19r1V_5^M!=@dy<2hM!J^^ zZ0pMAWf{ z!!c$TCDYxKR*Kt3iiYWTI{*0Q^{wFdNIhPvp|7~HG_y#AAt>rBXhu~gL#Fqgh;yWa z)Yuon0w?K7Y{~WR5VM zY3u_~PlBeOLQ*TfeB06yN2S#a(Bzvt@{8d8!>8QOnUA?H4~0R2hH=NT5l2}g3j`Ov zN#@J4HJKW92K1us4WX05kv0AVzt=noHv0~Wz?4BhIE8(ap3I2?tuoZy8ATy}SbaP|P%~P|F zX}xdDOK=>u6-I-8_EWkas)Kqm?Bi1DKBrOkA0$VM21t4hi^|)(eoZ2at?w=irBmsm51tGT}6(yZ+yU#hLoT(2SG>O9q zWn3b;CR!gUS3j@HA5yUlh@)eDoz+;iUf|rl?LNah7R$dItOe%v7yYbJ1Z|qAobD{~ zA!ytGHP+RohQKkJ+qu8#(%d1ImVGLK>=|TNkEO6NFqbA;?x*Aih?Zwc9g+2-qPNuy z$Hu6^qOcexdh*C8*qXj<>49Yzi^0JIum~ksx6K`6(ATL7@2ji6r(L9?gSYjToq>C1 zUwU*0u(;#%s$Yx+>o%)ejb2nn5>66dAWL>GYJwPx&JH&%=vjFECAhPlfW%x25%?z; z3U`~24Xzuq-M~CkL-P-Vo{i)Y8A_=L+@SnRE>OyArp!_$IWlZ(39nWY|Fubad&ZkC_2uhE zxwo9hwVNyvUUxr?Y2a=8u%N}8xGlT)2c2z~;{la!G)wHf)1Bkjwrb<6&GALCy{&{> z!aekCT{io2CdDQFUH2jFG`Fqr&Ihwexp24yJ9C`VR%n#SRDM*eF;!&ZQYnxZ5i)zT zG`WJ?hZ|LiD6+{|b?^Un)wNf!!oQ*`Er*Q~{TP@ht# zY|SmIOe(cweF4EmMg7d zNbME+-Lbv*%Ho^L2io)TcM2Dl-K93=qdr_$^{<#Udf8pS5NJt*88K~V+2|=@nl^gT z<6IH7YUex+zEN_|tNd2(;Lg>!(C*E^W@25TiwbC;!UeMaznBaFB0W^!D?82w#mu-Y z5~rrep7P&42n?m{VzGBN2ItJ2)xFT;s(0#9wcIoh3co{6Wb*=Pxcr4jDn~`#HY^L@ z<^>11;opw~xL5N4APr8|lY+GQM&KW?QtjpJOZ+cr`oVRsbv}ee*^URE{`7_oH8C2G zx0;-{d?XkZ0H#d;n!fkC$lp&TkP0V|{be`w4sJ zeg)N9yd$R-=hKouq(c{Kf%=R2n(>*#$d$oyNg$PYh`+ZEXfh7{x=F96`wGjwpjQ!C z1;aP-p?IK-1=hHJoHVuX^- zzhmFsbZt+5yA!0)grmZRl2}tx6Trw$>Ji`u^z+OpM_W+g$Hd%I!Ih#^zpLSi4+Q;*gqM&#|K3;{%9GOB`)uv}5ALArc2U~s+ zGiA@DWEVw=-p)X|g)vhP)NeK$SMeH41vPdO(zV4!Z( zxfRxVTQ-&)^2og!K_*&qpfG74Irlvvq7?+ixtUSl&YSU!>BWnY=473yO?p8BrXw=yELrd}~PqX&n z_b%<}h4F2dzM?Xd7`ASY_8acca`2mK_5A(S>f)x9s4SVrcE?ofW_dwGv3G9X*Usl( z(ZHA+tz_lO8X3%wMWIm>Oty>Hxg&vJ)|4K3Uf%Q7Ir(~5R7*x&3WMUta>Gq_%2oc@ z<(2^bLU0{Nh6_n%JKn1M#wke@R?o*rH%8@Br+&;^&d(K+Fu0Arx4t}2+tVqsB~CJ* zv@ue_c_zztlRGk$hoifVJlNHnTy=<}s3Zn4R#7^*`^S5U*~nfSnPoLsNxCs~R4ujsKOuRc+Msx0M`lkJJ$AS+MO6;2Lk}ST2)FF#nCn z*VNs>Y0nTD#QVjJ%W$OVRRpaLq81Fgp!$){v1REkfn&qUtMLXO6J#LdQufVUqDq#s zt~ThlNUs>~C1>6omm;7-Mx7W<;cUKVGDZDJ$$YC-`jp}j-*Lx$lHyRkLq5-|rGB!B zD?(SZoW)9A(kGiNcwdDq6|DA^Tw;{utG+((_L#S%L0wVEPOzH#6MoTh&iKHPNWgNrcRjJ%w2n zc7!p}{E#G|LgK1dy;iU;gJ)!mYvaqclj4?oDSB<#itZqos_{KA518TL$ooX^Ga3QP0WY|se zpaXV^y@UhhMiJeJuIB}9uQ@1oWK~_O&bx3S&RZ4w;p5oKD_rLEJyebY(5lRf8jCa8~9%f~Cl9}P@ z+Q+y&E7sU++A}z+zY(Qr7DlAIlKJxCTO#;Da#_>BThfMZ+|*aIw^%OkU$b71U5K=mFN8^# z+@R+Dc06+)wz?ngYA$K(UJ-oI(e4XD-GYf}4e{*CN*IUBekTduf6^zv?)mfh&a%az zS+CQiIm7Zs8yBR@Rlx*Y%w1nw^zz|8b6%ruD>H+^!ku@fv&qN8-Sh11I`bBTF7)K- zeRlBlsq@=iiOgvrKRL9xCjYje7S z8$(u3=^ugOzURI#39r#PLH+GENH1Hiai5cZGU|vM*f%#jc`xxw^ew}!se5e`Q`DpU z7gVw(H$moA;A@_fo&~e*>F6btA$=DaLXiQJkqdWrrW>|WzEQQG5OPe5-v#gVX z+56Teel7_N&;kr?CVtzwB8n?qBSU<6KK=R2en8ZWCd*>!i$ezqyuA03Tj;#vO zZT0~cUFW*9`$Gp?%PmnD6Nt^V)->cQ)a?E&(!W9$c8GC?o_n$S?}|I1A|&uOL>?z4 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 eb700da1e6b78960bd76ec1ffd4b7695b00704e0 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 29 Oct 2025 20:34:36 -0700 Subject: [PATCH 20/35] Organizing some front end toast component items. --- .../java/com/vsp/endpointinsightsapi/model/Job.java | 8 +++----- .../com/vsp/endpointinsightsapi/model/TestBatch.java | 11 ++++++++--- .../{constants.ts => notification.constants.ts} | 0 .../toast-notification/toast.component.scss | 0 .../components/toast-notification/toast.component.ts | 0 .../src/app/services/toast.service.ts | 4 ++-- 6 files changed, 13 insertions(+), 10 deletions(-) rename endpoint-insights-ui/src/app/common/{constants.ts => notification.constants.ts} (100%) rename endpoint-insights-ui/src/app/{batch-component => }/components/toast-notification/toast.component.scss (100%) rename endpoint-insights-ui/src/app/{batch-component => }/components/toast-notification/toast.component.ts (100%) 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 321530f..55df833 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 @@ -13,6 +13,7 @@ //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.annotations.UuidGenerator; import org.hibernate.type.SqlTypes; import java.util.Map; import com.vsp.endpointinsightsapi.model.enums.JobStatus; @@ -27,7 +28,8 @@ public class Job { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) + @GeneratedValue + @UuidGenerator @Column(name = "job_id") private String jobId; @@ -56,19 +58,15 @@ public class Job { 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 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 index d66b932..b5bd756 100644 --- 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 @@ -5,9 +5,11 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.UUID; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; @Data @Entity @@ -17,10 +19,13 @@ public class TestBatch { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @GeneratedValue + @UUID + private String id; - //TODO: Create ManyToMany with jobs entity once made +// @ManyToMany +// @JoinTable(name = ) +// private List jobs; @Column(name = "batch_name", nullable = false) String batchName; diff --git a/endpoint-insights-ui/src/app/common/constants.ts b/endpoint-insights-ui/src/app/common/notification.constants.ts similarity index 100% rename from endpoint-insights-ui/src/app/common/constants.ts rename to endpoint-insights-ui/src/app/common/notification.constants.ts diff --git a/endpoint-insights-ui/src/app/batch-component/components/toast-notification/toast.component.scss b/endpoint-insights-ui/src/app/components/toast-notification/toast.component.scss similarity index 100% rename from endpoint-insights-ui/src/app/batch-component/components/toast-notification/toast.component.scss rename to endpoint-insights-ui/src/app/components/toast-notification/toast.component.scss diff --git a/endpoint-insights-ui/src/app/batch-component/components/toast-notification/toast.component.ts b/endpoint-insights-ui/src/app/components/toast-notification/toast.component.ts similarity index 100% rename from endpoint-insights-ui/src/app/batch-component/components/toast-notification/toast.component.ts rename to endpoint-insights-ui/src/app/components/toast-notification/toast.component.ts diff --git a/endpoint-insights-ui/src/app/services/toast.service.ts b/endpoint-insights-ui/src/app/services/toast.service.ts index 6f3042f..8ffaa15 100644 --- a/endpoint-insights-ui/src/app/services/toast.service.ts +++ b/endpoint-insights-ui/src/app/services/toast.service.ts @@ -1,7 +1,7 @@ import {Injectable} from "@angular/core"; import {MatSnackBar} from "@angular/material/snack-bar"; -import {TOAST_TIMEOUT} from "../common/constants"; -import {ToastComponent} from "../batch-component/components/toast-notification/toast.component"; +import {TOAST_TIMEOUT} from "../common/notification.constants"; +import {ToastComponent} from "../components/toast-notification/toast.component"; @Injectable({providedIn: 'root'}) From 06124e70b89201158d7e6e1cd24b9278e601fcf4 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 29 Oct 2025 21:27:18 -0700 Subject: [PATCH 21/35] Removed unnecessary sql folder in project. --- endpoint-insights-sql/user.sql | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 endpoint-insights-sql/user.sql 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 From a2cbf20bd13d374e7dc27a653105e09ba66c4291 Mon Sep 17 00:00:00 2001 From: cbrock-csus Date: Sat, 1 Nov 2025 11:55:26 -0700 Subject: [PATCH 22/35] EI-242 - Add architecture writeup and diagram (#31) * EI-242 - Add architecture writeup and diagram * Moved docs to /docs folder --- ...nt Insights UI API Integration Diagram.pdf | Bin 0 -> 113132 bytes docs/design.md | 41 ++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 docs/Endpoint Insights UI API Integration Diagram.pdf create mode 100644 docs/design.md diff --git a/docs/Endpoint Insights UI API Integration Diagram.pdf b/docs/Endpoint Insights UI API Integration Diagram.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8e8f1b0950ac339327a7d4e483fbb3e35875fae6 GIT binary patch literal 113132 zcmb@sbyyrx_AQFLhakZ^!L@1JT^sk{9^4!E5Zv9}g9ix~=m3G>?(UF4aJL7UncvL3 zd+&Sik4II1b!6|g*FLAZzS@hXR+f-r2C;BsqERtF#?HnuQ#0kAz!s{-YX?Jelc?dbtPC0B^8mHqPs@81apV`m##`wtG!&M!@K zGXTWd)m%sjs9+8;HhV?_fH?meYFe4Qn>#Bzn}0BOHn%r@1^{urOb7{KqM6&9{bleH z>%XY{r2rInaJ7d3KtMSwGZ$R|`-_)8;6MFdhW;17Xa3KDApcB2)!fCw)%iK>Gf_zo zh_o8S7-Ih1mR92caJ(Qsx3~cRWCWCQu!lTPN`U~}FLTf7C_6its+mJ{fzQhaR5$m4 z0A=l-*GKFh7x8~wWM61KXKrR?Eau>$`|QdJVCQ+x69nSn(FZ&yVh?$y+E`Mc_WJ3uv{y0fvpi{lG*Q%|6{8c@RA&C1kVRa)#BS>4#g1*rC)nZHo~ugw1^ zH=vjTP|?BJ&e#@c`n(DM>GTr)Urth1wh(h?pp@)L>8&f2B8)p~b^6F4Jf`*#^ z_Ufk98wMk#Phz7mdv1`?XsrI?^n$G$m_t7Q>;CdsL}ND;xp#ifE&sH`rtmXMB*E^2 zCF5qhokmy1JGjk)-iXV7F}mxLp!ek&)vas zo=s4NAHTr$#(w;+0%Ur!I|!^X%6NaT{vdp~Ybolf-Smm8$GGvkqGqOHkwcWxa(hi@i*;H=8}s*4HwhI={lkQR(LhKWtw1SA-#m#91F5XY2S0-`ti??CG+n! zw63di1?;l~9+W4K;;p!P^R{!RE1vT2n4`^Zv7vmLDB)DfP^{BW@2yzZkRjsE5TUB4Dp#wqv z-Rim~%#NkDaI(gvJJk+{es9BPB+ceK0J{&IJK+H}d~faBxUn4!KZKI`TR|Ion?n*m ze2s5!GSk~cA-80o0#d&AxV{a1FYhanjVEEkDNA+kyMwo%WaC;^$=;ARHSXW=YJI1= z#(Pb|LOqjV_Iq0zB4r1BWe+vFeVTB4r*s2o{)o47$-LU`dt&?_gS7hhHRi?PPCwW> zsN@1r)U^yZ#%E_eW?Hr*a_7aiPRJI=4k4Z{uV|+=&=?hi$0@e0ZqW526sm`dvSqA$ zLghF3rrkc<#w!+%(-GtHY;fpyXeI|k6Ol;tz*fp(u_kYUW?f<#?|9{5kFn6ng#w58 zYy00g0wJ!0N3ZM8h&gf2R3FqfWg^Qjnw=`2_`qKid8JE3{It_L(5pCK#bk^m9yMY$ zxQX$@RC0m8a-DN5O4jfyxtfLA%DR$=m2x144`0s!4=s-bI0yI>^5GpYY;wjKoFZMP zzA#%BywOQo5T)G2M|sR+UlM+!%GZ>$^Bv+vjE(z>Grv0HMikEUGIf>XIKCz`I`Sg) z1N%wVeRjT?90CHaAJ!|rcWLLJP}^OnB_I7i9W1XNQIJX?qcZH43eY^TB6JlZwe0h)vhH~Ct(Rd@3j6)u18Sr1g!j~JfAG9%f+2bCZ} z*wH{JLO$-~0C5oteldf7(0o-7-xRUF?ZaOGqVvVjQ zU{;67TZLds4WvR(otsso;1I;VWM65+!B4@6N$b_%u3AoewfP&Z*$y#Yl2E{60F}IX zVoa7zz7a}j&b6l9lwWxN`FT(9S#|1D7!t;8&gw@S0>@Hl-s1&z3pPvg6~Zc z9XmjAsbZB!n>Rm_H(eZ2&~2X8o9RVD;}XUDG8qrSGh_O$i>%W>N zBB{v2Fn9mx_s0?VQ#Vr0;%TI4!*xD1SGmiT4?EFE#Q9YuD=Myl<3PuDSqAyt-orW~S#Bq`3q5|J3rgO@9Xn>@y`7rM01;o>s6wbHHA{hrXhpsQIcV`BJLyzDOq$31J6`d5;X`@N+;?NDYK4rJ&Ku9=3{<@ za<*V>8i|(@KRPNkfz!mh>>VOv{UVG{_Xs}SZ%(Qj?Qw-k2IiLZ(vG zB+5bXbcjPlMAWlw1v996_U*WaR1FScA_4>!0iEXTIT;pBD023J>2ushSISy@AK#Ul z6jD?Fv7_oFPnOJEkm!uMHMrE+DlK@47{Yd<9VqZB=B32glcY?XvUITYPIm*gnKe zf*KRu3#y0**J%~PbYJ+o-_G#y@)Vd_sZsR16+ILPOMAieAb>?}87+J_;q-b%>4|b3 z4WOh1PrSkf9q<&Af^!y#a`Z=Ita*jlF^-8u>9M@vZ_5Puc-szyz67vE$!cT>prlDy zdbK%snDEB27W6URYErpeRRZ5dMk~DcZ391I^T&E|ctRzd2k|KMU0+TGT-b_6SaED60HBuGR-rzur({vu&0MyHe;!3c9xNk3^z_-*IozN+-VE~%EP zMg+&cAELOLL`GNT4_0@45o(%FMuj&LG|tB95xU;{q%)xQ^j2ymc>hl@T)q=6u@m*Cx|YNtw8-YE zGwu?`xmG;XlO=vbqCfAXT*i>8?$WWglvcNI!;GuPct>9^19;chE;7ni(eQVdhHa2Ess9~SyPjme))>-hdveU)CGFne={rOJ-mnjN{QT>$r^zY_Nl@rZZ>r6{l4KrV;m zhtxK4iuZc)rr<_!KxE+X;}r`ys``;ImTb}>DF-5al*G~LX8cF&v54(l>O!0-6F9=c zGI3IBJms_xu?SX?KBGI1K4GqRNUTMJq6DwyHg)7gDvL`*GZje*ktO37%wZc$nHnY|)gPfgo)L6B*W{)QpERSle$#2i{x+cr`lgtK zQ(?E>d{hxgsPZKYutYM**KQ-d`Wh1Ja&t-$I%u?O({Xj}k~p<7m5s1ZEQFo}2%cdDIu9bItDTC6AcEzXy%g|`i88Z>!&e^!$HlSv ztrCaiTd@E7iUj@lpZtm0_!@SPZf=uviXHl=*+llf;MXOQEfda8L+7N>4FTs5hNqQU zdaIHxCFNTPUj1$IMmI72ZZoDKNCc#cfs9nJW7Yd}&Yh*B5cE`*n2Fb)i-Eo?b8$+c z{s+K|bL4Jr1(Ym(7eU4WB`4`m6c#yb^b)+mzsh4qf_=Y}=M$*|t($glu8K8Bt)#BDawdpRlZVL}?<%0DtM#Y2={9y7y4jCx4V&5~Qa)FbS0hJ6%!*i6o0P7jB~U ztLlQbk4m!;qoX!~v&z<=T!_e2bZ2;#yb~q7|&M)?~l0syrMQhFf@gNa{B{?@VEfeSQra zUSFV&O)=YZDL4f+J0utK6EK#T;i5NpC8UV`#4miCtiR)C7G)g9i6KUQ18~0crgn5$ zAIFFpY-bE^isgD||EVNt##uDZ(rp{K55C>-f&vQ6cpp=!f3q;(~*crgi z_N=4)vkhVcJ-eGhET46dzlz3x7U(aI{|nE51OJsL{*wH^5qqxUEnHs0$UZALR;Hr% z7PjU9R-mYh>5CA;%gOevCj6t#%*M(3Ec7_am|Iy`LIB*)JyAD{zk~ps&(su*J^s-F zadWW$ji>bwOAc06;QN1iy}ToYWoe$Z z26Hw5=&#)JpXt6RN&l7Zzci+QtQ`RKqNBWMP%h6Z(2HF4U!u|9+5d63g4mkVahMo$ za~d1-Fq@dLaWQjnvKccQv$BGiIXT$bxQ&guP0dVAS=?OAJf8Kef2u_PP_zE-)jwnW zXS{z(P|Tp01%&)lg95(S)_ErLqU`{lQ~b}2{<8dUPA^gxGw9y||C!YPN4)=3#>AiX z7>JdFy~ML*Lnpz<#>&PCVrAn6aX%|I+{~=pw5+VO&v6tS%>JJ({)_PcNNBnMHnx{9 z*S`)fo|o9{&s+NPc^Q4#S$0;g=PTQ@y7ezxPS%%i*Z=9@#i`_J3f3UN(V)jTOMd`65wqaj^k7LEHc?ZuVzi_P_QVJkNcO7e@cP zgX1L%+cWwz;@>MT&ok5KJ~!xZpN;*QJv;9+66f>v`PSt9hdn17_uu&!A9fC206Q-T zz|PM34~*w5xnCGRGw0xB*9ZJtuKar^|7(~3&q5lAm7R-~ z3C;F2OA`Y(`~Co%Szob@Lc;Kc?9^02E(_%e-_>}JE8T)P-&YarxB7Q7JAUt+uym5O zMk?NWoJ~lx06q^>qaCWZK*CLDySS0@mOpx(J#eC14C~%?2&I)(XVDFCR=7zHvulkw z;&Q%yB+k&+S8=^gu`j7O<`lDYt{J}9|c>DnQ(28oqbMK*K?hjDW>wvh&Y`;W8Bd&JeGGt2)8p0zpXTE z&a;=Ti00bxGe|l?C)5JAZ?9z`hrb!=+kO;xW)k}XymtE9WGOXtOi>mTT(o?({cD%1 z^Xg1NiB!O`?Ck;LAOOExhLWoR{o9|hMTvD?Y;vAj&I3kDHIm=83SPDrY!yNSFO)C`7Jx z`hkVq5{{K2w#ORYCss2#K}w}#p4~A?%6ycxp)fA(O=$q#>QMBwl{Ww1Y-96@|%R_=5nTGj|^*MK37w<9q zQj`{A!y!TYOrIY#I<}=3BWwKl34fU;&qyJh3Ju#e_&!I$`}o@cvcWe=rtZSvt5qUA z;we9@UU(#IPfDZw9d$8DwbZn_CB0gB`!`uZQXDSFwbtXVWn9PORc^)O8wJ5>8+vxk zJjRQ3b+>}nF+_EOQ*uBvdEIuO-Vs27KiS4Il8$qW0o>RwRbE1~R{`(EQ=9|7iz_ZA zC_U}Jj$_rM+?}0`b1iw0_RU|W*YJ-*dSsY&x_Wc0U*6!6&2?|!k7L@C$v4Tnky}+G z`}2$6B*?Q`KxHwGq4mq*h2M1Q&@18k#9n0Q{=11BUap!PF-cl$(~jToJKYG2NfXc1 zz@TZ0-~8*1xTf~f^!dSQs}2u-?NOq_z?*M8=C_SgKbB5^Ta*C{$!{;Fw$;yMCYw~M zFHxthH!;bp8pr5PSY4-CN^=$n)fpMe#fEHrUv2AvvaJ;oaqr3l1q#U)!YID`(&_q0 zD9%eQinu+HfM;h^-R&snpJelIUN9G|iTZ)=aRuin#Rxy@7*bQDsO`pkR zr?WgcT#7p=iQTQu9>MF7t%wJx4HpgM zHyOu$It?q2o%^!?6Z79@|H@SOKGnZ|6~f#dCHs~Z^g&3hEB)- ze!u(vXfEM)NmR|o1`q^K+^WXM7FATo5y%iTI7Zvz?@YrMg<|$T2qQB-Fiy1LF~E;` zhIaG9&y}Pqd12E_2-EiPX}hG!@UG35j67AlvW?kA~nIjzR_poRb zQ~v|jfKFPDqR_C~dkntDAo3)#m-*j#AeT!`@oOi7oDwhv(_X3J32;Lx>vC-q)Z zdR!Yq_)0Q8jbL4n#4I;y@Ibq)NGP( zgTpAI$?rYx9q7ch(eH)Wqx}|}i^u)l_OxJZE?h%2-FrA!@1=5HEwdB5EA*{g$+n(^ zN^iRV`8~@0@U=o=IemLl;G(D?w=K%}hWw4iYMc4RD;o|D8ZDCHiKSiU9>}2Bv+|QrAf{znMmVeZHlnuD| zeEI<^j|_$@gA;)Ld87Ru#~EXL^s(ks3~CQ-0cCT>TfQJ0BC(iNrjRtEpNJ?oXZ<^@ zpY9*DU5LeQM~qN-Ltw)Yjc(fJcf@Cd2w8jHY%C+;?0zJSuR{+%gSuikz4bs9*F&tf z2=j)@1)7g}Lk9z2k0}LwMFQ;hXJc$?J6P;JiR!JwIOZ-Gg+K+VO4*shwI>;G? z6}y$r-yJ)!Sc{jB906c+VV`hb4>~K)=#|fJa=$HU2H$9J9xtf+{87a)ET7cXXUI*M z{PnvIJ5>%;ZQ0QEjf}AB<92pvi4Lj3a7pSwDPkxk1#skGy{vY_iCFSRqCS+ry-SJ# zLkm?x##Npi|9mQI{0lf$!9{9O6|6SrS18vck~>r`B$uhD$rE!PUgI)R?bL9fj4f^8 zYM+Tko;XFiX9QRbzbo&rL(wB*W;%6>(bVu_&}8v>GeN|1gP%w$l(BcrSCL(;IP~^P zqNbgjcc%A@naPcSAlWmzlq`*!x-Y3&C{a!bEZWFm$(t5*!;ebsr}4-=aQd(mtcn`Wizm>C)SL$P!qp}U+^mm z^ivW>9UYPnTwHC-(dcIb7T7;pEg=NzoP3ery6vQPX+3A>GzV<@RPyPV`lT46zhq`_ zWEFh)%2V1r)F#)lpQW~OVIww3KgweFCxY*Y1zCug$UwoXSdD=63xHig?7K>q>~bq%AfL{^X#? znGcZJx8d0kA2olUDbY;6+Pu3*<}uK|NQrtAttDY5*UcoK5zYJQDp5c>Im^F7&VoB7 zbznIN52SU%?9;{j(I>+N!HX1cAKhKk;_hA6{6sGGgZ2S-{G;-aPdwdm?RLrH`#(KW zQLi_tGuIkR29sh^W@hjl2F{uuis|3WsRgpt0EdFPxm|t{^+YGog?cB|MUTg|#st0Gp4 zKlD~?f~S;M!GNx?0W>l{I_Fb6)2+rWizdEWHaAF464Csg9$VFt%FAG1Lc_czXjcX_ z!`7+&X>m?1T5vT<(+;piP)6mc>t!vW-VChd#@o>S6}I9$3MvgrNpaqdo+uH4skXb2 z%#*gV3t=0BDGd4yH&5u}Af{!$*UhG&g0A4q(lw$=K5X4nF0$OOC8Rye3rVHbRJIgk>x?-+l5z5s@-c4dewXOT4L9#q zDK1b^bn22Vp4vhGL87K0*~pG~1Q2S0KiL};%77aTv5k%%k;<*JKf{eWw|eh}m~i}U zY`d~7MSAaz=f`TUg`L8--o2l;O#-wt&YVlV?@iWfFNTwV$tW6cIIpr<@{!dkUJSU^~pE0(m0p9GR(*n#K7iE3d25TEe zk@2quGq)}j)Zg}cC78qymNt$`6t{bV%7-d0>Gd@UjXajqG`Qv$)+fR3fqkY8z&HiF zUx7~S9#L0u&|AJ!`@)sbk_FZh+KbI$Q3~x!!D2ztbhFtUjAQSl@^1>{hs?($Yuxbn zeAk@E6>C~hV2~l4FoGY>J!`g>64XC^YATbZ6fe^n&P^?Ad zbuB_O0xRXE=+tsl)JM{VdH$}19wLFU3vgC(Pdb=`u1+z$5nQQa(uh})^uD|YjXH&~ zPCmRCJjGZi5za?VKwqaEjtCyoGReVf1ZU;<=uz*`fpo*Q!Bcc9RT9Z~$>1UN0JM}v{ctO=yoN~xo;f&)VO?VbLQTWCu2LWoZbD5%uTs<_K}|ykG6;79M`;u&hl9ZQ zbReB@74Q~ge5ph=9xvF85u}Rx6;B%+rM$sG9l{8b56=fw(YJ3qtXhllMv_GVsd55RI;K2|{YsBu6@9t%7>IelSF} zt3*PYnvX8NNWz;MFIh(Uok6%8xQ8CM;*&-=5p_XwnCd&#a1`pUWE8co0*Nxbx8Ss7 zR{DI#eEPRZc>y?8U{cMEIJ{0eC*^Q>@V#_+Zt}HkI7RZcX!ud`^;f(n)eU~?4vh^G z>JCklPCP^UlVZFl^$m1tA*~H|Y9Wn{SJXn98J_aGLFyHa4KnH# z%?)Pi74;2FY6GnePHF>77dP7LzPhH?XK5t9uT?OC>#3;H83|VenE>Pc(QbuSb)*T5E%kx>{r74Ry8V z1_O1q`UV;`i`E7kHH*du9yN>Rh67lys3#b#m)Ao;&Bf@{fwxOPUe?nB&MNGoq2^+! z6Ge4B!2LBxIaMcJW*V#9N1h)eC`RV}iSH8S|7C)JF@Ho@EZhGR-xG@KBkr$z%BdUa zva?v_U5fudC;m-m0ryt}<&>3l*<_4zDOtWEzgV{0C%!`z*9BbO#bV3xkxO@S39YWW}XiD?mOG;9h+Z$>#{teDi^P7-xi!Kgzy792?6 z-~32A`uh6g>3vgOo?Z!QiETAW|KXCn_L4oqq?LR(uQSo4vc1qJ`a~<%NkBhzE8mhdw?VRsQ6_whzA#f6!I4gvd>bT@obetdF3^qee8VohFtp zc27q0ap)0C67VWkw0m3(M4j#HRiCo7p5AJp|}ePUEz81(S-JV>}4;ryF$ zInlWT%R}Toe~ClO9}*Vd;fCCO#_GF-{oI55kNYkqqiI6haZ1hSpj4ra3E?y$vxPoa z3f6C_Y)*bx$_FVn%`txSf49s^K<$>(qdd}a4wnH0Ivq#wl z`_7pr*-1}1Ce;pn682F``qIfwy^<&?)+f5huj)no{5SBG*%M>;?MvFy{+KQFPW3=Sg>mm1ulX%nviG^F+miPQN;1>! zOpaZIPw9E}=`(k)tVJw9+51Yz0dMY6=c0b_`F8=v zbV@jvd^o@A{%DxURYuhLKAg(VY^pP$KHBi9JVp(;vlE#{aQliu6rE?@Cnl>;eGq~) zsaL|gWdD3K{?W$duKDnJ<0rTzUH-NFwmf@U$+N<_nWJEy(Z#LbTP}AaW>7NO(w2B5 zMn0n2r9Q+FUjCEf2ub0*Z;(;`xNmS@aD}3ioMVJy6`dnR94)=~deeyo2T||MFh@l8 z&Es`ro`C;7jdHcH9~oUA#VX9eW#4v?6kALBmo%yC&ErQF#mxyve_MLX=bP2PGV6_a z8rwr%p-9j}oxvEu-nK(M`C{{D$K!iXQ{VeKXS)xq7dt!OuF>}8Srlgy z%aLh+k4#0=N7GMJFiMv*CMLSLt!L3^4Sm->kk4+>XH%o4AyJbq!Segfp`5VFM>e(M zkPQnq0~ zNlSMN?*=7bii0#R4fC+!kOQ8eSL{B*u)=V{Fst294r_*GcUapFferyRfe*+2h(6)1 z{ss4l$GpeH$5!V|ceLa6hZOJ>uum{TpByNz;n#fE1d~<~JK@_g+Y#G|T|*wC%Q`M+ zzt>1kFBnFH>pS7vF&D?u&xEv@+3^xgr za}GWdEeJ*U#KK_1|Ctf=@I~>!@c=l-KRo{Yae4Rd_dy$F>YO_)GgR^V2=R~TGS-w` z=qNS^ywKX8u-BJu?=%;VqTbuB64dps;MdLgAs@T1xvg=nX|AP&8C+(5=Z57j7{*+y z!nK&I3GKkT)meM9R}uf4VXGCdr8?lqd5vvNbq#OLWX-|S9rsk9NNB}R^cvwVU|#D0b&?hp19xZk5HtY^401b`H{Lx&mg7YYt~lqk@e9u8l;`o5hSS&Wah1Do=}O zeika9MvGz#jY;q*+9=!TX^DBx@`BXoajxc2?-Z_SZ~2Rp3%OqxN53W2zWuBs^!*hq zbur^5{f)e(r+!XtwqZ^@l9t8({eiCSn;qSDhdQZ9Ne{W-Ifs#U*=OJXC`1-k-b4zZ z3!rBbc=xAOJEaHrFw;Dw%cR%TOF)I`PN=3;alz%#l@)%t1}x6x>{=NY&mWUR2*BPUKxs z8{=4DXJB7V40Xy=oKcKXY}L3l_o0PE*uHCv9kyGvxSRR#E_1Z0ucYq>q``g4s|SL^ z`-(3Nk!;4bIWLGj@mwrnNAXvD4#nhS@FhjVQ^~jT>^9%nm763>1qjtI)KjikeaTxS zS;>P-?s-m&ml75_e)1o~WNT?HJy)s1qKnW&ai}T*%OBIo-hI!h5e95^%5o;+5UPe( z)cz1lMDtAvSM%8*HMFktaV1Q?5gR#)FSU*MsvX8$+$*ef{A1AH&?Jf5c16B_KDcAc zq{eWym6NxvVQd!AxM;VhS&sU)xO(69!j+}wjw523@XoF>=Q!>Hv4Y?grHP4-DTg}- zAH^%X*qJRkn}$CzfB1QXX$HuP`%`D=1joq`W7|9J<6p_0n!Y>0&7fs85&N_g^K+oz zUHbf2;#0-l63bCy&4CUZ8F2DAJw%;^~xigvRm&S(HrfRPd z-a@P<3EtG^#|fIp1FZXt=qLTrpPGlZpC@MEEnqF+Frj=#%(Y1GF)QIT;hqa4D4P+z zGBy+JuK-D?m=P>1MH=!HoCSrFt5^Qk* zArDy^hDQKi9#I+sKJ2SNRH%m$DJuaTLN5#=)Y1q)4too>MFcAjGYVlzgn$)}2a^a3 z13m;!JV4%vl@)~u1q0?5{vn`4gaQD+hj|V+0sH(z@;z7xax1Kr2*cQGM>zced?@T( z;D`v@7-}snSs<1O-Wa(I0$zYe9vKdD51b|dDGaF~Kn4JZg4_jzj)V3I<^z=D%j+7x zJXkouXT0O^Zs^-J!Zm;+%r*8g`|+z|%47Ir!QfMXa(VMmHuk~zc%82&)-K<@xDs2lX} zGsk|7WlhnDWDH>m_MWVntQoWUbu(f!aWiT&ZZqs0njW?uvL3n~ydI_=q8_RqtR9vg zk{+5KoF2wKW;04NPBY9LoGsD_@(BE!K$k$500pR&5xhpUEzbKVxZj3G59pQtffvBq zN5q*<=q=&@d5{UI78&IL7S1~WN)Gt{xB0KwcW^d_Mu+H?zJZOv+DpWl4(I~m`{%)@ z(Us7r4b58%_cfrsy4LKD5e>6d$bGD&ME)!;Sx4H!f<%U#>r_BZbVE8zN$4I~hV1YB z>Y^$L<3qO3Ijf+8!7L!NEOiH=63E*QmV!=WAw99w33CYI&W~xxu+z#Q=9315Oh1;F z@;6KHu9tZK!reYnM<380dX8zusy|pMc+BOw^3dQd|4i zUT8K;=u;&Za|%+gid3~UmBsolpuUpVo{FVMuvW-RTCa7g`KfTFsqj?SfRlW-fjGS) zIzhe9Td4$CXj2F00IeX^D7XLg!S!4I;%yB-UfVR?ss#5Wvj@RfVlc?tKDZ;=zHgx; zD8rJZBj#6A#0^X9q=&PRHI1-cDVayY)r6uo=)P0K{oT`Lz^Z>weWcr@Z`xbz3GxGu z*flv%@_?iKfGbdU-?sFgN|HH#YEB+)ow%U*=!DBToM6m^!R7^hl0i}G{%bFB3DIv7 zhHDo%-z*=~3bvmRIgfgI48=mVRY9+z4xFD!gHro-?^-+o=EKg>Hj zsq=jtmoYANoD`9-6aXSZiRm2^zK}fM8odt)oo6arq;hDieHT3EZd?U3h}hb8QU48eib)Oe1(^|$&}K()$?g4LTb?qOwJc*2X<6v}x{Ak*m(@?mURQZHjxQTOBXelKD?UP) z)yh6WOq?n}KVo{Rtf{HF6!(^xc(UY269pbR@#1Q0r=jgrhwmpFowbM6?Jq6-l;&o? zt{UxJnuZgL@W^C0#Rvr2r+RvDHp5*f)y^%K=c$?P#*Dtlmiv~eDS@VSKXFdmE7=i# z&~eZBXYamuPtMJe&0#4 zJe8Kvg)HN}s=FCu7}~Lupb7L`PS;D474ENdb#I{6{=w76Db*pdPEC&o#EMF~&-8G= zib1uXqHR{mjl+p&37Rx_Y{gJa{l3q}-whxSmt*HybYheTv`w_ehB-Sc9{>_dN6RU zi(`axernVcStq-5yXzpJuVm|s(Xmk}8``Lme8ZH*RQMgI<-Sd9|tDx*t|16 zfGUQT@`ft#oUbS!Io_=gDF)H@KeZT+-&WC2ED_&D*Uj1aPN~zC68yF z@%5j(f@K$dESXenbior6t)2RH`$FT1NU9X$ZM?Q@T)A3Ta$y+cEWdxp8uw(VCFn@> zUM?iLSuwx!m9}f!2rMBOckw=i)(V{h^R376;IGo;#1v<*_u{ztrDep7Xyd)*#e6Ij zVp6hNOJs&^!`(;UYt&xOqxZfxyqstqYB9gziqz)Te#bQaK*N;5>gh7Q7MM(^NUWvB z0ifX!kXW@+h=`0i8XwklK69U4IUZbAdqpZ}H-5<1`@`LuaE7^1ccnK}tH#S{`Da5^ zL)_fRX(`Fi79ZFhXVUq7G*cQ{i{dahXTPSzv-;&H(?eb8Vp+B8Zn3AORP6ZPgP(Q+ z^WqokzL@)jgX`D2iYEMZi8!=WO|RhKojf?UR8Vjxkd^Zg7L*qz7E_v*3>K)|HWCPU zu0m87oLN={_xJ74eH19-LZMlL9`%jpKlMbh9{ne^y~jQvnhL+ z`egn3v5|JOy5}qH{So|WK6Vm)r^H==CESFFs~kIjVj2E+l06$_OdE&H{@DfR3++d~ zHFGErBdX4CQWT_R987*5hRL%@`^jFXWRV`~S3rCmLb8NAx~iCO$1n&Xa|6HLM4Bo6j40P1K`=eO|YyQZ_onbWhRN|Z+VP8gdNd{2q_Ae%tE_<)e1X9J3t28>ndprDd^BN ziY0OjuLa%T3=$;;@28?9!YW|2q^0K*)T+JN$2K=OaPQcL>Bb{`;OIA(|I}oI=z-8{ z9lA7TtR(H^&q4By4J?Jp07iOE9rVi=#m;IPo8m}-;0SX_EX89Q?EGXpnd28scm*`xd6R}%?6>?~wnSu^~sMg5O{dk0f z{jw|~4NF%BmK4fXC^jcoc-)6ErMFp%@fSM2CW=Ld=-~#~-M#2kAC=nb3v$Y#oEq zd|(V?gGOyNx&b_z7MPwE~*LrCoHr ziw>gkD&d*LEgjsi`h<+`QR5*~WikxgHtFyIND42FCF3+h+Q1L@A%#ey2w7UuhzV*J zNWB2OAKd_5{N5N3zCvmBs4R%CLA2DZ9mY9)MZ5@Jyt1V#fr}{*FlAUvL*sdF3ZnXS zUT(0{!r1Fb2r(LXsC?|409n*VE=jA8_sl_UW-J|nPq0ho7>gB_ zdIzRUImk;nVc1^h`Nn^!a(bw6K3m6HPRU?rsqVY!_lb4mafi~z_9yhlbhVZc&Ft=C z=?@_(FR%)y2sgL6VYFph2MH9umBIO@EnPN4Sb0X0ED9!FVQ=>L6|}%X6osT!0$#JG zVVE2$Bj3E{G2t0y<@|sdFwXZn^B$(2uCTG}KTfEH3+o80ZF={nwQ|=h;85%ct9*AX zc>RNM8a)WBbs83juNl46Hd!)c9VnJBVB{uCit8jR+}v*)D?{6#UrTr-*=gcLi(p_RF)>b2O!sBvzf!Bxr{zWkK~BH;&h7GRi>%;a zO=8D|t>q)GqnN+*K--~0k>MN3@Ws*4 z?}2}Q{SrPO$oE!SofIVbwh=iwX}F|Lto;=H+g+#~BqeaZeJ0HnEqFOn7`l45QZnxF z*ymn-H?Gg9x$5h@crm)f*?hY~@opJ?YRDxZ*}f9*j$wk`SciyLbXS1Y{|w4JpY|R$h(!V2$m1R~7^h4F!SC0thOzRvti(|K3O%zh zn=-1KL1iZ3QBOyq$sPQ2OTjG+m4!vliq}wFQsr!ZSkT+H9h~#H(CTb3(N#+GF*>81 zZ6vUkQ}+}gFtbg^Z0plBSkCKoWSQX)OqsX^gQ9w+>pd7}y@+8{S=aH)+L>BL(sVkLgVfadQA8ijBK#e-+eoC?!xkzt5Z zRj)12T1l`QW`#LasOI#a+#GV%=M5U~TRYOynSI3XASASKJ`yM#N3i)8+ z{i!h|=Ytrvmbhw<0fNI;4X(P|G^3WzwlrpieYuTRW7RKJ1dI$RXM!J4@((y?A{3Yc zN>B@yJ`qhLs5pFN<%VrnRPRC)Gne`__mNWN$9r`vhR+@(#I zFD$^ceSjC|n#L@sq%wqSt$YU)D5R4atYBe2#tNfLo;qDJc9;AaMsk;s+NNKQ$GXu| zwvoXou~M`BEnc)lgeQqoX19R_bV#j@iSw{R*PiYR;g*xCZ>Y;!$m?{)fX4#4>h zxWK~cZ4eD@%Hi#TM_)`G0SPt&Muk$JhS2k6(O@Z?qa{)a)qv^9|TwfKPI)qaWw|ii{7n)2hO8u88T-gue`i zc8RrL4>4s~G@zE$kWW|82A27vf4{R`@<223NxDo^9{ELod$Q%z#aFWz$$}dG*|5Op zURmYrBPFLHEq0zQ*>?Gne{s(#bLCzNRq+CP?+s7bQClEOlxcm$XvY_%EAgZC(Ekf7 zK-9n9#1CodhbqYr83~`y&Wh!k4q!26RiUt|P*_zctSS^%6$Ggo5iATc zhn*0!S-UMNM09fmp5<&2KX{*yXy=HQtPzV;c&eiB!Ypmdbd}~RHOqrZIPOT;PoDu( z#FTa#Useua5!DD<2+?=t`8H~12ea)3CF^K_Sy-~BP|2G7HA5OBfk1_sCC3WcVM;57 z-JX!1VDasgV2^qoA-joZ$xjmB!?(3soqCF-1up+5PGCum-fbbD<@F4SlNvDZ_7~un z4P##ezrIfS)vEPYq1HAE`)8UbaX~e%+nwUNg~A_+RF5*o?Jb^aRLn1U$j6iH>MjbB&&@ zTDBH{IMf`o7M^z1v=#>9owfGDH>~k?=oPNL_$GM=RfjFXu7M~VwPVpaqCKk>tnVj0 z84uwJ&fzBDhPM*$`s?fJiTdg}e8~}JJ4_45^XL}{nx|p$_qfOUzU&cE1FbA>ttZ}< z>lt|dQ5iH?RQFv_J^7%zFL?*+Y*;?tBp@IYrhN_BL&MXne~2T@ERNgVPbfO0)I1wEAed%4_AZ0@nQU1AZ}4z0BuGY4`Xzh zOb!EE_&H;AN}|Ka6u!wA9cW}1-y_}xBMV`x(8x3*9GS_@^DfNo(wq}T8}+__(stFt zqG)kg22pcP2Iu&x_hod}f&_v7uv(~jA| zFU+t;hh%bsdtc2egJsE|20Q_H@AAbr$Tz4Ymc^#gs*2ZZu)}9Gv6x|wSen%?3BB#m zfS)=od`P8vs~OCPBX!$V3qji`GX{7_SfI@Ej?mjOYS0W@%*f4K>tLIsa^3c#t)T4q zpt59WK6qL|sGu1o?=DU)@(m5cQHJ{5wYkyHZfo4=IEb`HpDks{YEjcYV~O4 ztyd3k0(_9w7UtpCrC_$IsW5a9+$A9d)*#L#6$>A$ zni9f_AGtJae4J_^`AgOUtm`*31uv>61Yk8VV0ZYimO;xYNx z63-{_FBgy15#j@2gcdBU*4e=$kd$m5P>fAE7B&q5~dS zv%Of%`a1=>uR51*>UGvtdce&9Ey^*h*%fwrV>T^-%jxt>EM7Z$-HK|4*YkqWX0Uoi zMlk5aa7&i>Rq5sx9^3=&aw)b^xyul782?7?opZAHeYz8FH?6Ueq}TieCF`{eM24G z9R?nX{CV-V&fms}F2xR-Dd20DVppS;f@(`m6j0(~mJ8|<+M1eRgBJcJV!h;rhrVe#q3O5ox;psMaZtPW_kXr>s+Z*%xf3{kj? z3dJmL6Gs*f6O764aQIA2#4h`)19lFtr0}HR2voYaI>Pg_X4m-xf&j=$ll#tpru6P} zK|d@2UT7eG;;nM>{$SBRy$LMRg5{8Z3Yg#n*KVqA4*waTqPua5ud>0YLT)RkqUxEl zeEH`KSV%(w5XB>rXvnOr5G@)lNc(N6T5{f)wn&)~y0O z^_UFLwP#Mn=PQ5e{JTu$5AYFIq81tLX*GBUiapL8!TQ+ZCfT}3wM3`X9sWj8L;H1o;$E??=|bgwH+3^f*4qP=vrXa&#&6> z1-Z3z`T^Cdj{vKtfRdV2e501I%+wp>Vgvk2kVp#>g$8$Ae6^*;mVO7$ImIg#Gq5vB zn1iMN9fb!|yhVIf250)y@5t)8moLvnFH@c?Wn(q~Nm)ilKEhfm+^&E{OO6`CbsY`c ziYx$f(Rt;aSJZiyg061{hW-N zOR&AVl&33B?^8;_>s0)M^umYKx`^{FTR%6F+LT*jWHbZ;v@>7Rv$?e<*W`=$tRG+B zTh*{(UtiVmvU)xGoMX7kw&7GXTWzoIUB7XCZ#5q6zj0-?$>lckMvKwxF>{`f$6DDE zsccJBRHeE%cVxHsR~oGj125W*63FkI9;c-u<%ze~Mq|mYjnL1#0l(S-zx-G;@);Nf z=yc3#po}2K9&=4Gn^^laQe(_;Y=|V zBF;ivlfS75oWH$f^;=jGWJ4~IVU!&tUqx9&1bDQpl6m?G33y{GVV>1Sri@drc~_h% zy5d>*0W28Jd*P2-Fe2Y*yY4GnHa)VV8PM163`lIKds8~Su`9qze!IsfG5EtbeqsC4 z)YOBw64ONqap958Q(eJe*Th<4N13t<0AybR_a4LsP|X0d0qXo^HfZx}Z5EIUWwpG` zGiBANir54KD5?!9g&|ZGI#K%Ej!uBnv$$Rui8MBrse`1|N;AZDl-3sU1*_}=RrnH3 z8Nz;F$jp#CooI^Is!3Xmzj>vof<5N*$DII* z|I6T}h~FEK81g@fKa;E};Bf~{4EZkpdyc%0<=ucGf5}zn`@XE?ZLxC7is?K$brEr!QtA^>}sR_kzY03weSTt>#w-;WVX{fLe|iXuk0KcBfsZA%RfB zKWVcS?qFfvP-nN}FXKN$)kGk!3y(UT_(oV!r1eg7Aq}vBRnr?#)wEaf(l$amsuP^> z8V7P$6N;=l8t_C&3YPe2) z<&{^oeAGNguvxQcdlqXjHV`e(G~ieR-q29nQ8kByDe__v4+bgE>$UxDFA0MbmMHEH zL|eMXcWxXnE|{N+ZyZmnI|Y+qer+6gWp%s_Z?HWp!~TM(L#vDhaVrJ-to6Ju*Y*o- zFUhc9d!lU5All(IuIP0d(rL+(GGUZjqKYB)9SS*5Mu}`zSZ*ORMz_o9)3@9|)O*8F zP5X`C-oDRTzarhXd3n9S3LM3_I!CuQZ2t7>$T#lkn(XwgTh+0n%`ONuEePu~JrzA$ zJ95+g6+I2BmbgGvV~q}j!{ZEjr0O-dtUhI{$yD}?banw79R@b~g=QyKg|%VFQB@vz z9c`ddU4v*;*CZNM)ZyO<^~O2;-K@(JhwIXDKiu03tr&+ZAaNtw@=Iv59A?orE(uT? zAO|$ZBmJ%(V=fI2k7x!_$N`41rHc#n@pG6XokrQswoOAbCbcGKIeeAk^P9E zFhJxpDP^^Hd|b^}VW}wj%IVW*L}l&CzayTPck~*Xea!@xGv)|^M7>XD&dQ+w_2>eE zT)G13K%jzaARfbu#4{;n-4}R|aV6_jJac)1kML#Y--tw1F-%<^e#-(QS#6PsYA(`3 z-Q0Wc!1}!dLCys744;{?)%Di5@9kz$c|>CQkfCjCsViKAY^AG652aOGt;tj&TS3dV z42&VmX64VVuvAMnbN$wbw^w#I26bfP!1A`OpV@rjC6-3^ijQYG`pJL+>va!YJFbR$dH)6wqAKjd0827U3&g)m#YZzXyQ)d=E z3plU~`!(Xg=!)z*$N^s@YrXL)B8p960$_j@+e*mCr>)>;h4v%?|5v?wKndT9DPD8U za%X;HZ>Dl(B~jOsZ6PXKDq9+B&jv>NfQzGNmk)|LO%A1ln8yZu=Pa$Ja0>N6_v7LP&2YJc;iRv}gkf$>=!z zuLa#=4lG@h_(wQ!{$>0VS{+A{49^L6W8uj{g=kTDp?O-f1G`0e4r>P6wS6TCiP6`z z5F{TK~SjO>pLN*AJ%bd3{?S z@G8`7&ET>;+k=W%fDp-YTA-qd^1xKecqVE5@#!r_s+JBV^(IF-b(9ccE)OPvI!kF- z6}u9|;zu;sVi7Ej{SawVU#11;U1_L9({RT|8tx`aLk*LLk|qs92!6mxgUFBMk?6@RdLBnnJy~tBp&hoIk?s9@ z*mg!njbXU}iJk?u?{Z}cX8L?GUh)yhvQ6Y8jOaGQ=N5YpUomm_SS-2a{!J_I$TDUh zR6g8e%Rbqa0Xhfho{m6UwkPT+%A8$;ql0%G*>dB_1HIkL2)_8h#)WR6aJKBrcHKS& z6wk7HfbKXz_b^cWajXG*9idy5Xv{S3Xe1@55hXu-XhjNC!@XM75MAYAJf!`B(l|HU z75_$@fRByMLJiqKsRD^obrAZSN3V+bry$;eK=pI`srxA6847=q!YRs~cq!6vfBj1R zw4Ts&ue%4;Qq}xZL5kFTIj#sI^h6#qK#;0_PW}YyBa(Ot=uW-;bs5te^@M@cySdk8 zx1xm5^8&Elcxg5G17{HUJKBhB!H|cejtfVj-w;c1cpzN>L`XLiT8u5WJL z`RK&e4_;aG7`b~_+lKZaK@icvz|Etz7N>>LJ4`ysAn*>m)V^nK&y7F0t-I@nFRhhs zKUABWg8!*0R&nt?;vk5It=Okg#l&ia0uu>Km#X7kMg8tl1-?rqt_yyZZe3Ny+{G8O zCiomig*M;V>x|^<`usU#9}0j;sDI+8Qg105G6lC|WW`2pUIu5@Me?$GE(&GI%q45d zI}mq z+928V8jk1esiEdAisv~0r(%F1EsDDs-}tG~N}WNFT+jpx^v2~zkY9C20eB)=jCFbnj05pI|$MbOL}Pf z;~{NRb-)KCNpv%C5HLyVj{#r(R+v<^b#zCU?dxh^yYzBN74OvhMPXnnpch=TvJIg z45MYWg7)uaV94I=O`6@9340WK6WROFp&gGxJJ0Uhw5fmH1U%Xy?a0J=C=R%OfBz2f zvk#ji+*#j}gZuYA+V@EQ?w;w1effiZpU7>`t?TQ~Wq56zs?$R=H4kdQ%ZWOWa*yO4 zy@&{^r&kz}R90Y9s$xU}A&q`38&1@18vaN7?tUaM!z&%!mzNh`t67i2Li1o9yj+%- z;RPKdc^O<1u|$mz3SE-wO8g?j;w1tsN|P-gn^S=gq?fW0xg;+v%ukA@k1Rv^wDbK& zNEH{Lid9dc1z5n87-9-Yzf*{N-Mvvpf(bg}o)&P=3b_r{Q_n!)eE|5j=M`7+{Kqucp$hPBe3=DkN>XF=yIBa}KMefl`S(;C z&@_ku*J6(#8>CWAaI?g@ne|@pGUx=3UR%=yUXBm;E}N1ZMyS#)SM_h|iRMRoo0jDV z``U6feGWA@ocH-KEqo!REEz(RD9?w^fG(~=eK=*<$w)LWgKj`7A(wSz&y>6Q1-aj4 zvj2bB-K&KKnpUh}W~`;$9~tEz_42*<4y@ao3phR`hx+cz@9&u$o6hg)yE-?X z8|hyhk1foRY9L%5KXe-PlRuI!ufCfFD>h7ah@j=&xwZg+))~^HN~K$>(p9?eTWU!y^?kUd zcDJQcpLW~E?ci=3uz}cRyBpg;Fd^YcfMjwQ3=T^&8FsTt_SDuc~zPVH;StTk4XkUe)_w@Be@Q|NA{>^w0Ur(Ni2f z)f%mib(jJkm0AVuv)dt3lzt0S^iJY5*3*p}Gq)o;o@twF6QEml@SL^{{`C2gsl9VY z29J9q>-uiz8JZqBT)UyNgP*Nc%b7r5ZG`WbkcnL%_n=|DD3e^Cg?ka}rD$?XVc1j& zwsphdnt)oX&ejCf-bi0fsJGZM62c2z7q6Npmtl`$l`(N8&*--__GX^hG0(78E$zFK z4*6vjN;>gSS%xglgdt05Qfp9|oiX=@0mxF&uocm|7G##fcGl*qRAD0e#vfSgoI^KH zTayuwj;2V3QmfQBlYVc;%X|Bfex_3IuryB8&WDAjkO02smd!~qcP3|df^XR8)0&4ZhK5QSwc6(97 zaI8d4J5$Y(jbt{FuCC5ddJQ(2RxL$-KdQV^W#2U*)pN8(oS2UWmB|VDh*b}X0+$d) zfl@(HVC+GcH{)a8gLX>;~6DWrCLlg)uWoP?Rbql$0)N<9k+h(buA1hd%N_ z;;UF*Mn`i){NI5;5?crx86%WL1bSP!eL2;6QaESI!JjSR^W;p)Ds&6xhs3nU2(~S zrWettfBEq$`3%jfiIey4N=x3e2ikx~iC4vHl24Gr+0X6VIk0aK`Othi%|-Dn*nlwb z@UxJ|_YpeeNpm0J2H+2`MeL0dGZXxMI{H)9yUPCBEtP%zO|{wbV2-cN@areKCZfEl z9v0Py>tay~jT&AwWOb__Ef(O%rKWH3*96q~>`gTRH9y1Ggq9nK;Zl9{E?y>G_P$-@ z)rayX_k5YKsXb~-$kh-BJeoss(C0*PxN0AHnFSF2@~$|?P_kbRwxARn6h)iXXoRA8 zP_J7(#rD2tEm~{=6r|<;`KppCN*MJOXfrkvH)EUO_lL_`G{$UnhS9KM?d!;mmo2qo zB`i*8IC)xj?CL)B%XJH28!s0vH36P2PhjBl9rZxDB}RU~Au;up51_d?_H}*$y~M@y zqP6{JsGGNm=olr<=FROnzh8&xc6L`TH@F|qOWd}z9TC!7E)CuS33+2>Cx2~iqTC)H ztd;rhi6)9Rl&?-!aZ&;j*Cct#fm_NqLIR^!Ca$dssGag)O=xP#(W+(dh2ZaBujqf4 zm4_0Q$Myl02Qn#qSi~z&VD8u|HfIL(>rYJ>f9Xxo3~QJljDa zOUtqPDdh3kc21V~4!*yi%hWuTF+yMCcqL947DRWrSQJM~$j1wajb?eF;;Egu0hds3 z!IP;8s0~hE6L2dIiGgA>>DDU1Ran!Uw2_%}#S}BxyCO+b**_L7`}8V`QmSdS73Fj~ z0t|RmjjB!pX7#VoqI)y*>u#QldTc6ql0w7!k~yxjyX2*^You{R@;7IFUQy{0z@A?9 zij+Cl&dHf$Z-_N&l+p1+Q>0Pkit^V`%RW#15ZkArA=ebjeVvW(*kKTiC_;5jyFPL+ zQn8d_zrh86;vXaO>G(%@fj@L8e`9THWs2u(J>`4d#&|8y2Pdo(_oL`VB^R|78X;;? zoH=T$3RpRH6p>oqdSgvMZTG-|4!53<*95VB)%`fKQPnHo3XPCeUj3(dj}hu>JbrJi zSN>YV`XRH*yGy>y`Lrlk7a#ROrEE#ifhHF-7bI z9JbIlbEbmo1S)DhdfoW+8a8ZOTd__z{raLa8l&u{~A;K0h!4KJsY4|@azhKu3 zx2qrfnV`4XHoqaD#s{h22&nnq^{)!OV}Wa56#~=pg5jcA=*m7{Pf9`okv;BMTnQsF z&MqO+$bni9s#)pokt`uG>B^w6HZFEp6`%4)u08ofB>AcEzG| z>nHEnlF_JxOK&JxpZjt4 zz2Mu3tO;Q>5@l4SU`Tk0nciw)XroUN9!C5BWyWBvOPvi*INqd1wuu=-96P^$0ow>% zJ!ELQGJ`HXBV@+H=;D$_2v5+CHw9W^ikSZsnk?P}ARZ3c$5>QNrg;foZfjlGZrb=6FN|X z>S%;wVRD}cB zEN4ha;uDwt1UAt*!bSv%uZcL4;pysm4hJ9d@FV&I;mLkaW+d+gDq>Zb9xLvJ}un zfT88Iv9t~Jr_pbnI@EnKY9naxPbK@{uP8S#K>K#n8hY@~8Ljky@)~;SK~`Twzv9+S zcZ9-9QBZ8r`0xPK{}YhvRbne~ohYT>BF4yt(;E{z&^GVa_=X^zurX)emZYgk)(pwDmBRsyGM&V*7-sk%44zZw^e-dh;B`< z&$;{7jmBjc@ZIl2KBm$G11WM8y`m%0Xqc=f#%MK~+|X#)vC+6b+8r?s-1xvG*&);Z z-GAxdITA4?i|yoL`LntN$iD=6=_gAMgWZrn5u%-V64we(7ZA2SE~kg@|Au5cDwmZZ z(0wH2QN+5mo{-LKf;1Inzm9E+SgLh8i9#0XK^F4ksf533MQ)+UOsEYa7jf;Bs1ZmX z&cJIDS*#N90@{lGJc)15r$v6zEihnQ>zcb3McL#l?XCne4vhj(Dve4Rwg+=QqwJ4a zWJ)u;k8Vk;wFZ;ffB>gyYlg2<|9zzb5dhO|6V#xHxPtJM9(DB`>}jl}dy<4Ad>;H& zYe%l48&`Pr%RjL+Lfi>Qh)&`u?3?L}-5{9<`H~q-=FeYx39~CNl@sO>KlS(>mlNin zCF4Yh&`Vrh8_^gJ8ZIuAArOjbu_rMhd40o7d}Fswc))EGKepU9DIC#0y2*i=U&K8VqX zSBLj)=mfU0W!c6y(1*+K&(JoWReUP?CFo~u1U}4Xw;6$eW2F2Ti(vcW4)nS(1IjB= z(M5d0CiL{**8K#Zk3$%%zJiEti&1Vdz%6zc& zTt_dGE4ZAqYvAIeMQc7C%gP=hh9z%8BnR(2EjfkQ%hP7YQqD+KW*3U88W`1V3y0jN zM8{AkG+iU6D6YtlR`Rdy8rBJYV)6>EVGA)K5qBZa5UoS|C8Q!k=Xe3r(Nj!X-e8zIn^R|9YUi@6b06 z!S|lObr0>GhM`Q*UEDj(?;Q<*|9#+FpB!I**O^=3`xtz`vwF|> z(`&Ahy!;|bc;%HANvSptvej1nMh@*hk{U|dChmLk$cDRj4_aVsxAdPj&kpXs6UWcT zar}&^`?P4;A$k3}G*Z*FRxd9w#pXDT)?}oJ4p9)B_Uieqv{#S3hmX8hkG7<>MXS1X zX}^NaBKo+-deNE;b`rTStFOtBmt@G-xw5$S)-}fV;w2gQ+Lk354TyxRkFhmK)BRRe zy+pcj!?B%-@`g$Z?Ji|qEu>yLBU2V!O!kEtd2L2(6#ehw;ta9mwH`&2U&HIM#k$jl zE)ZiRv2ARMA&D;|$u)z-*W3hKisYDZL8*?GH0iMzV+&Mm6NEIBVb>?!h?h^L@rhcY zai&8WrIxJ@Sj`?{!Bmo8Bb6Gp+U||mob6rx;pNsaI^5sqH3TDGy@CSN^__pGICs*3Td%H*D45ij;HAaM6wdqogd=b*53Rux^%#9bvr^i1w{^U5_1Wf!N5+E@a zv|+S-*Rl+-7y}i9UzGeJIx)s2Mq(?06Qd;-$TGU00snyk^R>u@)R(YZ2@hkiy`ug} zJ*m(BQ;+sd(?-)S(}IZ-L5P2jkb=j!H$;O_haZ-}hvyLFYZLfzQ?y-*_GJG_FlpZu z2oq}xm?$G90TBNjqYsbK+#9l~s6!8VGF}phW98Y?Bl zY0PU~2beaAK40^q>{VC$k<0mIyDWd3){L$({ZH>F6)L?yfwMKfql_7$C*R@Bu1_XL z+FfC<8pUy%P}f*9lP@nKnT`8~oM|&>Z{Kn6mYF-Zq<)34nB@eKlmIYsCY_FvQvl7Y z0rTZ#Y0MMwts))$mzS;n>o&xcN(UcXe{fUF62wLl03p)+j@+;KCecqki0x;RFoiRc zO~^>LA|u&~j5G*kB)giy$v?XxQw#H6L(R_T8!4mOMPxWTq?LddFW{y5u<4or&$XBA zhMHjKRr%%=P};>_EVf|Wu1N8$!8LNfnhhkg_VVtM_b!oVAC*I{U!#mSq;^lm9`RV! zw1#d?w}`a9OriwSNbzU-c49Yi9Q#N!!^4H$9jNopDNn3G6hiRFFgvxIpPyHD#HMPq zd=JWAYWc~w2@lWJloiS8L3uQk-h!05AZ5=kN-G?2+KiE!n4PK#v%J`fK@gi!ay3C& zkv$-kE<-ILo7S4>l7{0oa@Ut%Mk^DU>JRh#tDzCJ77eF4)9pBm^@5n&{Cj!0T1q#O zb!{F{PdApT;k1=mx5mP@<|Jp(v?h}f!YAYk^*cI8#=zGJf>4oB@~_cWqWJS)K#3j} zt2IvNhFKK7r+vOOgRG>U>PipuY`Qkk#iJRWr=pV@aS`i9X!}48m)@dTNz4%gJhdxb z69!6MeAJ4aBz!)0l5oW{uF>Lb?}y4?_i^&#^rhTYQqt}E%Zc|+FG-4jr1&WuI=(8= z#R%zgIz2Uu>y!=a4IcPIbfvctQ~WSLFc9F|d6J(srfXfi87XRXaz~SbBU!#MC#Hih zpwxYRjkrWQr`Qa^vplJz_^w$aA|xcESt6rqM~nI+k!H~<)YbC8+=_Lmt5LK8Uu#;; zPAGn)Hi?<|qHk%qiQpuqVtk3!Ne;d%PuSs=;%4KoyDRm~{SALd8muKC){2nj@SdUg_(5uY8Ltgm|&`BxCLLXdOFB8lUB zQ%YavqzgCvfVL*oo4$FzCfv-=Rxm4PCkIjc=LG2nBJM+4Oz9UT80M>4IlH>*T(v?q zvA>@ESM5^J8l}pH^YDJAzV_A}6f^s6P25zOg7H!8qR1pvz4jHbzdUp+_ zy-Hnf79MO?aoS(9h|0~_BAyW~B2CH`Q8^4Xf3G-?eA+)WjSb=6!d~Ovxw*YY$~}du zu-50%Jm=}Cdovn$?7JuVi6K7Er_+Jn_Fl4gnsC>myaJg3o8&4=2B0X8^~iE!8N$MY z_YmBToAL>bwuoM~7n+0K=rj>^*Mul%OX`iq@F7e`T997Sbu zwDC_IPbS1!Jc$ebcutIO{gNl=mpnQ6{tk|a(H~FY?S-Wf*)PR?e!SYulBvdZw6hvH z59kKig9z*^g=8-`P3pQ!8tDRK1Wzn9ARd+>PS+70mZj#p8<{!kiz>6%X7`$ulaJ!5 zK{?%K&++X;M>nXfew1v})MpHD+d4IP(}UNOp?V(e|4#25iOy~%k2JEHfCyds6Ll0) zu?-j`bnQ#;l95K%4~$ z8iW$?gLNSg34tK`vls-CAPC@310WIraR%HP1VIE(pfTC_U;xI;@aT<_20Gb6ge!rL z5Wj+`eLcJ|n5YJIuBvWAhQ-6QC(?6xW-%@P2WWm#dOj^RGkBV5H298)*-%hUHTJ!ALPph zkQezd#(-?gEc4c5Z(H?`f?Ms9$g+9$C8_!C6)xeLYOMM!gzGdXe2f~^5Oh3WCCUFq zYRpjQ{AM-zk0f}7)L4QphZk{LWn~(CCO@OmynCp@b;hdF=pfTo2FP@c1~MHL$DzGK z9r6*W)rZ3nn42ehC#Ou=mID`g7~b{<=d0hu+HSIn(8T#zM{ztM8FgRu?i`Us5_8Ty5`ec zAU0)yXlhk}%_exw5w#`m_l0db#ZUiHp|gcN-lz#^fMe-xHL%13-mq1xxcHJnYx29j zQ8TGo`eU2XqNgc@MY(6`aRh@)>n%p`H29j)Vo*>@t!n8Mm{y{_8+BG@X&%|NrQ_6{ zIIF!VQqK)3(S>Z18zkMpfzyc&5Hofg$+!l%&@^4Y3pjfbS)IUNb!shD?YLr^7?;v` zMQBvgqB%ik1wrdIpCl#c5GjI`NQoXx4B@zU)?sKZ;xaU=itM;unJ>5kCQ^B)hNYIi zt7aoUU&yMV0ifPdnnD3j#H3t0%bIAt)d>0&W-YbD<}lKfnlZeaC10`VXf!6Gti>q# z99g2>M26@m{tA0EDqH_~aLpM4)ghk;+e=<1`bZ$)wjB#(L3_58C9_$r`;o+A&%@fo z)FEkh9B;8}!ieY1g=x`1^brAGoV6Vjh%B4^eU{Qw@cM-N5h1at?Ri+hZAi1^5;kUI z2bIv8xR{Npr)F48vgGy!UD3Jzw()L%Vq6&6V(@pwqJtTq+F&*g>|QrI*XR1=OkyBr zF0{23Bjms6^*Td)G|9CUQ`rp}F6>Tu3}%}t?6FvV4sZ8l?&Eqcz{TT{IHZ*TY4s(g zg@_S7#12d=t-t+Quni^5z^6(k!s6E&+fIfSo%@Z4I!@7t<@8Knp9BPtiGt1wwVf2i z^XQIK0z6Yr#`G-%Z&^OFi_)Nya)dT96+?arp=Wst*^I*-VmTwNWL;LPi=_+OJ4zqw zb$!<0FGQl{T%wW;7yK;sc6o7QTFZqUgL(rh+@m}+!oY?vOD|-ixs7{AqoZAcWcPQn z8Gq*p)CVLb4%$hy6Eo8OOwseO$Bfo?!W;Smvq(MGmiVITu((SvM*9{wV=8f8+X=zT zguWm!i*3|nLYpe_ML~7AWg8~EBUPQFP1D9ok*&DLq=QtbLLVCV%Eew25M{GBG}u9**f$Dj0uu8q5Z*BlzfNrVourlsc11Ycv~mRL3R zV*`0+2hqT9v>X@UHHs`$a|Liy@VY?qAtA8n&^#hI4%at(Li*jjLsA_Ri?g)|K?4zs zJm9h{9Wy7_=VQe}P^(oNL+N}^;E_k-WBW$SFif~#v0*ga8L^NG!sU#wOL00zZ*h5? zM!kl9=#lc`R4P%P?>3dk?TJnw@{J!UeG$-`cKzRA@ht3Y`#A9Q+l}#9?}K!nrrZl>(HpuKL9kfqV_WW4Ep0{aelKuyySxXx9?v%IJ;qf|_ zijegR#DDNV9A1y3yJx1$rPinoR)Qi@;B9gb)Y(B|g1A=PpZ&YUCNN4Q2s4-=($G2F z{B%AIf1>Vn=fURFDq<2DGdHmXTvJL!703GyCN>=}**4lp8-I++s;KS&+FCtOI@Y}y z*ao&8D+NFR!O5z1eBf51n4YUGiZTL2*V-#{wLS?D_2pk6+)EJ%^L30nUCQ?MD8(Y#?8jy&^2BDUbEH^3p|{i==6lE2gmsvhkb2vPay2#oS~s@ z9iE)+oKE+h{vLPIZRqdxq}_&WSML6>V|+B-A7&IkcXF1rBg+?D2EEqKnjNH)w8eTu ziIGk(7wZZnhJA*dD?DK5`qDYR!>yzpUvBR)dE?ghPSzV)+UxU?3U{0f2N*{H?*c~t zfV>NiNRnMXl`ta?dI%j9XNK^Y5_aRM^kQhg{SbXfE?e$v-joEMkv5(Z>gUL{%6%>A zYDAsO)hd&BsaUtw0tcL)-Y}n}#nDG8k3!iGwfAo-aOA(%rL~u@*Q565{-8v4h}W$KJVYj)vc6OHjJ zKhUY0ge$Z3532}P=vAdwNueIAt>%+UZ<-mg$HX6CkDKbhx1G_l`*E-2`tSW>4_1|; zKP-Q)#&b08EomzCTR85Pi33J)IpiAwlsFWjOL$wZ>D!i-A*pi-XwH=`A+!+qr3-kHvR)@PImvi(_<*jS0`toDap@;w0cVv5Vo|sb?SvS)w zN#apRtu|?=rHH9v*NlY6crv~iu?H4p0*Q5Z$f{w|7~{s1Lem*dRd$!5Mx*!0q8&vH zwZN`+_l7G8nF!8s&{^2kF))*Fv1V1&rQ<8QggXN**#_uyJpp@TfnJxi!>1KIh<3e# zUZzIte3V%Xe?g2Ens)ePoR31MnGSzJ4iBze%+^glk(|DTpI)ej;uA-v#tu}YpJAfw zvZ-|mD|(;WO1(X@Fq4T-?5~U*+|-sF-(O8sx_q9_N?WSj>77SUD}Z;%FTtlpQO@<^ z9Cy1Gk>7@+94lc%A&#~^=U33~1@=fi&_PvBwdO#F6Qi8Qsm-wtI=i_?%L~uyyjU6Q z6zA8u+ENL-EP%9;V?(Zv>joRK4xdSs6Oj%S>r@t}(;AD{y7VrM_CiuZKdOe*6S64Z zo}1o>W1V#8cQRQN>%gc0_Wus4|9D@l(_USV7!~Be10{<^*Aw?DjNygUNylR6qoHXw<>D7lj9m+-IPMft?f~;K)xT!8cmt1FU)AM|eJT ziXzhOw}?KlvyYB0YL9)-^pc4*aSzdloA<4n zYu&i26wRlO3E~#S(e8%?e62})43|$qYiXjnha?FXA%OArRdcIXOLcd3)(;geQ7@!O zEmGXx6&T3+^-79XDYV{1cQiAU8mbnPfxb-zUq{@fqv1ESl8fa01sHma6;sr0>Gc_h zj$!n6o57-|Su+!gdxCa*qSO^1OmiB&K?}bz>1hLNNVP=(VdluOS@m0gV6DP;EW}x z4noi3LnWOm5DXez3x-3)f;dD1XU^q7QG0#&tNQoi04V|FK<7)I^QL04)+uk&Z*?RC9IZ832mB%I zI{kU@S7$g~PNzK&o`$huAgoI+gmtuc!BlsmFd3g~5|Vn%jy?M3^P8sb0VfUMag{i8 z-`Qh{$0V}ZC6`{MA~({24Y(XWi<-Rsqm%}E_O1Y{{N{gA0w&;cdQEET@F7agxNR;! zLn^;azNyix6eLvVAN&BCIb5_1^h;_3{7|W)zw>trG{>bfX#R|}0lZD#2q_R0=kbsq zf9@%@T5CTK9y}Z50vgVG9(=l_*K(c(n}%7?+(O+dIgYKH!iiR1+jN$rJ~1zCA=}eK zHS!Wo`+iMP>6G2e0LA1lRKV!_f9!n;d>luWcXjvl+%rA*eQ9Pi(&(N`_c6K;OR{Xs zwoco!WXVd#@*y9w6P(K?34w%!8@}DJ2Ol9J7+ZE?ViH1_1d`y8gb>0mA+YQcE-xg3 zumLQ6ud2IebQuyp_S+x7PySmo)iu>suU@@+^{V<+wZjJU7RiU{_tH|m+h+4{6iag& zl|-&lo~A9FNiCseYSsK9n)-lD30+EM)M4ujF%Oa5xhkYa`Qm$(_n(z27<{hp4=(bP zlCugXNzUnY{NQ4Ct(>0{QX9n0uk)uQ7lo~Q^QUp%5+AjWJ-IaRBP8#MWqD6rA@6lX zRh0#WRh5PF=h)ne!f-_;X7K| z&OEF(s97l}nm<(K6gpKZXJJlGv0bII7h}#|TDU}=l58gwR`UJCLQfzcGSgKLs&oZF z?=%8pb!pKCe-K9|o%kJx4jjK^`Wn9y(I0{Nx7p&GC#KPJ8A~XRG~2c#!Bh=g~uLLQ#~Lj-KYVir0}bl07a6) zkMM-@7YWHsX?<-8{@c@DTG9%CSVvLwuQ5vYQQ*Fny4anr;B8*yl|3giph8D;lM zG$yy%o@0$u2SI=eHT)AN7xN4OrrD=Xt!LkVmR_EG)p>um$qHNXwW5V3B+f zN;*0JS?W#pb`(HS{uB`gKdfc&<^V>g)==Gy*{eE<8nBxgc_i*UgRgB3#4Ah+Qw4)H z^TNUshMcLvSDE_N_R;ObETwhZ4GyE4Dc@M>@>Xq(Q80j8tS%kR?tE%~@TKq04?UyS zt5_JE*vaQ#er5mu?|<)wDT!3dNR>Lwi(^3HYd~QRYUZ_wf%oVz2)@qv`y8g!fL~;W zA0{J*lBc*ZDv*pL1d_4riqCw@4T(6N89gip>NQv8hH^%2bQm0N4aJUZ+qO+Y>s(f| zOE0IV4$=1gue|*HBrBKFtV*wbn)>W_U!p$yltQNjYNe8k^Xq}$6AK*mq~t7cw2WUz z)KQ;CQsjV7(6=;m1{jL4+urLh{fNB9_cUDdLL>6iv?@Qp+TGRNDbaXr2D?$k6g3rF zY$Z(vFwJq8VI+`9?)azqdrzI7zyC8zy^_VFR{!Tu&5ewn{o?)oVAC_K5}!D945)jU zP#5L7>80Lt#>}A$v;i3q3m2S2Nyr1@bUb|!VJrPJ%=0kR!`7mvB3fmD=JsePNmpk_ zw}j!`R(Jr%zD>|D@0=Sq5fg{r7+IEXe4|1qn#x zPiZN09F4;DF}Qw>TwjkyL9^b@Owyl(ne8Io?+mr;+CleUj1t}NOp zV_A&FOy4ZkSq%moC$%a~IaXVaNkPruvLvo7#Jo4N)#+PeXg;zuP6sk8L1^JWSufj# z`q6H59eNK+k-nwuoQ->~u@A|(8GN=vZ%;VPRpHlqwe(EWpB{AR?>VI>SCpi-XXRA+uwuC8G8?%e}7ZY~V<-#qH?+d5oi^4Zifo!4&hniR&I zh^M%@)T>k)RM6IHpTks^++0;KIz8LmuzyE)!cFCI-ePa(c&*c1(iTZ{mRb%5S|*#r zz3oY7d}_yFZnQaUnEw&Axng{DYfvuA=v$C$OFx;9<2HlCU%~@P~ zIX}LoA)t4Mt;J(I$4Yz+4N2yt(0-By$&F|o+K%?41L8j4rrmp3iL$P4gWXjkDer!i zu0Tp;qAh3(DxfYUl|JPbpKptjDc^CPu1=azZf;%E?h@A?JG(*NKKvGUU}6`3-*o$Q zd5vSy|pT)7bL;5Bqwh8trXNDdGV$(BdAF!MN8B{t1(@))q%>f5ABRn0c>NnY>gN= ze^o)azQBCITQgYGeIgd$zIlCaag9$i{~fBZc;ou+kiV&}!rqkXDF`+WBnsDe_k=r7 zw&hJ%1YpQyX}MPI+4TN~x(KDP*^G9bLZMQ4DoTCvTvKH8LHb5bpt#bXQ(0DQag_Q^ zmi+R|4;6Kkd!qUIJ?nb%ozX~y{)W)zvh&gfXzdf|qv&Jkb$$olduRFrc zo~KJ68oQfS)=Spid4VoO(_rZp&Yc_@gD&}pq?#L>KFM(>r^h7goENB{qhb`N98%l|cWXp2s zGS7hoO%bQbN}xp8sfDG2;ol7_u^0p?TI$!2AAgD~P8C`2HEM~_WY&-Il0U_oYR9f^ zD4N`RsHS4LAvpZN@9#PG#3%Q62Fy;2#$>aZq$*<|#~#`7!N=dY>nl{*i~IAtChG=1 zopk0ojdHveM@b|KcVWW4;rWAC{o+XTwbT8zIR$OoV#%)j;k%}WuHTq@d0%+_Y**jk zwbxxUdsRuWzSu$io3WrdlISQlRmWnZ?S+5%q$H<(??6`|Ix#jFO5FOHukHN|MV%Us z4IIAZj@EUPjRA#BZ_sPyrmDe1tz#GNy>oZ<-sk3DeDwP7U-)13WtCDXD^XhwMu%R3 zkB8`K?(DFH>(=kAY@FWO>D~PIjrE6yD~o$3E^D!}DJ%+)PV7$DqBSk_&AzGvhc}k= zMXzmhSJih=`DEAN{5*415(Ck=lOLgDbZ1i2w>iJBDZj5TzlqVh&(qTnA+52_SZAv| zPuY^nb(>2T{C;-bi0wROKh2Ku8#7BURq6QcfMi*bckqVEHkoaZyVsH1HL1=1l7$q> zfW=Fr5YyLc=bsY z-?+E6X}rZ>wCQN~F*T=kvDy0gTg-hh-n$3F}hHf1y_SFwp zH_U7*J-*@g-L)34SEVs~EJiyg%?bJZ^;=@)0}Xze&S3^+APqDOmWMmby@7Cm)j2Jk zMXxpG2COChhuZ3JxIP73zZ8pV2 zHToL8rJQiRb5qfR&&PI-Sl%($$=#jhJl8k*iWX8N1J?3RxL*DjGX3uQgZEwCxPPFU zlS>(`Mv>UGx20*K#b3DTXwS94ZJAW1RqQ7$k8em+k99|t*l&?xW!mcg+2)~Jhk@lo zHO+hbicbuDaH_)W@o-w0%>QMlRk%$3UvoLPI`zr1ifx?{4y0ABQWZ3f)>RMIdVDR@Z3pC91y~Lmc`ClO!EY>R zDvb@a7UK=13<$QaqIWFOdt^h{ljtt4-O&`J3OWvNEY?{ac-x)H2gZ!uQQDMW&{|@b zYV9V2UCTPkTEo7Iu+8Cjuv)v3GwU>*$7KpM@2rn*ZZ1|bY(di?SkF6w%O${NDf7HS z*ptj0WpftGd3*)ehZf1Xipcq!n0uQ*N>!)XE$>FoRlJ*Nf$@=>=>dVavjwZ!ISL>nQTXV`245ybRwz5P10*KQawq$uOI|TLfDBt(@Juk zxQCzI>GYA;Ch}g@(1gY3h#NI|mp7D!wAghXQfq}-vljcQ8`Th~!N*6{V0ZfN(i%#S zPZQwCzKF+?N*wtv1c5kT6aV=a3&k@4h+N=9gs^A`CjnrGWUhu%G4EDPvilwWM>HZW zl^d%!?@cb&h}40>J0~m5Zm*W8M3*lYBxQ57Vj#IlQU)vX@feg(R3c~9YmLEx4OF5a zDY8W0)zDWP=`=mazDCO=_z=HOLCg-iiyD{igBXJlGXOCoB*utiK)xCm!t@~Bje12r zT1>r=)Ue>jS_M9~sf zQh)r$n_F5=+<1KhjbGRyR{?bcK-~zTE-tR!94Bix)B|Mg=7u@Wq2$TKi#KFd`3#QX z>C4To-oz`)aWgDly%{kC5`IR(S+o`#bj+%%8k$zyjCzY!O8FB(~I{Kq%1G-C;#JQxVqbJx=9qi}`Rx|i{_;->% zq)sHlx-zQlzGiQj3g7GHIFr}QHec^GQKtLZn?N4-<6e(lelP8Z|N5#`$9Kr3x$M4_ zH+*kO%z-uCpMneowL#vbtFKyQMM?gI&d3VpGK~R}smt$}toJv>b2+I(A$Jwj76c+* z&X8AM)Fe~#jscDBUGXY+JnWWAKzu2NRR+tOLzNpVoW?-RlV2CMe5trA?pA2^I=jPZ z;PhIZJx6a1Iy89S1ZPx9V#R?{ospBMjan6_mMe{VjlHlg*BuSJ(N%U9ZjLV=m@%=>LxSlnZDhr{#4~P*VSH^zwcntLEnyv;Do%R zTiuP4Es_>pS=7F=vT}tK}7_qs!4jk@1+;r@C+wtgCGvzamp>3XR zhK*Y+Tj=U~X}z+bL|bzB_{_E~^(7_sTei&{KP(GP?(~OH>BZ8EdSQdKaBjn&{-E$3 zhIexZmd47zjJ{+D+Q9K{NhE_{z@Lc6qItr%QTVnB-{SAGW!KBUm;WxaEWOTMmcLjY z^I};%UiM-9@w-?g7760ed_@%gJrIjTV)RD*dD(#@=$kWXFMpvd9*qX6NIV{)zK(yK zAHko$!>K=vL(E6vqYSR*zZ;81UWE(lV-VPabGsHks7IouiOU@j`bb$>oc0Nc=4BB0 zDZcG{@v?Xc1faLL=!^7=?9X7_o2ZI)&4p zlZVqOx!{=s@@JR|7`J#H?Q&O?=BjmOg-Xrz``l`o)vAw8d}#a5Po=8-0j=NU^XOzcUv9@$ zt+&5dq0z~dY75Vc=jqo8FCIeT^#Hj(Pk3_&@UD^lcjOfOSXd4Aicp&r$i${K8oeZK zJ5QfHttVbA{4R|b)4I!qCu$9wPC*)*B%Kmd+4vU(WS&_qa>XH!^Yr8imW6Iq>gQiy z&}lTf1y5ec<8V+DdL?_W+xtUHj@v)~veszS(qHqM+(7dXSWK_5zXqBg7V2=3zDU~p zf_P2Y$n}@WHA_ED)sgE*GIefbe-77Q5w5?*yo#SS$R8uuL-aQoC;Ojp{WanDXXxXE zM^7Mfh9SL^xrY4}GJ*tcCr|2%Tz(S_$nQf1NJ-uPpufoFWz%^e4`;1h+KUC~n}S>X zr57*Z7cpSh7)X={48zHi3adBi<?ZCmFq`n|E{s?2 zHTq#MrtS56%_aE zs;Ha7-;?vlf;M9?k)yLdogV%8Rn^Zv`p8USu3ReDnt%sefd@wk51u4)(guB~8hFsk z`x<`<)lviJf{|cE<2X;@c^BF=pzdARCy zO1R-1Yt1-vW{w5R?tCGJ7XSKC!P?BrE_jp9CH5`s>xOL|y1JlqS557xOKVUtR+U4o z^5&JfYdWl6U=QIDt9JIf-0rlqPD6`^~L20YhKh9?5fUX=BftkyiT25tF!2v z^)i``Q`W>Cc^-#aA4;q%kN1_koxQ%GKGYuB}(Zh=f8y*QM)1!DnymMht-?|g<_9VTagNk)!V>EDFXGX zMJh)q{aloaM&-F_4pSaW%MWCyA$S%7-2;?~o;?0AIHI9+3PmXk1GjLw=@h={oIC~^ z_P~lM2=V?CHOe9*jv@A*ncs7h3DWl%1JT;{q8B*3Lwmksw9cn>oJE1H z+kN>CO^G2`p1=FJr_gU?P6z9wA&16jqaU>yow0SLu2^9}h36_%mDz7of79SCn194o z=;nT;b%$}Q3Yr#dbT4Y+eL;+%ArQOJd&x&Pwa{U*^k(a ziHQ%4RE&4zU;dH5bvM!VME9d2G)Z*5BM<9(ox*2C3P<|65ETkZ1v>LYI!Q}WrLSa^ zzCHwWq7XC^q|OqbmnB|kB(V-T8LclI2bW_On+qC;cry%OZ{qnQ9Mp$8$tqa9D9O%$ z@At$<3qF2N6@f@Hj0)y0GQMsy&Z^@qoa{-t+NRanw9=o*bXGFfld%HqQt~u*&gJ54 z=c%DHD4)Y)zB!%=&?q@x@UkEPUqS_tSfdVr6sSQ8R?|wvO?inYdd@OWd~yg(){*rhzZI2} z&3{{}a~cgE>^S5x7_rAwvG^PPL+s#yeF&)T`Lkk}eoG9^f14^6!+f2wRRG$kfbWfn z8bV5>13N|7igSrnM~L8e<4`Neq-@J6%F>Z78_E-`4iw^76;5L#bMTxhC#{=hfkB8I z!b4d2pbo4@A=btpqGQbZ_zDXPA;I;@D!e52tW@VF#pCyrSn_kM61hyHQR(GMmq}~Z z$k>-eS}s2u*-+(`E7h#h;06}$x#D%J)fmH0m;CmmSFuyA(W#aFj8cG#(OI+#>iZNW)7i|}quOI^v-<2h z=~K+NWd^g|(4{o074(mxa_|piB6<1C4E7|KNF@;dS|{yUdjkCO52UH%KnmkGJN{|0=`ym+dBrHdNV_$c@=#^M5qw#-T2PeT&Lb3%{RZ zuA~7~zz{JPb~WhnxM*{gG^CN9;!m;tq5ju;Rd}gF1_Q6K%Hh7WMW3{4Zza^1ldgT&2 zm-y)YZ_dieE+5K}eLL6&oZbfdM~6@A6RdB%Ii2i4RU#kAY$3A1rwWBY2g3+3=M=hF zgV@C`h(DcUotZ916v~xeT9b{Tzd#rC?r(2Qbr(r>F0%o2p|!9klvfkBvU-QnO{3? zBDF>5>1XCrEq(`lCygo2@Aa4ZxBK_{S-+pHrU1X#YZi4XYr=PUE%i1T$z$J$o;Xl;%z&xIuBN`0CDr`7ZY`bnspntlvnt#3}` zM9utyljO&oLJ``!V^{pPf3)iz4Ciy|5w&nH`vHlLeFNlOds?ajCY?@65mvbrzdDnH z7yFpsXyM-&Zd?-lSb@_$i$K-tSf)wl@f zc&K~-B7FTy&~$5_&O+-DaLX$A5AIL~Kf%yBmmx%O6!1sgAN6&M@Pr|`2!Fpij8edD zCX0YCWnq>Jm|PWU%Wwuiv$ijX&3;8ZV?UgQ;~M7)*Ill=+(`ioE8+RD_x-+a=X}J! zKk%tw%Sz%xuUAMhx6vhXg9q6dl}5mAy7QA!isx&n`sK2r99NI3GI z$a|t8g8f;spgxS-)i}3>z3Ag+qkw{+a77}Z@HWJ5p6Y#JHLMS;zcz!X z`0Za(NC;l4Qp7#8rJatHU4Y#zqXwKd}n(*;DsICJ5KC)X~#=rjlYBm_b3w zqe57+C=HAJl7ADz(nawybeNS0VL2*bZxzA{8uo@M}X(6o9 zO65gDSc|$Kjj+FzQdpuwm_jmZjS!}h%yzpFW{}Nxw-A;rO2Z<(?LHwaT@)`vHMR>v zSdPq=Dj}>uI@?=9SV{F|(yLIRT`Gjt$ZU@YVU3Kjw+Ue_%7ruxq9h6+&cGfM!aSe( zFwbW`%=4KKOBSW!`OJrS14xoMHa|~jpAvA+Z;Fm^}0{GBIh?_#Y;kQ|G zwF|y?LF&WsIRPm(r~^VfA!Zjk0x8x*Zo43lesq+Ce5f08J_`9BA|<9EG({-!!QWm; za}@3s%kX7rE<-U0g)&zasECw520845v_2?z49dZIjH9cCv|Vtu8)EUVhk(A>OnvYh zGp0!$QdgvBlJLQYn&5gT{DNc02p5*tkJ0=JEvj^a&J+X?-MqmbB?K&?2^8wjTKR zgFwYJ+&K$eXePCpCN-JP)ME_(_P`xHg?xR+NEtq%t*0SJoa-2*!+9Nrt0NFPNZMf* z>bDcZDMHf$!V6sAY51HHIK}6BkkpAUbB5GtoKQMLN|`0?)Ja-p5@N7C9wHo^CHd|W z+G3j2iRZ;Ek&0Q!bxe>~++zELc(If{kWY&6XP-dj48-gqrSf^s5>90)z$NY@b>aJl z$XlNF6pKqv|~{IL2@-i+FF!ro@ab1d^^qv_1a5#yOX5MQoOiEm}}RN z`}kU44c8^4?=Ei5Jd)cUlEYELheJXyUCeJ$`ep=4#`Wjhw#JgXFH;+y z#wj7qEL?c9|k=g@0M8uc}wn zdW#@AqU4uDE>ZZb0X-Ch@5G;w`GchN2_loYysI;x3KWL5497i*h?ro2*8@F8kGVj2ygM7dsawKi6C>O z5I#y|Xo^S?ZkwYJI|j7#JQj21^Gp#MFa?u@T3+Mvd95a+vqaAKk-GC-5bwjSI!Mak zbGlayu~=j8BYd3^GzFi_F5!Ai(9XMvZkZ-E z1j3OdX2ektX2BhY7;dgTgq(_fNjuqD(oJ&H=V|y$dAqoO@Z-Es7^x4EmK$F( zVlJ;ic6?k;SIr_Bz%}9P!rKj^t~`*jK_OY$Q6wwfe`vk%}c>9p)6iw&j{^aMCyD%7=1*$g13;SgmxC?n3t=4g5|@Pu$Nfi zW2ByQ2LD8F2v{Wr8lhC^ed?wD`?hrMrskE*)%-g{;;?}U&ENk{@5EyZYaV+4#5 zQksM$Bp8yIgom`+OePaDI>}6DCLv&iF=C8JHBv-WYNNGOyxpA{w)b{bE zy>veC#PN>yYitiT7ByolQFIJ*S@fJH&SO#C9pfQ>I*ED7-`ZkUShd&9)EBp0sX9l; zGIlOe$M9-7;z5cM?`R*Qajo^Gi??d*3a_$^U1_vFJ!XmJWXJYP9LMNt&`IY^)iUmw z9<&gOHplF8D{{DE){f8x=3{l{Zy^TJ6>pYi7FTy;UG#jeU3Ukq5z$p9_M{eFC(bpe zXiEiYu29FSS@J&jn(I8zhz3U42gyr2sCG4q&OQTQss}{Zw@Ow{skN*E+G4Dh_4K9+ z?-b0U^+;I+tqgC=@a78SXprew&`2$&b*K{g7SbB0)=>|vfqoOsaTQDQR5COf5m(RaBqozi7XLo@++?Ss{B`LN*dLi#q3`N7bZ>w+kWbkgtK- zFCorqZ)=D{1=3Y6<<`ec=BvZr~iT+jN9%EHPHmIcDjM;B4WF2}g>YsDfHjwqorOgD}YSPA7sUtO)*a)i%8t0Nwu`WO3E4#St6{X|^VN*ld`6j`Km8hIfyWi_1%0g{ zWv(ya_XV6GkFO(7De-!hde5@9P*ACN2i<`#cS~MkrMo%cUar*o-5rf9{BEV%xx&{O zQoO!p9+%?s`Bwx)2}RVLKSP-&#C5q!z0>P&Q!1SuE}!c*yq)iB>rg5?TY{pU#x_q- z@y6EE>I*2Po@TGd<@74DMdb0JQ6=c>47l8i=RYA79drdeenF4gXmN*}9&a!&ajw(rX%2WqBTl8=hZ@n4 zvm=OV0-jc-)!FXxu27bHLTyU0vpM8-D*+!G_H-;muaGt5Zb!+E7FZzA;SL1zl&X-@ z>UM@Y1MZ*_aD#>?gf?8kTqW4GAZ(+XfOsaz0Sa>W6g>-Jxe&mwToaPF$M*B(+g(T&M3H6WB*XV+2U?@ z25u8%XkbVEa~aI;7q4AD*r3DX4(3&Nx^kSsD>0VJynxRaY72$@!C6;d-Qsfv^V*{& z^HAXG&Gyj*a37JDr`+W0gxOXo zop7X(a9QzMSi^;>oX z1)>kFqJ`YExx*P+I$fb$;gek`n=8sh8^9nfZ}Yg?VtTe5je0s<-cCfoSnquuaF-m< zm1=OrWJe7b>a7|}aCi)UFck2(RIiLSK+YJgYc^RR$AeZ77-B*Ri12CgE${I9oGn`G zIaO1^y+8-UCmuROe#`|eZb4JzX>)u1T8mn2>t8Z^?<)QRP7zV-`n6k*UA=s;)OR6&am6DjrOR<|n@b+9o%hV!?0DDY;fE{RxzbQu z(YUyzzFetlQ0nSy7gd#&mnm12G(f*1S6N)uSXsNUQ9+LSlA6Ywl-dfVq~<2&hN_yf zT%~+TU43~&gHl_sR4u5ht}4g7s+zgg3(Kl%<|(BpS5u4Cy$X?tN*ikx(V(oVs=Pr| zwxGOzZY9)`(yHpJ#+!1LimJvMQD+4zEm7)9>Km)(F03x8SLznl*VQ(ZqxmvaTT@k2 zQIA&27nIjDV%@F5JEeRP6s4iEq`I1#Dp`o$*Hi!I*4EurUp23?QK_u0E-S~&(sJ~y zq_nzRZ3?93R+m&Q$W_Wp7L?2@r&6`3rJl0M-Yu>yr&nmN1b^l>R@K%B8gpxF8tb9v zf@OWv6=G{KmR!J`Nx6JVB60>4*dN-4p`~G&p#0S-}!++9lg`6xV;Pa0JpMNBX zpUpr2P@w%#5Vg+d9}7PJSn&DBg8$<_79cj&e|x~#nGDv)&UNYJPv|qA19_Zj|KCz) zUj{>t-OiCmmwmDM6B9*#ee%WTA2&|qHyAE9e{wSAKY6kF$B!5J6OLYNeuo434f;=* z_!kQX$}jqM9pQMGIfZ4i3`}I%Y$lrq^Iw61$z@-~0^Ej0q?65Iy%@d+FllYZ@H_*T z-oci#z3dkDCZ_Xuk?Rj!$4>HbT+fqnXYwpQndji1!RvT2U&KE#+ z*DukP=)Jmm`hae+ewFTK{c4?8|FG_M{U+UA`akIg^q=Uyjq%;0<*a-55;;Ew&OZd_ z-Qc_*oZkWG5#;(a*Kr2UHZYzD&Qrm8IyiqBoNoZzCE(l&&OvbQ1?K^9ejJ>i0q5=D z{1Q0t2j_Q?@;*5K37k*yh|UDgDd7ACaLxng+2C9S&NqVdVsKst&h6lQ2RMHdoF4+` z?}GF5;J8c6IiV$nb2>PG5u9g$a|t-tg7eMbd>c5g0Oxza`5|!rE;#Q3=Y8OO5S;%6 z&i~C?xQ(@er;c9%&NINd6r5|o`DSqTfO9uE-vezUw5P!N1#sRA&c6ib5pez(oKN%n zbcuYOE}IYNa`>aVnfwJ^DLB`F^HOkb2j>;w+z-wVgYzIbe;=HG49+is^PAxOTW~%K z&i|$VL~lV{I<%b4%VRiC1m_%Zz7Cu#zr7b0Ij-1Lp>Cc7d}GoL7VMec=2UI6n){yTExLIKP9`--Gifyh~^2cjz+s zy}GI3JOi9d!MO&Umx6N}IETPF49?#I=kI{?bKtxioL>d!1K>Oi&LiObiT+`|UB5{` zLH{RxJ~)2`oNv%_w!IR=ISZV>1kSU;`37+QDmdQ;&VAtg1UUZ?oL>g#H<5ajE#@Y6 z6Hf={nc!Ro&e$yS7I0n-&g;PW+u-~xIR6Bk_kr^};QV`V{*-%k7Vgz$ai1=icj~?j z&h_B@b#V5Aa}PML1LsG;c?&qd0M4&~^Dn^peQ^E{{d_&wFVv^#m*}(fUj60zfc{$j zD*bHzYJDv@yTI8C&i8`zBjEf!E$4B|VmMC%=R9z}0i4^wc?CGH1?TUB^ImZN6*!N8 z^MA4%cq%ww1J0%3>;mU7I6nx^PlEGH;QVuN{xvxN5uDHP9QbRUZVF$j%jY*^3_ZF= zaQ1-n?cn@PaDED$w}Ep6oZkfJ_rUoWIG^S(=#zLvpUq#^&)`G)`TSKdov-f%=X>z< zAlPjJ=jXtAJ2?LgoZrQp5A=UBnDn0*GB8a~73-VXgg?p2IXPuLy}jlHZZ-`M3>@q4 z?>{CKqrX3ltKV-naz+N|!}Z7`R6+L|y1J=5)G_3Nuh(HB3xH;XLDMEU*wC&S@x zvjM%&FFsZ*E@Uzqy9WlE!hY3csOkBAqKL|vsl=@MGiJTQVmTYb;eo-(;NXD9G)6Nw zT6g^HUR>0qT9<4cm*|zzr1lkt6pGqGv)QQUM#HdL3s@Na;Yfb+u*two2DP*KRE@~J zsm++cj0yeyb#;njv@oNkKinT)h$-xH+-e$9>iW%N*}!$Iqb#h~F|IEz4s%@kuq}aE z63pi0WJP2Qhq)e`gJG)<1%xWD;$l+8gSf(By&eNUI5=n)gPC7hnP0zdU4TZ+-*5Jt z&Ha9Z@RQ+U*z5dWia|9Aj|+#T$Nlf{QVTcRUJkz;eiHWv+`@W-hNZBq7Z++BbxGl^ z=CPC_{a@*=mIQ7wg=5{-sJbg9m`D9pBs2{i6A1=pfxn*1VWX9u$72m+9&0gh3p`f3 ztc7#SsGa^QpB3Tud?e0iDZ+}+?X<=VIIVHaX^l~*jqU2id96){5>c;(w~`)pTh(tZ z!f!DNSuDbDo7a=$VpyY&o1kY_Lqx%Cu`r9p%u;cSUY4-cWM7MsTg<{DCo#MyMNQ_S zQfjZXi0VDLMz~8a(!>y*46Cj?rkxC{)1bv7%H7}FE6a&Orv+$-NcccPvb=^xEyifl zNsE=9f~6hBeCp6+$H;k6cPQR$uHLgk{v)LC*4i%7#k^v=<{6ssT)k7ese7*cP= z5<*6?n3%=hnvBG{x_)a+Ryenn>#WiF3%m_F zrZWg{)^V-_w?ree84U(aEO0}@aH5`D6O@=>Q0R?#P%k7I4BToQz-2XatF^GK3^V!K zHGM1O=$mCo7?>?#IJqf#*lJ-`gv3}-tVURnSDCogLe3+c|CFfCGN(jsxnvI2eyW%J z@lK?Q5j_=+gi|zRqk$2o%vAP)JMWZbMY%InI&MJ8b&^M;L)2^R1)iy`=J$jWO45({@hZ-z4>pAmbs~s6-jCfhIrk6+vS8qxdCbOED z)jS$7$)hCHh?IV2=z(9@jNB&NJ2qxaGG-{vaPFY7i9tcoj7E(KX{&_ihOyB0Xsi8; zVPi|+HZq)y8yn}g7&HH!0!L8orYYvw-y?8rTxW|0&S#07Bt5sqM2?6UdNme0GInef zJ2nfq*$Q!rC}Wi@pWVprWq0Egc{l40lRIIc4c(x=rW+eNI|3q zVg@s?2h@7PP2nkwlE#MlOw5qSy8t;{@>r~iR>ootttN8YxJX7MV=!khXP|PRQY`#; zo9{OFnyHm+=F$XG3G{3xxzc(`o@qT#)=7xD(adVh^%BwUH6W937WTE7m@OvilH=Ho zRX2yp0}4VAE@n4zyG0Eav8L_aso9!Rst(tz3XxhdOKw6%7$P+RV_G;Chhp^1V}aP) z6IXd}uR4W|(o58zvBt)t60sY(onlu8;~G2X_Sm5an{C{j^yA17O=i)XcCpb$G+SER zSPI1?=}|TyMq61Klwr9W5)&EbF-&X5;^LDkwL+>@Z2)G#&fbjb%W`s(lg&0}wu@fO zFP>i%g=dn9*-biKG(cs1CL|1x({sCs&th2?PYS)G zr)YpugxXCaLg7?)yBeWoEC(@Khxtpy=v-E$I#u@?G}LX-=|qt7M57E+yN%gxNi2zF z(S1$$ns8HOHC7a{sMyWiZap=b2y?_7Sb+4}5W8Y)9+qgY(3j2n% zLiBEmd?Nyus!hloR9BMy{JFzynB|kvPO%}jhwdRJo>lGTFqxsKH4+)_&Q3BK_jlXP z%x*a|*7=M$>SLEP{1rwU)t(`van;b!fv!X|Pqd1pBO`mnHF8AVyth*R)*?#RP{{2I;V9aH)bIZ6R!@tWP(%iJ!A8k+F`nS?^ zYmq%BZRCg;d+}nFAGp#^@6d3x32_GK-Yd*)U)L=*1;#PDg;cj}2gVV{BXN>;F>x2* z&c;Rc>yJeDltSA8L4q^T*NUec4i zM0x@S)VyC@i8h{?IESgdX=O#MFyf62MMSW=B1LMX_YCRv#v&tZHq?VTBFQ+6S}=4h z=39&S)}(9kb}W<6c|rAgk$5f%IGb~ol0bV$t}=H;z?-YgbNg;13;+XeJd2-f&Q+?N zp^nd%n|ff?XQyvrCMQa=TqjEM$u5-S#mi8J4=+O*ezXl`bcz>cbQLhUp`xT-Ve=PM z-=wfmLrs~&9$#1w9AfOu8Eo=RnD)Me^|BiKng;d_b{~6$J_he^b`0ln zBjrxP!62LEVtc%RHPR0Ztz(a}@30rytL&HT*WzmsH_4n9Ea?BhidhX?NWZJ+o9r<* z$bQQ9v9~cR9K}R$rkvxMl}%=O*bazK?c&D~?q{3X6YOW~SL`?Jj~H?b<#R9_o5HTf zS+n*VsY!9{Y%6Eu+5|7WwTcA$~{85_U7|!ZBumZDs$#KH)Z& z%ygx-4GJr6s4o>iSRiswfPE8zm$B~7p9Ee!fs{DvGID4eUJSU+sTV=Hp;bUbPrwogRkHc@ zwF?y1+*p5u!ZuN!bmU5=Z)DTiEbMr1V$JM!wubeyhuAahhioTi?q9HfWxr!5*lC_j zWh{t>*{q5!MR@gKXS|V*r}r7+_o=ao>`FERK{ua$m9@a)cd$O}p&w??vTf|gY%hC* z9bq4^|72%)itK@rzSCXBX0k7{8`#&Fo2_E^u(jynKe8XOUF=`jo9y4%@7YPtxPv#i zrUzZ7W0p5MeLj zK#SM2%(|Iy3*qyGy9oCZ9w2<1@G#*p;Ydq|uibi-@MFT0gr^1C41`v~WWqGU>{d^Q z({?#w4q+bQOu|{M0jJAWOgNvgk?l*j%W;Twbpy&awH_9KMv5q?1U5#e#dPX#72LL;Hw z>vMS%QwTE%ClgL3yozu-VIg5LVFh6|VST&1#gn*%@D{>WLN8$ejRX?A30D!WAzVv1 zK)8``GvSkjPy0mQo+I2&xQj4CI7GOg@GZhagzx$SEggyP6Mjf|%rD-bAUq{7Nk?cV zOd?Dr%p_C@rxE57PA4oRoI_X^aCt*X^9g$ipCx>Y@Y4VWV_Y8L9KssHTL?P{R}roW zBI3raC0tLqiExl`3*lD6?LkjR>$qKn5yBzD{e*839wK~~@cm%B%RlZz!sCRe1tyyb zQwXyOrv-!g*Cgi?77!K@mJ(JH))6itY$9wU^b>XirzeLA`v}(&t|#0?_&DJf!siHg z5bh=%B0K<`k$jNw2;qB#9}s>-c%1N*!0`q`J7FqeHsMs@%<;K|MTFIaUnBGq_7L_G z_7mPuxPkCd!a?A*i85mH z1|SSJ|9CBS z=+|M_eLeQFv#|&LGR`_*!MUX567pYs?+m1=&w92I`_@N3!xP8OcMPYTg^8DhH(mhs zI5pUCqWRlDahwz{gmt5sj&s^_oV!-wY;!wK5qDtEzZ$2iJ8@FF3+Io!;oJA%6ml=l z0S}R#{$DAr*wMa?9p?gj$O{aa0!yL%@;r&o65d;36jgGJ2&dsG^p3biM4kg{ga_i_ShwO6lW7Z6aKAPa8`Rk9Cu0R zB5Waa6SfjABWxq|5Z+378=;r5ov?$@N9ZT~24R3ONEjmQB2DK0PWTzUtPALRb?bFc>bB`#(Y>X6PxrB2 zr%%yeuAirtE0mS&q~E3j4A7TH>DUA8{kpzV2E#CF*B zq3u(<)t+hh+Yj51CnhCMPAp8UO!OuWBn~DXNc=d-p46Q5RMPgOy-DvTeKam*-28DZ z<3i&$jeC0B&g6{bqU1%%{^U23hm((uuNc2*eCznG@qObrj(>i9Wc(W`*(r@Fy(zm= zUQ0Qg@?pxS4yz;6vDNWnYE9}bsokmlsUJ_UPspB-JE3SoXu{SBLlX{87@2S)&6t*! zHZ83nts-qvT5DQYT3_16v@K~n(uUFwrj4YXNH?aZrB6#QNUum=l-`=&mEM=WF?~z= zj`X4QgXtsbCo+r~X&KWp3Nk7(7G<<%bY=8qY|Pk_u_I$B<6y=}#)*l>iD?t3O)QvL zF>%qv)`?vc`zCIjxMkvwi9-_)P8^wdBGZ_emN_l6AhRNKQD$pqS7u-4#>_35J2Hnd z4`z;Jp2#w0rDaXaD#)tHT9nnA)s@wkwJ~c;){d;9tbe=Qv-58@ApzC6EBV&_hG82!{zi?2iPu;35 z|HU3{{Z|cZ^)Ic`)_;~h5SM@3U(nyv>bZq+df#Wz(f{c?wCOXR)9SOQ#pyXQI`%EI zuZl}=Azg=EZHmgUXjqkB_EU<1zWPkY%1dwOD60I*t*R{EuF6v5xpL2)FJ)ND9a0|G z_M+@6Ez7cXTD`I|PG74nU%6AO&wpC0->^PT-=Nj2jd6OM7gk&2bbFjG`p?JuSG_b& zZzes?I@PUGJ}u=DDL7h!5UT8OqcQlRn`uwvhH#z-&W;~Qok`ErObEZX;s$C{@1Tk zWdoD4M#>&3pH*e!*QDI4%7s=b+oU|I%0=C(Tx^hXrz)3psPd+(q?G+%`jmDQz8Yus zuP%zyo8$DpIQ{t;9rfQ_sZGCmRh)jmR`(i}JG$cZ%`rOq-|-ab8LWWKVHG$5 zF2b3p6}y-&>@@nY8`y{yTYS&m!FIDDc7PpZ@3Im0k=ol1Ia(bjRO#EKN`H}*@2T<| znNs?t+^5Qb9Q8n{lrm-m5mg3tQp!;X)=Med3BIe!ko1m_9JP=f#ZFniQ?}D7``fu* zYxT~Hy&U)Q)KI{Hal`EtbR=lFhmC|Y} zAC>ZBRo*UH-2R#>d*qDQ^Q0>8kZkW*DWw|QG4t)JN-4K%d9SvTZe!UvOXaeetcaDd zYSxIeatn63RA#k|q}9h%89u1WJMULzZ-taHa_^G!?;1HacgwN5TaI1dMzm=e^T>ND z;`Hh`y(UigYdL6itUmqy+Wh@$dwTjwglx4B{nDTMNHmWx^=clT?FGoAC+*cQ;$64vVgK_#1t$u%;l^*ysPCpT&!)gyc z$xJ#8hX+Sgxjqn+Q|P^M`s48>*Gs#6t4`a-w}!PPH%yPyH)*Z4;V{)~mSZp$i)yNz za~?{PQjXHb$*O!fL&|(9rO$8rh+46-Om;cDihf$7l+9=LY$=m*`>0$?9+PX@W4l$k zd5J2&y;hZv%h~F2842H!@i{2-4GzYS_Mn`Xp0LNKJTXVgN3}M2^0|2HJ}G%WwLzP6 zOYYfzy+zK`-`}jtryI}C^R$f9XQcg}X{HuO>wTv8T(R)1TrdAIS(RI5@BV4>*)2Sm zDrJk5XW8Ys{i^(d^+F^5137{}kZb(&(#}7W>Dy$~ZIkohkK|ha!c=C`i>H*BF{hLr zZ%8Sl@So)jy;Jt<$97fjD%9F~S6iIECZ5qQxv%-j8g0%OQ_i-}i*j^+D)pc4Cl5`a zpC?l2Cv)ljZn=8ymi%6ld!d))Ug@Ps{5oTfe-%Bay?f2`)Rk>eUeZTDY{5b2;DqT&}u5 ze_Tr0@_{t%h#x49(_6`|=kf6aa+UpsjIUqFx%3UW^LbO&^5#>jd~2yHe>tFSAV>9eD{_u)c|8uEUKmIH;(X-S~#hyS>-+!;yM(OEU zWZ9&1tW&qL09(m=nOuoZ%e~cUxl=qXPd#U3+?)wXsqRoYE0j__1#-@$3`?n=cDSxt zN;NjQzEaaeuHSl*J>+^da=F1SKe;UOQlrjS#B6^9s)cM-_{}+4T0VPGTy!|i;& zbawGy+G*pJFFxGP-%2yzeY*e;-@kxNXGezHg(`&Gg>#16Mf~C8smPgh_RVm+Xohrl zL^``>cz+gclJ5IP;qfdwINUB#C)|$C7;cx^5pI_qudBoRcKPPvcEz0G_A@8K`?t!h zbaq0xT{R}$u9iEU-8-FqEZnYxA#$IAc0HUA$iOr?b=E6Tp1&Vq&%MS1`{Z!@u{Pm$&ivta?sH-OvwU;I;>89=hT9+J4Y!Af z=WjNyLbyF8oFCYijl=B)t-|f^!)LYFvfknLy72yFn@5M+yQhTPN9KgvXP2a@o3Q0- z+VO(;uO^1a`|9R&-zTNBccrt>gxl9Dq_flExwbV;<%2EP4ut>ywL|IbeZ+lMh?7_8 zNYaLm*GFwhM;OWo;WrtG&#opD?W%H>TqbpOL(kF+^b)-yE6QiIUBW#M%fQ^Hntb}G z6oM!t@HgCg(0%3Qy;)%VT_%{t6w72+v7gh&1pK}par^PXTyb}-fcgEqDE$7sF_0-_ z2AP9;Hkn5jqh3T3$STw;$Xb$Q*CouZYS%+q&90BKx;#OEDfIrN)`{{I>P%>#);dup z6Nc6fl!LH2KW<EoIOs)v3x8a8PAHcI%EQi zVVy|=>%zK_WvmT4%>fprDYmkoW$)TA*>UWvV5?wz_H(dTa4@?mne>BS zJ|-UnM?Nke2PJdKTo96ZWFE*M^T~YhkSrhzKt@?e7J^K&h%5pTvY0FenPo{?5+Y@^ zjD{?-lq>~NvWzSPS!Fp{4zkJevOGL2E656vT~?Bn;1O9_R)$AqRaq4tlg(r^$RS(F zR`9rNBileu*;clNT=EV12IQ7+$+sYn>?AuuUfEf8hJ3P%>;n1ayYgKqAbZJPP*C=f zeV~y1Kz;y)Cqr2|MNWZoGG4~RQ*yeT4&~(xIRl=Sv*awOAZN?j zP*Hv@zlKWk8~F`9Bj?NcP+2aN3*lM$t^5|M$nWHL@SIGL2~brom&>7=Tq##Vb-7xu zh8l8>Tmv=bI=K#N$sgnoP+R^ee}w1dM!6B{$e-j-P*-k|TcDoYCbvO-xm|9D26Cs| z2@U0Lxf>eE{c=CNAkWIP&{$rO7vM#CNnU~`^0K@PFUhO&8ocb}aB@I1r;t+!UU7;z zMWDG;%qa#foD$9x(9$XAJO!rwX)lsybDny;I$(4zD}4o!ZdB zspHgvH=KG-J$Tb;;52}@oJLL~=;$ELvL_nbGKH=(=J(dh_1oVT5~p{En$#6T}6 z)`^AQPFJTZ^l`d5-Jq}2-RTbRJ3XDA(9h}Z^o9?dzD{50@APx}!2qYf(;o&p_<;|d zLCzo;oH+Q{ndD4@Va{Y{GW^4t;!J_zPP`Kj zpE%Q<=`g~X<;;SS&TMBkjB@5UbKp~Ft}_=#JM)}*FveNnEP%1jB4-hda~3;`VZ5`% zSppNB1SbLFoMp~xnCPr`l3=R)5>}X@Kmp8DNEk3n2_@i56;J_~tt@51S1PE2Fh@Dc zfv;6agWp<1O@!6KEY65(65R;`7_YQ0(y z->D6111wQVDhZaVO==S)sLg6Ke6O~ut*}fbt7KTNcBmb&LhVw!V5Qol_P{E&Pwj)% z>VP@`iRzF#1Z&h0bp+O`W9k^JQzz63Sg%g0Q}Ba2qt3tvbxxgwAJqkQ0g^&I#9?E| z7xKZTkU!*ypF(EHgv}v4WW$z_3`y9EfD~*CWe8<}MIYCa8pUKbVR5?w4 zA!o`jlckKl7xrW=T@0ajgWsaqn@ zz&inVrDZmngXX8jX&G9HR;Tr_=4eUV)3<3i+867L;dCsWOlQ!!bTM5)*VD~(7d=Ez z&~x-M?zxPvrJLvudH`E4(Cc_uKgK7LJ<9T8RZ)snU{zTi)|fSCZCOXwmGx%**zD%SUj7}7O(`C$dXtx+sBTvbL=Wh0Ut<+fb5VH@ zAtPkNmV#J$l!MAp6B@wF&+=i6|fePNH!P?lVJwTg~iyi z9yY@+I0UEQ65QmBn>+)L;yHMJUYwWVm3Vbtk2m4Xc^lp#(3JqjDkku*)|~_0w2lqD zr*)S=57aUES5K{D0==~E9O$ieY@m|f;?u^^@(>f;bf!3V^{k4t_4A8nu;6v20 zxZNPFV*(#(-8nE=>)5~$t-Az1M%@Ls8>V$k;2&Cd4h+{iHt>noT>{++!*Bbd>>L<~ zGBz+2WtYH6?f*N0Q69ceJ$$1*d}BO(V?BK1JY3^FToXK86Fpp$JY1i7xF&nJKKF1< z^>D>|xTblyrhB-)@NmuaaLw{?ed*zv?cw^$!!-g?yc3AixTa`aGc>NRJzR4=T;F)O z=6Sg0d$<;QxE6W+_N|9+v4`(F58o0G-%<}>f`{vS57%-J*9s5UN)Oj657%lB*BTGk zS`XJc57&AR*AE`9IU3gj-D}Gx7TlbJbZgSeEU3n`#pRIJbVW|T!%eeM?73dJzU2;T*p0J8#J!X8rKfp zYlk$hlOC>99u8SV7OCGMDJzQ5jTvt6@*F0R;JzO_DT(>-2 zw>?~UJX|SWzuis6=l&-F7oSN}@tG_Yp9vnm6B^e!jq9?;b<^!N%(0D0KDwDr#QgL; zw1L;4J-iO@KxgOz??Mm2x(^1yN0_aK;PuiWI1ESNC>(?1Z~{(ZW;zXL;4GYj^Kbz! zVxIaLF2j|ed&edp-NfRc2Gqu!*BaVFJ9ry2Uo3RR?AH@|L2u{-ec^o=hq>-M%z#TF z0ltT2upBerN>~M}AraPKR$Pa9@CVocKL(%B*ZIX=Ucg_yLWsUnjU@M;1S4dso&)IM zZo#wjJl+;AW9Id?h{x{$b8rVjct-v(ZkLzmC03d}+~3HX<~Q7@R2y6beQhm9fVo<8(#rtFZA?B{0$e6zJGpqhmOf19g_z!T8vv^jEs(K{x6>TOMQOk z-ZLP>$tPq48A(QwPxbSkW63x&o=hNd`stR>$YiYAreF`olWAl+`GU+KGs!ISC7DgW zB6G;sWG?xJ%p>#30MzV?gL^hKxWGmT5lF4?mgX|=`$ZoQS>?QliesX{uB!|dha)cZu$H;MVf}A9$ z$Z2whoF(VncLR}&xmax6ZR5&nKi{a;uWkeTCkSvRo05N#;W2qtTo!P_Uv`mfxUq>##>l!?1B;z!7uZF1iT%tjvnyDO zTw~YS4R({=Vz=2Hmcs600-(SEup%+AP7&aT0MvzgP+!+0O`$oofH$BMRwN%le;5Ft zz!=@Z9JK8=R5dLzKieXd-z_ykMHLP_(6V%ALd8+QGSdc=O_3{eu|&wXZTruj-Tfj z_(gt+|I9D*EBq?I#;@}m{3gG}Z}U4mh2J%TMg~z)R1(jK%Hmm3MLZ{}ifW>|s3B^K zTB5diUepnFMLkhpG!P9%Bk_W0EM62%#7p93(Nr`OuZZTNg=i^W#ZPPeyoR54qP=)s zbP#WdH^p0`qv#~w7Vn4{(OJZbE~2Y=S9HTqchN)i6um@m(MR+Z?~8uo1JPd$5Cg@B zVvzVq3>HJgQ1P)CCjKFYi%-M|F;a{YpNi39j2J7%iSc5Bh!YdVB=MP;EIt=g#7r?u zd?{v&uf!bjwU{fu5%a`+u|O;oi^R8LvG`6b5lcma_+Bg%%f$+@QmhiIMWR?E){1pv zz4$?F5I>3}u~BRiKZ(s^i`Xi*iDa=|><~M}F0ote5qrfxv0oez2gMy7sVxUUEB~i#Vv7L+z~0_uAlg+m@4AMG%;O#A!dl5#bt3t zTou>MuI9UDH}gHSyV=9+Y4$REn|;i_=KE$p^8>TL^`6z;>S6V?dRe`#K2~4reXF0< z-x^>Iv_7;3Ssz)0ts&M>>tkz}HP#wujkhLPan?j@lJ%K2+4|g?VokN;t!dVD>kDg! zHPiainr(e$&9T0==33ua^Q`&S_tr9NxwXPtX|1wWTZz_s>j&#cE6LhuZL)r{Hd|Y) zt=2Xx+1ge~7>$G*=x@i4uUAC@RSFLN-b?b(8)4FBdwooEnS|P76-Y z+LpM_W9s=>JuljeO=Oru^=#*7P#tQJZBPqplkLz1ULrf88N5PvYtGvX9ib!Hhg{W% z?1$knoE(PHFq#}e79K~AYQ8z9`R2Ihn-iLEPQn6MNKWb8e}&2{E=R$R}Nw$yW^@$O6-o2OYk zKnf$bN0SPqGHK+kNTDR4uhGh~Dy$lN-u%Q|Xu4}R-PK7|FalNeKap}*mNX)MtYn&z zR=8J3Zyw)$Ru4#5(i3?J&>o0f1jtFl$!O~X!sI$@mX>Q!ZnxH=+-a>tx!c;H+qa`! zZtc`^H_9KZy(oXQ_M_Zropra7ncRJ4guAcItoOa6+XE<9OB3Z9Y3UY6wm@#j5h|k?g7iCWsv_i#=FmG$bF-cqth~9MXWH~ z`XaTuaBGV?SY6!kYKvP~S-gl*;(yu_QQ5|`w{z5`!PF*{kWae&SmGe^VoUqe0Kg{Wz-LTol|8% zn$r41%sT6r+$tTZWat+RyT)2;tKTjFWgqK25G%v|W>(B<|aeXf|FKtF!lLGWL z`Wh)g+tGHUBz>K}L7t#*(Qc#^?M_FM%5*H9K$_9{bR%(}qmCgxSS;&7db3`v4;jeb zX9LJb>_aw$3}YX&;bf$~t2u^^Vwmq)9E&4yU_%Bn5i&wX5)ToOpG<>-P=qXjVo;eZ z)2zN#v-&oDjh4(O^Nr+`5i&wF(zt3|rCE&Y#&sHH+%#^|tUe4F&F1^iH-tXy``Gsh z&EXsA8%^{0#`-4E0=|j98MKIRmTw*{>s#PUq?LVZeVb@QU$QTmHuLTD?WV8z_WJhI zmdO8iXlvhHVswo^+8<5V`b+yu({=vx z{_=Fazk(d{3{K7tHH4 z#bVY=Oju2=Zmfkp(LTg_1#1Lru$93f!Le+WUN6b;%5%1!VQ)&?cr6Rkw7ByMKCHv? z>J{PMifLMa7N(`>bF>B>joVMfk1O$-UH?5>TT4A_DO!8+!;ouR7*R%6Bb)KCk==O2 zc+_~z$YDHg_xs5zVUL&88-zZ=dGzuAojUq--qnJ_LC_%T-t#lhrrrYTbx|8mr zyXhXfm+qtc=>d9>9-@co5qgv!qsQq9dXk=^r|B6EoWoVP2G`*R+=N?j8}2{~-2GSD zIN_ALYiwI!O%}qqM3QX!EgS_&5mE+YQ(pgj%o^kc{U#N+N^7g%WZ}L$s283`j{dKT z?*CM*gXd6FJZn1O+0vQD(jN3f`Vk#Whv6ABpDv@zX(C-iH|QCpKR9pnM`x|vSsPyQ z{*2KuZ^mdE%BNT{7R@TN>R6w=fYrzA=0Nj9bC5aM9AXYNKQ@P%@#Zx13v-4!)0|}{ zo7>Hu<}P!$xyRgZ9xxA@hs?uX7Jrb#-5h-{L%SLEUOsj6=z}YlvwDq7S@!aMzY;vZ zrlV}2{VI)~)zO|cLEk$4u5Yu=ugp2-T=N@qo;lxK5a#P~^MrZQJZ+va&zk4V^S|qH zDx;+3le8F8^L?6ZuTfNE?Y(r+O2SmSph#Jyf1mE5aJQrBHsLF!xY+0T+B zWVMIs7~HNfqAiBkJkczgP()oCk6DhDBY^lT6OO%8ofsI2I(Wo-tR4{zG%KkBwoq(Bo1Dy3@6GQVU8o&@QP&t$!RV!zbDan z-Ey9kH!qskNo(Ze0pty2-eII0GVLtV7kP9(8Hv2PlT5(g+e;Q<5AG-5A{!ngi?J^c zlkc!UkCG+Wr^m@s?AMbd0sHne`5qbaELnyud7dmsro2d2AY0xcE0G(0Bnj(=F^q*p&LLBW$VKGKD6FRMjf*ngxcFa0|MiHvk*tLA$>`l_{X(xUga1YL zUykg(m{z(!TK^Z>zi!Oty`NWSxc52q>`-b%AJefc>b9EW%_-Qr%v?(X<_*i;BmG^H ze|B$oucB$rml^ka^`AM)z5R>c_u`*F3d1}Y^=IlLcU8rosYGx;8-0u*qltSAz&*!0 zgj68A$RTn{zZ;)XlV-r9{_!;oYiSZ9*oEF6qi5(PiWMv4%w&p1ux#$S4px|zWMx#}RHX+^Eu(8^l3qgAx-Kx=6I7JWW7 zwnk=G^94qv)(ed+u9mm_maC12{I;uoQ~W`#m-?mF34TZGJAS2gia(_FU4I5w;}yt5 zT0iZi_F;RPkGP|u0Lr|yAj&4Ru&(@zpln8Q{2|6-D4WybC|l4HC|lB!C|{*dpln5> zQMRT}qHIGexhr+(GbmrD&!X%=pF{Z;t%|ZEt%kA_t&Z|-S`+11S_|cPS{vm!BO_sm zHxtT*hC9=E%g+cyq!x&;*3IabTE9YPYu%iFrFBa>N9$MV*IKurbG2?oztOriou_ph|HH0Ef3mw8J$gjz zrT#~?PVhgb^&Ni>tyBDuYkk+B)79u*F0G%YaXQu$b(AORD1WA-JXuHia~0vg+t$bED%Boo(C|>F)D{ zxrzHkE5~e*3B4JHXSkc2+|^~-y;Wv8XoMaWnRRbQ>R!vDWn10Xqujp<)z^&~^z+ax z>ZPdG`We@I$I2Gf30s>+y^XSF)H^8aMRi8mAS%Xf$0J0N4{1aC0)3G-rj2M@voSJN zlhivetTXG0XZlDO3tzxod0XC*De^An#0h4cInkVCer8TKKR2gfeq3&@FjtzZ%++S1 zxyD>8uSq6Ba%o7P6w+_rFmIZ-%-iN2GsV1X5sO;?u!dWoSR<^F)+p;!YqT}ST3{`- z7Fpj~i>>dhCDu|a!P;f*vG!R9tV7lj>zH-II%S=)&RG|%OSZ86cEC1m%eL*HEp5jx zU>CFt*@f*Qc2T>SUED5Vm$aX-qwOc{Qg&&(j9u0)XFp|^x1Y8v*cI(c_A_>6`&ntr zpp-I$d`M=L*X0d)Q)ZRfZWqV&Cu5PS_^lMKGEd~?V{zJxL0V!q; zM4m5WeTfYJjI|RPzNWR?+D&R{p0BNW{&~&wbu`b{)jVHM^L%~H^9?l5H`F}eNb~#) zn&%r^Dai0I+6q~{rDpXWn$>&DOvvi*BhJO7$sg&_Ki7l*mVUa|L#B=<(a{X(IA+js z%&OyiMK!Yby1S(kKR>KE8Qn=QrBR(^H(IJ_k4}(Ayv&? z&(w0lRL5U!hhRzkCbfF2e7|y>i88awsq(49>IoICo>Zk&X;nr&rJhlh)w8OKdQMeU z)l_v=L)BLG)l2FXeblbrk)kz*jQ5Xa|LU=DKfPDYuzerJeWTLsOI3wGv{j*WTQSm2 z&(uxVPXoEz&B8Wi1dIS7s<|5h)l#*_$h@Z76QAm!-XNxWOLanT-%;-nM|D>35~bc# z?~zQZhw4QlR3FusWKsQ8Kay4TR|82lHAIafkEpR~3dyJ9F`gyeH9Ls5CjLfkRU2FD zsrn?pYN#5Lf~tvXN(!l0Fj~dbU^SQ&*WMP?((MHx-SIOA?HS&xroW5V?px;mE?zU- zuHk*}Iq8X?xx{^Ehx-PGsK1&2?we!XH_TG+Y;Zjfd7isIQ|+^%eGX`!E$y?^K0Dgy zjN0c++UH2^bCmWutNKWNM6%!a{B!O3Oj1I9iT;;PGY)F%Gp;vr@8i!7JLCRSzWmt{ zTvNxey3T)$)Jfy{pFLh`eg&QNU8@Rl-zp#ZmmN2?{={n^tWS%QoZfNMZ&~ktp2w(b zZ=&@3YO3PjO-OUn;4kqf{jWUmEhl=H{WpC3b+6o29JT+VC_V7!zv1kt^%sw^T4}~Y z&NO4;x8hyv7g72B5mf6pM$q5!{I2zvpNS8m&-9Zj56-E-X7r}-t-t!r{q5fEPuzEy z|7Yu^2YxlyvahZv29mzSealey|8Pa|z?*u%@F{im*ngK#Zsp~^+3N36d8PIJUft!^ zjen22E4}xN(EEhHk@x9GkAY`=Z>)Rgk!4<1&-OP&!M#!R_wNb!of3cFo^acY!uqO?mUUhEyZgBBGR=tl zxHB2rJ0{!Q5IH3P!>@|Q5IFjP!?0gQ5IJv zP?k{GXR0K|nYlA6D5KSrD4$fNP?l0yE4ec)D9fm_D9fsHD9fp*P(G!~qb#qUM)|a= zfU<(Bh_a%pgtC%)2IVsft5i3}DBUNijAn!xAGk7zOiFF?KK9Tk8b{;lY`TCZ&_tRBpAY4ucpQ)Cv-tv^z!P~A zPv-mhF@BC;;VK^*OK3>+nXX z8@SIpqi)7q@%E_OVoYLCzspeD zD7OF4-f&AOk~?KfC_?Bxga82n5fKp)k=~>Pq)Bfgs34Dr%1`Mc(gg$rM5Xs8D#b2M zMMOj_Pq8cTDE~9xdlM4TpA`KC{_mNObC&PjojqlC=A1LLv)L)eA7WiAro!*~^E1us zPw*FKTGU_KpTsngEl`bVRew!?9j3MY_59pJu|=BrTQP0HcIm`4)!)_Mi)jykKmTB+ zgZvNpd0g-hXKV4Z;UDcE=by}Ul7FgyHq)8@dHzLA7y6g_S1?`XU*q4zbc27Je+Sc7 z{jdAqW%{;%pZ_4!5B!Jy$Cw`Vf93y<>2d#0{!>g(_|FEiQB1%W2nDWVnlm5*92Exa zK;A$C(*l8_fznJ%1`-36m?j6R25K^`5vU!g$Fy#sL7)lKMu8TAwoKauQUhI?b`JCi z^kdp5FevZ<(;MS9@9C2g@L6^mjspt)-YWa*bvyp zbW7mX!0SwR1>O$qW4b5sLEtdcLxH1#ub6%jI3DjxVJTQF@FY!ggn+CJDh z*n?^JV4vV1rUQaQg2S0U6dV~G&GgaWnBXL)6M|EMGnq~g&IvALIzPB1xQywG!BxQx zOxFds1Yc#kJ-93QHq$qQdx9S@JrFz;Jj(P)@QdJareB-4u$cZ5JRR~;Oo&1OGnXC8 z9*PUuOjRh}%&3O)hYE*EGA$k|8%k!H6iNxzU|KCyE5uP^s7|PUs1ehKp=O~rOk0K8 zhdMLu6zU%8!?aguKxhcl!J&sjBbkl}JsKLr^zqPy&=jVVL(@ZZn9dH(4=rK3DD+}z z71I@=b)hXxH-)x`b}`)%dNZ_#>ARr=p+ig$hK_{3V0tX{b?67C--Uh&oo0F}mSO`G z6PqnIdu$xj>ta=GJX1F|e=I-$u?exoWBK`yEghQ_o5Fn(_t>V)m5j228Be(nFKiH$ z70lSmND&}KfD{E%6i87Z#eft8QVd9OAjN?c2T}q^2_Pkalmt=|NJ${2fRqAK3P@=n zrGb+rlmk)@NFtC#Ac;WA11S%rJdh+HNkEc-Q~*)|NChCt zK$3wZ1E~n4B9MweDgmhkq!N(IKq>>N3?v0e3Xl{aRe)3hQUyp=AXR}>1#$zB8-UyZ zq#BTFK&k<$4x~De>Og7$sR5)0kQ;&A2;@c}HG$LwQWMBcKyCtZ6OdX!Y5}PQeoCke)z#0_hE;H;~>y z`T*$zqz{n3K>7md3#31g{y_Qz83<$`kbyu30T~2j5Rm(T+y~@7AcKJn1~M4P{Xp&q zazBuvK!ySt3gkf`4+41*$U{IL0`d@$VL*lf83yEGAP)n17{~}9BY=zmG7`v0AR~c{ z0x}B7C?Jmjc?8HKKpqA1D3C{iJPzb>Addri0>~3Uo&Yii$QU4FfQ$t)7RXp2khMV80a*uR z9gy`v)&p4&WCM^5KsEr`2xKFWjX*X5*#u-0kj+3g1KA8@3y>{9wgA}*WGfI;ntf0U zP%cWOWJ;lGc!Rnw)u)Ekgj!G=YHwDFaAe}OPLuBr%{uN~h?%?u{XpR7pKi8NOuYRj`j!|MWbm9@-owDHqE1j zw1k$?Dq0u)VwWA)o@3FDah>As;b^pLTz3Nkyi1Im4Jj$5(I* z5fRL^WxkTzFJHG2$D7P?rff?WWclV9OPfsKxpR~@_hxp_8JS@QcIp4|3~YVK)F*Cf zl$SZM=J4no*Z(CqKGslS_E9OWG5c@V$F*i(s{k@rg?^XQdEDmAtkZ~SPV8#tbv{3> z+1SqJFaE0MHibB9L+_KuX$<#kX<5eX=pl2YUm2Mt&3Jt??hj+wDh<(xMr!zD4lcF! z7Tj-}70B__2>tV4UB6jn;itU#Yl7bYuf9WMWnMzG=FAo_|C*X^2eXrxjS=V1c=+R5 zsJqs)k@c5HFynI%H3a8UmAb4xz(k${u)>na-|&pbv35(SO9%!+IX3HeMrXV ztRZW}JU{1K>*cRI$yo1&_rK|Hy8ngsn&+T6YE6i~1NH(%X79|IH>1222 zBQ^eL4>N-C_%$PLR@_`3qZY=kVJ|EOqm6mG%!t$ABDnb9?-yjBmq*RG=i(N{EoL7u zBIk&xobCSxIsZ&T!`Y0eoUNjA*1ZBbe=Fhn`mVv|2NxnK30qh{xjcn zu1y60>eAoyy6z9Is2AlZU@}N?>*F@WZ8X^?GVk;oU$RcrpL^+VxoaeYW%OoN8<}s@ z8s9Kvj~Ws%hANUG?o8e2RF;Ef|W3r_qJV4w2Y3?Ry8ng5VxtB(Kfa0|2t{R zT!geX#UN`ogm$}CIzXfIEYnK1o z*VMfQR~%8ZHkyP00RjYf2uyGb?vMoc;LhMK!5so5xVukq8DMbN1a}=^aCZiGn2-0| z@7#NS!nteJ^xnI=SFi5fyQfQ@s^Ukuu>}QitrBiFcnCG^Ft2f}pRA+)vnWbvCvD8P zedGZ)={3spkb#49vyuQ2xXPl3(rsOHGm`QIj?X}~0BJHPeifPtdV+y|@7@OhmC$w*3iSYR?$y8|{=>mIg9m^&3 z2jv1BDQLfBu%R2uIT{{8IO!vw_=S;p<&EX9IgKzqMYPjX zwa*ZfoDgORYppQzb3sws>GJyZI#yhX4P7|fwcNk{%yrWdX&wmgMfH##L2?->!aV6f z&UU2lc=eI?pZv4!!AN;Ua)8ZK!@B!~1`SlFB(mk?25t{4q4k}Fk(!s-&w^%l#xKck zC?;3Q9e+2z5yHRwTlyE28_N1qOzq|%aza)YKjFhuYt!LKxq8QG#wqNy<@EkERWAqiRw@@Hk98tr7?!SP`q?cHSx z^6IZKZm_Z6VZX;lUnQa(dD@OU{VsY4T31<@)_(5)oBt1i=P&&D7N|DFe4KnX{kmSqUVZ1dmiVd-R%A#As?QcBY?q{()Vjob>V3uW{XOnY zc(+NkHZwH*$|QK6f%m`h38}sAXAzznZFKHo*(Ry;tcIx^oSB!yKi?08BC zJqA$Dl@`9Q(2@=I)r8wIWe#?q`wK{5C1VeuZi_pok+V^y0JajHYkcLD;+2O>odfk5 zK#7rC!_KX~k{wLIM4>IEW_f?c&ICjPaM*Fn=>Dm4`}+@17t6U_z!AsBl>f?tWIe!Jlqs1r^1x}h1Urb4$}^D4{L+O*oV+UMG` zSIAa`Rt({O4M$fSl#`Lte;<$=sm?<9CFbN=F;u*CzCw#KYMPI}L~n_0<-0a!(OIfW zzhF1>*_<1f#M`%H&$U{B$Qr^T1sUKS?yvQl6jn5`!{SzD-V)J;={D86au_q-^?`&g zWH5zzXsvpC-t|=Efy))M{cNEtqqkP-r1`#xfg$~*p<{4k__dEv8^vW{qzU~%_2Uwr8ecuJi^YXomR;?5*cn;{1;vd5%aLYO@zf~Y5QgSJ|Mg^0OF zu3D~~a5a*uCw7DWtoBy&kx3~maU`C;(+1z*L0 zI{2`qEua*Wink>pAUl?Pu~qBbq0i1Y5bd0}#L_rW;hg!XO_>530Gz9Nsr%E#z5rT@ z&ZC5x#~D>qn2K>Vypju;O3XA=G*pu{BFgg0%E;7tGz@0H^Oa>B&}qomt1qj2xIqqb zy~_;C8cB;nz@fA6uIe(%ic0rwLNZD%N*4}D>h&wWGtV+nfNKxQ1&hZDA=AxnSy;BY zg_^pydP1<-oXQeruf?|1_T!bqmBkgxao<(Be;aQjZMB<;%d_4x7k3TK8g0X>oRPGf zIm(xiHBZo)Z$gqE{taPwW&AVlKC4=jn0Mf`yTP~PLvHYa-P&@@M}B3Eg*p8 zE}XvC>?|pbCg;V}^#bP^`M}l7!l5S&*a?g6H9iaHrx7KP?zP-VyVdul?!X6z2ZTd< z`8JwvHGS#T%%vA(R{%o8Y&GB`y4GPIbL_h`a#qAkqygx~M8_haCkogd*mnN?0;eCG zeSxbn&keRagO=E_2X3}e&&9pI`+ve7#5*q#rbVzSgN2zOL&fLdLygxO-)T#}l~pn2 zMU8 zaVOJJn(>@iN;6q(V7v$-!i1aKr;q+nO4#8B8r^)9ztB(Ump#4ZVzFn@m5Jo|`Z20I z|KcRpblHSFrtn?Z5sRf#eCFfPB6@_mn3fvWwb~|Q>Hs~usy06E@4*l;cm!vjuvcDC ziOij(dHgrzUd%jF0$iS+XmyDtrUO+Dr`8f>S_vd4`efYbd1{BH*Ot@Ve=rbz& zec>>{`k>;C5A<;i9?zEnXpOAknS2);8|;)Ft0%5SxrEt^fW=}Q zz(L$;$yI+~*uwYB6G#K*1Aqyjmg!QydCX>G1KB#gpV6wn!*WQq@B*xb3^E9H1gF{i zGWz&Nlz$I~X8ZbD6ceXbP~SJR1fQ%s{5`WEckDWoB6sXQ({xd=_Cdc1*G(MHUlOjY z+4f?wmx}0rsXrJceO*73CH=QzCP@0mbS9?1{S#?7z=eRc0=%!ckGA}!v7p$EsPalj zM2o$scTB>Psp7|6bE|dmLxt9_6$wZ)KaBEF+Rw&iHCjKT{XwAzDqMuz;l|hFEB0|I z#B}F`oaYAXh6BgL)AM%W)VDF(hi@aVqvY&2#){Gk{!?=MuB#s5_9kZ@9f6ULYn~bW z?cs}Edg%7Dp_3%#9mWj%iR|2WUv+V!X9o-AzTD{I?0C2FN0B1w#0J-S|1(bPJ2O$8 zxSByTop6(F%o>#KBp!3th{XB@>4c*|C~a?!rMUC_q1`vH`CU7yyM=#VzbmyMr4;I4 zjy2&bZ+u)HN|MfYOSpYpq)SeEYN+p`cl(J{iDwGqUUUUp}PxuJRJve)q=sXAI?6E zJ^15{vyUqC_frO)VMRH{?I874-&hKZZQfLO8u^O(eW98Oz3jgadw%hw^j{L)%RePQ zO9d2n&WRm>F30bQ{8T}cyG>|)^9y#J6m_P@NA6e;f2(iTO!O{nBB3vX;C5<&MIOf{r;Q3 zN_NxSvMlf_miZfV7x%E6>Z*oQ7Us;=Qw}a-uZX=Y8Ahd-GbDxY8GtjX8DlunCTzQ> z3|z`%#h%UI+46juQp3`T!9xDeoc2g5n@4Zzi*XVw#u6qM#V}r7<{OZ`7}db9~SbNsB=opRoLeYtl%HLG9`Zb-CJ6$ ze0RcUOl^$ueqnTfr%tz}d3i~bS*z)<&9PvmZN1Y)>s>{I!`H??vtz7>DfO^cS%-w# z?utFl8r5Yc2Lf=S-e`rz@+Q>Pu|}}9ei^2zb)(r>IXh~<-OzAy!B zS$*g_pa20xLudKKAHZHZ8Eduahf5=@E6`|1dB9`VzBkxO{=T<#WhX z47Y)3%Ra_*C_e#^ZsPgM4V7*#6{4bYUX3?Kl#&Khwtl{QX z+hVZ%dqkIg%LMg6cp!x?YdreEqNUlPdy>c{pDGM$faD}=;(aW@Wk!~I$}bk(@093< zapCB{l}GktI4#vsdaFV$u(_A8goCB;W9t=u+wasFG&fDP5Mk_xS9C%0j@v#ZpC3j* z#t2UNq9Mu>T0PRa9m*7Jr^eVao%p8Xn+rY^8|9Y}_*L{5-BW|RzJiW_UwdBY`0H6aLBStTl+YR#o6QV0LT zcvy4N@}-GgE3FCIHlchkdIXn?*bzsD=wDNcnBoJeQ1##|zGMf^`+VI~dWUtx^S5rs zA~{8s2jznDH_jjuc@gc%W~&c3y3PCkC(Xj7e|5`y00@XXx`;_Zezue#3=_D837~54 z#icCt$OFidLqk~pI%U^&-0{_tjfV+LB~#wWdo^3i#r-ymC}&!Ya~x`X2|E;NM!mqL zlGy#E1b!_0Dfhki^X7z($Uu#x1%ZMil{JQCKmg&_n>p+*y4Ln5GYi*AJw(wscUFeS zn1$hQ;fH~7LoM0sw{nM5_`LdwuT~>Oa&I^S94E8h+gtByFo-u`^t0B1U@FJ-1wOp3 zQE;Trq_qIj8OY5U-iR{9<22~P1Nft3a^Alk#B<&89M_&0dr5D1JbuZJZ<`}NByJ5P zmj(YTFSs^MaZ1+B|193^@%TGz@rs&>SVW8jU}UX-hVp_I!e-Kq(9Ej{=jMNIAGSxw zu57c_B&L(vy<7A60lMbC(X-SB;y{c*&qQKs0`_xkrdsumRp12=80&HCaf1rN$a4lY z&7srGv2L{zrcs>;jOjJ@((nJZR;JzT(y65a?`7EkeXJB zd;HD5l;c2LK#2COj^9tcpX8E6w%@g=#Fz(kGCl}KmLNR{E|!q_2!T|Acj=E&C@)eo zpDXB5(_bDto!~XMLXHZd$cX57F4Ep{Rczg%j`5GWeb3_zy0J%yVVg*p(&ECfovhG$ zXGbpTLHA`!>)^5++za9fiT5JJKsb>pI$8!y4Nd&LMvP6jF)lYaKKI*f>pD`ntQWH1 zjozbBu6uI-gT1=00+{-rAk1mI0ul9YZC6y_y zMtl6bdF6P1#*SP|BxG4BdI7v3J~|H(`040<$4|P|vD~w_zly1-GNrl9$C zuX(AqPiQ)8bY}P=?qM!GK(}9rNF(&O({IkG-QUzYQA7Fs8lF?pZa} zLoqJjFl|1rJq|opnwH{m)D-In>c?wOm%pCR351sqGWQjYI}n-Jldu?@Xv# z$(=scB|C93+~se~kEDExP=dJeK{&oEZq_#?I!b)S#nZ$4hcB_ILP%MGV}CBjBYD_@g>|M4<*E@Teev7EY$-`T+eGRb zIcsN-*E`I*CAl_l-7Fk$Ne&7PzYkkA33hJ|%SfbOTm|1#>lUQ$^VGe>ap+k$Pf}OR z_#D;N!S*Hv|7Jv0$JFpXK`o{7p&ZcCs*caFx|mu~ibEl2simLerOi`U2CL1JnX65< zNd<3%QmsX%u4R;aMQ!=2Iu*(2{hRVdwvG>njhXDwz4_Gadf|c8#HW*gqU%|%N_QDm zj`hoPQTZ8je)esRZH<2Rib-%=#|R_Uv^7-vNy}q{xzRXEF<-nn1TqB}E^MmR+$eL%1&ZydB4JZv6eebjH44+a>o4 zsE9puj7NHWP0|ItLVFQqzg@HU#Cx#`HBy8=f+ViT>qUfC#)3zHXfd#1Sh%~_)^md5 zPAuSxJ04RVgN}elz+=2?*@q;*5Kxv3cDzDyQz(P~q~Knh zamAS#M)w9+0X1OYnI!Ap&F#R43WfalOwavtbL1eazNnZ&nc)QWtj7+ zcT#Y|+MhT4C74(TpHU$yL_a3*$S>cZwnM^hh`~^ryI#2(!$ASR0e+s<&72i|3smtkLyO@VxIMJTIx?U&8L(W_B>w zaPV%wy^rvfaF@uVq?ycY(x}WAikG%>ShZEo=Y7=AE~IaRv~AYSJS;pAz%dQkfOF1^ z!*f6H(H&#b(oYA-0P%OH1DQ1O0%v=2 zOxGBp)YDII8vBw165y53nNc1P1p&CYFjkf+2%P2;>{2|ZFTYC=$Xx!T#dNOg$tH6Gp6}Ra@KHX?V3}^gPIv{wfy^U*=FEv z_p;~fz(XRTeY_9d7i#WEx7@HJX0ExWtOXm91x*;vnR)7qFd z@Xv7UF0+(tvQN5Jt*y)q}leb_Ff3tyh} zR%BN439#U-^ZtaP6(Y(=SSo81vdLN`t@ex6wMbF-VcCm8+ANVF%| z97mjvCZzl)G3Q;xy`&2wW8eRJF7;L+Q$bz2mL}75BVLLRf z3P$5`@@0VYoi6q;nN)+R1Te$ORh=KdP? z&+=n=aaqi}HRCnXa=?$!gmSjW)Tus#$UxrJof1 zBMV5&Le8y36fgl5hHE*~yaMLuRUlczr=R{mMVj3mgjy(Dm|rcYH``Ld-Yl-z{77DW z)}680xto~QzwhJyqNj#^?q#ktc%d-S1Q-YJhP7F60(mn30CGm!^{11uHsk%iuIv=LFzJFhyn zu7q0-i+uI4uP0Dl)AH9L z3wlB?Q)Z{>&OBCA(WV)svBYFctEW*xs>uqD(O9)H2IQ0;%s)Cp1##|T%5fbgWC$Wl!(R0ar=JVy^ijR^B2^vuff=|1@uEJ#A#v1FZ-$1 z>m>8`O6c*PH>R`yOo;#abwB%N8Aa29!J62cRV-in)0>}&w~qvCtv}(fpSe!80^umn zhHSHW`VORi4wU`vzw3Ll;N}resXPV0Idt;^x$KUO2)OKsa9N(@ zJY*|>etKKv`0d$eiw_Whd&fF}`1x2ofCzik+zR;i%(bOV-HCZOJpljmY`KMf-nK9K z{NY@70RGMJn?LqB+tv*YToL``4$|)8$o3Di%PZ7R7i}`>vBR zGkKJ3;A&6n^yV#`+1S?A4wFiUn@SI0VAig+K^A%8hzaqtvlDTDj_IU zm%v>75hc(U**DzB+z0N%HBGN3-<=Sse^Yx+vMEGm{)laXBU@i!-LFe z%(f<)g4VJ>77_vOO?Illmr}xu%3=$SpKdhKoBHhBLMGK;Xu9P}W!D?lRKYKV<$Hyg zUF<#_17P$>Rp}r3XBvn^Q}m4&>1<80R9`uqOOrTJ0Q>(|&&f*Gx2ZnJw62vHh#EEm zq*^b#JeR)`UnE3wuq&Iko!CXqMVqvp=-Vgyhm33jEDAA+Imo)g7CuOggm9QNkV}oE z^oK2=6LVzhmurm0SZE^bQgItj9ov}0HP7N)`rpd;Sd#O`koHHzqu|k0QJ|>lJ`y!f z^J#m!w89aIX+2jv1vXu5y$d+6gC~}CZ_F*;Csb25mjLtVyM{)Kia0J zQ272`E23%1G}dwM@Bs6w^|H^qET_n0ru9(d((oSXKG@q)!~mXeX>dI`*jedk3P{7Q zBE&)*5S~0@0MZbvB(;y=SWjz+RrA|E_DFwL{oB@w>@fV%O^J(llc7k`I15FeE2Hs! zKl6WV5@-@qr&ecG=W-<~-*)0LWF?>Vjp;6w@2O&pD&tV~k@mU4HgX3_Op*lc3E+)K zXSg(v7HjMu*HO2}8W51*lV^*AirFWc>wb>qU+llgLH9I%;T;M@Q(Aj8_e=%~z;SEUN5g^(IQc~sZ_K)3E0>#>(H_2;Ei zoWEp^+;?^x} zHPq}K(nh5$QAd$}6-sZOvRq;1n$;*rV57M5nxkd!q56HqEF(TKktgTrO&4mnQg>x{ z+gi!m?HX72Rd;MKYEXaBYN&87TJG*><47k+ZMTP+AKl4h?P(%0_bwMN7k+oM^HQeF zQm@8;g_)n!=Gz+8?hO6@kQdPrF1d9j_gu}&s9=q>17l;d(9rU>p>t6dD|Wo}km)ws zxtRdXc*5?$#kSd?JeF-iW2ms@TV)kE2wVeh0XKjH z!QtTF;CgUAxD}krJe|BbpP%)XG=`Jp&3y2{!1duBE!c`yRATHc6Z1Rh0+W&On! z3>V!HU4NbwU!oVLR8F8@;}G5!ArR3JZuC6xCiTwn*7b&Y3wpPBdvjQL*NBJ#WerCpm7-ZVgH3btTSL zO4%f#pz7bqU6ZBI8x6=j9JJh#_S7At-=V9)TLm1`)hrB!{Y?MZO3$lDor-<^I=`oP z_p@PttN$M)EY#hfSQx*v1Fxi}Dk1)yv@2 z%Y0>3{T_)wLxcQZU5S^GcSMQg3!&S@2k934A!N)WJdS#==`6HGV<+gtUWAQI@q?cq zsg7gr`$%#~;dI=ld~tZ;bd@VOXKpvaXQCCKX!l0}g3ostycURARYH6JqhJ1ivoA2j?q71{Bl1oEL_Gq~33 z1^?#n1#4*qO%=0Mye*SfND4iq_6@#!6D6%6H#4q%`9W+IjQ7>hn@Htgq zSKvp-;L%=6poyq_`%kxd-uLCWHT@ry`~+Dj=eXVx|3#+2 zo4$^d=q3CgANk+cb%%dkoonyyIAOhZbF7B=MEWh0J?QdISD(0+3sL;%Za?#eVk*78 zYnkr-A5`t&PKO@)W9A%xx1p+U{Jn`yL|R5rsmNdMhEbV2cG1K$y9v9OT3X#SyJjYsxoR07|W>>a~m(jxn{wb_eqBIorQ~NX&!>S8*kqIzT5}Iu4sd@K0R#;3!NxNOqhCx}H zn8pZ*X-AngLyX`8v4DMFOLe(;6>Kth@IDKZ>j&@17dEORr1J~Oki9^&Kd1?Twck)G z4@mx>0k!u>lBPb(FiM&KcdsqV3oj2ffiVs3T(`&hZp|oejz(>U2CsfVf|XMxQsKY4 z`1N7QIo9|^)w*VRgy|)cbT;vfHj}6#>7oN?wLq%L#Qk7f3#ue2YT;G4KqoeIQTo!X zrplGj*>|6%+LbV6?C+G0wjhSQrSX5pz)RPAnwOQHqXeA9J_uEQp#IOYe+c*g9>7~D zR*CX7iMJnHbDz)iV0ve#|Fy;mc~Hs$o&N(l3T#ax4*0dEm*A7$3;+K%lFDYU^h{ns zxnC7xf7wU+e*Y>dCwR)`yEm4zT36uO=Lr=0ptehL74H}Z5$U8HfZRAv;f33Kadwk5 zoZ$;7WtiZ5353PR`>(;;4*WZ0F?S)R9bcPTu>bl7PhgWq<%}3jCXz%k-kA&?dd_LJ zWd?FX6G>RSImN3kKU80S_@A#ubzx{&VQ5||Cw_ZU?q1=0NoK5G91i?uk~OOz?}=aj z6`1%jw_bL=9HD-x>a6mOO>RIEt(k$Eh<1yHDASRfA0-Q845>J1`itSWN(O4Jq_|MO z?EldJT>f4rz};}aZ2D`rocTGgJSy%thm?VCv0wf(M^U53FYkKC369A!pr=JT%q{lA zAtm+wSCYK`L~3tglD#vth|+Wu8@eJaf~P-As})r0FL#bC`Sj<_BsLSn0V)ChRWQ1Q zSjm^m|JnRJ^`8))#Lm!~!Ooz+|9TPee}58URylj9qj*n${&OA5m%&Y}_A0n=RDv-7 z?}2;&eX_7QS^~Ig?_;USvpPqB&E#WgTIX$3^}Gxi{2aI07ZLkmQ@OjuDu!D+J%2QG zlw$|5qkW;mwdy-UZW+TzpN2QuKT1C$yOX$M<`N+=`;wcI55x)BdGAtmr*&a{LE@5f zS1z!q@6chatNno5N8NBOXF=Aw0|IfSueGEtkgJkLo_^vnRH zdxZBa5`YCIeyAHk?w9ppzjT#=a#Bt~8O%Q=#`IxI`Ekx$b)}HU!bT*)SzB%!li)Pu z03J|p$1(BElWF>@?-Om~ut$1hWc{9i-u6TEoZ$=lE7cDe%}FI-34ZLnyO(#+TH|!o zq7kCGSWw|-e10cvDTI{T-j=XP(KfP}{c%hNZ+^j|!8*IPw|iJybq3WOr$v z#Dhj?Q8c`kd8}gB&TY?w;Ud;wal-#B;}0<2HpqVgf_90nkGE&uK}W2$m0_fRZ5wnK z+C-Pu`M^W%`x}C`zIvAN{Tkavk>NoonS{tmX#)^!?%pUEUr1@%>eP2+UzF)}q?85% z6HLV{2RG0>-WXBiMs|DV-VaJ%9saz>{PxtB%X2m(ic=WfpBev6nC?%3A3%;#nyL1o z!c3(Y9AJDh4_U;p1?KeRl(5FD3S)f^82fWpR6XUn=qX*^j8m$Z0oX7oe$btn>L4K%fIeruL}lR|oXX*P0qy=+17GA5Wcb=lZ7Pn! z0K2b!*cjBWy9sN8-m=PR$?`gB+bUZkLgR$bxk>t+6J_XzV~sB~5caxEVS>o?L6BS} zzm0>?c4A#02i9njyp(#K=(5iMa1|gAC+3Lh=pCf{d{lDy6UaUtQC;_u%bd|A1YyAn zx~u(wG8!meP2PCWcv*6TY_oibd$5u;+d;=wL|0n|82l}*^O-Sa(}4iyYLKPaX+UA3 zToCqz3!JKIyZ#bR=_+}aJ~9?jSRV-cXkW!EK~}bH^RI3lCjJYt;-zinxtIIhaBuRv z|6cV3e?^WqY9|sSQKTcy({FrH>XcNfS6>YPfWHW~0h z_aN13Q{F&1*addm3B-da>bT>_eli_=`zYw2a_jeKQj=9q1CMvRE%aW8%=E&i3O|KY zVVLV6K2vlOMLb!Ud3?v#e7{24CUk3Ynz$zWh~n>SrDIhA)wRtpwAS%X=!icTY#Bk8 zz$yq%H=_ECO8yoL#Um3VoP1_9m6$n$ooglHad|CsjeaLGie5mx{Z^H_oRvF=olLiH z7p-D@M)zW0m+W(NK$-bjTh3dDt%A8)0WamDe`BvTUbG^%MS0d+4k~_9{;_}yM|VEd zT2?U|Hg2Ht{+TxE5$)7-b<$}4gnkb~w|7cQ);LP%`M9~MZrbbt#qCisD{WU+%Dd()-G>f&a2j1ie1t^ z`@SBOQL$4ZKBMo}B&bV~zXRT!4L|tgRwJnBCzqZ3tl=l+r&+JA=cnXnm<=9NLlkwC zbd4msMMYN}jg_)DDyyG#pAZXY_P-1Psc)PNod0QjJ@B-Vg@BH`*n~$LVix0CHa2 z-sGr{H|};y%{favC(9zvgnIXS=k5C9m9%rlAYO}U)h%9UpXS6R4gc?B2`;!obTK9% z&`t8K`Wca){Fzp^3ov{*qEowcH-Lxj%x367^D)`^g!xS7jA$_hqpb}uqH~^?D8Nl{ zb@;*gjL5cd19IY-?T0W5*Fy0zH&{z{=}5g%IMYgi^bE6{$+11-E)s#+pWf z5?kXXnj4ogJ-0iYfeD|mlTZ`DxI(nI(Wf zx4fmrphlUs!h4XqKI(v8T0>WA<$@(Riw*w5&*mG*VMMJ#9-QF1qtFq@@{wf1AOrN;Z*>0B;+OQKvWewZNI4Y@w;n!r zpRH3aHUu#~vO56pwRUSQYYl5HdSpUk;O;~Y$&dMo0dE07KlpkfZg`kro5_wH zYSAwZXl`DTbLfu=qI7OqQl|Wbohm(icfNV9?o}jAmcK+eB)>&)ZsS$)_;r%M@#!4r z?w4=bJb$BHWm%=3zFUM;OTC9%?_Qt03Dz+j!g3L)JOc zxrfRt;=_jF`q(R@TkS@!h`disqLNxejDvFKg)ORTrC@IL3?|m(207`%*7H9JgYIE=0 z@LcB1rTJ}*3&Jgz1 zROOS4=nKV<6~2kST(V@VEUOp=U`W>u*5JpQ)=LeZDv$u;jpiE&DswR3PmM0J?lL2u zDnU;?ZKO7mHsm&faP*o;J~L_J?}#J1tty<0)%eM1(k*wxxyWW-e^;E6r|rj5uB zt>MjLP^l5=Jju};cjVT8@%cQGP6PY^7K9fyzON-HdDMKS=p0e9@zRa&@Jzv7GAs;I#@= znLwd975IU5D}4J#`_&lIZg2n+(NFdu_CWSvjBc$i$u;nr$tlkdv|GU@e0>x))YVs7 zKOKV{1DU%u))v=nPAQb!E8A8A{vz5D1qf6`>@(=G__6rO@=@%e^YSIK9oD&uh(`QG z{6WMast_`hb@|Vx&zsfOJIfYowdo5>ZT)t}E4B^X@XY`nX7}Pmc!28CeiG5P{SEvS zehEK^U&GG=PF4>Qe>=xJR}i@Y!3Y3C3K8%~^&ocUc<#uS(a?)14Y0M#&{=K!XJ@ox z&C3nvBukGTrR&tD3@97D+F)`$6Ds{{T`g2LUzhj56s1+hpmDc-j4|+hyoU9Iz6oOq z^W?P{>P-;v=R?=kTId?eDc}d-mg>~>6z>OK6UovWe{_d!?!#b@pO{@j-63lRMt~}g z`7QK1qE==U?4Z!De``3W44&_WvHUUo`}e6{zO|u0#k=)?5c8A!z8gW3{)yKWzP6cV zL2xQn#iWf{jVA-=8J-H3)O387z@8CH@`SbAnk*2Vr|>*Y+ew9UeQWTvVwSdFj6PBab_cFME^=3Zr~~so?V~%X#c+Lz)lMm1Q*jLzDIgbD z*Pp(#_T%vnWkBVuPNsa!&_-`3UIo2x|ND?=mvKZD)+*ccC2HH?afRdBpiuhxwbye} z?ZTF!IIn()NfTzQ$`FJ7CsIR7fvA2=t{|y_iTBH&qpMV)43_$%rlBN*FPa3wrc{2J z%4pMIiSg11AoQ%sSlEU*mZT35a^`8=CnANhX_#N=1ezR@;m{?sk7|%An;etjf{{@~ z#Yh!b(Pw>NBVeOYdQblwf(~BiGzQL7Vf_l$vU@bZ%zJ`aKCgv~!61GKabAKA^g4$P z|8g!-Ta4Lf|MIl}&d8h)y#V2=<|m?9)PK+-Tx2J^7!}G;-R_qP>hGXod?7+>RPXR^ zmxQmQJC9h+~CC68&3&FyoqBN}9U zH1x{bi&!TO=TdBj>$YKJF!W=^V^;gZ200%+y$Zj|$UEi3aIwta5-FE^06OIyeB>>=Oe% z3ZWRlc;ioS_IImiq7--IPat&dGQ5L;P3=tSmpsi$<16704 z(izPIHgnnLDQ$O4?#N|=UXv(CC@nY2T;$xUYUKLd2t6Z)7V)seh!fxBAq#dYe z^sr;}Z~$TIDrM>_PUMB9%Dn%-ShOLvG4PmbT3~qXybGmzSx^vt(M}48q^U#g+ z(9Lt{jdSVE^Zt$V{>}5%zU|J)VGw^N-zKa_c#%%H%vRDpd)>6rW#Q?Z-+dW0cy0Vz zXW)8+sV3#PU(pP8F)?%it$|%6Vd&aoeeW2>E|xH6Wh}+{&Mlb4HR!|6BbKv$mUyOQ zi(~;P!WAqd#=H)5k3adauM~fR`;xItsPvd%pNM9eeu`c4K3S%daNj@PA25YPBf>Bx zP9wrH1)vdOnlhykVViQ4yw8?V#uw7mFPg8Mr!=Ynjg2iH4oJC|A5lLnOrGa|s@4j=8Y2A;kDVj321#VMP*5Ep67&}Fx zf0DUCyxZPwlKoh&qtWL!@hz3>8u7yuX26r>I^5B*L{&hA%=r ztpdZPgmub`#^BSGIgJ79)Q#kQyo{+dexEX7A7ILVO3Avoo(ncEI7w>&HE79eb;HiX zPkzZL$8JJU>NFYgt)&k@m@YJGu;XB%(2D^qji}@$YKE=})(U9W=Dic!z64*L-}87Q zKk16-bSgng5R#ArU~H3^#wFQRS=h6Sn*Jk5*V~>FX<*6J-{C}Z6g}UI^?e(zHohlM z75}NOk*txmk&cz-0J@}jgQl)9W|TB3dQUIu#yIlk_**^Gj>zI54cf2AIQHe&B`FY* zqUh;9TfDk(*d`1Q_UTyGzJ^yavrKWiDD$zj#Q6w~EeuBSMcJJe(TZZS{~=|Vj^_%5 zqo-EHR`#9mYT~Ufx(aVQcd2&4=tmR#gCy193~q(tDd(^jFU*P$R9GR)m)^SRD__79WY!wG(VE44lcbjx-Wcu4(1Lm^dvCG|amQk}h5P41zduiU zdJsQiAe-V>mWc-LjPy^j>8o}t8L zMRzP-YXyX1bI7AkWZgCu_9-Q%8FOVLY7TI79Hp|fD^d*U4 zWRKXUM2b=ZRYcD1P(IC>l~MGICl&9>p}llP!Lf2Rzi|RSt9Nj-Uol38YFm;6#-0v6 z#NPM1f9F>qDo0fzHZrzWamDMcC!*n4Tuj|JMf`;0SN>d*0}-P$v2Zi9yc_c-YG)q1 zx_R--<~U9Eb6zS)%zA$V6^MmjAk_UbL`B#-pwstUVJd`Fp(B%^>D|5YL-swFt2cf* zqCiAu$Cm({$w9&c$X?`!{G<9N26M+f<_Jk9A8BBIC%Cg1VE_ap!~=kc;tnN5>htT3 zRKF6(39)bZwXvHUY4)>X%!I>svCqvNT8M9}#Q~)Og#i`txz0)KJye|6M`8ZzVj=uS ztG$wM9%RLSTN#1(g>ZYy`Nam#kr65U1G>3ChS{I?CjuNU5ZH_Qw|?n?aMD|@ zHuC`YKe(QHgO1BRi7lJEMm@Eyq^EAhC z*EK%B08?oD_^t8`v|#73WqS2E=0S~x>~9p!flCrEx^Bwiut8*=&DNmlmectsua?I; z@?>Bf(Lm%@;kn*9>7Ad~Q}TT!^V)hRE%v9Mgxt_^<9fyg8xwp{!1l5;YEcb>jhPG;a9Pyp*J8+ zcU>EyRsrVGj^4Y!ZVg)QI$3)%!mgs9$LL%VLwkPp{OE}D=4!QCxraCZ&v9^8VvTkzoS4#C~s-3jjQ?(VSQcK&_$ zc<+rp#yjWj@y@ybNqQ93-K(1#PJUA0$#&6 zc)r72XjhW{AXtEwdavPY*;x;)vcOAySoX#1jQG3-9XMIP`4N!@R|F<-vU`){AGP71 zCj0bD9JudX%-;@g9{)!f?w{}2+Xd*5YR3UpMs)LkWKnzYd_muipo3Wbptu|^rB5*h zn;`KP*mfNmjN5|JnyQN#Ko6_<4P{wr*G{QPvqQ7POQp%P$&1VB}tde{zE7mDNA zad)25YrTxs=cdUK2@Sz zEdSL1{Wd?|serP<{+(&#)>^s@DB{C^If}}A1&QeEFKl<-Pl#OrOhqBd$?VJUJV}E)jWrG>A^GJ zQFr{lgOa%L614A{N~O+#5c~9)^$C+$6JCj*MGkN9^CY}Tc-!yCNArP|AHiN3iZJ{f zjvQWH9)fnojUyG*{4yK3XV^&^Pjl8oZ05)B-f$~nY~EG+?goBGARxQHAY8cW*}>

LB5rOv0jooCSRE8FT9*c+xZl%Kl>d~Ux^ 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 Date: Sun, 2 Nov 2025 12:19:35 -0800 Subject: [PATCH 23/35] job and test_batch entity relationship done, had to make changes to id's in job/job controller to be UUID from String. --- .../controller/JobsController.java | 5 +++-- .../com/vsp/endpointinsightsapi/model/Job.java | 18 +++++++++++++++--- .../endpointinsightsapi/model/TestBatch.java | 16 ++++++++-------- 3 files changed, 26 insertions(+), 13 deletions(-) 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 f631ed7..2584bbf 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 @@ -16,6 +16,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; @RestController @RequestMapping("/api/jobs") @@ -55,7 +56,7 @@ public ResponseEntity updateJob( LOG.info("Updating job"); Job updatedJob = new Job(); - updatedJob.setJobId(jobId); + updatedJob.setJobId(UUID.fromString(jobId)); updatedJob.setName("Updated Job #" + jobId); updatedJob.setDescription("This is a stub for job #" + jobId); @@ -85,7 +86,7 @@ public ResponseEntity getJob( String jobId) { Job job = new Job(); - job.setJobId(jobId); + job.setJobId(UUID.fromString(jobId)); job.setName("Job #" + jobId); job.setDescription("This is a stub for job #" + jobId); 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 55df833..1369777 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 @@ -4,8 +4,6 @@ import java.util.Date; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -16,6 +14,9 @@ import org.hibernate.annotations.UuidGenerator; import org.hibernate.type.SqlTypes; import java.util.Map; +import java.util.Set; +import java.util.UUID; + import com.vsp.endpointinsightsapi.model.enums.JobStatus; import com.vsp.endpointinsightsapi.model.enums.TestType; @@ -31,7 +32,7 @@ public class Job { @GeneratedValue @UuidGenerator @Column(name = "job_id") - private String jobId; + private UUID jobId; @Column(name = "name", nullable = false) private String name; @@ -43,12 +44,23 @@ public class Job { @Column(name = "test_type", nullable = false, length = 20) private TestType testType; + + @ManyToMany + @JoinTable( + name = "test_batch_tests", + joinColumns = @JoinColumn(name = "job_id", columnDefinition = "uuid"), + inverseJoinColumns = @JoinColumn(name = "test_job_id", columnDefinition = "uuid") + ) + private Set testBatches; + + // 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; 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 index b5bd756..879d2a5 100644 --- 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 @@ -5,11 +5,11 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import org.hibernate.validator.constraints.UUID; +import org.hibernate.annotations.UuidGenerator; import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; + +import java.util.*; @Data @Entity @@ -20,12 +20,12 @@ public class TestBatch { @Id @GeneratedValue - @UUID - private String id; + @UuidGenerator + @Column(name = "id", nullable = false) + private UUID batch_id; -// @ManyToMany -// @JoinTable(name = ) -// private List jobs; + @ManyToMany(mappedBy = "testBatches") + private List jobs; @Column(name = "batch_name", nullable = false) String batchName; From e717677dd9bd5b0c16d7a3858199ab0c75621c3b Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 2 Nov 2025 18:45:03 -0800 Subject: [PATCH 24/35] Corrections to toast component and jobs UUID type --- .../controller/JobsController.java | 5 +++-- .../com/vsp/endpointinsightsapi/model/Job.java | 5 +++-- .../vsp/endpointinsightsapi/model/TestBatch.java | 9 ++++----- endpoint-insights-ui/src/styles.scss | 14 +++++++------- 4 files changed, 17 insertions(+), 16 deletions(-) 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 f631ed7..2584bbf 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 @@ -16,6 +16,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; @RestController @RequestMapping("/api/jobs") @@ -55,7 +56,7 @@ public ResponseEntity updateJob( LOG.info("Updating job"); Job updatedJob = new Job(); - updatedJob.setJobId(jobId); + updatedJob.setJobId(UUID.fromString(jobId)); updatedJob.setName("Updated Job #" + jobId); updatedJob.setDescription("This is a stub for job #" + jobId); @@ -85,7 +86,7 @@ public ResponseEntity getJob( String jobId) { Job job = new Job(); - job.setJobId(jobId); + job.setJobId(UUID.fromString(jobId)); job.setName("Job #" + jobId); job.setDescription("This is a stub for job #" + jobId); 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 55df833..6518c42 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 @@ -16,6 +16,8 @@ import org.hibernate.annotations.UuidGenerator; import org.hibernate.type.SqlTypes; import java.util.Map; +import java.util.UUID; + import com.vsp.endpointinsightsapi.model.enums.JobStatus; import com.vsp.endpointinsightsapi.model.enums.TestType; @@ -30,8 +32,7 @@ public class Job { @Id @GeneratedValue @UuidGenerator - @Column(name = "job_id") - private String jobId; + private UUID jobId; @Column(name = "name", nullable = false) private String name; 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 index b5bd756..62193f0 100644 --- 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 @@ -5,11 +5,10 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import org.hibernate.validator.constraints.UUID; +import org.hibernate.annotations.UuidGenerator; import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; +import java.util.UUID; @Data @Entity @@ -20,8 +19,8 @@ public class TestBatch { @Id @GeneratedValue - @UUID - private String id; + @UuidGenerator + private UUID id; // @ManyToMany // @JoinTable(name = ) diff --git a/endpoint-insights-ui/src/styles.scss b/endpoint-insights-ui/src/styles.scss index dbe36ba..fc55505 100644 --- a/endpoint-insights-ui/src/styles.scss +++ b/endpoint-insights-ui/src/styles.scss @@ -81,16 +81,16 @@ body { .toast-success, .toast-error { - background: transparent !important; - box-shadow: none !important; - padding: 0 !important; - max-width: 500px !important; + background: transparent; + box-shadow: none; + padding: 0; + max-width: 500px; .mat-mdc-snack-bar-container, .mdc-snackbar__surface { - background: transparent !important; - box-shadow: none !important; - padding: 0 !important; + background: transparent; + box-shadow: none; + padding: 0; } } From 4c311feb0ce61d1bc40cdc9e0ed88a92ff3070bf Mon Sep 17 00:00:00 2001 From: Nicholas Cooper Date: Sun, 2 Nov 2025 19:00:34 -0800 Subject: [PATCH 25/35] EI-243 - Modal creation along with setting them up in the batch cards for demonstration and beginning of setup for the next sprint. (#29) * EI-243 - Modal creation along with setting them up in the batch cards for demonstration and beginning of setup for the next sprint. * Blunder correction * Review tweaks * Review tweaks * Removed title and title test --------- Signed-off-by: Nicholas Cooper --- endpoint-insights-ui/src/app/app.config.ts | 4 +- endpoint-insights-ui/src/app/app.html | 12 ++-- endpoint-insights-ui/src/app/app.scss | 49 +++++++++++++++ endpoint-insights-ui/src/app/app.spec.ts | 6 -- endpoint-insights-ui/src/app/app.ts | 11 ++-- .../app/batch-component/batch-component.scss | 17 +++--- .../app/batch-component/batch-component.ts | 4 +- .../batch-card/batch-card.component.html | 32 ++++++---- .../batch-card/batch-card.component.scss | 53 ++++++++-------- .../batch-card/batch-card.component.ts | 61 ++++++++++++++----- .../test-results-card.component.html | 2 +- .../dashboard-component.scss | 14 +++++ .../dashboard-component.ts | 8 +-- .../src/app/models/batch.model.ts | 15 ++++- .../src/app/shared/modal/modal.component.html | 32 ++++++++++ .../src/app/shared/modal/modal.component.scss | 16 +++++ .../src/app/shared/modal/modal.component.ts | 24 ++++++++ .../src/app/shared/modal/modal.models.ts | 16 +++++ .../src/app/shared/modal/modal.service.ts | 21 +++++++ endpoint-insights-ui/src/main.ts | 1 + 20 files changed, 309 insertions(+), 89 deletions(-) create mode 100644 endpoint-insights-ui/src/app/shared/modal/modal.component.html create mode 100644 endpoint-insights-ui/src/app/shared/modal/modal.component.scss create mode 100644 endpoint-insights-ui/src/app/shared/modal/modal.component.ts create mode 100644 endpoint-insights-ui/src/app/shared/modal/modal.models.ts create mode 100644 endpoint-insights-ui/src/app/shared/modal/modal.service.ts diff --git a/endpoint-insights-ui/src/app/app.config.ts b/endpoint-insights-ui/src/app/app.config.ts index e6b1db6..1d94833 100644 --- a/endpoint-insights-ui/src/app/app.config.ts +++ b/endpoint-insights-ui/src/app/app.config.ts @@ -1,11 +1,11 @@ import { ApplicationConfig } from '@angular/core'; import { provideRouter, withEnabledBlockingInitialNavigation } from '@angular/router'; import { routes } from './app.routes'; -//import { provideAnimations } from '@angular/platform-browser/animations'; +import { provideAnimations } from '@angular/platform-browser/animations'; export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes, withEnabledBlockingInitialNavigation()), - //provideAnimations(), + provideAnimations(), ], }; diff --git a/endpoint-insights-ui/src/app/app.html b/endpoint-insights-ui/src/app/app.html index 794c097..e970f91 100644 --- a/endpoint-insights-ui/src/app/app.html +++ b/endpoint-insights-ui/src/app/app.html @@ -1,9 +1,9 @@

-

{{ title() }}

-
- + + \ No newline at end of file diff --git a/endpoint-insights-ui/src/app/app.scss b/endpoint-insights-ui/src/app/app.scss index e69de29..fb1a81e 100644 --- a/endpoint-insights-ui/src/app/app.scss +++ b/endpoint-insights-ui/src/app/app.scss @@ -0,0 +1,49 @@ +:root, body, html { + max-width: 100%; + overflow-x: hidden; +} + +.sr-only { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; clip: rect(0,0,1px,1px); + white-space: nowrap; border: 0; +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: .75rem 1rem; + background: #0b2335; +} + +.brand { + margin: 0; + color: rgba(255,255,255,.82); + text-transform: uppercase; + letter-spacing: .06em; + font-size: 1rem; + line-height: 1; +} + +.nav { + display: flex; + gap: 0.75rem; +} + +.nav a[mat-button] { + border-radius: 9999px; + padding: 0.5rem 1.1rem; + font-weight: 600; + background: #1976d2; + color: #fff; + text-decoration: none; + transition: background .18s ease, box-shadow .18s ease, transform .12s ease; +} + +.nav a[mat-button].active { background: #2196f3; } +.nav a[mat-button]:hover { background: #42a5f5; box-shadow: 0 4px 14px rgba(25,118,210,.22); } +.nav a[mat-button]:active { transform: translateY(1px); } diff --git a/endpoint-insights-ui/src/app/app.spec.ts b/endpoint-insights-ui/src/app/app.spec.ts index 060c162..b56a712 100644 --- a/endpoint-insights-ui/src/app/app.spec.ts +++ b/endpoint-insights-ui/src/app/app.spec.ts @@ -1,4 +1,3 @@ -// app.spec.ts import { TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { AppComponent } from './app'; @@ -18,9 +17,4 @@ describe('App', () => { expect(fixture.componentInstance).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 283712f..f36a204 100644 --- a/endpoint-insights-ui/src/app/app.ts +++ b/endpoint-insights-ui/src/app/app.ts @@ -1,12 +1,15 @@ -import {Component, signal} from '@angular/core'; + +import { Component, signal } from '@angular/core'; import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; +import { MatButtonModule, MatButton } from '@angular/material/button'; @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet, RouterLink, RouterLinkActive], + imports: [RouterLink, RouterLinkActive, MatButtonModule, RouterOutlet], templateUrl: './app.html', + styleUrls: ['./app.scss'], }) export class AppComponent { - readonly title = signal('endpoint-insights-ui'); -} \ No newline at end of file + readonly title = signal('endpoint-insights-ui'); // needed for the test +} diff --git a/endpoint-insights-ui/src/app/batch-component/batch-component.scss b/endpoint-insights-ui/src/app/batch-component/batch-component.scss index af1e542..87e71ea 100644 --- a/endpoint-insights-ui/src/app/batch-component/batch-component.scss +++ b/endpoint-insights-ui/src/app/batch-component/batch-component.scss @@ -1,13 +1,14 @@ +:host { + display: block; + padding: 8px; +} + .grid { display: grid; - grid-template-columns: repeat(12, 1fr); gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + max-width: 1200px; + margin: 0 auto; } -app-batch-card { - grid-column: span 6; -} - -@media (max-width: 900px) { - app-batch-card { grid-column: span 12; } -} +:host, :host * { box-sizing: border-box; } diff --git a/endpoint-insights-ui/src/app/batch-component/batch-component.ts b/endpoint-insights-ui/src/app/batch-component/batch-component.ts index 9651bbe..0729b6c 100644 --- a/endpoint-insights-ui/src/app/batch-component/batch-component.ts +++ b/endpoint-insights-ui/src/app/batch-component/batch-component.ts @@ -13,8 +13,8 @@ import { BatchCardComponent } from './components/batch-card/batch-card.component 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' }, + { id: 'B-2025-00123', title: 'Nightly ETL (US-East)', date: '2025-10-17T02:13:00Z' }, + { id: 'B-2025-00124', title: 'Customer Backfill – Oct', date: '2025-10-18T15:45:00Z' }, ]; onConfigure(batch: Batch) { 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 index 4b6a511..66f084f 100644 --- 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 @@ -1,16 +1,24 @@ + -
-
-

{{ batch.title }}

-
Batch ID{{ batch.id }}
-
Date{{ formattedDate() }}
-
+
+
+

{{ 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 index 17c5e01..ecb6aa5 100644 --- 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 @@ -1,12 +1,14 @@ .batch-card { - border-radius: 12px; - padding: 10px 12px; + border: 1px solid #3359; + border-radius: 5px; + padding: 12px; + box-sizing: border-box; } .row { - display: grid; - grid-template-columns: 1fr auto; - align-items: start; + display: flex; + align-items: flex-start; + justify-content: space-between; gap: 12px; } @@ -16,39 +18,36 @@ } .title { - margin: 0; + margin: 0 0 2px 0; font-size: 1.05rem; - font-weight: 700; - letter-spacing: .2px; + line-height: 1.2; } .kv { display: grid; - grid-template-columns: 110px 1fr; - gap: 8px; + grid-template-columns: auto 1fr; + gap: 4px 8px; align-items: baseline; - - .label { color: #6b7280; } - .value { font-weight: 600; } } +.kv .label { color: rgba(0,0,0,.62); } +.kv .value { font-weight: 600; font-variant-numeric: tabular-nums; } .actions { display: flex; align-items: center; - justify-content: flex-end; - min-width: 160px; + gap: 8px; +} - button[mat-stroked-button] { - display: inline-flex; - gap: 6px; - border-radius: 999px; - padding: 0 14px; - } +button[mat-stroked-button] { + border-radius: 8px; + font-weight: 500; + transition: transform .15s ease, box-shadow .15s ease; } -.batch-card { - transition: transform .06s ease, box-shadow .12s ease; +button[mat-stroked-button]:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,.15); } +button[mat-stroked-button]:active { transform: translateY(1px); box-shadow: 0 2px 4px rgba(0,0,0,.2) inset; } +button[mat-stroked-button]:focus-visible { outline: 2px solid #0b2335; outline-offset: 2px; } + +@media (max-width: 480px) { + .row { flex-direction: column; align-items: stretch; } + .actions { justify-content: flex-end; } } -.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 index 11d42f3..0c5584a 100644 --- 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 @@ -1,24 +1,57 @@ -import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core'; +// batch-card.component.ts +import { Component, Input, Output, EventEmitter, inject } 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 { ModalService } from '../../../shared/modal/modal.service'; 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, + selector: 'app-batch-card', + standalone: true, + imports: [CommonModule, MatCardModule, MatButtonModule, MatIconModule], + templateUrl: './batch-card.component.html', + styleUrls: ['./batch-card.component.scss'], }) export class BatchCardComponent { - @Input({ required: true }) batch!: Batch; - @Output() configure = new EventEmitter(); + @Input() batch!: Batch; + + @Output() configure = new EventEmitter(); + + private modal = inject(ModalService); + + onConfigure() { + // Open the reusable modal with multiple tabs + this.modal.open({ + title: `Configure: ${this.batch.title}`, + tabs: [ + { + label: 'Overview', + content: ` +

Batch ID: ${this.batch.id}

+

Date: ${new Date(this.batch.date).toLocaleString()}

+ `, + }, + { + label: 'Settings', + content: ` +

Owner, thresholds, notifications…

+ `, + }, + { + label: 'Runs', + content: ` +

Recent executions and statuses.

+ `, + }, + ], + }); + + // If parents still expect the event, keep it: + this.configure.emit(this.batch); + } - formattedDate(): string { - const d = new Date(this.batch?.createdIso ?? ''); - return isNaN(d.valueOf()) ? '—' : d.toLocaleString(); - } + formattedDate(): string { + return new Date(this.batch.date).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 index 7298d9a..181773b 100644 --- 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 @@ -115,7 +115,7 @@
- Aggregate Error Rate + Aggregate Error Rate: {{ pct(test.errorRatePct) }}
diff --git a/endpoint-insights-ui/src/app/dashboard-component/dashboard-component.scss b/endpoint-insights-ui/src/app/dashboard-component/dashboard-component.scss index e69de29..ae06d93 100644 --- a/endpoint-insights-ui/src/app/dashboard-component/dashboard-component.scss +++ b/endpoint-insights-ui/src/app/dashboard-component/dashboard-component.scss @@ -0,0 +1,14 @@ +:host { + display: block; + padding: 8px; +} + +.grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + max-width: 1200px; + margin: 0 auto; +} + +:host, :host * { box-sizing: border-box; } 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 057e78e..7b05b29 100644 --- a/endpoint-insights-ui/src/app/dashboard-component/dashboard-component.ts +++ b/endpoint-insights-ui/src/app/dashboard-component/dashboard-component.ts @@ -1,12 +1,14 @@ -import { Component } from '@angular/core'; +import { Component, inject } 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'; +import { MatButtonModule } from '@angular/material/button'; +//import { ModalService } from '../shared/modal/modal.service'; @Component({ selector: 'app-dashboard', standalone: true, - imports: [ CommonModule, TestResultsCardComponent,], + imports: [CommonModule, TestResultsCardComponent, MatButtonModule], templateUrl: './dashboard-component.html', styleUrls: ['./dashboard-component.scss'], }) @@ -39,6 +41,4 @@ export class DashboardComponent { //Currently just a mock data for now; I will n ]; 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 index 2ca271d..f21aeca 100644 --- a/endpoint-insights-ui/src/app/models/batch.model.ts +++ b/endpoint-insights-ui/src/app/models/batch.model.ts @@ -1,5 +1,14 @@ +/** Represents a single batch entry shown on the Dashboard. */ export interface Batch { - id: string; // Batch ID - title: string; // Name - createdIso: string; // ISO date string + /** Unique ID for the batch (UUID) */ + id: string; + + /** Display title (usually a short descriptive name) */ + title: string; + + /** ISO string or timestamp representing when the batch was created or run */ + date: string; + + /** description or notes */ + description?: string; } diff --git a/endpoint-insights-ui/src/app/shared/modal/modal.component.html b/endpoint-insights-ui/src/app/shared/modal/modal.component.html new file mode 100644 index 0000000..7b0db3e --- /dev/null +++ b/endpoint-insights-ui/src/app/shared/modal/modal.component.html @@ -0,0 +1,32 @@ + + + + + +
+ No content yet. +
+ + +
+

{{ tabs[0].label }}

+
+
+ + + + +
+
+
+
+ + + + +
diff --git a/endpoint-insights-ui/src/app/shared/modal/modal.component.scss b/endpoint-insights-ui/src/app/shared/modal/modal.component.scss new file mode 100644 index 0000000..baa18f5 --- /dev/null +++ b/endpoint-insights-ui/src/app/shared/modal/modal.component.scss @@ -0,0 +1,16 @@ +:host { + display: block; +} + +:host ::ng-deep .mat-dialog-container { + display: flex; + flex-direction: column; +} + +.modal-body { + flex: 1 1 auto; + max-height: 70vh; + overflow: auto; +} + +.tab-pane { padding: .25rem 0; } diff --git a/endpoint-insights-ui/src/app/shared/modal/modal.component.ts b/endpoint-insights-ui/src/app/shared/modal/modal.component.ts new file mode 100644 index 0000000..fd8c1f9 --- /dev/null +++ b/endpoint-insights-ui/src/app/shared/modal/modal.component.ts @@ -0,0 +1,24 @@ +import { Component, Inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MAT_DIALOG_DATA, MatDialogContent, MatDialogTitle, MatDialogActions, MatDialogClose } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTabsModule } from '@angular/material/tabs'; +import { ModalConfig } from './modal.models'; + +@Component({ + selector: 'app-modal', + standalone: true, + imports: [ + CommonModule, + MatDialogTitle, MatDialogContent, MatDialogActions, MatDialogClose, + MatButtonModule, MatIconModule, MatTabsModule + ], + templateUrl: './modal.component.html', + styleUrls: ['./modal.component.scss'] +}) +export class ModalComponent { + constructor(@Inject(MAT_DIALOG_DATA) public data: ModalConfig) {} + get tabs() { return this.data?.tabs ?? []; } + get showTabHeader() { return this.tabs.length > 1; } +} diff --git a/endpoint-insights-ui/src/app/shared/modal/modal.models.ts b/endpoint-insights-ui/src/app/shared/modal/modal.models.ts new file mode 100644 index 0000000..ce810db --- /dev/null +++ b/endpoint-insights-ui/src/app/shared/modal/modal.models.ts @@ -0,0 +1,16 @@ +export interface ModalTab { + /** Text shown on the tab button */ + label: string; + /** Plain content for now (can be upgraded to TemplateRef later) */ + content: string; +} + +export interface ModalConfig { + /** Title shown in the modal header */ + title?: string; + /** Tabs to render. 0, 1, or many allowed. */ + tabs: ModalTab[]; + /** Optional width constraints */ + width?: string; + maxWidth?: string; +} diff --git a/endpoint-insights-ui/src/app/shared/modal/modal.service.ts b/endpoint-insights-ui/src/app/shared/modal/modal.service.ts new file mode 100644 index 0000000..4a74076 --- /dev/null +++ b/endpoint-insights-ui/src/app/shared/modal/modal.service.ts @@ -0,0 +1,21 @@ +import { Injectable, inject } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { ModalComponent } from './modal.component'; +import { ModalConfig } from './modal.models'; + +@Injectable({ providedIn: 'root' }) +export class ModalService { + private dialog = inject(MatDialog); + + open(config: ModalConfig) { + return this.dialog.open(ModalComponent, { + data: config, + width: config.width ?? '600px', + maxWidth: config.maxWidth ?? '95vw', + autoFocus: 'first-tabbable', + restoreFocus: true, + enterAnimationDuration: '150ms', + exitAnimationDuration: '100ms' + }); + } +} diff --git a/endpoint-insights-ui/src/main.ts b/endpoint-insights-ui/src/main.ts index e47684e..9483d1b 100644 --- a/endpoint-insights-ui/src/main.ts +++ b/endpoint-insights-ui/src/main.ts @@ -1,5 +1,6 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { AppComponent } from './app/app'; import { appConfig } from './app/app.config'; +import { provideAnimations } from '@angular/platform-browser/animations'; bootstrapApplication(AppComponent, appConfig); From f7b62a63c6dfdc7d34f8eb24e626da899dc9dd8c Mon Sep 17 00:00:00 2001 From: cbrock-csus Date: Sun, 2 Nov 2025 19:13:24 -0800 Subject: [PATCH 26/35] EI-40 - Test Results Tables (#36) * EI-40 - Test Results Tables * EI-40 - Update test_result_tables.sql --- endpoint-insights-sql/test_result_tables.sql | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 endpoint-insights-sql/test_result_tables.sql diff --git a/endpoint-insights-sql/test_result_tables.sql b/endpoint-insights-sql/test_result_tables.sql new file mode 100644 index 0000000..57359d7 --- /dev/null +++ b/endpoint-insights-sql/test_result_tables.sql @@ -0,0 +1,34 @@ +create table test_result +( + result_id uuid default gen_random_uuid() + constraint test_result_pk + primary key, + job_type integer +); + +create table perf_test_result +( + result_id uuid not null + constraint perf_test_result_pk + primary key + constraint perf_test_result_fk + references test_result, + p50_latency_ms integer, + p95_latency_ms integer, + p99_latency_ms integer, + volume_last_minute integer, + volume_last_5_minutes integer, + error_rate_percent double precision +); + +create table perf_test_result_code +( + result_id uuid not null + constraint perf_test_result_code_fk + references perf_test_result, + error_code integer not null, + count integer, + constraint perf_test_result_code_pk + primary key (result_id, error_code) +); + From 41fff1f1bda504fe4b0941ab28c2c5e25ca4d564 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 2 Nov 2025 21:08:51 -0800 Subject: [PATCH 27/35] Corrections to toast component and jobs UUID type --- endpoint-insights-ui/src/styles.scss | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/endpoint-insights-ui/src/styles.scss b/endpoint-insights-ui/src/styles.scss index dbe36ba..99291b0 100644 --- a/endpoint-insights-ui/src/styles.scss +++ b/endpoint-insights-ui/src/styles.scss @@ -81,16 +81,16 @@ body { .toast-success, .toast-error { - background: transparent !important; - box-shadow: none !important; - padding: 0 !important; - max-width: 500px !important; + background: transparent; + box-shadow: none; + padding: 0 ; + max-width: 500px; .mat-mdc-snack-bar-container, .mdc-snackbar__surface { - background: transparent !important; - box-shadow: none !important; - padding: 0 !important; + background: transparent; + box-shadow: none; + padding: 0; } } From 68ced1495dab60df6ed4d1c592d5ddde6772eff5 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 8 Nov 2025 19:40:27 -0800 Subject: [PATCH 28/35] Audit class completed --- .../EndpointInsightsApiApplication.java | 2 + .../config/EntityAuditConfig.java | 26 ++++++++++ .../model/AuditingEntity.java | 36 +++++++++++++ .../endpointinsightsapi/model/TestBatch.java | 4 +- .../repository/TestBatchRepository.java | 11 ++++ .../src/main/resources/application.yaml | 4 ++ .../model/AbstractEntityTest.java | 51 +++++++++++++++++++ 7 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/config/EntityAuditConfig.java create mode 100644 endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/AuditingEntity.java create mode 100644 endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/repository/TestBatchRepository.java create mode 100644 endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/model/AbstractEntityTest.java diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/EndpointInsightsApiApplication.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/EndpointInsightsApiApplication.java index cd8967f..35e5a8f 100644 --- a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/EndpointInsightsApiApplication.java +++ b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/EndpointInsightsApiApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class EndpointInsightsApiApplication { diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/config/EntityAuditConfig.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/config/EntityAuditConfig.java new file mode 100644 index 0000000..186b8f9 --- /dev/null +++ b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/config/EntityAuditConfig.java @@ -0,0 +1,26 @@ +package com.vsp.endpointinsightsapi.config; + + +import com.vsp.endpointinsightsapi.model.UserContext; +import com.vsp.endpointinsightsapi.util.CurrentUser; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.AuditorAware; + +import java.util.Optional; + +@Configuration +public class EntityAuditConfig { + + @Bean + public AuditorAware auditorProvider() { + + return () -> { + if(!CurrentUser.getUsername().equals("system")) { + return Optional.of(CurrentUser.getUsername()); + } + return Optional.of("system"); + }; + } + +} diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/AuditingEntity.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/AuditingEntity.java new file mode 100644 index 0000000..deb599e --- /dev/null +++ b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/model/AuditingEntity.java @@ -0,0 +1,36 @@ +package com.vsp.endpointinsightsapi.model; + + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +public abstract class AuditingEntity { + + @CreatedBy + @Column(nullable = false, updatable = false) + private String createdBy; + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdDate; + + @LastModifiedBy + @Column(nullable = false, updatable = false) + private String lastModifiedBy; + + @LastModifiedDate + @Column(nullable = false, updatable = false) + private LocalDateTime lastModifiedDate; + +} 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 index e156a15..50624a9 100644 --- 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 @@ -3,6 +3,7 @@ import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.hibernate.annotations.UuidGenerator; @@ -10,12 +11,13 @@ import java.util.*; +@EqualsAndHashCode(callSuper = true) @Data @Entity @AllArgsConstructor @NoArgsConstructor @Table(name = "test_batch") -public class TestBatch { +public class TestBatch extends AuditingEntity{ @Id @GeneratedValue diff --git a/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/repository/TestBatchRepository.java b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/repository/TestBatchRepository.java new file mode 100644 index 0000000..2a11eb6 --- /dev/null +++ b/endpoint-insights-api/src/main/java/com/vsp/endpointinsightsapi/repository/TestBatchRepository.java @@ -0,0 +1,11 @@ +package com.vsp.endpointinsightsapi.repository; + +import com.vsp.endpointinsightsapi.model.TestBatch; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface TestBatchRepository extends JpaRepository { + + public TestBatch findByTestId(UUID testId); +} diff --git a/endpoint-insights-api/src/main/resources/application.yaml b/endpoint-insights-api/src/main/resources/application.yaml index befc2bd..094129d 100644 --- a/endpoint-insights-api/src/main/resources/application.yaml +++ b/endpoint-insights-api/src/main/resources/application.yaml @@ -5,6 +5,10 @@ spring: url: jdbc:${DB_URI} username: ${DB_NAME} password: ${DB_PASSWORD} + jpa: + database-platform: org.hibernate.dialect.PostgreSQLDialect + hibernate: + ddl-auto: create security: oauth2: diff --git a/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/model/AbstractEntityTest.java b/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/model/AbstractEntityTest.java new file mode 100644 index 0000000..04f1e81 --- /dev/null +++ b/endpoint-insights-api/src/test/java/com/vsp/endpointinsightsapi/model/AbstractEntityTest.java @@ -0,0 +1,51 @@ +package com.vsp.endpointinsightsapi.model; + +import com.vsp.endpointinsightsapi.repository.TestBatchRepository; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.ArrayList; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class AbstractEntityTest { + + @Mock + private TestBatchRepository testBatchRepository; + + @Test + public void createEntityWithAuditFields() { + // Arrange + TestBatch batch = new TestBatch(); + batch.setBatchName("batchName"); + batch.setJobs(new ArrayList<>()); + + // Create a mock saved entity with ID + TestBatch savedBatch = new TestBatch(); + UUID batchId = UUID.randomUUID(); + savedBatch.setBatch_id(batchId); + savedBatch.setBatchName("batchName"); + + when(testBatchRepository.save(any(TestBatch.class))).thenReturn(savedBatch); + + when(testBatchRepository.findByTestId(batchId)).thenReturn(savedBatch); + + TestBatch result = testBatchRepository.save(batch); + TestBatch expectedBatch = testBatchRepository.findByTestId(savedBatch.getBatch_id()); + + assertNotNull(result); + assertNotNull(expectedBatch); + assertEquals("batchName", expectedBatch.getBatchName()); + assertEquals(batchId, expectedBatch.getBatch_id()); + + verify(testBatchRepository, times(1)).save(batch); + verify(testBatchRepository, times(1)).findByTestId(batchId); + } +} \ No newline at end of file From eda9a5b26972dba0471b5c133b707025a3dc34af Mon Sep 17 00:00:00 2001 From: Morningstar515 Date: Fri, 14 Nov 2025 23:57:10 -0800 Subject: [PATCH 29/35] Modal made, form almost done --- .../create-job-form/create-job-form.ts | 20 +++- .../edit-job-modal/edit-job-modal.html | 63 +++++++++++++ .../edit-job-modal/edit-job-modal.scss | 92 +++++++++++++++++++ .../edit-job-modal/edit-job-modal.ts | 50 ++++++++++ .../app/pages/test-overview/test-overview.ts | 32 ++++++- .../src/app/shared/modal/modal.models.ts | 1 + 6 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.html create mode 100644 endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.scss create mode 100644 endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.ts diff --git a/endpoint-insights-ui/src/app/components/create-job-form/create-job-form.ts b/endpoint-insights-ui/src/app/components/create-job-form/create-job-form.ts index 72b925c..b512ca4 100644 --- a/endpoint-insights-ui/src/app/components/create-job-form/create-job-form.ts +++ b/endpoint-insights-ui/src/app/components/create-job-form/create-job-form.ts @@ -1,4 +1,4 @@ -import {Component, EventEmitter, Output} from '@angular/core'; +import {Component, EventEmitter, Input, Output, SimpleChanges} from '@angular/core'; import { AbstractControl, FormBuilder, @@ -10,6 +10,7 @@ import { import {MatFormField} from "@angular/material/form-field"; import {MatInputModule} from "@angular/material/input"; import {MatOption, MatSelect} from "@angular/material/select"; +import {TestItem} from "../../pages/test-overview/test-overview"; @Component({ selector: 'app-job-form', @@ -26,7 +27,7 @@ import {MatOption, MatSelect} from "@angular/material/select"; }) export class CreateJobForm { createJobForm: FormGroup; - + @Input() job!: TestItem; constructor(private formBuilder: FormBuilder) { this.createJobForm = this.formBuilder.group({ name: ["", [ @@ -53,6 +54,21 @@ export class CreateJobForm { }) } + ngOnChanges(changes: SimpleChanges) { + if (changes['job'] && this.job) { + this.createJobForm.patchValue({ + name: this.job.name, + description: this.job.description, + gitUrl: this.job.gitUrl, + jobType: this.job.jobType, + runCommand: this.job.runCommand, + compileCommand: this.job.compileCommand, + }); + } + } + + + @Output() jobSubmitted = new EventEmitter(); getErrorMessage(fieldName: string): string { diff --git a/endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.html b/endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.html new file mode 100644 index 0000000..669e1d4 --- /dev/null +++ b/endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.html @@ -0,0 +1,63 @@ + \ No newline at end of file diff --git a/endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.scss b/endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.scss new file mode 100644 index 0000000..7804ef4 --- /dev/null +++ b/endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.scss @@ -0,0 +1,92 @@ +button.mat-mdc-raised-button.create-job-modal-cancel-button { + background-color: transparent; + color: #666; + border: 1px #ccc solid; + border-radius: 4px; + transition: background-color 0.2s; + + &:hover { + background-color: #f5f5f5; + border-color: #999; + } + + &:active { + background-color: #e0e0e0; + } + + &:focus-visible { + outline: 2px solid #1976d2; + outline-offset: 2px; + } + + &:focus:not(:focus-visible) { + outline: none; + } +} + +.form { + display: flex; + flex-direction: column; + gap: 20px; + padding: 24px; + max-width: 600px; +} + +.header{ + display: flex; + flex-direction: column; + padding: 0px; + +} + +.form-row { + display: flex; + gap: 16px; + mat-form-field { + flex: 1; + } +} + +.form-row-commands { + margin-top: 24px; +} + +.form-field { + width: 100%; +} + +mat-error { + color: #f44336; + font-size: 0.75rem; + margin-top: 4px; + line-height: 1.2; + + div { + margin: 0; + } +} + + +button.mat-mdc-raised-button.create-job-modal-create-button { + background-color: #1976d2; + color: white; + border-radius: 4px; + transition: background-color 0.2s; + + &:hover { + background-color: #1565c0; + } + + &:active { + background-color: #0d47a1; + } + + &:focus-visible { + outline: 2px solid #1976d2; + outline-offset: 2px; + } + + &:focus:not(:focus-visible) { + outline: none; + } +} \ No newline at end of file diff --git a/endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.ts b/endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.ts new file mode 100644 index 0000000..003e8f9 --- /dev/null +++ b/endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.ts @@ -0,0 +1,50 @@ +import {Component, Inject, signal, ViewChild} from "@angular/core"; +import { + MAT_DIALOG_DATA, + MatDialogActions, + MatDialogContent, + MatDialogRef, + MatDialogTitle +} from "@angular/material/dialog"; +import {MatButton} from "@angular/material/button"; +import {TestItem} from "../../pages/test-overview/test-overview"; +import {CreateJobForm} from "../create-job-form/create-job-form"; + +@Component({ + selector:'edit-job-modal', + standalone:true, + templateUrl:'edit-job-modal.html', + styleUrl:'edit-job-modal.scss', + imports: [ + MatDialogContent, + MatDialogActions, + MatButton, + CreateJobForm, + MatDialogTitle, + ] +}) + +export class EditJobModal{ + @ViewChild(CreateJobForm) createJobForm!: CreateJobForm; + private dialogRef: MatDialogRef; + public state = signal({ + inEditMode: false, + }) + constructor( + dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: TestItem) + { + this.dialogRef = dialogRef; + } + + toggleEditMode(){ + this.state.update(s => ({ + ...s, + inEditMode: !s.inEditMode + })) + } + + onCancel(){ + this.dialogRef.close() + } +} diff --git a/endpoint-insights-ui/src/app/pages/test-overview/test-overview.ts b/endpoint-insights-ui/src/app/pages/test-overview/test-overview.ts index 1e61588..75a535e 100644 --- a/endpoint-insights-ui/src/app/pages/test-overview/test-overview.ts +++ b/endpoint-insights-ui/src/app/pages/test-overview/test-overview.ts @@ -4,11 +4,18 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import {MatDialog} from "@angular/material/dialog"; import {CreateJobModal} from "../../components/create-job-modal/create-job-modal"; +import {EditJobModal} from "../../components/edit-job-modal/edit-job-modal"; +import {ModalComponent} from "../../shared/modal/modal.component"; export interface TestItem { id: string; name: string; batch: string; + description: string, + gitUrl: string, + runCommand: string, + compileCommand: string, + jobType: string, createdAt: Date | string; createdBy: string; status: 'running' | 'stopped'; @@ -22,7 +29,8 @@ export interface TestItem { imports: [ CommonModule, MatIconModule, - MatButtonModule + MatButtonModule, + EditJobModal ], }) export class TestOverview { @@ -33,13 +41,17 @@ export class TestOverview { tests: TestItem[] = [ - { id:'1', name:'Auth – Login OK', batch:'Nightly-01', createdAt:new Date(), createdBy:'Alex', status:'running' }, - { id:'2', name:'Billing – Refund', batch:'Nightly-01', createdAt:new Date(), createdBy:'Sam', status:'stopped' }, + { id:'1', name:'Auth – Login OK', batch:'Nightly-01', createdAt:new Date(), createdBy:'Alex', status:'running', + gitUrl:"git.com/test", description: "this is a test", jobType:"jmeter", compileCommand:"./ep-compile ", + runCommand:"./ep-run -" }, + + { id:'2', name:'Billing – Refund', batch:'Nightly-01', createdAt:new Date(), createdBy:'Sam', status:'stopped', + gitUrl:"git.com/test", description: "", jobType:"nightwatch", compileCommand:"", runCommand:"./ep-run -"}, ]; onOpen(t: TestItem) { console.log('Open Clicked') } onRun(t: TestItem) { console.log('Run Clicked') } - onEdit(t: TestItem) { console.log('Edit Clicked') } + onEdit(t: TestItem) { this.openEditModal(t) } onDelete(t: TestItem){ console.log('Delete Clicked') } @@ -58,4 +70,16 @@ export class TestOverview { } }); } + openEditModal(t:TestItem){ + const dialogRef = this.dialog.open(EditJobModal, { + width: '600px', + maxWidth: '95vw', + data: t, + }); + dialogRef.afterClosed().subscribe((result: any) => { + if (result) { + console.log("New job created:", result); + } + }); + } } diff --git a/endpoint-insights-ui/src/app/shared/modal/modal.models.ts b/endpoint-insights-ui/src/app/shared/modal/modal.models.ts index ce810db..4a990d1 100644 --- a/endpoint-insights-ui/src/app/shared/modal/modal.models.ts +++ b/endpoint-insights-ui/src/app/shared/modal/modal.models.ts @@ -10,6 +10,7 @@ export interface ModalConfig { title?: string; /** Tabs to render. 0, 1, or many allowed. */ tabs: ModalTab[]; + initialState?: Record /** Optional width constraints */ width?: string; maxWidth?: string; From 5391862c29c9c0f0167bc54a0c88f375f89d03a0 Mon Sep 17 00:00:00 2001 From: Morningstar515 Date: Sat, 15 Nov 2025 17:48:07 -0800 Subject: [PATCH 30/35] Hooking up API to backend --- .../controller/JobsController.java | 21 +++++++------- .../vsp/endpointinsightsapi/model/Job.java | 25 ++-------------- .../src/main/resources/application.yaml | 2 +- endpoint-insights-ui/src/app/app.config.ts | 2 ++ .../src/app/common/job.constants.ts | 4 +++ .../edit-job-modal/edit-job-modal.html | 3 +- .../edit-job-modal/edit-job-modal.ts | 29 +++++++++++++++++-- .../app/pages/test-overview/test-overview.ts | 7 +++-- .../src/app/services/job-services.ts | 22 ++++++++++++++ 9 files changed, 75 insertions(+), 40 deletions(-) create mode 100644 endpoint-insights-ui/src/app/common/job.constants.ts create mode 100644 endpoint-insights-ui/src/app/services/job-services.ts 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 a5136f2..2f5233c 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 @@ -25,6 +25,7 @@ @RestController @RequestMapping("/api/jobs") @Validated +@CrossOrigin(origins = "http://localhost:4200") // Add this public class JobsController { private final static Logger LOG = LoggerFactory.getLogger(JobsController.class); @@ -40,16 +41,16 @@ public JobsController(JobService jobService) { * @param request the job details * @return the created Job * */ - @PostMapping //("/") - // public ResponseEntity createJob(@RequestBody @Valid Job jobRequest) { - // try { - // jobService.createJob(jobRequest); - // return new ResponseEntity<>(jobRequest, HttpStatus.CREATED); - // } catch (RuntimeException e) { - // LOG.error("Error creating job: {}", e.getMessage()); - // return new ResponseEntity<>(null); - // } - // } + @PostMapping + public ResponseEntity createJob(@RequestBody @Valid Job jobRequest) { + try { + jobService.createJob(jobRequest); + return new ResponseEntity<>(jobRequest, HttpStatus.CREATED); + } catch (RuntimeException e) { + LOG.error("Error creating job: {}", e.getMessage()); + return new ResponseEntity<>(null); + } + } public ResponseEntity createJob(@RequestBody @Valid JobCreateRequest request) { LOG.info("Creating job"); try { 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 2d645d1..19edf01 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 @@ -8,6 +8,7 @@ import lombok.Setter; import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UuidGenerator; import org.hibernate.type.SqlTypes; import java.util.Date; @@ -24,7 +25,8 @@ public class Job { @Id - @ColumnDefault("get_random_uuid()") + @GeneratedValue + @UuidGenerator @Column(name = "job_id") private UUID jobId; @@ -60,17 +62,6 @@ public class Job { @Column(name = "status", nullable = false, length = 20) private JobStatus status = JobStatus.PENDING; - @Column(name = "created_at", nullable = false, updatable = false) - private Date createdAt; - - @Column(name = "updated_at", nullable = false) - private Date updatedAt; - - @Column(name = "started_at", nullable = false) - private Date startedAt; - - @Column(name = "completed_at", nullable = false) - private Date completedAt; // JSONB config: arbitrary key/value settings for the job @JdbcTypeCode(SqlTypes.JSON) @@ -78,14 +69,4 @@ public class Job { private Map config; - @PrePersist - void onCreate() { - this.createdAt = new Date(); - this.updatedAt = new Date(); - } - - @PreUpdate - void onUpdate() { - this.updatedAt = new Date(); - } } diff --git a/endpoint-insights-api/src/main/resources/application.yaml b/endpoint-insights-api/src/main/resources/application.yaml index befc2bd..3df5fd8 100644 --- a/endpoint-insights-api/src/main/resources/application.yaml +++ b/endpoint-insights-api/src/main/resources/application.yaml @@ -28,7 +28,7 @@ spring: app: authentication: - enabled: true + enabled: false groups: write: "ei:write" read: "ei:read" diff --git a/endpoint-insights-ui/src/app/app.config.ts b/endpoint-insights-ui/src/app/app.config.ts index 1d94833..87427a2 100644 --- a/endpoint-insights-ui/src/app/app.config.ts +++ b/endpoint-insights-ui/src/app/app.config.ts @@ -2,10 +2,12 @@ import { ApplicationConfig } from '@angular/core'; import { provideRouter, withEnabledBlockingInitialNavigation } from '@angular/router'; import { routes } from './app.routes'; import { provideAnimations } from '@angular/platform-browser/animations'; +import {provideHttpClient} from "@angular/common/http"; export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes, withEnabledBlockingInitialNavigation()), provideAnimations(), + provideHttpClient() ], }; diff --git a/endpoint-insights-ui/src/app/common/job.constants.ts b/endpoint-insights-ui/src/app/common/job.constants.ts new file mode 100644 index 0000000..cc5f097 --- /dev/null +++ b/endpoint-insights-ui/src/app/common/job.constants.ts @@ -0,0 +1,4 @@ + +export const JOB_STATUSES = ['RUNNING', 'STOPPED', 'PENDING', 'FAILED', 'SUCCESS'] as const; + +export type JobStatus = typeof JOB_STATUSES[number]; \ No newline at end of file diff --git a/endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.html b/endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.html index 669e1d4..2628998 100644 --- a/endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.html +++ b/endpoint-insights-ui/src/app/components/edit-job-modal/edit-job-modal.html @@ -1,7 +1,6 @@