import { useEffect, useState, useCallback } from 'react' import { Box, Button, Chip, CircularProgress, Container, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Paper, Snackbar, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, TextField, Toolbar, Tooltip, Typography, } from '@mui/material' import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon, Pending as PendingIcon, GppMaybe as GppMaybeIcon, CheckCircleOutline as CheckCircleOutlineIcon, WarningAmber as WarningAmberIcon, VerifiedUser as VerifiedUserIcon, Security as SecurityIcon } from '@mui/icons-material' import { useNavigate } from 'react-router-dom' import { apiClient, hostsApi, enrollmentApi } from '../api/client' import { useAuthStore } from '../store/authStore' import type { Host, HostHealthStatus, EnrollmentRequest, EnrollmentConflictResponse } from '../types' const statusColor = (s: HostHealthStatus) => s === 'healthy' ? 'success' : s === 'degraded' ? 'warning' : s === 'unreachable' ? 'error' : 'default' export default function HostsPage() { const navigate = useNavigate() const user = useAuthStore(state => state.user) const canWrite = user?.role === 'admin' || user?.role === 'operator' const [hosts, setHosts] = useState([]) const [total, setTotal] = useState(0) const [page, setPage] = useState(0) const [rowsPerPage, setRowsPerPage] = useState(25) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') const [refreshing, setRefreshing] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ open: false, message: '', severity: 'success' }) // ── Enrollment state ──────────────────────────────────────────────────── const [showPending, setShowPending] = useState(false) const [pendingEnrollments, setPendingEnrollments] = useState([]) const [pendingCount, setPendingCount] = useState(0) const [denyTarget, setDenyTarget] = useState(null) const [actionLoading, setActionLoading] = useState(null) const [conflictModal, setConflictModal] = useState<{ request: EnrollmentRequest; existingHost: Host } | null>(null) const load = useCallback(async () => { setLoading(true) try { const offset = page * rowsPerPage const res = await apiClient.get('/hosts', { params: { limit: rowsPerPage, offset } }) setHosts(res.data.hosts) setTotal(res.data.total) } catch { /* handled by interceptor */ } finally { setLoading(false) } }, [page, rowsPerPage]) const loadPending = useCallback(async () => { try { const data = await enrollmentApi.listPending() setPendingEnrollments(data) setPendingCount(data.length) } catch { /* handled by interceptor */ } }, []) const handleRefresh = async (e: React.MouseEvent, hostId: string) => { e.stopPropagation() setRefreshing(hostId) try { await hostsApi.refresh(hostId) setTimeout(() => { load(); setRefreshing(null) }, 2000) } catch { setRefreshing(null) } } const handleDelete = async () => { if (!deleteTarget) return try { await hostsApi.delete(deleteTarget.id) setSnackbar({ open: true, message: `Host "${deleteTarget.display_name || deleteTarget.fqdn}" deleted`, severity: 'success' }) load() } catch { setSnackbar({ open: true, message: `Failed to delete host "${deleteTarget.display_name || deleteTarget.fqdn}"`, severity: 'error' }) } finally { setDeleteTarget(null) } } // ── Enrollment action handlers ────────────────────────────────────────── const handleApprove = async (req: EnrollmentRequest) => { setActionLoading(req.id) try { await enrollmentApi.approve(req.id) setSnackbar({ open: true, message: `Host "${req.fqdn}" approved`, severity: 'success' }) load(); loadPending() } catch (err: unknown) { const errObj = err as { response?: { status?: number; data?: EnrollmentConflictResponse }; message?: string } const status = errObj?.response?.status if (status === 409 && errObj.response?.data) { const conflictData = errObj.response.data as EnrollmentConflictResponse setConflictModal({ request: req, existingHost: conflictData.conflict.existing_host }) } else { setSnackbar({ open: true, message: `Failed to approve "${req.fqdn}": ${errObj?.message || 'Unknown error'}`, severity: 'error' }) } } finally { setActionLoading(null) } } const handleDeny = async () => { if (!denyTarget) return setActionLoading(denyTarget.id) try { await enrollmentApi.deny(denyTarget.id) setSnackbar({ open: true, message: `Enrollment "${denyTarget.fqdn}" denied`, severity: 'success' }) loadPending() } catch { setSnackbar({ open: true, message: `Failed to deny enrollment`, severity: 'error' }) } finally { setActionLoading(null) setDenyTarget(null) } } const handleConflictResolve = async (action: 'overwrite' | 'cancel') => { if (!conflictModal) return if (action === 'cancel') { setConflictModal(null) return } // For overwrite: delete the existing host first, then approve try { await hostsApi.delete(conflictModal.existingHost.id) await enrollmentApi.approve(conflictModal.request.id) setSnackbar({ open: true, message: `Overwrote existing host and approved "${conflictModal.request.fqdn}"`, severity: 'success' }) load(); loadPending() } catch { setSnackbar({ open: true, message: `Failed to resolve conflict`, severity: 'error' }) } finally { setConflictModal(null) } } useEffect(() => { load(); loadPending() }, [load, loadPending]) const filtered = hosts.filter(h => h.fqdn.toLowerCase().includes(search.toLowerCase()) || h.display_name.toLowerCase().includes(search.toLowerCase()) ) const handleChangePage = (_event: React.MouseEvent | null, newPage: number) => { setPage(newPage) } const handleChangeRowsPerPage = (event: React.ChangeEvent) => { setRowsPerPage(parseInt(event.target.value, 10)) setPage(0) } return ( Hosts setSearch(e.target.value)} sx={{ mr: 2 }} /> { load(); loadPending() }}> {canWrite && } {loading ? : ( FQDN Display Name IP Address OS Health Checks CRL Agent {canWrite && Actions} {showPending ? ( pendingEnrollments.map(req => ( {req.fqdn} {req.fqdn} {req.ip_address} {(req.os_details['name'] as string) ?? 'Unknown'} {canWrite && e.stopPropagation()}> { e.stopPropagation(); handleApprove(req) }}> {actionLoading === req.id ? : } { e.stopPropagation(); setDenyTarget(req) }}> } )) ) : ( filtered.map(h => ( navigate(`/hosts/${h.id}`)}> {h.fqdn} {h.display_name} {h.ip_address} {h.os_name ?? h.os_family ?? '—'} {h.health_check_status === 'all_healthy' ? ( ) : h.health_check_status === 'some_unhealthy' ? ( ) : ( )} {h.crl_status === 'valid' ? ( ) : h.crl_status === 'expired' ? ( ) : h.crl_status === 'missing' ? ( ) : h.crl_status === 'invalid' ? ( ) : ( )} {h.agent_version ?? '—'} {canWrite && e.stopPropagation()}> handleRefresh(e, h.id)}> {refreshing === h.id ? : } { e.stopPropagation(); setDeleteTarget(h) }}> } )) )}
{!showPending && ( )}
)} setDeleteTarget(null)}> Confirm Delete Are you sure you want to delete host “{deleteTarget?.display_name || deleteTarget?.fqdn}”? {/* ── Deny Confirmation Dialog ─────────────────────────────────── */} setDenyTarget(null)}> Confirm Deny Are you sure you want to deny the enrollment for “{denyTarget?.fqdn}”? This action cannot be undone. {/* ── Conflict Modal ───────────────────────────────────────────── */} setConflictModal(null)}> Host Collision Detected Approving “{conflictModal?.request.fqdn}” conflicts with an existing host: Existing Host FQDN: {conflictModal?.existingHost.fqdn} IP: {conflictModal?.existingHost.ip_address} ID: {conflictModal?.existingHost.id} Options: setSnackbar(s => ({ ...s, open: false }))} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}> setSnackbar(s => ({ ...s, open: false }))} sx={{ width: '100%' }}>{snackbar.message}
) }