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:
shankar0123
2026-03-22 19:46:13 -04:00
parent 2f65dd1a61
commit ee75f149ae
21 changed files with 2125 additions and 28 deletions
+59
View File
@@ -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
View File
@@ -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');
+59
View File
@@ -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;
};
}