mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:11:38 +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:
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user