mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
layout fixes and file split for better handling
This commit is contained in:
+1
-1
Submodule .claude/commands updated: 06d4f6796f...bc73d1a95c
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch } from "@/lib/authenticatedFetch";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import type { AutoResolveResult, IgnoredSoftwareEntry } from "./SoftwareMappingTypes";
|
||||
|
||||
interface IgnoredSoftwareTabProps {
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
setError: (error: string | null) => void;
|
||||
refreshTrigger: number;
|
||||
onRestored: () => void;
|
||||
onMappingChanged: () => void;
|
||||
onCountChanged: (count: number) => void;
|
||||
}
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
export function IgnoredSoftwareTab({
|
||||
getAccessToken,
|
||||
setError,
|
||||
refreshTrigger,
|
||||
onRestored,
|
||||
onMappingChanged,
|
||||
onCountChanged,
|
||||
}: IgnoredSoftwareTabProps) {
|
||||
const [ignoredEntries, setIgnoredEntries] = useState<IgnoredSoftwareEntry[]>([]);
|
||||
const [ignoredLoading, setIgnoredLoading] = useState(false);
|
||||
const [ignoredLoaded, setIgnoredLoaded] = useState(false);
|
||||
const [ignoredPage, setIgnoredPage] = useState(0);
|
||||
const [restoringRow, setRestoringRow] = useState<string | null>(null);
|
||||
|
||||
// --- Fetching ---
|
||||
|
||||
const fetchIgnoredSoftware = useCallback(async () => {
|
||||
try {
|
||||
setIgnoredLoading(true);
|
||||
setError(null);
|
||||
const response = await authenticatedFetch(api.vulnerability.ignoredSoftware(), getAccessToken);
|
||||
if (!response.ok) throw new Error(`Failed to load ignored software: ${response.statusText}`);
|
||||
const data = await response.json();
|
||||
const items: IgnoredSoftwareEntry[] = data.items || [];
|
||||
setIgnoredEntries(items);
|
||||
setIgnoredLoaded(true);
|
||||
setIgnoredPage(0);
|
||||
onCountChanged(items.length);
|
||||
} catch (err) {
|
||||
console.error("Error fetching ignored software:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load ignored software");
|
||||
} finally {
|
||||
setIgnoredLoading(false);
|
||||
}
|
||||
}, [getAccessToken, setError, onCountChanged]);
|
||||
|
||||
// Lazy load on mount
|
||||
useEffect(() => {
|
||||
if (!ignoredLoaded && !ignoredLoading) {
|
||||
fetchIgnoredSoftware();
|
||||
}
|
||||
}, [ignoredLoaded, ignoredLoading, fetchIgnoredSoftware]);
|
||||
|
||||
// Reset when refreshTrigger changes (e.g. unmapped tab ignored an item)
|
||||
useEffect(() => {
|
||||
if (refreshTrigger > 0) {
|
||||
setIgnoredLoaded(false);
|
||||
}
|
||||
}, [refreshTrigger]);
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
const handleRestoreSoftware = async (entry: IgnoredSoftwareEntry) => {
|
||||
const key = `${entry.softwareName}::${entry.publisher}`;
|
||||
try {
|
||||
setRestoringRow(key);
|
||||
setError(null);
|
||||
const response = await authenticatedFetch(api.vulnerability.ignoredSoftware(), getAccessToken, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ softwareName: entry.softwareName, publisher: entry.publisher || "" }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Failed to restore software: ${response.statusText}`);
|
||||
setIgnoredEntries((prev) => {
|
||||
const next = prev.filter((e) => `${e.softwareName}::${e.publisher}` !== key);
|
||||
onCountChanged(next.length);
|
||||
return next;
|
||||
});
|
||||
onRestored();
|
||||
} catch (err) {
|
||||
console.error("Error restoring software:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to restore software");
|
||||
} finally {
|
||||
setRestoringRow(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckNvdFromIgnored = async (entry: IgnoredSoftwareEntry) => {
|
||||
const key = `${entry.softwareName}::${entry.publisher}`;
|
||||
try {
|
||||
setRestoringRow(key);
|
||||
setError(null);
|
||||
// First, call auto-resolve for this single item
|
||||
const resolveResponse = await authenticatedFetch(api.vulnerability.cpeAutoResolve(), getAccessToken, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
items: [{ softwareName: entry.softwareName, publisher: entry.publisher || "", normalizedVendor: entry.publisher || "" }],
|
||||
}),
|
||||
});
|
||||
if (!resolveResponse.ok) throw new Error(`NVD check failed: ${resolveResponse.statusText}`);
|
||||
const data: AutoResolveResult = await resolveResponse.json();
|
||||
|
||||
if (data.totalResolved > 0) {
|
||||
// Resolved! Remove from ignore list and refresh
|
||||
await authenticatedFetch(api.vulnerability.ignoredSoftware(), getAccessToken, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ softwareName: entry.softwareName, publisher: entry.publisher || "" }),
|
||||
});
|
||||
setIgnoredEntries((prev) => {
|
||||
const next = prev.filter((e) => `${e.softwareName}::${e.publisher}` !== key);
|
||||
onCountChanged(next.length);
|
||||
return next;
|
||||
});
|
||||
onMappingChanged();
|
||||
setError(null);
|
||||
trackEvent("ignored_nvd_check_resolved", { softwareName: entry.softwareName, cpeUri: data.resolved[0]?.cpeUri });
|
||||
} else {
|
||||
setError(`No CPE match found for "${entry.softwareName}" (${data.failed[0]?.reason || "no match"})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error checking NVD:", err);
|
||||
setError(err instanceof Error ? err.message : "NVD check failed");
|
||||
} finally {
|
||||
setRestoringRow(null);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
const ignoredTotalPages = Math.ceil(ignoredEntries.length / pageSize);
|
||||
const paginatedIgnoredEntries = ignoredEntries.slice(ignoredPage * pageSize, (ignoredPage + 1) * pageSize);
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Render ---
|
||||
|
||||
return (
|
||||
<>
|
||||
{ignoredLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600" />
|
||||
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading ignored software...</span>
|
||||
</div>
|
||||
) : ignoredEntries.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
<p className="text-sm">No ignored software. Items you ignore from the Unmapped tab will appear here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stats bar */}
|
||||
<div className="flex flex-wrap items-center gap-4 py-4 text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-200">{ignoredEntries.length}</span> ignored software entries
|
||||
</span>
|
||||
<button
|
||||
onClick={fetchIgnoredSoftware}
|
||||
className="ml-auto text-sm text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 flex items-center gap-1.5"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.996 19.632h-4.991" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<colgroup>
|
||||
<col />
|
||||
<col style={{ width: "22%" }} />
|
||||
<col className="w-24" />
|
||||
<col style={{ width: "190px" }} />
|
||||
</colgroup>
|
||||
<thead className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Software Name</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Publisher</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Ignored At</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{paginatedIgnoredEntries.map((entry) => {
|
||||
const key = `${entry.softwareName}::${entry.publisher}`;
|
||||
const isRestoring = restoringRow === key;
|
||||
|
||||
return (
|
||||
<tr key={key} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||
<td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100 truncate">
|
||||
{entry.softwareName}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||
{entry.publisher || <span className="text-gray-300 dark:text-gray-600 italic">unknown</span>}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
{formatDate(entry.ignoredAt)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-sm">
|
||||
<div className="flex items-center gap-2 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handleRestoreSoftware(entry)}
|
||||
disabled={isRestoring}
|
||||
className="text-xs px-2.5 py-1 rounded-md font-medium bg-gray-100 text-gray-600 hover:bg-amber-100 hover:text-amber-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-amber-900/50 dark:hover:text-amber-400 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{isRestoring ? "..." : "Restore"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCheckNvdFromIgnored(entry)}
|
||||
disabled={isRestoring}
|
||||
className="text-xs px-2.5 py-1 rounded-md font-medium bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-900/20 dark:text-blue-400 dark:hover:bg-blue-900/40 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{isRestoring ? "..." : "Check NVD"}
|
||||
</button>
|
||||
<a
|
||||
href={`https://nvd.nist.gov/products/cpe/search/results?keyword=${encodeURIComponent(entry.softwareName)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-gray-500 hover:text-amber-600 dark:text-gray-400 dark:hover:text-amber-400 transition-colors"
|
||||
title="Search NVD manually"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Ignored Pagination */}
|
||||
{ignoredTotalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md mt-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{ignoredPage * pageSize + 1}–{Math.min((ignoredPage + 1) * pageSize, ignoredEntries.length)} of {ignoredEntries.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIgnoredPage((p) => p - 1)}
|
||||
disabled={ignoredPage === 0}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{ignoredPage + 1} / {ignoredTotalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIgnoredPage((p) => p + 1)}
|
||||
disabled={(ignoredPage + 1) * pageSize >= ignoredEntries.length}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import { CpeMappingEntry } from "./SoftwareMappingTypes";
|
||||
|
||||
interface MappedSoftwareTabProps {
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
setError: (error: string | null) => void;
|
||||
refreshTrigger: number;
|
||||
onCountChanged: (count: number) => void;
|
||||
}
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
export function MappedSoftwareTab({
|
||||
getAccessToken,
|
||||
setError,
|
||||
refreshTrigger,
|
||||
onCountChanged,
|
||||
}: MappedSoftwareTabProps) {
|
||||
const [mappedEntries, setMappedEntries] = useState<CpeMappingEntry[]>([]);
|
||||
const [mappedLoading, setMappedLoading] = useState(false);
|
||||
const [mappedLoaded, setMappedLoaded] = useState(false);
|
||||
const [mappedPage, setMappedPage] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Edit state
|
||||
const [editingRow, setEditingRow] = useState<string | null>(null);
|
||||
const [editForm, setEditForm] = useState<{
|
||||
cpeUri: string;
|
||||
cpeVendor: string;
|
||||
cpeProduct: string;
|
||||
category: string;
|
||||
displayNamePatterns: string;
|
||||
publisherPatterns: string;
|
||||
excludePatterns: string;
|
||||
}>({ cpeUri: "", cpeVendor: "", cpeProduct: "", category: "", displayNamePatterns: "", publisherPatterns: "", excludePatterns: "" });
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [deletingRow, setDeletingRow] = useState<string | null>(null);
|
||||
|
||||
// Track previous refreshTrigger to detect changes
|
||||
const prevRefreshTrigger = useRef(refreshTrigger);
|
||||
|
||||
// --- Fetching ---
|
||||
|
||||
const fetchCpeMappings = useCallback(async () => {
|
||||
try {
|
||||
setMappedLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
api.vulnerability.cpeMappings(),
|
||||
getAccessToken
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load CPE mappings: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const mappings = data.mappings || [];
|
||||
setMappedEntries(mappings);
|
||||
setMappedLoaded(true);
|
||||
setMappedPage(0);
|
||||
onCountChanged(mappings.length);
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
console.error("Session expired while fetching CPE mappings");
|
||||
} else {
|
||||
console.error("Error fetching CPE mappings:", err);
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to load CPE mappings");
|
||||
} finally {
|
||||
setMappedLoading(false);
|
||||
}
|
||||
}, [getAccessToken, setError, onCountChanged]);
|
||||
|
||||
// Lazy load on first render
|
||||
useEffect(() => {
|
||||
if (!mappedLoaded && !mappedLoading) {
|
||||
fetchCpeMappings();
|
||||
}
|
||||
}, [mappedLoaded, mappedLoading, fetchCpeMappings]);
|
||||
|
||||
// Reset loaded state when refreshTrigger changes (external signal to reload)
|
||||
useEffect(() => {
|
||||
if (refreshTrigger !== prevRefreshTrigger.current) {
|
||||
prevRefreshTrigger.current = refreshTrigger;
|
||||
setMappedLoaded(false);
|
||||
}
|
||||
}, [refreshTrigger]);
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
const getMappedRowKey = (entry: CpeMappingEntry): string =>
|
||||
`${entry.source}::${entry.normalizedVendor}::${entry.normalizedProduct}`;
|
||||
|
||||
const filteredMappedEntries = mappedEntries.filter((m) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
m.normalizedProduct.toLowerCase().includes(q) ||
|
||||
m.normalizedVendor.toLowerCase().includes(q) ||
|
||||
m.cpeUri.toLowerCase().includes(q) ||
|
||||
m.cpeVendor.toLowerCase().includes(q) ||
|
||||
m.cpeProduct.toLowerCase().includes(q) ||
|
||||
m.category.toLowerCase().includes(q) ||
|
||||
m.displayNamePatterns.some((p) => p.toLowerCase().includes(q)) ||
|
||||
m.publisherPatterns.some((p) => p.toLowerCase().includes(q)) ||
|
||||
(m.excludePatterns || []).some((p) => p.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
const mappedTotalPages = Math.ceil(filteredMappedEntries.length / pageSize);
|
||||
const paginatedMappedEntries = filteredMappedEntries.slice(
|
||||
mappedPage * pageSize,
|
||||
(mappedPage + 1) * pageSize
|
||||
);
|
||||
|
||||
// Reset page when search changes
|
||||
useEffect(() => {
|
||||
setMappedPage(0);
|
||||
}, [searchQuery]);
|
||||
|
||||
const getSourceBadge = (source: string) => {
|
||||
switch (source) {
|
||||
case "custom":
|
||||
return "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300";
|
||||
case "community":
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300";
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = (entry: CpeMappingEntry) => {
|
||||
const key = getMappedRowKey(entry);
|
||||
setEditingRow(key);
|
||||
setEditForm({
|
||||
cpeUri: entry.cpeUri,
|
||||
cpeVendor: entry.cpeVendor,
|
||||
cpeProduct: entry.cpeProduct,
|
||||
category: entry.category,
|
||||
displayNamePatterns: entry.displayNamePatterns.join(", "),
|
||||
publisherPatterns: entry.publisherPatterns.join(", "),
|
||||
excludePatterns: (entry.excludePatterns || []).join(", "),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (entry: CpeMappingEntry) => {
|
||||
const cpeUri = editForm.cpeUri.trim();
|
||||
if (!cpeUri) return;
|
||||
|
||||
try {
|
||||
setSavingEdit(true);
|
||||
setError(null);
|
||||
|
||||
const displayNamePatterns = editForm.displayNamePatterns
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const publisherPatterns = editForm.publisherPatterns
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const excludePatterns = editForm.excludePatterns
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
api.vulnerability.cpeMapping(),
|
||||
getAccessToken,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
normalizedProduct: entry.normalizedProduct,
|
||||
normalizedVendor: entry.normalizedVendor,
|
||||
cpeUri: cpeUri,
|
||||
cpeVendor: editForm.cpeVendor.trim(),
|
||||
cpeProduct: editForm.cpeProduct.trim(),
|
||||
category: editForm.category.trim() || "custom",
|
||||
displayNamePatterns: displayNamePatterns.length > 0 ? displayNamePatterns : entry.displayNamePatterns,
|
||||
publisherPatterns: publisherPatterns.length > 0 ? publisherPatterns : entry.publisherPatterns,
|
||||
excludePatterns: excludePatterns,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(data?.message || `Failed to save mapping: ${response.statusText}`);
|
||||
}
|
||||
|
||||
setEditingRow(null);
|
||||
// Reload to reflect changes
|
||||
await fetchCpeMappings();
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
console.error("Session expired while saving CPE mapping edit");
|
||||
} else {
|
||||
console.error("Error saving CPE mapping edit:", err);
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to save CPE mapping");
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMapping = async (entry: CpeMappingEntry) => {
|
||||
if (!confirm(`Delete mapping for "${entry.normalizedProduct}"? This cannot be undone.`)) return;
|
||||
|
||||
const key = getMappedRowKey(entry);
|
||||
try {
|
||||
setDeletingRow(key);
|
||||
setError(null);
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
api.vulnerability.cpeMapping(),
|
||||
getAccessToken,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
normalizedProduct: entry.normalizedProduct,
|
||||
normalizedVendor: entry.normalizedVendor,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(data?.message || `Failed to delete mapping: ${response.statusText}`);
|
||||
}
|
||||
|
||||
setMappedEntries((prev) => {
|
||||
const updated = prev.filter((m) => getMappedRowKey(m) !== key);
|
||||
onCountChanged(updated.length);
|
||||
return updated;
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
console.error("Session expired while deleting CPE mapping");
|
||||
} else {
|
||||
console.error("Error deleting CPE mapping:", err);
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to delete CPE mapping");
|
||||
} finally {
|
||||
setDeletingRow(null);
|
||||
}
|
||||
};
|
||||
|
||||
// --- JSON Export ---
|
||||
|
||||
const handleExportJson = () => {
|
||||
const dataToExport = filteredMappedEntries;
|
||||
const exportData = {
|
||||
version: "1.0.0",
|
||||
lastUpdated: new Date().toISOString().split("T")[0],
|
||||
mappings: dataToExport.map((m) => ({
|
||||
normalizedVendor: m.normalizedVendor,
|
||||
normalizedProduct: m.normalizedProduct,
|
||||
displayNamePatterns: m.displayNamePatterns,
|
||||
publisherPatterns: m.publisherPatterns,
|
||||
excludePatterns: m.excludePatterns || [],
|
||||
cpeVendor: m.cpeVendor,
|
||||
cpeProduct: m.cpeProduct,
|
||||
cpeUri: m.cpeUri,
|
||||
category: m.category,
|
||||
})),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `cpe-mapping-export-${exportData.lastUpdated}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
trackEvent("software_mapping_exported");
|
||||
};
|
||||
|
||||
// --- Render ---
|
||||
|
||||
if (mappedLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600" />
|
||||
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading CPE mappings...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Search bar + Export + Refresh */}
|
||||
<div className="flex flex-wrap items-center gap-3 py-4">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by product, vendor, CPE URI, category..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold text-amber-600 dark:text-amber-400">{filteredMappedEntries.length}</span>
|
||||
{searchQuery ? ` of ${mappedEntries.length}` : ""} mappings
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleExportJson}
|
||||
disabled={filteredMappedEntries.length === 0}
|
||||
className="text-sm text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
Export JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchCpeMappings}
|
||||
className="text-sm text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 flex items-center gap-1.5"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.996 19.632h-4.991" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMappedEntries.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p className="text-sm">
|
||||
{searchQuery
|
||||
? "No mappings match your search."
|
||||
: "No CPE mappings found. Seed mappings may need to be imported."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Mapped Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<colgroup>
|
||||
<col />
|
||||
<col style={{ width: "14%" }} />
|
||||
<col style={{ width: "30%" }} />
|
||||
<col className="w-24" />
|
||||
<col className="w-20" />
|
||||
<col className="w-24" />
|
||||
</colgroup>
|
||||
<thead className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Product</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Vendor</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">CPE URI</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Category</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Source</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{paginatedMappedEntries.map((entry) => {
|
||||
const key = getMappedRowKey(entry);
|
||||
const isEditing = editingRow === key;
|
||||
|
||||
return (
|
||||
<tr key={key} className="group">
|
||||
<td colSpan={6} className="p-0">
|
||||
<div className={`hover:bg-amber-50/50 dark:hover:bg-amber-900/5 transition-colors ${isEditing ? "bg-amber-50/50 dark:bg-amber-900/5" : ""}`}>
|
||||
<div className="flex">
|
||||
<div className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100 flex-1 min-w-0 truncate" title={entry.normalizedProduct}>
|
||||
{entry.normalizedProduct}
|
||||
</div>
|
||||
<div className="px-3 py-3 text-sm text-gray-600 dark:text-gray-400 truncate" style={{ width: "14%", flexShrink: 0 }} title={entry.normalizedVendor}>
|
||||
{entry.normalizedVendor || <span className="text-gray-300 dark:text-gray-600 italic">unknown</span>}
|
||||
</div>
|
||||
<div className="px-3 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono text-xs truncate" style={{ width: "30%", flexShrink: 0 }} title={entry.cpeUri}>
|
||||
{entry.cpeUri}
|
||||
</div>
|
||||
<div className="px-3 py-3 text-sm text-gray-600 dark:text-gray-400 flex-shrink-0 w-24 truncate">
|
||||
{entry.category || <span className="italic text-gray-300 dark:text-gray-600">-</span>}
|
||||
</div>
|
||||
<div className="px-3 py-3 text-sm flex-shrink-0 w-20">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getSourceBadge(entry.source)}`}>
|
||||
{entry.source}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-3 text-sm flex-shrink-0 w-24 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => isEditing ? setEditingRow(null) : startEditing(entry)}
|
||||
className={`text-xs px-2.5 py-1 rounded-md font-medium transition-colors ${
|
||||
isEditing
|
||||
? "bg-amber-200 text-amber-800 dark:bg-amber-800 dark:text-amber-200"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-amber-100 hover:text-amber-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-amber-900/50 dark:hover:text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{isEditing ? "Close" : "Edit"}
|
||||
</button>
|
||||
{entry.source === "custom" && (
|
||||
<button
|
||||
onClick={() => handleDeleteMapping(entry)}
|
||||
disabled={deletingRow === key}
|
||||
className="text-xs px-2.5 py-1 rounded-md font-medium transition-colors bg-red-50 text-red-600 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/40 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deletingRow === key ? "..." : "Delete"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline edit form */}
|
||||
{isEditing && (
|
||||
<div className="px-4 pb-3 pt-2 border-t border-amber-100 dark:border-amber-900/30 bg-amber-50/80 dark:bg-amber-900/10">
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">CPE URI</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.cpeUri}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, cpeUri: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none"
|
||||
disabled={savingEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">Category</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.category}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, category: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none"
|
||||
disabled={savingEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">CPE Vendor</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.cpeVendor}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, cpeVendor: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none"
|
||||
disabled={savingEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">CPE Product</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.cpeProduct}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, cpeProduct: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none"
|
||||
disabled={savingEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">Display Name Patterns (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.displayNamePatterns}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, displayNamePatterns: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none"
|
||||
disabled={savingEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">Publisher Patterns (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.publisherPatterns}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, publisherPatterns: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none"
|
||||
disabled={savingEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">Exclude Patterns (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.excludePatterns}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, excludePatterns: e.target.value }))}
|
||||
placeholder="e.g. edge update, webview helper"
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none"
|
||||
disabled={savingEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleSaveEdit(entry)}
|
||||
disabled={savingEdit || !editForm.cpeUri.trim()}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
{savingEdit ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingRow(null)}
|
||||
disabled={savingEdit}
|
||||
className="px-2 py-1.5 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{entry.source === "seed" && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 italic ml-auto">
|
||||
Editing a seed entry will create a custom override
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mapped Pagination */}
|
||||
{mappedTotalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md mt-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{mappedPage * pageSize + 1}–{Math.min((mappedPage + 1) * pageSize, filteredMappedEntries.length)} of {filteredMappedEntries.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setMappedPage((p) => p - 1)}
|
||||
disabled={mappedPage === 0}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{mappedPage + 1} / {mappedTotalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setMappedPage((p) => p + 1)}
|
||||
disabled={(mappedPage + 1) * pageSize >= filteredMappedEntries.length}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
export interface UnmatchedSoftwareEntry {
|
||||
softwareName: string;
|
||||
publisher: string;
|
||||
frequency: number;
|
||||
lastSeenAt: string;
|
||||
exampleSessionId: string;
|
||||
normalizedVendor?: string;
|
||||
normalizedVersion?: string;
|
||||
}
|
||||
|
||||
export interface AutoResolveResult {
|
||||
resolved: Array<{ softwareName: string; cpeUri: string; confidence: number }>;
|
||||
failed: Array<{ softwareName: string; reason: string }>;
|
||||
totalProcessed: number;
|
||||
totalResolved: number;
|
||||
totalFailed: number;
|
||||
}
|
||||
|
||||
export interface CpeMappingEntry {
|
||||
normalizedVendor: string;
|
||||
normalizedProduct: string;
|
||||
cpeVendor: string;
|
||||
cpeProduct: string;
|
||||
cpeUri: string;
|
||||
category: string;
|
||||
displayNamePatterns: string[];
|
||||
publisherPatterns: string[];
|
||||
excludePatterns: string[];
|
||||
source: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface IgnoredSoftwareEntry {
|
||||
softwareName: string;
|
||||
publisher: string;
|
||||
reason: string;
|
||||
ignoredAt: string;
|
||||
}
|
||||
|
||||
export interface TabProps {
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import type { UnmatchedSoftwareEntry, AutoResolveResult } from "./SoftwareMappingTypes";
|
||||
|
||||
interface UnmappedSoftwareTabProps {
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
setError: (error: string | null) => void;
|
||||
onMappingChanged: () => void;
|
||||
onIgnored: () => void;
|
||||
onCountChanged: (count: number) => void;
|
||||
refreshTrigger: number;
|
||||
}
|
||||
|
||||
export function UnmappedSoftwareTab({
|
||||
getAccessToken,
|
||||
setError,
|
||||
onMappingChanged,
|
||||
onIgnored,
|
||||
onCountChanged,
|
||||
refreshTrigger,
|
||||
}: UnmappedSoftwareTabProps) {
|
||||
// Unmapped state
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [entries, setEntries] = useState<UnmatchedSoftwareEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const pageSize = 20;
|
||||
|
||||
// CPE mapping state
|
||||
const [expandedMappingRow, setExpandedMappingRow] = useState<string | null>(null);
|
||||
const [cpeInputs, setCpeInputs] = useState<Record<string, string>>({});
|
||||
const [savingMapping, setSavingMapping] = useState<string | null>(null);
|
||||
const [savedMappings, setSavedMappings] = useState<Set<string>>(new Set());
|
||||
|
||||
// Bulk auto-resolve state
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
const [autoResolving, setAutoResolving] = useState(false);
|
||||
const [autoResolveProgress, setAutoResolveProgress] = useState<{
|
||||
batchIndex: number;
|
||||
totalBatches: number;
|
||||
resolvedSoFar: number;
|
||||
failedSoFar: number;
|
||||
} | null>(null);
|
||||
const [autoResolveResults, setAutoResolveResults] = useState<AutoResolveResult | null>(null);
|
||||
|
||||
// Ignore state
|
||||
const [ignoringRow, setIgnoringRow] = useState<string | null>(null);
|
||||
|
||||
// Propagate total to parent
|
||||
useEffect(() => {
|
||||
onCountChanged(total);
|
||||
}, [total, onCountChanged]);
|
||||
|
||||
// --- Fetching ---
|
||||
|
||||
const fetchUnmatchedSoftware = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
api.vulnerability.unmatchedSoftware(),
|
||||
getAccessToken
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load unmatched software: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const sorted = (data.software || []).sort(
|
||||
(a: UnmatchedSoftwareEntry, b: UnmatchedSoftwareEntry) => b.frequency - a.frequency
|
||||
);
|
||||
setEntries(sorted);
|
||||
setTotal(data.total ?? sorted.length);
|
||||
setCurrentPage(0);
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
console.error("Session expired while fetching unmatched software");
|
||||
} else {
|
||||
console.error("Error fetching unmatched software:", err);
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to load unmatched software");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getAccessToken, setError]);
|
||||
|
||||
// Load on mount
|
||||
useEffect(() => {
|
||||
if (entries.length === 0 && !loading) {
|
||||
fetchUnmatchedSoftware();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Refresh when parent signals (e.g. item restored from ignored tab)
|
||||
useEffect(() => {
|
||||
if (refreshTrigger > 0) {
|
||||
fetchUnmatchedSoftware();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshTrigger]);
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
const totalPages = Math.ceil(entries.length / pageSize);
|
||||
const paginatedEntries = entries.slice(
|
||||
currentPage * pageSize,
|
||||
(currentPage + 1) * pageSize
|
||||
);
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const getFrequencyBadgeClasses = (frequency: number): string => {
|
||||
if (frequency >= 100) return "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300";
|
||||
if (frequency >= 50) return "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300";
|
||||
if (frequency >= 20) return "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300";
|
||||
if (frequency >= 5) return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300";
|
||||
return "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300";
|
||||
};
|
||||
|
||||
const getRowKey = (entry: UnmatchedSoftwareEntry): string =>
|
||||
`${entry.softwareName}::${entry.publisher}`;
|
||||
|
||||
// --- Mapping ---
|
||||
|
||||
const toggleMappingRow = (entry: UnmatchedSoftwareEntry) => {
|
||||
const key = getRowKey(entry);
|
||||
if (expandedMappingRow === key) {
|
||||
setExpandedMappingRow(null);
|
||||
} else {
|
||||
setExpandedMappingRow(key);
|
||||
if (!cpeInputs[key]) {
|
||||
setCpeInputs((prev) => ({ ...prev, [key]: "" }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveMapping = async (entry: UnmatchedSoftwareEntry) => {
|
||||
const key = getRowKey(entry);
|
||||
const cpeUri = (cpeInputs[key] || "").trim();
|
||||
if (!cpeUri) return;
|
||||
|
||||
try {
|
||||
setSavingMapping(key);
|
||||
setError(null);
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
api.vulnerability.cpeMapping(),
|
||||
getAccessToken,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
normalizedProduct: entry.softwareName,
|
||||
normalizedVendor: entry.publisher || "",
|
||||
cpeUri: cpeUri,
|
||||
displayNamePatterns: [entry.softwareName],
|
||||
publisherPatterns: entry.publisher ? [entry.publisher] : [],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(data?.message || `Failed to save mapping: ${response.statusText}`);
|
||||
}
|
||||
|
||||
setSavedMappings((prev) => new Set(prev).add(key));
|
||||
setExpandedMappingRow(null);
|
||||
onMappingChanged();
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
console.error("Session expired while saving CPE mapping");
|
||||
} else {
|
||||
console.error("Error saving CPE mapping:", err);
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to save CPE mapping");
|
||||
} finally {
|
||||
setSavingMapping(null);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Selection ---
|
||||
|
||||
const toggleRowSelection = (key: string) => {
|
||||
setSelectedRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAllOnPage = () => {
|
||||
const pageKeys = paginatedEntries
|
||||
.filter((e) => !savedMappings.has(getRowKey(e)))
|
||||
.map((e) => getRowKey(e));
|
||||
setSelectedRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
pageKeys.forEach((k) => next.add(k));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const deselectAll = () => setSelectedRows(new Set());
|
||||
|
||||
const allOnPageSelected = paginatedEntries.length > 0 &&
|
||||
paginatedEntries
|
||||
.filter((e) => !savedMappings.has(getRowKey(e)))
|
||||
.every((e) => selectedRows.has(getRowKey(e)));
|
||||
|
||||
// --- Auto-resolve ---
|
||||
|
||||
const handleAutoResolveSelected = async () => {
|
||||
const selectedEntries = entries.filter((e) => selectedRows.has(getRowKey(e)));
|
||||
if (selectedEntries.length === 0) return;
|
||||
|
||||
setAutoResolving(true);
|
||||
setAutoResolveResults(null);
|
||||
setAutoResolveProgress(null);
|
||||
|
||||
const BATCH_SIZE = 25;
|
||||
const batches: UnmatchedSoftwareEntry[][] = [];
|
||||
for (let i = 0; i < selectedEntries.length; i += BATCH_SIZE) {
|
||||
batches.push(selectedEntries.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
|
||||
const cumulativeResolved: AutoResolveResult["resolved"] = [];
|
||||
const cumulativeFailed: AutoResolveResult["failed"] = [];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
setAutoResolveProgress({
|
||||
batchIndex: i + 1,
|
||||
totalBatches: batches.length,
|
||||
resolvedSoFar: cumulativeResolved.length,
|
||||
failedSoFar: cumulativeFailed.length,
|
||||
});
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
api.vulnerability.cpeAutoResolve(),
|
||||
getAccessToken,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
items: batches[i].map((e) => ({
|
||||
softwareName: e.softwareName,
|
||||
publisher: e.publisher,
|
||||
normalizedVendor: e.normalizedVendor || e.publisher || "",
|
||||
})),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Batch ${i + 1} failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: AutoResolveResult = await response.json();
|
||||
cumulativeResolved.push(...data.resolved);
|
||||
cumulativeFailed.push(...data.failed);
|
||||
|
||||
// Mark resolved items
|
||||
data.resolved.forEach((r) => {
|
||||
const entry = selectedEntries.find((e) => e.softwareName === r.softwareName);
|
||||
if (entry) setSavedMappings((prev) => new Set(prev).add(getRowKey(entry)));
|
||||
});
|
||||
}
|
||||
|
||||
setAutoResolveResults({
|
||||
resolved: cumulativeResolved,
|
||||
failed: cumulativeFailed,
|
||||
totalProcessed: cumulativeResolved.length + cumulativeFailed.length,
|
||||
totalResolved: cumulativeResolved.length,
|
||||
totalFailed: cumulativeFailed.length,
|
||||
});
|
||||
setSelectedRows(new Set());
|
||||
onMappingChanged();
|
||||
|
||||
trackEvent("bulk_auto_resolve_completed", {
|
||||
totalSelected: selectedEntries.length.toString(),
|
||||
totalResolved: cumulativeResolved.length.toString(),
|
||||
totalFailed: cumulativeFailed.length.toString(),
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Auto-resolve failed");
|
||||
} finally {
|
||||
setAutoResolving(false);
|
||||
setAutoResolveProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Ignore ---
|
||||
|
||||
const handleIgnoreSoftware = async (entry: UnmatchedSoftwareEntry) => {
|
||||
const key = getRowKey(entry);
|
||||
try {
|
||||
setIgnoringRow(key);
|
||||
setError(null);
|
||||
const response = await authenticatedFetch(api.vulnerability.ignoredSoftware(), getAccessToken, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
items: [{ softwareName: entry.softwareName, publisher: entry.publisher || "", reason: "manual" }],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Failed to ignore software: ${response.statusText}`);
|
||||
// Remove from unmapped list
|
||||
setEntries((prev) => prev.filter((e) => getRowKey(e) !== key));
|
||||
setTotal((prev) => Math.max(0, prev - 1));
|
||||
setSelectedRows((prev) => { const n = new Set(prev); n.delete(key); return n; });
|
||||
onIgnored();
|
||||
} catch (err) {
|
||||
console.error("Error ignoring software:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to ignore software");
|
||||
} finally {
|
||||
setIgnoringRow(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkIgnoreSelected = async () => {
|
||||
const selectedEntries = entries.filter((e) => selectedRows.has(getRowKey(e)));
|
||||
if (selectedEntries.length === 0) return;
|
||||
|
||||
try {
|
||||
setIgnoringRow("bulk");
|
||||
setError(null);
|
||||
const response = await authenticatedFetch(api.vulnerability.ignoredSoftware(), getAccessToken, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
items: selectedEntries.map((e) => ({ softwareName: e.softwareName, publisher: e.publisher || "", reason: "manual" })),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Failed to ignore software: ${response.statusText}`);
|
||||
const keys = new Set(selectedEntries.map(getRowKey));
|
||||
setEntries((prev) => prev.filter((e) => !keys.has(getRowKey(e))));
|
||||
setTotal((prev) => Math.max(0, prev - selectedEntries.length));
|
||||
setSelectedRows(new Set());
|
||||
onIgnored();
|
||||
} catch (err) {
|
||||
console.error("Error bulk ignoring software:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to ignore software");
|
||||
} finally {
|
||||
setIgnoringRow(null);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Render ---
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600" />
|
||||
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading unmatched software...</span>
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
<p className="text-sm">No unmatched software found. All detected software has been mapped to CPE identifiers.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stats bar */}
|
||||
<div className="flex flex-wrap items-center gap-4 py-4 text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold text-amber-600 dark:text-amber-400">{total}</span> unmatched software entries
|
||||
</span>
|
||||
<button
|
||||
onClick={fetchUnmatchedSoftware}
|
||||
disabled={autoResolving}
|
||||
className="ml-auto text-sm text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 flex items-center gap-1.5 disabled:opacity-40"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.996 19.632h-4.991" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selection toolbar */}
|
||||
{(selectedRows.size > 0 || autoResolving) && (
|
||||
<div className="flex flex-wrap items-center gap-3 pb-3 text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-300 font-medium">
|
||||
{selectedRows.size} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={allOnPageSelected ? deselectAll : selectAllOnPage}
|
||||
disabled={autoResolving}
|
||||
className="text-xs px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{allOnPageSelected ? "Deselect All" : "Select All"}
|
||||
</button>
|
||||
{selectedRows.size > 0 && (
|
||||
<button
|
||||
onClick={deselectAll}
|
||||
disabled={autoResolving}
|
||||
className="text-xs px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleBulkIgnoreSelected}
|
||||
disabled={autoResolving || ignoringRow === "bulk" || selectedRows.size === 0}
|
||||
className="text-xs px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-red-50 hover:text-red-600 hover:border-red-300 dark:hover:bg-red-900/20 dark:hover:text-red-400 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{ignoringRow === "bulk" ? "Ignoring..." : "Ignore Selected"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAutoResolveSelected}
|
||||
disabled={autoResolving || selectedRows.size === 0}
|
||||
className="ml-auto text-xs px-3 py-1.5 rounded-md font-medium bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
{autoResolving ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white" />
|
||||
Auto-Mapping...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
</svg>
|
||||
Auto-Map Selected
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto-resolve progress */}
|
||||
{autoResolving && autoResolveProgress && (
|
||||
<div className="mb-3 px-4 py-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
|
||||
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-300">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
|
||||
<span>
|
||||
Auto-resolving... Batch {autoResolveProgress.batchIndex}/{autoResolveProgress.totalBatches}
|
||||
{autoResolveProgress.resolvedSoFar > 0 && <> · Resolved: {autoResolveProgress.resolvedSoFar}</>}
|
||||
{autoResolveProgress.failedSoFar > 0 && <> · Failed: {autoResolveProgress.failedSoFar}</>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto-resolve results */}
|
||||
{autoResolveResults && !autoResolving && (
|
||||
<div className={`mb-3 px-4 py-3 rounded-md text-sm border ${
|
||||
autoResolveResults.totalFailed === 0
|
||||
? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800"
|
||||
: "bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800"
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={autoResolveResults.totalFailed === 0 ? "text-green-700 dark:text-green-300" : "text-amber-700 dark:text-amber-300"}>
|
||||
Auto-resolve complete: <strong>{autoResolveResults.totalResolved}</strong> resolved,{" "}
|
||||
<strong>{autoResolveResults.totalFailed}</strong> failed
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setAutoResolveResults(null)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{autoResolveResults.failed.length > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium">Failed items:</span>{" "}
|
||||
{autoResolveResults.failed.map((f) => `${f.softwareName} (${f.reason})`).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<colgroup>
|
||||
<col className="w-10" />
|
||||
<col />
|
||||
<col style={{ width: "18%" }} />
|
||||
<col className="w-24" />
|
||||
<col className="w-24" />
|
||||
<col className="w-20" />
|
||||
<col className="w-28" />
|
||||
</colgroup>
|
||||
<thead className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th className="px-2 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allOnPageSelected && paginatedEntries.length > 0}
|
||||
onChange={allOnPageSelected ? deselectAll : selectAllOnPage}
|
||||
disabled={autoResolving}
|
||||
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Software Name</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Publisher</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Frequency</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Seen</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Session</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{paginatedEntries.map((entry) => {
|
||||
const key = getRowKey(entry);
|
||||
const isMapped = savedMappings.has(key);
|
||||
const isMappingExpanded = expandedMappingRow === key;
|
||||
const isSaving = savingMapping === key;
|
||||
|
||||
return (
|
||||
<tr key={key} className={`group hover:bg-amber-50 dark:hover:bg-amber-900/10 transition-colors ${isMappingExpanded ? "bg-amber-50/50 dark:bg-amber-900/5" : ""}`}>
|
||||
<td colSpan={7} className="p-0">
|
||||
<div className="flex">
|
||||
<div className="w-10 px-2 py-3 flex items-center justify-center flex-shrink-0">
|
||||
{!isMapped && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRows.has(key)}
|
||||
onChange={() => toggleRowSelection(key)}
|
||||
disabled={autoResolving}
|
||||
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500 disabled:opacity-40"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100 flex-1 min-w-0 truncate">
|
||||
{entry.softwareName}
|
||||
</div>
|
||||
<div className="px-3 py-3 text-sm text-gray-600 dark:text-gray-400 truncate" style={{ width: "18%", flexShrink: 0 }}>
|
||||
{entry.publisher || <span className="text-gray-300 dark:text-gray-600 italic">unknown</span>}
|
||||
</div>
|
||||
<div className="px-3 py-3 text-sm flex-shrink-0 w-24">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${getFrequencyBadgeClasses(entry.frequency)}`}>
|
||||
{entry.frequency} sess.
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-3 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap flex-shrink-0 w-24">
|
||||
{formatDate(entry.lastSeenAt)}
|
||||
</div>
|
||||
<div className="px-3 py-3 text-sm flex-shrink-0 w-20">
|
||||
<a
|
||||
href={`/sessions/${entry.exampleSessionId}`}
|
||||
className="text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 font-mono text-xs hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{entry.exampleSessionId.length > 8
|
||||
? `${entry.exampleSessionId.slice(0, 8)}...`
|
||||
: entry.exampleSessionId}
|
||||
</a>
|
||||
</div>
|
||||
<div className="px-3 py-3 text-sm flex-shrink-0 w-28 flex items-center gap-1.5">
|
||||
{isMapped ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400 font-medium">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
Mapped
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => toggleMappingRow(entry)}
|
||||
className={`text-xs px-2.5 py-1 rounded-md font-medium transition-colors ${
|
||||
isMappingExpanded
|
||||
? "bg-amber-200 text-amber-800 dark:bg-amber-800 dark:text-amber-200"
|
||||
: "bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/50 dark:text-amber-400 dark:hover:bg-amber-800/50"
|
||||
}`}
|
||||
>
|
||||
Map
|
||||
</button>
|
||||
<a
|
||||
href={`https://nvd.nist.gov/products/cpe/search/results?keyword=${encodeURIComponent(entry.softwareName)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-gray-500 hover:text-amber-600 dark:text-gray-400 dark:hover:text-amber-400 transition-colors"
|
||||
title="Search NVD for CPE"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleIgnoreSoftware(entry)}
|
||||
disabled={ignoringRow === key}
|
||||
className="text-xs text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors"
|
||||
title="Ignore — never auto-resolve"
|
||||
>
|
||||
{ignoringRow === key ? (
|
||||
<div className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-gray-400" />
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline mapping form */}
|
||||
{isMappingExpanded && (
|
||||
<div className="px-4 pb-3 pt-1 border-t border-amber-100 dark:border-amber-900/30 bg-amber-50/80 dark:bg-amber-900/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap flex-shrink-0">
|
||||
CPE URI:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*"
|
||||
value={cpeInputs[key] || ""}
|
||||
onChange={(e) =>
|
||||
setCpeInputs((prev) => ({ ...prev, [key]: e.target.value }))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (cpeInputs[key] || "").trim()) {
|
||||
handleSaveMapping(entry);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setExpandedMappingRow(null);
|
||||
}
|
||||
}}
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none"
|
||||
autoFocus
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveMapping(entry)}
|
||||
disabled={isSaving || !(cpeInputs[key] || "").trim()}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save Mapping"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpandedMappingRow(null)}
|
||||
disabled={isSaving}
|
||||
className="px-2 py-1.5 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors flex-shrink-0"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<a
|
||||
href={`https://nvd.nist.gov/products/cpe/search/results?keyword=${encodeURIComponent(entry.softwareName)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 whitespace-nowrap flex items-center gap-1 flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Search NVD
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md mt-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{currentPage * pageSize + 1}–{Math.min((currentPage + 1) * pageSize, entries.length)} of {entries.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => p - 1)}
|
||||
disabled={currentPage === 0}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{currentPage + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => p + 1)}
|
||||
disabled={(currentPage + 1) * pageSize >= entries.length}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user