import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useTrackedMutation } from '../hooks/useTrackedMutation';
import {
getDiscoveredCertificates,
getDiscoverySummary,
getDiscoveryScans,
claimDiscoveredCertificate,
dismissDiscoveredCertificate,
getAgents,
} from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils';
import type { DiscoveredCertificate, DiscoveryScan } from '../api/types';
/** Map agent_id to a human-readable source type badge. */
function sourceTypeBadge(agentId: string): { label: string; style: string } {
switch (agentId) {
case 'server-scanner':
return { label: 'Network', style: 'bg-blue-100 text-blue-800' };
case 'cloud-aws-sm':
return { label: 'AWS SM', style: 'bg-orange-100 text-orange-800' };
case 'cloud-azure-kv':
return { label: 'Azure KV', style: 'bg-sky-100 text-sky-800' };
case 'cloud-gcp-sm':
return { label: 'GCP SM', style: 'bg-green-100 text-green-800' };
default:
return { label: 'Filesystem', style: 'bg-gray-100 text-gray-800' };
}
}
function ClaimModal({ cert, onClose, onClaim }: { cert: DiscoveredCertificate; onClose: () => void; onClaim: (managedCertId: string) => void }) {
const [managedCertId, setManagedCertId] = useState('');
return (
e.stopPropagation()}>
Claim Certificate
Link {cert.common_name} to a managed certificate
);
}
function ScanHistoryPanel({ scans }: { scans: DiscoveryScan[] }) {
if (scans.length === 0) return No scans recorded yet
;
return (
| Agent |
Directories |
Found |
New |
Errors |
Duration |
Started |
{scans.map(s => (
| {s.agent_id} |
{s.directories?.join(', ') || '—'} |
{s.certificates_found} |
{s.certificates_new} |
{s.errors_count > 0 ? {s.errors_count} : '0'} |
{s.scan_duration_ms}ms |
{formatDateTime(s.started_at)} |
))}
);
}
export default function DiscoveryPage() {
const [statusFilter, setStatusFilter] = useState('');
const [agentFilter, setAgentFilter] = useState('');
const [claimingCert, setClaimingCert] = useState(null);
const [showScans, setShowScans] = useState(false);
const params: Record = {};
if (statusFilter) params.status = statusFilter;
if (agentFilter) params.agent_id = agentFilter;
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['discovered-certificates', params],
queryFn: () => getDiscoveredCertificates(params),
refetchInterval: 30000,
});
const { data: summary } = useQuery({
queryKey: ['discovery-summary'],
queryFn: getDiscoverySummary,
refetchInterval: 30000,
});
const { data: scansData } = useQuery({
queryKey: ['discovery-scans'],
queryFn: () => getDiscoveryScans(),
enabled: showScans,
});
const { data: agentsData } = useQuery({
queryKey: ['agents-for-filter'],
queryFn: () => getAgents({ per_page: '200' }),
});
// Phase 2 TQ-M3 closure: claim + dismiss with optimistic updates.
// Each one flips the row's status in the ['discovered-certificates']
// cache immediately so the visual response is sub-100ms regardless
// of network RTT. Rollback restores the snapshot + fires a Sonner
// error toast.
const queryClient = useQueryClient();
type DiscSnapshot = {
prev?: { data: DiscoveredCertificate[]; total: number } | undefined;
};
const claimMutation = useTrackedMutation({
mutationFn: ({ id, managedCertId }) =>
claimDiscoveredCertificate(id, managedCertId),
invalidates: [['discovered-certificates'], ['discovery-summary']],
onMutate: async ({ id }): Promise => {
await queryClient.cancelQueries({ queryKey: ['discovered-certificates'] });
const prev = queryClient.getQueryData(['discovered-certificates']) as DiscSnapshot['prev'];
if (prev) {
queryClient.setQueryData(['discovered-certificates'], {
...prev,
data: prev.data.map((c) => (c.id === id ? { ...c, status: 'Managed' as const } : c)),
});
}
return { prev };
},
onError: (err, _vars, snap) => {
if (snap?.prev) queryClient.setQueryData(['discovered-certificates'], snap.prev);
toast.error(`Claim failed: ${err.message}`);
},
onSuccess: () => {
toast.success('Certificate claimed');
setClaimingCert(null);
},
});
const dismissMutation = useTrackedMutation({
mutationFn: dismissDiscoveredCertificate,
invalidates: [['discovered-certificates'], ['discovery-summary']],
onMutate: async (id): Promise => {
await queryClient.cancelQueries({ queryKey: ['discovered-certificates'] });
const prev = queryClient.getQueryData(['discovered-certificates']) as DiscSnapshot['prev'];
if (prev) {
queryClient.setQueryData(['discovered-certificates'], {
...prev,
data: prev.data.map((c) => (c.id === id ? { ...c, status: 'Dismissed' as const } : c)),
});
}
return { prev };
},
onError: (err, _id, snap) => {
if (snap?.prev) queryClient.setQueryData(['discovered-certificates'], snap.prev);
toast.error(`Dismiss failed: ${err.message}`);
},
onSuccess: () => toast.success('Discovery dismissed'),
});
const formatExpiry = (notAfter?: string) => {
if (!notAfter) return '—';
const d = new Date(notAfter);
const now = new Date();
const days = Math.floor((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (days < 0) return Expired {Math.abs(days)}d ago;
if (days < 30) return {days}d left;
return {days}d left;
};
const discoveryStatusStyle: Record = {
Unmanaged: 'badge badge-warning',
Managed: 'badge badge-success',
Dismissed: 'badge badge-neutral',
};
const columns: Column[] = [
{
key: 'common_name',
label: 'Common Name',
render: (c) => (
{c.common_name || '(no CN)'}
{c.sans?.length > 0 && (
{c.sans.slice(0, 2).join(', ')}{c.sans.length > 2 ? ` +${c.sans.length - 2}` : ''}
)}
),
},
{
key: 'status',
label: 'Status',
render: (c) => {c.status},
},
{
key: 'source',
label: 'Source',
render: (c) => {
const badge = sourceTypeBadge(c.agent_id);
return (
{badge.label}
{c.source_path}
);
},
},
{
key: 'issuer',
label: 'Issuer',
render: (c) => {c.issuer_dn?.split(',')[0] || '—'},
},
{
key: 'expiry',
label: 'Expiry',
render: (c) => {formatExpiry(c.not_after)},
},
{
key: 'key_info',
label: 'Key',
render: (c) => (
{c.key_algorithm}{c.key_size ? ` ${c.key_size}` : ''}
{c.is_ca && (
CA
)}
),
},
{
key: 'fingerprint',
label: 'Fingerprint',
render: (c) => {c.fingerprint_sha256?.substring(0, 16)}...,
},
{
key: 'actions',
label: '',
render: (c) => (
c.status === 'Unmanaged' ? (
) : null
),
},
];
return (
<>
{/* Summary stats bar */}
{summary && (
{summary.Unmanaged || 0} Unmanaged
{summary.Managed || 0} Managed
{summary.Dismissed || 0} Dismissed
)}
{/* Scan history collapsible */}
{showScans && (
)}
{/* Filters */}
{/* Table */}
{error ? (
refetch()} />
) : (
)}
{claimingCert && (
setClaimingCert(null)}
onClaim={(managedCertId) => claimMutation.mutate({ id: claimingCert.id, managedCertId })}
/>
)}
>
);
}