From 45d6e2f7b23e8b706ee0d5b5a66bbe7b1c6a0532 Mon Sep 17 00:00:00 2001 From: Shankar Date: Sun, 22 Mar 2026 19:46:13 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20M14=20=E2=80=94=20Observability=20(dash?= =?UTF-8?q?board=20charts,=20agent=20fleet,=20stats=20API,=20metrics,=20st?= =?UTF-8?q?ructured=20logging,=20rollback)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 4 +- cmd/server/main.go | 14 +- internal/api/handler/metrics.go | 134 +++++++ internal/api/handler/stats.go | 147 ++++++++ internal/api/handler/stats_handler_test.go | 204 +++++++++++ internal/api/middleware/middleware.go | 29 ++ internal/api/router/router.go | 12 + internal/integration/lifecycle_test.go | 27 ++ internal/integration/negative_test.go | 4 + internal/service/stats.go | 312 ++++++++++++++++ internal/service/stats_test.go | 249 +++++++++++++ web/package-lock.json | 402 ++++++++++++++++++++- web/package.json | 3 +- web/src/api/client.test.ts | 59 +++ web/src/api/client.ts | 21 +- web/src/api/types.ts | 59 +++ web/src/components/Layout.tsx | 1 + web/src/main.tsx | 2 + web/src/pages/AgentFleetPage.tsx | 261 +++++++++++++ web/src/pages/CertificateDetailPage.tsx | 24 +- web/src/pages/DashboardPage.tsx | 185 +++++++++- 21 files changed, 2125 insertions(+), 28 deletions(-) create mode 100644 internal/api/handler/metrics.go create mode 100644 internal/api/handler/stats.go create mode 100644 internal/api/handler/stats_handler_test.go create mode 100644 internal/service/stats.go create mode 100644 internal/service/stats_test.go create mode 100644 web/src/pages/AgentFleetPage.tsx diff --git a/README.md b/README.md index d4a4ae8..e3a564e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/server/main.go b/cmd/server/main.go index bf17e55..9412992 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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, diff --git a/internal/api/handler/metrics.go b/internal/api/handler/metrics.go new file mode 100644 index 0000000..ff5776c --- /dev/null +++ b/internal/api/handler/metrics.go @@ -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"` +} diff --git a/internal/api/handler/stats.go b/internal/api/handler/stats.go new file mode 100644 index 0000000..580bd4c --- /dev/null +++ b/internal/api/handler/stats.go @@ -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) +} diff --git a/internal/api/handler/stats_handler_test.go b/internal/api/handler/stats_handler_test.go new file mode 100644 index 0000000..40b07c4 --- /dev/null +++ b/internal/api/handler/stats_handler_test.go @@ -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) + } +} diff --git a/internal/api/middleware/middleware.go b/internal/api/middleware/middleware.go index 3da37d2..e2afa49 100644 --- a/internal/api/middleware/middleware.go +++ b/internal/api/middleware/middleware.go @@ -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) { diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 5934d10..40b62e0 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -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. diff --git a/internal/integration/lifecycle_test.go b/internal/integration/lifecycle_test.go index 53743d2..bce2999 100644 --- a/internal/integration/lifecycle_test.go +++ b/internal/integration/lifecycle_test.go @@ -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 +} diff --git a/internal/integration/negative_test.go b/internal/integration/negative_test.go index 1f21015..6e50642 100644 --- a/internal/integration/negative_test.go +++ b/internal/integration/negative_test.go @@ -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, ) diff --git a/internal/service/stats.go b/internal/service/stats.go new file mode 100644 index 0000000..770b161 --- /dev/null +++ b/internal/service/stats.go @@ -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 +} diff --git a/internal/service/stats_test.go b/internal/service/stats_test.go new file mode 100644 index 0000000..28cec20 --- /dev/null +++ b/internal/service/stats_test.go @@ -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") + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 3d04314..55086f7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 1cf035e..02fe74f 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 75c9314..b473ecb 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -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', () => { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 12cc512..6f1e24c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(`${BASE}/stats/summary`); + +export const getCertificatesByStatus = () => + fetchJSON(`${BASE}/stats/certificates-by-status`); + +export const getExpirationTimeline = (days = 30) => + fetchJSON(`${BASE}/stats/expiration-timeline?days=${days}`); + +export const getJobTrends = (days = 30) => + fetchJSON(`${BASE}/stats/job-trends?days=${days}`); + +export const getIssuanceRate = (days = 30) => + fetchJSON(`${BASE}/stats/issuance-rate?days=${days}`); + +export const getMetrics = () => + fetchJSON(`${BASE}/metrics`); + // Health export const getHealth = () => fetchJSON<{ status: string }>('/health'); diff --git a/web/src/api/types.ts b/web/src/api/types.ts index d045511..e1f0fae 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -206,3 +206,62 @@ export interface PaginatedResponse { 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; + }; +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index f91a24c..4726fb9 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -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' }, diff --git a/web/src/main.tsx b/web/src/main.tsx index 2de72c1..e948319 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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( } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/pages/AgentFleetPage.tsx b/web/src/pages/AgentFleetPage.tsx new file mode 100644 index 0000000..876e29b --- /dev/null +++ b/web/src/pages/AgentFleetPage.tsx @@ -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 = { + linux: '#f97316', + darwin: '#3b82f6', + windows: '#8b5cf6', + unknown: '#64748b', +}; + +const STATUS_COLORS: Record = { + 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(); + + 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 ( +
+ {payload.map((entry: any, i: number) => ( +

+ {entry.name}: {entry.value} +

+ ))} +
+ ); +}; + +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>((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>((acc, a) => { + const v = a.version || 'unknown'; + acc[v] = (acc[v] || 0) + 1; + return acc; + }, {}); + + return ( + <> + +
+ {/* Summary Cards */} +
+
+

Total Agents

+

{totalAgents}

+
+
+

Online

+

{onlineAgents}

+
+
+

Offline

+

{offlineAgents}

+
+
+ + {/* Charts */} +
+ {/* OS Distribution */} +
+

OS Distribution

+
+ {osPieData.length > 0 ? ( + + + `${name}: ${value}`} labelLine={false}> + {osPieData.map((entry, index) => ( + + ))} + + } /> + + + ) : ( +
No data
+ )} +
+
+ + {/* Status Distribution */} +
+

Status Distribution

+
+ {statusPieData.length > 0 ? ( + + + `${name}: ${value}`} labelLine={false}> + {statusPieData.map((entry, index) => ( + + ))} + + } /> + + + ) : ( +
No data
+ )} +
+
+ + {/* Version Breakdown */} +
+

Agent Versions

+
+ {Object.entries(versionCounts) + .sort(([, a], [, b]) => b - a) + .map(([version, count]) => ( +
+ {version} +
+
+
+
+ {count} +
+
+ ))} + {Object.keys(versionCounts).length === 0 && ( +

No version data

+ )} +
+
+
+ + {/* Environment Groups */} +
+

Fleet by Platform

+ {isLoading ? ( +

Loading fleet data...

+ ) : groups.length === 0 ? ( +

No agents registered

+ ) : ( +
+ {groups.map(group => ( +
+
+
+
+

+ {group.os} / {group.arch} +

+ + {group.agents.length} agent{group.agents.length !== 1 ? 's' : ''} + +
+
+ {group.online} online + {group.offline > 0 && {group.offline} offline} +
+
+
+ {group.agents.map(agent => ( +
navigate(`/agents/${agent.id}`)} + className="px-5 py-3 flex items-center justify-between hover:bg-slate-700/30 cursor-pointer transition-colors" + > +
+
+
+
{agent.name || agent.hostname}
+
{agent.ip_address || agent.id}
+
+
+
+ {agent.version && ( + {agent.version} + )} + +
+
+ ))} +
+
+ ))} +
+ )} +
+
+ + ); +} diff --git a/web/src/pages/CertificateDetailPage.tsx b/web/src/pages/CertificateDetailPage.tsx index 4b83761..51f954b 100644 --- a/web/src/pages/CertificateDetailPage.tsx +++ b/web/src/pages/CertificateDetailPage.tsx @@ -480,15 +480,29 @@ export default function CertificateDetailPage() {

No versions yet

) : (
- {versions.data.map((v) => ( + {versions.data.map((v, idx) => (
-
Version {v.version}
+
+ Version {v.version} + {idx === 0 && Current} +
{v.serial_number}
-
-
{formatDate(v.not_before)} — {formatDate(v.not_after)}
-
{formatDateTime(v.created_at)}
+
+
+
{formatDate(v.not_before)} — {formatDate(v.not_after)}
+
{formatDateTime(v.created_at)}
+
+ {idx > 0 && cert?.status !== 'Archived' && cert?.status !== 'Revoked' && ( + + )}
))} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index a29881a..dc1db85 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -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 = { + 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 = { 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 ( +
+

{title}

+
+ {children} +
+
+ ); +} + +const CustomTooltip = ({ active, payload, label }: any) => { + if (!active || !payload?.length) return null; + return ( +
+

{label}

+ {payload.map((entry: any, i: number) => ( +

+ {entry.name}: {typeof entry.value === 'number' && entry.name?.includes('rate') ? `${entry.value.toFixed(1)}%` : entry.value} +

+ ))} +
+ ); +}; + 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() { />
{/* Stats */} -
+
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" /> + 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" /> +
+ + {/* Charts Row 1 */} +
+ {/* Certificates by Status (Pie) */} + + {pieData.length > 0 ? ( + + + `${name}: ${value}`} + labelLine={false} + > + {pieData.map((entry, index) => ( + + ))} + + } /> + {value}} + /> + + + ) : ( +
No certificate data
+ )} +
+ + {/* Expiration Heatmap (Bar chart by week) */} + + {weeklyExpiration.length > 0 ? ( + + + + + + } /> + + + + ) : ( +
No expiration data
+ )} +
+
+ + {/* Charts Row 2 */} +
+ {/* Job Trends (Line chart) */} + + {(jobTrends || []).length > 0 ? ( + + + + + + } /> + {value}} /> + + + + + ) : ( +
No job trend data
+ )} +
+ + {/* Issuance Rate (Bar chart) */} + + {(issuanceRate || []).length > 0 ? ( + + + + + + } /> + + + + ) : ( +
No issuance data
+ )} +