feat: complete Gap 4 (CI/CD UI) and Gap 5 (Governance, Analytics, Audit logging)
Gap 4 - CI/CD Integration (100%): - Add Trigger Now panel to CicdIntegrationView (fire suite from UI) - Add Pipeline Runs History table showing all scheduler-triggered executions - Upgrade snippet generator into card layout with breadcrumbs Gap 5 - Governance and Audit (100%): - Create AuditService - centralised, never-throws helper for writing audit logs - Wire AuditService into TestSuiteController (CREATE/UPDATE/DELETE/RUN) - Wire AuditService into TestCaseController (CREATE/UPDATE/DELETE) - Wire AuditService into EnvironmentController (already had direct writes, now uses service) - Create AnalyticsController - exposes 4 real analytics REST endpoints (/api/analytics/time-saved, /flaky-suites, /suite-performance, /fleet-health) - Rewrite AnalyticsView.jsx to call real backend APIs instead of client-side JS computation; adds sparkline activity chart, fleet health widget, suite performance leaderboard with visual pass rate bars, trend badges on flakiness - Remove @CrossOrigin wildcard from TestSuiteController and TestCaseController
This commit is contained in:
parent
0c39f6141b
commit
155bd6ac11
|
|
@ -0,0 +1,68 @@
|
||||||
|
package com.autopilot.localagent_cloud.controller;
|
||||||
|
|
||||||
|
import com.autopilot.localagent_cloud.service.AnalyticsService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gap 5: Governance & Analytics Controller
|
||||||
|
*
|
||||||
|
* Exposes the real analytics data computed by AnalyticsService.
|
||||||
|
* All endpoints require authentication via JWT.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/analytics")
|
||||||
|
public class AnalyticsController {
|
||||||
|
|
||||||
|
private final AnalyticsService analyticsService;
|
||||||
|
|
||||||
|
public AnalyticsController(AnalyticsService analyticsService) {
|
||||||
|
this.analyticsService = analyticsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long orgId(HttpServletRequest req) {
|
||||||
|
Object o = req.getAttribute("orgId");
|
||||||
|
return o != null ? ((Number) o).longValue() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top N flaky test suites for this org */
|
||||||
|
@GetMapping("/flaky-suites")
|
||||||
|
public ResponseEntity<List<Map<String, Object>>> getFlakySuites(
|
||||||
|
HttpServletRequest req,
|
||||||
|
@RequestParam(defaultValue = "10") int limit) {
|
||||||
|
Long orgId = orgId(req);
|
||||||
|
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
|
return ResponseEntity.ok(analyticsService.getFlakySuites(orgId, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-suite performance leaderboard */
|
||||||
|
@GetMapping("/suite-performance")
|
||||||
|
public ResponseEntity<List<Map<String, Object>>> getSuitePerformance(
|
||||||
|
HttpServletRequest req,
|
||||||
|
@RequestParam(defaultValue = "20") int limit) {
|
||||||
|
Long orgId = orgId(req);
|
||||||
|
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
|
return ResponseEntity.ok(analyticsService.getSuitePerformance(orgId, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent fleet health: online/offline counts, queue depth */
|
||||||
|
@GetMapping("/fleet-health")
|
||||||
|
public ResponseEntity<Map<String, Object>> getFleetHealth(HttpServletRequest req) {
|
||||||
|
Long orgId = orgId(req);
|
||||||
|
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
|
return ResponseEntity.ok(analyticsService.getFleetHealth(orgId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time saved & ROI metrics with 14-day daily breakdown */
|
||||||
|
@GetMapping("/time-saved")
|
||||||
|
public ResponseEntity<Map<String, Object>> getTimeSaved(HttpServletRequest req) {
|
||||||
|
Long orgId = orgId(req);
|
||||||
|
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
|
return ResponseEntity.ok(analyticsService.getTimeSavedRoi(orgId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
package com.autopilot.localagent_cloud.controller;
|
package com.autopilot.localagent_cloud.controller;
|
||||||
|
|
||||||
import com.autopilot.localagent_cloud.model.AuditLog;
|
|
||||||
import com.autopilot.localagent_cloud.model.Environment;
|
import com.autopilot.localagent_cloud.model.Environment;
|
||||||
import com.autopilot.localagent_cloud.model.Variable;
|
import com.autopilot.localagent_cloud.model.Variable;
|
||||||
import com.autopilot.localagent_cloud.repository.AuditLogRepository;
|
|
||||||
import com.autopilot.localagent_cloud.repository.EnvironmentRepository;
|
import com.autopilot.localagent_cloud.repository.EnvironmentRepository;
|
||||||
import com.autopilot.localagent_cloud.repository.VariableRepository;
|
import com.autopilot.localagent_cloud.repository.VariableRepository;
|
||||||
|
import com.autopilot.localagent_cloud.service.AuditService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
@ -23,14 +22,14 @@ public class EnvironmentController {
|
||||||
|
|
||||||
private final EnvironmentRepository environmentRepository;
|
private final EnvironmentRepository environmentRepository;
|
||||||
private final VariableRepository variableRepository;
|
private final VariableRepository variableRepository;
|
||||||
private final AuditLogRepository auditLogRepository;
|
private final AuditService auditService;
|
||||||
|
|
||||||
public EnvironmentController(EnvironmentRepository environmentRepository,
|
public EnvironmentController(EnvironmentRepository environmentRepository,
|
||||||
VariableRepository variableRepository,
|
VariableRepository variableRepository,
|
||||||
AuditLogRepository auditLogRepository) {
|
AuditService auditService) {
|
||||||
this.environmentRepository = environmentRepository;
|
this.environmentRepository = environmentRepository;
|
||||||
this.variableRepository = variableRepository;
|
this.variableRepository = variableRepository;
|
||||||
this.auditLogRepository = auditLogRepository;
|
this.auditService = auditService;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getUserEmail(HttpServletRequest req) {
|
private String getUserEmail(HttpServletRequest req) {
|
||||||
|
|
@ -57,7 +56,7 @@ public class EnvironmentController {
|
||||||
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
env.setOrgId(orgId);
|
env.setOrgId(orgId);
|
||||||
Environment saved = environmentRepository.save(env);
|
Environment saved = environmentRepository.save(env);
|
||||||
auditLogRepository.save(new AuditLog(orgId, getUserEmail(request), "CREATE", "ENVIRONMENT", saved.getId().toString(), "Created Environment: " + saved.getName()));
|
auditService.log(orgId, getUserEmail(request), "CREATE", "ENVIRONMENT", saved.getId(), "Created Environment: " + saved.getName());
|
||||||
return ResponseEntity.ok(saved);
|
return ResponseEntity.ok(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,7 +72,7 @@ public class EnvironmentController {
|
||||||
e.setName(body.getName());
|
e.setName(body.getName());
|
||||||
e.setDescription(body.getDescription());
|
e.setDescription(body.getDescription());
|
||||||
Environment saved = environmentRepository.save(e);
|
Environment saved = environmentRepository.save(e);
|
||||||
auditLogRepository.save(new AuditLog(orgId, getUserEmail(request), "UPDATE", "ENVIRONMENT", saved.getId().toString(), "Updated Environment: " + saved.getName()));
|
auditService.log(orgId, getUserEmail(request), "UPDATE", "ENVIRONMENT", saved.getId(), "Updated Environment: " + saved.getName());
|
||||||
return ResponseEntity.ok(saved);
|
return ResponseEntity.ok(saved);
|
||||||
}).orElse(ResponseEntity.notFound().build());
|
}).orElse(ResponseEntity.notFound().build());
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +91,7 @@ public class EnvironmentController {
|
||||||
List<Variable> envVars = variableRepository.findByOrgIdAndScopeAndScopeId(orgId, "ENVIRONMENT", id);
|
List<Variable> envVars = variableRepository.findByOrgIdAndScopeAndScopeId(orgId, "ENVIRONMENT", id);
|
||||||
variableRepository.deleteAll(envVars);
|
variableRepository.deleteAll(envVars);
|
||||||
environmentRepository.deleteById(id);
|
environmentRepository.deleteById(id);
|
||||||
auditLogRepository.save(new AuditLog(orgId, getUserEmail(request), "DELETE", "ENVIRONMENT", id.toString(), "Deleted Environment: " + env.getName()));
|
auditService.log(orgId, getUserEmail(request), "DELETE", "ENVIRONMENT", id, "Deleted Environment: " + env.getName());
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.autopilot.localagent_cloud.controller;
|
package com.autopilot.localagent_cloud.controller;
|
||||||
|
|
||||||
import com.autopilot.localagent_cloud.model.TestCase;
|
import com.autopilot.localagent_cloud.model.TestCase;
|
||||||
|
import com.autopilot.localagent_cloud.service.AuditService;
|
||||||
import com.autopilot.localagent_cloud.service.TestCaseService;
|
import com.autopilot.localagent_cloud.service.TestCaseService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
@ -11,13 +12,14 @@ import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/test-cases")
|
@RequestMapping("/api/test-cases")
|
||||||
@CrossOrigin(origins = "*")
|
|
||||||
public class TestCaseController {
|
public class TestCaseController {
|
||||||
|
|
||||||
private final TestCaseService testCaseService;
|
private final TestCaseService testCaseService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
public TestCaseController(TestCaseService testCaseService) {
|
public TestCaseController(TestCaseService testCaseService, AuditService auditService) {
|
||||||
this.testCaseService = testCaseService;
|
this.testCaseService = testCaseService;
|
||||||
|
this.auditService = auditService;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long orgId(HttpServletRequest req) {
|
private Long orgId(HttpServletRequest req) {
|
||||||
|
|
@ -25,6 +27,11 @@ public class TestCaseController {
|
||||||
return o != null ? ((Number) o).longValue() : null;
|
return o != null ? ((Number) o).longValue() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String userEmail(HttpServletRequest req) {
|
||||||
|
Object e = req.getAttribute("email");
|
||||||
|
return e != null ? e.toString() : "SYSTEM";
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<List<TestCase>> getTestCases(HttpServletRequest req) {
|
public ResponseEntity<List<TestCase>> getTestCases(HttpServletRequest req) {
|
||||||
return testCaseService.getAll(orgId(req));
|
return testCaseService.getAll(orgId(req));
|
||||||
|
|
@ -38,18 +45,38 @@ public class TestCaseController {
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<Map<String, Object>> createTestCase(
|
public ResponseEntity<Map<String, Object>> createTestCase(
|
||||||
@RequestBody Map<String, Object> body, HttpServletRequest req) {
|
@RequestBody Map<String, Object> body, HttpServletRequest req) {
|
||||||
return testCaseService.create(body, orgId(req));
|
Long orgId = orgId(req);
|
||||||
|
ResponseEntity<Map<String, Object>> res = testCaseService.create(body, orgId);
|
||||||
|
if (res.getStatusCode().is2xxSuccessful() && res.getBody() != null) {
|
||||||
|
TestCase tc = (TestCase) res.getBody().get("testCase");
|
||||||
|
if (tc != null) {
|
||||||
|
auditService.log(orgId, userEmail(req), "CREATE", "TEST_CASE", tc.getId(), "Created Test Case: " + tc.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<Map<String, Object>> updateTestCase(
|
public ResponseEntity<Map<String, Object>> updateTestCase(
|
||||||
@PathVariable("id") Long id,
|
@PathVariable("id") Long id,
|
||||||
@RequestBody Map<String, Object> body) {
|
@RequestBody Map<String, Object> body, HttpServletRequest req) {
|
||||||
return testCaseService.update(id, body);
|
ResponseEntity<Map<String, Object>> res = testCaseService.update(id, body);
|
||||||
|
if (res.getStatusCode().is2xxSuccessful() && res.getBody() != null) {
|
||||||
|
TestCase tc = (TestCase) res.getBody().get("testCase");
|
||||||
|
if (tc != null) {
|
||||||
|
auditService.log(orgId(req), userEmail(req), "UPDATE", "TEST_CASE", tc.getId(), "Updated Test Case: " + tc.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<Void> deleteTestCase(@PathVariable("id") Long id) {
|
public ResponseEntity<Void> deleteTestCase(@PathVariable("id") Long id, HttpServletRequest req) {
|
||||||
return testCaseService.delete(id);
|
Long orgId = orgId(req);
|
||||||
|
ResponseEntity<Void> res = testCaseService.delete(id);
|
||||||
|
if (res.getStatusCode().is2xxSuccessful()) {
|
||||||
|
auditService.log(orgId, userEmail(req), "DELETE", "TEST_CASE", id, "Deleted Test Case ID: " + id);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
package com.autopilot.localagent_cloud.controller;
|
package com.autopilot.localagent_cloud.controller;
|
||||||
|
|
||||||
import com.autopilot.localagent_cloud.model.AuditLog;
|
|
||||||
import com.autopilot.localagent_cloud.model.TestSuite;
|
import com.autopilot.localagent_cloud.model.TestSuite;
|
||||||
import com.autopilot.localagent_cloud.repository.AuditLogRepository;
|
import com.autopilot.localagent_cloud.service.AuditService;
|
||||||
import com.autopilot.localagent_cloud.service.TestSuiteService;
|
import com.autopilot.localagent_cloud.service.TestSuiteService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
@ -13,15 +12,14 @@ import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/test-suites")
|
@RequestMapping("/api/test-suites")
|
||||||
@CrossOrigin(origins = "*")
|
|
||||||
public class TestSuiteController {
|
public class TestSuiteController {
|
||||||
|
|
||||||
private final TestSuiteService testSuiteService;
|
private final TestSuiteService testSuiteService;
|
||||||
private final AuditLogRepository auditLogRepository;
|
private final AuditService auditService;
|
||||||
|
|
||||||
public TestSuiteController(TestSuiteService testSuiteService, AuditLogRepository auditLogRepository) {
|
public TestSuiteController(TestSuiteService testSuiteService, AuditService auditService) {
|
||||||
this.testSuiteService = testSuiteService;
|
this.testSuiteService = testSuiteService;
|
||||||
this.auditLogRepository = auditLogRepository;
|
this.auditService = auditService;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getUserEmail(HttpServletRequest req) {
|
private String getUserEmail(HttpServletRequest req) {
|
||||||
|
|
@ -47,10 +45,11 @@ public class TestSuiteController {
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<Map<String, Object>> createTestSuite(
|
public ResponseEntity<Map<String, Object>> createTestSuite(
|
||||||
@RequestBody Map<String, Object> body, HttpServletRequest req) {
|
@RequestBody Map<String, Object> body, HttpServletRequest req) {
|
||||||
ResponseEntity<Map<String, Object>> res = testSuiteService.create(body, orgId(req));
|
Long orgId = orgId(req);
|
||||||
|
ResponseEntity<Map<String, Object>> res = testSuiteService.create(body, orgId);
|
||||||
if (res.getStatusCode().is2xxSuccessful() && res.getBody() != null) {
|
if (res.getStatusCode().is2xxSuccessful() && res.getBody() != null) {
|
||||||
TestSuite suite = (TestSuite) res.getBody().get("suite");
|
TestSuite suite = (TestSuite) res.getBody().get("suite");
|
||||||
auditLogRepository.save(new AuditLog(orgId(req), getUserEmail(req), "CREATE", "TEST_SUITE", suite.getId().toString(), "Created Test Suite: " + suite.getName()));
|
auditService.log(orgId, getUserEmail(req), "CREATE", "TEST_SUITE", suite.getId(), "Created Test Suite: " + suite.getName());
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +61,7 @@ public class TestSuiteController {
|
||||||
ResponseEntity<Map<String, Object>> res = testSuiteService.update(id, body);
|
ResponseEntity<Map<String, Object>> res = testSuiteService.update(id, body);
|
||||||
if (res.getStatusCode().is2xxSuccessful() && res.getBody() != null) {
|
if (res.getStatusCode().is2xxSuccessful() && res.getBody() != null) {
|
||||||
TestSuite suite = (TestSuite) res.getBody().get("suite");
|
TestSuite suite = (TestSuite) res.getBody().get("suite");
|
||||||
auditLogRepository.save(new AuditLog(orgId(req), getUserEmail(req), "UPDATE", "TEST_SUITE", suite.getId().toString(), "Updated Test Suite: " + suite.getName()));
|
auditService.log(orgId(req), getUserEmail(req), "UPDATE", "TEST_SUITE", suite.getId(), "Updated Test Suite: " + suite.getName());
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
@ -72,14 +71,20 @@ public class TestSuiteController {
|
||||||
@PathVariable("id") Long id,
|
@PathVariable("id") Long id,
|
||||||
@RequestBody(required = false) Map<String, Object> body,
|
@RequestBody(required = false) Map<String, Object> body,
|
||||||
HttpServletRequest req) {
|
HttpServletRequest req) {
|
||||||
return testSuiteService.run(id, body, orgId(req));
|
Long orgId = orgId(req);
|
||||||
|
ResponseEntity<Map<String, Object>> res = testSuiteService.run(id, body, orgId);
|
||||||
|
if (res.getStatusCode().is2xxSuccessful()) {
|
||||||
|
auditService.log(orgId, getUserEmail(req), "RUN", "TEST_SUITE", id, "Triggered run for Suite ID: " + id);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<Void> deleteTestSuite(@PathVariable("id") Long id, HttpServletRequest req) {
|
public ResponseEntity<Void> deleteTestSuite(@PathVariable("id") Long id, HttpServletRequest req) {
|
||||||
ResponseEntity<Void> res = testSuiteService.delete(id);
|
Long orgId = orgId(req);
|
||||||
|
ResponseEntity<Void> res = testSuiteService.delete(id, orgId);
|
||||||
if (res.getStatusCode().is2xxSuccessful()) {
|
if (res.getStatusCode().is2xxSuccessful()) {
|
||||||
auditLogRepository.save(new AuditLog(orgId(req), getUserEmail(req), "DELETE", "TEST_SUITE", id.toString(), "Deleted Test Suite ID: " + id));
|
auditService.log(orgId, getUserEmail(req), "DELETE", "TEST_SUITE", id, "Deleted Test Suite ID: " + id);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package com.autopilot.localagent_cloud.service;
|
||||||
|
|
||||||
|
import com.autopilot.localagent_cloud.model.AuditLog;
|
||||||
|
import com.autopilot.localagent_cloud.repository.AuditLogRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight service for writing immutable audit log entries.
|
||||||
|
* Call log() from any service or controller whenever a significant
|
||||||
|
* mutation occurs (create, update, delete, run, etc.).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AuditService {
|
||||||
|
|
||||||
|
private final AuditLogRepository auditLogRepository;
|
||||||
|
|
||||||
|
public AuditService(AuditLogRepository auditLogRepository) {
|
||||||
|
this.auditLogRepository = auditLogRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void log(Long orgId, String userEmail, String action, String entityType, String entityId, String details) {
|
||||||
|
try {
|
||||||
|
AuditLog entry = new AuditLog(orgId, userEmail, action, entityType, entityId, details);
|
||||||
|
auditLogRepository.save(entry);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
// Never let audit logging crash the main operation
|
||||||
|
System.err.println("Failed to write audit log: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience overload when entityId is numeric
|
||||||
|
public void log(Long orgId, String userEmail, String action, String entityType, Long entityId, String details) {
|
||||||
|
log(orgId, userEmail, action, entityType, entityId != null ? entityId.toString() : null, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ public class TestSuiteService {
|
||||||
private final TestCaseRepository testCaseRepository;
|
private final TestCaseRepository testCaseRepository;
|
||||||
private final TestStepRepository testStepRepository;
|
private final TestStepRepository testStepRepository;
|
||||||
private final SchedulerRepository schedulerRepository;
|
private final SchedulerRepository schedulerRepository;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
public TestSuiteService(TestSuiteRepository testSuiteRepository,
|
public TestSuiteService(TestSuiteRepository testSuiteRepository,
|
||||||
TestSuiteGroupMappingRepository testSuiteGroupMappingRepository,
|
TestSuiteGroupMappingRepository testSuiteGroupMappingRepository,
|
||||||
|
|
@ -40,7 +41,8 @@ public class TestSuiteService {
|
||||||
TestCaseGroupMappingRepository testCaseGroupMappingRepository,
|
TestCaseGroupMappingRepository testCaseGroupMappingRepository,
|
||||||
TestCaseRepository testCaseRepository,
|
TestCaseRepository testCaseRepository,
|
||||||
TestStepRepository testStepRepository,
|
TestStepRepository testStepRepository,
|
||||||
SchedulerRepository schedulerRepository) {
|
SchedulerRepository schedulerRepository,
|
||||||
|
AuditService auditService) {
|
||||||
this.testSuiteRepository = testSuiteRepository;
|
this.testSuiteRepository = testSuiteRepository;
|
||||||
this.testSuiteGroupMappingRepository = testSuiteGroupMappingRepository;
|
this.testSuiteGroupMappingRepository = testSuiteGroupMappingRepository;
|
||||||
this.testCaseGroupRepository = testCaseGroupRepository;
|
this.testCaseGroupRepository = testCaseGroupRepository;
|
||||||
|
|
@ -48,6 +50,7 @@ public class TestSuiteService {
|
||||||
this.testCaseRepository = testCaseRepository;
|
this.testCaseRepository = testCaseRepository;
|
||||||
this.testStepRepository = testStepRepository;
|
this.testStepRepository = testStepRepository;
|
||||||
this.schedulerRepository = schedulerRepository;
|
this.schedulerRepository = schedulerRepository;
|
||||||
|
this.auditService = auditService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ResponseEntity<List<TestSuite>> getAll(Long orgId) {
|
public ResponseEntity<List<TestSuite>> getAll(Long orgId) {
|
||||||
|
|
@ -122,6 +125,7 @@ public class TestSuiteService {
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
result.put("suite", suite);
|
result.put("suite", suite);
|
||||||
result.put("testCaseGroupIds", groupIds);
|
result.put("testCaseGroupIds", groupIds);
|
||||||
|
auditService.log(orgId, null, "CREATE", "TestSuite", suite.getId(), "Created suite: " + suite.getName());
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(result);
|
return ResponseEntity.status(HttpStatus.CREATED).body(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,6 +155,7 @@ public class TestSuiteService {
|
||||||
|
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
result.put("suite", existing);
|
result.put("suite", existing);
|
||||||
|
auditService.log(existing.getOrgId(), null, "UPDATE", "TestSuite", id, "Updated suite: " + existing.getName());
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
}).orElse(ResponseEntity.notFound().build());
|
}).orElse(ResponseEntity.notFound().build());
|
||||||
}
|
}
|
||||||
|
|
@ -188,15 +193,16 @@ public class TestSuiteService {
|
||||||
result.put("scheduler", scheduler);
|
result.put("scheduler", scheduler);
|
||||||
result.put("suite", suite);
|
result.put("suite", suite);
|
||||||
result.put("message", "Test suite queued for immediate execution");
|
result.put("message", "Test suite queued for immediate execution");
|
||||||
|
auditService.log(orgId, null, "RUN", "TestSuite", id, "Triggered run for suite: " + suite.getName());
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(result);
|
return ResponseEntity.status(HttpStatus.CREATED).body(result);
|
||||||
}).orElse(ResponseEntity.notFound().build());
|
}).orElse(ResponseEntity.notFound().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
public ResponseEntity<Void> delete(Long id) {
|
public ResponseEntity<Void> delete(Long id, Long orgId) {
|
||||||
if (!testSuiteRepository.existsById(id)) {
|
return testSuiteRepository.findById(id).map(suite -> {
|
||||||
return ResponseEntity.notFound().build();
|
testSuiteRepository.deleteById(id);
|
||||||
}
|
auditService.log(orgId, null, "DELETE", "TestSuite", id, "Deleted suite: " + suite.getName());
|
||||||
testSuiteRepository.deleteById(id);
|
return ResponseEntity.noContent().<Void>build();
|
||||||
return ResponseEntity.noContent().build();
|
}).orElse(ResponseEntity.notFound().build());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,83 +1,95 @@
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '../../api/apiClient';
|
import { api } from '../../api/apiClient';
|
||||||
import { BarChart3, TrendingUp, AlertTriangle, Clock } from 'lucide-react';
|
import {
|
||||||
|
BarChart3, TrendingUp, AlertTriangle, Clock,
|
||||||
|
DollarSign, Activity, Cpu, CheckCircle, XCircle, Minus
|
||||||
|
} from 'lucide-react';
|
||||||
import { fmt } from '../../utils/helpers';
|
import { fmt } from '../../utils/helpers';
|
||||||
|
|
||||||
|
function StatCard({ icon, value, label, color = 'var(--brand)', suffix = '' }) {
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: '24px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{ color }}>{icon}</div>
|
||||||
|
<div style={{ fontSize: '32px', fontWeight: 800, color: 'var(--txt-h)' }}>{value}{suffix}</div>
|
||||||
|
<div style={{ color: 'var(--txt-muted)', fontSize: '13px', textAlign: 'center' }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendBar({ value, max = 100, color = 'var(--brand)' }) {
|
||||||
|
const pct = Math.min((value / max) * 100, 100);
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{ flex: 1, height: 6, background: 'var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 4, transition: 'width 0.6s ease' }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--txt-muted)', minWidth: 36 }}>{value.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AnalyticsView() {
|
export default function AnalyticsView() {
|
||||||
const [execs, setExecs] = useState([]);
|
const [roi, setRoi] = useState(null);
|
||||||
|
const [flaky, setFlaky] = useState([]);
|
||||||
|
const [suitePerf, setSuitePerf] = useState([]);
|
||||||
|
const [fleet, setFleet] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api('/api/executions')
|
Promise.all([
|
||||||
.then(r => r.json())
|
api('/api/analytics/time-saved').then(r => r.json()).catch(() => null),
|
||||||
.then(d => { setExecs(d || []); setLoading(false); })
|
api('/api/analytics/flaky-suites').then(r => r.json()).catch(() => []),
|
||||||
.catch(() => setLoading(false));
|
api('/api/analytics/suite-performance').then(r => r.json()).catch(() => []),
|
||||||
|
api('/api/analytics/fleet-health').then(r => r.json()).catch(() => null),
|
||||||
|
]).then(([roiData, flakyData, perfData, fleetData]) => {
|
||||||
|
setRoi(roiData);
|
||||||
|
setFlaky(flakyData || []);
|
||||||
|
setSuitePerf(perfData || []);
|
||||||
|
setFleet(fleetData);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getName = (e) => {
|
|
||||||
try { return JSON.parse(e.environmentJson || '{}').referenceId || `Run #${e.orgExecutionId || e.id}`; }
|
|
||||||
catch { return `Run #${e.orgExecutionId || e.id}`; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const { passRate, totalExecs, avgDuration, flakiness } = useMemo(() => {
|
|
||||||
if (!execs.length) return { passRate: 0, totalExecs: 0, avgDuration: 0, flakiness: [] };
|
|
||||||
|
|
||||||
// Pass Rate
|
|
||||||
const completed = execs.filter(e => e.status?.toLowerCase() === 'success' || e.status?.toLowerCase() === 'failed');
|
|
||||||
const passed = completed.filter(e => e.status?.toLowerCase() === 'success').length;
|
|
||||||
const rate = completed.length > 0 ? (passed / completed.length) * 100 : 0;
|
|
||||||
|
|
||||||
// Average Duration
|
|
||||||
let totalDur = 0;
|
|
||||||
let durCount = 0;
|
|
||||||
completed.forEach(e => {
|
|
||||||
if (e.createdAt && e.finishedAt) {
|
|
||||||
totalDur += (new Date(e.finishedAt) - new Date(e.createdAt));
|
|
||||||
durCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const avgDur = durCount > 0 ? Math.floor(totalDur / durCount / 1000) : 0;
|
|
||||||
|
|
||||||
// Flakiness Analysis: Group by Test Suite Name (referenceId)
|
|
||||||
const suiteMap = {};
|
|
||||||
completed.forEach(e => {
|
|
||||||
const name = getName(e);
|
|
||||||
if (!suiteMap[name]) suiteMap[name] = { total: 0, passed: 0, failed: 0 };
|
|
||||||
suiteMap[name].total++;
|
|
||||||
if (e.status?.toLowerCase() === 'success') suiteMap[name].passed++;
|
|
||||||
if (e.status?.toLowerCase() === 'failed') suiteMap[name].failed++;
|
|
||||||
});
|
|
||||||
|
|
||||||
const flakeData = Object.entries(suiteMap)
|
|
||||||
.map(([name, stats]) => ({
|
|
||||||
name,
|
|
||||||
total: stats.total,
|
|
||||||
flakeScore: stats.total > 2 ? Math.min(stats.passed, stats.failed) / stats.total : 0 // The closer passed/failed are, the higher the flakiness
|
|
||||||
}))
|
|
||||||
.filter(f => f.flakeScore > 0)
|
|
||||||
.sort((a, b) => b.flakeScore - a.flakeScore)
|
|
||||||
.slice(0, 10);
|
|
||||||
|
|
||||||
return { passRate: rate.toFixed(1), totalExecs: execs.length, avgDuration: avgDur, flakiness: flakeData };
|
|
||||||
}, [execs]);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="page-view">
|
<div className="page-view">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div className="page-header-left"><h1>Analytics & Reports</h1></div>
|
<div className="page-header-left"><h1>Analytics & Reports</h1></div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'center', padding: 48 }}><div className="spinner" /></div>
|
<div style={{ textAlign: 'center', padding: 64 }}><div className="spinner" /></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const passRate = roi ? (roi.totalRuns > 0 ? '—' : '—') : '—';
|
||||||
|
const totalRuns = roi?.totalRuns ?? 0;
|
||||||
|
const avgDuration = roi?.avgDurationSecs ?? 0;
|
||||||
|
const hoursSaved = roi?.hoursSaved ?? 0;
|
||||||
|
const dollarsSaved = roi?.dollarsSaved ?? 0;
|
||||||
|
const todayRuns = roi?.todayRuns ?? 0;
|
||||||
|
const weekRuns = roi?.weekRuns ?? 0;
|
||||||
|
const dailyBreakdown = roi?.dailyBreakdown ?? [];
|
||||||
|
|
||||||
|
// Compute pass rate from suite performance data
|
||||||
|
const allRuns = suitePerf.reduce((a, s) => a + (s.totalRuns || 0), 0);
|
||||||
|
const allPassed = suitePerf.reduce((a, s) => a + (s.passedRuns || 0), 0);
|
||||||
|
const overallPassRate = allRuns > 0 ? ((allPassed / allRuns) * 100).toFixed(1) : 0;
|
||||||
|
|
||||||
|
// Fleet summary
|
||||||
|
const onlineAgents = fleet?.onlineCount ?? 0;
|
||||||
|
const runningAgents = fleet?.runningCount ?? 0;
|
||||||
|
const offlineAgents = fleet?.offlineCount ?? 0;
|
||||||
|
const queueDepth = fleet?.queueDepth ?? 0;
|
||||||
|
|
||||||
|
// Sparkline max
|
||||||
|
const maxRuns = Math.max(...dailyBreakdown.map(d => d.runs), 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-view">
|
<div className="page-view">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div className="page-header-left">
|
<div className="page-header-left">
|
||||||
<h1>Analytics & Reports</h1>
|
<h1>Analytics & Reports</h1>
|
||||||
<div className="breadcrumbs">
|
<div className="breadcrumbs">
|
||||||
<Link to="/dashboard">Home</Link>
|
<Link to="/dashboard">Home</Link>
|
||||||
<span className="sep">›</span>
|
<span className="sep">›</span>
|
||||||
|
|
@ -86,29 +98,138 @@ export default function AnalyticsView() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '20px', marginBottom: '24px' }}>
|
{/* Top stat cards */}
|
||||||
<div className="card" style={{ padding: '24px', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 20, marginBottom: 24 }}>
|
||||||
<TrendingUp size={32} color="var(--brand)" style={{ marginBottom: 12 }} />
|
<StatCard icon={<TrendingUp size={32} />} value={overallPassRate} suffix="%" label="Overall Pass Rate" color="var(--brand)" />
|
||||||
<div style={{ fontSize: '32px', fontWeight: 800 }}>{passRate}%</div>
|
<StatCard icon={<BarChart3 size={32} />} value={totalRuns} label="Total Executions" color="var(--blue)" />
|
||||||
<div style={{ color: 'var(--txt-muted)', fontSize: '13px' }}>Overall Pass Rate</div>
|
<StatCard icon={<Clock size={32} />} value={avgDuration} suffix="s" label="Avg Execution Time" color="var(--green)" />
|
||||||
|
<StatCard icon={<DollarSign size={32} />} value={`$${dollarsSaved.toLocaleString()}`} label={`${hoursSaved}h Saved (ROI)`} color="var(--amber)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ROI + Fleet Row */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 24 }}>
|
||||||
|
|
||||||
|
{/* Daily Run Sparkline */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<div>
|
||||||
|
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Activity size={18} color="var(--brand)" /> Run Activity (Last 14 Days)
|
||||||
|
</h2>
|
||||||
|
<p>{todayRuns} runs today • {weekRuns} runs this week</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '0 24px 24px', display: 'flex', alignItems: 'flex-end', gap: 4, height: 80 }}>
|
||||||
|
{dailyBreakdown.map((d, i) => (
|
||||||
|
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||||
|
<div
|
||||||
|
title={`${d.date}: ${d.runs} runs`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: `${Math.max((d.runs / maxRuns) * 60, d.runs > 0 ? 6 : 2)}px`,
|
||||||
|
background: d.runs > 0 ? 'var(--brand)' : 'var(--border)',
|
||||||
|
borderRadius: 3,
|
||||||
|
transition: 'height 0.4s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '0 24px 16px', fontSize: 11, color: 'var(--txt-muted)' }}>
|
||||||
|
<span>{dailyBreakdown[0]?.date}</span>
|
||||||
|
<span>{dailyBreakdown[dailyBreakdown.length - 1]?.date}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card" style={{ padding: '24px', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
|
||||||
<BarChart3 size={32} color="var(--blue)" style={{ marginBottom: 12 }} />
|
{/* Fleet Health */}
|
||||||
<div style={{ fontSize: '32px', fontWeight: 800 }}>{totalExecs}</div>
|
<div className="card">
|
||||||
<div style={{ color: 'var(--txt-muted)', fontSize: '13px' }}>Total Executions</div>
|
<div className="card-header">
|
||||||
</div>
|
<div>
|
||||||
<div className="card" style={{ padding: '24px', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<Clock size={32} color="var(--green)" style={{ marginBottom: 12 }} />
|
<Cpu size={18} color="var(--blue)" /> Agent Fleet Health
|
||||||
<div style={{ fontSize: '32px', fontWeight: 800 }}>{avgDuration}s</div>
|
</h2>
|
||||||
<div style={{ color: 'var(--txt-muted)', fontSize: '13px' }}>Avg Execution Time</div>
|
<p>Queue depth: <strong>{queueDepth}</strong> pending job{queueDepth !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '0 24px 24px', display: 'flex', gap: 24 }}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 800, color: 'var(--green)' }}>{runningAgents + onlineAgents}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--txt-muted)' }}>Online</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 800, color: 'var(--brand)' }}>{runningAgents}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--txt-muted)' }}>Running</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 800, color: 'var(--txt-muted)' }}>{offlineAgents}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--txt-muted)' }}>Offline</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{fleet?.agents?.length > 0 && (
|
||||||
|
<div style={{ padding: '0 24px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{fleet.agents.slice(0, 4).map(a => (
|
||||||
|
<div key={a.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--txt-h)' }}>{a.name}</span>
|
||||||
|
<span className={`badge ${a.status === 'running' ? 'badge-primary' : a.status === 'idle' ? 'badge-success' : 'badge-error'}`}>
|
||||||
|
{a.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Suite Performance Leaderboard */}
|
||||||
|
{suitePerf.length > 0 && (
|
||||||
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
|
<div className="card-header">
|
||||||
|
<div>
|
||||||
|
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<CheckCircle size={18} color="var(--green)" /> Suite Performance Leaderboard
|
||||||
|
</h2>
|
||||||
|
<p>Pass rates and run counts per test suite, sorted by reliability.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Test Suite</th>
|
||||||
|
<th>Total Runs</th>
|
||||||
|
<th>Passed</th>
|
||||||
|
<th>Failed</th>
|
||||||
|
<th>Pass Rate</th>
|
||||||
|
<th>Avg Duration</th>
|
||||||
|
<th>Last Run</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{suitePerf.map(s => (
|
||||||
|
<tr key={s.suiteName}>
|
||||||
|
<td style={{ fontWeight: 600 }}>{s.suiteName}</td>
|
||||||
|
<td>{s.totalRuns}</td>
|
||||||
|
<td><span style={{ color: 'var(--green)', display: 'flex', alignItems: 'center', gap: 4 }}><CheckCircle size={13} />{s.passedRuns}</span></td>
|
||||||
|
<td><span style={{ color: s.failedRuns > 0 ? 'var(--red)' : 'var(--txt-muted)', display: 'flex', alignItems: 'center', gap: 4 }}><XCircle size={13} />{s.failedRuns}</span></td>
|
||||||
|
<td style={{ minWidth: 160 }}><TrendBar value={s.successRate} color={s.successRate >= 80 ? 'var(--green)' : s.successRate >= 50 ? 'var(--amber)' : 'var(--red)'} /></td>
|
||||||
|
<td>{s.avgDurationSecs != null ? `${s.avgDurationSecs}s` : <Minus size={13} color="var(--txt-muted)" />}</td>
|
||||||
|
<td><span className="text-muted text-sm">{s.lastRunAt ? fmt(s.lastRunAt) : '—'}</span></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Flakiness Analysis */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8 }}><AlertTriangle size={18} color="var(--amber)" /> Flakiness Analysis</h2>
|
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<p>Test suites that frequently alternate between pass and fail.</p>
|
<AlertTriangle size={18} color="var(--amber)" /> Flakiness Analysis
|
||||||
|
</h2>
|
||||||
|
<p>Test suites that frequently alternate between pass and fail (requires ≥2 runs each).</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
|
|
@ -117,27 +238,30 @@ export default function AnalyticsView() {
|
||||||
<tr>
|
<tr>
|
||||||
<th>Test Suite</th>
|
<th>Test Suite</th>
|
||||||
<th>Total Runs</th>
|
<th>Total Runs</th>
|
||||||
|
<th>Passed</th>
|
||||||
|
<th>Failed</th>
|
||||||
<th>Flakiness Score</th>
|
<th>Flakiness Score</th>
|
||||||
<th>Status</th>
|
<th>Trend</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{flakiness.length === 0 ? (
|
{flaky.length === 0 ? (
|
||||||
<tr><td colSpan="4" style={{ textAlign: 'center', padding: '32px', color: 'var(--txt-muted)' }}>No flaky tests detected! Great job!</td></tr>
|
<tr><td colSpan="6" style={{ textAlign: 'center', padding: '32px', color: 'var(--txt-muted)' }}>
|
||||||
|
🎉 No flaky tests detected — great reliability!
|
||||||
|
</td></tr>
|
||||||
) : (
|
) : (
|
||||||
flakiness.map(f => (
|
flaky.map(f => (
|
||||||
<tr key={f.name}>
|
<tr key={f.suiteName}>
|
||||||
<td style={{ fontWeight: 600 }}>{f.name}</td>
|
<td style={{ fontWeight: 600 }}>{f.suiteName}</td>
|
||||||
<td>{f.total}</td>
|
<td>{f.totalRuns}</td>
|
||||||
|
<td>{f.passedRuns}</td>
|
||||||
|
<td>{f.failedRuns}</td>
|
||||||
|
<td style={{ minWidth: 160 }}><TrendBar value={f.flakinessScore} color="var(--amber)" /></td>
|
||||||
<td>
|
<td>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<span className={`badge ${f.trend === 'improving' ? 'badge-success' : 'badge-error'}`}>
|
||||||
<div style={{ width: '100px', height: '6px', background: 'var(--border)', borderRadius: '4px', overflow: 'hidden' }}>
|
{f.trend === 'improving' ? '↑ Improving' : '↓ Deteriorating'}
|
||||||
<div style={{ width: `${f.flakeScore * 200}%`, height: '100%', background: 'var(--amber)' }} />
|
</span>
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: '12px' }}>{(f.flakeScore * 200).toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td><span className="badge badge-warning">Needs Review</span></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,26 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '../../api/apiClient';
|
import { api } from '../../api/apiClient';
|
||||||
|
import { Play, RefreshCw, CheckCircle, XCircle, Clock, Loader } from 'lucide-react';
|
||||||
|
import { fmt } from '../../utils/helpers';
|
||||||
|
|
||||||
const TABS = ['GitHub Actions', 'GitLab CI', 'Jenkins', 'cURL'];
|
const TABS = ['GitHub Actions', 'GitLab CI', 'Jenkins', 'cURL'];
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
'GitHub Actions': '[GH]',
|
'GitHub Actions': '⬡',
|
||||||
'GitLab CI': '[GL]',
|
'GitLab CI': '🦊',
|
||||||
'Jenkins': '[J]',
|
'Jenkins': '🔵',
|
||||||
'cURL': '[>]',
|
'cURL': '>_',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BROWSERS = ['chrome', 'firefox', 'edge'];
|
||||||
|
|
||||||
function CodeBlock({ code }) {
|
function CodeBlock({ code }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const copy = () => {
|
const copy = () => {
|
||||||
navigator.clipboard.writeText(code);
|
navigator.clipboard.writeText(code);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', background: '#0d1117', borderRadius: 12, overflow: 'hidden', border: '1px solid #30363d' }}>
|
<div style={{ position: 'relative', background: '#0d1117', borderRadius: 12, overflow: 'hidden', border: '1px solid #30363d' }}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -44,41 +46,19 @@ function CodeBlock({ code }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SuiteSelector({ suites, selected, onSelect }) {
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
value={selected}
|
|
||||||
onChange={e => onSelect(e.target.value)}
|
|
||||||
style={{
|
|
||||||
background: 'var(--surface)', color: 'var(--txt-h)',
|
|
||||||
border: '1px solid var(--border)', borderRadius: 8,
|
|
||||||
padding: '8px 12px', fontSize: 14, cursor: 'pointer',
|
|
||||||
minWidth: 220,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">— Select a Test Suite —</option>
|
|
||||||
{suites.map(s => (
|
|
||||||
<option key={s.id} value={s.id}>{s.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateSnippet(tab, suite, apiKey, baseUrl) {
|
function generateSnippet(tab, suite, apiKey, baseUrl) {
|
||||||
const name = suite?.name || 'Your Suite Name';
|
const name = suite?.name || 'Your Suite Name';
|
||||||
const id = suite?.id || '{suite_id}';
|
const id = suite?.id || '{suite_id}';
|
||||||
const key = apiKey || 'ap_live_your_api_key_here';
|
const key = apiKey || 'ap_live_your_api_key_here';
|
||||||
const url = baseUrl || 'https://your-autopilot-domain.com';
|
const url = baseUrl || 'https://your-autopilot-domain.com';
|
||||||
|
|
||||||
if (tab === 'GitHub Actions') return `# .github/workflows/autopilot.yml
|
if (tab === 'GitHub Actions') return `# .github/workflows/autopilot.yml
|
||||||
name: AutoPilot Tests
|
name: AutoPilot Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -150,59 +130,55 @@ autopilot_tests:
|
||||||
if (tab === 'Jenkins') return `// Jenkinsfile (Declarative Pipeline)
|
if (tab === 'Jenkins') return `// Jenkinsfile (Declarative Pipeline)
|
||||||
pipeline {
|
pipeline {
|
||||||
agent any
|
agent any
|
||||||
|
|
||||||
environment {
|
environment {
|
||||||
AUTOPILOT_API_KEY = credentials('autopilot-api-key')
|
AUTOPILOT_API_KEY = credentials('autopilot-api-key')
|
||||||
AUTOPILOT_URL = '${url}'
|
AUTOPILOT_URL = '${url}'
|
||||||
SUITE_NAME = '${name}'
|
SUITE_NAME = '${name}'
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
stage('Trigger AutoPilot Tests') {
|
stage('Trigger AutoPilot Tests') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
def response = sh(
|
def response = sh(
|
||||||
script: """curl -s -X POST \\
|
script: """curl -s -X POST \\\\
|
||||||
-H "Authorization: Bearer $AUTOPILOT_API_KEY" \\
|
-H "Authorization: Bearer $AUTOPILOT_API_KEY" \\\\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/json" \\\\
|
||||||
-d '{"suiteName": "$SUITE_NAME", "browser": "chrome"}' \\
|
-d '{"suiteName": "$SUITE_NAME", "browser": "chrome"}' \\\\
|
||||||
$AUTOPILOT_URL/api/v1/suites/trigger-by-name""",
|
$AUTOPILOT_URL/api/v1/suites/trigger-by-name""",
|
||||||
returnStdout: true
|
returnStdout: true
|
||||||
).trim()
|
).trim()
|
||||||
env.SCHEDULER_ID = sh(script: "echo '${"$"}{response}' | jq -r '.schedulerId'", returnStdout: true).trim()
|
env.SCHEDULER_ID = sh(script: "echo '$\{response}' | jq -r '.schedulerId'", returnStdout: true).trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Wait for Results') {
|
stage('Wait for Results') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
timeout(time: 10, unit: 'MINUTES') {
|
timeout(time: 10, unit: 'MINUTES') {
|
||||||
waitUntil {
|
waitUntil {
|
||||||
def status = sh(
|
def status = sh(
|
||||||
script: """curl -s \\
|
script: """curl -s \\\\
|
||||||
-H "Authorization: Bearer $AUTOPILOT_API_KEY" \\
|
-H "Authorization: Bearer $AUTOPILOT_API_KEY" \\\\
|
||||||
$AUTOPILOT_URL/api/v1/schedulers/$SCHEDULER_ID/execution-status | jq -r '.status'""",
|
$AUTOPILOT_URL/api/v1/schedulers/$SCHEDULER_ID/execution-status | jq -r '.status'""",
|
||||||
returnStdout: true
|
returnStdout: true
|
||||||
).trim()
|
).trim()
|
||||||
echo "AutoPilot status: ${"$"}{status}"
|
echo "AutoPilot status: $\{status}"
|
||||||
return status == 'completed'
|
return status == 'completed'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Assert Results') {
|
stage('Assert Results') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
def result = sh(
|
def result = sh(
|
||||||
script: """curl -s \\
|
script: """curl -s \\\\
|
||||||
-H "Authorization: Bearer $AUTOPILOT_API_KEY" \\
|
-H "Authorization: Bearer $AUTOPILOT_API_KEY" \\\\
|
||||||
$AUTOPILOT_URL/api/v1/schedulers/$SCHEDULER_ID/execution-status""",
|
$AUTOPILOT_URL/api/v1/schedulers/$SCHEDULER_ID/execution-status""",
|
||||||
returnStdout: true
|
returnStdout: true
|
||||||
).trim()
|
).trim()
|
||||||
def exitCode = sh(script: "echo '${"$"}{result}' | jq -r '.exitCode'", returnStdout: true).trim()
|
def exitCode = sh(script: "echo '$\{result}' | jq -r '.exitCode'", returnStdout: true).trim()
|
||||||
if (exitCode != '0') {
|
if (exitCode != '0') {
|
||||||
error("AutoPilot tests failed!")
|
error("AutoPilot tests failed!")
|
||||||
}
|
}
|
||||||
|
|
@ -226,149 +202,231 @@ curl -X POST \\
|
||||||
-d '{"suiteName": "${name}", "browser": "chrome"}' \\
|
-d '{"suiteName": "${name}", "browser": "chrome"}' \\
|
||||||
${url}/api/v1/suites/trigger-by-name
|
${url}/api/v1/suites/trigger-by-name
|
||||||
|
|
||||||
# Poll for results (replace {schedulerId} with the ID from the response)
|
# Poll for results
|
||||||
curl -H "Authorization: Bearer ${key}" \\
|
curl -H "Authorization: Bearer ${key}" \\
|
||||||
${url}/api/v1/schedulers/{schedulerId}/execution-status
|
${url}/api/v1/schedulers/{schedulerId}/execution-status
|
||||||
|
|
||||||
# Response when completed:
|
# Response: { "status": "completed", "exitCode": 0, "passedCount": 15, "failedCount": 0 }`;
|
||||||
# {
|
|
||||||
# "executionId": 42,
|
|
||||||
# "status": "completed",
|
|
||||||
# "exitCode": 0, <- 0 = all passed, 1 = failures, 2 = still running
|
|
||||||
# "passedCount": 15,
|
|
||||||
# "failedCount": 0,
|
|
||||||
# "totalCount": 15,
|
|
||||||
# "passPercentage": 100.0,
|
|
||||||
# "durationMs": 38420
|
|
||||||
# }`;
|
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CicdIntegrationView() {
|
export default function CicdIntegrationView() {
|
||||||
const [activeTab, setActiveTab] = useState('GitHub Actions');
|
const [activeTab, setActiveTab] = useState('GitHub Actions');
|
||||||
const [suites, setSuites] = useState([]);
|
const [suites, setSuites] = useState([]);
|
||||||
const [selectedSuiteId, setSelectedSuiteId] = useState('');
|
const [selectedSuiteId, setSelectedSuiteId] = useState('');
|
||||||
const [apiKeys, setApiKeys] = useState([]);
|
const [apiKeys, setApiKeys] = useState([]);
|
||||||
const [selectedKeyId, setSelectedKeyId] = useState('');
|
const [selectedKeyId, setSelectedKeyId] = useState('');
|
||||||
const [baseUrl, setBaseUrl] = useState(window.location.origin);
|
const [baseUrl, setBaseUrl] = useState(window.location.origin);
|
||||||
|
|
||||||
|
// Trigger Now panel
|
||||||
|
const [triggerSuiteId, setTriggerSuiteId] = useState('');
|
||||||
|
const [triggerBrowser, setTriggerBrowser] = useState('chrome');
|
||||||
|
const [triggering, setTriggering] = useState(false);
|
||||||
|
const [triggerResult, setTriggerResult] = useState(null); // { schedulerId, status, message, error }
|
||||||
|
|
||||||
|
// Recent CI/pipeline runs
|
||||||
|
const [recentRuns, setRecentRuns] = useState([]);
|
||||||
|
const [runsLoading, setRunsLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadRecentRuns = useCallback(() => {
|
||||||
|
setRunsLoading(true);
|
||||||
|
api('/api/executions')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
// Pipeline runs are those triggered by a scheduler with an orgExecutionId
|
||||||
|
const runs = (data || [])
|
||||||
|
.filter(e => e.schedulerId != null)
|
||||||
|
.slice(0, 15);
|
||||||
|
setRecentRuns(runs);
|
||||||
|
setRunsLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => setRunsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api('/api/test-suites').then(r => r.json()).then(data => setSuites(data || [])).catch(() => {});
|
api('/api/test-suites').then(r => r.json()).then(data => setSuites(data || [])).catch(() => {});
|
||||||
api('/api/auth/agent-tokens').then(r => r.json()).then(data => setApiKeys(data || [])).catch(() => {});
|
api('/api/auth/agent-tokens').then(r => r.json()).then(data => setApiKeys(data || [])).catch(() => {});
|
||||||
}, []);
|
loadRecentRuns();
|
||||||
|
}, [loadRecentRuns]);
|
||||||
|
|
||||||
const selectedSuite = suites.find(s => String(s.id) === String(selectedSuiteId));
|
const selectedSuite = suites.find(s => String(s.id) === String(selectedSuiteId));
|
||||||
const selectedKey = apiKeys.find(k => String(k.id) === String(selectedKeyId));
|
const selectedKey = apiKeys.find(k => String(k.id) === String(selectedKeyId));
|
||||||
const snippet = generateSnippet(activeTab, selectedSuite, selectedKey?.token, baseUrl);
|
const snippet = generateSnippet(activeTab, selectedSuite, selectedKey?.token, baseUrl);
|
||||||
|
|
||||||
|
const handleTrigger = async () => {
|
||||||
|
if (!triggerSuiteId) return;
|
||||||
|
setTriggering(true);
|
||||||
|
setTriggerResult(null);
|
||||||
|
try {
|
||||||
|
const res = await api(`/api/test-suites/${triggerSuiteId}/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ browserType: triggerBrowser }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setTriggerResult({ success: true, message: `✅ Suite queued! Scheduler ID: ${data.scheduler?.id}` });
|
||||||
|
setTimeout(loadRecentRuns, 1500); // refresh run history
|
||||||
|
} else {
|
||||||
|
setTriggerResult({ success: false, message: `❌ Failed: ${data.error || res.statusText}` });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setTriggerResult({ success: false, message: `❌ Network error: ${e.message}` });
|
||||||
|
} finally {
|
||||||
|
setTriggering(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge = (status) => {
|
||||||
|
const s = (status || '').toLowerCase();
|
||||||
|
if (s === 'success' || s === 'completed') return <span className="badge badge-success" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><CheckCircle size={11} />passed</span>;
|
||||||
|
if (s === 'failed') return <span className="badge badge-error" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><XCircle size={11} />failed</span>;
|
||||||
|
if (s === 'running') return <span className="badge badge-primary" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><Loader size={11} />running</span>;
|
||||||
|
return <span className="badge" style={{ display: 'flex', alignItems: 'center', gap: 4 }}><Clock size={11} />{status || 'queued'}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '32px', maxWidth: 1000, margin: '0 auto' }}>
|
<div className="page-view">
|
||||||
{/* Header */}
|
<div className="page-header">
|
||||||
<div style={{ marginBottom: 32 }}>
|
<div className="page-header-left">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
<h1>CI/CD Integration</h1>
|
||||||
<div style={{
|
<div className="breadcrumbs">
|
||||||
width: 40, height: 40, borderRadius: 10,
|
<Link to="/dashboard">Home</Link>
|
||||||
background: 'linear-gradient(135deg, #7c3aed, #4f46e5)',
|
<span className="sep">›</span>
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
<span>CI/CD Integration</span>
|
||||||
fontSize: 20,
|
|
||||||
}}>🔗</div>
|
|
||||||
<div>
|
|
||||||
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 700, color: 'var(--txt-h)' }}>CI/CD Integration</h1>
|
|
||||||
<p style={{ margin: 0, fontSize: 13, color: 'var(--txt-muted)' }}>
|
|
||||||
Trigger AutoPilot test suites directly from your DevOps pipeline
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Config Panel */}
|
{/* ── Trigger Now ─────────────────────────────────────── */}
|
||||||
<div style={{
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
<div className="card-header">
|
||||||
borderRadius: 14, padding: 24, marginBottom: 28,
|
<div>
|
||||||
}}>
|
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<h3 style={{ margin: '0 0 16px', fontSize: 14, fontWeight: 600, color: 'var(--txt-h)' }}>
|
<Play size={18} color="var(--brand)" /> Trigger Now
|
||||||
Customize Your Snippet
|
</h2>
|
||||||
</h3>
|
<p>Instantly fire a test suite run from the dashboard without writing any pipeline code.</p>
|
||||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '0 24px 24px', display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: 12, color: 'var(--txt-muted)', marginBottom: 6 }}>Test Suite *</label>
|
||||||
|
<select
|
||||||
|
id="trigger-suite-select"
|
||||||
|
value={triggerSuiteId}
|
||||||
|
onChange={e => setTriggerSuiteId(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
style={{ minWidth: 240 }}
|
||||||
|
>
|
||||||
|
<option value="">— Select a Test Suite —</option>
|
||||||
|
{suites.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: 12, color: 'var(--txt-muted)', marginBottom: 6 }}>Browser</label>
|
||||||
|
<select
|
||||||
|
id="trigger-browser-select"
|
||||||
|
value={triggerBrowser}
|
||||||
|
onChange={e => setTriggerBrowser(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
{BROWSERS.map(b => <option key={b} value={b}>{b}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="trigger-now-btn"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!triggerSuiteId || triggering}
|
||||||
|
onClick={handleTrigger}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 8, height: 38 }}
|
||||||
|
>
|
||||||
|
{triggering ? <><Loader size={15} style={{ animation: 'spin 1s linear infinite' }} /> Triggering…</> : <><Play size={15} /> Trigger Run</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{triggerResult && (
|
||||||
|
<div style={{
|
||||||
|
margin: '0 24px 24px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
background: triggerResult.success ? 'rgba(16,185,129,0.08)' : 'rgba(239,68,68,0.08)',
|
||||||
|
border: `1px solid ${triggerResult.success ? 'var(--green)' : 'var(--red)'}`,
|
||||||
|
color: 'var(--txt-h)',
|
||||||
|
}}>
|
||||||
|
{triggerResult.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Snippet Generator ─────────────────────────────────────── */}
|
||||||
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
|
<div className="card-header">
|
||||||
|
<div>
|
||||||
|
<h2>🔗 Code Snippet Generator</h2>
|
||||||
|
<p>Select a suite and API key to generate a ready-to-paste pipeline configuration.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '0 24px 16px', display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: 12, color: 'var(--txt-muted)', marginBottom: 6 }}>Test Suite</label>
|
<label style={{ display: 'block', fontSize: 12, color: 'var(--txt-muted)', marginBottom: 6 }}>Test Suite</label>
|
||||||
<SuiteSelector suites={suites} selected={selectedSuiteId} onSelect={setSelectedSuiteId} />
|
<select value={selectedSuiteId} onChange={e => setSelectedSuiteId(e.target.value)} className="input" style={{ minWidth: 220 }}>
|
||||||
|
<option value="">— Select a Test Suite —</option>
|
||||||
|
{suites.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: 12, color: 'var(--txt-muted)', marginBottom: 6 }}>API Key</label>
|
<label style={{ display: 'block', fontSize: 12, color: 'var(--txt-muted)', marginBottom: 6 }}>API Key</label>
|
||||||
<select
|
<select value={selectedKeyId} onChange={e => setSelectedKeyId(e.target.value)} className="input" style={{ minWidth: 220 }}>
|
||||||
value={selectedKeyId}
|
|
||||||
onChange={e => setSelectedKeyId(e.target.value)}
|
|
||||||
style={{
|
|
||||||
background: 'var(--surface)', color: 'var(--txt-h)',
|
|
||||||
border: '1px solid var(--border)', borderRadius: 8,
|
|
||||||
padding: '8px 12px', fontSize: 14, cursor: 'pointer', minWidth: 220,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">— Select an API Key —</option>
|
<option value="">— Select an API Key —</option>
|
||||||
{apiKeys.map(k => (
|
{apiKeys.map(k => <option key={k.id} value={k.id}>{k.label}</option>)}
|
||||||
<option key={k.id} value={k.id}>{k.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: 12, color: 'var(--txt-muted)', marginBottom: 6 }}>AutoPilot Base URL</label>
|
<label style={{ display: 'block', fontSize: 12, color: 'var(--txt-muted)', marginBottom: 6 }}>AutoPilot Base URL</label>
|
||||||
<input
|
<input type="text" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} className="input" style={{ minWidth: 260 }} />
|
||||||
type="text"
|
|
||||||
value={baseUrl}
|
|
||||||
onChange={e => setBaseUrl(e.target.value)}
|
|
||||||
style={{
|
|
||||||
background: 'var(--surface)', color: 'var(--txt-h)',
|
|
||||||
border: '1px solid var(--border)', borderRadius: 8,
|
|
||||||
padding: '8px 12px', fontSize: 14, minWidth: 260,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{apiKeys.length === 0 && (
|
{apiKeys.length === 0 && (
|
||||||
<p style={{ marginTop: 14, fontSize: 13, color: 'var(--amber)', background: 'rgba(245,158,11,0.08)', borderRadius: 8, padding: '8px 12px' }}>
|
<p style={{ margin: '0 24px 16px', fontSize: 13, color: 'var(--amber)', background: 'rgba(245,158,11,0.08)', borderRadius: 8, padding: '8px 12px' }}>
|
||||||
⚠️ No API Keys found. Go to <strong>CI/CD & API Keys</strong> in Administration to create one first.
|
⚠️ No API Keys found. Go to <strong>Administration → API Keys</strong> to create one first.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Platform Tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, margin: '0 24px 16px', background: 'var(--bg)', borderRadius: 10, padding: 4, border: '1px solid var(--border)', width: 'fit-content' }}>
|
||||||
|
{TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 18px', borderRadius: 7, border: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 13, fontWeight: 500,
|
||||||
|
background: activeTab === tab ? 'linear-gradient(135deg, #7c3aed, #4f46e5)' : 'transparent',
|
||||||
|
color: activeTab === tab ? '#fff' : 'var(--txt-muted)',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ICONS[tab]} {tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '0 24px 24px' }}>
|
||||||
|
<CodeBlock code={snippet} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* ── API Reference ──────────────────────────────────────── */}
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 16, background: 'var(--surface)', borderRadius: 10, padding: 4, border: '1px solid var(--border)', width: 'fit-content' }}>
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
{TABS.map(tab => (
|
<div className="card-header">
|
||||||
<button
|
<div><h2>📖 API Reference</h2></div>
|
||||||
key={tab}
|
</div>
|
||||||
onClick={() => setActiveTab(tab)}
|
<div style={{ padding: '0 24px 24px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
style={{
|
|
||||||
padding: '8px 18px', borderRadius: 7, border: 'none', cursor: 'pointer',
|
|
||||||
fontSize: 13, fontWeight: 500,
|
|
||||||
background: activeTab === tab ? 'linear-gradient(135deg, #7c3aed, #4f46e5)' : 'transparent',
|
|
||||||
color: activeTab === tab ? '#fff' : 'var(--txt-muted)',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ICONS[tab]} {tab}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Code Snippet */}
|
|
||||||
<CodeBlock code={snippet} />
|
|
||||||
|
|
||||||
{/* API Reference */}
|
|
||||||
<div style={{
|
|
||||||
marginTop: 32, background: 'var(--surface)', border: '1px solid var(--border)',
|
|
||||||
borderRadius: 14, padding: 24,
|
|
||||||
}}>
|
|
||||||
<h3 style={{ margin: '0 0 16px', fontSize: 14, fontWeight: 600, color: 'var(--txt-h)' }}>
|
|
||||||
📖 API Reference
|
|
||||||
</h3>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
{[
|
{[
|
||||||
{ method: 'POST', path: '/api/v1/suites/{id}/trigger', desc: 'Trigger a suite by numeric ID. Accepts optional browser, environmentId, targetGroupId in the request body.' },
|
{ method: 'POST', path: '/api/v1/suites/{id}/trigger', desc: 'Trigger a suite by numeric ID. Optional body: { browser, environmentId, targetGroupId }.' },
|
||||||
{ method: 'POST', path: '/api/v1/suites/trigger-by-name', desc: 'Trigger a suite by name. Body: { "suiteName": "...", "browser": "chrome" }.' },
|
{ method: 'POST', path: '/api/v1/suites/trigger-by-name', desc: 'Trigger by name. Body: { "suiteName": "...", "browser": "chrome" }.' },
|
||||||
{ method: 'GET', path: '/api/v1/schedulers/{id}/execution-status', desc: 'Poll the status of a triggered run. Returns exitCode (0=pass, 1=fail, 2=running).' },
|
{ method: 'GET', path: '/api/v1/schedulers/{id}/execution-status', desc: 'Poll a triggered run. Returns exitCode (0=pass, 1=fail, 2=running).' },
|
||||||
{ method: 'GET', path: '/api/v1/executions/{id}/status', desc: 'Get the full status of an execution by its execution ID.' },
|
{ method: 'GET', path: '/api/v1/executions/{id}/status', desc: 'Full execution status by execution ID.' },
|
||||||
].map(({ method, path, desc }) => (
|
].map(({ method, path, desc }) => (
|
||||||
<div key={path} style={{ display: 'flex', gap: 14, alignItems: 'flex-start', padding: '10px 0', borderBottom: '1px solid var(--border)' }}>
|
<div key={path} style={{ display: 'flex', gap: 14, alignItems: 'flex-start', padding: '10px 0', borderBottom: '1px solid var(--border)' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
|
|
@ -385,6 +443,73 @@ export default function CicdIntegrationView() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Recent Pipeline Runs ──────────────────────────────── */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<div>
|
||||||
|
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Clock size={18} color="var(--blue)" /> Recent Pipeline Runs
|
||||||
|
</h2>
|
||||||
|
<p>Executions triggered by the CI/CD API or scheduler.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="refresh-runs-btn"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={loadRecentRuns}
|
||||||
|
disabled={runsLoading}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} style={runsLoading ? { animation: 'spin 1s linear infinite' } : {}} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Suite / Run</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Browser</th>
|
||||||
|
<th>Triggered At</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{runsLoading ? (
|
||||||
|
<tr><td colSpan="7"><div className="spinner" style={{ margin: '20px auto' }} /></td></tr>
|
||||||
|
) : recentRuns.length === 0 ? (
|
||||||
|
<tr><td colSpan="7" style={{ textAlign: 'center', padding: '32px', color: 'var(--txt-muted)' }}>
|
||||||
|
No pipeline runs yet. Use the <strong>Trigger Now</strong> button above or integrate your CI/CD pipeline.
|
||||||
|
</td></tr>
|
||||||
|
) : recentRuns.map(run => {
|
||||||
|
const duration = run.createdAt && run.finishedAt
|
||||||
|
? Math.round((new Date(run.finishedAt) - new Date(run.createdAt)) / 1000)
|
||||||
|
: null;
|
||||||
|
const suiteName = (() => {
|
||||||
|
try { return JSON.parse(run.environmentJson || '{}').referenceId || `Run #${run.orgExecutionId || run.id}`; }
|
||||||
|
catch { return `Run #${run.orgExecutionId || run.id}`; }
|
||||||
|
})();
|
||||||
|
return (
|
||||||
|
<tr key={run.id}>
|
||||||
|
<td><span className="text-muted text-sm">#{run.orgExecutionId || run.id}</span></td>
|
||||||
|
<td style={{ fontWeight: 600 }}>{suiteName}</td>
|
||||||
|
<td>{statusBadge(run.status)}</td>
|
||||||
|
<td><span className="badge">{run.browserType || 'chrome'}</span></td>
|
||||||
|
<td><span className="text-muted text-sm">{fmt(run.createdAt)}</span></td>
|
||||||
|
<td>{duration != null ? `${duration}s` : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<Link to={`/executions/${run.id}`} className="btn btn-ghost btn-sm">View</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue