mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 08:38:52 +00:00
feat(gui): add discovery triage, network scan management, and approval workflow pages (M24)
Three new GUI surfaces closing the backend-to-frontend gap for V2: - Discovery triage page: summary stats bar, DataTable with claim/dismiss actions, status/agent filters, collapsible scan history panel - Network scan target management: CRUD with create modal, enable/disable toggle, Scan Now button, last scan results display - Jobs page approval workflow: Approve/Reject buttons for AwaitingApproval jobs, rejection reason modal, pending approval banner with count, AwaitingApproval/AwaitingCSR added to status filter dropdown Also adds 13 new frontend tests, 4 API types, 12 API client functions, 2 sidebar nav items, 2 routes, and discovery status badge styles. Docs updated: README, architecture, quickstart, demo-advanced, CLAUDE.md, roadmap. Version bumped to v2.0.4. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,18 @@ import {
|
||||
getJobTrends,
|
||||
getIssuanceRate,
|
||||
getMetrics,
|
||||
getDiscoveredCertificates,
|
||||
getDiscoveredCertificate,
|
||||
claimDiscoveredCertificate,
|
||||
dismissDiscoveredCertificate,
|
||||
getDiscoveryScans,
|
||||
getDiscoverySummary,
|
||||
getNetworkScanTargets,
|
||||
getNetworkScanTarget,
|
||||
createNetworkScanTarget,
|
||||
updateNetworkScanTarget,
|
||||
deleteNetworkScanTarget,
|
||||
triggerNetworkScan,
|
||||
} from './client';
|
||||
|
||||
// Mock global fetch
|
||||
@@ -686,4 +698,104 @@ describe('API Client', () => {
|
||||
expect(result.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Discovery ────────────────────────────────────
|
||||
|
||||
describe('Discovery', () => {
|
||||
it('getDiscoveredCertificates calls with params', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
await getDiscoveredCertificates({ status: 'Unmanaged' });
|
||||
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/discovered-certificates');
|
||||
expect(mockFetch.mock.calls[0][0]).toContain('status=Unmanaged');
|
||||
});
|
||||
|
||||
it('getDiscoveredCertificate calls with id', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'dc-1', common_name: 'test.example.com' }));
|
||||
const result = await getDiscoveredCertificate('dc-1');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/discovered-certificates/dc-1');
|
||||
expect(result.common_name).toBe('test.example.com');
|
||||
});
|
||||
|
||||
it('claimDiscoveredCertificate sends POST with managed cert id', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'claimed' }));
|
||||
await claimDiscoveredCertificate('dc-1', 'mc-api-prod');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/discovered-certificates/dc-1/claim');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body)).toEqual({ managed_certificate_id: 'mc-api-prod' });
|
||||
});
|
||||
|
||||
it('dismissDiscoveredCertificate sends POST', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'dismissed' }));
|
||||
await dismissDiscoveredCertificate('dc-1');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/discovered-certificates/dc-1/dismiss');
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('getDiscoveryScans calls endpoint', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
await getDiscoveryScans();
|
||||
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/discovery-scans');
|
||||
});
|
||||
|
||||
it('getDiscoverySummary calls endpoint', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ Unmanaged: 5, Managed: 3, Dismissed: 1 }));
|
||||
const result = await getDiscoverySummary();
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/discovery-summary');
|
||||
expect(result.Unmanaged).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Network Scan Targets ────────────────────────
|
||||
|
||||
describe('Network Scan Targets', () => {
|
||||
it('getNetworkScanTargets calls endpoint', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
await getNetworkScanTargets();
|
||||
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/network-scan-targets');
|
||||
});
|
||||
|
||||
it('getNetworkScanTarget calls with id', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'nst-1', name: 'DMZ' }));
|
||||
const result = await getNetworkScanTarget('nst-1');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/network-scan-targets/nst-1');
|
||||
expect(result.name).toBe('DMZ');
|
||||
});
|
||||
|
||||
it('createNetworkScanTarget sends POST', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'nst-new', name: 'Production' }));
|
||||
await createNetworkScanTarget({ name: 'Production', cidrs: ['10.0.0.0/24'], ports: [443] });
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/network-scan-targets');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.name).toBe('Production');
|
||||
expect(body.cidrs).toEqual(['10.0.0.0/24']);
|
||||
});
|
||||
|
||||
it('updateNetworkScanTarget sends PUT', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'nst-1', enabled: false }));
|
||||
await updateNetworkScanTarget('nst-1', { enabled: false });
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/network-scan-targets/nst-1');
|
||||
expect(init.method).toBe('PUT');
|
||||
});
|
||||
|
||||
it('deleteNetworkScanTarget sends DELETE', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({}, 204));
|
||||
await deleteNetworkScanTarget('nst-1');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/network-scan-targets/nst-1');
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('triggerNetworkScan sends POST to scan endpoint', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'scan triggered' }));
|
||||
await triggerNetworkScan('nst-1');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/network-scan-targets/nst-1/scan');
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+48
-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 } 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 } from './types';
|
||||
|
||||
const BASE = '/api/v1';
|
||||
|
||||
@@ -258,6 +258,53 @@ 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 }) });
|
||||
|
||||
// Discovery
|
||||
export const getDiscoveredCertificates = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
return fetchJSON<PaginatedResponse<DiscoveredCertificate>>(`${BASE}/discovered-certificates?${qs}`);
|
||||
};
|
||||
|
||||
export const getDiscoveredCertificate = (id: string) =>
|
||||
fetchJSON<DiscoveredCertificate>(`${BASE}/discovered-certificates/${id}`);
|
||||
|
||||
export const claimDiscoveredCertificate = (id: string, managedCertificateId: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/discovered-certificates/${id}/claim`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ managed_certificate_id: managedCertificateId }),
|
||||
});
|
||||
|
||||
export const dismissDiscoveredCertificate = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/discovered-certificates/${id}/dismiss`, { method: 'POST' });
|
||||
|
||||
export const getDiscoveryScans = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
return fetchJSON<PaginatedResponse<DiscoveryScan>>(`${BASE}/discovery-scans?${qs}`);
|
||||
};
|
||||
|
||||
export const getDiscoverySummary = () =>
|
||||
fetchJSON<DiscoverySummary>(`${BASE}/discovery-summary`);
|
||||
|
||||
// Network Scan Targets
|
||||
export const getNetworkScanTargets = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
return fetchJSON<PaginatedResponse<NetworkScanTarget>>(`${BASE}/network-scan-targets?${qs}`);
|
||||
};
|
||||
|
||||
export const getNetworkScanTarget = (id: string) =>
|
||||
fetchJSON<NetworkScanTarget>(`${BASE}/network-scan-targets/${id}`);
|
||||
|
||||
export const createNetworkScanTarget = (data: Partial<NetworkScanTarget>) =>
|
||||
fetchJSON<NetworkScanTarget>(`${BASE}/network-scan-targets`, { method: 'POST', body: JSON.stringify(data) });
|
||||
|
||||
export const updateNetworkScanTarget = (id: string, data: Partial<NetworkScanTarget>) =>
|
||||
fetchJSON<NetworkScanTarget>(`${BASE}/network-scan-targets/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
|
||||
export const deleteNetworkScanTarget = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/network-scan-targets/${id}`, { method: 'DELETE' });
|
||||
|
||||
export const triggerNetworkScan = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/network-scan-targets/${id}/scan`, { method: 'POST' });
|
||||
|
||||
// Stats
|
||||
export const getDashboardSummary = () =>
|
||||
fetchJSON<DashboardSummary>(`${BASE}/stats/summary`);
|
||||
|
||||
@@ -244,6 +244,67 @@ export interface IssuanceRateDataPoint {
|
||||
issued_count: number;
|
||||
}
|
||||
|
||||
// Discovery types
|
||||
export interface DiscoveredCertificate {
|
||||
id: string;
|
||||
fingerprint_sha256: string;
|
||||
common_name: string;
|
||||
sans: string[];
|
||||
serial_number: string;
|
||||
issuer_dn: string;
|
||||
subject_dn: string;
|
||||
not_before?: string;
|
||||
not_after?: string;
|
||||
key_algorithm: string;
|
||||
key_size: number;
|
||||
is_ca: boolean;
|
||||
source_path: string;
|
||||
source_format: string;
|
||||
agent_id: string;
|
||||
discovery_scan_id?: string;
|
||||
managed_certificate_id?: string;
|
||||
status: string;
|
||||
first_seen_at: string;
|
||||
last_seen_at: string;
|
||||
dismissed_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DiscoveryScan {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
directories: string[];
|
||||
certificates_found: number;
|
||||
certificates_new: number;
|
||||
errors_count: number;
|
||||
scan_duration_ms: number;
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
export interface DiscoverySummary {
|
||||
Unmanaged: number;
|
||||
Managed: number;
|
||||
Dismissed: number;
|
||||
}
|
||||
|
||||
// Network scan types
|
||||
export interface NetworkScanTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
cidrs: string[];
|
||||
ports: number[];
|
||||
enabled: boolean;
|
||||
scan_interval_hours: number;
|
||||
timeout_ms: number;
|
||||
last_scan_at?: string;
|
||||
last_scan_duration_ms?: number;
|
||||
last_scan_certs_found?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MetricsResponse {
|
||||
gauge: {
|
||||
certificate_total: number;
|
||||
|
||||
Reference in New Issue
Block a user