Files
vulncheck/frontend/app/assets/page.tsx
T
vulncheck af94797c3b feat(assets): coverage-gap report — installed packages with no finding (#4)
Tester: Wazuh's vuln-detector misses some products (MS365, mRemoteNG,
…). Chosen approach (safe option): surface the GAP, make NO automatic
CVE assignment → zero false positives.

Backend
- GET /api/v1/assets/{id}/coverage-gap: fetches syscollector packages,
  cross-references against the asset's open-vuln package_name blob
  (token match, len>=3 to avoid substring noise). Returns packages
  WITHOUT any finding, each annotated with its endoflife.date status
  when known (EOL / EOL SOON / out-of-active-support) — a hint only,
  never a CVE claim. Pure local data, no NVD, no rate-limit risk.

Frontend
- Amber check-circle action per Wazuh-linked asset opens a modal
  listing the gap packages (name / version / EOL hint), with a clear
  "investigate manually — no automatic CVE claim" disclaimer.

Operator workflow: spot a high-value uncovered package (e.g. an EOL
mRemoteNG), investigate in NVD/vendor advisories, mark accordingly.
Deliberately conservative — keeps data quality intact.
2026-06-01 08:45:32 +02:00

726 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from 'react';
import api from '../../lib/api';
import { Asset, UserInfo, Group } from '../../types';
import Link from 'next/link';
import { PencilSquareIcon, TrashIcon, ArrowPathIcon, UserGroupIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import { UserCircleIcon } from '@heroicons/react/24/solid';
export default function AssetsPage() {
const [assets, setAssets] = useState<Asset[]>([]);
const [users, setUsers] = useState<UserInfo[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [rescanLoading, setRescanLoading] = useState<number | null>(null);
const [nessusRescanLoading, setNessusRescanLoading] = useState<number | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [searchText, setSearchText] = useState('');
const [showInactive, setShowInactive] = useState(false);
// Coverage-gap report modal
const [gapReport, setGapReport] = useState<any | null>(null);
const [gapLoading, setGapLoading] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
// Form State
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<{
hostname: string;
ip_address: string;
operating_system: string;
os_version: string;
description: string;
policy_id?: number | null;
criticality: 'low' | 'normal' | 'high' | 'critical';
}>({
hostname: '',
ip_address: '',
operating_system: '',
os_version: '',
description: '',
policy_id: undefined,
criticality: 'normal',
});
const [policies, setPolicies] = useState<{ id: number, name: string }[]>([]);
const fetchAssets = async () => {
try {
const params: any = {};
if (searchText) params.search = searchText;
if (showInactive) params.include_inactive = true;
const [assetsRes, usersRes, groupsRes] = await Promise.all([
api.get('/api/v1/assets', { params }),
api.get('/auth/users').catch(() => ({ data: [] })),
api.get('/api/v1/groups')
]);
setAssets(assetsRes.data);
setUsers(usersRes.data);
setGroups(groupsRes.data);
} catch (error) {
console.error("Failed to fetch assets:", error);
} finally {
setLoading(false);
}
};
const fetchPolicies = async () => {
try {
const response = await api.get('/api/v1/policies');
setPolicies(response.data);
} catch (error) {
console.error("Failed to fetch policies:", error);
}
};
useEffect(() => {
fetchAssets();
fetchPolicies();
}, []);
// Re-fetch when the inactive toggle flips.
useEffect(() => {
fetchAssets();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showInactive]);
const resetForm = () => {
setFormData({
hostname: '',
ip_address: '',
operating_system: '',
os_version: '',
description: '',
policy_id: undefined,
criticality: 'normal',
});
setEditingId(null);
setIsModalOpen(false);
};
const handleSaveAsset = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingId) {
// Update
await api.put(`/api/v1/assets/${editingId}`, formData);
} else {
// Create
await api.post('/api/v1/assets', formData);
}
resetForm();
fetchAssets();
} catch (error: any) {
console.error("Failed to save asset:", error);
const msg = error.response?.data?.detail || "Failed to save asset.";
alert(`Error: ${msg}`);
}
};
const handleAssign = async (assetId: number, value: string) => {
try {
const payload: any = {};
if (value === "") {
payload.user_id = null;
payload.group_id = null;
} else if (value.startsWith("group_")) {
payload.group_id = parseInt(value.replace("group_", ""));
payload.user_id = null;
} else {
payload.user_id = parseInt(value);
payload.group_id = null;
}
await api.patch(`/api/v1/assets/${assetId}/assign`, payload);
// Refetch to get updated groups array
fetchAssets();
} catch (error) {
console.error("Failed to assign user/group", error);
fetchAssets(); // Revert on failure
}
};
const handleEdit = (asset: Asset) => {
setEditingId(asset.id);
setFormData({
hostname: asset.hostname,
ip_address: asset.ip_address,
operating_system: asset.operating_system,
os_version: asset.os_version || '',
description: asset.description || '',
policy_id: asset.policy_id,
criticality: (asset as any).criticality || 'normal',
});
setIsModalOpen(true);
};
const handleCoverageGap = async (asset: Asset) => {
setGapLoading(asset.id);
try {
const res = await api.get(`/api/v1/assets/${asset.id}/coverage-gap`);
setGapReport(res.data);
} catch (e: any) {
alert(`Coverage-gap failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
} finally {
setGapLoading(null);
}
};
const handleRescan = async (id: number) => {
setRescanLoading(id);
try {
const res = await api.post(`/api/v1/assets/${id}/rescan`);
alert(res.data.message);
fetchAssets();
} catch (error: any) {
console.error("Rescan failed:", error);
alert(error.response?.data?.detail || "Rescan failed.");
} finally {
setRescanLoading(null);
}
};
const handleNessusRescan = async (asset: Asset) => {
if (!asset.ip_address) {
alert('Asset has no IP address — Nessus cannot target it.');
return;
}
if (!confirm(
`Launch a targeted Nessus scan for ${asset.hostname} (${asset.ip_address})?\n\n` +
`Uses the scan configured in Settings → Tenable Nessus (default_scan_ids), ` +
`but targets only this host's IP. After the scan completes in Nessus, ` +
`use "Sync Data (Nessus)" to import the updated findings.`
)) return;
setNessusRescanLoading(asset.id);
try {
const res = await api.post('/api/v1/vulnerabilities/nessus/scan-host', {
asset_id: asset.id,
});
alert(res.data.message || 'Nessus scan launched successfully.');
} catch (error: any) {
const detail = error.response?.data?.detail || 'Failed to launch Nessus scan.';
alert(`Nessus rescan failed: ${detail}`);
} finally {
setNessusRescanLoading(null);
}
};
const handleDeleteAsset = async (id: number) => {
if (!confirm("Are you sure you want to delete this asset?")) return;
try {
await api.delete(`/api/v1/assets/${id}`);
fetchAssets();
} catch (error) {
console.error("Failed to delete asset:", error);
alert("Failed to delete asset.");
}
};
const handleBulkUpdate = async (type: 'user' | 'group' | 'policy', value: string | number | null) => {
if (selectedIds.length === 0) return;
// Skip if no value selected (placeholder option)
if (value === "" || value === null) return;
try {
const payload: any = {
asset_ids: selectedIds
};
// Handle "unassign" special value
if (value === "unassign") {
payload.assigned_user_id = -1;
payload.assigned_group_id = -1;
} else if (type === 'user') {
payload.assigned_user_id = Number(value);
} else if (type === 'group') {
payload.assigned_group_id = Number(value);
} else if (type === 'policy') {
payload.policy_id = Number(value);
}
await api.post('/api/v1/assets/bulk-update', payload);
setSelectedIds([]);
fetchAssets();
} catch (error) {
console.error("Bulk update failed", error);
alert("Bulk update failed.");
}
};
const toggleSelectAll = () => {
if (selectedIds.length === assets.length) {
setSelectedIds([]);
} else {
setSelectedIds(assets.map(a => a.id));
}
};
const toggleSelect = (id: number) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
if (loading) return <div className="p-8">Loading Assets...</div>;
return (
<div className="max-w-7xl mx-auto">
<div className="flex md:items-center md:justify-between mb-8">
<div>
<h2 className="text-3xl font-bold leading-7 text-gray-900 font-mono">
Assets Inventory
</h2>
</div>
<div className="mt-4 flex md:ml-4 md:mt-0 items-center gap-3">
<div className="relative">
<input
type="text"
placeholder="Search assets..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchAssets()}
className="block w-64 rounded-sm border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-vulncheck-blue sm:text-sm sm:leading-6 font-mono"
/>
<button
onClick={fetchAssets}
className="absolute inset-y-0 right-0 flex items-center pr-3"
>
<svg className="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
</div>
<label className="flex items-center gap-1.5 text-xs font-mono text-gray-600 cursor-pointer whitespace-nowrap" title="Show soft-inactive + decommissioned assets (no source sync within the threshold)">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
/>
Show inactive
</label>
<button
type="button"
onClick={async () => {
if (!confirm('Refresh network-exposure scores from Wazuh syscollector ports for all assets?')) return;
try {
const r = await api.post('/api/v1/assets/refresh-exposure');
alert(`Exposure refreshed: ${r.data?.exposed || 0} of ${r.data?.assets || 0} assets have exposed listeners.`);
fetchAssets();
} catch (e: any) {
alert(`Failed: ${e?.response?.data?.detail || e?.message || 'unknown'}`);
}
}}
className="inline-flex items-center rounded-sm border border-purple-500 text-purple-700 px-3 py-2 text-xs font-mono font-semibold hover:bg-purple-50 whitespace-nowrap"
title="Pull open listeners from Wazuh and recompute exposure scores"
>
Exposure
</button>
<button
type="button"
onClick={() => { resetForm(); setIsModalOpen(true); }}
className="inline-flex items-center rounded-sm bg-vulncheck-blue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 font-mono whitespace-nowrap"
>
Add Asset
</button>
</div>
</div>
{/* Bulk Actions Bar */}
{selectedIds.length > 0 && (
<div className="sticky top-0 z-40 bg-vulncheck-blue/90 backdrop-blur-sm text-white px-6 py-3 mb-4 rounded-sm shadow-lg flex items-center justify-between animate-in slide-in-from-top duration-300">
<div className="flex items-center gap-4">
<span className="font-mono font-bold">{selectedIds.length} Assets selected</span>
<div className="h-6 w-px bg-white/20" />
<div className="flex items-center gap-2">
<span className="text-xs font-mono opacity-80 uppercase">Assign to:</span>
<select
onChange={(e) => {
const val = e.target.value;
if (val === 'unassign') {
handleBulkUpdate('user', 'unassign');
} else if (val.startsWith('group_')) {
handleBulkUpdate('group', val.replace('group_', ''));
} else {
handleBulkUpdate('user', val);
}
}}
className="bg-white/10 border border-white/20 rounded-sm text-xs py-1 px-2 focus:bg-white focus:text-gray-900 transition-all cursor-pointer"
value=""
>
<option value="" className="text-gray-900">User / Group</option>
<optgroup label="Users" className="text-gray-900">
{users.map(u => <option key={`bulk-u-${u.id}`} value={u.id}>{u.username}</option>)}
</optgroup>
<optgroup label="Groups" className="text-gray-900">
{groups.map(g => <option key={`bulk-g-${g.id}`} value={`group_${g.id}`}>{g.name}</option>)}
</optgroup>
<option value="unassign" className="text-gray-900">Unassign</option>
</select>
<select
onChange={(e) => handleBulkUpdate('policy', e.target.value)}
className="bg-white/10 border border-white/20 rounded-sm text-xs py-1 px-2 focus:bg-white focus:text-gray-900 transition-all cursor-pointer"
value=""
>
<option value="" className="text-gray-900">Policy</option>
{policies.map(p => <option key={`bulk-p-${p.id}`} value={p.id}>{p.name}</option>)}
<option value="" className="text-gray-900">None</option>
</select>
</div>
</div>
<button
onClick={() => setSelectedIds([])}
className="text-xs hover:underline opacity-80"
>
Clear Selection
</button>
</div>
)}
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-500 bg-opacity-75">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<h3 className="text-lg font-bold mb-4 font-mono">{editingId ? 'Edit Asset' : 'Add New Asset'}</h3>
<form onSubmit={handleSaveAsset} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 font-mono">Hostname</label>
<input
type="text"
required
value={formData.hostname}
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 font-mono">IP Address</label>
<input
type="text"
value={formData.ip_address}
onChange={(e) => setFormData({ ...formData, ip_address: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 font-mono">Operating System</label>
<input
type="text"
value={formData.operating_system}
onChange={(e) => setFormData({ ...formData, operating_system: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 font-mono">Version</label>
<input
type="text"
value={formData.os_version}
onChange={(e) => setFormData({ ...formData, os_version: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
placeholder="e.g. 22.04"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 font-mono">Description</label>
<input
type="text"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 font-mono">
Asset Criticality
<span className="text-xs text-gray-400 ml-1">(URS multiplier)</span>
</label>
<select
value={formData.criticality}
onChange={(e) => setFormData({ ...formData, criticality: e.target.value as any })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm font-mono"
>
<option value="low">Low (×0.7)</option>
<option value="normal">Normal (×1.0)</option>
<option value="high">High (×1.3)</option>
<option value="critical">Critical (×1.5)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 font-mono">Assign Policy</label>
<select
value={formData.policy_id || ''}
onChange={(e) => setFormData({ ...formData, policy_id: e.target.value ? Number(e.target.value) : null })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-vulncheck-blue focus:ring-vulncheck-blue sm:text-sm"
>
<option value="">-- No Policy --</option>
{policies.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
type="button"
onClick={resetForm}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-vulncheck-blue rounded-md hover:bg-blue-600"
>
{editingId ? 'Update Asset' : 'Create Asset'}
</button>
</div>
</form>
</div>
</div>
)}
<div className="bg-white border border-gray-200 shadow-sm rounded-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left">
<input
type="checkbox"
checked={assets.length > 0 && selectedIds.length === assets.length}
onChange={toggleSelectAll}
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
/>
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">Hostname</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">IP Address</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">OS</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider" title="Network exposure from open listeners (VNC/RDP/Telnet/...)">Exposure</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">Last Scan</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">Policy</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">Assigned To</th>
<th scope="col" className="px-6 py-3 text-right text-xs font-mono font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200 font-mono text-sm">
{assets.map((asset) => (
<tr key={asset.id} className={selectedIds.includes(asset.id) ? 'bg-blue-50/50' : ''}>
<td className="px-6 py-4">
<input
type="checkbox"
checked={selectedIds.includes(asset.id)}
onChange={() => toggleSelect(asset.id)}
className="h-4 w-4 rounded border-gray-300 text-vulncheck-blue focus:ring-vulncheck-blue"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap font-bold text-vulncheck-blue">
<Link href={`/vulnerabilities?asset_id=${asset.id}`} className="hover:underline">
{asset.hostname}
</Link>
</td>
<td className="px-6 py-4 text-gray-500">{asset.ip_address}</td>
<td className="px-6 py-4 text-gray-500">
{asset.operating_system}
{asset.os_version && <span className="text-gray-400 ml-1 text-xs">v{asset.os_version}</span>}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
${asset.status === 'active' ? 'bg-green-100 text-green-700'
: asset.status === 'inactive' ? 'bg-amber-100 text-amber-700'
: 'bg-gray-200 text-gray-600'}`}
title={asset.status === 'inactive'
? 'No source sync within the threshold — history kept, hidden from default view'
: asset.status === 'decommissioned' ? 'Operator-retired' : 'Reported by a source'}
>
{asset.status.toUpperCase()}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{asset.network_exposure_score != null && asset.network_exposure_score > 0 ? (
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-bold font-mono
${asset.network_exposure_score >= 60 ? 'bg-red-100 text-red-700'
: asset.network_exposure_score >= 30 ? 'bg-orange-100 text-orange-700'
: 'bg-yellow-100 text-yellow-800'}`}
title={(asset.exposed_services || []).map(s => `${s.service} :${s.port}`).join(', ') || 'Exposed listeners'}
>
{asset.network_exposure_score.toFixed(0)}
{asset.exposed_services && asset.exposed_services.length > 0 && (
<span className="ml-1 font-normal text-[10px] opacity-80">
{asset.exposed_services.slice(0, 2).map(s => s.service.split(' ')[0]).join('/')}
{asset.exposed_services.length > 2 ? '…' : ''}
</span>
)}
</span>
) : (
<span className="text-gray-300 text-xs"></span>
)}
</td>
<td className="px-6 py-4 text-gray-500">{asset.last_scan ? new Date(asset.last_scan).toLocaleDateString() : 'Never'}</td>
<td className="px-6 py-4 whitespace-nowrap">
{asset.policy_name ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
<svg className="h-1.5 w-1.5 fill-blue-400" viewBox="0 0 6 6" aria-hidden="true">
<circle cx={3} cy={3} r={3} />
</svg>
{asset.policy_name}
</span>
) : (
<span className="text-gray-400 text-xs italic">Default</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{asset.groups && asset.groups.length > 0 ? (
<UserGroupIcon className="h-5 w-5 text-indigo-600" />
) : (
<UserCircleIcon className={`h-5 w-5 ${asset.assigned_user_id ? 'text-indigo-600' : 'text-gray-300'}`} />
)}
<div className="relative">
<select
value={
asset.groups && asset.groups.length > 0
? `group_${groups.find(g => g.name === asset.groups![0])?.id || ''}`
: (asset.assigned_user_id ? String(asset.assigned_user_id) : "")
}
onChange={(e) => handleAssign(asset.id, e.target.value)}
className="block w-full appearance-none rounded-md border-0 bg-transparent py-1.5 pl-2 pr-8 text-xs font-medium text-gray-900 focus:ring-1 focus:ring-inset focus:ring-indigo-600 cursor-pointer hover:bg-gray-50 transition-colors truncate"
style={{ maxWidth: '140px' }}
>
<option value="">Unassigned</option>
<optgroup label="Users">
{users.map(u => (
<option key={`u-${u.id}`} value={u.id}>{u.username}</option>
))}
</optgroup>
<optgroup label="Groups">
{groups.map(g => (
<option key={`g-${g.id}`} value={`group_${g.id}`}>{g.name}</option>
))}
</optgroup>
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-400">
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end gap-x-2">
{asset.wazuh_agent_id && (
<button
onClick={() => handleRescan(asset.id)}
disabled={rescanLoading === asset.id}
title="Rescan with Wazuh"
className={`${rescanLoading === asset.id ? 'text-gray-300' : 'text-indigo-600 hover:text-indigo-900'}`}
>
<ArrowPathIcon className={`h-5 w-5 ${rescanLoading === asset.id ? 'animate-spin' : ''}`} />
</button>
)}
{asset.ip_address && (
<button
onClick={() => handleNessusRescan(asset)}
disabled={nessusRescanLoading === asset.id}
title="Launch targeted Nessus scan for this host"
className={`${nessusRescanLoading === asset.id ? 'text-gray-300' : 'text-purple-600 hover:text-purple-900'}`}
>
{nessusRescanLoading === asset.id
? <ArrowPathIcon className="h-5 w-5 animate-spin" />
: <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" title="Nessus">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm0 0v18M3 12h18" />
</svg>
}
</button>
)}
{asset.wazuh_agent_id && (
<button
onClick={() => handleCoverageGap(asset)}
disabled={gapLoading === asset.id}
title="Coverage gap — installed packages with no vuln finding"
className={`${gapLoading === asset.id ? 'text-gray-300' : 'text-amber-600 hover:text-amber-800'}`}
>
{gapLoading === asset.id
? <ArrowPathIcon className="h-5 w-5 animate-spin" />
: <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
}
</button>
)}
<button
onClick={() => handleEdit(asset)}
className="text-vulncheck-blue hover:text-blue-900"
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => handleDeleteAsset(asset.id)}
className="text-red-600 hover:text-red-900"
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</td>
</tr>
))}
{assets.length === 0 && (
<tr><td colSpan={6} className="text-center py-4">No assets found.</td></tr>
)}
</tbody>
</table>
</div>
</div>
{/* Coverage-gap modal — installed packages with no finding */}
{gapReport && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setGapReport(null)}>
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="p-4 border-b border-gray-200">
<h3 className="text-lg font-bold font-mono text-gray-900">Coverage Gap {gapReport.hostname}</h3>
<p className="text-xs text-gray-500 font-mono mt-1">
{gapReport.gap_count} of {gapReport.total_packages} installed packages have NO open vuln finding.
Investigate manually VulnCheck makes no automatic CVE claim here.
</p>
</div>
<div className="overflow-y-auto p-4">
{gapReport.gap_count === 0 ? (
<p className="text-sm text-green-700 font-mono">All installed packages map to a finding no gap.</p>
) : (
<table className="min-w-full text-xs font-mono">
<thead className="text-gray-500 uppercase">
<tr><th className="text-left py-1">Package</th><th className="text-left py-1">Version</th><th className="text-left py-1">EOL hint</th></tr>
</thead>
<tbody className="divide-y divide-gray-100">
{gapReport.gap.map((g: any, i: number) => (
<tr key={i}>
<td className="py-1 pr-3 text-gray-900">{g.name}</td>
<td className="py-1 pr-3 text-gray-500">{g.version || '—'}</td>
<td className="py-1">
{g.eol_hint ? (
<span className={`rounded px-1.5 py-0.5 font-bold ${g.eol_hint === 'EOL' ? 'bg-red-100 text-red-700' : g.eol_hint.startsWith('EOL SOON') ? 'bg-orange-100 text-orange-700' : 'bg-yellow-100 text-yellow-800'}`}>{g.eol_hint}</span>
) : <span className="text-gray-300"></span>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="p-3 border-t border-gray-200 text-right">
<button onClick={() => setGapReport(null)} className="rounded-md bg-gray-100 px-4 py-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-200">Close</button>
</div>
</div>
</div>
)}
</div>
);
}