Private
Public Access
1
0

feat(M8+M9): CA certificates page + Reporting CSV/PDF with charts

This commit is contained in:
2026-04-23 18:56:11 +00:00
parent a5d52ffab0
commit 7b7fac315e
22 changed files with 3210 additions and 70 deletions

View File

@ -12,6 +12,8 @@ import DashboardPage from './pages/DashboardPage'
import PatchDeploymentPage from './pages/PatchDeploymentPage'
import JobsPage from './pages/JobsPage'
import MaintenanceWindowsPage from './pages/MaintenanceWindowsPage'
import CertificatesPage from './pages/CertificatesPage'
import ReportsPage from './pages/ReportsPage'
// Placeholder pages — implemented in later milestones
const PlaceholderPage = ({ title }: { title: string }) => (
@ -53,8 +55,10 @@ function App() {
<Route path="/maintenance" element={<RequireAuth><MaintenanceWindowsPage /></RequireAuth>} />
{/* Placeholder — later milestones */}
<Route path="/reports" element={<RequireAuth><PlaceholderPage title="Reports" /></RequireAuth>} />
<Route path="/certificates" element={<RequireAuth><PlaceholderPage title="Certificates" /></RequireAuth>} />
{/* Protected — M9 */}
<Route path="/reports" element={<RequireAuth><ReportsPage /></RequireAuth>} />
{/* Protected — M8 */}
<Route path="/certificates" element={<RequireAuth><CertificatesPage /></RequireAuth>} />
<Route path="/settings" element={<RequireAuth><PlaceholderPage title="Settings" /></RequireAuth>} />
<Route path="*" element={<PlaceholderPage title="404 Not Found" />} />

View File

@ -6,6 +6,8 @@ import type {
CreateJobRequest,
CreateMaintenanceWindowRequest,
UpdateMaintenanceWindowRequest,
Certificate,
IssuedCert,
} from '../types'
const BASE_URL = '/api/v1'
@ -147,3 +149,51 @@ export const wsApi = {
createTicket: (): Promise<{ ticket: string }> =>
apiClient.post<{ ticket: string }>('/ws/ticket').then((r) => r.data),
}
// ── Certificates API (M8) ────────────────────────────────────────────────────
export const certsApi = {
// List all certs, optional filters
list: (params?: { host_id?: string; status?: string }) =>
apiClient.get<Certificate[]>('/certificates', { params }),
// Download root CA cert as blob
downloadRootCa: () =>
apiClient.get('/ca/root.crt', { responseType: 'blob' }),
// Issue client cert for a host — returns IssuedCert (key_pem only shown once!)
issue: (hostId: string, hostname: string) =>
apiClient.post<IssuedCert>(`/hosts/${hostId}/certificates`, { hostname }),
// Renew a cert
renew: (certId: string) =>
apiClient.post<IssuedCert>(`/certificates/${certId}/renew`),
// Revoke a cert
revoke: (certId: string) =>
apiClient.delete(`/certificates/${certId}`),
// Download host client cert as blob
downloadClientCert: (hostId: string) =>
apiClient.get(`/hosts/${hostId}/client.crt`, { responseType: 'blob' }),
}
// ── Reports API (M9) ─────────────────────────────────────────────────────────
export type ReportType = 'compliance' | 'patch-history' | 'vulnerability' | 'audit'
export type ReportFormat = 'csv' | 'pdf'
export const reportsApi = {
download: (
reportType: ReportType,
format: ReportFormat,
params?: {
from?: string // ISO 8601
to?: string // ISO 8601
group_id?: string // UUID
}
) =>
apiClient.get(`/reports/${reportType}`, {
params: { format, ...params },
responseType: 'blob',
timeout: 120_000, // reports can take a while
}),
}

View File

@ -0,0 +1,483 @@
import { useCallback, useEffect, useState } from 'react'
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
type SelectChangeEvent,
Snackbar,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Toolbar,
Tooltip,
Typography,
} from '@mui/material'
import {
ContentCopy as CopyIcon,
Download as DownloadIcon,
Refresh as RefreshIcon,
Security as SecurityIcon,
} from '@mui/icons-material'
import { certsApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import type { Certificate, CertStatus, IssuedCert } from '../types'
// ── Helpers ───────────────────────────────────────────────────────────────────
function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
function isExpiringSoon(iso: string): boolean {
return new Date(iso).getTime() - Date.now() < 30 * 24 * 60 * 60 * 1000
}
function statusChip(status: CertStatus) {
const map: Record<CertStatus, { label: string; color: 'success' | 'error' | 'warning' }> = {
active: { label: 'Active', color: 'success' },
revoked: { label: 'Revoked', color: 'error' },
expired: { label: 'Expired', color: 'warning' },
}
const { label, color } = map[status]
return <Chip label={label} color={color} size="small" />
}
// ── Issue Dialog ──────────────────────────────────────────────────────────────
interface IssueDialogProps {
open: boolean
onClose: () => void
onIssued: (cert: IssuedCert) => void
}
function IssueDialog({ open, onClose, onIssued }: IssueDialogProps) {
const [hostId, setHostId] = useState('')
const [hostname, setHostname] = useState('')
const [saving, setSaving] = useState(false)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
if (open) { setHostId(''); setHostname(''); setErr(null) }
}, [open])
const handleSubmit = async () => {
if (!hostId.trim()) { setErr('Host ID is required'); return }
if (!hostname.trim()) { setErr('Hostname is required'); return }
setSaving(true); setErr(null)
try {
const res = await certsApi.issue(hostId.trim(), hostname.trim())
onIssued(res.data)
onClose()
} catch (e: unknown) {
const msg = (e as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Failed to issue certificate'
setErr(msg)
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Issue Client Certificate</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
{err && <Alert severity="error">{err}</Alert>}
<TextField
label="Host ID (UUID)"
value={hostId}
onChange={(e) => setHostId(e.target.value)}
required
fullWidth
placeholder="e.g. 3fa85f64-5717-4562-b3fc-2c963f66afa6"
/>
<TextField
label="Hostname"
value={hostname}
onChange={(e) => setHostname(e.target.value)}
required
fullWidth
placeholder="e.g. web-01.example.com"
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={saving}>Cancel</Button>
<Button variant="contained" onClick={handleSubmit} disabled={saving}>
{saving ? <CircularProgress size={20} /> : 'Issue'}
</Button>
</DialogActions>
</Dialog>
)
}
// ── One-Time Key Display Dialog ───────────────────────────────────────────────
interface KeyDisplayDialogProps {
open: boolean
cert: IssuedCert | null
onClose: () => void
}
function KeyDisplayDialog({ open, cert, onClose }: KeyDisplayDialogProps) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
if (!cert?.key_pem) return
await navigator.clipboard.writeText(cert.key_pem)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Certificate Issued Save Your Private Key</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
<Alert severity="warning">
<strong>This private key will NOT be shown again.</strong> Copy and store it securely
before closing this dialog.
</Alert>
{cert && (
<Box>
<Typography variant="caption" color="text.secondary">
Serial: {cert.serial_number} &nbsp;|&nbsp; Expires: {fmtDate(cert.expires_at)}
</Typography>
<Box
component="pre"
sx={{
mt: 1,
p: 2,
bgcolor: 'grey.100',
borderRadius: 1,
fontSize: 12,
overflow: 'auto',
maxHeight: 320,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{cert.key_pem}
</Box>
</Box>
)}
</DialogContent>
<DialogActions>
<Tooltip title={copied ? 'Copied!' : 'Copy private key to clipboard'}>
<Button startIcon={<CopyIcon />} onClick={handleCopy} variant="outlined">
{copied ? 'Copied!' : 'Copy Key'}
</Button>
</Tooltip>
<Button variant="contained" onClick={onClose}>I Have Saved the Key</Button>
</DialogActions>
</Dialog>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export default function CertificatesPage() {
const user = useAuthStore((s) => s.user)
const isAdmin = user?.role === 'admin'
const [certs, setCerts] = useState<Certificate[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Filters
const [statusFilter, setStatusFilter] = useState<string>('all')
const [hostFilter, setHostFilter] = useState<string>('')
// Dialogs
const [issueOpen, setIssueOpen] = useState(false)
const [issuedCert, setIssuedCert] = useState<IssuedCert | null>(null)
const [keyDialogOpen, setKeyDialogOpen] = useState(false)
// Snackbar
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
open: false, message: '', severity: 'success',
})
const showSnack = (message: string, severity: 'success' | 'error') =>
setSnackbar({ open: true, message, severity })
// ── Load certs ──────────────────────────────────────────────────────────────
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params: { status?: string; host_id?: string } = {}
if (statusFilter !== 'all') params.status = statusFilter
if (hostFilter.trim()) params.host_id = hostFilter.trim()
const res = await certsApi.list(params)
setCerts(res.data)
} catch {
setError('Failed to load certificates')
} finally {
setLoading(false)
}
}, [statusFilter, hostFilter])
useEffect(() => { load() }, [load])
// ── Download Root CA ────────────────────────────────────────────────────────
const handleDownloadRootCa = async () => {
try {
const res = await certsApi.downloadRootCa()
downloadBlob(res.data as Blob, 'ca.crt')
} catch {
showSnack('Failed to download Root CA certificate', 'error')
}
}
// ── Issue cert ──────────────────────────────────────────────────────────────
const handleIssued = (cert: IssuedCert) => {
setIssuedCert(cert)
setKeyDialogOpen(true)
void load()
}
// ── Renew cert ──────────────────────────────────────────────────────────────
const handleRenew = async (certId: string) => {
try {
const res = await certsApi.renew(certId)
setIssuedCert(res.data)
setKeyDialogOpen(true)
void load()
} catch {
showSnack('Failed to renew certificate', 'error')
}
}
// ── Revoke cert ─────────────────────────────────────────────────────────────
const handleRevoke = async (certId: string) => {
if (!window.confirm('Revoke this certificate? This cannot be undone.')) return
try {
await certsApi.revoke(certId)
showSnack('Certificate revoked', 'success')
void load()
} catch {
showSnack('Failed to revoke certificate', 'error')
}
}
// ── Render ──────────────────────────────────────────────────────────────────
return (
<Container maxWidth="xl" sx={{ mt: 3, mb: 6 }}>
{/* Header */}
<Toolbar disableGutters sx={{ mb: 3 }}>
<SecurityIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
Certificate Management
</Typography>
{isAdmin && (
<Button
variant="outlined"
startIcon={<SecurityIcon />}
onClick={() => setIssueOpen(true)}
sx={{ mr: 1 }}
>
Issue Client Certificate
</Button>
)}
<Tooltip title="Download Root CA">
<Button
variant="contained"
startIcon={<DownloadIcon />}
onClick={handleDownloadRootCa}
sx={{ mr: 1 }}
>
Download Root CA
</Button>
</Tooltip>
<Tooltip title="Refresh">
<span>
<IconButton onClick={load} disabled={loading}>
{loading ? <CircularProgress size={20} /> : <RefreshIcon />}
</IconButton>
</span>
</Tooltip>
</Toolbar>
{/* Error */}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{/* Filters */}
<Box display="flex" gap={2} sx={{ mb: 3 }} flexWrap="wrap">
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={statusFilter}
onChange={(e: SelectChangeEvent) => setStatusFilter(e.target.value)}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="active">Active</MenuItem>
<MenuItem value="revoked">Revoked</MenuItem>
<MenuItem value="expired">Expired</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label="Filter by Host ID"
value={hostFilter}
onChange={(e) => setHostFilter(e.target.value)}
placeholder="UUID or partial…"
sx={{ minWidth: 260 }}
/>
</Box>
{/* Table */}
<Paper variant="outlined">
{loading ? (
<Box display="flex" justifyContent="center" py={6}>
<CircularProgress />
</Box>
) : certs.length === 0 ? (
<Box p={4}>
<Alert severity="info">No certificates found.</Alert>
</Box>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Common Name</TableCell>
<TableCell>Serial Number</TableCell>
<TableCell>Status</TableCell>
<TableCell>Issued At</TableCell>
<TableCell>Expires At</TableCell>
<TableCell>Host</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{certs.map((cert) => {
const expiring = cert.status === 'active' && isExpiringSoon(cert.expires_at)
return (
<TableRow key={cert.id} hover>
<TableCell>
<Typography variant="body2" fontWeight={500}>
{cert.common_name}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
{cert.serial_number}
</Typography>
</TableCell>
<TableCell>{statusChip(cert.status)}</TableCell>
<TableCell>
<Typography variant="body2">{fmtDate(cert.issued_at)}</Typography>
</TableCell>
<TableCell>
<Typography
variant="body2"
sx={{ color: expiring ? 'error.main' : 'inherit', fontWeight: expiring ? 600 : 400 }}
>
{fmtDate(cert.expires_at)}
{expiring && ' ⚠️'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 11 }}>
{cert.host_id ?? <em>Root CA</em>}
</Typography>
</TableCell>
<TableCell align="right">
{isAdmin && (
<>
<Tooltip title="Renew certificate">
<Button
size="small"
variant="outlined"
sx={{ mr: 1 }}
onClick={() => handleRenew(cert.id)}
>
Renew
</Button>
</Tooltip>
{cert.status === 'active' && (
<Tooltip title="Revoke certificate">
<Button
size="small"
variant="outlined"
color="error"
onClick={() => handleRevoke(cert.id)}
>
Revoke
</Button>
</Tooltip>
)}
</>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</Paper>
{/* Issue Dialog */}
<IssueDialog
open={issueOpen}
onClose={() => setIssueOpen(false)}
onIssued={handleIssued}
/>
{/* One-time key display dialog */}
<KeyDisplayDialog
open={keyDialogOpen}
cert={issuedCert}
onClose={() => setKeyDialogOpen(false)}
/>
{/* Snackbar */}
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
onClose={() => setSnackbar((p) => ({ ...p, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={snackbar.severity}
onClose={() => setSnackbar((p) => ({ ...p, open: false }))}
>
{snackbar.message}
</Alert>
</Snackbar>
</Container>
)
}

View File

@ -21,8 +21,9 @@ import {
BugReport,
RestartAlt,
Refresh as RefreshIcon,
Security as SecurityIcon,
} from '@mui/icons-material'
import { fleetApi } from '../api/client'
import { fleetApi, certsApi } from '../api/client'
import type { FleetStatus } from '../types'
// ── StatCard ─────────────────────────────────────────────────────────────────
@ -84,12 +85,33 @@ export default function DashboardPage() {
return () => clearInterval(t)
}, [load])
// ── Download Root CA ──────────────────────────────────────────────────────
const handleDownloadRootCa = async () => {
try {
const res = await certsApi.downloadRootCa()
const url = URL.createObjectURL(res.data as Blob)
const a = document.createElement('a')
a.href = url
a.download = 'ca.crt'
a.click()
URL.revokeObjectURL(url)
} catch {
// silently ignore — user will see no download; no state change needed
}
}
return (
<Container maxWidth="xl" sx={{ mt: 3 }}>
<Toolbar disableGutters sx={{ mb: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
Dashboard
</Typography>
<Tooltip title="Download Root CA">
<IconButton onClick={handleDownloadRootCa}>
<SecurityIcon />
</IconButton>
</Tooltip>
<Tooltip title="Refresh">
<span>
<IconButton onClick={load} disabled={loading}>

View File

@ -37,8 +37,9 @@ import {
Delete as DeleteIcon,
Edit as EditIcon,
Schedule as ScheduleIcon,
VpnKey as VpnKeyIcon,
} from '@mui/icons-material'
import { apiClient, maintenanceWindowsApi } from '../api/client'
import { apiClient, maintenanceWindowsApi, certsApi } from '../api/client'
import type { MaintenanceWindow, WindowRecurrence } from '../types'
// ── Helpers ───────────────────────────────────────────────────────────────────
@ -295,6 +296,22 @@ export default function HostDetailPage() {
}
}
// ── Download client cert ─────────────────────────────────────────────────
const handleDownloadClientCert = async () => {
if (!id) return
try {
const res = await certsApi.downloadClientCert(id)
const url = URL.createObjectURL(res.data as Blob)
const a = document.createElement('a')
a.href = url
a.download = 'client.crt'
a.click()
URL.revokeObjectURL(url)
} catch {
showSnack('No client certificate found for this host', 'error')
}
}
// ── Render ────────────────────────────────────────────────────────────────
if (loading) return <Box display="flex" justifyContent="center" mt={8}><CircularProgress /></Box>
if (error) return <Container sx={{ mt: 4 }}><Alert severity="error">{error}</Alert></Container>
@ -307,9 +324,16 @@ export default function HostDetailPage() {
{/* ── Host details ─────────────────────────────────────────────────── */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h5" fontWeight={700} mb={2}>
{String(host?.fqdn ?? '')}
</Typography>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="h5" fontWeight={700}>
{String(host?.fqdn ?? '')}
</Typography>
<Tooltip title="Download mTLS Client Certificate">
<IconButton onClick={handleDownloadClientCert} color="primary">
<VpnKeyIcon />
</IconButton>
</Tooltip>
</Box>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
{host && Object.entries(host).map(([k, v]) =>

View File

@ -0,0 +1,273 @@
import { useState } from 'react'
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Container,
Divider,
FormControl,
FormHelperText,
Grid,
InputLabel,
MenuItem,
Paper,
Select,
Snackbar,
TextField,
Toolbar,
Typography,
} from '@mui/material'
import DescriptionIcon from '@mui/icons-material/Description'
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
import { reportsApi } from '../api/client'
import type { ReportType, ReportFormat } from '../types'
// ── Report metadata ───────────────────────────────────────────────────────────
const REPORT_INFO: Record<ReportType, { title: string; description: string; columns: string[] }> = {
compliance: {
title: 'Compliance Report',
description:
'Shows patch compliance percentage per host and group. Includes total packages, pending patches, and last patch timestamp.',
columns: [
'Host',
'FQDN',
'Groups',
'Total Packages',
'Pending Patches',
'Compliance %',
'Last Patched',
'Health Status',
],
},
'patch-history': {
title: 'Patch History',
description:
'Full history of patch job operations across all hosts. Filter by date range to narrow results.',
columns: [
'Job ID',
'Kind',
'Status',
'Host',
'FQDN',
'Package Count',
'Started At',
'Completed At',
'Duration',
'Operator',
],
},
vulnerability: {
title: 'Vulnerability Exposure',
description:
'Lists all known CVEs affecting managed hosts based on cached patch data from agents.',
columns: ['Host', 'FQDN', 'CVE ID', 'Package', 'Severity', 'Available Version', 'Last Seen'],
},
audit: {
title: 'Audit Trail',
description:
'Complete tamper-evident audit log of all system actions. Limited to 10,000 most recent events.',
columns: [
'ID',
'Timestamp',
'Action',
'Actor',
'Target Type',
'Target ID',
'IP Address',
'Request ID',
],
},
}
// ── Default date helpers ──────────────────────────────────────────────────────
const defaultFromDate = () =>
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const defaultToDate = () => new Date().toISOString().split('T')[0]
// ── Component ─────────────────────────────────────────────────────────────────
export default function ReportsPage() {
const [reportType, setReportType] = useState<ReportType>('compliance')
const [fromDate, setFromDate] = useState<string>(defaultFromDate())
const [toDate, setToDate] = useState<string>(defaultToDate())
const [groupId, setGroupId] = useState<string>('')
const [downloading, setDownloading] = useState(false)
const [error, setError] = useState<string | null>(null)
const info = REPORT_INFO[reportType]
const handleDownload = async (format: ReportFormat) => {
setDownloading(true)
setError(null)
try {
const params: Record<string, string> = {}
if (fromDate) params.from = new Date(fromDate).toISOString()
if (toDate) params.to = new Date(toDate + 'T23:59:59Z').toISOString()
if (reportType === 'compliance' && groupId.trim()) params.group_id = groupId.trim()
const res = await reportsApi.download(reportType, format, params)
// Trigger browser download
const url = window.URL.createObjectURL(new Blob([res.data]))
const link = document.createElement('a')
link.href = url
const ext = format === 'pdf' ? 'pdf' : 'csv'
const dateStr = new Date().toISOString().split('T')[0]
link.setAttribute('download', `${reportType}-report-${dateStr}.${ext}`)
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
} catch (err: unknown) {
setError('Failed to generate report. Please try again.')
} finally {
setDownloading(false)
}
}
return (
<Container maxWidth="xl" sx={{ mt: 3 }}>
{/* ── Page header ── */}
<Toolbar disableGutters sx={{ mb: 3 }}>
<Typography variant="h5" fontWeight={700}>
Reports
</Typography>
</Toolbar>
<Grid container spacing={3}>
{/* ── Controls card ── */}
<Grid size={{ xs: 12, md: 4 }}>
<Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>
Report Options
</Typography>
{/* Report Type */}
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel id="report-type-label">Report Type</InputLabel>
<Select
labelId="report-type-label"
value={reportType}
label="Report Type"
onChange={(e) => setReportType(e.target.value as ReportType)}
>
<MenuItem value="compliance">Compliance Report</MenuItem>
<MenuItem value="patch-history">Patch History</MenuItem>
<MenuItem value="vulnerability">Vulnerability Exposure</MenuItem>
<MenuItem value="audit">Audit Trail</MenuItem>
</Select>
</FormControl>
{/* Date Range */}
<Box sx={{ display: 'flex', gap: 1.5, mb: 2 }}>
<TextField
label="From"
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<TextField
label="To"
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
/>
</Box>
{/* Group Filter — compliance only */}
{reportType === 'compliance' && (
<FormControl fullWidth sx={{ mb: 2 }}>
<TextField
label="Group ID (optional)"
value={groupId}
onChange={(e) => setGroupId(e.target.value)}
placeholder="e.g. 550e8400-e29b-41d4-a716-446655440000"
/>
<FormHelperText>Filter compliance report by a specific group UUID</FormHelperText>
</FormControl>
)}
<Divider sx={{ my: 2 }} />
{/* Download buttons */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button
variant="contained"
fullWidth
startIcon={
downloading ? <CircularProgress size={20} color="inherit" /> : <DescriptionIcon />
}
onClick={() => handleDownload('csv')}
disabled={downloading}
>
Download CSV
</Button>
<Button
variant="outlined"
fullWidth
startIcon={
downloading ? <CircularProgress size={20} color="inherit" /> : <PictureAsPdfIcon />
}
onClick={() => handleDownload('pdf')}
disabled={downloading}
>
Download PDF
</Button>
</Box>
</Paper>
</Grid>
{/* ── Info card ── */}
<Grid size={{ xs: 12, md: 8 }}>
<Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="h6" fontWeight={600} sx={{ mb: 1 }}>
{info.title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{info.description}
</Typography>
<Typography variant="subtitle2" fontWeight={600}>
Columns in this report:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1 }}>
{info.columns.map((col) => (
<Chip key={col} label={col} size="small" />
))}
</Box>
<Divider sx={{ my: 2 }} />
<Typography variant="body2" sx={{ mb: 0.5 }}>
📊 PDF includes bar charts for compliance and patch history reports.
</Typography>
<Typography variant="body2">
📁 CSV is suitable for import into Excel or Google Sheets.
</Typography>
</Paper>
</Grid>
</Grid>
{/* ── Error snackbar ── */}
<Snackbar
open={!!error}
autoHideDuration={6000}
onClose={() => setError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setError(null)} sx={{ width: '100%' }}>
{error}
</Alert>
</Snackbar>
</Container>
)
}

View File

@ -166,3 +166,30 @@ export interface JobWsEvent {
error_message?: string
agent_job_id?: string
}
// ── Certificates (M8) ────────────────────────────────────────────────────────
export type CertStatus = 'active' | 'revoked' | 'expired'
export interface Certificate {
id: string
host_id: string | null // null = root CA cert
serial_number: string
common_name: string
status: CertStatus
issued_at: string
expires_at: string
revoked_at: string | null
cert_pem: string
}
export interface IssuedCert {
cert_pem: string
key_pem: string
serial_number: string
expires_at: string
}
// ── Reports (M9) ─────────────────────────────────────────────────────────────
export type ReportType = 'compliance' | 'patch-history' | 'vulnerability' | 'audit'
export type ReportFormat = 'csv' | 'pdf'