feat(Pre-2.1.0-E): GUI completeness — 5 new pages, clickable nav, verification badges

Wire all remaining backend features to the frontend GUI:

New pages:
- DigestPage: preview digest HTML via iframe + send with confirmation
- ObservabilityPage: health status, metrics gauges, Prometheus config + live output
- JobDetailPage: full job details, verification section, timeline, audit events
- IssuerDetailPage: redacted config, test connection, issued certificates list
- TargetDetailPage: config, agent link, deployment history with verification

Existing page updates:
- JobsPage: clickable job IDs, verification column with VerificationBadge
- IssuersPage: clickable issuer names linking to detail page
- TargetsPage: clickable target names linking to detail page
- Sidebar: Digest and Observability nav items
- 5 new routes in main.tsx

API client: getJob, getIssuer, getTarget, getJobVerification, getPrometheusMetrics
Tests: 7 new Vitest tests (203 total), testing-guide Part 37 (17 manual tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shankar
2026-03-30 14:10:58 -04:00
parent 5096dc5d71
commit 23ac38bef6
13 changed files with 1047 additions and 6 deletions
+100
View File
@@ -78,6 +78,11 @@ import {
triggerNetworkScan,
previewDigest,
sendDigest,
getJob,
getJobVerification,
getIssuer,
getTarget,
getPrometheusMetrics,
} from './client';
// Mock global fetch
@@ -1006,4 +1011,99 @@ describe('API Client', () => {
expect(result.message).toBe('digest sent');
});
});
// ─── Job Detail ────────────────────────────
describe('Job Detail', () => {
it('getJob fetches single job by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'job-1', type: 'Deployment', status: 'Completed' }));
const result = await getJob('job-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/jobs/job-1');
expect(result.id).toBe('job-1');
expect(result.type).toBe('Deployment');
});
it('getJobVerification fetches verification result', async () => {
const verificationData = {
job_id: 'job-1',
target_id: 't-nginx1',
verified: true,
actual_fingerprint: 'abc123',
expected_fingerprint: 'abc123',
verified_at: '2026-03-28T12:00:00Z',
};
mockFetch.mockReturnValueOnce(mockJsonResponse(verificationData));
const result = await getJobVerification('job-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/jobs/job-1/verification');
expect(result.verified).toBe(true);
expect(result.actual_fingerprint).toBe('abc123');
});
});
// ─── Issuer Detail ─────────────────────────
describe('Issuer Detail', () => {
it('getIssuer fetches single issuer by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-local', name: 'Local CA', type: 'local_ca', status: 'active' }));
const result = await getIssuer('iss-local');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/issuers/iss-local');
expect(result.name).toBe('Local CA');
expect(result.type).toBe('local_ca');
});
});
// ─── Target Detail ─────────────────────────
describe('Target Detail', () => {
it('getTarget fetches single target by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-nginx1', name: 'Web Server', type: 'nginx', hostname: 'web1.example.com' }));
const result = await getTarget('t-nginx1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/targets/t-nginx1');
expect(result.name).toBe('Web Server');
expect(result.type).toBe('nginx');
});
});
// ─── Prometheus Metrics ────────────────────
describe('Prometheus Metrics', () => {
it('getPrometheusMetrics fetches text format', async () => {
const metricsText = '# HELP certctl_certificate_total Total certificates\ncertctl_certificate_total 10';
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(metricsText),
} as Response)
);
const result = await getPrometheusMetrics();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/metrics/prometheus');
expect(result).toContain('certctl_certificate_total');
});
it('getPrometheusMetrics throws on error', async () => {
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: false,
status: 500,
text: () => Promise.resolve('error'),
} as Response)
);
await expect(getPrometheusMetrics()).rejects.toThrow('Prometheus metrics failed: 500');
});
it('getPrometheusMetrics includes auth header', async () => {
setApiKey('prom-key');
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve('metrics'),
} as Response)
);
await getPrometheusMetrics();
const [, init] = mockFetch.mock.calls[0];
expect(init.headers['Authorization']).toBe('Bearer prom-key');
});
});
});
+27
View File
@@ -365,5 +365,32 @@ export const previewDigest = () => {
export const sendDigest = () =>
fetchJSON<{ message: string }>(`${BASE}/digest/send`, { method: 'POST' });
// Jobs (single)
export const getJob = (id: string) =>
fetchJSON<Job>(`${BASE}/jobs/${id}`);
// Job Verification
export const getJobVerification = (id: string) =>
fetchJSON<{ job_id: string; target_id: string; verified: boolean; actual_fingerprint: string; expected_fingerprint: string; verified_at: string; error?: string }>(`${BASE}/jobs/${id}/verification`);
// Issuers (single)
export const getIssuer = (id: string) =>
fetchJSON<Issuer>(`${BASE}/issuers/${id}`);
// Targets (single)
export const getTarget = (id: string) =>
fetchJSON<Target>(`${BASE}/targets/${id}`);
// Prometheus metrics (text format)
export const getPrometheusMetrics = () => {
const headers: Record<string, string> = {};
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
return fetch(`${BASE}/metrics/prometheus`, { headers })
.then(r => {
if (!r.ok) throw new Error(`Prometheus metrics failed: ${r.status}`);
return r.text();
});
};
// Health
export const getHealth = () => fetchJSON<{ status: string }>('/health');