From 155bd6ac116508170db7a87ca05eed2f952c2cb4 Mon Sep 17 00:00:00 2001 From: vithobaa Date: Tue, 30 Jun 2026 10:31:16 +0530 Subject: [PATCH] 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 --- .../controller/AnalyticsController.java | 68 +++ .../controller/EnvironmentController.java | 15 +- .../controller/TestCaseController.java | 41 +- .../controller/TestSuiteController.java | 29 +- .../service/AuditService.java | 35 ++ .../service/TestSuiteService.java | 20 +- ui/src/pages/analytics/AnalyticsView.jsx | 298 ++++++++---- ui/src/pages/cicd/CicdIntegrationView.jsx | 429 +++++++++++------- 8 files changed, 662 insertions(+), 273 deletions(-) create mode 100644 master/src/main/java/com/autopilot/localagent_cloud/controller/AnalyticsController.java create mode 100644 master/src/main/java/com/autopilot/localagent_cloud/service/AuditService.java diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/AnalyticsController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/AnalyticsController.java new file mode 100644 index 0000000..3827860 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/AnalyticsController.java @@ -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>> 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>> 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> 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> getTimeSaved(HttpServletRequest req) { + Long orgId = orgId(req); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return ResponseEntity.ok(analyticsService.getTimeSavedRoi(orgId)); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/EnvironmentController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/EnvironmentController.java index 578cf7d..d92a186 100644 --- a/master/src/main/java/com/autopilot/localagent_cloud/controller/EnvironmentController.java +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/EnvironmentController.java @@ -1,11 +1,10 @@ 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.Variable; -import com.autopilot.localagent_cloud.repository.AuditLogRepository; import com.autopilot.localagent_cloud.repository.EnvironmentRepository; import com.autopilot.localagent_cloud.repository.VariableRepository; +import com.autopilot.localagent_cloud.service.AuditService; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -23,14 +22,14 @@ public class EnvironmentController { private final EnvironmentRepository environmentRepository; private final VariableRepository variableRepository; - private final AuditLogRepository auditLogRepository; + private final AuditService auditService; public EnvironmentController(EnvironmentRepository environmentRepository, VariableRepository variableRepository, - AuditLogRepository auditLogRepository) { + AuditService auditService) { this.environmentRepository = environmentRepository; this.variableRepository = variableRepository; - this.auditLogRepository = auditLogRepository; + this.auditService = auditService; } private String getUserEmail(HttpServletRequest req) { @@ -57,7 +56,7 @@ public class EnvironmentController { if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); env.setOrgId(orgId); 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); } @@ -73,7 +72,7 @@ public class EnvironmentController { e.setName(body.getName()); e.setDescription(body.getDescription()); 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); }).orElse(ResponseEntity.notFound().build()); } @@ -92,7 +91,7 @@ public class EnvironmentController { List envVars = variableRepository.findByOrgIdAndScopeAndScopeId(orgId, "ENVIRONMENT", id); variableRepository.deleteAll(envVars); 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(); } diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/TestCaseController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/TestCaseController.java index 2042f99..88ec704 100644 --- a/master/src/main/java/com/autopilot/localagent_cloud/controller/TestCaseController.java +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/TestCaseController.java @@ -1,6 +1,7 @@ package com.autopilot.localagent_cloud.controller; import com.autopilot.localagent_cloud.model.TestCase; +import com.autopilot.localagent_cloud.service.AuditService; import com.autopilot.localagent_cloud.service.TestCaseService; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.ResponseEntity; @@ -11,13 +12,14 @@ import java.util.Map; @RestController @RequestMapping("/api/test-cases") -@CrossOrigin(origins = "*") public class TestCaseController { private final TestCaseService testCaseService; + private final AuditService auditService; - public TestCaseController(TestCaseService testCaseService) { + public TestCaseController(TestCaseService testCaseService, AuditService auditService) { this.testCaseService = testCaseService; + this.auditService = auditService; } private Long orgId(HttpServletRequest req) { @@ -25,6 +27,11 @@ public class TestCaseController { return o != null ? ((Number) o).longValue() : null; } + private String userEmail(HttpServletRequest req) { + Object e = req.getAttribute("email"); + return e != null ? e.toString() : "SYSTEM"; + } + @GetMapping public ResponseEntity> getTestCases(HttpServletRequest req) { return testCaseService.getAll(orgId(req)); @@ -38,18 +45,38 @@ public class TestCaseController { @PostMapping public ResponseEntity> createTestCase( @RequestBody Map body, HttpServletRequest req) { - return testCaseService.create(body, orgId(req)); + Long orgId = orgId(req); + ResponseEntity> 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}") public ResponseEntity> updateTestCase( @PathVariable("id") Long id, - @RequestBody Map body) { - return testCaseService.update(id, body); + @RequestBody Map body, HttpServletRequest req) { + ResponseEntity> 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}") - public ResponseEntity deleteTestCase(@PathVariable("id") Long id) { - return testCaseService.delete(id); + public ResponseEntity deleteTestCase(@PathVariable("id") Long id, HttpServletRequest req) { + Long orgId = orgId(req); + ResponseEntity res = testCaseService.delete(id); + if (res.getStatusCode().is2xxSuccessful()) { + auditService.log(orgId, userEmail(req), "DELETE", "TEST_CASE", id, "Deleted Test Case ID: " + id); + } + return res; } } diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/TestSuiteController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/TestSuiteController.java index fe3593b..28a59b9 100644 --- a/master/src/main/java/com/autopilot/localagent_cloud/controller/TestSuiteController.java +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/TestSuiteController.java @@ -1,8 +1,7 @@ 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.repository.AuditLogRepository; +import com.autopilot.localagent_cloud.service.AuditService; import com.autopilot.localagent_cloud.service.TestSuiteService; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.ResponseEntity; @@ -13,15 +12,14 @@ import java.util.Map; @RestController @RequestMapping("/api/test-suites") -@CrossOrigin(origins = "*") public class TestSuiteController { 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.auditLogRepository = auditLogRepository; + this.auditService = auditService; } private String getUserEmail(HttpServletRequest req) { @@ -47,10 +45,11 @@ public class TestSuiteController { @PostMapping public ResponseEntity> createTestSuite( @RequestBody Map body, HttpServletRequest req) { - ResponseEntity> res = testSuiteService.create(body, orgId(req)); + Long orgId = orgId(req); + ResponseEntity> res = testSuiteService.create(body, orgId); if (res.getStatusCode().is2xxSuccessful() && res.getBody() != null) { 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; } @@ -62,7 +61,7 @@ public class TestSuiteController { ResponseEntity> res = testSuiteService.update(id, body); if (res.getStatusCode().is2xxSuccessful() && res.getBody() != null) { 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; } @@ -72,14 +71,20 @@ public class TestSuiteController { @PathVariable("id") Long id, @RequestBody(required = false) Map body, HttpServletRequest req) { - return testSuiteService.run(id, body, orgId(req)); + Long orgId = orgId(req); + ResponseEntity> 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}") public ResponseEntity deleteTestSuite(@PathVariable("id") Long id, HttpServletRequest req) { - ResponseEntity res = testSuiteService.delete(id); + Long orgId = orgId(req); + ResponseEntity res = testSuiteService.delete(id, orgId); 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; } diff --git a/master/src/main/java/com/autopilot/localagent_cloud/service/AuditService.java b/master/src/main/java/com/autopilot/localagent_cloud/service/AuditService.java new file mode 100644 index 0000000..3263748 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/service/AuditService.java @@ -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); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/service/TestSuiteService.java b/master/src/main/java/com/autopilot/localagent_cloud/service/TestSuiteService.java index ecc2d0b..82ca9c6 100644 --- a/master/src/main/java/com/autopilot/localagent_cloud/service/TestSuiteService.java +++ b/master/src/main/java/com/autopilot/localagent_cloud/service/TestSuiteService.java @@ -33,6 +33,7 @@ public class TestSuiteService { private final TestCaseRepository testCaseRepository; private final TestStepRepository testStepRepository; private final SchedulerRepository schedulerRepository; + private final AuditService auditService; public TestSuiteService(TestSuiteRepository testSuiteRepository, TestSuiteGroupMappingRepository testSuiteGroupMappingRepository, @@ -40,7 +41,8 @@ public class TestSuiteService { TestCaseGroupMappingRepository testCaseGroupMappingRepository, TestCaseRepository testCaseRepository, TestStepRepository testStepRepository, - SchedulerRepository schedulerRepository) { + SchedulerRepository schedulerRepository, + AuditService auditService) { this.testSuiteRepository = testSuiteRepository; this.testSuiteGroupMappingRepository = testSuiteGroupMappingRepository; this.testCaseGroupRepository = testCaseGroupRepository; @@ -48,6 +50,7 @@ public class TestSuiteService { this.testCaseRepository = testCaseRepository; this.testStepRepository = testStepRepository; this.schedulerRepository = schedulerRepository; + this.auditService = auditService; } public ResponseEntity> getAll(Long orgId) { @@ -122,6 +125,7 @@ public class TestSuiteService { Map result = new HashMap<>(); result.put("suite", suite); result.put("testCaseGroupIds", groupIds); + auditService.log(orgId, null, "CREATE", "TestSuite", suite.getId(), "Created suite: " + suite.getName()); return ResponseEntity.status(HttpStatus.CREATED).body(result); } @@ -151,6 +155,7 @@ public class TestSuiteService { Map result = new HashMap<>(); result.put("suite", existing); + auditService.log(existing.getOrgId(), null, "UPDATE", "TestSuite", id, "Updated suite: " + existing.getName()); return ResponseEntity.ok(result); }).orElse(ResponseEntity.notFound().build()); } @@ -188,15 +193,16 @@ public class TestSuiteService { result.put("scheduler", scheduler); result.put("suite", suite); 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); }).orElse(ResponseEntity.notFound().build()); } - public ResponseEntity delete(Long id) { - if (!testSuiteRepository.existsById(id)) { - return ResponseEntity.notFound().build(); - } - testSuiteRepository.deleteById(id); - return ResponseEntity.noContent().build(); + public ResponseEntity delete(Long id, Long orgId) { + return testSuiteRepository.findById(id).map(suite -> { + testSuiteRepository.deleteById(id); + auditService.log(orgId, null, "DELETE", "TestSuite", id, "Deleted suite: " + suite.getName()); + return ResponseEntity.noContent().build(); + }).orElse(ResponseEntity.notFound().build()); } } diff --git a/ui/src/pages/analytics/AnalyticsView.jsx b/ui/src/pages/analytics/AnalyticsView.jsx index c63fdca..4b2c1ec 100644 --- a/ui/src/pages/analytics/AnalyticsView.jsx +++ b/ui/src/pages/analytics/AnalyticsView.jsx @@ -1,83 +1,95 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; 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'; +function StatCard({ icon, value, label, color = 'var(--brand)', suffix = '' }) { + return ( +
+
{icon}
+
{value}{suffix}
+
{label}
+
+ ); +} + +function TrendBar({ value, max = 100, color = 'var(--brand)' }) { + const pct = Math.min((value / max) * 100, 100); + return ( +
+
+
+
+ {value.toFixed(1)}% +
+ ); +} + 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); useEffect(() => { - api('/api/executions') - .then(r => r.json()) - .then(d => { setExecs(d || []); setLoading(false); }) - .catch(() => setLoading(false)); + Promise.all([ + api('/api/analytics/time-saved').then(r => r.json()).catch(() => null), + api('/api/analytics/flaky-suites').then(r => r.json()).catch(() => []), + 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) { return (
-

Analytics & Reports

+

Analytics & Reports

-
+
); } + 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 (
-

Analytics & Reports

+

Analytics & Reports

Home @@ -86,29 +98,138 @@ export default function AnalyticsView() {
-
-
- -
{passRate}%
-
Overall Pass Rate
+ {/* Top stat cards */} +
+ } value={overallPassRate} suffix="%" label="Overall Pass Rate" color="var(--brand)" /> + } value={totalRuns} label="Total Executions" color="var(--blue)" /> + } value={avgDuration} suffix="s" label="Avg Execution Time" color="var(--green)" /> + } value={`$${dollarsSaved.toLocaleString()}`} label={`${hoursSaved}h Saved (ROI)`} color="var(--amber)" /> +
+ + {/* ROI + Fleet Row */} +
+ + {/* Daily Run Sparkline */} +
+
+
+

+ Run Activity (Last 14 Days) +

+

{todayRuns} runs today • {weekRuns} runs this week

+
+
+
+ {dailyBreakdown.map((d, i) => ( +
+
0 ? 6 : 2)}px`, + background: d.runs > 0 ? 'var(--brand)' : 'var(--border)', + borderRadius: 3, + transition: 'height 0.4s', + }} + /> +
+ ))} +
+
+ {dailyBreakdown[0]?.date} + {dailyBreakdown[dailyBreakdown.length - 1]?.date} +
-
- -
{totalExecs}
-
Total Executions
-
-
- -
{avgDuration}s
-
Avg Execution Time
+ + {/* Fleet Health */} +
+
+
+

+ Agent Fleet Health +

+

Queue depth: {queueDepth} pending job{queueDepth !== 1 ? 's' : ''}

+
+
+
+
+
{runningAgents + onlineAgents}
+
Online
+
+
+
{runningAgents}
+
Running
+
+
+
{offlineAgents}
+
Offline
+
+
+ {fleet?.agents?.length > 0 && ( +
+ {fleet.agents.slice(0, 4).map(a => ( +
+ {a.name} + + {a.status} + +
+ ))} +
+ )}
+ {/* Suite Performance Leaderboard */} + {suitePerf.length > 0 && ( +
+
+
+

+ Suite Performance Leaderboard +

+

Pass rates and run counts per test suite, sorted by reliability.

+
+
+
+ + + + + + + + + + + + + + {suitePerf.map(s => ( + + + + + + + + + + ))} + +
Test SuiteTotal RunsPassedFailedPass RateAvg DurationLast Run
{s.suiteName}{s.totalRuns}{s.passedRuns} 0 ? 'var(--red)' : 'var(--txt-muted)', display: 'flex', alignItems: 'center', gap: 4 }}>{s.failedRuns}= 80 ? 'var(--green)' : s.successRate >= 50 ? 'var(--amber)' : 'var(--red)'} />{s.avgDurationSecs != null ? `${s.avgDurationSecs}s` : }{s.lastRunAt ? fmt(s.lastRunAt) : '—'}
+
+
+ )} + + {/* Flakiness Analysis */}
-

Flakiness Analysis

-

Test suites that frequently alternate between pass and fail.

+

+ Flakiness Analysis +

+

Test suites that frequently alternate between pass and fail (requires ≥2 runs each).

@@ -117,27 +238,30 @@ export default function AnalyticsView() { Test Suite Total Runs + Passed + Failed Flakiness Score - Status + Trend - {flakiness.length === 0 ? ( - No flaky tests detected! Great job! + {flaky.length === 0 ? ( + + 🎉 No flaky tests detected — great reliability! + ) : ( - flakiness.map(f => ( - - {f.name} - {f.total} + flaky.map(f => ( + + {f.suiteName} + {f.totalRuns} + {f.passedRuns} + {f.failedRuns} + -
-
-
-
- {(f.flakeScore * 200).toFixed(1)}% -
+ + {f.trend === 'improving' ? '↑ Improving' : '↓ Deteriorating'} + - Needs Review )) )} diff --git a/ui/src/pages/cicd/CicdIntegrationView.jsx b/ui/src/pages/cicd/CicdIntegrationView.jsx index 92c4b01..e93bd7b 100644 --- a/ui/src/pages/cicd/CicdIntegrationView.jsx +++ b/ui/src/pages/cicd/CicdIntegrationView.jsx @@ -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 { Play, RefreshCw, CheckCircle, XCircle, Clock, Loader } from 'lucide-react'; +import { fmt } from '../../utils/helpers'; const TABS = ['GitHub Actions', 'GitLab CI', 'Jenkins', 'cURL']; - const ICONS = { - 'GitHub Actions': '[GH]', - 'GitLab CI': '[GL]', - 'Jenkins': '[J]', - 'cURL': '[>]', + 'GitHub Actions': '⬡', + 'GitLab CI': '🦊', + 'Jenkins': '🔵', + 'cURL': '>_', }; +const BROWSERS = ['chrome', 'firefox', 'edge']; + function CodeBlock({ code }) { const [copied, setCopied] = useState(false); - const copy = () => { navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 2000); }; - return (
+
+ {triggerResult && ( +
+ {triggerResult.message} +
+ )} +
+ + {/* ── Snippet Generator ─────────────────────────────────────── */} +
+
+
+

🔗 Code Snippet Generator

+

Select a suite and API key to generate a ready-to-paste pipeline configuration.

+
+
+
- +
- setSelectedKeyId(e.target.value)} className="input" style={{ minWidth: 220 }}> - {apiKeys.map(k => ( - - ))} + {apiKeys.map(k => )}
- 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, - }} - /> + setBaseUrl(e.target.value)} className="input" style={{ minWidth: 260 }} />
{apiKeys.length === 0 && ( -

- ⚠️ No API Keys found. Go to CI/CD & API Keys in Administration to create one first. +

+ ⚠️ No API Keys found. Go to Administration → API Keys to create one first.

)} + + {/* Platform Tabs */} +
+ {TABS.map(tab => ( + + ))} +
+
+ +
- {/* Tabs */} -
- {TABS.map(tab => ( - - ))} -
- - {/* Code Snippet */} - - - {/* API Reference */} -
-

- 📖 API Reference -

-
+ {/* ── API Reference ──────────────────────────────────────── */} +
+
+

📖 API Reference

+
+
{[ - { 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/trigger-by-name', desc: 'Trigger a suite 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/executions/{id}/status', desc: 'Get the full status of an execution by its execution ID.' }, + { 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 by name. Body: { "suiteName": "...", "browser": "chrome" }.' }, + { 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: 'Full execution status by execution ID.' }, ].map(({ method, path, desc }) => (
+ + {/* ── Recent Pipeline Runs ──────────────────────────────── */} +
+
+
+

+ Recent Pipeline Runs +

+

Executions triggered by the CI/CD API or scheduler.

+
+ +
+
+ + + + + + + + + + + + + + {runsLoading ? ( + + ) : recentRuns.length === 0 ? ( + + ) : 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 ( + + + + + + + + + + ); + })} + +
#Suite / RunStatusBrowserTriggered AtDuration
+ No pipeline runs yet. Use the Trigger Now button above or integrate your CI/CD pipeline. +
#{run.orgExecutionId || run.id}{suiteName}{statusBadge(run.status)}{run.browserType || 'chrome'}{fmt(run.createdAt)}{duration != null ? `${duration}s` : '—'} + View +
+
+
); }