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:
vithobaa 2026-06-30 10:31:16 +05:30
parent 0c39f6141b
commit 155bd6ac11
8 changed files with 662 additions and 273 deletions

View File

@ -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));
}
}

View File

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

View File

@ -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<List<TestCase>> getTestCases(HttpServletRequest req) {
return testCaseService.getAll(orgId(req));
@ -38,18 +45,38 @@ public class TestCaseController {
@PostMapping
public ResponseEntity<Map<String, Object>> createTestCase(
@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}")
public ResponseEntity<Map<String, Object>> updateTestCase(
@PathVariable("id") Long id,
@RequestBody Map<String, Object> body) {
return testCaseService.update(id, body);
@RequestBody Map<String, Object> body, HttpServletRequest req) {
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}")
public ResponseEntity<Void> deleteTestCase(@PathVariable("id") Long id) {
return testCaseService.delete(id);
public ResponseEntity<Void> deleteTestCase(@PathVariable("id") Long id, HttpServletRequest req) {
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;
}
}

View File

@ -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<Map<String, Object>> createTestSuite(
@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) {
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<Map<String, Object>> 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<String, Object> body,
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}")
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()) {
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;
}

View File

@ -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);
}
}

View File

@ -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<List<TestSuite>> getAll(Long orgId) {
@ -122,6 +125,7 @@ public class TestSuiteService {
Map<String, Object> 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<String, Object> 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<Void> delete(Long id) {
if (!testSuiteRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
testSuiteRepository.deleteById(id);
return ResponseEntity.noContent().build();
public ResponseEntity<Void> 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().<Void>build();
}).orElse(ResponseEntity.notFound().build());
}
}

View File

@ -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 (
<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() {
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 (
<div className="page-view">
<div className="page-header">
<div className="page-header-left"><h1>Analytics & Reports</h1></div>
<div className="page-header-left"><h1>Analytics &amp; Reports</h1></div>
</div>
<div style={{ textAlign: 'center', padding: 48 }}><div className="spinner" /></div>
<div style={{ textAlign: 'center', padding: 64 }}><div className="spinner" /></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 (
<div className="page-view">
<div className="page-header">
<div className="page-header-left">
<h1>Analytics & Reports</h1>
<h1>Analytics &amp; Reports</h1>
<div className="breadcrumbs">
<Link to="/dashboard">Home</Link>
<span className="sep"></span>
@ -86,29 +98,138 @@ export default function AnalyticsView() {
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '20px', marginBottom: '24px' }}>
<div className="card" style={{ padding: '24px', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<TrendingUp size={32} color="var(--brand)" style={{ marginBottom: 12 }} />
<div style={{ fontSize: '32px', fontWeight: 800 }}>{passRate}%</div>
<div style={{ color: 'var(--txt-muted)', fontSize: '13px' }}>Overall Pass Rate</div>
{/* Top stat cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 20, marginBottom: 24 }}>
<StatCard icon={<TrendingUp size={32} />} value={overallPassRate} suffix="%" label="Overall Pass Rate" color="var(--brand)" />
<StatCard icon={<BarChart3 size={32} />} value={totalRuns} label="Total Executions" color="var(--blue)" />
<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 &bull; {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 className="card" style={{ padding: '24px', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<BarChart3 size={32} color="var(--blue)" style={{ marginBottom: 12 }} />
<div style={{ fontSize: '32px', fontWeight: 800 }}>{totalExecs}</div>
<div style={{ color: 'var(--txt-muted)', fontSize: '13px' }}>Total Executions</div>
</div>
<div className="card" style={{ padding: '24px', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Clock size={32} color="var(--green)" style={{ marginBottom: 12 }} />
<div style={{ fontSize: '32px', fontWeight: 800 }}>{avgDuration}s</div>
<div style={{ color: 'var(--txt-muted)', fontSize: '13px' }}>Avg Execution Time</div>
{/* Fleet Health */}
<div className="card">
<div className="card-header">
<div>
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Cpu size={18} color="var(--blue)" /> Agent Fleet Health
</h2>
<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>
{/* 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-header">
<div>
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8 }}><AlertTriangle size={18} color="var(--amber)" /> Flakiness Analysis</h2>
<p>Test suites that frequently alternate between pass and fail.</p>
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<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 className="table-responsive">
@ -117,27 +238,30 @@ export default function AnalyticsView() {
<tr>
<th>Test Suite</th>
<th>Total Runs</th>
<th>Passed</th>
<th>Failed</th>
<th>Flakiness Score</th>
<th>Status</th>
<th>Trend</th>
</tr>
</thead>
<tbody>
{flakiness.length === 0 ? (
<tr><td colSpan="4" style={{ textAlign: 'center', padding: '32px', color: 'var(--txt-muted)' }}>No flaky tests detected! Great job!</td></tr>
{flaky.length === 0 ? (
<tr><td colSpan="6" style={{ textAlign: 'center', padding: '32px', color: 'var(--txt-muted)' }}>
🎉 No flaky tests detected great reliability!
</td></tr>
) : (
flakiness.map(f => (
<tr key={f.name}>
<td style={{ fontWeight: 600 }}>{f.name}</td>
<td>{f.total}</td>
flaky.map(f => (
<tr key={f.suiteName}>
<td style={{ fontWeight: 600 }}>{f.suiteName}</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>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: '100px', height: '6px', background: 'var(--border)', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{ width: `${f.flakeScore * 200}%`, height: '100%', background: 'var(--amber)' }} />
</div>
<span style={{ fontSize: '12px' }}>{(f.flakeScore * 200).toFixed(1)}%</span>
</div>
<span className={`badge ${f.trend === 'improving' ? 'badge-success' : 'badge-error'}`}>
{f.trend === 'improving' ? '↑ Improving' : '↓ Deteriorating'}
</span>
</td>
<td><span className="badge badge-warning">Needs Review</span></td>
</tr>
))
)}

View File

@ -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 (
<div style={{ position: 'relative', background: '#0d1117', borderRadius: 12, overflow: 'hidden', border: '1px solid #30363d' }}>
<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) {
const name = suite?.name || 'Your Suite Name';
const id = suite?.id || '{suite_id}';
const key = apiKey || 'ap_live_your_api_key_here';
const url = baseUrl || 'https://your-autopilot-domain.com';
const id = suite?.id || '{suite_id}';
const key = apiKey || 'ap_live_your_api_key_here';
const url = baseUrl || 'https://your-autopilot-domain.com';
if (tab === 'GitHub Actions') return `# .github/workflows/autopilot.yml
name: AutoPilot Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
@ -150,59 +130,55 @@ autopilot_tests:
if (tab === 'Jenkins') return `// Jenkinsfile (Declarative Pipeline)
pipeline {
agent any
environment {
AUTOPILOT_API_KEY = credentials('autopilot-api-key')
AUTOPILOT_URL = '${url}'
SUITE_NAME = '${name}'
}
stages {
stage('Trigger AutoPilot Tests') {
steps {
script {
def response = sh(
script: """curl -s -X POST \\
-H "Authorization: Bearer $AUTOPILOT_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"suiteName": "$SUITE_NAME", "browser": "chrome"}' \\
script: """curl -s -X POST \\\\
-H "Authorization: Bearer $AUTOPILOT_API_KEY" \\\\
-H "Content-Type: application/json" \\\\
-d '{"suiteName": "$SUITE_NAME", "browser": "chrome"}' \\\\
$AUTOPILOT_URL/api/v1/suites/trigger-by-name""",
returnStdout: true
).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') {
steps {
script {
timeout(time: 10, unit: 'MINUTES') {
waitUntil {
def status = sh(
script: """curl -s \\
-H "Authorization: Bearer $AUTOPILOT_API_KEY" \\
script: """curl -s \\\\
-H "Authorization: Bearer $AUTOPILOT_API_KEY" \\\\
$AUTOPILOT_URL/api/v1/schedulers/$SCHEDULER_ID/execution-status | jq -r '.status'""",
returnStdout: true
).trim()
echo "AutoPilot status: ${"$"}{status}"
echo "AutoPilot status: $\{status}"
return status == 'completed'
}
}
}
}
}
stage('Assert Results') {
steps {
script {
def result = sh(
script: """curl -s \\
-H "Authorization: Bearer $AUTOPILOT_API_KEY" \\
script: """curl -s \\\\
-H "Authorization: Bearer $AUTOPILOT_API_KEY" \\\\
$AUTOPILOT_URL/api/v1/schedulers/$SCHEDULER_ID/execution-status""",
returnStdout: true
).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') {
error("AutoPilot tests failed!")
}
@ -226,149 +202,231 @@ curl -X POST \\
-d '{"suiteName": "${name}", "browser": "chrome"}' \\
${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}" \\
${url}/api/v1/schedulers/{schedulerId}/execution-status
# Response when completed:
# {
# "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
# }`;
# Response: { "status": "completed", "exitCode": 0, "passedCount": 15, "failedCount": 0 }`;
return '';
}
export default function CicdIntegrationView() {
const [activeTab, setActiveTab] = useState('GitHub Actions');
const [suites, setSuites] = useState([]);
const [activeTab, setActiveTab] = useState('GitHub Actions');
const [suites, setSuites] = useState([]);
const [selectedSuiteId, setSelectedSuiteId] = useState('');
const [apiKeys, setApiKeys] = useState([]);
const [selectedKeyId, setSelectedKeyId] = useState('');
const [baseUrl, setBaseUrl] = useState(window.location.origin);
const [apiKeys, setApiKeys] = useState([]);
const [selectedKeyId, setSelectedKeyId] = useState('');
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(() => {
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(() => {});
}, []);
loadRecentRuns();
}, [loadRecentRuns]);
const selectedSuite = suites.find(s => String(s.id) === String(selectedSuiteId));
const selectedKey = apiKeys.find(k => String(k.id) === String(selectedKeyId));
const snippet = generateSnippet(activeTab, selectedSuite, selectedKey?.token, baseUrl);
const selectedKey = apiKeys.find(k => String(k.id) === String(selectedKeyId));
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 (
<div style={{ padding: '32px', maxWidth: 1000, margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: 32 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: 'linear-gradient(135deg, #7c3aed, #4f46e5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
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 className="page-view">
<div className="page-header">
<div className="page-header-left">
<h1>CI/CD Integration</h1>
<div className="breadcrumbs">
<Link to="/dashboard">Home</Link>
<span className="sep"></span>
<span>CI/CD Integration</span>
</div>
</div>
</div>
{/* Config Panel */}
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 14, padding: 24, marginBottom: 28,
}}>
<h3 style={{ margin: '0 0 16px', fontSize: 14, fontWeight: 600, color: 'var(--txt-h)' }}>
Customize Your Snippet
</h3>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-end' }}>
{/* ── Trigger Now ─────────────────────────────────────── */}
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-header">
<div>
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Play size={18} color="var(--brand)" /> Trigger Now
</h2>
<p>Instantly fire a test suite run from the dashboard without writing any pipeline code.</p>
</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>
<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>
<label style={{ display: 'block', fontSize: 12, color: 'var(--txt-muted)', marginBottom: 6 }}>API Key</label>
<select
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,
}}
>
<select value={selectedKeyId} onChange={e => setSelectedKeyId(e.target.value)} className="input" style={{ minWidth: 220 }}>
<option value=""> Select an API Key </option>
{apiKeys.map(k => (
<option key={k.id} value={k.id}>{k.label}</option>
))}
{apiKeys.map(k => <option key={k.id} value={k.id}>{k.label}</option>)}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: 12, color: 'var(--txt-muted)', marginBottom: 6 }}>AutoPilot Base URL</label>
<input
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,
}}
/>
<input type="text" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} className="input" style={{ minWidth: 260 }} />
</div>
</div>
{apiKeys.length === 0 && (
<p style={{ marginTop: 14, 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.
<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>Administration API Keys</strong> to create one first.
</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>
{/* Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 16, background: 'var(--surface)', 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>
{/* 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 }}>
{/* ── API Reference ──────────────────────────────────────── */}
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-header">
<div><h2>📖 API Reference</h2></div>
</div>
<div style={{ padding: '0 24px 24px', 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/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 }) => (
<div key={path} style={{ display: 'flex', gap: 14, alignItems: 'flex-start', padding: '10px 0', borderBottom: '1px solid var(--border)' }}>
<span style={{
@ -385,6 +443,73 @@ export default function CicdIntegrationView() {
))}
</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>
);
}