feat(m28+m29+m30): ACME ARI, email digest, and Helm chart

M28: ACME Renewal Information (RFC 9702) — CA-directed renewal timing
with cert ID computation, directory endpoint discovery, graceful
degradation for non-ARI CAs. 19 tests.

M29: Email notifier wiring + scheduled certificate digest — SMTP
connector bridged to service layer via NotifierAdapter, DigestService
with HTML email template, 7th scheduler loop (24h), digest preview/send
API endpoints and GUI card. 21 tests.

M30: Production-ready Helm chart — server Deployment, PostgreSQL
StatefulSet, agent DaemonSet, ConfigMaps, Secrets, Ingress, security
contexts, health probes, example values for dev/prod/ACME scenarios.

Also: OpenAPI spec updates, MCP tool additions, CI helm-lint job,
documentation updates across 5 doc files and README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shankar
2026-03-28 21:18:35 -04:00
parent 7cbcf69d72
commit 3f1f94f56b
61 changed files with 6106 additions and 27 deletions
+40
View File
@@ -76,6 +76,8 @@ import {
updateNetworkScanTarget,
deleteNetworkScanTarget,
triggerNetworkScan,
previewDigest,
sendDigest,
} from './client';
// Mock global fetch
@@ -966,4 +968,42 @@ describe('API Client', () => {
expect(result.data[0].verified_at).toBeUndefined();
});
});
// ─── Digest ─────────────────────────────
describe('Digest', () => {
it('previewDigest fetches HTML preview', async () => {
const html = '<html><body>Digest Preview</body></html>';
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(html),
} as Response)
);
const result = await previewDigest();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/digest/preview');
expect(result).toBe(html);
});
it('previewDigest throws on error', async () => {
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: false,
status: 503,
text: () => Promise.resolve('not configured'),
} as Response)
);
await expect(previewDigest()).rejects.toThrow('Digest preview failed: 503');
});
it('sendDigest sends POST request', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'digest sent' }));
const result = await sendDigest();
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/digest/send');
expect(init.method).toBe('POST');
expect(result.message).toBe('digest sent');
});
});
});
+14
View File
@@ -351,5 +351,19 @@ export const getIssuanceRate = (days = 30) =>
export const getMetrics = () =>
fetchJSON<MetricsResponse>(`${BASE}/metrics`);
// Digest
export const previewDigest = () => {
const headers: Record<string, string> = {};
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
return fetch(`${BASE}/digest/preview`, { headers })
.then(r => {
if (!r.ok) throw new Error(`Digest preview failed: ${r.status}`);
return r.text();
});
};
export const sendDigest = () =>
fetchJSON<{ message: string }>(`${BASE}/digest/send`, { method: 'POST' });
// Health
export const getHealth = () => fetchJSON<{ status: string }>('/health');
+89 -2
View File
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import {
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
@@ -7,7 +8,7 @@ import {
import {
getCertificates, getAgents, getJobs, getNotifications, getHealth,
getDashboardSummary, getCertificatesByStatus, getExpirationTimeline,
getJobTrends, getIssuanceRate,
getJobTrends, getIssuanceRate, previewDigest, sendDigest,
} from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
@@ -75,6 +76,89 @@ const CustomTooltip = ({ active, payload, label }: any) => {
);
};
function DigestCard() {
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
const [showPreview, setShowPreview] = useState(false);
const previewMutation = useMutation({
mutationFn: previewDigest,
onSuccess: (html) => {
setPreviewHtml(html);
setShowPreview(true);
},
});
const sendMutation = useMutation({ mutationFn: sendDigest });
return (
<>
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-ink-muted">Certificate Digest</h3>
<p className="text-xs text-ink-faint mt-0.5">Send an email summary of certificate status to configured recipients</p>
</div>
<div className="flex gap-2">
<button
onClick={() => previewMutation.mutate()}
disabled={previewMutation.isPending}
className="btn btn-secondary text-xs"
>
{previewMutation.isPending ? 'Loading...' : 'Preview'}
</button>
<button
onClick={() => sendMutation.mutate()}
disabled={sendMutation.isPending}
className="btn btn-primary text-xs"
>
{sendMutation.isPending ? 'Sending...' : 'Send Now'}
</button>
</div>
</div>
{sendMutation.isSuccess && (
<div className="mt-3 text-xs text-emerald-600 bg-emerald-50 border border-emerald-200 rounded px-3 py-2">
Digest sent successfully.
</div>
)}
{sendMutation.isError && (
<div className="mt-3 text-xs text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
Failed to send digest. Check SMTP configuration.
</div>
)}
{previewMutation.isError && (
<div className="mt-3 text-xs text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
Digest not configured. Set CERTCTL_DIGEST_ENABLED=true and configure SMTP.
</div>
)}
</div>
{/* Preview Modal */}
{showPreview && previewHtml && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowPreview(false)}>
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200">
<h3 className="text-sm font-semibold text-gray-700">Digest Email Preview</h3>
<button onClick={() => setShowPreview(false)} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="overflow-y-auto max-h-[calc(80vh-52px)]">
<iframe
srcDoc={previewHtml}
title="Digest Preview"
className="w-full h-[600px] border-0"
sandbox=""
/>
</div>
</div>
</div>
)}
</>
);
}
export default function DashboardPage() {
const navigate = useNavigate();
@@ -293,6 +377,9 @@ export default function DashboardPage() {
</div>
</div>
{/* Certificate Digest */}
<DigestCard />
{/* Pending Jobs Banner */}
{pendingJobs > 0 && (
<div className="bg-brand-50 border border-brand-200 rounded px-5 py-4 flex items-center justify-between">