feat(M8+M9): CA certificates page + Reporting CSV/PDF with charts
This commit is contained in:
@ -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" />} />
|
||||
|
||||
@ -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
|
||||
}),
|
||||
}
|
||||
|
||||
483
frontend/src/pages/CertificatesPage.tsx
Normal file
483
frontend/src/pages/CertificatesPage.tsx
Normal 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} | 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>
|
||||
)
|
||||
}
|
||||
@ -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}>
|
||||
|
||||
@ -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]) =>
|
||||
|
||||
273
frontend/src/pages/ReportsPage.tsx
Normal file
273
frontend/src/pages/ReportsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
|
||||
Reference in New Issue
Block a user