From a6515b432326f39d835564a1390f6cd4a5068104 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Mon, 30 Mar 2026 14:10:58 -0400 Subject: [PATCH] =?UTF-8?q?feat(Pre-2.1.0-E):=20GUI=20completeness=20?= =?UTF-8?q?=E2=80=94=205=20new=20pages,=20clickable=20nav,=20verification?= =?UTF-8?q?=20badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/testing-guide.md | 89 +++++++++++++- web/src/api/client.test.ts | 100 +++++++++++++++ web/src/api/client.ts | 27 ++++ web/src/components/Layout.tsx | 2 + web/src/main.tsx | 10 ++ web/src/pages/DigestPage.tsx | 110 +++++++++++++++++ web/src/pages/IssuerDetailPage.tsx | 162 ++++++++++++++++++++++++ web/src/pages/IssuersPage.tsx | 5 +- web/src/pages/JobDetailPage.tsx | 183 ++++++++++++++++++++++++++++ web/src/pages/JobsPage.tsx | 42 ++++++- web/src/pages/ObservabilityPage.tsx | 149 ++++++++++++++++++++++ web/src/pages/TargetDetailPage.tsx | 169 +++++++++++++++++++++++++ web/src/pages/TargetsPage.tsx | 5 +- 13 files changed, 1047 insertions(+), 6 deletions(-) create mode 100644 web/src/pages/DigestPage.tsx create mode 100644 web/src/pages/IssuerDetailPage.tsx create mode 100644 web/src/pages/JobDetailPage.tsx create mode 100644 web/src/pages/ObservabilityPage.tsx create mode 100644 web/src/pages/TargetDetailPage.tsx diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 0f18882..3973cf3 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -40,6 +40,8 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp - [Part 33: Apache & HAProxy Target Connectors](#part-33-apache--haproxy-target-connectors) - [Part 34: Sub-CA Mode](#part-34-sub-ca-mode) - [Part 35: ARI (RFC 9702) Scheduler Integration](#part-35-ari-rfc-9702-scheduler-integration) +- [Part 36: Agent Work Routing (M31)](#part-36-agent-work-routing-m31) +- [Part 37: GUI Completeness (Pre-2.1.0-E)](#part-37-gui-completeness-pre-210-e) - [Release Sign-Off](#release-sign-off) --- @@ -5116,6 +5118,54 @@ docker logs certctl-server 2>&1 | grep "ARI check failed, falling back" --- +## Part 36: Agent Work Routing (M31) + +Tests that `GetPendingWork()` returns only jobs scoped to the requesting agent, and that deployment jobs have `agent_id` populated at creation time. + +### 36.1 Multi-Agent Routing + +**Prerequisite:** Two agents registered (`agent-web-01`, `agent-lb-01`), two targets (one per agent), one certificate mapped to both targets. Trigger renewal to create deployment jobs. + +```bash +# Poll as agent-web-01 — should only see its deployment job +curl -s -H "Authorization: Bearer $API_KEY" \ + "http://localhost:8443/api/v1/agents/agent-web-01/work" | jq '.[] | .target_id' + +# Poll as agent-lb-01 — should only see its deployment job +curl -s -H "Authorization: Bearer $API_KEY" \ + "http://localhost:8443/api/v1/agents/agent-lb-01/work" | jq '.[] | .target_id' +``` + +**Expected:** Each agent receives only the deployment job for its assigned target. Agent-web-01 does NOT see agent-lb-01's job and vice versa. +**PASS if** each agent's work response contains only jobs for targets it owns. + +### 36.2 Agent With No Targets Gets Empty Work + +**Prerequisite:** Register a new agent with no target assignments. + +```bash +curl -s -H "Authorization: Bearer $API_KEY" \ + "http://localhost:8443/api/v1/agents/agent-no-targets/work" | jq 'length' +``` + +**Expected:** Empty array (0 jobs). +**PASS if** the response is an empty list. + +### 36.3 Deployment Jobs Have agent_id Populated + +**Prerequisite:** Deployment jobs created via renewal or manual trigger. + +```bash +# Check that deployment jobs in the system have agent_id set +curl -s -H "Authorization: Bearer $API_KEY" \ + "http://localhost:8443/api/v1/jobs" | jq '[.data[] | select(.type == "Deployment") | .agent_id] | map(select(. != null)) | length' +``` + +**Expected:** All deployment jobs for targets with agent assignments have `agent_id` populated. +**PASS if** deployment jobs have non-null `agent_id` values. + +--- + ## Release Sign-Off All tests below must pass before tagging v2.1.0. Each row is one individual test from the guide above. The **Method** column indicates whether `qa-smoke-test.sh` covers the test automatically (**Auto**) or requires hands-on verification (**Manual**). @@ -5632,14 +5682,47 @@ These must be green before starting manual QA: | 35.2 | ARI triggers renewal when CA says "now" (requires ACME+ARI) | Manual | ☐ | | | | 35.3 | ARI fallback on error — threshold-based (requires ACME+ARI) | Manual | ☐ | | | +### Part 36: Agent Work Routing (M31) + +| Test | Description | Method | Pass? | Date | Notes | +|------|-------------|--------|-------|------|-------| +| 36.a1 | Agent receives only its deployment jobs | Auto | ☐ | | | +| 36.a2 | Agent with no targets gets empty work list | Auto | ☐ | | | +| 36.a3 | Deployment jobs have agent_id populated | Auto | ☐ | | | +| 36.1 | Multi-agent routing with 2 agents, 2 targets | Manual | ☐ | | | +| 36.2 | Agent with no assigned targets gets empty work | Manual | ☐ | | | +| 36.3 | Database agent_id populated on deployment jobs | Manual | ☐ | | | + +### Part 37: GUI Completeness (Pre-2.1.0-E) + +| Test | Description | Method | Pass? | Date | Notes | +|------|-------------|--------|-------|------|-------| +| 37.1 | DigestPage renders preview iframe | Manual | ☐ | | | +| 37.2 | DigestPage send button with confirmation modal | Manual | ☐ | | | +| 37.3 | ObservabilityPage shows metrics gauges | Manual | ☐ | | | +| 37.4 | ObservabilityPage Prometheus config block | Manual | ☐ | | | +| 37.5 | ObservabilityPage live Prometheus output | Manual | ☐ | | | +| 37.6 | JobDetailPage displays job info and timeline | Manual | ☐ | | | +| 37.7 | JobDetailPage verification section for deployment jobs | Manual | ☐ | | | +| 37.8 | IssuerDetailPage shows redacted config | Manual | ☐ | | | +| 37.9 | IssuerDetailPage test connection button | Manual | ☐ | | | +| 37.10 | IssuerDetailPage issued certificates list | Manual | ☐ | | | +| 37.11 | TargetDetailPage shows config and agent link | Manual | ☐ | | | +| 37.12 | TargetDetailPage deployment history table | Manual | ☐ | | | +| 37.13 | JobsPage — job IDs clickable to /jobs/:id | Manual | ☐ | | | +| 37.14 | JobsPage — verification column for deployment jobs | Manual | ☐ | | | +| 37.15 | IssuersPage — issuer names clickable to /issuers/:id | Manual | ☐ | | | +| 37.16 | TargetsPage — target names clickable to /targets/:id | Manual | ☐ | | | +| 37.17 | Sidebar — Digest and Observability nav items | Manual | ☐ | | | + ### Summary | Category | Count | |----------|-------| -| ☑ Auto (passed in `qa-smoke-test.sh`) | 124 | +| ☑ Auto (passed in `qa-smoke-test.sh`) | 127 | | — Skipped (preconditions not met in demo) | 5 | -| ☐ Manual (requires hands-on verification) | 197 | -| **Total** | **326** | +| ☐ Manual (requires hands-on verification) | 217 | +| **Total** | **349** | **Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss. diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index cc69c14..f6cff7b 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -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'); + }); + }); }); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3e231e2..a7f9e6c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(`${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(`${BASE}/issuers/${id}`); + +// Targets (single) +export const getTarget = (id: string) => + fetchJSON(`${BASE}/targets/${id}`); + +// Prometheus metrics (text format) +export const getPrometheusMetrics = () => { + const headers: Record = {}; + 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'); diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 02d0c4d..a2c7ea2 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -19,6 +19,8 @@ const nav = [ { to: '/discovery', label: 'Discovery', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' }, { to: '/network-scans', label: 'Network Scans', icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 12l2 2 4-4' }, { to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' }, + { to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' }, + { to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' }, { to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' }, ]; diff --git a/web/src/main.tsx b/web/src/main.tsx index 0668ed2..3586b9f 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -25,6 +25,11 @@ import ShortLivedPage from './pages/ShortLivedPage'; import AgentFleetPage from './pages/AgentFleetPage'; import DiscoveryPage from './pages/DiscoveryPage'; import NetworkScanPage from './pages/NetworkScanPage'; +import DigestPage from './pages/DigestPage'; +import ObservabilityPage from './pages/ObservabilityPage'; +import JobDetailPage from './pages/JobDetailPage'; +import IssuerDetailPage from './pages/IssuerDetailPage'; +import TargetDetailPage from './pages/TargetDetailPage'; import './index.css'; const queryClient = new QueryClient({ @@ -53,11 +58,14 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> } /> } /> } /> } /> + } /> } /> + } /> } /> } /> } /> @@ -65,6 +73,8 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> + } /> diff --git a/web/src/pages/DigestPage.tsx b/web/src/pages/DigestPage.tsx new file mode 100644 index 0000000..6ea6073 --- /dev/null +++ b/web/src/pages/DigestPage.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { previewDigest, sendDigest } from '../api/client'; +import PageHeader from '../components/PageHeader'; +import ErrorState from '../components/ErrorState'; + +export default function DigestPage() { + const [showConfirm, setShowConfirm] = useState(false); + + const { data: html, isLoading, error, refetch } = useQuery({ + queryKey: ['digest-preview'], + queryFn: previewDigest, + retry: false, + }); + + const sendMutation = useMutation({ + mutationFn: sendDigest, + onSuccess: () => setShowConfirm(false), + }); + + return ( + <> + setShowConfirm(true)} + disabled={!html || sendMutation.isPending} + className="btn btn-primary text-xs disabled:opacity-50" + > + Send Digest Now + + } + /> + +
+ {sendMutation.isSuccess && ( +
+ Digest sent successfully. +
+ )} + {sendMutation.isError && ( +
+ Failed to send digest: {(sendMutation.error as Error).message} +
+ )} + + {isLoading && ( +
+
Loading digest preview...
+
+ )} + + {error && ( + refetch()} + /> + )} + + {html && ( +
+
+ Email Preview + +
+