mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
feat: M14 — Observability (dashboard charts, agent fleet, stats API, metrics, structured logging, rollback)
Backend: StatsService with 5 aggregation methods, JSON metrics endpoint, slog-based structured logging middleware. Stats API: dashboard summary, certificates-by-status, expiration timeline, job trends, issuance rate. 23 new backend tests. Frontend: Recharts-powered dashboard with 4 charts (status pie, expiration heatmap, job trends line, issuance bar), agent fleet overview page with OS/arch grouping and version breakdown, deployment rollback buttons on version history. 7 new frontend tests. 78 API endpoints, 744+ total tests (658 Go + 86 Vitest). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ certctl is a self-hosted platform for **end-to-end certificate lifecycle automat
|
||||
|
||||
## What It Does
|
||||
|
||||
certctl gives you a single pane of glass for every TLS certificate in your organization. The **web dashboard** shows your full certificate inventory — what's healthy, what's expiring, what's already expired, and who owns each one. The **REST API** (71 endpoints) lets you automate everything. **Agents** deployed on your infrastructure generate private keys locally and submit CSRs — private keys never leave your servers. The background scheduler watches expiration dates and triggers renewals automatically — when certificate lifespans drop to 47 days, certctl handles the constant rotation without human involvement.
|
||||
certctl gives you a single pane of glass for every TLS certificate in your organization. The **web dashboard** shows your full certificate inventory — what's healthy, what's expiring, what's already expired, and who owns each one. The **REST API** (78 endpoints) lets you automate everything. **Agents** deployed on your infrastructure generate private keys locally and submit CSRs — private keys never leave your servers. The background scheduler watches expiration dates and triggers renewals automatically — when certificate lifespans drop to 47 days, certctl handles the constant rotation without human involvement.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -365,7 +365,7 @@ make docker-clean # Stop + remove volumes
|
||||
## Roadmap
|
||||
|
||||
### V1 (v1.0.0 released)
|
||||
All nine development milestones (M1–M9) are complete. The backend covers the full certificate lifecycle: Local CA and ACME v2 issuers, NGINX/Apache/HAProxy/F5/IIS target connectors, threshold-based expiration alerting, agent-side ECDSA P-256 key generation, API auth with rate limiting, and a React dashboard with 17 pages wired to the real API. The CI pipeline runs build, vet, test with coverage gates (service layer 30%+, handler layer 50%+), frontend type checking, Vitest test suite, and Vite production build on every push. 677+ tests total: 497 Go test functions + 101 subtests across service, handler, integration, connector, and domain layers, plus 79 frontend Vitest tests covering all API client endpoints, utilities, and M13 operations. Docker images are published to GitHub Container Registry on every version tag via the release workflow.
|
||||
All nine development milestones (M1–M9) are complete. The backend covers the full certificate lifecycle: Local CA and ACME v2 issuers, NGINX/Apache/HAProxy/F5/IIS target connectors, threshold-based expiration alerting, agent-side ECDSA P-256 key generation, API auth with rate limiting, and a React dashboard with 17 pages wired to the real API. The CI pipeline runs build, vet, test with coverage gates (service layer 30%+, handler layer 50%+), frontend type checking, Vitest test suite, and Vite production build on every push. 744+ tests total: ~520 Go test functions + ~138 subtests across service, handler, integration, connector, and domain layers, plus 86 frontend Vitest tests covering all API client endpoints, stats/metrics endpoints, utilities, and M13 operations. Docker images are published to GitHub Container Registry on every version tag via the release workflow.
|
||||
|
||||
### V2: Operational Maturity
|
||||
- **M10: Agent Metadata + Targets** ✅ — agents report OS, architecture, IP, hostname, version via heartbeat; Apache httpd and HAProxy target connectors
|
||||
|
||||
+12
-2
@@ -152,6 +152,10 @@ func main() {
|
||||
agentGroupService := service.NewAgentGroupService(agentGroupRepo, auditService)
|
||||
logger.Info("initialized all services")
|
||||
|
||||
// Initialize stats and metrics services
|
||||
statsService := service.NewStatsService(certificateRepo, jobRepo, agentRepo)
|
||||
logger.Info("initialized stats service")
|
||||
|
||||
// Initialize API handlers
|
||||
certificateHandler := handler.NewCertificateHandler(certificateService)
|
||||
issuerHandler := handler.NewIssuerHandler(issuerService)
|
||||
@@ -165,6 +169,8 @@ func main() {
|
||||
agentGroupHandler := handler.NewAgentGroupHandler(agentGroupService)
|
||||
auditHandler := handler.NewAuditHandler(auditService)
|
||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||
statsHandler := handler.NewStatsHandler(statsService)
|
||||
metricsHandler := handler.NewMetricsHandler(statsService, time.Now())
|
||||
healthHandler := handler.NewHealthHandler(cfg.Auth.Type)
|
||||
logger.Info("initialized all handlers")
|
||||
|
||||
@@ -208,6 +214,8 @@ func main() {
|
||||
agentGroupHandler,
|
||||
auditHandler,
|
||||
notificationHandler,
|
||||
statsHandler,
|
||||
metricsHandler,
|
||||
healthHandler,
|
||||
)
|
||||
logger.Info("registered all API handlers")
|
||||
@@ -221,9 +229,11 @@ func main() {
|
||||
AllowedOrigins: cfg.CORS.AllowedOrigins,
|
||||
})
|
||||
|
||||
structuredLogger := middleware.NewLogging(logger)
|
||||
|
||||
middlewareStack := []func(http.Handler) http.Handler{
|
||||
middleware.RequestID,
|
||||
middleware.Logging,
|
||||
structuredLogger,
|
||||
middleware.Recovery,
|
||||
corsMiddleware,
|
||||
authMiddleware,
|
||||
@@ -237,7 +247,7 @@ func main() {
|
||||
})
|
||||
middlewareStack = []func(http.Handler) http.Handler{
|
||||
middleware.RequestID,
|
||||
middleware.Logging,
|
||||
structuredLogger,
|
||||
middleware.Recovery,
|
||||
rateLimiter,
|
||||
corsMiddleware,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
)
|
||||
|
||||
// MetricsService defines the service interface for metrics collection.
|
||||
type MetricsService interface {
|
||||
GetDashboardSummary(ctx context.Context) (interface{}, error)
|
||||
}
|
||||
|
||||
// MetricsHandler handles HTTP requests for Prometheus-style metrics.
|
||||
// In V2, returns JSON metrics (not Prometheus format).
|
||||
// Prometheus format can be added in V3 when observability becomes a paid feature.
|
||||
type MetricsHandler struct {
|
||||
svc MetricsService
|
||||
serverStarted time.Time
|
||||
}
|
||||
|
||||
// NewMetricsHandler creates a new MetricsHandler with a service dependency.
|
||||
// serverStarted is used to calculate uptime_seconds.
|
||||
func NewMetricsHandler(svc MetricsService, serverStarted time.Time) MetricsHandler {
|
||||
return MetricsHandler{
|
||||
svc: svc,
|
||||
serverStarted: serverStarted,
|
||||
}
|
||||
}
|
||||
|
||||
// MetricsResponse represents the JSON metrics response for V2.
|
||||
type MetricsResponse struct {
|
||||
Gauge MetricsGauge `json:"gauge"`
|
||||
Counter MetricsCounter `json:"counter"`
|
||||
Uptime UptimeMetric `json:"uptime"`
|
||||
}
|
||||
|
||||
// MetricsGauge represents gauge metrics (point-in-time values).
|
||||
type MetricsGauge struct {
|
||||
CertificateTotal int64 `json:"certificate_total"`
|
||||
CertificateActive int64 `json:"certificate_active"`
|
||||
CertificateExpiringSoon int64 `json:"certificate_expiring_soon"` // Within 30d
|
||||
CertificateExpired int64 `json:"certificate_expired"`
|
||||
CertificateRevoked int64 `json:"certificate_revoked"`
|
||||
AgentTotal int64 `json:"agent_total"`
|
||||
AgentOnline int64 `json:"agent_online"`
|
||||
JobPending int64 `json:"job_pending"`
|
||||
}
|
||||
|
||||
// MetricsCounter represents counter metrics (cumulative values).
|
||||
type MetricsCounter struct {
|
||||
JobCompletedTotal int64 `json:"job_completed_total"`
|
||||
JobFailedTotal int64 `json:"job_failed_total"`
|
||||
}
|
||||
|
||||
// UptimeMetric represents server uptime information.
|
||||
type UptimeMetric struct {
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
ServerStarted time.Time `json:"server_started"`
|
||||
MeasuredAt time.Time `json:"measured_at"`
|
||||
}
|
||||
|
||||
// GetMetrics returns JSON metrics (aggregated from dashboard summary).
|
||||
// GET /api/v1/metrics
|
||||
func (h MetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
summary, err := h.svc.GetDashboardSummary(r.Context())
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to collect metrics", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract fields from summary via JSON round-trip (avoids cross-package type assertion)
|
||||
jsonBytes, err := json.Marshal(summary)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to marshal metrics data", requestID)
|
||||
return
|
||||
}
|
||||
var dashboardSummary DashboardSummary
|
||||
if err := json.Unmarshal(jsonBytes, &dashboardSummary); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Invalid metrics data", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Build metrics response
|
||||
metricsResp := MetricsResponse{
|
||||
Gauge: MetricsGauge{
|
||||
CertificateTotal: dashboardSummary.TotalCertificates,
|
||||
CertificateActive: dashboardSummary.TotalCertificates - dashboardSummary.ExpiringCertificates - dashboardSummary.ExpiredCertificates - dashboardSummary.RevokedCertificates,
|
||||
CertificateExpiringSoon: dashboardSummary.ExpiringCertificates,
|
||||
CertificateExpired: dashboardSummary.ExpiredCertificates,
|
||||
CertificateRevoked: dashboardSummary.RevokedCertificates,
|
||||
AgentTotal: dashboardSummary.TotalAgents,
|
||||
AgentOnline: dashboardSummary.ActiveAgents,
|
||||
JobPending: dashboardSummary.PendingJobs,
|
||||
},
|
||||
Counter: MetricsCounter{
|
||||
JobCompletedTotal: dashboardSummary.CompleteJobs,
|
||||
JobFailedTotal: dashboardSummary.FailedJobs,
|
||||
},
|
||||
Uptime: UptimeMetric{
|
||||
UptimeSeconds: int64(time.Since(h.serverStarted).Seconds()),
|
||||
ServerStarted: h.serverStarted,
|
||||
MeasuredAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, metricsResp)
|
||||
}
|
||||
|
||||
// DashboardSummary mirrors the service.DashboardSummary for JSON unmarshaling.
|
||||
// JSON tags must match the service-layer struct exactly.
|
||||
type DashboardSummary struct {
|
||||
TotalCertificates int64 `json:"total_certificates"`
|
||||
ExpiringCertificates int64 `json:"expiring_certificates"`
|
||||
ExpiredCertificates int64 `json:"expired_certificates"`
|
||||
RevokedCertificates int64 `json:"revoked_certificates"`
|
||||
ActiveAgents int64 `json:"active_agents"`
|
||||
OfflineAgents int64 `json:"offline_agents"`
|
||||
TotalAgents int64 `json:"total_agents"`
|
||||
PendingJobs int64 `json:"pending_jobs"`
|
||||
FailedJobs int64 `json:"failed_jobs"`
|
||||
CompleteJobs int64 `json:"complete_jobs"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
)
|
||||
|
||||
// StatsService defines the service interface for statistics operations.
|
||||
type StatsService interface {
|
||||
GetDashboardSummary(ctx context.Context) (interface{}, error)
|
||||
GetCertificatesByStatus(ctx context.Context) (interface{}, error)
|
||||
GetExpirationTimeline(ctx context.Context, days int) (interface{}, error)
|
||||
GetJobStats(ctx context.Context, days int) (interface{}, error)
|
||||
GetIssuanceRate(ctx context.Context, days int) (interface{}, error)
|
||||
}
|
||||
|
||||
// StatsHandler handles HTTP requests for statistics and observability endpoints.
|
||||
type StatsHandler struct {
|
||||
svc StatsService
|
||||
}
|
||||
|
||||
// NewStatsHandler creates a new StatsHandler with a service dependency.
|
||||
func NewStatsHandler(svc StatsService) StatsHandler {
|
||||
return StatsHandler{svc: svc}
|
||||
}
|
||||
|
||||
// GetDashboardSummary returns a high-level summary of system state.
|
||||
// GET /api/v1/stats/summary
|
||||
func (h StatsHandler) GetDashboardSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
summary, err := h.svc.GetDashboardSummary(r.Context())
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get dashboard summary", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// GetCertificatesByStatus returns certificate counts grouped by status.
|
||||
// GET /api/v1/stats/certificates-by-status
|
||||
func (h StatsHandler) GetCertificatesByStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
counts, err := h.svc.GetCertificatesByStatus(r.Context())
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get certificate status counts", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, counts)
|
||||
}
|
||||
|
||||
// GetExpirationTimeline returns certificates expiring over the next N days.
|
||||
// GET /api/v1/stats/expiration-timeline?days=30
|
||||
func (h StatsHandler) GetExpirationTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Parse query parameter
|
||||
days := 30
|
||||
if d := r.URL.Query().Get("days"); d != "" {
|
||||
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
|
||||
timeline, err := h.svc.GetExpirationTimeline(r.Context(), days)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get expiration timeline", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, timeline)
|
||||
}
|
||||
|
||||
// GetJobTrends returns job success/failure trends over the past N days.
|
||||
// GET /api/v1/stats/job-trends?days=30
|
||||
func (h StatsHandler) GetJobTrends(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Parse query parameter
|
||||
days := 30
|
||||
if d := r.URL.Query().Get("days"); d != "" {
|
||||
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
|
||||
trends, err := h.svc.GetJobStats(r.Context(), days)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get job trends", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, trends)
|
||||
}
|
||||
|
||||
// GetIssuanceRate returns the rate of new certificate issuance over the past N days.
|
||||
// GET /api/v1/stats/issuance-rate?days=30
|
||||
func (h StatsHandler) GetIssuanceRate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Parse query parameter
|
||||
days := 30
|
||||
if d := r.URL.Query().Get("days"); d != "" {
|
||||
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
|
||||
issuanceRate, err := h.svc.GetIssuanceRate(r.Context(), days)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get issuance rate", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, issuanceRate)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MockStatsService implements both StatsService and MetricsService.
|
||||
type MockStatsService struct {
|
||||
GetDashboardSummaryFn func(ctx context.Context) (interface{}, error)
|
||||
GetCertificatesByStatusFn func(ctx context.Context) (interface{}, error)
|
||||
GetExpirationTimelineFn func(ctx context.Context, days int) (interface{}, error)
|
||||
GetJobStatsFn func(ctx context.Context, days int) (interface{}, error)
|
||||
GetIssuanceRateFn func(ctx context.Context, days int) (interface{}, error)
|
||||
}
|
||||
|
||||
func (m *MockStatsService) GetDashboardSummary(ctx context.Context) (interface{}, error) {
|
||||
if m.GetDashboardSummaryFn != nil {
|
||||
return m.GetDashboardSummaryFn(ctx)
|
||||
}
|
||||
return map[string]int64{"total_certificates": 0}, nil
|
||||
}
|
||||
|
||||
func (m *MockStatsService) GetCertificatesByStatus(ctx context.Context) (interface{}, error) {
|
||||
if m.GetCertificatesByStatusFn != nil {
|
||||
return m.GetCertificatesByStatusFn(ctx)
|
||||
}
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
func (m *MockStatsService) GetExpirationTimeline(ctx context.Context, days int) (interface{}, error) {
|
||||
if m.GetExpirationTimelineFn != nil {
|
||||
return m.GetExpirationTimelineFn(ctx, days)
|
||||
}
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
func (m *MockStatsService) GetJobStats(ctx context.Context, days int) (interface{}, error) {
|
||||
if m.GetJobStatsFn != nil {
|
||||
return m.GetJobStatsFn(ctx, days)
|
||||
}
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
func (m *MockStatsService) GetIssuanceRate(ctx context.Context, days int) (interface{}, error) {
|
||||
if m.GetIssuanceRateFn != nil {
|
||||
return m.GetIssuanceRateFn(ctx, days)
|
||||
}
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
func TestGetDashboardSummary_Success(t *testing.T) {
|
||||
mock := &MockStatsService{}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/summary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetDashboardSummary(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDashboardSummary_MethodNotAllowed(t *testing.T) {
|
||||
mock := &MockStatsService{}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/stats/summary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetDashboardSummary(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDashboardSummary_ServiceError(t *testing.T) {
|
||||
mock := &MockStatsService{
|
||||
GetDashboardSummaryFn: func(ctx context.Context) (interface{}, error) {
|
||||
return nil, fmt.Errorf("db error")
|
||||
},
|
||||
}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/summary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetDashboardSummary(w, req)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCertificatesByStatus_Success(t *testing.T) {
|
||||
mock := &MockStatsService{}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/certificates-by-status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetCertificatesByStatus(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExpirationTimeline_Success(t *testing.T) {
|
||||
mock := &MockStatsService{}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/expiration-timeline?days=60", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetExpirationTimeline(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExpirationTimeline_DefaultDays(t *testing.T) {
|
||||
mock := &MockStatsService{
|
||||
GetExpirationTimelineFn: func(ctx context.Context, days int) (interface{}, error) {
|
||||
if days != 30 {
|
||||
t.Errorf("expected default 30 days, got %d", days)
|
||||
}
|
||||
return []interface{}{}, nil
|
||||
},
|
||||
}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/expiration-timeline", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetExpirationTimeline(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobTrends_Success(t *testing.T) {
|
||||
mock := &MockStatsService{}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/job-trends?days=14", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetJobTrends(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIssuanceRate_Success(t *testing.T) {
|
||||
mock := &MockStatsService{}
|
||||
h := NewStatsHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/issuance-rate?days=7", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetIssuanceRate(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetrics_Success(t *testing.T) {
|
||||
mock := &MockStatsService{
|
||||
GetDashboardSummaryFn: func(ctx context.Context) (interface{}, error) {
|
||||
return &DashboardSummary{
|
||||
TotalCertificates: 10,
|
||||
ExpiringCertificates: 2,
|
||||
ExpiredCertificates: 1,
|
||||
RevokedCertificates: 0,
|
||||
ActiveAgents: 3,
|
||||
TotalAgents: 5,
|
||||
PendingJobs: 1,
|
||||
FailedJobs: 0,
|
||||
CompleteJobs: 8,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
h := NewMetricsHandler(mock, time.Now())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetMetrics(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetrics_MethodNotAllowed(t *testing.T) {
|
||||
mock := &MockStatsService{}
|
||||
h := NewMetricsHandler(mock, time.Now())
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetMetrics(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetrics_ServiceError(t *testing.T) {
|
||||
mock := &MockStatsService{
|
||||
GetDashboardSummaryFn: func(ctx context.Context) (interface{}, error) {
|
||||
return nil, fmt.Errorf("db error")
|
||||
},
|
||||
}
|
||||
h := NewMetricsHandler(mock, time.Now())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.GetMetrics(w, req)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -30,6 +31,7 @@ func RequestID(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// Logging middleware logs request details including method, path, status, and duration.
|
||||
// Deprecated: Use NewLogging for structured logging with slog.
|
||||
func Logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
@@ -45,6 +47,33 @@ func Logging(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// NewLogging creates a structured logging middleware using slog.
|
||||
// Logs request_id, method, path, status, duration_ms, and remote_addr.
|
||||
func NewLogging(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Wrap response writer to capture status code
|
||||
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
requestID := getRequestID(r.Context())
|
||||
|
||||
logger.InfoContext(r.Context(), "request completed",
|
||||
"request_id", requestID,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", wrapped.statusCode,
|
||||
"duration_ms", duration.Milliseconds(),
|
||||
"remote_addr", r.RemoteAddr,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery middleware recovers from panics and returns a 500 error.
|
||||
func Recovery(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -57,6 +57,8 @@ func (r *Router) RegisterHandlers(
|
||||
agentGroups handler.AgentGroupHandler,
|
||||
audit handler.AuditHandler,
|
||||
notifications handler.NotificationHandler,
|
||||
stats handler.StatsHandler,
|
||||
metrics handler.MetricsHandler,
|
||||
health handler.HealthHandler,
|
||||
) {
|
||||
// Health endpoints (no auth middleware — must always be accessible)
|
||||
@@ -174,6 +176,16 @@ func (r *Router) RegisterHandlers(
|
||||
r.Register("GET /api/v1/notifications", http.HandlerFunc(notifications.ListNotifications))
|
||||
r.Register("GET /api/v1/notifications/{id}", http.HandlerFunc(notifications.GetNotification))
|
||||
r.Register("POST /api/v1/notifications/{id}/read", http.HandlerFunc(notifications.MarkAsRead))
|
||||
|
||||
// Stats routes: /api/v1/stats
|
||||
r.Register("GET /api/v1/stats/summary", http.HandlerFunc(stats.GetDashboardSummary))
|
||||
r.Register("GET /api/v1/stats/certificates-by-status", http.HandlerFunc(stats.GetCertificatesByStatus))
|
||||
r.Register("GET /api/v1/stats/expiration-timeline", http.HandlerFunc(stats.GetExpirationTimeline))
|
||||
r.Register("GET /api/v1/stats/job-trends", http.HandlerFunc(stats.GetJobTrends))
|
||||
r.Register("GET /api/v1/stats/issuance-rate", http.HandlerFunc(stats.GetIssuanceRate))
|
||||
|
||||
// Metrics routes: /api/v1/metrics
|
||||
r.Register("GET /api/v1/metrics", http.HandlerFunc(metrics.GetMetrics))
|
||||
}
|
||||
|
||||
// GetMux returns the underlying http.ServeMux for direct access if needed.
|
||||
|
||||
@@ -75,6 +75,8 @@ func TestCertificateLifecycle(t *testing.T) {
|
||||
agentGroupHandler := handler.NewAgentGroupHandler(&mockAgentGroupService{})
|
||||
auditHandler := handler.NewAuditHandler(auditService)
|
||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||
statsHandler := handler.NewStatsHandler(&mockStatsService{})
|
||||
metricsHandler := handler.NewMetricsHandler(&mockStatsService{}, time.Now())
|
||||
healthHandler := handler.NewHealthHandler("none")
|
||||
|
||||
// Create router and register handlers
|
||||
@@ -92,6 +94,8 @@ func TestCertificateLifecycle(t *testing.T) {
|
||||
agentGroupHandler,
|
||||
auditHandler,
|
||||
notificationHandler,
|
||||
statsHandler,
|
||||
metricsHandler,
|
||||
healthHandler,
|
||||
)
|
||||
|
||||
@@ -1109,3 +1113,26 @@ func (m *mockRevocationRepository) MarkIssuerNotified(ctx context.Context, id st
|
||||
}
|
||||
return fmt.Errorf("revocation not found")
|
||||
}
|
||||
|
||||
// mockStatsService implements both handler.StatsService and handler.MetricsService for integration tests.
|
||||
type mockStatsService struct{}
|
||||
|
||||
func (m *mockStatsService) GetDashboardSummary(ctx context.Context) (interface{}, error) {
|
||||
return &handler.DashboardSummary{}, nil
|
||||
}
|
||||
|
||||
func (m *mockStatsService) GetCertificatesByStatus(ctx context.Context) (interface{}, error) {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
|
||||
func (m *mockStatsService) GetExpirationTimeline(ctx context.Context, days int) (interface{}, error) {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
func (m *mockStatsService) GetJobStats(ctx context.Context, days int) (interface{}, error) {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
func (m *mockStatsService) GetIssuanceRate(ctx context.Context, days int) (interface{}, error) {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
||||
agentGroupHandler := handler.NewAgentGroupHandler(&mockAgentGroupService{})
|
||||
auditHandler := handler.NewAuditHandler(auditService)
|
||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||
statsHandler := handler.NewStatsHandler(&mockStatsService{})
|
||||
metricsHandler := handler.NewMetricsHandler(&mockStatsService{}, time.Now())
|
||||
healthHandler := handler.NewHealthHandler("none")
|
||||
|
||||
r := router.New()
|
||||
@@ -85,6 +87,8 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
||||
agentGroupHandler,
|
||||
auditHandler,
|
||||
notificationHandler,
|
||||
statsHandler,
|
||||
metricsHandler,
|
||||
healthHandler,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// StatsService provides statistics and observability data for dashboards and monitoring.
|
||||
type StatsService struct {
|
||||
certRepo repository.CertificateRepository
|
||||
jobRepo repository.JobRepository
|
||||
agentRepo repository.AgentRepository
|
||||
}
|
||||
|
||||
// NewStatsService creates a new stats service.
|
||||
func NewStatsService(
|
||||
certRepo repository.CertificateRepository,
|
||||
jobRepo repository.JobRepository,
|
||||
agentRepo repository.AgentRepository,
|
||||
) *StatsService {
|
||||
return &StatsService{
|
||||
certRepo: certRepo,
|
||||
jobRepo: jobRepo,
|
||||
agentRepo: agentRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// DashboardSummary represents a high-level summary of system state.
|
||||
type DashboardSummary struct {
|
||||
TotalCertificates int64 `json:"total_certificates"`
|
||||
ExpiringCertificates int64 `json:"expiring_certificates"`
|
||||
ExpiredCertificates int64 `json:"expired_certificates"`
|
||||
RevokedCertificates int64 `json:"revoked_certificates"`
|
||||
ActiveAgents int64 `json:"active_agents"`
|
||||
OfflineAgents int64 `json:"offline_agents"`
|
||||
TotalAgents int64 `json:"total_agents"`
|
||||
PendingJobs int64 `json:"pending_jobs"`
|
||||
FailedJobs int64 `json:"failed_jobs"`
|
||||
CompleteJobs int64 `json:"complete_jobs"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
}
|
||||
|
||||
// GetDashboardSummary returns a summary of key metrics.
|
||||
func (s *StatsService) GetDashboardSummary(ctx context.Context) (interface{}, error) {
|
||||
summary := &DashboardSummary{
|
||||
CompletedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Get all certificates
|
||||
allCerts, total, err := s.certRepo.List(ctx, &repository.CertificateFilter{Page: 1, PerPage: 10000})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list certificates: %w", err)
|
||||
}
|
||||
summary.TotalCertificates = int64(total)
|
||||
|
||||
now := time.Now()
|
||||
thirtyDaysFromNow := now.AddDate(0, 0, 30)
|
||||
|
||||
for _, cert := range allCerts {
|
||||
if cert.Status == domain.CertificateStatusRevoked {
|
||||
summary.RevokedCertificates++
|
||||
} else if cert.Status == domain.CertificateStatusExpired || (!cert.ExpiresAt.IsZero() && cert.ExpiresAt.Before(now)) {
|
||||
summary.ExpiredCertificates++
|
||||
} else if !cert.ExpiresAt.IsZero() && cert.ExpiresAt.Before(thirtyDaysFromNow) && cert.ExpiresAt.After(now) {
|
||||
summary.ExpiringCertificates++
|
||||
}
|
||||
}
|
||||
|
||||
// Get all agents
|
||||
allAgents, err := s.agentRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list agents: %w", err)
|
||||
}
|
||||
summary.TotalAgents = int64(len(allAgents))
|
||||
|
||||
// Count active agents (heartbeat within last 5 minutes)
|
||||
fiveMinutesAgo := now.Add(-5 * time.Minute)
|
||||
for _, agent := range allAgents {
|
||||
if agent.LastHeartbeatAt != nil && agent.LastHeartbeatAt.After(fiveMinutesAgo) {
|
||||
summary.ActiveAgents++
|
||||
} else {
|
||||
summary.OfflineAgents++
|
||||
}
|
||||
}
|
||||
|
||||
// Get all jobs
|
||||
allJobs, err := s.jobRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list jobs: %w", err)
|
||||
}
|
||||
|
||||
for _, job := range allJobs {
|
||||
switch job.Status {
|
||||
case domain.JobStatusPending, domain.JobStatusAwaitingCSR, domain.JobStatusAwaitingApproval, domain.JobStatusRunning:
|
||||
summary.PendingJobs++
|
||||
case domain.JobStatusFailed:
|
||||
summary.FailedJobs++
|
||||
case domain.JobStatusCompleted:
|
||||
summary.CompleteJobs++
|
||||
}
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// CertificateStatusCount represents count of certificates by status.
|
||||
type CertificateStatusCount struct {
|
||||
Status string `json:"status"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// GetCertificatesByStatus returns certificate counts grouped by status.
|
||||
func (s *StatsService) GetCertificatesByStatus(ctx context.Context) (interface{}, error) {
|
||||
allCerts, _, err := s.certRepo.List(ctx, &repository.CertificateFilter{Page: 1, PerPage: 10000})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list certificates: %w", err)
|
||||
}
|
||||
|
||||
counts := make(map[string]int64)
|
||||
now := time.Now()
|
||||
thirtyDaysFromNow := now.AddDate(0, 0, 30)
|
||||
|
||||
for _, cert := range allCerts {
|
||||
status := string(cert.Status)
|
||||
if status == "" || status == "Active" {
|
||||
if !cert.ExpiresAt.IsZero() {
|
||||
if cert.ExpiresAt.Before(now) {
|
||||
status = "Expired"
|
||||
} else if cert.ExpiresAt.Before(thirtyDaysFromNow) {
|
||||
status = "Expiring"
|
||||
} else {
|
||||
status = "Active"
|
||||
}
|
||||
} else {
|
||||
status = "Active"
|
||||
}
|
||||
}
|
||||
counts[status]++
|
||||
}
|
||||
|
||||
result := make([]CertificateStatusCount, 0, len(counts))
|
||||
for status, count := range counts {
|
||||
result = append(result, CertificateStatusCount{Status: status, Count: count})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExpirationBucket represents certificates expiring on a specific date.
|
||||
type ExpirationBucket struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// GetExpirationTimeline returns certificates expiring over the next N days, bucketed by day.
|
||||
func (s *StatsService) GetExpirationTimeline(ctx context.Context, days int) (interface{}, error) {
|
||||
if days <= 0 {
|
||||
days = 30
|
||||
}
|
||||
|
||||
allCerts, _, err := s.certRepo.List(ctx, &repository.CertificateFilter{Page: 1, PerPage: 10000})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list certificates: %w", err)
|
||||
}
|
||||
|
||||
buckets := make(map[string]int64)
|
||||
now := time.Now()
|
||||
endDate := now.AddDate(0, 0, days)
|
||||
|
||||
for _, cert := range allCerts {
|
||||
if cert.ExpiresAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
if cert.ExpiresAt.After(now) && cert.ExpiresAt.Before(endDate) {
|
||||
dateStr := cert.ExpiresAt.Format("2006-01-02")
|
||||
buckets[dateStr]++
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]ExpirationBucket, 0, days)
|
||||
for i := 0; i < days; i++ {
|
||||
date := now.AddDate(0, 0, i)
|
||||
dateStr := date.Format("2006-01-02")
|
||||
if count, exists := buckets[dateStr]; exists {
|
||||
result = append(result, ExpirationBucket{Date: dateStr, Count: count})
|
||||
} else {
|
||||
result = append(result, ExpirationBucket{Date: dateStr, Count: 0})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// JobTrendDataPoint represents success/failure counts for a specific day.
|
||||
type JobTrendDataPoint struct {
|
||||
Date string `json:"date"`
|
||||
CompletedCount int64 `json:"completed_count"`
|
||||
FailedCount int64 `json:"failed_count"`
|
||||
SuccessRate float64 `json:"success_rate"`
|
||||
}
|
||||
|
||||
// GetJobStats returns job success/failure trends over the past N days.
|
||||
func (s *StatsService) GetJobStats(ctx context.Context, days int) (interface{}, error) {
|
||||
if days <= 0 {
|
||||
days = 30
|
||||
}
|
||||
|
||||
allJobs, err := s.jobRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list jobs: %w", err)
|
||||
}
|
||||
|
||||
type dayData struct {
|
||||
completed int64
|
||||
failed int64
|
||||
}
|
||||
buckets := make(map[string]*dayData)
|
||||
now := time.Now()
|
||||
|
||||
for _, job := range allJobs {
|
||||
if job.Status != domain.JobStatusCompleted && job.Status != domain.JobStatusFailed {
|
||||
continue
|
||||
}
|
||||
if job.CompletedAt == nil {
|
||||
continue
|
||||
}
|
||||
if job.CompletedAt.Before(now.AddDate(0, 0, -days)) {
|
||||
continue
|
||||
}
|
||||
|
||||
dateStr := job.CompletedAt.Format("2006-01-02")
|
||||
if _, exists := buckets[dateStr]; !exists {
|
||||
buckets[dateStr] = &dayData{}
|
||||
}
|
||||
|
||||
if job.Status == domain.JobStatusCompleted {
|
||||
buckets[dateStr].completed++
|
||||
} else {
|
||||
buckets[dateStr].failed++
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]JobTrendDataPoint, 0, days)
|
||||
for i := 0; i < days; i++ {
|
||||
date := now.AddDate(0, 0, -days+i+1)
|
||||
dateStr := date.Format("2006-01-02")
|
||||
point := JobTrendDataPoint{Date: dateStr}
|
||||
|
||||
if data, exists := buckets[dateStr]; exists {
|
||||
point.CompletedCount = data.completed
|
||||
point.FailedCount = data.failed
|
||||
total := data.completed + data.failed
|
||||
if total > 0 {
|
||||
point.SuccessRate = (float64(data.completed) / float64(total)) * 100
|
||||
}
|
||||
}
|
||||
result = append(result, point)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IssuanceRateDataPoint represents new certificates issued on a specific day.
|
||||
type IssuanceRateDataPoint struct {
|
||||
Date string `json:"date"`
|
||||
IssuedCount int64 `json:"issued_count"`
|
||||
}
|
||||
|
||||
// GetIssuanceRate returns the rate of new certificate issuance over the past N days.
|
||||
func (s *StatsService) GetIssuanceRate(ctx context.Context, days int) (interface{}, error) {
|
||||
if days <= 0 {
|
||||
days = 30
|
||||
}
|
||||
|
||||
allCerts, _, err := s.certRepo.List(ctx, &repository.CertificateFilter{Page: 1, PerPage: 10000})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list certificates: %w", err)
|
||||
}
|
||||
|
||||
buckets := make(map[string]int64)
|
||||
now := time.Now()
|
||||
|
||||
for _, cert := range allCerts {
|
||||
if cert.CreatedAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
if cert.CreatedAt.Before(now.AddDate(0, 0, -days)) {
|
||||
continue
|
||||
}
|
||||
|
||||
dateStr := cert.CreatedAt.Format("2006-01-02")
|
||||
buckets[dateStr]++
|
||||
}
|
||||
|
||||
result := make([]IssuanceRateDataPoint, 0, days)
|
||||
for i := 0; i < days; i++ {
|
||||
date := now.AddDate(0, 0, -days+i+1)
|
||||
dateStr := date.Format("2006-01-02")
|
||||
point := IssuanceRateDataPoint{Date: dateStr}
|
||||
|
||||
if count, exists := buckets[dateStr]; exists {
|
||||
point.IssuedCount = count
|
||||
}
|
||||
result = append(result, point)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
func newTestStatsService() (*StatsService, *mockCertRepo, *mockJobRepo, *mockAgentRepo) {
|
||||
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate)}
|
||||
jobRepo := newMockJobRepository()
|
||||
agentRepo := newMockAgentRepository()
|
||||
svc := NewStatsService(certRepo, jobRepo, agentRepo)
|
||||
return svc, certRepo, jobRepo, agentRepo
|
||||
}
|
||||
|
||||
func TestGetDashboardSummary_Empty(t *testing.T) {
|
||||
svc, _, _, _ := newTestStatsService()
|
||||
result, err := svc.GetDashboardSummary(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
summary, ok := result.(*DashboardSummary)
|
||||
if !ok {
|
||||
t.Fatal("expected *DashboardSummary")
|
||||
}
|
||||
if summary.TotalCertificates != 0 {
|
||||
t.Errorf("expected 0 total certs, got %d", summary.TotalCertificates)
|
||||
}
|
||||
if summary.TotalAgents != 0 {
|
||||
t.Errorf("expected 0 total agents, got %d", summary.TotalAgents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDashboardSummary_WithData(t *testing.T) {
|
||||
svc, certRepo, jobRepo, agentRepo := newTestStatsService()
|
||||
|
||||
now := time.Now()
|
||||
tenDays := now.AddDate(0, 0, 10)
|
||||
pastDate := now.AddDate(0, 0, -5)
|
||||
futureDate := now.AddDate(0, 0, 60)
|
||||
|
||||
// Add certificates
|
||||
certRepo.Certs["mc-active"] = &domain.ManagedCertificate{ID: "mc-active", Status: domain.CertificateStatusActive, ExpiresAt: futureDate}
|
||||
certRepo.Certs["mc-expiring"] = &domain.ManagedCertificate{ID: "mc-expiring", Status: domain.CertificateStatusActive, ExpiresAt: tenDays}
|
||||
certRepo.Certs["mc-expired"] = &domain.ManagedCertificate{ID: "mc-expired", Status: domain.CertificateStatusExpired, ExpiresAt: pastDate}
|
||||
certRepo.Certs["mc-revoked"] = &domain.ManagedCertificate{ID: "mc-revoked", Status: domain.CertificateStatusRevoked}
|
||||
|
||||
// Add agents
|
||||
recentHeartbeat := now.Add(-2 * time.Minute)
|
||||
oldHeartbeat := now.Add(-10 * time.Minute)
|
||||
agentRepo.AddAgent(&domain.Agent{ID: "a-1", LastHeartbeatAt: &recentHeartbeat})
|
||||
agentRepo.AddAgent(&domain.Agent{ID: "a-2", LastHeartbeatAt: &oldHeartbeat})
|
||||
agentRepo.AddAgent(&domain.Agent{ID: "a-3"}) // no heartbeat
|
||||
|
||||
// Add jobs
|
||||
jobRepo.AddJob(&domain.Job{ID: "j-1", Status: domain.JobStatusPending})
|
||||
jobRepo.AddJob(&domain.Job{ID: "j-2", Status: domain.JobStatusCompleted})
|
||||
jobRepo.AddJob(&domain.Job{ID: "j-3", Status: domain.JobStatusFailed})
|
||||
|
||||
result, err := svc.GetDashboardSummary(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
summary := result.(*DashboardSummary)
|
||||
|
||||
if summary.TotalCertificates != 4 {
|
||||
t.Errorf("expected 4 total certs, got %d", summary.TotalCertificates)
|
||||
}
|
||||
if summary.ExpiringCertificates != 1 {
|
||||
t.Errorf("expected 1 expiring, got %d", summary.ExpiringCertificates)
|
||||
}
|
||||
if summary.ExpiredCertificates != 1 {
|
||||
t.Errorf("expected 1 expired, got %d", summary.ExpiredCertificates)
|
||||
}
|
||||
if summary.RevokedCertificates != 1 {
|
||||
t.Errorf("expected 1 revoked, got %d", summary.RevokedCertificates)
|
||||
}
|
||||
if summary.TotalAgents != 3 {
|
||||
t.Errorf("expected 3 total agents, got %d", summary.TotalAgents)
|
||||
}
|
||||
if summary.ActiveAgents != 1 {
|
||||
t.Errorf("expected 1 active agent, got %d", summary.ActiveAgents)
|
||||
}
|
||||
if summary.OfflineAgents != 2 {
|
||||
t.Errorf("expected 2 offline agents, got %d", summary.OfflineAgents)
|
||||
}
|
||||
if summary.PendingJobs != 1 {
|
||||
t.Errorf("expected 1 pending job, got %d", summary.PendingJobs)
|
||||
}
|
||||
if summary.CompleteJobs != 1 {
|
||||
t.Errorf("expected 1 complete job, got %d", summary.CompleteJobs)
|
||||
}
|
||||
if summary.FailedJobs != 1 {
|
||||
t.Errorf("expected 1 failed job, got %d", summary.FailedJobs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDashboardSummary_CertRepoError(t *testing.T) {
|
||||
svc, certRepo, _, _ := newTestStatsService()
|
||||
certRepo.ListErr = errNotFound
|
||||
_, err := svc.GetDashboardSummary(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCertificatesByStatus_Empty(t *testing.T) {
|
||||
svc, _, _, _ := newTestStatsService()
|
||||
result, err := svc.GetCertificatesByStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
counts := result.([]CertificateStatusCount)
|
||||
if len(counts) != 0 {
|
||||
t.Errorf("expected 0 status counts, got %d", len(counts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCertificatesByStatus_WithData(t *testing.T) {
|
||||
svc, certRepo, _, _ := newTestStatsService()
|
||||
future := time.Now().AddDate(0, 0, 60)
|
||||
certRepo.Certs["mc-1"] = &domain.ManagedCertificate{ID: "mc-1", Status: domain.CertificateStatusActive, ExpiresAt: future}
|
||||
certRepo.Certs["mc-2"] = &domain.ManagedCertificate{ID: "mc-2", Status: domain.CertificateStatusRevoked}
|
||||
|
||||
result, err := svc.GetCertificatesByStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
counts := result.([]CertificateStatusCount)
|
||||
if len(counts) < 2 {
|
||||
t.Errorf("expected at least 2 status counts, got %d", len(counts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExpirationTimeline_Default(t *testing.T) {
|
||||
svc, certRepo, _, _ := newTestStatsService()
|
||||
expiresIn10d := time.Now().AddDate(0, 0, 10)
|
||||
certRepo.Certs["mc-1"] = &domain.ManagedCertificate{ID: "mc-1", ExpiresAt: expiresIn10d}
|
||||
|
||||
result, err := svc.GetExpirationTimeline(context.Background(), 30)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
buckets := result.([]ExpirationBucket)
|
||||
if len(buckets) != 30 {
|
||||
t.Errorf("expected 30 buckets, got %d", len(buckets))
|
||||
}
|
||||
// At least one bucket should have count > 0
|
||||
hasNonZero := false
|
||||
for _, b := range buckets {
|
||||
if b.Count > 0 {
|
||||
hasNonZero = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasNonZero {
|
||||
t.Error("expected at least one non-zero bucket")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExpirationTimeline_InvalidDays(t *testing.T) {
|
||||
svc, _, _, _ := newTestStatsService()
|
||||
result, err := svc.GetExpirationTimeline(context.Background(), -1)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
buckets := result.([]ExpirationBucket)
|
||||
if len(buckets) != 30 {
|
||||
t.Errorf("expected default 30 buckets for invalid days, got %d", len(buckets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobStats_Empty(t *testing.T) {
|
||||
svc, _, _, _ := newTestStatsService()
|
||||
result, err := svc.GetJobStats(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
points := result.([]JobTrendDataPoint)
|
||||
if len(points) != 7 {
|
||||
t.Errorf("expected 7 data points, got %d", len(points))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobStats_WithData(t *testing.T) {
|
||||
svc, _, jobRepo, _ := newTestStatsService()
|
||||
completedAt := time.Now().Add(-1 * time.Hour)
|
||||
jobRepo.AddJob(&domain.Job{ID: "j-1", Status: domain.JobStatusCompleted, CompletedAt: &completedAt})
|
||||
jobRepo.AddJob(&domain.Job{ID: "j-2", Status: domain.JobStatusFailed, CompletedAt: &completedAt})
|
||||
|
||||
result, err := svc.GetJobStats(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
points := result.([]JobTrendDataPoint)
|
||||
|
||||
// The last data point should have today's data
|
||||
todayPoint := points[len(points)-1]
|
||||
if todayPoint.CompletedCount != 1 {
|
||||
t.Errorf("expected 1 completed today, got %d", todayPoint.CompletedCount)
|
||||
}
|
||||
if todayPoint.FailedCount != 1 {
|
||||
t.Errorf("expected 1 failed today, got %d", todayPoint.FailedCount)
|
||||
}
|
||||
if todayPoint.SuccessRate != 50.0 {
|
||||
t.Errorf("expected 50%% success rate, got %.1f%%", todayPoint.SuccessRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIssuanceRate_Empty(t *testing.T) {
|
||||
svc, _, _, _ := newTestStatsService()
|
||||
result, err := svc.GetIssuanceRate(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
points := result.([]IssuanceRateDataPoint)
|
||||
if len(points) != 7 {
|
||||
t.Errorf("expected 7 data points, got %d", len(points))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIssuanceRate_WithData(t *testing.T) {
|
||||
svc, certRepo, _, _ := newTestStatsService()
|
||||
certRepo.Certs["mc-1"] = &domain.ManagedCertificate{ID: "mc-1", CreatedAt: time.Now()}
|
||||
certRepo.Certs["mc-2"] = &domain.ManagedCertificate{ID: "mc-2", CreatedAt: time.Now()}
|
||||
|
||||
result, err := svc.GetIssuanceRate(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
points := result.([]IssuanceRateDataPoint)
|
||||
|
||||
todayPoint := points[len(points)-1]
|
||||
if todayPoint.IssuedCount != 2 {
|
||||
t.Errorf("expected 2 issued today, got %d", todayPoint.IssuedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIssuanceRate_RepoError(t *testing.T) {
|
||||
svc, certRepo, _, _ := newTestStatsService()
|
||||
certRepo.ListErr = errNotFound
|
||||
_, err := svc.GetIssuanceRate(context.Background(), 7)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
Generated
+397
-5
@@ -11,7 +11,8 @@
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.3"
|
||||
"react-router-dom": "^6.30.3",
|
||||
"recharts": "^3.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -445,6 +446,42 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
@@ -720,7 +757,12 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
@@ -855,6 +897,69 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -873,7 +978,7 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -889,6 +994,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
||||
@@ -1300,6 +1411,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
@@ -1355,9 +1475,130 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
@@ -1379,6 +1620,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@@ -1448,6 +1695,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -1468,6 +1725,12 @@
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -1609,6 +1872,16 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||
@@ -1619,6 +1892,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -2495,10 +2777,32 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
@@ -2554,6 +2858,36 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -2568,6 +2902,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -2578,6 +2927,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -2845,6 +3200,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -3049,6 +3410,15 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -3056,6 +3426,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.3"
|
||||
"react-router-dom": "^6.30.3",
|
||||
"recharts": "^3.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
||||
@@ -55,6 +55,12 @@ import {
|
||||
deleteAgentGroup,
|
||||
getAgentGroupMembers,
|
||||
getHealth,
|
||||
getDashboardSummary,
|
||||
getCertificatesByStatus,
|
||||
getExpirationTimeline,
|
||||
getJobTrends,
|
||||
getIssuanceRate,
|
||||
getMetrics,
|
||||
} from './client';
|
||||
|
||||
// Mock global fetch
|
||||
@@ -617,6 +623,59 @@ describe('API Client', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stats ─────────────────────────────────────────
|
||||
|
||||
describe('Stats', () => {
|
||||
it('getDashboardSummary calls /api/v1/stats/summary', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ total_certificates: 10 }));
|
||||
const result = await getDashboardSummary();
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/stats/summary');
|
||||
expect(result.total_certificates).toBe(10);
|
||||
});
|
||||
|
||||
it('getCertificatesByStatus calls /api/v1/stats/certificates-by-status', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse([{ status: 'Active', count: 5 }]));
|
||||
const result = await getCertificatesByStatus();
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/stats/certificates-by-status');
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('getExpirationTimeline calls with days parameter', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse([]));
|
||||
await getExpirationTimeline(60);
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/stats/expiration-timeline?days=60');
|
||||
});
|
||||
|
||||
it('getExpirationTimeline uses default 30 days', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse([]));
|
||||
await getExpirationTimeline();
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/stats/expiration-timeline?days=30');
|
||||
});
|
||||
|
||||
it('getJobTrends calls with days parameter', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse([]));
|
||||
await getJobTrends(14);
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/stats/job-trends?days=14');
|
||||
});
|
||||
|
||||
it('getIssuanceRate calls with days parameter', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse([]));
|
||||
await getIssuanceRate(7);
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/stats/issuance-rate?days=7');
|
||||
});
|
||||
|
||||
it('getMetrics calls /api/v1/metrics', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({
|
||||
gauge: { certificate_total: 10 },
|
||||
counter: { job_completed_total: 5 },
|
||||
uptime: { uptime_seconds: 3600 },
|
||||
}));
|
||||
const result = await getMetrics();
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/metrics');
|
||||
expect(result.gauge.certificate_total).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Health ─────────────────────────────────────────
|
||||
|
||||
describe('Health', () => {
|
||||
|
||||
+20
-1
@@ -1,4 +1,4 @@
|
||||
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse } from './types';
|
||||
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse } from './types';
|
||||
|
||||
const BASE = '/api/v1';
|
||||
|
||||
@@ -257,5 +257,24 @@ export const approveRenewal = (jobId: string) =>
|
||||
export const rejectRenewal = (jobId: string, reason: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/jobs/${jobId}/reject`, { method: 'POST', body: JSON.stringify({ reason }) });
|
||||
|
||||
// Stats
|
||||
export const getDashboardSummary = () =>
|
||||
fetchJSON<DashboardSummary>(`${BASE}/stats/summary`);
|
||||
|
||||
export const getCertificatesByStatus = () =>
|
||||
fetchJSON<CertificateStatusCount[]>(`${BASE}/stats/certificates-by-status`);
|
||||
|
||||
export const getExpirationTimeline = (days = 30) =>
|
||||
fetchJSON<ExpirationBucket[]>(`${BASE}/stats/expiration-timeline?days=${days}`);
|
||||
|
||||
export const getJobTrends = (days = 30) =>
|
||||
fetchJSON<JobTrendDataPoint[]>(`${BASE}/stats/job-trends?days=${days}`);
|
||||
|
||||
export const getIssuanceRate = (days = 30) =>
|
||||
fetchJSON<IssuanceRateDataPoint[]>(`${BASE}/stats/issuance-rate?days=${days}`);
|
||||
|
||||
export const getMetrics = () =>
|
||||
fetchJSON<MetricsResponse>(`${BASE}/metrics`);
|
||||
|
||||
// Health
|
||||
export const getHealth = () => fetchJSON<{ status: string }>('/health');
|
||||
|
||||
@@ -206,3 +206,62 @@ export interface PaginatedResponse<T> {
|
||||
page: number;
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
// Stats types
|
||||
export interface DashboardSummary {
|
||||
total_certificates: number;
|
||||
expiring_certificates: number;
|
||||
expired_certificates: number;
|
||||
revoked_certificates: number;
|
||||
active_agents: number;
|
||||
offline_agents: number;
|
||||
total_agents: number;
|
||||
pending_jobs: number;
|
||||
failed_jobs: number;
|
||||
complete_jobs: number;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
export interface CertificateStatusCount {
|
||||
status: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ExpirationBucket {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface JobTrendDataPoint {
|
||||
date: string;
|
||||
completed_count: number;
|
||||
failed_count: number;
|
||||
success_rate: number;
|
||||
}
|
||||
|
||||
export interface IssuanceRateDataPoint {
|
||||
date: string;
|
||||
issued_count: number;
|
||||
}
|
||||
|
||||
export interface MetricsResponse {
|
||||
gauge: {
|
||||
certificate_total: number;
|
||||
certificate_active: number;
|
||||
certificate_expiring_soon: number;
|
||||
certificate_expired: number;
|
||||
certificate_revoked: number;
|
||||
agent_total: number;
|
||||
agent_online: number;
|
||||
job_pending: number;
|
||||
};
|
||||
counter: {
|
||||
job_completed_total: number;
|
||||
job_failed_total: number;
|
||||
};
|
||||
uptime: {
|
||||
uptime_seconds: number;
|
||||
server_started: string;
|
||||
measured_at: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const nav = [
|
||||
{ to: '/', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4' },
|
||||
{ to: '/certificates', label: 'Certificates', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' },
|
||||
{ to: '/agents', label: 'Agents', icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2' },
|
||||
{ to: '/fleet', label: 'Fleet Overview', icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
{ to: '/jobs', label: 'Jobs', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' },
|
||||
{ to: '/notifications', label: 'Notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
|
||||
{ to: '/policies', label: 'Policies', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' },
|
||||
|
||||
@@ -22,6 +22,7 @@ import TeamsPage from './pages/TeamsPage';
|
||||
import AgentGroupsPage from './pages/AgentGroupsPage';
|
||||
import AuditPage from './pages/AuditPage';
|
||||
import ShortLivedPage from './pages/ShortLivedPage';
|
||||
import AgentFleetPage from './pages/AgentFleetPage';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -48,6 +49,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="certificates/:id" element={<CertificateDetailPage />} />
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route path="agents/:id" element={<AgentDetailPage />} />
|
||||
<Route path="fleet" element={<AgentFleetPage />} />
|
||||
<Route path="jobs" element={<JobsPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="policies" element={<PoliciesPage />} />
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
|
||||
import { getAgents } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import type { Agent } from '../api/types';
|
||||
|
||||
const OS_COLORS: Record<string, string> = {
|
||||
linux: '#f97316',
|
||||
darwin: '#3b82f6',
|
||||
windows: '#8b5cf6',
|
||||
unknown: '#64748b',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
Online: '#10b981',
|
||||
Offline: '#ef4444',
|
||||
Unknown: '#64748b',
|
||||
};
|
||||
|
||||
interface GroupedAgents {
|
||||
os: string;
|
||||
arch: string;
|
||||
agents: Agent[];
|
||||
online: number;
|
||||
offline: number;
|
||||
}
|
||||
|
||||
function groupAgents(agents: Agent[]): GroupedAgents[] {
|
||||
const groups = new Map<string, GroupedAgents>();
|
||||
|
||||
for (const agent of agents) {
|
||||
const os = agent.os || 'unknown';
|
||||
const arch = agent.architecture || 'unknown';
|
||||
const key = `${os}/${arch}`;
|
||||
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, { os, arch, agents: [], online: 0, offline: 0 });
|
||||
}
|
||||
const group = groups.get(key)!;
|
||||
group.agents.push(agent);
|
||||
if (agent.status === 'Online') {
|
||||
group.online++;
|
||||
} else {
|
||||
group.offline++;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.values()).sort((a, b) => b.agents.length - a.agents.length);
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-xs shadow-lg">
|
||||
{payload.map((entry: any, i: number) => (
|
||||
<p key={i} style={{ color: entry.payload?.fill || entry.color }}>
|
||||
{entry.name}: {entry.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function AgentFleetPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: agentsResponse, isLoading } = useQuery({
|
||||
queryKey: ['agents'],
|
||||
queryFn: () => getAgents(),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const agents = agentsResponse?.data || [];
|
||||
const groups = groupAgents(agents);
|
||||
|
||||
// Summary stats
|
||||
const totalAgents = agents.length;
|
||||
const onlineAgents = agents.filter(a => a.status === 'Online').length;
|
||||
const offlineAgents = totalAgents - onlineAgents;
|
||||
|
||||
// OS distribution for pie chart
|
||||
const osDistribution = agents.reduce<Record<string, number>>((acc, a) => {
|
||||
const os = a.os || 'unknown';
|
||||
acc[os] = (acc[os] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const osPieData = Object.entries(osDistribution).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
fill: OS_COLORS[name.toLowerCase()] || '#64748b',
|
||||
}));
|
||||
|
||||
// Status for pie chart
|
||||
const statusPieData = [
|
||||
{ name: 'Online', value: onlineAgents, fill: STATUS_COLORS.Online },
|
||||
{ name: 'Offline', value: offlineAgents, fill: STATUS_COLORS.Offline },
|
||||
].filter(s => s.value > 0);
|
||||
|
||||
// Version distribution
|
||||
const versionCounts = agents.reduce<Record<string, number>>((acc, a) => {
|
||||
const v = a.version || 'unknown';
|
||||
acc[v] = (acc[v] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Agent Fleet Overview"
|
||||
subtitle={`${totalAgents} agents — ${onlineAgents} online, ${offlineAgents} offline`}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="card p-5 text-center">
|
||||
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Total Agents</p>
|
||||
<p className="text-3xl font-bold mt-2 text-blue-400">{totalAgents}</p>
|
||||
</div>
|
||||
<div className="card p-5 text-center">
|
||||
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Online</p>
|
||||
<p className="text-3xl font-bold mt-2 text-emerald-400">{onlineAgents}</p>
|
||||
</div>
|
||||
<div className="card p-5 text-center">
|
||||
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Offline</p>
|
||||
<p className="text-3xl font-bold mt-2 text-red-400">{offlineAgents}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* OS Distribution */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">OS Distribution</h3>
|
||||
<div className="h-48">
|
||||
{osPieData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={osPieData} cx="50%" cy="50%" outerRadius={70} dataKey="value" label={({ name, value }) => `${name}: ${value}`} labelLine={false}>
|
||||
{osPieData.map((entry, index) => (
|
||||
<Cell key={index} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-sm text-slate-500">No data</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Distribution */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Status Distribution</h3>
|
||||
<div className="h-48">
|
||||
{statusPieData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={statusPieData} cx="50%" cy="50%" innerRadius={40} outerRadius={70} dataKey="value" label={({ name, value }) => `${name}: ${value}`} labelLine={false}>
|
||||
{statusPieData.map((entry, index) => (
|
||||
<Cell key={index} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-sm text-slate-500">No data</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Breakdown */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Agent Versions</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(versionCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([version, count]) => (
|
||||
<div key={version} className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-300 font-mono">{version}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 bg-slate-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
style={{ width: `${(count / totalAgents) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 w-8 text-right">{count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(versionCounts).length === 0 && (
|
||||
<p className="text-sm text-slate-500">No version data</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment Groups */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Fleet by Platform</h3>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-slate-500">Loading fleet data...</p>
|
||||
) : groups.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No agents registered</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groups.map(group => (
|
||||
<div key={`${group.os}/${group.arch}`} className="card">
|
||||
<div className="px-5 py-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: OS_COLORS[group.os.toLowerCase()] || '#64748b' }}
|
||||
/>
|
||||
<h4 className="text-sm font-medium text-slate-200">
|
||||
{group.os} / {group.arch}
|
||||
</h4>
|
||||
<span className="text-xs text-slate-500">
|
||||
{group.agents.length} agent{group.agents.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-emerald-400">{group.online} online</span>
|
||||
{group.offline > 0 && <span className="text-red-400">{group.offline} offline</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-700/50">
|
||||
{group.agents.map(agent => (
|
||||
<div
|
||||
key={agent.id}
|
||||
onClick={() => navigate(`/agents/${agent.id}`)}
|
||||
className="px-5 py-3 flex items-center justify-between hover:bg-slate-700/30 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${agent.status === 'Online' ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
||||
<div>
|
||||
<div className="text-sm text-slate-200">{agent.name || agent.hostname}</div>
|
||||
<div className="text-xs text-slate-500">{agent.ip_address || agent.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{agent.version && (
|
||||
<span className="text-xs text-slate-500 font-mono">{agent.version}</span>
|
||||
)}
|
||||
<StatusBadge status={agent.status} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -480,16 +480,30 @@ export default function CertificateDetailPage() {
|
||||
<p className="text-sm text-slate-500">No versions yet</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{versions.data.map((v) => (
|
||||
{versions.data.map((v, idx) => (
|
||||
<div key={v.id} className="flex items-center justify-between py-2 border-b border-slate-700/50 last:border-0">
|
||||
<div>
|
||||
<div className="text-sm text-slate-200">Version {v.version}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-200">Version {v.version}</span>
|
||||
{idx === 0 && <span className="text-xs bg-blue-500/20 text-blue-400 px-1.5 py-0.5 rounded">Current</span>}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 font-mono">{v.serial_number}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-300">{formatDate(v.not_before)} — {formatDate(v.not_after)}</div>
|
||||
<div className="text-xs text-slate-500">{formatDateTime(v.created_at)}</div>
|
||||
</div>
|
||||
{idx > 0 && cert?.status !== 'Archived' && cert?.status !== 'Revoked' && (
|
||||
<button
|
||||
onClick={() => setShowDeploy(true)}
|
||||
className="text-xs text-amber-400 hover:text-amber-300 border border-amber-500/30 px-2 py-1 rounded hover:bg-amber-500/10 transition-colors"
|
||||
title="Redeploy this version to targets"
|
||||
>
|
||||
Rollback
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+173
-12
@@ -1,10 +1,29 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getCertificates, getAgents, getJobs, getNotifications, getHealth } from '../api/client';
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||
} from 'recharts';
|
||||
import {
|
||||
getCertificates, getAgents, getJobs, getNotifications, getHealth,
|
||||
getDashboardSummary, getCertificatesByStatus, getExpirationTimeline,
|
||||
getJobTrends, getIssuanceRate,
|
||||
} from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { daysUntil, expiryColor, formatDate } from '../api/utils';
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
Active: '#10b981',
|
||||
Expiring: '#f59e0b',
|
||||
Expired: '#ef4444',
|
||||
Revoked: '#8b5cf6',
|
||||
Pending: '#6366f1',
|
||||
RenewalInProgress: '#3b82f6',
|
||||
Failed: '#f43f5e',
|
||||
Archived: '#64748b',
|
||||
};
|
||||
|
||||
function StatCard({ label, value, icon, color }: { label: string; value: string | number; icon: string; color: string }) {
|
||||
const colorMap: Record<string, string> = {
|
||||
success: 'bg-emerald-500/10 text-emerald-400',
|
||||
@@ -27,23 +46,71 @@ function StatCard({ label, value, icon, color }: { label: string; value: string
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">{title}</h3>
|
||||
<div className="h-64">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-xs shadow-lg">
|
||||
<p className="text-slate-300 mb-1">{label}</p>
|
||||
{payload.map((entry: any, i: number) => (
|
||||
<p key={i} style={{ color: entry.color }}>
|
||||
{entry.name}: {typeof entry.value === 'number' && entry.name?.includes('rate') ? `${entry.value.toFixed(1)}%` : entry.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, refetchInterval: 30000 });
|
||||
const { data: summary } = useQuery({ queryKey: ['dashboard-summary'], queryFn: getDashboardSummary, refetchInterval: 30000 });
|
||||
const { data: statusCounts } = useQuery({ queryKey: ['certs-by-status'], queryFn: getCertificatesByStatus, refetchInterval: 30000 });
|
||||
const { data: expirationTimeline } = useQuery({ queryKey: ['expiration-timeline'], queryFn: () => getExpirationTimeline(90), refetchInterval: 60000 });
|
||||
const { data: jobTrends } = useQuery({ queryKey: ['job-trends'], queryFn: () => getJobTrends(30), refetchInterval: 30000 });
|
||||
const { data: issuanceRate } = useQuery({ queryKey: ['issuance-rate'], queryFn: () => getIssuanceRate(30), refetchInterval: 60000 });
|
||||
const { data: certs } = useQuery({ queryKey: ['certificates', {}], queryFn: () => getCertificates(), refetchInterval: 30000 });
|
||||
const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => getAgents(), refetchInterval: 15000 });
|
||||
const { data: jobs } = useQuery({ queryKey: ['jobs', {}], queryFn: () => getJobs(), refetchInterval: 10000 });
|
||||
const { data: notifs } = useQuery({ queryKey: ['notifications'], queryFn: () => getNotifications() });
|
||||
|
||||
const totalCerts = certs?.total || 0;
|
||||
const expiringSoon = certs?.data?.filter(c => {
|
||||
const d = daysUntil(c.expires_at);
|
||||
return d > 0 && d <= 30;
|
||||
}).length || 0;
|
||||
const expired = certs?.data?.filter(c => c.status === 'Expired' || daysUntil(c.expires_at) <= 0).length || 0;
|
||||
const activeAgents = agents?.data?.filter(a => a.status === 'Online').length || agents?.total || 0;
|
||||
const pendingJobs = jobs?.data?.filter(j => j.status === 'Pending' || j.status === 'Running').length || 0;
|
||||
const totalCerts = summary?.total_certificates || 0;
|
||||
const expiringSoon = summary?.expiring_certificates || 0;
|
||||
const expired = summary?.expired_certificates || 0;
|
||||
const activeAgents = summary?.active_agents || 0;
|
||||
const pendingJobs = summary?.pending_jobs || 0;
|
||||
|
||||
// Prepare pie chart data
|
||||
const pieData = (statusCounts || []).filter(s => s.count > 0).map(s => ({
|
||||
name: s.status,
|
||||
value: s.count,
|
||||
fill: STATUS_COLORS[s.status] || '#64748b',
|
||||
}));
|
||||
|
||||
// Format expiration heatmap for display — aggregate weekly for 90 days
|
||||
const weeklyExpiration = (expirationTimeline || []).reduce<{ week: string; count: number }[]>((acc, bucket, i) => {
|
||||
const weekIdx = Math.floor(i / 7);
|
||||
if (!acc[weekIdx]) {
|
||||
acc[weekIdx] = { week: bucket.date, count: 0 };
|
||||
}
|
||||
acc[weekIdx].count += bucket.count;
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// Format dates for x-axis labels
|
||||
const formatShortDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -53,7 +120,7 @@ export default function DashboardPage() {
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<StatCard label="Total Certificates" value={totalCerts} color="info"
|
||||
icon="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
<StatCard label="Expiring Soon" value={expiringSoon} color={expiringSoon > 0 ? 'warning' : 'success'}
|
||||
@@ -62,6 +129,100 @@ export default function DashboardPage() {
|
||||
icon="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
<StatCard label="Active Agents" value={activeAgents} color="success"
|
||||
icon="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||
<StatCard label="Pending Jobs" value={pendingJobs} color={pendingJobs > 0 ? 'warning' : 'info'}
|
||||
icon="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</div>
|
||||
|
||||
{/* Charts Row 1 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Certificates by Status (Pie) */}
|
||||
<ChartCard title="Certificates by Status">
|
||||
{pieData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={90}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={({ name, value }) => `${name}: ${value}`}
|
||||
labelLine={false}
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={index} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
formatter={(value: string) => <span className="text-xs text-slate-400">{value}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-sm text-slate-500">No certificate data</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
{/* Expiration Heatmap (Bar chart by week) */}
|
||||
<ChartCard title="Expiration Timeline (Next 90 Days)">
|
||||
{weeklyExpiration.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={weeklyExpiration}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="week" tick={{ fill: '#94a3b8', fontSize: 11 }} tickFormatter={formatShortDate} />
|
||||
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} allowDecimals={false} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="count" name="Expiring certs" fill="#f59e0b" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-sm text-slate-500">No expiration data</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* Charts Row 2 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Job Trends (Line chart) */}
|
||||
<ChartCard title="Job Success/Failure Trends (30 Days)">
|
||||
{(jobTrends || []).length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={jobTrends}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="date" tick={{ fill: '#94a3b8', fontSize: 11 }} tickFormatter={formatShortDate} />
|
||||
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} allowDecimals={false} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend formatter={(value: string) => <span className="text-xs text-slate-400">{value}</span>} />
|
||||
<Line type="monotone" dataKey="completed_count" name="Completed" stroke="#10b981" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="failed_count" name="Failed" stroke="#ef4444" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-sm text-slate-500">No job trend data</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
{/* Issuance Rate (Bar chart) */}
|
||||
<ChartCard title="Certificate Issuance Rate (30 Days)">
|
||||
{(issuanceRate || []).length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={issuanceRate}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="date" tick={{ fill: '#94a3b8', fontSize: 11 }} tickFormatter={formatShortDate} />
|
||||
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} allowDecimals={false} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="issued_count" name="Issued" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-sm text-slate-500">No issuance data</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
Reference in New Issue
Block a user