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:
shankar0123
2026-03-30 14:10:58 -04:00
parent 11173a74c6
commit a6515b4323
13 changed files with 1047 additions and 6 deletions
+86 -3
View File
@@ -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.
+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');
+2
View File
@@ -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' },
];
+10
View File
@@ -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(
<Route path="agents/:id" element={<AgentDetailPage />} />
<Route path="fleet" element={<AgentFleetPage />} />
<Route path="jobs" element={<JobsPage />} />
<Route path="jobs/:id" element={<JobDetailPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="policies" element={<PoliciesPage />} />
<Route path="profiles" element={<ProfilesPage />} />
<Route path="issuers" element={<IssuersPage />} />
<Route path="issuers/:id" element={<IssuerDetailPage />} />
<Route path="targets" element={<TargetsPage />} />
<Route path="targets/:id" element={<TargetDetailPage />} />
<Route path="owners" element={<OwnersPage />} />
<Route path="teams" element={<TeamsPage />} />
<Route path="agent-groups" element={<AgentGroupsPage />} />
@@ -65,6 +73,8 @@ createRoot(document.getElementById('root')!).render(
<Route path="short-lived" element={<ShortLivedPage />} />
<Route path="discovery" element={<DiscoveryPage />} />
<Route path="network-scans" element={<NetworkScanPage />} />
<Route path="digest" element={<DigestPage />} />
<Route path="observability" element={<ObservabilityPage />} />
</Route>
</Routes>
</BrowserRouter>
+110
View File
@@ -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 (
<>
<PageHeader
title="Certificate Digest"
subtitle="Preview and send the scheduled certificate digest email"
action={
<button
onClick={() => setShowConfirm(true)}
disabled={!html || sendMutation.isPending}
className="btn btn-primary text-xs disabled:opacity-50"
>
Send Digest Now
</button>
}
/>
<div className="flex-1 overflow-y-auto px-6 py-4">
{sendMutation.isSuccess && (
<div className="mb-4 px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg text-sm text-emerald-700">
Digest sent successfully.
</div>
)}
{sendMutation.isError && (
<div className="mb-4 px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
Failed to send digest: {(sendMutation.error as Error).message}
</div>
)}
{isLoading && (
<div className="flex items-center justify-center py-20">
<div className="text-sm text-ink-muted">Loading digest preview...</div>
</div>
)}
{error && (
<ErrorState
error={error as Error}
onRetry={() => refetch()}
/>
)}
{html && (
<div className="bg-white border border-surface-border rounded-lg shadow-sm overflow-hidden">
<div className="px-4 py-2.5 bg-surface border-b border-surface-border flex items-center justify-between">
<span className="text-xs text-ink-muted font-medium">Email Preview</span>
<button
onClick={() => refetch()}
className="text-xs text-brand-400 hover:text-brand-500"
>
Refresh
</button>
</div>
<iframe
srcDoc={html}
title="Digest Preview"
className="w-full border-0"
style={{ minHeight: '600px' }}
sandbox="allow-same-origin"
/>
</div>
)}
</div>
{showConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowConfirm(false)}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4" onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-surface-border">
<h3 className="text-lg font-semibold text-ink">Send Digest</h3>
<p className="text-sm text-ink-muted mt-1">
This will send the certificate digest email to all configured recipients.
</p>
</div>
<div className="px-6 py-3 border-t border-surface-border flex justify-end gap-2">
<button onClick={() => setShowConfirm(false)} className="px-4 py-2 text-sm text-ink-muted hover:text-ink rounded border border-surface-border">
Cancel
</button>
<button
onClick={() => sendMutation.mutate()}
disabled={sendMutation.isPending}
className="px-4 py-2 text-sm text-white bg-brand-500 hover:bg-brand-600 rounded disabled:opacity-50"
>
{sendMutation.isPending ? 'Sending...' : 'Send'}
</button>
</div>
</div>
</div>
)}
</>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useParams } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import { getIssuer, testIssuerConnection, getCertificates } from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils';
import type { Certificate } from '../api/types';
const typeLabels: Record<string, string> = {
local_ca: 'Local CA',
acme: 'ACME (Let\'s Encrypt)',
step_ca: 'step-ca',
openssl: 'OpenSSL / Custom',
vault: 'Vault PKI',
};
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between py-2 border-b border-surface-border/50">
<span className="text-sm text-ink-muted">{label}</span>
<span className="text-sm text-ink">{value}</span>
</div>
);
}
export default function IssuerDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: issuer, isLoading, error, refetch } = useQuery({
queryKey: ['issuer', id],
queryFn: () => getIssuer(id!),
enabled: !!id,
});
const { data: certsData } = useQuery({
queryKey: ['certificates', { issuer_id: id }],
queryFn: () => getCertificates({ issuer_id: id! }),
enabled: !!id,
});
const testMutation = useMutation({
mutationFn: () => testIssuerConnection(id!),
});
if (error) {
return (
<>
<PageHeader title="Issuer Details" />
<ErrorState error={error as Error} onRetry={() => refetch()} />
</>
);
}
if (isLoading || !issuer) {
return (
<>
<PageHeader title="Issuer Details" />
<div className="flex items-center justify-center py-20">
<div className="text-sm text-ink-muted">Loading issuer...</div>
</div>
</>
);
}
// Redact sensitive config fields
const safeConfig = issuer.config ? Object.fromEntries(
Object.entries(issuer.config).map(([k, v]) => {
const sensitive = ['password', 'secret', 'token', 'key', 'hmac', 'private'].some(s => k.toLowerCase().includes(s));
return [k, sensitive ? '********' : v];
})
) : {};
const certColumns: Column<Certificate>[] = [
{
key: 'name',
label: 'Certificate',
render: (c) => (
<div>
<div className="font-medium text-ink text-sm">{c.common_name}</div>
<div className="text-xs text-ink-faint font-mono">{c.id}</div>
</div>
),
},
{ key: 'status', label: 'Status', render: (c) => <StatusBadge status={c.status} /> },
{ key: 'expires', label: 'Expires', render: (c) => <span className="text-xs text-ink-muted">{formatDateTime(c.expires_at)}</span> },
];
return (
<>
<PageHeader
title={issuer.name}
subtitle={typeLabels[issuer.type] || issuer.type}
action={
<button
onClick={() => testMutation.mutate()}
disabled={testMutation.isPending}
className="btn btn-primary text-xs disabled:opacity-50"
>
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
</button>
}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{testMutation.isSuccess && (
<div className="px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg text-sm text-emerald-700">
Connection test passed.
</div>
)}
{testMutation.isError && (
<div className="px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
Connection test failed: {(testMutation.error as Error).message}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Issuer info */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Issuer Information</h3>
<InfoRow label="ID" value={<span className="font-mono text-xs">{issuer.id}</span>} />
<InfoRow label="Name" value={issuer.name} />
<InfoRow label="Type" value={typeLabels[issuer.type] || issuer.type} />
<InfoRow label="Status" value={<StatusBadge status={issuer.status} />} />
<InfoRow label="Created" value={formatDateTime(issuer.created_at)} />
</div>
{/* Config (redacted) */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Configuration</h3>
{Object.keys(safeConfig).length > 0 ? (
<div className="space-y-0">
{Object.entries(safeConfig).map(([key, val]) => (
<InfoRow key={key} label={key} value={
<span className="font-mono text-xs truncate max-w-xs inline-block">{String(val)}</span>
} />
))}
</div>
) : (
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
)}
</div>
</div>
{/* Issued certificates */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">
Issued Certificates {certsData ? `(${certsData.total})` : ''}
</h3>
<DataTable
columns={certColumns}
data={certsData?.data || []}
isLoading={!certsData}
emptyMessage="No certificates issued by this issuer"
/>
</div>
</div>
</>
);
}
+4 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client';
import PageHeader from '../components/PageHeader';
@@ -120,7 +121,9 @@ export default function IssuersPage() {
label: 'Issuer',
render: (i) => (
<div>
<div className="font-medium text-ink">{i.name}</div>
<Link to={`/issuers/${i.id}`} className="font-medium text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
{i.name}
</Link>
<div className="text-xs text-ink-faint font-mono">{i.id}</div>
</div>
),
+183
View File
@@ -0,0 +1,183 @@
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getJob, getJobVerification, getAuditEvents } from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
import { formatDateTime, timeAgo } from '../api/utils';
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between py-2 border-b border-surface-border/50">
<span className="text-sm text-ink-muted">{label}</span>
<span className="text-sm text-ink">{value}</span>
</div>
);
}
function VerificationBadge({ status }: { status?: string }) {
if (!status) return <span className="text-xs text-ink-faint"></span>;
const styles: Record<string, string> = {
success: 'bg-emerald-100 text-emerald-700',
failed: 'bg-red-100 text-red-700',
pending: 'bg-yellow-100 text-yellow-700',
skipped: 'bg-gray-100 text-gray-600',
};
const labels: Record<string, string> = {
success: 'Verified',
failed: 'Failed',
pending: 'Pending',
skipped: 'Skipped',
};
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
{labels[status] || status}
</span>
);
}
export default function JobDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: job, isLoading, error, refetch } = useQuery({
queryKey: ['job', id],
queryFn: () => getJob(id!),
enabled: !!id,
refetchInterval: 10000,
});
const { data: verification } = useQuery({
queryKey: ['job-verification', id],
queryFn: () => getJobVerification(id!),
enabled: !!id && job?.type === 'Deployment' && job?.status === 'Completed',
retry: false,
});
const { data: auditData } = useQuery({
queryKey: ['audit', { resource_id: id }],
queryFn: () => getAuditEvents({ resource_id: id!, per_page: '10' }),
enabled: !!id,
});
if (error) {
return (
<>
<PageHeader title="Job Details" />
<ErrorState error={error as Error} onRetry={() => refetch()} />
</>
);
}
if (isLoading || !job) {
return (
<>
<PageHeader title="Job Details" />
<div className="flex items-center justify-center py-20">
<div className="text-sm text-ink-muted">Loading job...</div>
</div>
</>
);
}
return (
<>
<PageHeader
title={`Job ${job.id}`}
subtitle={`${job.type} job`}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Job details */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Job Information</h3>
<InfoRow label="ID" value={<span className="font-mono text-xs">{job.id}</span>} />
<InfoRow label="Type" value={job.type} />
<InfoRow label="Status" value={<StatusBadge status={job.status} />} />
<InfoRow label="Certificate" value={
<Link to={`/certificates/${job.certificate_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{job.certificate_id}
</Link>
} />
{job.agent_id && (
<InfoRow label="Agent" value={
<Link to={`/agents/${job.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{job.agent_id}
</Link>
} />
)}
{job.target_id && (
<InfoRow label="Target" value={
<Link to={`/targets/${job.target_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{job.target_id}
</Link>
} />
)}
<InfoRow label="Attempts" value={`${job.attempts} / ${job.max_attempts}`} />
{job.error_message && (
<InfoRow label="Error" value={
<span className="text-red-600 text-xs">{job.error_message}</span>
} />
)}
</div>
{/* Timeline */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Timeline</h3>
<InfoRow label="Created" value={formatDateTime(job.created_at)} />
<InfoRow label="Scheduled" value={formatDateTime(job.scheduled_at)} />
{job.started_at && <InfoRow label="Started" value={formatDateTime(job.started_at)} />}
{job.completed_at && <InfoRow label="Completed" value={formatDateTime(job.completed_at)} />}
{job.completed_at && job.started_at && (
<InfoRow label="Duration" value={timeAgo(job.started_at)} />
)}
</div>
</div>
{/* Verification section — only for deployment jobs */}
{job.type === 'Deployment' && (
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Post-Deployment Verification</h3>
{job.verification_status ? (
<div className="space-y-0">
<InfoRow label="Status" value={<VerificationBadge status={job.verification_status} />} />
{job.verified_at && <InfoRow label="Verified At" value={formatDateTime(job.verified_at)} />}
{job.verification_fingerprint && (
<InfoRow label="Fingerprint" value={<span className="font-mono text-xs">{job.verification_fingerprint}</span>} />
)}
{job.verification_error && (
<InfoRow label="Error" value={<span className="text-red-600 text-xs">{job.verification_error}</span>} />
)}
{verification && verification.verified && (
<InfoRow label="Expected Fingerprint" value={<span className="font-mono text-xs">{verification.expected_fingerprint}</span>} />
)}
</div>
) : (
<div className="text-sm text-ink-faint py-4 text-center">
{job.status === 'Completed' ? 'No verification data recorded' : 'Verification runs after deployment completes'}
</div>
)}
</div>
)}
{/* Audit trail */}
{auditData && auditData.data.length > 0 && (
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Related Audit Events</h3>
<div className="space-y-2">
{auditData.data.map(event => (
<div key={event.id} className="flex items-center justify-between py-2 border-b border-surface-border/50 last:border-0">
<div>
<span className="text-sm text-ink">{event.action}</span>
<span className="text-xs text-ink-faint ml-2">by {event.actor}</span>
</div>
<span className="text-xs text-ink-muted">{formatDateTime(event.timestamp)}</span>
</div>
))}
</div>
</div>
)}
</div>
</>
);
}
+41 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getJobs, cancelJob, approveRenewal, rejectRenewal } from '../api/client';
import PageHeader from '../components/PageHeader';
@@ -47,6 +48,27 @@ function RejectModal({ job, onClose, onReject }: { job: Job; onClose: () => void
);
}
function VerificationBadge({ status }: { status?: string }) {
if (!status) return <span className="text-xs text-ink-faint"></span>;
const styles: Record<string, string> = {
success: 'bg-emerald-100 text-emerald-700',
failed: 'bg-red-100 text-red-700',
pending: 'bg-yellow-100 text-yellow-700',
skipped: 'bg-gray-100 text-gray-600',
};
const labels: Record<string, string> = {
success: 'Verified',
failed: 'Failed',
pending: 'Pending',
skipped: 'Skipped',
};
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
{labels[status] || status}
</span>
);
}
export default function JobsPage() {
const [statusFilter, setStatusFilter] = useState('');
const [typeFilter, setTypeFilter] = useState('');
@@ -89,13 +111,26 @@ export default function JobsPage() {
label: 'Job',
render: (j) => (
<div>
<div className="font-mono text-xs text-ink">{j.id}</div>
<Link to={`/jobs/${j.id}`} className="font-mono text-xs text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
{j.id}
</Link>
<div className="text-xs text-ink-faint">{j.type}</div>
</div>
),
},
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
{ key: 'cert', label: 'Certificate', render: (j) => <span className="text-xs text-ink-muted font-mono">{j.certificate_id}</span> },
{
key: 'agent',
label: 'Agent',
render: (j) => j.agent_id ? (
<Link to={`/agents/${j.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono" onClick={(e) => e.stopPropagation()}>
{j.agent_id}
</Link>
) : (
<span className="text-xs text-ink-faint"></span>
),
},
{
key: 'attempts',
label: 'Attempts',
@@ -103,6 +138,11 @@ export default function JobsPage() {
},
{ key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.scheduled_at)}</span> },
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
{
key: 'verification',
label: 'Verification',
render: (j) => j.type === 'Deployment' ? <VerificationBadge status={j.verification_status} /> : <span className="text-xs text-ink-faint"></span>,
},
{
key: 'actions',
label: '',
+149
View File
@@ -0,0 +1,149 @@
import { useQuery } from '@tanstack/react-query';
import { getMetrics, getPrometheusMetrics, getHealth } from '../api/client';
import PageHeader from '../components/PageHeader';
import ErrorState from '../components/ErrorState';
function MetricCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
return (
<div className="bg-surface border border-surface-border rounded p-4 shadow-sm">
<div className="text-xs text-ink-muted mb-1">{label}</div>
<div className="text-2xl font-bold text-ink">{value}</div>
{sub && <div className="text-xs text-ink-faint mt-1">{sub}</div>}
</div>
);
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export default function ObservabilityPage() {
const { data: metrics, isLoading, error, refetch } = useQuery({
queryKey: ['metrics'],
queryFn: getMetrics,
refetchInterval: 15000,
});
const { data: health } = useQuery({
queryKey: ['health'],
queryFn: getHealth,
refetchInterval: 15000,
});
const { data: promText } = useQuery({
queryKey: ['prometheus-metrics'],
queryFn: getPrometheusMetrics,
refetchInterval: 30000,
retry: false,
});
if (error) {
return (
<>
<PageHeader title="Observability" />
<ErrorState error={error as Error} onRetry={() => refetch()} />
</>
);
}
return (
<>
<PageHeader
title="Observability"
subtitle={health ? `Server: ${health.status}` : undefined}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{/* Health status */}
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${health?.status === 'ok' ? 'bg-emerald-500' : 'bg-red-500'}`} />
<span className="text-sm text-ink font-medium">
Server {health?.status === 'ok' ? 'Healthy' : 'Unhealthy'}
</span>
{metrics && (
<span className="text-xs text-ink-faint ml-auto">
Uptime: {formatUptime(metrics.uptime.uptime_seconds)} | Started: {new Date(metrics.uptime.server_started).toLocaleString()}
</span>
)}
</div>
{/* Gauge metrics */}
{isLoading && (
<div className="text-sm text-ink-muted py-10 text-center">Loading metrics...</div>
)}
{metrics && (
<>
<div>
<h3 className="text-sm font-semibold text-ink-muted mb-3">Certificate Gauges</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<MetricCard label="Total" value={metrics.gauge.certificate_total} />
<MetricCard label="Active" value={metrics.gauge.certificate_active} />
<MetricCard label="Expiring Soon" value={metrics.gauge.certificate_expiring_soon} />
<MetricCard label="Expired" value={metrics.gauge.certificate_expired} />
<MetricCard label="Revoked" value={metrics.gauge.certificate_revoked} />
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-ink-muted mb-3">Agent & Job Gauges</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<MetricCard label="Total Agents" value={metrics.gauge.agent_total} />
<MetricCard label="Online Agents" value={metrics.gauge.agent_online} />
<MetricCard label="Pending Jobs" value={metrics.gauge.job_pending} />
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-ink-muted mb-3">Counters</h3>
<div className="grid grid-cols-2 md:grid-cols-2 gap-3">
<MetricCard label="Jobs Completed (total)" value={metrics.counter.job_completed_total} />
<MetricCard label="Jobs Failed (total)" value={metrics.counter.job_failed_total} />
</div>
</div>
</>
)}
{/* Prometheus config */}
<div>
<h3 className="text-sm font-semibold text-ink-muted mb-3">Prometheus Integration</h3>
<div className="bg-surface border border-surface-border rounded p-4 shadow-sm">
<p className="text-sm text-ink mb-3">
Add this scrape target to your <code className="text-xs bg-surface-muted px-1 py-0.5 rounded">prometheus.yml</code>:
</p>
<pre className="bg-ink text-white rounded p-4 text-xs overflow-x-auto font-mono">
{`scrape_configs:
- job_name: 'certctl'
metrics_path: '/api/v1/metrics/prometheus'
scheme: 'https'
bearer_token: '<YOUR_API_KEY>'
static_configs:
- targets: ['<CERTCTL_HOST>:443']`}
</pre>
</div>
</div>
{/* Live Prometheus output */}
{promText && (
<div>
<h3 className="text-sm font-semibold text-ink-muted mb-3">Live Prometheus Output</h3>
<div className="bg-surface border border-surface-border rounded shadow-sm">
<div className="px-4 py-2 border-b border-surface-border flex items-center justify-between">
<span className="text-xs text-ink-faint font-mono">GET /api/v1/metrics/prometheus</span>
<span className="text-xs text-ink-faint">text/plain</span>
</div>
<pre className="p-4 text-xs text-ink-muted overflow-x-auto font-mono max-h-96 overflow-y-auto whitespace-pre">
{promText}
</pre>
</div>
</div>
)}
</div>
</>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getTarget, getJobs } from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils';
import type { Job } from '../api/types';
const typeLabels: Record<string, string> = {
nginx: 'NGINX',
apache: 'Apache',
haproxy: 'HAProxy',
traefik: 'Traefik',
caddy: 'Caddy',
f5_bigip: 'F5 BIG-IP',
iis: 'IIS',
};
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between py-2 border-b border-surface-border/50">
<span className="text-sm text-ink-muted">{label}</span>
<span className="text-sm text-ink">{value}</span>
</div>
);
}
export default function TargetDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: target, isLoading, error, refetch } = useQuery({
queryKey: ['target', id],
queryFn: () => getTarget(id!),
enabled: !!id,
});
// Deployment jobs for this target
const { data: jobsData } = useQuery({
queryKey: ['jobs', { target_id: id, type: 'Deployment' }],
queryFn: () => getJobs({ target_id: id! }),
enabled: !!id,
});
if (error) {
return (
<>
<PageHeader title="Target Details" />
<ErrorState error={error as Error} onRetry={() => refetch()} />
</>
);
}
if (isLoading || !target) {
return (
<>
<PageHeader title="Target Details" />
<div className="flex items-center justify-center py-20">
<div className="text-sm text-ink-muted">Loading target...</div>
</div>
</>
);
}
const jobColumns: Column<Job>[] = [
{
key: 'id',
label: 'Job',
render: (j) => (
<Link to={`/jobs/${j.id}`} className="font-mono text-xs text-accent hover:text-accent-bright">
{j.id}
</Link>
),
},
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
{ key: 'cert', label: 'Certificate', render: (j) => (
<Link to={`/certificates/${j.certificate_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{j.certificate_id}
</Link>
)},
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
{
key: 'verification',
label: 'Verification',
render: (j) => {
if (!j.verification_status) return <span className="text-xs text-ink-faint"></span>;
const styles: Record<string, string> = {
success: 'bg-emerald-100 text-emerald-700',
failed: 'bg-red-100 text-red-700',
pending: 'bg-yellow-100 text-yellow-700',
skipped: 'bg-gray-100 text-gray-600',
};
const labels: Record<string, string> = {
success: 'Verified',
failed: 'Failed',
pending: 'Pending',
skipped: 'Skipped',
};
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[j.verification_status] || 'bg-gray-100 text-gray-600'}`}>
{labels[j.verification_status] || j.verification_status}
</span>
);
},
},
];
return (
<>
<PageHeader
title={target.name}
subtitle={typeLabels[target.type] || target.type}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Target info */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Target Information</h3>
<InfoRow label="ID" value={<span className="font-mono text-xs">{target.id}</span>} />
<InfoRow label="Name" value={target.name} />
<InfoRow label="Type" value={typeLabels[target.type] || target.type} />
<InfoRow label="Hostname" value={target.hostname || '—'} />
<InfoRow label="Status" value={<StatusBadge status={target.status} />} />
{target.agent_id && (
<InfoRow label="Agent" value={
<Link to={`/agents/${target.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{target.agent_id}
</Link>
} />
)}
<InfoRow label="Created" value={formatDateTime(target.created_at)} />
</div>
{/* Config */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Configuration</h3>
{target.config && Object.keys(target.config).length > 0 ? (
<div className="space-y-0">
{Object.entries(target.config).map(([key, val]) => (
<InfoRow key={key} label={key.replace(/_/g, ' ')} value={
<span className="font-mono text-xs truncate max-w-xs inline-block">{String(val)}</span>
} />
))}
</div>
) : (
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
)}
</div>
</div>
{/* Deployment history */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">
Deployment History {jobsData ? `(${jobsData.total})` : ''}
</h3>
<DataTable
columns={jobColumns}
data={jobsData?.data || []}
isLoading={!jobsData}
emptyMessage="No deployments to this target"
/>
</div>
</div>
</>
);
}
+4 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getTargets, createTarget, deleteTarget } from '../api/client';
import PageHeader from '../components/PageHeader';
@@ -266,7 +267,9 @@ export default function TargetsPage() {
label: 'Target',
render: (t) => (
<div>
<div className="font-medium text-ink">{t.name}</div>
<Link to={`/targets/${t.id}`} className="font-medium text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
{t.name}
</Link>
<div className="text-xs text-ink-faint font-mono">{t.id}</div>
</div>
),