mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 10:28:54 +00:00
feat(M48): continuous TLS health monitoring — endpoint state machine, shared tlsprobe, 8 API endpoints, GUI
Adds continuous TLS endpoint health monitoring that closes the deploy→verify→monitor loop. After M25 verifies a deployment succeeded once, M48 continuously confirms it stays healthy. Key components: - Shared `internal/tlsprobe/` package extracted from network scanner for reuse - Health status state machine: healthy → degraded (2 failures) → down (5 failures), plus cert_mismatch when served fingerprint differs from expected - 8th scheduler loop (60s tick, per-endpoint configurable intervals) - PostgreSQL migration 000011: endpoint_health_checks + endpoint_health_history tables - 8 REST API endpoints (CRUD, history, acknowledge, summary) - Health Monitor GUI page with summary bar, status table, create modal, auto-refresh - 38 new tests (5 tlsprobe + 11 domain + 10 service + 8 handler + 4 frontend) - All coverage thresholds maintained (service 68%, handler 83%, domain 87%, middleware 63%) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -90,6 +90,14 @@ import {
|
||||
updateIssuer,
|
||||
updateTarget,
|
||||
getPolicy,
|
||||
listHealthChecks,
|
||||
getHealthCheck,
|
||||
createHealthCheck,
|
||||
updateHealthCheck,
|
||||
deleteHealthCheck,
|
||||
getHealthCheckHistory,
|
||||
acknowledgeHealthCheck,
|
||||
getHealthCheckSummary,
|
||||
} from './client';
|
||||
|
||||
// Mock global fetch
|
||||
@@ -1236,4 +1244,38 @@ describe('API Client', () => {
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/policies/pol-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Health Checks (M48)', () => {
|
||||
it('listHealthChecks sends GET with optional filters', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
const result = await listHealthChecks({ status: 'degraded' });
|
||||
expect(result.total).toBe(0);
|
||||
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/health-checks');
|
||||
expect(mockFetch.mock.calls[0][0]).toContain('status=degraded');
|
||||
});
|
||||
|
||||
it('getHealthCheck sends GET with health check ID', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'hc-1', endpoint: 'example.com:443' }));
|
||||
const result = await getHealthCheck('hc-1');
|
||||
expect(result.id).toBe('hc-1');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/health-checks/hc-1');
|
||||
});
|
||||
|
||||
it('createHealthCheck sends POST with data', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'hc-1', endpoint: 'example.com:443' }));
|
||||
const result = await createHealthCheck({ endpoint: 'example.com:443' });
|
||||
expect(result.id).toBe('hc-1');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toContain('/api/v1/health-checks');
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('getHealthCheckSummary sends GET to /health-checks/summary', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ healthy: 5, degraded: 1, down: 0, cert_mismatch: 0, unknown: 2, total: 8 }));
|
||||
const result = await getHealthCheckSummary();
|
||||
expect(result.healthy).toBe(5);
|
||||
expect(result.total).toBe(8);
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/health-checks/summary');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+36
-1
@@ -1,4 +1,4 @@
|
||||
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget } 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, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary } from './types';
|
||||
|
||||
const BASE = '/api/v1';
|
||||
|
||||
@@ -432,3 +432,38 @@ export const getPrometheusMetrics = () => {
|
||||
|
||||
// Health
|
||||
export const getHealth = () => fetchJSON<{ status: string }>('/health');
|
||||
|
||||
// Health checks (M48)
|
||||
export const listHealthChecks = (params?: { status?: string; certificate_id?: string; enabled?: string; page?: number; per_page?: number }): Promise<PaginatedResponse<EndpointHealthCheck>> => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.certificate_id) query.set('certificate_id', params.certificate_id);
|
||||
if (params?.enabled) query.set('enabled', params.enabled);
|
||||
if (params?.page) query.set('page', String(params.page));
|
||||
if (params?.per_page) query.set('per_page', String(params.per_page));
|
||||
const qs = query.toString();
|
||||
return fetchJSON<PaginatedResponse<EndpointHealthCheck>>(`${BASE}/health-checks${qs ? '?' + qs : ''}`);
|
||||
};
|
||||
|
||||
export const getHealthCheck = (id: string) =>
|
||||
fetchJSON<EndpointHealthCheck>(`${BASE}/health-checks/${id}`);
|
||||
|
||||
export const createHealthCheck = (data: Partial<EndpointHealthCheck>) =>
|
||||
fetchJSON<EndpointHealthCheck>(`${BASE}/health-checks`, { method: 'POST', body: JSON.stringify(data) });
|
||||
|
||||
export const updateHealthCheck = (id: string, data: Partial<EndpointHealthCheck>) =>
|
||||
fetchJSON<EndpointHealthCheck>(`${BASE}/health-checks/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
|
||||
export const deleteHealthCheck = (id: string) =>
|
||||
fetchJSON<void>(`${BASE}/health-checks/${id}`, { method: 'DELETE' });
|
||||
|
||||
export const getHealthCheckHistory = (id: string, limit?: number) => {
|
||||
const query = limit ? `?limit=${limit}` : '';
|
||||
return fetchJSON<HealthHistoryEntry[]>(`${BASE}/health-checks/${id}/history${query}`);
|
||||
};
|
||||
|
||||
export const acknowledgeHealthCheck = (id: string) =>
|
||||
fetchJSON<void>(`${BASE}/health-checks/${id}/acknowledge`, { method: 'POST', body: JSON.stringify({}) });
|
||||
|
||||
export const getHealthCheckSummary = () =>
|
||||
fetchJSON<HealthCheckSummary>(`${BASE}/health-checks/summary`);
|
||||
|
||||
@@ -347,3 +347,54 @@ export interface MetricsResponse {
|
||||
measured_at: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Health check types (M48)
|
||||
export interface EndpointHealthCheck {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
certificate_id?: string;
|
||||
network_scan_target_id?: string;
|
||||
expected_fingerprint: string;
|
||||
observed_fingerprint: string;
|
||||
status: string;
|
||||
consecutive_failures: number;
|
||||
response_time_ms: number;
|
||||
tls_version: string;
|
||||
cipher_suite: string;
|
||||
cert_subject: string;
|
||||
cert_issuer: string;
|
||||
cert_expiry?: string;
|
||||
last_checked_at?: string;
|
||||
last_success_at?: string;
|
||||
last_failure_at?: string;
|
||||
last_transition_at?: string;
|
||||
failure_reason: string;
|
||||
degraded_threshold: number;
|
||||
down_threshold: number;
|
||||
check_interval_seconds: number;
|
||||
enabled: boolean;
|
||||
acknowledged: boolean;
|
||||
acknowledged_by?: string;
|
||||
acknowledged_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface HealthHistoryEntry {
|
||||
id: string;
|
||||
health_check_id: string;
|
||||
status: string;
|
||||
response_time_ms: number;
|
||||
fingerprint: string;
|
||||
failure_reason: string;
|
||||
checked_at: string;
|
||||
}
|
||||
|
||||
export interface HealthCheckSummary {
|
||||
healthy: number;
|
||||
degraded: number;
|
||||
down: number;
|
||||
cert_mismatch: number;
|
||||
unknown: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user